feat: 单活动直达详情并隐藏返回;补充C端额度查询对接文档

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-20 14:02:38 +08:00
parent fe210b52ee
commit fb2789f752
3 changed files with 426 additions and 1 deletions

View File

@ -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 升级双闸,对齐后端真实校验链路 |

View File

@ -94,6 +94,15 @@ const fetchActivities = async () => {
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: keyword.value || undefined, 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 activities.value = res.list
total.value = res.total total.value = res.total
} catch { } catch {

View File

@ -7,7 +7,7 @@
<span>{{ activity.contestName?.charAt(0) }}</span> <span>{{ activity.contestName?.charAt(0) }}</span>
</div> </div>
<div class="hero-overlay"> <div class="hero-overlay">
<a-button shape="round" size="small" @click="$router.back()" class="back-btn"> <a-button v-if="showHeroBack" shape="round" size="small" @click="$router.back()" class="back-btn">
<arrow-left-outlined /> 返回 <arrow-left-outlined /> 返回
</a-button> </a-button>
<div class="hero-badge">{{ stageLabel }}</div> <div class="hero-badge">{{ stageLabel }}</div>
@ -337,6 +337,10 @@ import dayjs from 'dayjs'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
/** 活动大厅仅 1 条时列表页 replace 会带 soleActivity=1无列表可回不显示返回 */
const showHeroBack = computed(() => route.query.soleActivity !== "1")
const activity = ref<PublicActivityDetail | null>(null) const activity = ref<PublicActivityDetail | null>(null)
const activeTab = ref('info') const activeTab = ref('info')
const children = ref<any[]>([]) const children = ref<any[]>([])