From fb2789f752a024a0eead33d13b7794c1a0972257 Mon Sep 17 00:00:00 2001 From: zhonghua Date: Mon, 20 Apr 2026 14:02:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=95=E6=B4=BB=E5=8A=A8=E7=9B=B4?= =?UTF-8?q?=E8=BE=BE=E8=AF=A6=E6=83=85=E5=B9=B6=E9=9A=90=E8=97=8F=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=EF=BC=9B=E8=A1=A5=E5=85=85C=E7=AB=AF=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E6=9F=A5=E8=AF=A2=E5=AF=B9=E6=8E=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../create/18_C端额度查询接口对接文档_V1.0.md | 412 ++++++++++++++++++ .../src/views/public/Activities.vue | 9 + .../src/views/public/ActivityDetail.vue | 6 +- 3 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 docs/design/create/18_C端额度查询接口对接文档_V1.0.md diff --git a/docs/design/create/18_C端额度查询接口对接文档_V1.0.md b/docs/design/create/18_C端额度查询接口对接文档_V1.0.md new file mode 100644 index 0000000..eea8698 --- /dev/null +++ b/docs/design/create/18_C端额度查询接口对接文档_V1.0.md @@ -0,0 +1,412 @@ +# C 端用户额度查询接口对接文档 V2.1 + +> **接口路径**:`GET /api/v1/quota/user` +> **适用端**:H5 / Android / 小程序 / iOS +> **签名方式**:⚡ **无需 HMAC 签名**(服务端已对此路径加白名单) +> **更新时间**:2026-04-18 +> **变更说明**:V2.1 用户级闸门版——补回 V2.0 误删的 `userCallLimit/userCallRemaining` 两字段,**对齐后端真实校验链路**,避免「前端绿灯进入 → 创作时被拒」的体验断崖 + +--- + +## 0. 为什么有 V2.1(必读) + +V2.0 把字段从 12 个砍到 6 个,初衷是"机构没额度=用户没额度"够用了。但**遗漏了反命题**: + +> **机构有额度 ≠ 用户有额度** + +实际系统里,`QuotaService.checkUserCallLimit(orgId, phone, periodId)` 会按 (机构+手机+周期) 三元组撞 Redis 用户级上限(默认 5 次/用户/周期,可在 `t_org.user_call_limit` 或 `t_config.user_call_limit` 调整)。V2.0 接口不返用户级数据 → 前端拿 `canCreate=true` 进创作页 → 真正的 `POST /creation/image-story` 被 `QUOTA_EXCEEDED` 拒掉 → 用户懵 → 客服压力。 + +V2.1 修复:**接口返回 `userCallLimit/userCallRemaining` + canCreate 决策升级为四闸门**。 + +--- + +## 1. 用途 + +让 C 端用户在**进入创作前**一次拿到三件事:**能不能创作 + 还剩多少 + 不能为什么**。 + +`canCreate` 字段一键决定是否放行创作入口;`summary` 字段是服务端拼好的友好文案,前端直接 Toast 即可,无需自己拼字符串。 + +**典型触发场景**: +- 首页 / 创作中心点击"+"号开始新作品 +- 进入画一画页面前的额度预检 +- 创作历史页"重新创作"按钮的可用性判断 + +**额度范围**:仅 A1/A2/A3 主创作(绘本生成)。A6/A7 角色提取重建有独立 `a5_user_call_limit` 池,不在此接口范围。 + +--- + +## 2. 请求 + +### 2.1 端点 + +``` +GET http://121.40.20.224:8267/api/v1/quota/user +``` + +### 2.2 请求头 + +| Header | 必填 | 说明 | +|--------|------|------| +| `Content-Type` | 否 | GET 无 body,可省略 | +| `X-App-Key` / `X-Signature` / `X-Timestamp` / `X-Nonce` | **否** | **此接口不校验 HMAC 签名,无需带任何签名头** | +| `Authorization: Bearer xxx` | 否 | 不需要 Bearer token | + +### 2.3 Query 参数 + +| 参数 | 类型 | 必填 | 说明 | 示例 | +|------|------|------|------|------| +| `orgId` | string | 是 | 机构 ID | `LESINGLE888888888` | +| `phone` | string | 是 | **登录用户手机号(参与用户级额度计算 + 日志追溯)** | `13800138000` | + +> **⚠️ V2.1 变更**:phone 从"仅日志追溯"升级为"参与额度计算"——它是用户级 Redis 计数器的 key 一部分,必须传**真实登录用户手机号**,不能瞎传。 + +### 2.4 完整请求示例 + +``` +GET /api/v1/quota/user?orgId=LESINGLE888888888&phone=13800138000 HTTP/1.1 +``` + +**curl**: +```bash +curl 'http://121.40.20.224:8267/api/v1/quota/user?orgId=LESINGLE888888888&phone=13800138000' +``` + +--- + +## 3. 响应 + +### 3.1 统一外层结构(与现有 ApiResponse 对齐) + +```json +{ + "code": 200, + "msg": "success", + "data": { ... }, // 见下方 UserQuotaVO + "success": true +} +``` + +### 3.2 `data` 对象 `UserQuotaVO`(V2.1 用户级闸门版,8 字段) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `orgName` | string | 机构名称(用于显示「XX 幼儿园 · 剩余 N 次」) | +| `quotaLimit` | int | **机构总创作配额(A1/A2/A3 共享池)** | +| `quotaRemaining` | int | **机构剩余创作次数** | +| `userCallLimit` | int | **本用户每周期创作上限**。`0` = 未启用上限(不限制单用户) | +| `userCallRemaining` | int | **本用户本周期剩余次数**。`-1` = 不限制;`>=0` = 实际剩余 | +| `canCreate` | boolean | **决策位**:机构剩 > 0 **且** (用户不限 **或** 用户剩 > 0) 才 `true` | +| `reason` | string\|null | 不可创作时的实际情况;`canCreate=true` 时为 `null` | +| `summary` | string | **服务端拼好的用户友好文案**,前端直接 Toast 即可 | + +> **⚠️ 范围说明**:本接口额度仅反映 A1/A2/A3 主创作. +> A6/A7 提取重建额度(`a5_user_call_limit`)、视频生成额度等不在此接口范围. + +### 3.3 `canCreate=false` 的四类原因(按闸门顺序) + +| 闸门 | `reason` 示例 | 含义 | +|------|---------------|------| +| ① 机构存在 | `"机构不存在"` | orgId 在数据库中查不到 | +| ② 机构授权 | `"机构「XX 幼儿园」未授权"` | 机构记录存在但未开通授权 | +| ③ 机构总额度 | `"机构创作额度已耗尽(共 1000 次)"` | quotaA - quotaAUsed ≤ 0 | +| ④ 用户级额度 | `"本用户本周期创作额度已用完(共 5 次/周期)"` | user_call_limit > 0 且本用户已达上限 | + +### 3.4 响应示例 + +#### ✅ 通过场景 A:用户级有上限 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "orgName": "XX 幼儿园", + "quotaLimit": 1000, + "quotaRemaining": 832, + "userCallLimit": 5, + "userCallRemaining": 3, + "canCreate": true, + "reason": null, + "summary": "本人本周期剩余 3 次(机构总剩 832 次)" + }, + "success": true +} +``` + +#### ✅ 通过场景 B:用户级不限制(userCallLimit=0) + +```json +{ + "data": { + "orgName": "XX 幼儿园", + "quotaLimit": 1000, + "quotaRemaining": 832, + "userCallLimit": 0, + "userCallRemaining": -1, + "canCreate": true, + "reason": null, + "summary": "可继续创作,剩余 832 次" + } +} +``` + +#### ❌ 拒绝:机构不存在 + +```json +{ + "data": { + "orgName": null, + "quotaLimit": 0, + "quotaRemaining": 0, + "userCallLimit": 0, + "userCallRemaining": -1, + "canCreate": false, + "reason": "机构不存在", + "summary": "机构「LESINGLE999999999」不存在,请联系管理员核对机构 ID" + } +} +``` + +#### ❌ 拒绝:机构未授权 + +```json +{ + "data": { + "orgName": "XX 幼儿园", + "quotaLimit": 0, + "quotaRemaining": 0, + "userCallLimit": 0, + "userCallRemaining": -1, + "canCreate": false, + "reason": "机构「XX 幼儿园」未授权", + "summary": "机构尚未授权,请联系管理员开通服务" + } +} +``` + +#### ❌ 拒绝:机构额度已耗尽 + +```json +{ + "data": { + "orgName": "XX 幼儿园", + "quotaLimit": 1000, + "quotaRemaining": 0, + "userCallLimit": 5, + "userCallRemaining": 2, + "canCreate": false, + "reason": "机构创作额度已耗尽(共 1000 次)", + "summary": "机构创作额度已用完,请联系管理员补充额度" + } +} +``` + +#### ❌ 拒绝:本用户本周期额度已用完(V2.1 新增场景) + +```json +{ + "data": { + "orgName": "XX 幼儿园", + "quotaLimit": 1000, + "quotaRemaining": 832, + "userCallLimit": 5, + "userCallRemaining": 0, + "canCreate": false, + "reason": "本用户本周期创作额度已用完(共 5 次/周期)", + "summary": "您本周期创作次数已用完(共 5 次),请等待下个周期或联系管理员" + } +} +``` + +> 这正是 V2.0 不返用户级字段时**会被坑的场景**:机构剩 832 次,用户却已用完个人 5 次额度。V2.0 错给 `canCreate=true`,V2.1 正确给 `false`。 + +--- + +## 4. 前端使用建议(推荐流程) + +> ### 🟢 核心原则:**通过 / 失败 两种场景都必须把 `vo.summary` 主动展示给用户** +> +> `summary` 是服务端拼好的**唯一信息源**,前端不要自己拼词、不要丢弃。 +> - **失败**:Toast / Dialog 拦截,不放行 +> - **通过**:同样 Toast / 顶部条幅 / 入口角标提示"还剩 X 次",让用户**心里有数** +> +> 不要把通过场景的 `summary` 默默吞掉——用户点"+"号是个**心智决策点**,必须告诉他"你还剩 3 次",不然下次进来才发现没了,体验断崖。 + +``` +点击 "+" 创作入口 + ↓ +弹"额度查询中..."转圈对话框(防止用户连点) + ↓ +GET /api/v1/quota/user?orgId=xxx&phone=登录手机号 + ↓ +设置 3s 超时兜底(超时直接放行,后端创作提交时还会强校验) + ↓ +┌─ canCreate=true → 关闭转圈, Toast(vo.summary) 提示"剩余 N 次", 进画画页 +├─ canCreate=false → 关闭转圈, Dialog(vo.summary) 拦截, 不进入 +├─ HTTP 异常 → 关闭转圈, 放行(兜底体验, 创作提交时再次校验) +└─ 超时 3s → 关闭转圈, 放行(同上) +``` + +### 4.1 通过场景 — summary 直接 Toast(推荐) + +```js +// 不管通过还是失败, summary 都展示给用户 +// 通过场景的 summary 长这样: "本人本周期剩余 3 次(机构总剩 832 次)" +// 或 "可继续创作,剩余 832 次"(用户级不限时) +if (vo.canCreate) { + Toast.show(vo.summary, { duration: 2000 }) // 2s 轻提示, 不阻塞进入创作页 + navigateToSketchBook() +} else { + Dialog.alert(vo.summary, { title: '无法创作', confirmText: '我知道了' }) + // 不进入创作页 +} +``` + +### 4.2 主入口角标 — 持续提示剩余次数(推荐 UI 增强) + +```js +// "+号" 创作入口旁边挂一个角标 / 副标题, 让用户随时看到额度 +if (vo.canCreate) { + if (vo.userCallLimit > 0 && vo.userCallRemaining >= 0) { + // 有用户级上限:显示双数字, 让用户清楚个人剩余 + showBadge(`本人剩 ${vo.userCallRemaining}/${vo.userCallLimit}`) + showSubtitle(`${vo.orgName} · 机构剩 ${vo.quotaRemaining} 次`) + } else if (vo.quotaLimit > 0) { + showBadge(`剩余 ${vo.quotaRemaining} 次`) + showSubtitle(vo.orgName) + } else { + showSubtitle(vo.orgName) // 没启用 quotaLimit 时不显示数字 + } +} + +// 进度条颜色阈值(用户级优先, 因为用户感知最强) +let ratio +if (vo.userCallLimit > 0) { + ratio = vo.userCallRemaining / vo.userCallLimit +} else if (vo.quotaLimit > 0) { + ratio = vo.quotaRemaining / vo.quotaLimit +} else { + ratio = 1 +} +if (ratio > 0.3) color = 'green' +else if (ratio > 0.1) color = 'orange' +else color = 'red' +``` + +### 4.3 反例 — 不要这么做 ❌ + +```js +// ❌ 反例 1:通过场景默默放行, 用户不知道还剩多少 +if (vo.canCreate) { + navigateToSketchBook() // 没提示, 体验断崖 +} + +// ❌ 反例 2:自己拼词, 丢掉服务端 summary +if (vo.canCreate) { + Toast.show(`还剩 ${vo.quotaRemaining} 次`) // 漏掉用户级数字, 信息不全 +} + +// ❌ 反例 3:只在 canCreate=false 时提示, 通过场景静默 +// → 用户连续创作 4 次后, 第 5 次突然被拒, 完全没有预警 + +// ❌ 反例 4:拿 reason 字段当用户文案展示 +if (!vo.canCreate) { + Dialog.alert(vo.reason) // 错!reason 是系统视角("本用户...") + // 应该用 summary("您的本周期创作次数已用完...") +} +``` + +### 4.4 ⭐ `reason` vs `summary` 分工速查表(前端必读) + +> **底层逻辑**:服务端给两个字段是**故意分工**,**前端必须用对**: +> - `reason` = **系统视角**,给运维 / 客服 / 日志 / 埋点用,**不要给用户看** +> - `summary` = **用户视角**("您"开头的人称话术),**直接展示给用户**,前端**不要拼词、不要加工** + +| 场景 | `canCreate` | `reason`(系统视角,日志用) | `summary`(用户视角,直接展示) | 前端展示方式 | +|------|-------------|---------------------------|-----------------------------|------------| +| ✅ 通过 + 用户级有上限 | true | `null` | "本人本周期剩余 3 次(机构总剩 832 次)" | Toast 2s + 入口角标 | +| ✅ 通过 + 用户级不限 | true | `null` | "可继续创作,剩余 832 次" | Toast 2s + 入口角标 | +| ❌ 机构不存在 | false | "机构不存在" | "机构「LESINGLE999」不存在,请联系管理员核对机构 ID" | Dialog 拦截 | +| ❌ 机构未授权 | false | "机构「XX 幼儿园」未授权" | "机构尚未授权,请联系管理员开通服务" | Dialog 拦截 | +| ❌ 机构额度耗尽 | false | "机构创作额度已耗尽(共 1000 次)" | "机构创作额度已用完,请联系管理员补充额度" | Dialog 拦截 | +| ❌ **本用户额度用完** | false | "本用户本周期创作额度已用完(共 5 次/周期)" | **"您本周期创作次数已用完(共 5 次),请等待下个周期或联系管理员"** | Dialog 拦截 | + +**关键差异举例**(V2.1 新增的用户级耗尽场景): + +``` +reason : "本用户本周期创作额度已用完(共 5 次/周期)" ← 系统视角, 给客服查问题用 +summary : "您本周期创作次数已用完(共 5 次),请等待下个周期或联系管理员" ← 用户视角, 直接 Dialog +``` + +**Owner 意识**:summary 文案、用词、人称、引导动作("请联系管理员"/"请等待下个周期")**全部在服务端拼好**,前端只负责"原文展示"。如果你想改文案 → 提需求让后端改 → 后端在 `QuotaQueryController.buildPassSummary` / 各闸门分支统一改 → 多端自动同步,**单一信息源原则**。 + +--- + +## 5. 注意事项 + +1. **不要带 HMAC 签名头**:服务端 `HmacAuthenticationFilter` 已对此路径加白名单,带签名头不会报错但浪费计算。 +2. **额度仅含 A1/A2/A3**:A6 角色提取、A7 角色重建、视频生成有各自的额度池,本接口**不反映**这些。 +3. **三层兜底语义**:机构不存在 / 机构未授权 / 机构空 / 用户空 任一不过即 `canCreate=false`。 +4. **phone 必须是真实登录用户**:它是用户级 Redis key 的一部分,传错号码 = 查到错的人的额度。 +5. **没有真正的鉴权**:任何人知道 orgId+phone 即可查到额度数字。如果未来要收紧,可改成需要 Bearer token,**接口语义不变**。 +6. **不修改任何状态**:纯查询接口,重复调用幂等。 +7. **创作提交时仍会强校验**:本接口返回 `canCreate=true` 不代表 100% 能创建成功,A1/A2/A3 提交时会再次原子扣减额度(机构 + 用户两层都校验)。前端不应依赖此接口做"扣减预占"。 +8. **`userCallLimit=0` 含义**:服务端兜底未启用单用户上限——即同机构不限制单用户次数(早期未配置时默认值)。 + +--- + +## 6. 错误码 + +| HTTP | code | 说明 | +|------|------|------| +| 200 | 200 | 业务正常(即使 `canCreate=false` 也是 200) | +| 400 | 40001 | orgId 或 phone 缺失 | +| 500 | 50000 | 服务端异常(数据库不可达) | + +--- + +## 7. V2.0 → V2.1 迁移说明(前端适配) + +| V2.0 字段 | V2.1 字段 | 处理 | +|-----------|-----------|------| +| `orgName` | `orgName` | 不变 ✓ | +| `quotaLimit` | `quotaLimit` | 不变 ✓ | +| `quotaRemaining` | `quotaRemaining` | 不变 ✓ | +| — | `userCallLimit` | **新增**:本用户每周期创作上限(0=不限) | +| — | `userCallRemaining` | **新增**:本用户本周期剩余次数(-1=不限) | +| `canCreate` | `canCreate` | 决策位语义升级(双闸:机构 ✓ + 用户 ✓) | +| `reason` | `reason` | 新增 1 类原因「本用户本周期创作额度已用完」 | +| `summary` | `summary` | 通过场景拼词升级,含本人剩余次数 | + +**Android 端代码免改条件**:原代码只用 `isCanCreate()` + `getSummary()` 两字段,可平滑兼容。 +**Android 端推荐适配**:补显示 `getUserCallRemaining()`,让用户清楚个人剩余次数。 + +--- + +## 8. 服务端校验链路(运维参考) + +``` +GET /api/v1/quota/user?orgId=X&phone=P + ↓ +QuotaQueryController.getUserQuota + ├─ 闸 1: orgMapper.selectOne(orgId) → 机构存在? + ├─ 闸 2: org.authorized == 1 → 已授权? + ├─ 闸 3: org.quotaA - quotaAUsed > 0 → 机构有额度? + └─ 闸 4: QuotaService.getUserCallRemaining(orgId, phone, periodId) + ├─ resolveUserCallLimit(orgId) // org.user_call_limit 优先, 兜底 t_config + └─ Redis GET aicreate:quota:user:{orgId}:{phone}:{periodId} + → limit - used = remaining +``` + +**用户级 Redis Key 格式**:`aicreate:quota:user:{orgId}:{phone}:{periodId}` +**TTL**:跟随活动周期,不会无限增长 + +--- + +## 9. 变更历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| V1.0 | 2026-04-18 | 首次发布(12 字段) | +| V2.0 | 2026-04-18 | 简化为 6 字段,只保留 A1/A2/A3 主创作额度;reason 改自然语言 | +| V2.1 | 2026-04-18 | 补回 `userCallLimit`/`userCallRemaining`(V2.0 误删),canCreate 升级双闸,对齐后端真实校验链路 | diff --git a/lesingle-creation-frontend/src/views/public/Activities.vue b/lesingle-creation-frontend/src/views/public/Activities.vue index 7da9759..cbace6c 100644 --- a/lesingle-creation-frontend/src/views/public/Activities.vue +++ b/lesingle-creation-frontend/src/views/public/Activities.vue @@ -94,6 +94,15 @@ const fetchActivities = async () => { pageSize: pageSize.value, keyword: keyword.value || undefined, }) + // 全库仅 1 个公开活动时直接进入详情,避免多一层列表 + if (res.total === 1 && res.list.length === 1) { + await router.replace({ + name: "PublicActivityDetail", + params: { id: String(res.list[0].id) }, + query: { soleActivity: "1" }, + }) + return + } activities.value = res.list total.value = res.total } catch { diff --git a/lesingle-creation-frontend/src/views/public/ActivityDetail.vue b/lesingle-creation-frontend/src/views/public/ActivityDetail.vue index fd55d53..7d76187 100644 --- a/lesingle-creation-frontend/src/views/public/ActivityDetail.vue +++ b/lesingle-creation-frontend/src/views/public/ActivityDetail.vue @@ -7,7 +7,7 @@ {{ activity.contestName?.charAt(0) }}
- + 返回
{{ stageLabel }}
@@ -337,6 +337,10 @@ import dayjs from 'dayjs' const route = useRoute() const router = useRouter() + +/** 活动大厅仅 1 条时列表页 replace 会带 soleActivity=1,无列表可回,不显示返回 */ +const showHeroBack = computed(() => route.query.soleActivity !== "1") + const activity = ref(null) const activeTab = ref('info') const children = ref([])