Explorar o código

添加了支付功能

kinglee hai 1 semana
pai
achega
b3ce02054a

+ 18 - 0
.env

@@ -0,0 +1,18 @@
+# ============================================================
+# 应用基础配置
+# ============================================================
+SECRET_KEY=dev-secret-key
+ENABLE_MOCK_PAY=0
+
+# ============================================================
+# 支付宝中间层 REST API 配置
+# ============================================================
+
+# 中间层 API 地址(不含末尾斜杠)
+PAY_API_BASE_URL=https://aigc-api.aitoolcore.com
+
+# 支付完成后支付宝同步跳转地址(用户浏览器跳转,需公网可访问)
+PAY_RETURN_URL=https://resources.wangxunai.cn//ui/vip
+
+# 支付成功后中间层异步回调本系统的地址(需公网可访问)
+PAY_CALLBACK_URL=https://resources.wangxunai.cn/pay/notify

+ 2 - 2
server/__pycache__/__init__.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:cbdf7d1c985ed28648584e0feca7e520326877e8e1e732d08befc760e7f23c98
-size 217
+oid sha256:a49777b7fec632879d6675784f0ac562c83fb5c14dc89998115153bc1100637c
+size 215

+ 2 - 2
server/__pycache__/app_factory.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:bc680ef83e6b43de08f3a4855240adbc311d5f04d6bd646768abe7d90b7bf031
-size 5688
+oid sha256:a3c694d6ad6b4809ccbb76859bdbd780993cc4b06d66ebde7153a785859c2271
+size 5686

+ 2 - 2
server/__pycache__/audit.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:750652c18c142483112f02d51f59f5cf3ffb5ab5457ae69235b1b45efb7b9661
-size 1585
+oid sha256:01bb3bdaf768701121f2f98f6c3c55a6f0b9e9d4fa892ebb033527de7654be0f
+size 1583

+ 2 - 2
server/__pycache__/auth.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:4122f61c2f22a04612afba9e78bbc72ca8aed81d75b5769048eed692389e9d6e
-size 3086
+oid sha256:6903e02a2786381af61e8199ea83a73bd16abb1dbb5758ea8f006f7496570d10
+size 3084

+ 2 - 2
server/__pycache__/config.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:46632e796e60e18f77491c68b244f1f84cccc3bfbb53afb21444bed129425fee
-size 3464
+oid sha256:30d3a38dd36e9db10cbed1a49914e5527c2e23648a1d7ca68fd6f801a857aa63
+size 4933

+ 2 - 2
server/__pycache__/context.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:b732dfb2d7da8f6fd7e217ce419a20f14daa602e5cfd65f3a0bdfe302c15d2a1
-size 597
+oid sha256:c74f0dd0fb02862b9bf08230321b826cc878d475d054fa6b3c581b48545f4532
+size 595

+ 2 - 2
server/__pycache__/core.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:aa2410ff349646fbbe8e6faf4a8e6e0438d95958a240894d06aaa4f11c0170a5
-size 1526
+oid sha256:8fa527c2bf4c3d37af3d2b28b4e6495464948fd2d365e1339ffe6489c4566647
+size 1524

+ 2 - 2
server/__pycache__/db.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:1b9e76f91ad48bbe1e76649e86ec71e3a81eab254c75d80f54b9f19d2f11c975
-size 61627
+oid sha256:f34612a417c33a91444195d7fad51e23ed6de30f5304c048df889980ca9ab88d
+size 61625

+ 2 - 2
server/__pycache__/gogs.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:c675edc04180a1d1493672568319e75630b068cd55e3ec4739b103111fb7522e
-size 49486
+oid sha256:2c73e5e94b96d6e249c3377ae48212c7f468b36b612f2d535bda0dd501714cc3
+size 49484

+ 2 - 2
server/__pycache__/routes.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:9d5e9c2096147b9547dc40ca453a72e65c4a7ad95e011544232849ee2c9679dc
-size 263067
+oid sha256:86e9bf9b71eec7eb7c2b55d124849dda74b57b14ead2c6ded9cde573582a7585
+size 270842

+ 2 - 2
server/__pycache__/settings.cpython-311.pyc

@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
 version https://git-lfs.github.com/spec/v1
-oid sha256:1c5f82cabc8b8c3d7fac4cecf9234abc8045f69ea410984c92eda5803e090911
-size 1658
+oid sha256:b734d4f1a7eb42d7f3dfd32de3563fb6100e5e8bd6b7f2f965bf033971b411fa
+size 1656

+ 21 - 0
server/config.py

@@ -18,6 +18,10 @@ class AppConfig:
     require_login_to_view_repo: bool
     require_login_to_view_repo: bool
     enable_mock_pay: bool
     enable_mock_pay: bool
     max_preview_bytes: int
     max_preview_bytes: int
+    # 支付宝中间层 REST API 配置
+    pay_api_base_url: str
+    pay_return_url: str
+    pay_callback_url: str
 
 
 
 
 def load_config() -> AppConfig:
 def load_config() -> AppConfig:
@@ -25,6 +29,20 @@ def load_config() -> AppConfig:
     data_dir = project_root / "data"
     data_dir = project_root / "data"
     data_dir.mkdir(parents=True, exist_ok=True)
     data_dir.mkdir(parents=True, exist_ok=True)
 
 
