Merge branch 'dev' into test

This commit is contained in:
2026-06-23 17:56:11 +08:00
9 changed files with 271 additions and 266 deletions
+85 -110
View File
@@ -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,20 +43,21 @@
## 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 不受限**
- 生产环境:游客每天最多免费发送 **30** 条消息,游客累计最多 **50** 条消息;注册的非 VIP 用户每天最多免费发送 **30** 条消息。
- 预发环境:上述数量为生产的 1/10,即游客每天 **3** 条、累计 **5** 条;注册非 VIP 每天 **3**
- **VIP 不受限**。
- 超限后,`POST /api/chat/send` 返回 `blocked=true``reply` 为空,前端据此弹开通会员提示。
### 触发时的响应(data 字段)
@@ -95,9 +96,14 @@ 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` 判断。
> 注:消息次数限制当前用 `blocked/blockReason/blockDetail` 表达(沿用既有字段)。这与 `showUpgrade` 不冲突——限流场景 `showUpgrade=false`,前端通过 `blocked` 判断。
---
@@ -181,10 +187,9 @@ if (data.blocked === true && data.blockReason === "daily_limit") {
- AI 回复中若包含私密/亲密/大尺度内容,后端**异步**判定并给该消息打标 `is_private=true`,生成模糊预告 `private_hint`
- 用户拉取历史时:
- **VIP** → 正常看到完整内容
- **VIP** → 所有历史私密消息直接返回完整内容;之前锁住的内容也会一起解开,后续也不再锁
- **非VIP** → 私密消息内容被屏蔽,返回 `privateLocked=true` + `privateHint`,前端展示**锁定卡片**。
- 非VIP 每天有 **1** 次免费解锁额度(`FREE_DAILY_PRIVATE_VIEWS=1`UTC 日切)
- 解锁通过 `POST /api/chat/unlock-private` 完成;免费额度用尽则 `showUpgrade=true`,前端弹开通会员提示。
- 当前产品**没有单条私密消息解锁流程**。是否展示完整内容,只取决于当前用户是不是 VIP
### 4.1 GET /api/chat/history —— 历史含锁定标记
@@ -193,21 +198,23 @@ if (data.blocked === true && data.blockReason === "daily_limit") {
| 字段 | 类型 | 说明 |
|------|------|------|
| `role` | string | `user` / `assistant` |
| `content` | string | 消息内容;**私密且未解锁时为空字符串** |
| `id` | string | 消息 ID(解锁时作为 messageId 传入) |
| `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` 顶层新增的配额状态字段
`data` 顶层建议前端只关心
| 字段 | 类型 | 说明 |
|------|------|------|
| `isVip` | bool | 当前用户是否 VIP |
| `privateFreeLimit` | int | 每日免费查看上限(当前为 1) |
| `privateUsedToday` | int | 今日已用次数 |
| `privateCanViewFree` | bool | 今日是否还有免费额度(VIP 恒为 true) |
> 兼容旧版本客户端,后端可能仍返回 `privateFreeLimit`、`privateUsedToday`、`privateCanViewFree` 等旧字段;当前前端可以忽略。
**历史响应示例(非VIP,含一条锁定的私密消息):**
```json
@@ -215,18 +222,24 @@ if (data.blocked === true && data.blockReason === "daily_limit") {
"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": "他好像想说什么悄悄话,解锁会员才能看到…"
@@ -235,90 +248,19 @@ if (data.blocked === true && data.blockReason === "daily_limit") {
"total": 2,
"limit": 50,
"offset": 0,
"isVip": false,
"privateFreeLimit": 1,
"privateUsedToday": 0,
"privateCanViewFree": true
"isVip": false
}
```
### 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" }
```
### 前端处理
### 4.2 前端处理
```
拉取 history
对每条 privateLocked===true 的消息,渲染为锁定卡片(展示 privateHint)。
用户点击锁定卡片 → 调 POST /api/chat/unlock-private { messageId }
if (resp.unlocked === true) 用 resp.content 替换卡片,显示完整内容
else if (resp.showUpgrade === true) 弹"开通会员查看更多私密消息"提示
用户开通 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=<MQ 消息签名密钥,可空>
- `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=<MQ 消息签名密钥,可空>
| 订单轮询 | 已有 `/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`。
---
+13
View File
@@ -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 (
<MobileShell>
<div className={styles.shell}>
@@ -176,6 +187,8 @@ export function ChatScreen() {
{showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner
title={messageLimitTitle}
ctaLabel={messageLimitCta}
onUnlock={handleMessageLimitUnlock}
/>
) : (
<ChatInputBar disabled={state.isReplyingAI} />
@@ -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);
+21 -17
View File
@@ -26,26 +26,13 @@ export function PwaInstallOverlay() {
let mounted = true;
const init = async () => {
// 0) PWA 支持 / 安装 双重门禁(两种环境都检查):
// isSupported() 检查 serviceWorker + windowSSR 安全)
// 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 <div className={styles.hidden} aria-hidden="true" />;
}
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
if (!pwaUtil.isSupported()) return false;
if (pwaUtil.isInstalled()) return false;
if (BrowserDetector.isInAppBrowser()) return false;
// 开发环境:跳过"每日一次"门禁,每次进入都弹,方便 UI 测试。
if (isDevelopment) return true;
// 生产环境:每日只弹一次(防骚扰)。
const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY);
if (!lastResult.success) return false;
const today = new Date().toISOString().slice(0, 10);
return lastResult.data !== today;
}
function showPwaDialog() {
if (typeof document === "undefined") return;
// 记录显示时间
@@ -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 {
@@ -63,10 +63,12 @@ export function SubscriptionCheckoutButton({
}
if (isEzpay) {
void redirectToEzpay({
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
return;
}
@@ -144,10 +146,14 @@ export function SubscriptionCheckoutButton({
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void redirectToEzpay({
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
};
@@ -235,47 +241,57 @@ interface RedirectToEzpayInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: "vip" | "voice";
onFailed: (errorMessage: string) => void;
}
async function redirectToEzpay({
async function launchEzpayRedirect({
orderId,
paymentUrl,
subscriptionType,
onFailed,
}: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] redirectToEzpay START", {
log.debug("[subscription-checkout] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrl,
});
if (orderId) {
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[subscription-checkout] pending ezpay order save skipped", {
subscriptionType,
paymentUrl,
errorMessage,
});
onFailed(errorMessage);
return;
}
const saveResult = await PendingPaymentOrderStorage.setPendingOrder({
orderId,
payChannel: "ezpay",
subscriptionType,
createdAt: Date.now(),
});
if (Result.isOk(saveResult)) {
log.debug("[subscription-checkout] pending ezpay order saved", {
orderId,
subscriptionType,
});
} else {
if (Result.isErr(saveResult)) {
const errorMessage =
"Could not save payment order before opening Ezpay. Please try again.";
log.error("[subscription-checkout] pending ezpay order save failed", {
orderId,
subscriptionType,
error: saveResult.error,
});
}
} else {
log.warn("[subscription-checkout] skip pending ezpay order save: missing orderId", {
subscriptionType,
paymentUrl,
});
onFailed(errorMessage);
return;
}
log.debug("[subscription-checkout] redirectToEzpay NOW", {
log.debug("[subscription-checkout] pending ezpay order saved", {
orderId,
subscriptionType,
});
log.debug("[subscription-checkout] launchEzpayRedirect NOW", {
orderId,
subscriptionType,
paymentUrl,
@@ -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;
}
@@ -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 (
@@ -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],
);