From 21b995435137033be90d1c2175507600b765daaa Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 22 Jun 2026 17:29:47 +0800 Subject: [PATCH] feat(payment): restore ezpay return orders --- PAYWALL_API.md | 796 ++++++++++++++++++ .../images/{ => chat}/ic_voicemessage_act.png | Bin .../{ => chat}/ic_voicemessage_lock.png | Bin .../{ => chat}/pic_popup_voicemessage.png | Bin public/images/{ => sidebar}/ic_user_vip.png | Bin .../subscription-checkout-button.tsx | 95 ++- src/app/subscription/return/page.tsx | 86 ++ src/app/subscription/subscription-screen.tsx | 36 + src/data/storage/index.ts | 1 + src/data/storage/payment/index.ts | 5 + .../payment/pending_payment_order_storage.ts | 52 ++ src/data/storage/storage_keys.ts | 3 + src/stores/payment/payment-events.ts | 1 + src/stores/payment/payment-machine.ts | 9 + 14 files changed, 1083 insertions(+), 1 deletion(-) create mode 100644 PAYWALL_API.md rename public/images/{ => chat}/ic_voicemessage_act.png (100%) rename public/images/{ => chat}/ic_voicemessage_lock.png (100%) rename public/images/{ => chat}/pic_popup_voicemessage.png (100%) rename public/images/{ => sidebar}/ic_user_vip.png (100%) create mode 100644 src/app/subscription/return/page.tsx create mode 100644 src/data/storage/payment/index.ts create mode 100644 src/data/storage/payment/pending_payment_order_storage.ts diff --git a/PAYWALL_API.md b/PAYWALL_API.md new file mode 100644 index 00000000..70b3e928 --- /dev/null +++ b/PAYWALL_API.md @@ -0,0 +1,796 @@ +# 付费墙接口说明(PAYWALL_API) + +> 适用服务:`ai-boyfriend-unified` +> 聊天接口路径:`POST /api/chat/send` +> 历史接口路径:`GET /api/chat/history` +> 私密解锁接口:`POST /api/chat/unlock-private` +> WebSocket 路径:`/ws` +> 字段命名:**camelCase**(与现有接口完全一致) +> 文档语言:中文 + +--- + +## 总览:三个付费墙功能 + +本文档覆盖三个会员限制功能,它们共用同一套「Elio 人设提示 + 是否展示开会员引导」的交互模式: + +| 功能 | 触发场景 | 限制对象 | 核心接口 | 核心字段 | +|------|---------|---------|---------|---------| +| ① 每日免费消息次数 | 用户发消息 | 注册非VIP(游客豁免) | `POST /api/chat/send` | `blocked` / `blockReason` / `blockDetail` | +| ② AI 男友照片查看 | 用户请求发图 | 非VIP(含游客) | `POST /api/chat/send` | `paywallTriggered` / `showUpgrade` / `imageType` / `imageUrl` | +| ③ 私密消息查看次数 | 查看被锁定的私密消息 | 非VIP(含游客) | `GET /api/chat/history` + `POST /api/chat/unlock-private` | `isPrivate` / `privateLocked` / `privateHint` / `showUpgrade` | + +> **当前状态**:付费墙、VIP 判定、订单接口、RabbitMQ 支付结果消费、VIP/DOL 发放逻辑已经写入后端。充值能否真正闭环取决于 `PAYMENT_SERVICE_URL`、`RABBITMQ_URL`、`orders` 表迁移以及支付服务 B 是否部署完成。若 B 未配置或不可用,聊天付费墙仍会返回 `showUpgrade=true`,但前端创建订单会失败,用户无法完成购买。 + +--- + +## 目录 + +1. [设计原则与兼容性](#1-设计原则与兼容性) +2. [功能①:每日免费消息次数](#2-功能每日免费消息次数) +3. [功能②:AI 男友照片查看](#3-功能ai-男友照片查看) +4. [功能③:私密消息查看](#4-功能私密消息查看) +5. [POST /api/chat/send 完整响应结构](#5-post-apichatsend-完整响应结构) +6. [WebSocket 事件](#6-websocket-事件) +7. [支付服务与前端充值对接](#7-支付服务与前端充值对接) +8. [前端接入清单](#8-前端接入清单) +9. [当前未生效或依赖外部配置的内容](#9-当前未生效或依赖外部配置的内容) +10. [通话与语音补充](#10-通话与语音补充) +11. [Manager 图片上传错误码](#11-manager-图片上传错误码) +12. [附录:图片意图关键词](#12-附录图片意图关键词) + +--- + +## 1. 设计原则与兼容性 + +1. **不新增聊天接口**:功能①②都复用现有 `POST /api/chat/send`,功能③复用 `GET /api/chat/history` 并新增一个 `POST /api/chat/unlock-private` 解锁接口。 +2. **响应结构固定**:付费墙相关字段在对应接口的**每一条**响应中都会出现(默认值 `false`/`null`),前端可稳定读取,不会"有时有有时没有"。 +3. **完全向后兼容**:所有现有字段原样保留,仅**新增**字段,不删改任何旧字段。 +4. **统一的展示判断字段 `showUpgrade`**:前端只需判断 `showUpgrade === true` 即展示"开通会员"引导,无需关心三个功能各自的内部逻辑。 +5. **Fail-safe**:所有付费墙逻辑失败时降级放行,不影响正常聊天。 + +--- + +## 2. 功能①:每日免费消息次数 + +### 规则 + +- 注册的非VIP用户每天最多免费发送 **30** 条消息(`FREE_DAILY_MSG_LIMIT=30`,按 UTC 日切)。 +- **游客不受限**;**VIP 不受限**。 +- 超限后,`POST /api/chat/send` 返回 `blocked=true`,`reply` 为空,前端据此弹开通会员提示。 + +### 触发时的响应(data 字段) + +```json +{ + "mode": "http", + "reply": "", + "voiceUrl": "", + "audioUrl": "", + "intimacyChange": 0, + "newIntimacy": 0, + "relationshipStage": "密友", + "currentMood": "happy", + "messageId": "", + "isGuest": false, + "timestamp": 1780975180614, + "blocked": true, + "blockReason": "daily_limit", + "blockDetail": { + "type": "daily_msg_limit", + "usedToday": 30, + "limit": 30 + }, + "paywallTriggered": false, + "showUpgrade": false, + "imageType": null, + "imageUrl": null +} +``` + +### 前端处理 + +``` +if (data.blocked === true && data.blockReason === "daily_limit") { + 展示"今日免费消息已用完,开通会员畅聊"引导 UI; + data.blockDetail 提供 usedToday / limit 供文案展示。 +} +``` + +> 注:每日限流当前用 `blocked/blockReason/blockDetail` 表达(沿用既有字段)。这与 `showUpgrade` 不冲突——限流场景 `showUpgrade=false`,前端通过 `blocked` 判断。 + +--- + +## 3. 功能②:AI 男友照片查看 + +### 规则 + +照片有两种触发方式: + +**(a) 用户主动索要**(如"发张照片"、"让我看看你"、"send me a photo"): +- **VIP** → 返回当天最接近当前时间的 Elio 日程图(`imageUrl` 有值,`imageType="elio_schedule"`)。 +- **非VIP**(含游客)→ 返回 Elio 升级提示,`showUpgrade=true`、`imageUrl=null`。 + +**(b) Elio 主动发图**(无需用户索要): +- 普通聊天中,主生成模型判断"此刻适合发照片"(如夜晚、亲密氛围、用户在想念时),且用户为 **VIP**、当天主动照片配额未用完、当天确有日程图时,后端会在该条普通回复里**附带** `imageUrl`(HTTP)/ 推送 `image` 事件(WS)。 +- 非 VIP **静默不发、不打扰**(不弹升级提示)。 +- VIP 每天主动收图上限 **3 张**(`PHOTO_DAILY_LIMIT=3`,UTC 日切)。 +- 主动发图时 `paywallTriggered=false`、`showUpgrade=false`,前端只需照常渲染 `imageUrl` 即可。 + +> 对前端而言协议不变:无论 (a) 还是 (b),都通过同一个 `imageUrl` 字段(HTTP)或 `image` 事件(WS)拿到图片,直接渲染即可。 + +### 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `paywallTriggered` | bool | 本条是否触发图片付费墙 | +| `showUpgrade` | bool | **前端是否展示开会员引导(核心判断字段)** | +| `imageType` | string \| null | `null`=无图 / `"elio_schedule"`=Elio 日程图 | +| `imageUrl` | string \| null | 图片公开 URL(VIP 有图时有值) | + +### 三种场景(data 字段示例) + +**A. 非VIP 请求图片(当前所有用户):** +```json +{ + "reply": "宝贝,我的照片只有会员才能看到哦……开通会员,我就把最近的照片发给你。😉", + "paywallTriggered": true, + "showUpgrade": true, + "imageType": null, + "imageUrl": null +} +``` + +**B. VIP 请求图片 + 当天有图:** +```json +{ + "reply": "嗯,你想看我……那就给你看一张。这是我今天下午的照片,你喜欢吗?", + "paywallTriggered": false, + "showUpgrade": false, + "imageType": "elio_schedule", + "imageUrl": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg" +} +``` + +**C. VIP 请求图片 + 当天无图:** +```json +{ + "reply": "今天还没拍什么好看的,等我有了好照片再发给你好不好?", + "paywallTriggered": false, + "showUpgrade": false, + "imageType": null, + "imageUrl": null +} +``` + +> 上面仅列出付费墙字段,完整 data 结构见第 5 节。 + +### 前端处理 + +``` +1. 始终渲染 data.reply 为 Elio 文字气泡。 +2. if (data.showUpgrade) 展示开会员引导。 +3. if (data.imageUrl) 渲染图片(URL 可直接用于 ,无需鉴权)。 +``` + +--- + +## 4. 功能③:私密消息查看 + +### 规则 + +- AI 回复中若包含私密/亲密/大尺度内容,后端**异步**判定并给该消息打标 `is_private=true`,生成模糊预告 `private_hint`。 +- 用户拉取历史时: + - **VIP** → 正常看到完整内容。 + - **非VIP** → 私密消息内容被屏蔽,返回 `privateLocked=true` + `privateHint`,前端展示**锁定卡片**。 +- 非VIP 每天有 **1** 次免费解锁额度(`FREE_DAILY_PRIVATE_VIEWS=1`,UTC 日切)。 +- 解锁通过 `POST /api/chat/unlock-private` 完成;免费额度用尽则 `showUpgrade=true`,前端弹开通会员提示。 + +### 4.1 GET /api/chat/history —— 历史含锁定标记 + +每条消息(`data.messages[]`)字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `role` | string | `user` / `assistant` | +| `content` | string | 消息内容;**私密且未解锁时为空字符串** | +| `id` | string | 消息 ID(解锁时作为 messageId 传入) | +| `created_at` | string | ISO8601 时间 | +| `isPrivate` | bool \| null | 是否私密消息 | +| `privateLocked` | bool \| null | **true = 当前用户无权查看,内容已锁定** | +| `privateHint` | string \| null | 非会员看到的模糊预告文案 | + +`data` 顶层新增的配额状态字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `isVip` | bool | 当前用户是否 VIP | +| `privateFreeLimit` | int | 每日免费查看上限(当前为 1) | +| `privateUsedToday` | int | 今日已用次数 | +| `privateCanViewFree` | bool | 今日是否还有免费额度(VIP 恒为 true) | + +**历史响应示例(非VIP,含一条锁定的私密消息):** +```json +{ + "messages": [ + { + "role": "user", + "content": "今天好想你", + "id": "msg-1", + "created_at": "2026-06-09T03:00:00Z", + "isPrivate": false, + "privateLocked": false, + "privateHint": null + }, + { + "role": "assistant", + "content": "", + "id": "msg-2", + "created_at": "2026-06-09T03:00:05Z", + "isPrivate": true, + "privateLocked": true, + "privateHint": "他好像想说什么悄悄话,解锁会员才能看到…" + } + ], + "total": 2, + "limit": 50, + "offset": 0, + "isVip": false, + "privateFreeLimit": 1, + "privateUsedToday": 0, + "privateCanViewFree": true +} +``` + +### 4.2 POST /api/chat/unlock-private —— 解锁私密消息 + +> 前端在用户点击锁定卡片时调用。**当前前端可暂不调用**,接口已就绪。 + +**请求体:** +```json +{ "messageId": "msg-2" } +``` + +**响应 data 字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `unlocked` | bool | true = 已解锁,可显示 content | +| `content` | string \| null | 解锁成功时返回完整私密内容,否则 null | +| `showUpgrade` | bool | **true = 免费次数用尽,前端弹开通会员提示** | +| `paywallTriggered` | bool | 同 showUpgrade,业务语义标识 | +| `privateFreeLimit` | int | 每日免费查看上限 | +| `privateUsedToday` | int | 今日已用次数 | +| `reason` | string | `ok` / `quota_exceeded` / `not_private` / `not_found` | + +**场景 1 — 非VIP 当日仍有免费额度(解锁成功,消耗 1 次):** +```json +{ + "unlocked": true, + "content": "(完整的私密回复内容…)", + "showUpgrade": false, + "paywallTriggered": false, + "privateFreeLimit": 1, + "privateUsedToday": 1, + "reason": "ok" +} +``` + +**场景 2 — 非VIP 当日免费额度已用尽(需开会员):** +```json +{ + "unlocked": false, + "content": null, + "showUpgrade": true, + "paywallTriggered": true, + "privateFreeLimit": 1, + "privateUsedToday": 1, + "reason": "quota_exceeded" +} +``` + +**场景 3 — VIP 用户(直接解锁,不消耗额度):** +```json +{ + "unlocked": true, + "content": "(完整的私密回复内容…)", + "showUpgrade": false, + "paywallTriggered": false, + "privateFreeLimit": 1, + "privateUsedToday": 0, + "reason": "ok" +} +``` + +**场景 4 — 消息不存在 / 非私密:** +```json +{ "unlocked": false, "content": null, "showUpgrade": false, "reason": "not_found" } +``` +```json +{ "unlocked": true, "content": "(普通内容)", "showUpgrade": false, "reason": "not_private" } +``` + +### 前端处理 + +``` +拉取 history: + 对每条 privateLocked===true 的消息,渲染为锁定卡片(展示 privateHint)。 + +用户点击锁定卡片 → 调 POST /api/chat/unlock-private { messageId }: + if (resp.unlocked === true) 用 resp.content 替换卡片,显示完整内容; + else if (resp.showUpgrade === true) 弹"开通会员查看更多私密消息"提示。 +``` + +--- + +## 5. POST /api/chat/send 完整响应结构 + +`data` 字段的**全部字段**(现有 + 新增),所有 `/api/chat/send` 响应都包含: + +| 字段 | 类型 | 现有/新增 | 说明 | +|------|------|----------|------| +| `mode` | string | 现有 | `"http"` / `"websocket"` | +| `reply` | string | 现有 | Elio 回复文本 | +| `voiceUrl` | string | 现有 | 语音 URL(后台异步生成) | +| `audioUrl` | string | 现有 | 兼容旧客户端 | +| `intimacyChange` | int | 现有 | 亲密度变化(恒 0) | +| `newIntimacy` | int | 现有 | 当前亲密度 | +| `relationshipStage` | string | 现有 | 关系阶段 | +| `currentMood` | string | 现有 | Elio 情绪 | +| `messageId` | string | 现有 | 消息 ID | +| `isGuest` | bool \| null | 现有 | 是否游客 | +| `timestamp` | int | 现有 | Unix 毫秒时间戳 | +| `blocked` | bool \| null | 现有 | 是否每日限流拦截(功能①) | +| `blockReason` | string \| null | 现有 | `"daily_limit"` | +| `blockDetail` | object \| null | 现有 | `{ type, usedToday, limit }` | +| `paywallTriggered` | bool | 新增 | 是否触发图片付费墙(功能②) | +| `showUpgrade` | bool | 新增 | **前端是否展示开会员引导** | +| `imageType` | string \| null | 新增 | `null` / `"elio_schedule"` | +| `imageUrl` | string \| null | 新增 | 图片公开 URL | + +--- + +## 6. WebSocket 事件 + +通过 `/ws` 连接时: + +### image 事件(VIP 有图时额外推送) +```json +{ "type": "image", "data": { "imageUrl": "https://.../schedules/42/xxx.jpg" } } +``` + +### paywall_status 事件(每次图片请求都推送) +```json +{ + "type": "paywall_status", + "data": { + "paywallTriggered": true, + "showUpgrade": true, + "imageType": null, + "imageUrl": null + } +} +``` + +文字回复(含升级提示)沿用现有整句推送机制,前端无需改动。 + +--- + +## 7. 支付服务与前端充值对接 + +支付链路分三段: + +```text +前端 -> 后端 A(ai-boyfriend-unified) -> 支付服务 B -> RabbitMQ -> 后端 A 发放权益 -> 前端刷新状态 +``` + +### 7.1 后端 A 提供给前端的接口 + +#### GET /api/payment/plans + +获取套餐列表,无需登录。前端在开通会员/充值弹窗中展示。 + +响应 `data.plans[]` 字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `plan_id` | string | 前端创建订单时传入的套餐 ID | +| `plan_name` | string | 展示名称 | +| `order_type` | string | 传给支付服务 B 的订单类型 | +| `amount_cents` | int | 金额,单位为分 | +| `currency` | string | 当前为 `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 | + +#### POST /api/payment/create-order + +创建订单,需登录。 + +```http +POST /api/payment/create-order +Authorization: Bearer +Content-Type: application/json +``` + +请求体: + +```json +{ + "planId": "vip_monthly", + "payChannel": "stripe", + "autoRenew": true +} +``` + +字段说明: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `planId` | 是 | 来自 `GET /api/payment/plans` 的 `plan_id` | +| `payChannel` | 是 | 当前支持 `stripe` / `ezpay` | +| `autoRenew` | 是 | 是否自动续费;DOL 和永久会员建议传 `false` | + +成功响应: + +```json +{ + "success": true, + "data": { + "orderId": "pay_abc123", + "payParams": { + "checkout_url": "https://pay.example.com/checkout/pay_abc123" + } + } +} +``` + +`payParams` 是支付服务 B 返回给前端的拉起支付信息。对于 PayPal / Stripe Checkout / ezPay 这类浏览器跳转支付,B 必须在 `payParams` 内提供可直接打开的支付链接。 + +前端读取支付链接的优先级: + +1. `payParams.checkout_url` +2. `payParams.payment_url` +3. `payParams.approval_url` +4. `payParams.url` + +如果 B 使用 SDK 参数而不是跳转链接,也可以在 `payParams` 中返回 SDK 所需字段,但必须提前和前端约定字段名。后端 A 不解析 `payParams`,只做透传。 + +错误: + +| 状态码 | 场景 | +|---|---| +| `400` | `planId` 无效,或 `payChannel` 不是 `stripe` / `ezpay` | +| `401` | 未登录或 token 无效 | +| `502` | `PAYMENT_SERVICE_URL` 未配置,或支付服务 B 创建订单失败 | +| `500` | 后端 A 创建订单过程中发生未知错误 | + +#### GET /api/payment/order-status + +轮询订单状态,需登录。只能查询当前登录用户自己的订单。 + +```http +GET /api/payment/order-status?order_id=pay_abc123 +Authorization: Bearer +``` + +响应: + +```json +{ + "success": true, + "data": { + "orderId": "pay_abc123", + "status": "pending", + "orderType": "vip_monthly", + "planId": "vip_monthly" + } +} +``` + +`status` 取值: + +| status | 前端动作 | +|---|---| +| `pending` | 继续等待;建议每 3~5 秒轮询一次 | +| `paid` | 停止轮询,刷新 VIP 状态或 DOL 余额 | +| `failed` | 停止轮询,提示支付失败/取消 | + +#### GET /api/payment/vip-status + +查询当前用户 VIP 状态,需登录。 + +```http +GET /api/payment/vip-status +Authorization: Bearer +``` + +响应: + +```json +{ + "success": true, + "data": { + "isVip": true, + "vipExpiresAt": "2026-07-17T10:00:00+00:00" + } +} +``` + +`vipExpiresAt=null` 表示永久会员。 + +### 7.2 后端 A 调支付服务 B:创建订单 + +后端 A 在收到前端 `create-order` 后,会调用支付服务 B: + +```http +POST {PAYMENT_SERVICE_URL}/orders +Authorization: Bearer {PAYMENT_SERVICE_API_KEY} +Content-Type: application/json +``` + +请求体: + +```json +{ + "user_id": "用户 UUID", + "order_type": "vip_monthly", + "pay_channel": "stripe", + "automatic_renewal": true, + "amount_cents": 999, + "currency": "CNY" +} +``` + +B 必须返回: + +```json +{ + "order_id": "pay_abc123", + "pay_params": { + "checkout_url": "https://pay.example.com/checkout/pay_abc123" + } +} +``` + +约定: + +- `pay_params` 结构由支付服务 B 决定,后端 A 不解析,前端原样使用。 +- 浏览器跳转支付必须返回可打开的 URL,推荐统一使用 `pay_params.checkout_url`。 +- 当前 A/B 已确认支持的 `pay_channel` 是 `stripe` / `ezpay`;如果要接 PayPal,需要先由支付服务 B 的 `/orders` 支持 `paypal`,再把后端 A 的渠道校验和前端选项一起放开。 +- 后端 A 会把 `order_id`、`user_id`、`order_type`、`plan_id`、`status=pending` 写入本地 `orders` 表。 +- 发放什么权益由 A 侧 `plan_id` 决定,B 不需要知道 VIP 天数或 DOL 数量。 + +### 7.3 支付服务 B 回传支付结果:RabbitMQ + +B 在支付成功或失败后,向 RabbitMQ 队列发布消息。 + +| 项目 | 值 | +|---|---| +| 队列名 | 默认 `payment.events`,可通过 `RABBITMQ_QUEUE` 修改 | +| 消息格式 | JSON UTF-8 | +| 推荐属性 | durable queue + persistent message | + +消息体: + +```json +{ + "user_id": "用户 UUID", + "order_id": "pay_abc123", + "status": "success", + "info": "" +} +``` + +字段: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `user_id` | 是 | 当前订单所属用户 | +| `order_id` | 是 | B 创建订单时返回给 A 的订单号 | +| `status` | 是 | `success` / `fail` | +| `info` | 否 | 失败原因或备注 | +| `sign` | 视配置 | 如果 A 配置了 `PAYMENT_WEBHOOK_SECRET`,B 需要按双方约定签名 | + +A 收到消息后的行为: + +| status | A 侧处理 | +|---|---| +| `success` | 查本地订单 -> 按 `plan_id` 发放 VIP 或 DOL -> 订单置为 `paid` -> 推送 `payment_success` | +| `fail` | 订单置为 `failed` -> 推送 `payment_failed` | + +幂等规则:同一订单已经是 `paid` 时,再收到 `success` 不会重复发放权益。 + +### 7.4 支付 WebSocket 事件 + +权益发放后,A 会通过 `/ws` 推送给在线用户。 + +VIP 支付成功: + +```json +{ + "type": "payment_success", + "orderId": "pay_abc123", + "payType": "vip", + "planName": "月度会员", + "vipExpiresAt": "2026-07-17T10:00:00+00:00" +} +``` + +DOL 支付成功: + +```json +{ + "type": "payment_success", + "orderId": "pay_def456", + "payType": "dol", + "planName": "100 DOL", + "dolAmount": 100, + "dolBalance": 350 +} +``` + +支付失败: + +```json +{ + "type": "payment_failed", + "orderId": "pay_abc123", + "info": "用户取消支付" +} +``` + +### 7.5 环境变量 + +```env +PAYMENT_SERVICE_URL=https://pay.example.com +PAYMENT_SERVICE_API_KEY= +PAYMENT_SERVICE_TIMEOUT=10.0 + +RABBITMQ_URL=amqp://user:pass@host:5672/vhost +RABBITMQ_QUEUE=payment.events +PAYMENT_WEBHOOK_SECRET= +``` + +--- + +## 8. 前端接入清单 + +### 8.1 触发开通会员入口 + +前端需要在这些场景展示开通会员入口: + +- `POST /api/chat/send` 返回 `blocked=true` 且 `blockReason="daily_limit"`:每日免费消息用完。 +- `POST /api/chat/send` 返回 `showUpgrade=true`:照片付费墙或其他升级提示。 +- `POST /api/chat/unlock-private` 返回 `showUpgrade=true`:私密消息免费查看次数用完。 +- 用户主动点击会员中心/充值按钮。 + +### 8.2 购买流程 + +推荐流程: + +```text +1. 打开升级弹窗 +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 +6. 每 3~5 秒 GET /api/payment/order-status?order_id=... +7. 收到 paid 或 WebSocket payment_success 后停止轮询 +8. payType=vip 时调用 /api/payment/vip-status 并刷新本地会员状态 +9. payType=dol 时刷新用户资料或积分余额,使用最新 dolBalance +``` + +前端不应该自己推断权益是否发放成功,应以 `order-status=paid`、`payment_success` 或 `vip-status` 的后端结果为准。 +前端也不应该调用任何“确认支付成功”接口来改订单状态;订单状态只能由支付服务 B 接到支付通道回调后,通过 RabbitMQ 通知后端 A 更新。 + +### 8.3 支付中状态 + +创建订单后到支付结果返回前,按钮/弹窗应显示支付处理中,避免重复创建订单。用户关闭支付窗口时不要立即判定失败,继续轮询一小段时间或允许用户手动刷新状态。 + +--- + +## 9. 当前未生效或依赖外部配置的内容 + +这些内容已经有后端代码或接口定义,但上线生效需要对应外部条件: + +| 功能 | 代码状态 | 生效条件 | 未满足时表现 | +|---|---|---|---| +| 付费墙展示 | 已接入聊天、图片、私密消息 | 无额外条件 | 非 VIP 返回 `blocked` 或 `showUpgrade` | +| 创建订单 | 已有 `/api/payment/create-order` | 配置 `PAYMENT_SERVICE_URL`,支付服务 B 可访问,且 B 返回 `order_id` + 可拉起支付的 `pay_params` | 返回 502,或前端拿不到支付链接 | +| 支付结果发放权益 | 已有 RabbitMQ 消费者 | 配置 `RABBITMQ_URL`,B 发布 `payment.events` 消息 | 订单停留 `pending`,VIP/DOL 不会到账 | +| 订单轮询 | 已有 `/api/payment/order-status` | 执行 `database/payment-migration.sql` 创建 `orders` 表 | 查询不到订单或写入失败 | +| VIP 解锁付费功能 | 已有 VIP 判定 | `users.is_vip=true` 且未过期,或永久会员 | 非 VIP 仍按限制处理 | +| DOL 充值到账 | 已有 DOL 发放 | `users.dol_balance` 字段存在,B 回传成功 MQ | 前端不能只靠创建订单展示到账 | +| 通话强制语音 | `/ws` 已支持 `call_text` / `call_audio` | TTS/STT 服务和前端通话 UI 接入 | STT/TTS 失败时返回 `call_error` 或无语音 | + +VIP 当前会解除/放宽的功能: + +- 每日免费消息次数限制。 +- 私密消息查看限制。 +- AI 男友照片查看和主动发图。 +- 语音/TTS 相关门控中需要 VIP 的普通语音回复。 + +--- + +## 10. 通话与语音补充 + +近期 `/ws` 增加了通话消息类型,供前端通话 UI 使用: + +| type | 方向 | 说明 | +|---|---|---| +| `call_start` | 前端 -> 后端 | 进入通话态 | +| `call_end` | 前端 -> 后端 | 结束通话态,后端返回 `call_ended` | +| `call_text` | 前端 -> 后端 | 前端已完成本地 STT,直接把文字发给后端 | +| `call_audio` | 前端 -> 后端 | 前端上传 base64 音频,后端 STT 后再进入 AI 回复 | +| `call_stt_result` | 后端 -> 前端 | 后端 STT 识别出的文字 | +| `call_stt_empty` | 后端 -> 前端 | 没识别到有效文字 | +| `call_error` | 后端 -> 前端 | 音频、STT 或通话处理错误 | + +`call_text` 和 `call_audio` 会以 `force_voice=true` 进入聊天处理流程,目的是让通话场景生成可播放语音。普通文字聊天仍按原有语音门控和配额逻辑处理。 + +--- + +## 11. Manager 图片上传错误码 + +用于 ai-boyfriend-Manager 后台 `POST/PUT /api/schedule` 的图片上传(运营管理员侧)。 + +### 11.1 HTTP 400 — 格式不支持 +```json +{ "detail": "不支持的文件格式 image/gif,仅允许 JPEG、PNG、WEBP" } +``` + +### 11.2 HTTP 400 — 文件过大(>10MB) +```json +{ "detail": "文件大小超过 10 MB 限制(当前 12582912 字节)" } +``` + +### 11.3 image_warning — 图片处理失败(非阻断,HTTP 200) + +图片通过校验但压缩/上传失败时不返回 500,而是 200 + `image_warning`,日程文字正常保存: +```json +{ "id": 42, "title": "苏州行", "image_url": null, "image_warning": "图片压缩失败:PIL 解码错误" } +``` + +| image_warning 示例 | 含义 | +|---|---| +| `"图片压缩失败:PIL 解码错误"` | 图片损坏或格式异常 | +| `"图片上传失败:Storage 连接超时"` | Storage 网络异常 | +| `"图片上传失败:权限不足"` | Bucket 权限配置错误 | +| `"数据库写入 image_url 失败"` | 图片已传但 URL 未关联日程 | + +--- + +## 12. 附录:图片意图关键词 + +正则匹配,仅检测消息**前 600 字符**,大小写不敏感: + +| 语言 | 模式 | +|------|------| +| 中文 | `发.*照片`、`发.*图`、`让我看看你`、`给我看看你`、`你今天的照片`、`你最近的照片`、`你的照片`、`你的图片` | +| 英文 | `send.*photo`、`send.*picture`、`show.*picture`、`let me see you`、`what do you look like` | + +--- + +## 附:数据库迁移 + +付费墙、支付、语音、图片功能依赖的库表迁移脚本: + +- `database/paywall-migration.sql` — users 会员字段(`is_vip`/`vip_expires_at`)、chat_messages 私密字段(`is_private`/`private_hint`)、`daily_private_unlocks` 表 +- `database/payment-migration.sql` — `orders` 表,用于本地订单状态、轮询和 MQ 回调发放权益 +- `database/migrate_elio_schedules_image_url.sql` — elio_schedules 表 `image_url` 列(功能②) +- `database/voice-quota-migration.sql` — users 语音配额字段(`daily_voice_count`/`daily_voice_date`) +- `database/photo-quota-migration.sql` — users 主动照片配额字段(`daily_photo_count`/`daily_photo_date`,Elio 主动发图日限) + +> 上线前需在 Supabase SQL Editor 执行以上脚本,并创建 `elio-schedules` 公开 Storage Bucket。 +> 若支付功能要闭环,必须确认 `orders` 表、`users.is_vip`、`users.vip_expires_at`、`users.dol_balance` 均已存在。 diff --git a/public/images/ic_voicemessage_act.png b/public/images/chat/ic_voicemessage_act.png similarity index 100% rename from public/images/ic_voicemessage_act.png rename to public/images/chat/ic_voicemessage_act.png diff --git a/public/images/ic_voicemessage_lock.png b/public/images/chat/ic_voicemessage_lock.png similarity index 100% rename from public/images/ic_voicemessage_lock.png rename to public/images/chat/ic_voicemessage_lock.png diff --git a/public/images/pic_popup_voicemessage.png b/public/images/chat/pic_popup_voicemessage.png similarity index 100% rename from public/images/pic_popup_voicemessage.png rename to public/images/chat/pic_popup_voicemessage.png diff --git a/public/images/ic_user_vip.png b/public/images/sidebar/ic_user_vip.png similarity index 100% rename from public/images/ic_user_vip.png rename to public/images/sidebar/ic_user_vip.png diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index a0e58c40..3605a5a2 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -6,30 +6,45 @@ */ import { useEffect, useRef, useState } from "react"; +import { PendingPaymentOrderStorage } from "@/data/storage"; import { usePaymentDispatch, usePaymentState, } from "@/stores/payment/payment-context"; +import { AppEnvUtil } from "@/utils"; import { StripePaymentDialog } from "./stripe-payment-dialog"; import { SubscriptionCtaButton } from "./subscription-cta-button"; import styles from "./subscription-cta-button.module.css"; +const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 3000; + export interface SubscriptionCheckoutButtonProps { /** 是否可用(未选套餐 / 未勾选协议 → false) */ disabled?: boolean; + subscriptionType: "vip" | "voice"; } export function SubscriptionCheckoutButton({ disabled = false, + subscriptionType, }: SubscriptionCheckoutButtonProps) { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const launchedNonceRef = useRef(0); + const ezpayRedirectTimeoutRef = useRef(null); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< string | null >(null); + useEffect(() => { + return () => { + if (ezpayRedirectTimeoutRef.current !== null) { + window.clearTimeout(ezpayRedirectTimeoutRef.current); + } + }; + }, []); + useEffect(() => { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { return; @@ -43,6 +58,34 @@ export function SubscriptionCheckoutButton({ const paymentUrl = getPaymentUrl(payment.payParams); if (paymentUrl) { + const isEzpay = isEzpayPayment(payment.payParams); + + if (AppEnvUtil.isDevelopment() && isEzpay) { + if (ezpayRedirectTimeoutRef.current !== null) { + window.clearTimeout(ezpayRedirectTimeoutRef.current); + } + + void redirectToEzpay({ + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS, + setTimeoutId: (timeoutId) => { + ezpayRedirectTimeoutRef.current = timeoutId; + }, + }); + return; + } + + if (isEzpay) { + void redirectToEzpay({ + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + }); + return; + } + window.location.href = paymentUrl; return; } @@ -52,7 +95,13 @@ export function SubscriptionCheckoutButton({ errorMessage: "Payment parameters did not include a supported URL or Stripe client secret.", }); - }, [payment.launchNonce, payment.payParams, paymentDispatch]); + }, [ + payment.currentOrderId, + payment.launchNonce, + payment.payParams, + paymentDispatch, + subscriptionType, + ]); const isLoading = payment.isCreatingOrder || @@ -70,6 +119,10 @@ export function SubscriptionCheckoutButton({ const handleClick = () => { if (disabled || isLoading) return; + if (ezpayRedirectTimeoutRef.current !== null) { + window.clearTimeout(ezpayRedirectTimeoutRef.current); + ezpayRedirectTimeoutRef.current = null; + } setHiddenStripeClientSecret(null); paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); }; @@ -155,6 +208,46 @@ function getPaymentUrl(payParams: Record): string | null { return null; } +function isEzpayPayment(payParams: Record): boolean { + const provider = payParams.provider; + return typeof provider === "string" && provider.toLowerCase() === "ezpay"; +} + +interface RedirectToEzpayInput { + orderId: string | null; + paymentUrl: string; + subscriptionType: "vip" | "voice"; + delayMs?: number; + setTimeoutId?: (timeoutId: number) => void; +} + +async function redirectToEzpay({ + orderId, + paymentUrl, + subscriptionType, + delayMs = 0, + setTimeoutId, +}: RedirectToEzpayInput): Promise { + if (orderId) { + await PendingPaymentOrderStorage.setPendingOrder({ + orderId, + payChannel: "ezpay", + subscriptionType, + createdAt: Date.now(), + }); + } + + if (delayMs > 0) { + const timeoutId = window.setTimeout(() => { + window.location.href = paymentUrl; + }, delayMs); + setTimeoutId?.(timeoutId); + return; + } + + window.location.href = paymentUrl; +} + function getStripeClientSecret( payParams: Record, ): string | null { diff --git a/src/app/subscription/return/page.tsx b/src/app/subscription/return/page.tsx new file mode 100644 index 00000000..2f2bf3f0 --- /dev/null +++ b/src/app/subscription/return/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { MobileShell } from "@/app/_components/core"; +import { PendingPaymentOrderStorage } from "@/data/storage"; +import { ROUTES } from "@/router/routes"; + +type ReturnStatus = "loading" | "missing"; + +function buildSubscriptionUrl(subscriptionType: "vip" | "voice"): string { + if (subscriptionType === "voice") return `${ROUTES.subscription}?type=voice`; + return `${ROUTES.subscription}?type=vip`; +} + +export default function SubscriptionReturnPage() { + const router = useRouter(); + const [status, setStatus] = useState("loading"); + + useEffect(() => { + let cancelled = false; + + const redirectToSubscription = async () => { + const result = await PendingPaymentOrderStorage.getPendingOrder(); + if (cancelled) return; + + if (result.success && result.data !== null) { + router.replace(buildSubscriptionUrl(result.data.subscriptionType)); + return; + } + + setStatus("missing"); + }; + + void redirectToSubscription(); + return () => { + cancelled = true; + }; + }, [router]); + + return ( + +
+