+    # 自动加载 .env 文件(仅当环境变量未设置时生效)
+    env_file = project_root / ".env"
+    if env_file.exists():
+        with open(env_file, encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                if not line or line.startswith("#") or "=" not in line:
+                    continue
+                key, _, val = line.partition("=")
+                key = key.strip()
+                val = val.strip()
+                if key and key not in os.environ:
+                    os.environ[key] = val
+
     return AppConfig(
     return AppConfig(
         secret_key=os.environ.get("SECRET_KEY", "dev-secret-key"),
         secret_key=os.environ.get("SECRET_KEY", "dev-secret-key"),
         database_path=Path(os.environ.get("DATABASE_PATH", str(data_dir / "app.db"))),
         database_path=Path(os.environ.get("DATABASE_PATH", str(data_dir / "app.db"))),
@@ -38,4 +56,7 @@ def load_config() -> AppConfig:
         require_login_to_view_repo=os.environ.get("REQUIRE_LOGIN_TO_VIEW_REPO", "1") not in {"0", "false", "False"},
         require_login_to_view_repo=os.environ.get("REQUIRE_LOGIN_TO_VIEW_REPO", "1") not in {"0", "false", "False"},
         enable_mock_pay=os.environ.get("ENABLE_MOCK_PAY", "1") in {"1", "true", "True"},
         enable_mock_pay=os.environ.get("ENABLE_MOCK_PAY", "1") in {"1", "true", "True"},
         max_preview_bytes=int(os.environ.get("MAX_PREVIEW_BYTES", "200000")),
         max_preview_bytes=int(os.environ.get("MAX_PREVIEW_BYTES", "200000")),
+        pay_api_base_url=(os.environ.get("PAY_API_BASE_URL") or "https://aigc-api.aitoolcore.com").rstrip("/"),
+        pay_return_url=(os.environ.get("PAY_RETURN_URL") or "").strip(),
+        pay_callback_url=(os.environ.get("PAY_CALLBACK_URL") or "").strip(),
     )
     )

+ 208 - 52
server/routes.py

@@ -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 {}

+ 48 - 1
static/app_user.js

@@ -791,7 +791,9 @@ async function pageVip() {
       const payResp = await apiFetch(`/orders/${order.id}/pay`, { method: "POST" });
       const payResp = await apiFetch(`/orders/${order.id}/pay`, { method: "POST" });
       if (payResp && payResp.payUrl) {
       if (payResp && payResp.payUrl) {
         vipMsg.textContent = "已发起支付宝支付,正在跳转…";
         vipMsg.textContent = "已发起支付宝支付,正在跳转…";
-        window.location.href = payResp.payUrl;
+        // 打开支付宝收银台,同时在后台轮询订单状态
+        const payWin = window.open(payResp.payUrl, "_blank");
+        _startPayPolling(order.id, payWin);
         return;
         return;
       }
       }
       vipMsg.textContent = "支付成功(模拟),已发放会员权益。";
       vipMsg.textContent = "支付成功(模拟),已发放会员权益。";
@@ -809,6 +811,51 @@ async function pageVip() {
   });
   });
 }
 }
 
 
+/**
+ * 支付轮询:每 3 秒向后端查询一次订单状态。
+ * 与 callback_url 回调竞争,谁先触发谁先激活,后端幂等保护。
+ * @param {string} orderId  订单 ID
+ * @param {Window|null} payWin  支付宝收银台窗口(可为 null)
+ */
+function _startPayPolling(orderId, payWin) {
+  const INTERVAL_MS = 3000;   // 轮询间隔 3 秒
+  const MAX_TRIES   = 40;     // 最多轮询 40 次(约 2 分钟)
+  let tries = 0;
+  let stopped = false;
+
+  const vipMsg = document.getElementById("vipMsg");
+  if (vipMsg) {
+    vipMsg.style.display = "";
+    vipMsg.textContent = "等待支付结果,请在新窗口完成支付…";
+  }
+
+  const timer = setInterval(async () => {
+    if (stopped) return;
+    tries++;
+
+    try {
+      const res = await apiFetch(`/orders/${orderId}/query-and-activate`, { method: "POST" });
+      if (res && res.status === "PAID") {
+        stopped = true;
+        clearInterval(timer);
+        // 关闭支付宝窗口(如果还开着)
+        try { if (payWin && !payWin.closed) payWin.close(); } catch (_) {}
+        showToastSuccess("支付成功,会员权益已生效");
+        setTimeout(() => { window.location.href = "/ui/me"; }, 800);
+        return;
+      }
+    } catch (_) {
+      // 网络抖动,忽略,继续轮询
+    }
+
+    if (tries >= MAX_TRIES) {
+      stopped = true;
+      clearInterval(timer);
+      if (vipMsg) vipMsg.textContent = "未检测到支付结果,如已付款请稍后刷新个人中心查看。";
+    }
+  }, INTERVAL_MS);
+}
+
 async function pageResourceDetail() {
 async function pageResourceDetail() {
   const root = document.getElementById("resourceDetail");
   const root = document.getElementById("resourceDetail");
   const resourceId = Number(root.getAttribute("data-resource-id"));
   const resourceId = Number(root.getAttribute("data-resource-id"));