|
@@ -1572,17 +1572,16 @@ def register_routes(app: Flask) -> None:
|
|
|
if row["status"] != "PENDING":
|
|
if row["status"] != "PENDING":
|
|
|
return jsonify({"error": "order_not_pending"}), 409
|
|
return jsonify({"error": "order_not_pending"}), 409
|
|
|
|
|
|
|
|
- pay_provider = (get_setting_value("PAY_PROVIDER") or "MOCK").strip().upper()
|
|
|
|
|
|
|
+ # 判断是否启用模拟支付
|
|
|
enable_mock_pay_raw = get_setting_value("ENABLE_MOCK_PAY")
|
|
enable_mock_pay_raw = get_setting_value("ENABLE_MOCK_PAY")
|
|
|
if enable_mock_pay_raw is None:
|
|
if enable_mock_pay_raw is None:
|
|
|
enable_mock_pay = bool(config.enable_mock_pay)
|
|
enable_mock_pay = bool(config.enable_mock_pay)
|
|
|
else:
|
|
else:
|
|
|
enable_mock_pay = enable_mock_pay_raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
enable_mock_pay = enable_mock_pay_raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
- if not enable_mock_pay:
|
|
|
|
|
- if pay_provider != "ALIPAY":
|
|
|
|
|
- return jsonify({"error": "pay_not_implemented"}), 501
|
|
|
|
|
|
|
|
|
|
snapshot = json.loads(row["plan_snapshot_json"])
|
|
snapshot = json.loads(row["plan_snapshot_json"])
|
|
|
|
|
+
|
|
|
|
|
+ # 模拟支付:直接标记为已支付并发放会员权益
|
|
|
if enable_mock_pay:
|
|
if enable_mock_pay:
|
|
|
execute(
|
|
execute(
|
|
|
"UPDATE orders SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ? WHERE id = ?",
|
|
"UPDATE orders SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ? WHERE id = ?",
|
|
@@ -1591,66 +1590,132 @@ def register_routes(app: Flask) -> None:
|
|
|
extend_vip(user["id"], int(snapshot["durationDays"]))
|
|
extend_vip(user["id"], int(snapshot["durationDays"]))
|
|
|
return jsonify({"ok": True, "provider": "MOCK", "status": "PAID"})
|
|
return jsonify({"ok": True, "provider": "MOCK", "status": "PAID"})
|
|
|
|
|
|
|
|
- alipay_app_id = (get_setting_value("ALIPAY_APP_ID") or "").strip()
|
|
|
|
|
- alipay_gateway = (get_setting_value("ALIPAY_GATEWAY") or "https://openapi.alipay.com/gateway.do").strip()
|
|
|
|
|
- alipay_notify_url = (get_setting_value("ALIPAY_NOTIFY_URL") or "").strip()
|
|
|
|
|
- alipay_return_url = (get_setting_value("ALIPAY_RETURN_URL") or "").strip()
|
|
|
|
|
- alipay_private_key = (get_setting_value("ALIPAY_PRIVATE_KEY") or "").strip()
|
|
|
|
|
-
|
|
|
|
|
- if not alipay_app_id:
|
|
|
|
|
- return jsonify({"error": "alipay_app_id_missing"}), 400
|
|
|
|
|
- if not alipay_private_key:
|
|
|
|
|
- return jsonify({"error": "alipay_private_key_missing"}), 400
|
|
|
|
|
- if not alipay_notify_url:
|
|
|
|
|
- return jsonify({"error": "alipay_notify_url_missing"}), 400
|
|
|
|
|
- if not (alipay_gateway.startswith("http://") or alipay_gateway.startswith("https://")):
|
|
|
|
|
- return jsonify({"error": "invalid_alipay_gateway"}), 400
|
|
|
|
|
- if not (alipay_notify_url.startswith("http://") or alipay_notify_url.startswith("https://")):
|
|
|
|
|
- return jsonify({"error": "invalid_alipay_notify_url"}), 400
|
|
|
|
|
- if alipay_return_url and not (alipay_return_url.startswith("http://") or alipay_return_url.startswith("https://")):
|
|
|
|
|
- return jsonify({"error": "invalid_alipay_return_url"}), 400
|
|
|
|
|
|
|
+ # 真实支付:调用中间层 REST API 创建支付订单
|
|
|
|
|
+ host_base = request.host_url.rstrip("/")
|
|
|
|
|
+
|
|
|
|
|
+ # return_url:优先读 .env,否则回落到本站 /pay/return
|
|
|
|
|
+ return_url = config.pay_return_url or f"{host_base}/pay/return"
|
|
|
|
|
+ # callback_url:优先读 .env,否则回落到本站 /pay/notify
|
|
|
|
|
+ callback_url = config.pay_callback_url or f"{host_base}/pay/notify"
|
|
|
|
|
|
|
|
amount_cents = int(row["amount_cents"] or 0)
|
|
amount_cents = int(row["amount_cents"] or 0)
|
|
|
- total_amount = (Decimal(amount_cents) / Decimal(100)).quantize(Decimal("0.01"))
|
|
|
|
|
- host_base = request.host_url.rstrip("/")
|
|
|
|
|
- return_url = alipay_return_url or f"{host_base}/ui/me"
|
|
|
|
|
|
|
+ total_amount = float((Decimal(amount_cents) / Decimal(100)).quantize(Decimal("0.01")))
|
|
|
subject = f"VIP {snapshot.get('name') or ''}".strip()[:120] or "VIP"
|
|
subject = f"VIP {snapshot.get('name') or ''}".strip()[:120] or "VIP"
|
|
|
- now_bj = utcnow().astimezone(timezone(timedelta(hours=8)))
|
|
|
|
|
- ts = now_bj.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
-
|
|
|
|
|
- params: dict[str, Any] = {
|
|
|
|
|
- "app_id": alipay_app_id,
|
|
|
|
|
- "method": "alipay.trade.page.pay",
|
|
|
|
|
- "format": "JSON",
|
|
|
|
|
- "charset": "utf-8",
|
|
|
|
|
- "sign_type": "RSA2",
|
|
|
|
|
- "timestamp": ts,
|
|
|
|
|
- "version": "1.0",
|
|
|
|
|
- "notify_url": alipay_notify_url,
|
|
|
|
|
|
|
+
|
|
|
|
|
+ pay_api_base = config.pay_api_base_url
|
|
|
|
|
+ pay_payload = {
|
|
|
|
|
+ "bill_no": order_id,
|
|
|
|
|
+ "amount": total_amount,
|
|
|
|
|
+ "subject": subject,
|
|
|
"return_url": return_url,
|
|
"return_url": return_url,
|
|
|
- "biz_content": json.dumps(
|
|
|
|
|
- {
|
|
|
|
|
- "out_trade_no": order_id,
|
|
|
|
|
- "product_code": "FAST_INSTANT_TRADE_PAY",
|
|
|
|
|
- "total_amount": str(total_amount),
|
|
|
|
|
- "subject": subject,
|
|
|
|
|
- },
|
|
|
|
|
- ensure_ascii=False,
|
|
|
|
|
- separators=(",", ":"),
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ "callback_url": callback_url,
|
|
|
}
|
|
}
|
|
|
- sign_content = _alipay_sign_content(params)
|
|
|
|
|
|
|
+ import sys
|
|
|
|
|
+ print(f"[PAY] 调用中间层: POST {pay_api_base}/api/alipay/pay", file=sys.stderr)
|
|
|
|
|
+ print(f"[PAY] 请求体: {pay_payload}", file=sys.stderr)
|
|
|
try:
|
|
try:
|
|
|
- params["sign"] = _alipay_rsa2_sign(sign_content, alipay_private_key)
|
|
|
|
|
- except RuntimeError as e:
|
|
|
|
|
- return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
|
+ resp = requests.post(
|
|
|
|
|
+ f"{pay_api_base}/api/alipay/pay",
|
|
|
|
|
+ json=pay_payload,
|
|
|
|
|
+ timeout=15,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"[PAY] 连接失败: {e}", file=sys.stderr)
|
|
|
|
|
+ return jsonify({"error": "pay_api_unreachable", "detail": str(e)}), 502
|
|
|
|
|
+
|
|
|
|
|
+ print(f"[PAY] 响应状态: {resp.status_code}", file=sys.stderr)
|
|
|
|
|
+ print(f"[PAY] 响应体: {resp.text[:500]}", file=sys.stderr)
|
|
|
|
|
+
|
|
|
|
|
+ if resp.status_code != 200:
|
|
|
|
|
+ try:
|
|
|
|
|
+ detail = resp.json()
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ detail = resp.text[:200]
|
|
|
|
|
+ return jsonify({"error": "pay_api_error", "detail": detail}), 502
|
|
|
|
|
+
|
|
|
|
|
+ data = resp.json()
|
|
|
|
|
+ pay_url = data.get("payment_url") or ""
|
|
|
|
|
+ if not pay_url:
|
|
|
|
|
+ return jsonify({"error": "pay_api_no_payment_url", "detail": data}), 502
|
|
|
|
|
|
|
|
- pay_url = f"{alipay_gateway}?{urlencode(params, quote_via=quote_plus)}"
|
|
|
|
|
execute("UPDATE orders SET pay_channel = ? WHERE id = ? AND status = 'PENDING'", ("ALIPAY", order_id))
|
|
execute("UPDATE orders SET pay_channel = ? WHERE id = ? AND status = 'PENDING'", ("ALIPAY", order_id))
|
|
|
return jsonify({"ok": True, "provider": "ALIPAY", "status": "PENDING", "payUrl": pay_url})
|
|
return jsonify({"ok": True, "provider": "ALIPAY", "status": "PENDING", "payUrl": pay_url})
|
|
|
|
|
|
|
|
|
|
+ @app.post("/orders/<order_id>/query-and-activate")
|
|
|
|
|
+ def api_order_query_and_activate(order_id: str) -> Response:
|
|
|
|
|
+ """前端轮询调用:向中间层查询订单状态,若已支付则激活订单并发放会员权益。
|
|
|
|
|
+ 与 callback_url 回调竞争,后端已做幂等保护,重复调用安全。
|
|
|
|
|
+ """
|
|
|
|
|
+ user = require_user()
|
|
|
|
|
+ row = fetch_one("SELECT * FROM orders WHERE id = ? AND user_id = ?", (order_id, user["id"]))
|
|
|
|
|
+ if row is None:
|
|
|
|
|
+ abort(404)
|
|
|
|
|
+
|
|
|
|
|
+ # 已支付,直接返回,无需再查
|
|
|
|
|
+ if row["status"] == "PAID":
|
|
|
|
|
+ return jsonify({"status": "PAID"})
|
|
|
|
|
+
|
|
|
|
|
+ # 非 PENDING 状态(CLOSED/FAILED)也直接返回
|
|
|
|
|
+ if row["status"] != "PENDING":
|
|
|
|
|
+ return jsonify({"status": row["status"]})
|
|
|
|
|
+
|
|
|
|
|
+ config = get_config()
|
|
|
|
|
+ pay_api_base = config.pay_api_base_url
|
|
|
|
|
+
|
|
|
|
|
+ # 调用中间层查询接口
|
|
|
|
|
+ try:
|
|
|
|
|
+ resp = requests.get(
|
|
|
|
|
+ f"{pay_api_base}/api/alipay/query",
|
|
|
|
|
+ params={"bill_no": order_id},
|
|
|
|
|
+ timeout=10,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return jsonify({"status": "PENDING", "error": str(e)}), 200
|
|
|
|
|
+
|
|
|
|
|
+ if resp.status_code != 200:
|
|
|
|
|
+ return jsonify({"status": "PENDING"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ data = resp.json()
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return jsonify({"status": "PENDING"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ trade_status = (data.get("trade_status") or "").strip().upper()
|
|
|
|
|
+
|
|
|
|
|
+ if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
|
|
|
|
|
+ # 返回中间层的状态供前端判断是否继续轮询
|
|
|
|
|
+ return jsonify({"status": "PENDING", "tradeStatus": trade_status}), 200
|
|
|
|
|
+
|
|
|
|
|
+ # 查询到支付成功,激活订单(幂等:只有 PENDING 状态才会更新)
|
|
|
|
|
+ trade_no = (data.get("trade_no") or "").strip()
|
|
|
|
|
+ amount_raw = str(data.get("amount") or "").strip()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ amount = Decimal(amount_raw).quantize(Decimal("0.01"))
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return jsonify({"status": "PENDING", "error": "invalid_amount"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ expect_amount = (Decimal(int(row["amount_cents"] or 0)) / Decimal(100)).quantize(Decimal("0.01"))
|
|
|
|
|
+ if amount != expect_amount:
|
|
|
|
|
+ return jsonify({"status": "PENDING", "error": "amount_mismatch"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ snapshot = json.loads(row["plan_snapshot_json"])
|
|
|
|
|
+ cur = execute(
|
|
|
|
|
+ """
|
|
|
|
|
+ UPDATE orders
|
|
|
|
|
+ SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ?
|
|
|
|
|
+ WHERE id = ? AND status = 'PENDING'
|
|
|
|
|
+ """,
|
|
|
|
|
+ (isoformat(utcnow()), "ALIPAY", trade_no or None, order_id),
|
|
|
|
|
+ )
|
|
|
|
|
+ if getattr(cur, "rowcount", 0) == 1:
|
|
|
|
|
+ extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
|
|
|
|
|
+
|
|
|
|
|
+ return jsonify({"status": "PAID"})
|
|
|
|
|
+
|
|
|
@app.post("/pay/callback")
|
|
@app.post("/pay/callback")
|
|
|
def api_pay_callback() -> Response:
|
|
def api_pay_callback() -> Response:
|
|
|
|
|
+ """兼容旧版支付宝直连回调(form 表单格式),保留以防万一。"""
|
|
|
params: dict[str, Any] = {}
|
|
params: dict[str, Any] = {}
|
|
|
try:
|
|
try:
|
|
|
for k in request.form.keys():
|
|
for k in request.form.keys():
|
|
@@ -1726,6 +1791,97 @@ def register_routes(app: Flask) -> None:
|
|
|
extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
|
|
extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
|
|
|
return Response("success", mimetype="text/plain")
|
|
return Response("success", mimetype="text/plain")
|
|
|
|
|
|
|
|
|
|
+ @app.post("/pay/notify")
|
|
|
|
|
+ def api_pay_notify() -> Response:
|
|
|
|
|
+ """中间层 REST API 支付成功后的异步回调接口(JSON 格式)。
|
|
|
|
|
+ 接收字段:bill_no, trade_no, trade_status, amount, paid_at
|
|
|
|
|
+ """
|
|
|
|
|
+ data = request.get_json(silent=True) or {}
|
|
|
|
|
+
|
|
|
|
|
+ bill_no = (data.get("bill_no") or "").strip()
|
|
|
|
|
+ trade_no = (data.get("trade_no") or "").strip()
|
|
|
|
|
+ trade_status = (data.get("trade_status") or "").strip().upper()
|
|
|
|
|
+ amount_raw = str(data.get("amount") or "").strip()
|
|
|
|
|
+
|
|
|
|
|
+ if not bill_no:
|
|
|
|
|
+ return jsonify({"error": "bill_no_missing"}), 400
|
|
|
|
|
+
|
|
|
|
|
+ # 只处理支付成功的状态
|
|
|
|
|
+ if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
|
|
|
|
|
+ return jsonify({"ok": True, "msg": "ignored"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ row = fetch_one("SELECT * FROM orders WHERE id = ?", (bill_no,))
|
|
|
|
|
+ if row is None:
|
|
|
|
|
+ # 订单不存在,返回 200 避免中间层重试
|
|
|
|
|
+ return jsonify({"ok": True, "msg": "order_not_found"}), 200
|
|
|
|
|
+ if row["status"] == "PAID":
|
|
|
|
|
+ # 幂等:已处理过,直接返回成功
|
|
|
|
|
+ return jsonify({"ok": True, "msg": "already_paid"}), 200
|
|
|
|
|
+
|
|
|
|
|
+ # 校验金额
|
|
|
|
|
+ try:
|
|
|
|
|
+ amount = Decimal(amount_raw).quantize(Decimal("0.01"))
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return jsonify({"error": "invalid_amount"}), 400
|
|
|
|
|
+ expect_amount = (Decimal(int(row["amount_cents"] or 0)) / Decimal(100)).quantize(Decimal("0.01"))
|
|
|
|
|
+ if amount != expect_amount:
|
|
|
|
|
+ return jsonify({"error": "amount_mismatch"}), 400
|
|
|
|
|
+
|
|
|
|
|
+ snapshot = json.loads(row["plan_snapshot_json"])
|
|
|
|
|
+ cur = execute(
|
|
|
|
|
+ """
|
|
|
|
|
+ UPDATE orders
|
|
|
|
|
+ SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ?
|
|
|
|
|
+ WHERE id = ? AND status = 'PENDING'
|
|
|
|
|
+ """,
|
|
|
|
|
+ (isoformat(utcnow()), "ALIPAY", trade_no or None, bill_no),
|
|
|
|
|
+ )
|
|
|
|
|
+ if getattr(cur, "rowcount", 0) == 1:
|
|
|
|
|
+ extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
|
|
|
|
|
+
|
|
|
|
|
+ return jsonify({"ok": True}), 200
|
|
|
|
|
+
|
|
|
|
|
+ @app.get("/pay/return")
|
|
|
|
|
+ def api_pay_return() -> Response:
|
|
|
|
|
+ """支付宝支付完成后的同步跳转落地页。
|
|
|
|
|
+ 中间层会将用户浏览器重定向到此地址,展示支付结果并跳转到个人中心。
|
|
|
|
|
+ """
|
|
|
|
|
+ bill_no = (request.args.get("bill_no") or request.args.get("out_trade_no") or "").strip()
|
|
|
|
|
+ status = "unknown"
|
|
|
|
|
+ if bill_no:
|
|
|
|
|
+ row = fetch_one("SELECT status FROM orders WHERE id = ?", (bill_no,))
|
|
|
|
|
+ if row:
|
|
|
|
|
+ status = row["status"]
|
|
|
|
|
+
|
|
|
|
|
+ # 渲染一个简单的跳转页面,3 秒后自动跳转到个人中心
|
|
|
|
|
+ html = f"""<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta http-equiv="refresh" content="3;url=/ui/me">
|
|
|
|
|
+ <title>支付结果</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ body {{ font-family: sans-serif; display: flex; justify-content: center;
|
|
|
|
|
+ align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }}
|
|
|
|
|
+ .box {{ text-align: center; background: #fff; padding: 48px 64px;
|
|
|
|
|
+ border-radius: 16px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }}
|
|
|
|
|
+ .icon {{ font-size: 3rem; margin-bottom: 16px; }}
|
|
|
|
|
+ h2 {{ margin: 0 0 8px; color: #333; }}
|
|
|
|
|
+ p {{ color: #888; margin: 0 0 24px; }}
|
|
|
|
|
+ a {{ color: #0ea5e9; text-decoration: none; }}
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div class="box">
|
|
|
|
|
+ <div class="icon">{"✅" if status == "PAID" else "⏳"}</div>
|
|
|
|
|
+ <h2>{"支付成功" if status == "PAID" else "支付处理中"}</h2>
|
|
|
|
|
+ <p>{"会员权益已生效,正在跳转到个人中心…" if status == "PAID" else "订单处理中,请稍候,正在跳转…"}</p>
|
|
|
|
|
+ <a href="/ui/me">立即前往个人中心</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>"""
|
|
|
|
|
+ return Response(html, mimetype="text/html")
|
|
|
|
|
+
|
|
|
@app.post("/admin/auth/login")
|
|
@app.post("/admin/auth/login")
|
|
|
def api_admin_login() -> Response:
|
|
def api_admin_login() -> Response:
|
|
|
payload = request.get_json(silent=True) or {}
|
|
payload = request.get_json(silent=True) or {}
|