+ Returning to subscription +

+

+ {status === "loading" + ? "We are restoring your payment order." + : "No pending payment order was found. Please return to the subscription page."} +

+ {status === "missing" ? ( + + ) : null} +
+
+ ); +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 5169b290..4fe084d1 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -20,6 +20,7 @@ import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; import type { PaymentPlan } from "@/data/dto/payment"; import { VIP_BENEFITS } from "@/data/constants/vip-benefits"; +import { PendingPaymentOrderStorage } from "@/data/storage"; import { usePaymentDispatch, usePaymentState, @@ -122,6 +123,7 @@ export function SubscriptionScreen({ const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const refreshedPaidOrderRef = useRef(null); + const resumedPendingOrderRef = useRef(null); useEffect(() => { if (payment.status === "idle") { @@ -136,6 +138,39 @@ export function SubscriptionScreen({ userDispatch({ type: "UserFetch" }); }, [payment.currentOrderId, payment.isPaid, userDispatch]); + useEffect(() => { + if (payment.status !== "ready") return; + + let cancelled = false; + + const resumePendingOrder = async () => { + const result = await PendingPaymentOrderStorage.getPendingOrderForType( + subscriptionType, + ); + if (cancelled || !result.success || result.data === null) return; + if (payment.currentOrderId === result.data.orderId) return; + if (resumedPendingOrderRef.current === result.data.orderId) return; + + resumedPendingOrderRef.current = result.data.orderId; + paymentDispatch({ + type: "PaymentReturned", + orderId: result.data.orderId, + }); + }; + + void resumePendingOrder(); + return () => { + cancelled = true; + }; + }, [payment.currentOrderId, payment.status, paymentDispatch, subscriptionType]); + + useEffect(() => { + if (!payment.currentOrderId) return; + if (!payment.isPaid && payment.status !== "failed") return; + + void PendingPaymentOrderStorage.clearPendingOrder(); + }, [payment.currentOrderId, payment.isPaid, payment.status]); + const plans = useMemo( () => payment.plans @@ -274,6 +309,7 @@ export function SubscriptionScreen({ ) : ( )} diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts index 9e356693..5047502f 100644 --- a/src/data/storage/index.ts +++ b/src/data/storage/index.ts @@ -18,5 +18,6 @@ export * from "./chat/ichat_storage"; export * from "./chat/local_chat_db"; export * from "./chat/local_chat_storage"; export * from "./chat/local_message"; +export * from "./payment/pending_payment_order_storage"; export * from "./user/iuser_storage"; export * from "./user/user_storage"; diff --git a/src/data/storage/payment/index.ts b/src/data/storage/payment/index.ts new file mode 100644 index 00000000..3bb86b57 --- /dev/null +++ b/src/data/storage/payment/index.ts @@ -0,0 +1,5 @@ +/** + * @file Payment storage barrel. + */ + +export * from "./pending_payment_order_storage"; diff --git a/src/data/storage/payment/pending_payment_order_storage.ts b/src/data/storage/payment/pending_payment_order_storage.ts new file mode 100644 index 00000000..656fa152 --- /dev/null +++ b/src/data/storage/payment/pending_payment_order_storage.ts @@ -0,0 +1,52 @@ +"use client"; + +import { z } from "zod"; + +import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; + +import { StorageKeys } from "../storage_keys"; + +const PendingPaymentOrderSchema = z.object({ + orderId: z.string().min(1), + payChannel: z.literal("ezpay"), + subscriptionType: z.enum(["vip", "voice"]), + createdAt: z.number(), +}); + +export type PendingPaymentOrder = z.output; + +export class PendingPaymentOrderStorage { + private constructor() {} + + static setPendingOrder( + order: PendingPaymentOrder, + ): Promise> { + return SpAsyncUtil.setJson( + StorageKeys.pendingPaymentOrder, + order, + PendingPaymentOrderSchema, + ); + } + + static getPendingOrder(): Promise> { + return SpAsyncUtil.getJson( + StorageKeys.pendingPaymentOrder, + PendingPaymentOrderSchema, + ); + } + + static async getPendingOrderForType( + subscriptionType: PendingPaymentOrder["subscriptionType"], + ): Promise> { + const result = await PendingPaymentOrderStorage.getPendingOrder(); + if (Result.isErr(result)) return result; + if (result.data?.subscriptionType !== subscriptionType) { + return Result.ok(null); + } + return result; + } + + static clearPendingOrder(): Promise> { + return SpAsyncUtil.remove(StorageKeys.pendingPaymentOrder); + } +} diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index f5cfb04e..d0c83f65 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -29,6 +29,9 @@ export const StorageKeys = { lastExternalBrowserDialogShown: "last_external_browser_dialog_shown", lastPwaEventReported: "last_pwa_event_reported", lastAppInfoReported: "last_app_info_reported", + + // payment + pendingPaymentOrder: "pending_payment_order", } as const; export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys]; diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 55bd67ef..91ce5e03 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,6 +10,7 @@ export type PaymentEvent = | { type: "PaymentAutoRenewChanged"; autoRenew: boolean } | { type: "PaymentAgreementChanged"; agreed: boolean } | { type: "PaymentCreateOrderSubmitted" } + | { type: "PaymentReturned"; orderId: string } | { type: "PaymentRefreshVipStatus" } | { type: "PaymentLaunchFailed"; errorMessage: string } | { type: "PaymentErrorCleared" } diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index 6d09bdc4..3c6352fb 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -300,6 +300,15 @@ export const paymentMachine = setup({ }, }, on: { + PaymentReturned: { + target: ".pollingOrder", + actions: assign(({ event }) => ({ + currentOrderId: event.orderId, + payParams: null, + orderStatus: "pending", + errorMessage: null, + })), + }, PaymentLaunchFailed: { target: ".failed", actions: assign(({ event }) => ({