diff --git a/FRONTEND_VIP_CREDIT_API.md b/FRONTEND_VIP_CREDIT_API.md new file mode 100644 index 00000000..58431195 --- /dev/null +++ b/FRONTEND_VIP_CREDIT_API.md @@ -0,0 +1,309 @@ +# VIP / Credits Frontend API + +本文档是前端接入会员、积分、权益和历史解锁的推荐接口说明。新版接入原则: + +- 前端权益状态只认 `GET /api/user/entitlements`。 +- 游客和登录非会员权益一致,只按 `isVip` 区分会员/非会员。 +- 支付完成后只刷新一次 `GET /api/user/entitlements`。 +- `dolBalance` 是旧字段名,等价于 `creditBalance`,新代码优先使用 `creditBalance`。 + +## 推荐调用链 + +```text +打开充值/VIP 弹窗 + -> GET /api/payment/plans + -> POST /api/payment/create-order + -> 打开 data.payParams.url / checkout_url / payment_url + -> GET /api/payment/order-status 轮询,或等待 WebSocket payment_success + -> GET /api/user/entitlements 刷新权益 +``` + +不要再按订单类型分别调用 `/api/payment/vip-status` 和 `/api/user/credits` 刷新状态。 + +## GET /api/user/entitlements + +用途:当前用户 VIP、积分和权益快照。支持游客 token 和登录 token。 + +认证:需要 `Authorization: Bearer `,游客 token 也可以。 + +响应示例: + +```json +{ + "success": true, + "data": { + "userId": "user-1", + "isGuest": false, + "isVip": false, + "vipExpiresAt": null, + "creditBalance": 120, + "dolBalance": 120, + "policy": { + "membershipState": "non_vip", + "nonVipEntitlementsShared": true, + "guestSameAsLoggedInNonVip": true, + "refreshAfterPayment": "GET /api/user/entitlements" + }, + "costs": { + "normal_message": 2, + "private_message": 10, + "voice_message": 20, + "photo": 40, + "voice_call_minute": 50, + "private_album_10": 300, + "private_album_20": 600 + }, + "quotas": { + "normalChatFreeDaily": 30, + "privateUnlockFreeDaily": 1, + "voiceMessageFreeDaily": 0, + "photoFreeDaily": 0 + }, + "historyUnlock": { + "enabled": true, + "order": "oldest_first", + "chargeMode": "highest_cost_per_locked_message", + "insufficientBalanceBehavior": "no_deduction", + "costs": { + "private_message": 10, + "voice_message": 20, + "photo": 40 + } + } + } +} +``` + +前端使用: + +- App 启动、登录成功、游客 token 创建成功后调用。 +- 支付成功、充值成功、历史解锁成功后调用。 +- `isGuest` 只用于 UI 身份展示,不用于权益判断。 + +## GET /api/payment/plans + +用途:获取当前国家对应的会员/积分套餐。 + +认证:不需要登录。后端通过 `CF-IPCountry` 判断国家;登录后创建订单会再次按用户国家校验价格。 + +响应重点字段: + +```json +{ + "plan_id": "vip_monthly", + "plan_name": "月度会员", + "order_type": "vip_monthly", + "amount_cents": 1999, + "original_amount_cents": 1999, + "daily_price_cents": 66, + "currency": "USD", + "pricing_tier": "T1", + "vip_days": 30, + "dol_amount": null +} +``` + +字段说明: + +- `amount_cents`: 实付金额,传给支付服务。 +- `original_amount_cents`: 划线价;无划线价时为 `null`,字段一定存在。 +- `currency`: 支付币种。 +- `plan_id`: 创建订单时传回后端。 + +## POST /api/payment/create-order + +用途:创建支付订单。 + +认证:需要登录 token。 + +请求: + +```json +{ + "planId": "vip_monthly", + "payChannel": "stripe", + "autoRenew": true +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "orderId": "ord-1", + "payParams": { + "url": "https://pay.example/ord-1" + } + } +} +``` + +前端使用: + +- `planId` 必须来自 `/api/payment/plans`。 +- `payParams` 原样交给支付 SDK 或打开其中的支付 URL。 +- 后端创建订单时使用 plans 里的 `amount_cents/currency`,不会再固定覆盖成 CNY。 + +## GET /api/payment/order-status + +用途:轮询支付订单状态。 + +认证:需要登录 token,只能查自己的订单。 + +请求: + +```text +GET /api/payment/order-status?order_id=ord-1 +``` + +响应: + +```json +{ + "success": true, + "data": { + "orderId": "ord-1", + "status": "paid", + "orderType": "vip", + "planId": "vip_monthly" + } +} +``` + +状态: + +- `pending`: 支付处理中,继续轮询。 +- `paid`: 支付成功,停止轮询并调用 `/api/user/entitlements`。 +- `failed`: 支付失败,停止轮询并提示用户。 + +## WebSocket payment_success / payment_failed + +用途:支付服务回调后,后端主动推送支付结果。 + +成功事件示例: + +```json +{ + "type": "payment_success", + "orderId": "ord-1", + "payType": "vip", + "planName": "月度会员", + "vipExpiresAt": "2026-07-26T00:00:00+00:00" +} +``` + +积分充值成功示例: + +```json +{ + "type": "payment_success", + "orderId": "ord-2", + "payType": "dol", + "planName": "1000 Credits", + "dolAmount": 1000, + "dolBalance": 1120 +} +``` + +前端收到 `payment_success` 后: + +```text +停止 order-status 轮询 +-> GET /api/user/entitlements +-> 用 creditBalance/isVip 更新页面 +``` + +## POST /api/chat/unlock-history + +用途:一键解锁历史锁定消息,按创建时间从旧到新处理。 + +认证:需要登录 token。 + +请求:无 body。 + +余额不足响应: + +```json +{ + "success": true, + "data": { + "unlocked": false, + "reason": "insufficient_balance", + "totalLocked": 2, + "unlockedCount": 0, + "privateCount": 1, + "imageCount": 1, + "voiceCount": 0, + "requiredCredits": 50, + "currentCredits": 20, + "remainingCredits": 20, + "shortfallCredits": 30, + "costsByMessage": { + "private-1": 10, + "photo-1": 40 + } + } +} +``` + +余额充足响应: + +```json +{ + "success": true, + "data": { + "unlocked": true, + "reason": "ok", + "requiredCredits": 60, + "currentCredits": 100, + "remainingCredits": 40, + "shortfallCredits": 0, + "messageIds": ["voice-1", "photo-1"] + } +} +``` + +计费规则: + +- 私密消息:10 credits。 +- 语音消息:20 credits。 +- 照片:40 credits。 +- 同一条消息命中多个类型时,按最高成本计一次。 +- 余额不足时不扣费、不解锁,只返回差额。 +- 已解锁过的消息会跳过。 + +## 兼容接口 + +以下接口保留给旧前端,但新前端不推荐作为权益状态来源: + +- `GET /api/payment/vip-status`: 只返回 `isVip/vipExpiresAt`。 +- `GET /api/user/credits`: 只返回 `dolBalance`。 +- `GET /api/user/stats`: 用户统计、亲密度、记忆等,不再用于支付后刷新权益。 + +## 测试证明 + +本地测试命令: + +```powershell +.\.venv\Scripts\python.exe -m pytest -q tests/test_vip_credit_entitlements.py +.\.venv\Scripts\python.exe -m pytest -q tests/test_private_unlock.py tests/test_elio_image_paywall.py +``` + +已通过结果: + +```text +tests/test_vip_credit_entitlements.py: 13 passed +tests/test_private_unlock.py tests/test_elio_image_paywall.py: 11 passed +``` + +覆盖证明: + +- `/api/user/entitlements`: 已测游客、登录非会员、VIP;游客和登录非会员的 `policy/costs/quotas` 一致。 +- `/api/payment/plans`: 已测 T1/T2 国家;所有计划都有 `original_amount_cents`。 +- `/api/payment/create-order`: 已测国家价格计划会传入创建订单;支付服务收到的金额和币种来自 plan。 +- `/api/payment/order-status`: 已测 `pending/paid/failed` 和禁止查询他人订单。 +- WebSocket `payment_success`: 已测 VIP 和积分发放事件 payload。 +- `/api/chat/unlock-history`: 已测无锁定消息、余额不足不扣费、余额充足扣费、已解锁跳过。 +- 兼容接口:已测 `/api/payment/vip-status` 和 `/api/user/credits` 仍可用。 diff --git a/docs-change.md b/docs-change.md deleted file mode 100644 index 980b55e3..00000000 --- a/docs-change.md +++ /dev/null @@ -1,123 +0,0 @@ -图片 URL 长相 -真实图片 URL 大概是这种公开 Supabase Storage 地址: -https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg -前端不用拼 URL,也不用鉴权,后端会直接返回完整 URL。前端只读: -data.image?.url -消息接口 -POST https://proapi.banlv-ai.com/api/chat/send -响应 data 固定字段: -{ - "reply": "这是我昨天傍晚的照片,那会儿在外面走了走。", - "audioUrl": "", - "messageId": "msg_123", - "timestamp": 1782271808947, - "isGuest": false, - "image": { - "type": "elio_schedule", - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg" - }, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false, - "reason": null, - "hint": null, - "detail": null - } -} -前端用法: -const d = res.data - -if (d.lockDetail?.showContent !== false && d.reply) { - renderText(d.reply) -} - -if (d.image?.url) { - renderImage(d.image.url) -} - -if (d.audioUrl) { - renderAudio(d.audioUrl) -} - -if (d.lockDetail?.showUpgrade) { - showVipPaywall(d.lockDetail.hint) -} -image.type 目前主要是 elio_schedule,无图时: -"image": { "type": null, "url": null } -被限额/需要充值时: -{ - "reply": "", - "image": { "type": null, "url": null }, - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "daily_limit", - "hint": "免费消息额度已用完,开通会员后可以继续聊天。", - "detail": { - "type": "daily_msg_limit", - "usedToday": 3, - "limit": 3 - } - } -} -历史接口 -GET https://proapi.banlv-ai.com/api/chat/history?limit=50&offset=0 -响应结构: -{ - "messages": [ - { - "role": "user", - "type": "text", - "content": "你最近在干嘛", - "id": "msg_1", - "created_at": "2026-06-24T10:00:00Z", - "audioUrl": null, - "image": { "type": null, "url": null }, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false, - "reason": null, - "hint": null, - "detail": null - } - }, - { - "role": "assistant", - "type": "text", - "content": "", - "id": "msg_2", - "created_at": "2026-06-24T10:00:03Z", - "audioUrl": null, - "image": { "type": null, "url": null }, - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "private_message", - "hint": "他好像想说什么悄悄话,解锁会员才能看到…", - "detail": { "messageId": "msg_2" } - } - } - ], - "total": 200, - "limit": 50, - "offset": 0, - "isVip": false -} -历史前端用法: -for (const m of data.messages) { - if (m.lockDetail?.showContent === false) { - renderLockedCard(m.lockDetail.hint) - if (m.lockDetail?.showUpgrade) showVipEntry() - continue - } - - renderMessage(m.role, m.content) - - if (m.image?.url) renderImage(m.image.url) - if (m.audioUrl) renderAudio(m.audioUrl) -} -前端不要再读这些旧字段:mode、relationshipStage、currentMood、intimacyChange、newIntimacy、voiceUrl、imageUrl、imageType、privateLocked、privateHint。统一是 audioUrl、image.url、lockDetail。 \ No newline at end of file diff --git a/docs_simplified.md b/docs_simplified.md deleted file mode 100644 index 7d655f9b..00000000 --- a/docs_simplified.md +++ /dev/null @@ -1,146 +0,0 @@ -发消息 -前端调: -POST /api/chat/send -Authorization: Bearer -Content-Type: application/json -请求体最常用就是: -{ - "message": "给我发图片", - "image": null, - "useWebSocket": false -} -如果是“用户发自己的图片给 AI 看”,先上传图片,再把这些字段带进来: -{ - "message": "你看看这张图", - "imageId": "...", - "imageThumbUrl": "...", - "imageMediumUrl": "...", - "imageOriginalUrl": "...", - "imageWidth": 1080, - "imageHeight": 720, - "useWebSocket": false -} -返回给前端,重点只看这几个字段: -{ - "reply": "回复文本", - "audioUrl": "", - "messageId": "uuid", - "isGuest": true, - "timestamp": 1782281463683, - "image": { - "type": "elio_schedule", - "url": "https://..." - }, - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "image_paywall", - "hint": "嗯,你想看我……那就给你看一张。这是我06月15日的照片,你喜欢吗?", - "detail": { - "image": { - "type": "elio_schedule", - "url": "https://..." - } - } - } -} -前端规则很简单: -lockDetail.showContent=true:正常展示 reply、image.url、audioUrl -lockDetail.showContent=false:不要展示真实内容,改成会员引导卡 -lockDetail.showUpgrade=true:显示开会员入口 -也就是说,后端会正常生成内容,前端只靠 lockDetail 决定“显示还是锁住”。 -历史消息 -前端调: -GET /api/chat/history?limit=50&offset=0 -Authorization: Bearer -返回结构: -{ - "messages": [ - { - "role": "user", - "type": "text", - "content": "给我发图片", - "id": "uuid", - "created_at": "2026-06-24T06:13:27.000Z", - "audioUrl": null, - "image": { "type": null, "url": null }, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false, - "reason": null, - "hint": null, - "detail": null - } - }, - { - "role": "assistant", - "type": "text", - "content": "嗯,你想看我……那就给你看一张。这是我06月15日的照片,你喜欢吗?", - "id": "uuid", - "created_at": "2026-06-24T06:13:27.000Z", - "audioUrl": null, - "image": { - "type": "elio_schedule", - "url": "https://..." - }, - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "image_paywall", - "hint": "嗯,你想看我……那就给你看一张。这是我06月15日的照片,你喜欢吗?", - "detail": { - "image": { - "type": "elio_schedule", - "url": "https://..." - } - } - } - } - ], - "total": 2, - "limit": 50, - "offset": 0, - "isVip": false -} -历史列表也完全按同一套 lockDetail 渲染,不要自己猜 VIP 逻辑。 -看图片 -图片消息统一看这两个位置: -message.image.url -如果被锁,message.lockDetail.detail.image.url -前端建议处理: -先看 lockDetail.showContent -如果 true,直接展示 image.url -如果 false,不要展示真实图,只显示“图片已锁定”的会员引导卡 -也就是: -后端会返回真实图片 -非 VIP 前端不展示 -VIP 或解锁后前端正常展示 - -私密图片 / 私密消息 -现在前端不用区分很多旧字段,只看 lockDetail.reason: -image_paywall:图片锁 -private_message:私密消息锁 -voice_message:语音锁 -daily_limit:免费消息额度用完 -其中“私密图片”本质上也是同一套路子: -后端正常产出内容 -前端收到 lockDetail.showContent=false -前端不展示真实内容 -前端展示会员引导 -如果是 private_message,通常读: -content / reply:后端可能仍会返回 -但前端必须以 lockDetail.showContent 为准 -不要因为拿到了文本就直接渲染 - -前端最简对接结论 -你们只需要统一渲染这几个字段: -文本:content 或 reply -图片:image.url -语音:audioUrl -是否展示:lockDetail.showContent -是否引导会员:lockDetail.showUpgrade -锁类型:lockDetail.reason -引导文案:lockDetail.hint \ No newline at end of file