From 7dd4878d7d9185e2b97b10eacf4f99862f68a22d Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 14:10:59 +0800 Subject: [PATCH 1/2] fix(chat): constrain auth state transitions --- src/stores/chat/chat-events.ts | 2 +- src/stores/chat/chat-machine.ts | 19 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index ec877663..48c61931 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -11,7 +11,7 @@ * - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(不连 WS) * - `ChatNonVipLogin`:非 VIP 用户进入 /chat —— 拉服务器端首屏,不连 WS * - `ChatVipLogin`:VIP 用户进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload) - * - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息 + * - `ChatLogout`:正式登录用户登出 —— 断 WS + 清消息 * * 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, * chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index bbdc67c2..9926fc3c 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -7,7 +7,12 @@ * - `ChatGuestLogin` → 游客会话(断 WS = 不连) * - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS) * - `ChatVipLogin { token }` → VIP 用户会话(连 WS) - * - `ChatLogout` → 登出(机器自动 cleanup WS actor) + * - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor) + * + * 登录态流转约束: + * - 未登录:可以进入游客登录 / 其他登录。 + * - 游客登录:只可以升级为其他登录,不响应退出。 + * - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。 * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 @@ -364,10 +369,6 @@ export const chatMachine = setup({ guestSession: { on: { - ChatLogout: { - target: "#chat.idle", - actions: "clearChatSession", - }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", actions: "startUserSession", @@ -468,10 +469,6 @@ export const chatMachine = setup({ target: "#chat.idle", actions: "clearChatSession", }, - ChatGuestLogin: { - target: "#chat.guestSession", - actions: "startGuestSession", - }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", reenter: true, @@ -595,10 +592,6 @@ export const chatMachine = setup({ target: "#chat.idle", actions: "clearChatSession", }, - ChatGuestLogin: { - target: "#chat.guestSession", - actions: "startGuestSession", - }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", actions: "startUserSession", From 1a56a2a5f8c0a3234b4b2fdb98e145a1c04e6141 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 14:12:37 +0800 Subject: [PATCH 2/2] fix(paywall): clarify response fields and payment channel options --- PAYWALL_API.md | 55 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/PAYWALL_API.md b/PAYWALL_API.md index 70b3e928..24a6b057 100644 --- a/PAYWALL_API.md +++ b/PAYWALL_API.md @@ -325,7 +325,9 @@ if (data.blocked === true && data.blockReason === "daily_limit") { ## 5. POST /api/chat/send 完整响应结构 -`data` 字段的**全部字段**(现有 + 新增),所有 `/api/chat/send` 响应都包含: +`data` 字段的**全部字段**(现有 + 新增),所有 `/api/chat/send` 响应都包含: + +> 注意:`privateLocked` / `privateHint` / `isPrivate` **不属于** `/api/chat/send` 的响应字段;它们只出现在 `GET /api/chat/history` 的 `data.messages[]` 单条历史消息里。私密消息由后端异步打标,因此前端要在刷新/拉取历史时根据 `privateLocked=true` 渲染模糊锁定卡片。 | 字段 | 类型 | 现有/新增 | 说明 | |------|------|----------|------| @@ -341,8 +343,8 @@ if (data.blocked === true && data.blockReason === "daily_limit") { | `isGuest` | bool \| null | 现有 | 是否游客 | | `timestamp` | int | 现有 | Unix 毫秒时间戳 | | `blocked` | bool \| null | 现有 | 是否每日限流拦截(功能①) | -| `blockReason` | string \| null | 现有 | `"daily_limit"` | -| `blockDetail` | object \| null | 现有 | `{ type, usedToday, limit }` | +| `blockReason` | string \| null | 现有 | `"daily_limit"` / `"total_limit"` | +| `blockDetail` | object \| null | 现有 | 每日限制:`{ type, usedToday, limit }`;游客总量限制:`{ type, usedTotal, limit }` | | `paywallTriggered` | bool | 新增 | 是否触发图片付费墙(功能②) | | `showUpgrade` | bool | 新增 | **前端是否展示开会员引导** | | `imageType` | string \| null | 新增 | `null` / `"elio_schedule"` | @@ -398,20 +400,21 @@ if (data.blocked === true && data.blockReason === "daily_limit") { | `plan_name` | string | 展示名称 | | `order_type` | string | 传给支付服务 B 的订单类型 | | `amount_cents` | int | 金额,单位为分 | -| `currency` | string | 当前为 `CNY` | +| `original_amount_cents` | int \| null | 原价/划线价,单位为分;仅 VIP 套餐返回 | +| `daily_price_cents` | int \| null | 日均展示价,单位为分;仅 VIP 套餐返回 | +| `currency` | string | VIP 套餐当前为 `USD`;DOL 充值项当前为 `CNY` | | `vip_days` | int \| null | VIP 增加天数,`null` 表示永久或非 VIP 商品 | | `dol_amount` | int \| null | DOL 积分数量,VIP 商品为 `null` | 当前套餐: -| plan_id | plan_name | order_type | amount_cents | 发放内容 | -|---|---|---:|---:|---| -| `vip_monthly` | 月度会员 | `vip_monthly` | 999 | VIP +30 天 | -| `vip_quarterly` | 季度会员 | `vip_quarterly` | 2499 | VIP +90 天 | -| `vip_annual` | 年度会员 | `vip_annual` | 5999 | VIP +365 天 | -| `vip_lifetime` | 永久会员 | `vip_lifetime` | 19999 | 永久 VIP | -| `dol_100` | 100 DOL | `dol` | 600 | DOL +100 | -| `dol_500` | 500 DOL | `dol` | 2800 | DOL +500 | +| plan_id | plan_name | order_type | amount_cents | original_amount_cents | daily_price_cents | currency | 发放内容 | +|---|---|---:|---:|---:|---:|---|---| +| `vip_monthly` | Monthly | `vip_monthly` | 1990 | 2490 | 66 | USD | VIP +30 天 | +| `vip_quarterly` | Quarterly | `vip_quarterly` | 4990 | 5990 | 55 | USD | VIP +90 天 | +| `vip_annual` | Annual | `vip_annual` | 16990 | 19990 | 47 | USD | VIP +365 天 | +| `dol_100` | 100 DOL | `dol` | 600 | null | null | CNY | DOL +100 | +| `dol_500` | 500 DOL | `dol` | 2800 | null | null | CNY | DOL +500 | #### POST /api/payment/create-order @@ -438,7 +441,7 @@ Content-Type: application/json | 字段 | 必填 | 说明 | |---|---|---| | `planId` | 是 | 来自 `GET /api/payment/plans` 的 `plan_id` | -| `payChannel` | 是 | 当前支持 `stripe` / `ezpay` | +| `payChannel` | 是 | 支付通道取值约定:`stripe` / `ezpay` / `paycools`。当前生产代码若仍只放开 `stripe` / `ezpay`,需要同步把 A/B 两侧白名单加上 `paycools` | | `autoRenew` | 是 | 是否自动续费;DOL 和永久会员建议传 `false` | 成功响应: @@ -455,7 +458,18 @@ Content-Type: application/json } ``` -`payParams` 是支付服务 B 返回给前端的拉起支付信息。对于 PayPal / Stripe Checkout / ezPay 这类浏览器跳转支付,B 必须在 `payParams` 内提供可直接打开的支付链接。 +`payParams` 是支付服务 B 返回给前端的拉起支付信息。对于 Stripe Checkout / ezPay / PayCools 这类浏览器跳转支付,B 必须在 `payParams` 内提供可直接打开的支付链接。PayCools 返回的支付链接应放在 `checkout_url`、`payment_url` 或 `url` 中。 + +当前 Stripe 返回为 Checkout 链接模式。前端创建订单成功后应优先打开: + +1. `data.paymentUrl` +2. `data.checkoutUrl` +3. `data.url` +4. `data.payParams.paymentUrl` +5. `data.payParams.checkoutUrl` +6. `data.payParams.url` + +打开支付链接后继续轮询 `GET /api/payment/order-status?order_id=...`;支付服务 B 收到 Stripe webhook 后会发布 MQ,后端 A 消费后把订单从 `pending` 更新为 `paid`。 前端读取支付链接的优先级: @@ -470,7 +484,7 @@ Content-Type: application/json | 状态码 | 场景 | |---|---| -| `400` | `planId` 无效,或 `payChannel` 不是 `stripe` / `ezpay` | +| `400` | `planId` 无效,或 `payChannel` 不是后端当前放开的支付通道 | | `401` | 未登录或 token 无效 | | `502` | `PAYMENT_SERVICE_URL` 未配置,或支付服务 B 创建订单失败 | | `500` | 后端 A 创建订单过程中发生未知错误 | @@ -545,10 +559,10 @@ Content-Type: application/json { "user_id": "用户 UUID", "order_type": "vip_monthly", - "pay_channel": "stripe", + "pay_channel": "paycools", "automatic_renewal": true, - "amount_cents": 999, - "currency": "CNY" + "amount_cents": 1990, + "currency": "USD" } ``` @@ -567,7 +581,8 @@ B 必须返回: - `pay_params` 结构由支付服务 B 决定,后端 A 不解析,前端原样使用。 - 浏览器跳转支付必须返回可打开的 URL,推荐统一使用 `pay_params.checkout_url`。 -- 当前 A/B 已确认支持的 `pay_channel` 是 `stripe` / `ezpay`;如果要接 PayPal,需要先由支付服务 B 的 `/orders` 支持 `paypal`,再把后端 A 的渠道校验和前端选项一起放开。 +- PayCools 返回支付链接时,建议放在 `pay_params.checkout_url`;前端也兼容 `payment_url` / `approval_url` / `url`。 +- 当前支付对接文档约定的 `pay_channel` 是 `stripe` / `ezpay` / `paycools`;生产代码也需要同步支持这些取值。如果要接其他通道,需要先由支付服务 B 的 `/orders` 支持该通道,再把后端 A 的渠道校验和前端选项一起放开。 - 后端 A 会把 `order_id`、`user_id`、`order_type`、`plan_id`、`status=pending` 写入本地 `orders` 表。 - 发放什么权益由 A 侧 `plan_id` 决定,B 不需要知道 VIP 天数或 DOL 数量。 @@ -684,7 +699,7 @@ PAYMENT_WEBHOOK_SECRET= 2. GET /api/payment/plans 加载套餐 3. 用户选择 planId、payChannel、autoRenew 4. POST /api/payment/create-order -5. 从 data.payParams 读取 checkout_url/payment_url/approval_url/url 并打开支付页,或按约定调用支付 SDK +5. 从 data.payParams 读取 checkout_url/payment_url/approval_url/url 并打开支付页,PayCools 链接也走这个逻辑,或按约定调用支付 SDK 6. 每 3~5 秒 GET /api/payment/order-status?order_id=... 7. 收到 paid 或 WebSocket payment_success 后停止轮询 8. payType=vip 时调用 /api/payment/vip-status 并刷新本地会员状态