From 3e148f5c0d99a63eea50b99b2a6a481a3d09b8d9 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 16:55:34 +0800 Subject: [PATCH 1/5] style(subscription): update payment section spacing --- .../subscription-benefits-card.module.css | 41 +++++++++---------- .../subscription-cta-button.module.css | 26 ++++++------ 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/app/subscription/components/subscription-benefits-card.module.css b/src/app/subscription/components/subscription-benefits-card.module.css index 082bb2a0..daef8840 100644 --- a/src/app/subscription/components/subscription-benefits-card.module.css +++ b/src/app/subscription/components/subscription-benefits-card.module.css @@ -1,17 +1,14 @@ .card { - background: #ffffff; - border-radius: var(--radius-xl); + background-color: #ffffff; + border-radius: 21px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); overflow: hidden; - border: 1px solid rgba(0, 0, 0, 0.04); + border: solid 1px rgba(0, 0, 0, 0.14); } .header { - background: linear-gradient( - 90deg, - var(--color-auth-primary-gradient-start), - var(--color-auth-primary-gradient-end) - ); + background-color: #fce4ec; + border-radius: 5px; padding: var(--spacing-md); text-align: center; } @@ -20,35 +17,35 @@ margin: 0; font-size: var(--font-size-lg); font-weight: 600; - color: #ffffff; + color: #f657a0; line-height: 1.3; } .list { list-style: none; - margin: 0; - padding: 0; + margin: 18px 0; + padding: 0 0 0 20px; } .item { display: flex; align-items: flex-start; - gap: var(--spacing-sm); - padding: var(--spacing-md) var(--spacing-lg); - font-size: var(--font-size-md); - color: var(--color-auth-text-primary); - line-height: 1.4; -} - -.item + .item { - border-top: 1px solid rgba(0, 0, 0, 0.05); + gap: 0; + padding: 0; + font-size: 14px; + color: #1e1e1e; + line-height: normal; } .numeral { flex: 0 0 auto; font-weight: 500; - min-width: 20px; - color: var(--color-auth-text-primary); + color: #1e1e1e; +} + +.numeral::after { + content: " "; + white-space: pre; } .text { diff --git a/src/app/subscription/components/subscription-cta-button.module.css b/src/app/subscription/components/subscription-cta-button.module.css index 9af87f5a..a84c0d57 100644 --- a/src/app/subscription/components/subscription-cta-button.module.css +++ b/src/app/subscription/components/subscription-cta-button.module.css @@ -1,9 +1,3 @@ -/* 视觉规格: - * - 全宽,52px 高 - * - 粉渐变(#f96ADE → #f657A0) - * - 半径 24,白字 16px/600 - * - 阴影 0 3px 5px rgba(208,12,65,0.20) - */ .button { display: inline-flex; align-items: center; @@ -12,17 +6,23 @@ height: 52px; padding: 0 var(--spacing-lg); border: none; - border-radius: var(--radius-xxxl); - background: linear-gradient( - 90deg, - var(--color-auth-primary-gradient-start), - var(--color-auth-primary-gradient-end) - ); - box-shadow: 0 3px 5px var(--color-auth-primary-button-shadow); + border-radius: 35px; + background-image: + linear-gradient( + 269deg, + #ff67e0 0%, + rgba(254, 104, 224, 0.5) 20%, + rgba(252, 105, 223, 0.79) 66%, + #f96ade 100% + ), + linear-gradient(#f657a0, #f657a0); + background-blend-mode: normal, normal; + box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.31); color: #ffffff; font-size: var(--font-size-lg); font-weight: 600; cursor: pointer; + opacity: 0.85; transition: filter 0.15s ease, transform 0.05s ease, opacity 0.15s ease; } From 9874cadb49a95d87859415d8391ec4721f522079 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 17:40:50 +0800 Subject: [PATCH 2/5] style(subscription): limit visible plan cards --- .../components/subscription-plan-card.tsx | 15 +++++++-------- src/app/subscription/subscription-screen.tsx | 1 + 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/subscription/components/subscription-plan-card.tsx b/src/app/subscription/components/subscription-plan-card.tsx index e3436b62..d98508bc 100644 --- a/src/app/subscription/components/subscription-plan-card.tsx +++ b/src/app/subscription/components/subscription-plan-card.tsx @@ -5,18 +5,17 @@ * 视觉规格(与设计稿对齐): * - 卡片顶部:套餐名(12px / secondary) * - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999) - * - 底部 28px 高渐变条:白字 12px/600 每日折算价 + * - 底部 28px 高色条:白字 12px/600 每日折算价 * - 选中:2px 粉色边框 + 浅粉阴影 - * - 三个卡片底部渐变不同:粉 → 紫红 → 紫 + * - 三个卡片底部色条不同:粉 → 亮粉 → 紫 */ import styles from "./subscription-plan-card.module.css"; -const PER_DAY_GRADIENTS = [ - "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)", - "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)", - "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)", - "linear-gradient(90deg, #32c7b5 0%, #2f8fd8 100%)", +const PER_DAY_COLORS = [ + "#f657a0", + "#fa77e3", + "#bb72f9", ] as const; export interface SubscriptionPlanView { @@ -53,7 +52,7 @@ export function SubscriptionPlanCard({ .join(" "); const perDayStyle = { - background: PER_DAY_GRADIENTS[gradientIndex % PER_DAY_GRADIENTS.length], + backgroundColor: PER_DAY_COLORS[gradientIndex % PER_DAY_COLORS.length], }; return ( diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 471a71f0..532713ee 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -136,6 +136,7 @@ export function SubscriptionScreen({ () => payment.plans .filter((plan) => isPlanForType(plan, subscriptionType)) + .slice(0, 3) .map((plan) => toPlanView(plan, subscriptionType)), [payment.plans, subscriptionType], ); From 05821d9a293b25e609a303cba4a515038c47cb2a Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 17:41:07 +0800 Subject: [PATCH 3/5] fix(chat): prompt guests to log in at quota limit --- src/app/chat/chat-screen.tsx | 13 +++++++++++++ .../chat-quota-exhausted-banner.module.css | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 7ae5b62b..3c32dabf 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; import type { LoginStatus } from "@/data/dto/auth"; @@ -37,6 +38,7 @@ export function ChatScreen() { const state = useChatState(); const chatDispatch = useChatDispatch(); const authState = useAuthState(); + const router = useRouter(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = useState(false); @@ -52,6 +54,7 @@ export function ChatScreen() { state.paywallReason === "total_limit" ? "The limit for free chat times\nhas been reached" : undefined; + const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined; const showPhotoPaywallBanner = state.paywallTriggered && state.paywallReason === "photo_paywall"; const showPrivatePaywallBanner = @@ -140,6 +143,14 @@ export function ChatScreen() { chatDispatch({ type: "ChatUnlockPrivateMessage", messageId }); } + function handleMessageLimitUnlock(): void { + if (isGuest) { + router.push(ROUTES.auth); + return; + } + router.push(`${ROUTES.subscription}?type=vip`); + } + return (
@@ -176,6 +187,8 @@ export function ChatScreen() { {showMessageLimitBanner ? ( ) : ( diff --git a/src/app/chat/components/chat-quota-exhausted-banner.module.css b/src/app/chat/components/chat-quota-exhausted-banner.module.css index 7c82fe05..a0da91f7 100644 --- a/src/app/chat/components/chat-quota-exhausted-banner.module.css +++ b/src/app/chat/components/chat-quota-exhausted-banner.module.css @@ -17,7 +17,7 @@ .text { color: #fff; - font-size: 1rem; + font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 0 0 var(--spacing-lg, 16px); @@ -32,7 +32,7 @@ border-radius: 999px; background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%); color: #fff; - font-size: 1rem; + font-size: 1.125rem; font-weight: 700; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); From c8881a33c6ddb535dab643d8ac788f9599856f37 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 17:53:48 +0800 Subject: [PATCH 4/5] fix(paywall): update private message unlock functionality and documentation --- PAYWALL_API.md | 331 +++++++++++++++++++++++-------------------------- 1 file changed, 153 insertions(+), 178 deletions(-) diff --git a/PAYWALL_API.md b/PAYWALL_API.md index 24a6b057..dc1a3dd1 100644 --- a/PAYWALL_API.md +++ b/PAYWALL_API.md @@ -3,7 +3,7 @@ > 适用服务:`ai-boyfriend-unified` > 聊天接口路径:`POST /api/chat/send` > 历史接口路径:`GET /api/chat/history` -> 私密解锁接口:`POST /api/chat/unlock-private` +> 当前无单条私密消息解锁接口;VIP 自动查看全部私密消息 > WebSocket 路径:`/ws` > 字段命名:**camelCase**(与现有接口完全一致) > 文档语言:中文 @@ -18,7 +18,7 @@ |------|---------|---------|---------|---------| | ① 每日免费消息次数 | 用户发消息 | 注册非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(含游客) | `GET /api/chat/history` | `isPrivate` / `privateLocked` / `privateHint` | > **当前状态**:付费墙、VIP 判定、订单接口、RabbitMQ 支付结果消费、VIP/DOL 发放逻辑已经写入后端。充值能否真正闭环取决于 `PAYMENT_SERVICE_URL`、`RABBITMQ_URL`、`orders` 表迁移以及支付服务 B 是否部署完成。若 B 未配置或不可用,聊天付费墙仍会返回 `showUpgrade=true`,但前端创建订单会失败,用户无法完成购买。 @@ -27,7 +27,7 @@ ## 目录 1. [设计原则与兼容性](#1-设计原则与兼容性) -2. [功能①:每日免费消息次数](#2-功能每日免费消息次数) +2. [功能①:免费消息次数限制](#2-功能免费消息次数限制) 3. [功能②:AI 男友照片查看](#3-功能ai-男友照片查看) 4. [功能③:私密消息查看](#4-功能私密消息查看) 5. [POST /api/chat/send 完整响应结构](#5-post-apichatsend-完整响应结构) @@ -35,7 +35,7 @@ 7. [支付服务与前端充值对接](#7-支付服务与前端充值对接) 8. [前端接入清单](#8-前端接入清单) 9. [当前未生效或依赖外部配置的内容](#9-当前未生效或依赖外部配置的内容) -10. [通话与语音补充](#10-通话与语音补充) +10. [Elio 回复语音字段](#10-elio-回复语音字段) 11. [Manager 图片上传错误码](#11-manager-图片上传错误码) 12. [附录:图片意图关键词](#12-附录图片意图关键词) @@ -43,21 +43,22 @@ ## 1. 设计原则与兼容性 -1. **不新增聊天接口**:功能①②都复用现有 `POST /api/chat/send`,功能③复用 `GET /api/chat/history` 并新增一个 `POST /api/chat/unlock-private` 解锁接口。 +1. **不新增聊天接口**:功能①②都复用现有 `POST /api/chat/send`,功能③只复用 `GET /api/chat/history`。 2. **响应结构固定**:付费墙相关字段在对应接口的**每一条**响应中都会出现(默认值 `false`/`null`),前端可稳定读取,不会"有时有有时没有"。 3. **完全向后兼容**:所有现有字段原样保留,仅**新增**字段,不删改任何旧字段。 -4. **统一的展示判断字段 `showUpgrade`**:前端只需判断 `showUpgrade === true` 即展示"开通会员"引导,无需关心三个功能各自的内部逻辑。 +4. **升级引导判断**:图片付费墙用 `showUpgrade === true` 展示"开通会员"引导;消息次数限制沿用 `blocked === true` + `blockReason` 判断;私密消息只看 `privateLocked`。 5. **Fail-safe**:所有付费墙逻辑失败时降级放行,不影响正常聊天。 --- -## 2. 功能①:每日免费消息次数 +## 2. 功能①:免费消息次数限制 ### 规则 -- 注册的非VIP用户每天最多免费发送 **30** 条消息(`FREE_DAILY_MSG_LIMIT=30`,按 UTC 日切)。 -- **游客不受限**;**VIP 不受限**。 -- 超限后,`POST /api/chat/send` 返回 `blocked=true`,`reply` 为空,前端据此弹开通会员提示。 +- 生产环境:游客每天最多免费发送 **30** 条消息,游客累计最多 **50** 条消息;注册的非 VIP 用户每天最多免费发送 **30** 条消息。 +- 预发环境:上述数量为生产的 1/10,即游客每天 **3** 条、累计 **5** 条;注册非 VIP 每天 **3** 条。 +- **VIP 不受限**。 +- 超限后,`POST /api/chat/send` 返回 `blocked=true`,`reply` 为空,前端据此弹开通会员提示。 ### 触发时的响应(data 字段) @@ -75,7 +76,7 @@ "isGuest": false, "timestamp": 1780975180614, "blocked": true, - "blockReason": "daily_limit", + "blockReason": "daily_limit", "blockDetail": { "type": "daily_msg_limit", "usedToday": 30, @@ -91,13 +92,18 @@ ### 前端处理 ``` -if (data.blocked === true && data.blockReason === "daily_limit") { - 展示"今日免费消息已用完,开通会员畅聊"引导 UI; - data.blockDetail 提供 usedToday / limit 供文案展示。 -} -``` - -> 注:每日限流当前用 `blocked/blockReason/blockDetail` 表达(沿用既有字段)。这与 `showUpgrade` 不冲突——限流场景 `showUpgrade=false`,前端通过 `blocked` 判断。 +if (data.blocked === true && data.blockReason === "daily_limit") { + 展示"今日免费消息已用完,开通会员畅聊"引导 UI; + data.blockDetail 提供 usedToday / limit 供文案展示。 +} + +if (data.blocked === true && data.blockReason === "total_limit") { + 展示"游客免费消息已用完,注册或开通会员继续聊"引导 UI; + data.blockDetail 提供 usedTotal / limit 供文案展示。 +} +``` + +> 注:消息次数限制当前用 `blocked/blockReason/blockDetail` 表达(沿用既有字段)。这与 `showUpgrade` 不冲突——限流场景 `showUpgrade=false`,前端通过 `blocked` 判断。 --- @@ -175,151 +181,87 @@ if (data.blocked === true && data.blockReason === "daily_limit") { --- -## 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) 弹"开通会员查看更多私密消息"提示。 -``` +## 4. 功能③:私密消息查看 + +### 规则 + +- AI 回复中若包含私密/亲密/大尺度内容,后端**异步**判定并给该消息打标 `is_private=true`,生成模糊预告 `private_hint`。 +- 用户拉取历史时: + - **VIP** → 所有历史私密消息直接返回完整内容;之前锁住的内容也会一起解开,后续也不再锁。 + - **非VIP** → 私密消息内容被屏蔽,返回 `privateLocked=true` + `privateHint`,前端展示**锁定卡片**。 +- 当前产品**没有单条私密消息解锁流程**。是否展示完整内容,只取决于当前用户是不是 VIP。 + +### 4.1 GET /api/chat/history —— 历史含锁定标记 + +每条消息(`data.messages[]`)字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `role` | string | `user` / `assistant` | +| `type` | string | `text` / `voice`;有 Elio 语音地址时为 `voice` | +| `content` | string | 消息内容;**非VIP查看私密消息时为空字符串** | +| `id` | string | 消息 ID | +| `created_at` | string | ISO8601 时间 | +| `audioUrl` | string \| null | Elio 语音播放 URL;用户文本消息为 `null` | +| `voiceUrl` | string \| null | 同 `audioUrl`,兼容旧字段 | +| `isPrivate` | bool \| null | 是否私密消息 | +| `privateLocked` | bool \| null | **true = 当前用户无权查看,内容已锁定** | +| `privateHint` | string \| null | 非会员看到的模糊预告文案 | + +`data` 顶层建议前端只关心: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `isVip` | bool | 当前用户是否 VIP | + +> 兼容旧版本客户端,后端可能仍返回 `privateFreeLimit`、`privateUsedToday`、`privateCanViewFree` 等旧字段;当前前端可以忽略。 + +**历史响应示例(非VIP,含一条锁定的私密消息):** +```json +{ + "messages": [ + { + "role": "user", + "type": "text", + "content": "今天好想你", + "id": "msg-1", + "created_at": "2026-06-09T03:00:00Z", + "audioUrl": null, + "voiceUrl": null, + "isPrivate": false, + "privateLocked": false, + "privateHint": null + }, + { + "role": "assistant", + "type": "voice", + "content": "", + "id": "msg-2", + "created_at": "2026-06-09T03:00:05Z", + "audioUrl": "https://.../audio/msg-2.mp3", + "voiceUrl": "https://.../audio/msg-2.mp3", + "isPrivate": true, + "privateLocked": true, + "privateHint": "他好像想说什么悄悄话,解锁会员才能看到…" + } + ], + "total": 2, + "limit": 50, + "offset": 0, + "isVip": false +} +``` + +### 4.2 前端处理 + +``` +拉取 history: + 对每条 privateLocked===true 的消息,渲染为锁定卡片(展示 privateHint)。 + +用户开通 VIP 后: + 重新拉取 history; + 之前被锁住的私密消息会直接返回完整 content,不需要再调额外解锁接口。 +``` --- @@ -342,7 +284,7 @@ if (data.blocked === true && data.blockReason === "daily_limit") { | `messageId` | string | 现有 | 消息 ID | | `isGuest` | bool \| null | 现有 | 是否游客 | | `timestamp` | int | 现有 | Unix 毫秒时间戳 | -| `blocked` | bool \| null | 现有 | 是否每日限流拦截(功能①) | +| `blocked` | bool \| null | 现有 | 是否消息次数限流拦截(功能①) | | `blockReason` | string \| null | 现有 | `"daily_limit"` / `"total_limit"` | | `blockDetail` | object \| null | 现有 | 每日限制:`{ type, usedToday, limit }`;游客总量限制:`{ type, usedTotal, limit }` | | `paywallTriggered` | bool | 新增 | 是否触发图片付费墙(功能②) | @@ -687,7 +629,7 @@ PAYMENT_WEBHOOK_SECRET= - `POST /api/chat/send` 返回 `blocked=true` 且 `blockReason="daily_limit"`:每日免费消息用完。 - `POST /api/chat/send` 返回 `showUpgrade=true`:照片付费墙或其他升级提示。 -- `POST /api/chat/unlock-private` 返回 `showUpgrade=true`:私密消息免费查看次数用完。 +- `GET /api/chat/history` 返回 `privateLocked=true`:这条私密消息当前只有 VIP 可见。 - 用户主动点击会员中心/充值按钮。 ### 8.2 购买流程 @@ -727,7 +669,7 @@ PAYMENT_WEBHOOK_SECRET= | 订单轮询 | 已有 `/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` 或无语音 | +| Elio 回复语音 | `POST /api/chat/send` 已返回 `voiceUrl` / `audioUrl` 字段;`GET /api/chat/history` 的消息项也返回 `type` / `audioUrl`;WS 模式会推 `voice_ready` | TTS 服务可用,且前端按字段播放 | HTTP 首包里通常为空,后台生成后写入 DB;稍后拉 history 可拿到 `audioUrl` | VIP 当前会解除/放宽的功能: @@ -738,21 +680,54 @@ VIP 当前会解除/放宽的功能: --- -## 10. 通话与语音补充 +## 10. Elio 回复语音字段 -近期 `/ws` 增加了通话消息类型,供前端通话 UI 使用: +当前产品口径是 **Elio 可以发语音给用户,用户不需要发语音给 Elio**。因此聊天发送接口只需要前端传文字 `message`,不需要用户音频上传字段。 -| 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 或通话处理错误 | +### 10.1 POST /api/chat/send 中的语音字段 -`call_text` 和 `call_audio` 会以 `force_voice=true` 进入聊天处理流程,目的是让通话场景生成可播放语音。普通文字聊天仍按原有语音门控和配额逻辑处理。 +`POST /api/chat/send` 的 `data` 中包含: + +| 字段 | 说明 | +|---|---| +| `voiceUrl` | Elio 回复语音 URL,新字段 | +| `audioUrl` | 兼容旧客户端,语义同 `voiceUrl` | + +当前 HTTP 模式下,AI 回复文本会立即返回,TTS 语音在后台异步生成,所以首包里通常是: + +```json +{ + "reply": "AI 的回复内容", + "voiceUrl": "", + "audioUrl": "" +} +``` + +后台生成完成后会把语音地址写入 `chat_messages.voice_url`。`GET /api/chat/history` 会把这条消息返回为 `type="voice"`,并带 `audioUrl` / `voiceUrl`。因此前端若走 HTTP 首包,不能假设立刻拿到语音 URL;可以稍后刷新 history 获取。 + +语音消息对应的文字版不需要单独转写接口: + +| 场景 | 前端直接读取的字段 | +|---|---| +| `POST /api/chat/send` 首包 | `reply` | +| `GET /api/chat/history` 单条消息 | `content` | + +### 10.2 WebSocket 模式的语音事件 + +如果前端使用 WebSocket 模式,后端会在分句 TTS 生成完成后推送: + +```json +{ + "type": "voice_ready", + "index": 0, + "voiceUrl": "https://.../audio/xxx.mp3", + "audioUrl": "https://.../audio/xxx.mp3" +} +``` + +前端应优先使用 `voiceUrl` 播放,`audioUrl` 仅作为旧字段兼容。 + +> 注意:当前 `/api/chat/send` 请求体没有用户音频字段;用户侧语音输入不是当前需求,不需要额外转写字段或用户 `audioUrl`。 --- From d5574f1adbb544c7085fd4b3a3b0367668640271 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 17:55:51 +0800 Subject: [PATCH 5/5] fix(payment): guard ezpay redirect persistence --- .../chat/components/pwa-install-overlay.tsx | 38 ++++++----- .../subscription-checkout-button.tsx | 68 ++++++++++++------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx index 477a7a0d..337ca233 100644 --- a/src/app/chat/components/pwa-install-overlay.tsx +++ b/src/app/chat/components/pwa-install-overlay.tsx @@ -26,26 +26,13 @@ export function PwaInstallOverlay() { let mounted = true; const init = async () => { - // 0) PWA 支持 / 安装 双重门禁(两种环境都检查): - // isSupported() 检查 serviceWorker + window(SSR 安全) - // isInstalled() 检查 standalone display-mode(避免给已安装用户再弹) - if (!pwaUtil.isSupported()) return; - if (pwaUtil.isInstalled()) return; - if (BrowserDetector.isInAppBrowser()) return; + const isDev = AppEnvUtil.isDevelopment(); + const shouldShowDialog = await shouldShowPwaInstallDialog(isDev); + if (!shouldShowDialog) return; + // 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。 pwaUtil.prepareInstallPrompt(); - const isDev = AppEnvUtil.isDevelopment(); - - if (!isDev) { - // 生产环境:每日只弹一次(防骚扰) - const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY); - if (!lastResult.success) return; - const today = new Date().toISOString().slice(0, 10); - if (lastResult.data === today) return; // 今天已显示过 - } - // 开发环境:跳过"每日一次"门禁,每次进入都弹,方便 UI 测试 - // 开发环境 1s 后弹(快);生产环境 3.5s 后弹(让用户先看到聊天内容) const delay = isDev ? 1000 : 3500; setTimeout(() => { @@ -63,6 +50,23 @@ export function PwaInstallOverlay() { return