Files
FruitServer/docs/api-phase1.md
T

1017 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第一阶段接口清单(后台管理 + 客户端)
> 版本 v1.0 · 配套 [PRD](./prd-phase1.md) 与 [后端设计](./backend-design-phase1.md)
> 状态说明:**已实现** = 代码已落地可联调;**下一批** = 表结构与契约已冻结,代码待补。
## 0. 通用约定
**服务入口**:后台管理 `fruitshorts-admin-api` 只暴露 `/admin/**`(8080);
客户端 `fruitshorts-app-api` 只暴露 `/app/**` 与媒资 `/files/**`(8081)。
**统一响应体**
```json
{ "code": 0, "message": "ok", "data": {} }
```
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
**ID 字段一律以字符串下发(2026-10-16 J1 起,全局生效)**
所有 ID 字段(主键与外键,例如 `id` / `dramaId` / `episodeId` / `userId` / `orderId` …)
在 JSON 里都是**字符串**,不是数字:
```json
{ "code": 0, "message": "ok", "data": { "id": "2111012753262120962", "dramaId": "5" } }
```
原因:S0 之后主键是雪花 id,19 位十进制数已超过 JS 的 `Number.MAX_SAFE_INTEGER`
(`9007199254740991`)。裸数字下发会被浏览器/IEEE754 客户端四舍五入
(实测库里的 `2111012753262120962` 在浏览器里变成 `2111012753262121000`),
页面再拿它回查就是 `200002 数据不存在`。
约定与边界:
- **id 回传时按字符串传**(路径变量 / 查询参数 / 请求体都接受字符串,服务端自己转 `Long`);
- 实现方式:服务端**全局**把 `Long` / `long` 序列化成字符串(`fruitshorts-framework` 的
`JacksonLongToStringConfig`),**各模块不再逐字段加注解**;
- **例外:分页 `total` 仍是数字** —— 它是条数不是 id,前端分页组件要数字;
`Integer` / `int` 字段(`pageIndex` / `pageSize` / 各类计数如 `totalEpisodes`、状态码 `code`)
也**保持数字**。
**鉴权**:`Authorization: Bearer <token>`。
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
**设备标识**:客户端建议所有请求都带 `X-Device-Id`,用于下载授权、并发播放限制与风控。
**主要错误码**
| code | 含义 | 客户端处理 |
| --- | --- | --- |
| 0 | 成功 | — |
| 400 | 参数校验失败 | 提示 message |
| 401 | 未登录或登录过期 | 用 refresh token 换新 token,失败则跳登录 |
| 403 | 无权限 | 提示无权限 |
| 429 | 请求过于频繁 | 提示稍后再试(验证码频控走这里) |
| 100000 | 账号或密码错误 | 提示 |
| 100001 | 账号已停用 | 展示封禁申诉入口 |
| 100002 | 金币余额不足 | 拉起充值 |
| 100003 | 剧集未发布或不存在 | 返回上一页 |
| 100005 | 订单已支付或已关闭 | 刷新订单状态 |
| 200002 | 数据不存在 | 提示 |
| 200003 | 当前状态不允许该操作 | 提示 |
## 1. 客户端接口(/app/**)
### 1.1 账号(已实现)
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/app/auth/otp` | 匿名 | 发送验证码。`scene` = REGISTER/LOGIN/RESET_PWD/BIND |
| POST | `/app/auth/register` | 匿名 | 手机号/邮箱注册,返回令牌 |
| POST | `/app/auth/login` | 匿名 | 验证码或密码登录 |
| POST | `/app/auth/refresh` | 匿名 | 用 refresh token 换新令牌并轮换 |
| POST | `/app/auth/logout` | 可选 | 登出;`allDevices=true` 吊销全部会话 |
发送验证码:
```json
POST /app/auth/otp
{ "scene": "REGISTER", "targetType": "PHONE", "target": "08123456789", "deviceId": "d-1" }
```
手机号会自动规范化成 E.164(印尼本地 `08xx` → `+62xxx`)。频控:同号 60 秒一次、每日 10 次,
同 IP 每小时 20 次;连续输错 5 次该验证码作废。
注册:
```json
POST /app/auth/register
{
"targetType": "PHONE", "target": "08123456789", "code": "123456",
"password": "可选,设置后可用密码登录",
"nickname": "可选",
"deviceId": "d-1", "platform": "IOS", "appVersion": "1.0.0",
"agreementVersion": "2026-10-01", "marketingConsent": false
}
```
**邮箱 + 密码注册(C 端第一批口径)**
```json
POST /app/auth/register
{
"targetType": "EMAIL", "target": "User@Example.com ", "password": "nonton2026",
"deviceId": "d-1", "countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 规则 | 口径 |
| --- | --- |
| `targetType=EMAIL` | **`code` 可以不传**(邮箱+密码直接注册);传了就走一次验证码校验(邮箱验证能力保留) |
| `targetType=PHONE` | 维持原状:`code` **必填** |
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
注册频控阈值存储在 `sys_config`,可在后台「系统配置」修改:
```text
app.register.max-per-ip-per-day
app.register.max-per-device-per-day
```
运行时优先读取 `sys_config`。键缺失或值不是整数时,回退 `application.yml`
中的 `fruitshorts.auth.register-max-per-ip-per-day` /
`fruitshorts.auth.register-max-per-device-per-day`,缺省为 5 / 3;
值小于等于 0 表示对应维度不限制。
开发种子配置为 0 / 0,生产建议恢复为 5 / 3。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
响应(登录、刷新同结构):
```json
{
"code": 0, "message": "ok",
"data": {
"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "tokenType": "Bearer",
"newUser": true,
"user": { "uid": "U8KQ2ZP1", "nickname": "User8KQ2ZP", "status": "normal",
"countryCode": "ID", "language": "id-ID", "currency": "IDR",
"maskedTarget": "+628****789", "vipLevel": 0, "vipExpireAt": null }
}
}
```
### 1.2 个人中心(已实现)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/me` | 当前用户资料 |
| PUT | `/app/me` | 修改昵称/头像/性别/生日/语言/时区/区域 |
| GET | `/app/me/devices` | 登录设备列表 |
| GET | `/app/me/export` | 数据导出(商店与隐私法规要求) |
| POST | `/app/me/deactivate` | 申请注销,进入 15 天冷静期并立即登出全部设备 |
| POST | `/app/me/deactivate/cancel` | 冷静期内撤销注销 |
### 1.3 内容浏览(已实现)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
>
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。
> `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
#### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配,
可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
#### 短剧详情响应(`GET /app/dramas/{id}`)
```json
{
"code": 0, "message": "ok",
"data": {
"id": "5",
"title": "命运的相遇",
"synopsis": "一次意外的相遇,让两个世界的人走到一起。",
"cover": "/files/drama/202610/cover.jpg",
"genre": "都市 · 情感",
"category": "都市",
"totalEpisodes": 80,
"freeEpisodes": 10,
"unlockCoin": 30,
"isHot": true,
"viewCount": "12400",
"likeCount": "3200",
"favoriteCount": "1800",
"commentCount": "96",
"episodes": [
{
"id": "2111012753262120962",
"number": 1,
"title": "第 1 集",
"duration": "03:00",
"durationSec": 180,
"isFree": true,
"locked": false,
"unlockCoin": 0,
"likeCount": "45",
"commentCount": "8",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d..."
}
]
}
}
```
详情里的互动数量:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` |
| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 |
| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 |
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
| `episodes[].thumbnailsUrl` | String / null | 本集雪碧图 WebVTT 时间轴签名地址;没有 READY 产物时为 `null` |
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
客户端需要转成数字再展示(与 `viewCount` 相同)。
### 1.4 播放(已实现,链路已重做)
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| GET | `/app/episodes/{id}/play` | 可选 | 签发播放票据与签名地址 |
```json
{
"code": 0, "message": "ok",
"data": {
"episodeId": 12, "dramaId": 5, "number": 1, "title": "第 1 集", "durationSec": 180,
"accessible": true,
"accessType": "FREE",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"],
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...",
"unlockCoin": 0,
"previewSeconds": 0,
"likeCount": "45",
"commentCount": "8"
}
}
```
`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(需解锁,
此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(还没上传片源或未转码完成)。
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
`thumbnailsUrl` 指向 WebVTT:cue 链接为同目录相对雪碧图
`sprite_XXX.jpg#xywh=x,y,w,h`。有 READY `media_thumbnail` 时返回签名地址,
无产物时为 `null`;该字段不影响 `playUrl`、`qualities` 和 HLS/原文件回落。
### 1.5 观看记录与收藏(已实现)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) |
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
| GET | `/app/watch/recent?limit=20` | 最近观看 |
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
| DELETE | `/app/watch/progress` | 清空记录 |
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) |
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
这样用户换设备不会看到进度倒退。
### 1.6 离线下载(已实现)
服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 ——
这样并发下载不会打到应用服务器,服务端只记账(进度、配额、有效期)。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/downloads/policy` | 策略与配额:单设备上限、授权天数、仅 Wi-Fi、清晰度上限、已用/剩余 |
| PUT | `/app/downloads/settings` | 偏好设置:仅 Wi-Fi 下载、最高清晰度 |
| POST | `/app/downloads` | 申请下载授权(同剧集重复申请幂等,只刷新有效期) |
| GET | `/app/downloads` | 下载列表(授权 + 任务进度,可直接渲染) |
| GET | `/app/downloads/{episodeId}/url` | 断点续传前重新取地址(授权不变,地址是短时效的) |
| POST | `/app/downloads/{episodeId}/progress` | 上报下载进度,用于断点续传与跨端展示 |
| DELETE | `/app/downloads/{episodeId}` | 删除单条下载(吊销授权) |
| DELETE | `/app/downloads` | 清空当前设备全部下载,返回清除条数 |
设备标识优先取请求体 `deviceId`,其次取请求头 `X-Device-Id`。
授权默认 30 天有效,过期由每日定时任务置为 `EXPIRED`;被吊销或过期的授权客户端下次联网即失效。
需要解锁的剧集走权益判定,未解锁不允许下载(当前内容均为免费集,付费链路见 09)。
配额与有效期由 `fruitshorts.download.*` 配置,通过 `policy` 下发,客户端不要写死。
### 1.7 互动与内容治理(已实现)
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
审核策略由 `fruitshorts.interaction.comment-audit-mode` 控制:
`MACHINE_FIRST`(机审通过即发布,当前默认)或 `PRE_REVIEW`(全部先审后发,生产建议)。
**可见性**:匿名与其他人只看得到 `published`;自己待审的评论也会返回,便于展示"审核中"。
评论列表禁止重复提交(同一剧集每分钟 5 条,可配)。
### 1.8 金币与权益(已实现)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/products` | 充值商品列表(只返回上架商品,`type` 可选 `COIN_PACK`/`VIP`) |
| POST | `/app/orders` | 创建充值订单(`body` 传 `productId`,返回 `CREATED` 订单,等待支付) |
| GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
> `GET /app/products` 只返回 `status=1`(上架)的商品,按 `sort` 升序、`id` 降序。
> `POST /app/orders` 请求体 `{ "productId": 123 }`,商品下架时返回 `400 商品已下架`;
> 订单 `coinAmount = coinAmount + bonusCoin`(与商店票据口径一致),状态为 `CREATED`。
> VIP 商品下单后交付时发放 `TYPE_VIP` 权益(`dramaId=0`、`episodeId=0`,永久有效),
> 播放/下载的权益判定在 `EntitlementPort` 中增加「存在有效 VIP 权益即放行」的判定。
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
**`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
请求体:
```json
{ "platform": "GOOGLE_PLAY", "productId": "coin_550", "receipt": "<purchaseToken>" }
```
| 字段 | 说明 |
| --- | --- |
| `platform` | `GOOGLE` / `GOOGLE_PLAY`(两者等价,入库统一记 `GOOGLE`);Apple 走 B1-2 |
| `productId` | 商店商品 id(Google Play 的 `purchases.products.get` 必需),与本平台 `product.store_product_id` 对得上才发权益 |
| `receipt` | Google Play 的 `purchaseToken` |
行为与错误码:
| 场景 | 返回 |
| --- | --- |
| 校验通过 | `code=0`,`data` 是订单(`status=DELIVERED`、`coinAmount`=商品金币+赠送、`storeTransactionId`=Google `orderId`) |
| 同一票据**重复**提交(同用户) | `code=0` 返回**既有订单**,不重复发币、不重复建单 |
| 票据已绑定**其他账号** | `403 该票据已绑定其他账号,不能重复使用`(防跨用户重放) |
| 票据无效 / 未购买 / 商品不一致 / 缺 orderId | `200003` + 具体原因;**不产生订单、不发权益** |
| 商品未在后台配置 | `200002 该商品未在后台配置` |
| 平台不在支持范围 | `400 不支持的商店平台` |
| 商店未配置(默认 `verifier=disabled`) | `200003 Apple / Google 商店校验尚未配置`(与 B1-1 之前的行为一致) |
幂等键是 `(platform, store_transaction_id)`(DB 唯一索引 `uk_orders_store_txn`),
重复/并发提交由数据库兜底;票据审计写 `store_receipt`(唯一索引 `uk_store_receipt(platform, transaction_id)`),
B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service account JSON)走配置/环境变量,不入库。
**恢复购买**:客户端换设备后重新提交同一个 `purchaseToken`(或调用 `GET /app/orders` 拉已购列表),
服务端按 `(platform, storeTransactionId)` 识别"权益已发过",**返回既有订单、不重复发币**;
若商店侧有购买而本地没有订单(例如换机前没同步),会**按校验结果补单落库**再发放,不需要人工介入。
### 1.9 创作者入驻、启动配置与公告(C 端,已实现)
> 说明 1:本节原来把 `/app/orders`、`/app/wallet`、`/app/episodes/{id}/unlock` 也列在这里,
> 那三条**属「金币与订单」,已在 §1.8 实现并冻结**,因此已从本节移出。
>
> 说明 2(**口径变更 2026-10-16**):**创作者中心(工作台 / 作品管理 / 数据看板)由 Web 端承载** ——
> 创作者登录后台管理使用,见 §2.10;**App 客户端不提供创作者中心**。
> 因此本节**已移除** `GET /app/creator/works`、`GET /app/creator/stats` 两条原定义。
> C 端只保留「入驻申请」+「启动配置」+「系统公告」,下面三条即全部。
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/app/creator/apply` | 登录 | 创作者入驻申请(**幂等**:重复提交返回当前状态,不重复建档) |
| GET | `/app/config` | **匿名** | 启动配置(协议版本、最低版本、开关;**只下发白名单键**) |
| GET | `/app/notices` | **匿名** | 系统公告(无数据返回空列表,不是 404) |
#### 1.9.1 `GET /app/config`(匿名)
客户端**登录前**就要拿,用于:协议版本校验、强制更新判断、功能开关。
```json
{
"code": 0, "message": "ok",
"data": {
"agreementVersion": "2026-10-01",
"minAppVersion": "1.0.0",
"forceUpdate": false,
"noticeEnabled": true,
"coinUnlockEnabled": true,
"downloadEnabled": true
}
}
```
| 键(对客户端字段) | 后台配置键 | 契约默认值 | 说明 |
| --- | --- | --- | --- |
| `agreementVersion` | `app.agreement.version` | `2026-10-01` | 当前协议版本;`/app/creator/apply` 必须与它一致 |
| `minAppVersion` | `app.min.version` | `1.0.0` | 低于它客户端应提示升级 |
| `forceUpdate` | `app.force.update` | `false` | 是否强制更新 |
| `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 |
| `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 |
| `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 |
**口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关)
**一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。
#### 1.9.2 `GET /app/notices`(匿名)
```
GET /app/notices?pageIndex=1&pageSize=20
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `pageIndex` | int | 否 | 默认 1 |
| `pageSize` | int | 否 | 默认 20,最大 50 |
```json
{
"code": 0, "message": "ok",
"data": {
"total": 1,
"list": [
{ "id": 2110123456789, "title": "开服公告", "content": "…", "type": "SYSTEM",
"publishAt": "2026-10-16 10:00:00", "expireAt": null }
]
}
}
```
**口径**:只返回 `status=1`(启用)且 `target ∈ {ALL, USER}`、`publish_at <= 现在`、
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
**表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。**
#### 1.9.3 `POST /app/creator/apply`(登录)
```json
{
"name": "星野工作室", "type": "COMPANY",
"contactName": "张三", "contactMobile": "08123456789", "contactEmail": "creator@example.com",
"countryCode": "ID", "agreementVersion": "2026-10-01"
}
```
| 字段 | 必填 | 校验 |
| --- | --- | --- |
| `name` | ✅ | 1–128 字 |
| `type` | 否 | `INDIVIDUAL`(默认)/ `COMPANY` |
| `contactName` / `contactMobile` / `contactEmail` | 否 | 只是联系方式,不参与鉴权 |
| `countryCode` | 否 | 默认 `ID` |
| `agreementVersion` | ✅ | **必须等于 `/app/config` 的 `agreementVersion`**,否则 `400` |
**幂等口径(硬要求)**:以「**C 端账号唯一**」防重(DB 唯一索引 `uk_creator_user(user_id) where user_id > 0`):
| 情况 | 行为 |
| --- | --- |
| 首次申请 | 建档,`status=pending`,返回 `{id, code, status, statusText, auditNote, appliedAt}` |
| 已申请(pending) | **返回当前状态**,不重复建档、不改动已有记录 |
| 已通过(approved) | 返回 `status=approved`(**创作者工作台在 Web 端**,见 §2.10;App 端只提示"已通过,请到创作者后台使用") |
| 被驳回 / 冻结 | 返回当前状态与 `auditNote`(是否允许重新申请由运营在后台处理,本期不做二次申请) |
| 并发重复提交 | 唯一索引兜底:捕获冲突后**读回已有记录返回**,不报 500 |
## 2. 后台管理接口(/admin/**)
### 2.1 认证与系统(已实现)
**认证**
| 方法 | 路径 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| POST | `/admin/auth/login` | 匿名 | 登录,返回 token |
| GET | `/admin/auth/menus` | 登录 | 当前用户菜单树 |
| GET | `/admin/auth/profile` | 登录 | 当前用户资料 |
| POST | `/admin/auth/logout` | 登录 | 登出 |
| POST | `/admin/auth/password` | 登录 | 修改密码 |
**系统管理(RBAC)**
| 资源 | 方法 | 路径 | 权限点 |
| --- | --- | --- | --- |
| 管理员 | GET | `/admin/admin-users/page` · `/{id}` | `system:admin-user` |
| 管理员 | POST | `/admin/admin-users` | `system:admin-user` |
| 管理员 | PUT | `/admin/admin-users/{id}` · `/{id}/status` · `/{id}/password` | `system:admin-user` |
| 管理员 | DELETE | `/admin/admin-users/{id}` | `system:admin-user` |
| 角色 | GET | `/admin/roles/page` · `/list` · `/{id}` · `/{id}/permissions` | `system:role`(`/list` 登录即可) |
| 角色 | POST | `/admin/roles` | `system:role` |
| 角色 | PUT | `/admin/roles/{id}` · `/{id}/permissions` | `system:role` |
| 角色 | DELETE | `/admin/roles/{id}` | `system:role` |
| 权限 | GET | `/admin/permissions` | `system:role` |
| 菜单 | GET | `/admin/menus/tree` | `system:menu` |
| 菜单 | POST · PUT · DELETE | `/admin/menus` · `/admin/menus/{id}` | `system:menu` |
| 角色分类 | GET · POST | `/admin/role-categories` | `system:role-category` |
| 角色分类 | PUT · DELETE | `/admin/role-categories/{id}` | `system:role-category` |
| 系统配置 | GET | `/admin/configs` | `system:role` / `system:menu` |
| 系统配置 | PUT | `/admin/configs` | `system:role` |
| 操作日志 | GET | `/admin/operation-logs/page` · `/{id}` | `log:list` |
| 字典 | GET | `/admin/dicts/drama-category` · `/content-status` | `drama:list` |
### 2.2 内容与媒资(已实现)
新增字段:短剧 `rating`(内容分级)、`regionCodes`(可播区域)、版权窗口、
`creatorId`、`tags`、`trailerUrl`;剧集 `transcodeStatus`、`hlsUrl`、`previewSeconds`、字幕。
**短剧(含审核发布状态机)**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态、`dramaTypes[]`、`categoryIds[]` 多选筛选) |
| GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
| POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 |
| POST | `/admin/dramas/{id}/reject` | `drama:review` | 驳回 |
| POST | `/admin/dramas/{id}/publish` | `drama:review` | 上线 |
| POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
多选字段说明:
- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序;
`drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。
- 仅传 `dramaType` / `categoryId` 时保持原单值行为。
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
**剧集**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/episodes?dramaId=` | `episode:list` | 某剧分集列表 |
| POST | `/admin/episodes` | `episode:upload` | 新增分集 |
| PUT | `/admin/episodes/{id}` | `episode:upload` | 编辑分集 |
| DELETE | `/admin/episodes/{id}` | `episode:upload` | 删除分集 |
| PUT | `/admin/episodes/sort` | `episode:sort` | 批量排序 |
**短剧类型**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/drama-categories` | `drama:list` | 类型列表 |
| POST | `/admin/drama-categories` | `drama:create` | 新增类型 |
| PUT | `/admin/drama-categories/{id}` | `drama:update` | 编辑类型 |
| DELETE | `/admin/drama-categories/{id}` | `drama:delete` | 删除类型 |
**轮播图**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/banners/page` · `/admin/banners/{id}` | `banner:list` | 检索 / 详情 |
| POST | `/admin/banners` | `banner:create` | 新增 |
| PUT | `/admin/banners/{id}` · `/{id}/status` · `/sort` | `banner:update` | 编辑 / 改状态 / 排序 |
| DELETE | `/admin/banners/{id}` | `banner:delete` | 删除 |
**媒资分片上传(后台与创作者共用)**
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/admin/media/upload/init` | 建上传会话(返回 `uploadId` / `chunkSize` / `totalChunks` / `objectKey`) |
| POST | `/admin/media/upload/chunk` | 传分片(`uploadId` + `index` + `file`),可重复传同一片 |
| GET | `/admin/media/upload/{uploadId}` | 断点续传回显(`receivedChunks` / `totalChunks` / `percent` / `status` / 完成后带 `url`) |
| POST | `/admin/media/upload/complete` | 合并分片出媒资(幂等:同一 uploadId 重复调用返回同一产物) |
| GET | `/admin/media/{id}` | 媒资详情 |
> 媒资上传与详情权限点均为 `cover:upload` 或 `episode:upload`(`@ss.hasAnyPermission`)。
**上传会话归属**:`uploadId` 相当于上传凭证,`chunk` / `complete` / 进度查询都校验会话发起人,
别人拿到 `uploadId` 也塞不进分片(`{"code":403,"message":"无权操作该上传会话"}`)。
**转码档位配置**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/media/transcode-profile` | `episode:upload` | 读取当前配置;缺省返回四档默认值 |
| PUT | `/admin/media/transcode-profile` | `episode:upload` | 保存配置并记录操作日志 |
配置保存在 `sys_config` 的 `media.transcode.profile`,JSON 结构:
```json
{
"enabled": true,
"segmentSeconds": 6,
"ladder": [
{
"quality": "360p",
"width": 640,
"height": 360,
"videoBitrateKbps": 400,
"audioBitrateKbps": 64,
"enabled": true
}
],
"sprite": {
"enabled": true,
"intervalSeconds": 1,
"columns": 10,
"thumbWidth": 160
}
}
```
`quality` 必须唯一且非空,宽高必须为大于 0 的偶数,音视频码率必须大于 0,
至少启用一档;`segmentSeconds` 2–10、`intervalSeconds` 1–10、`columns` 5–20、
`thumbWidth` 120–240。非法请求返回统一错误码 `400` 且不写库。
### 2.3 App 用户管理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/app-users/page` | `app-user:list` | 检索:keyword(uid/昵称/手机号/邮箱)、status、countryCode、registerSource |
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
| GET | `/admin/app-users/{id}/watch-history` | `app-user:list` | 观看流水明细(带剧名/集号,客服核对用) |
| GET | `/admin/watch-history/page` | `watch-history:list` | 跨用户观看记录分页:keyword、userId、dramaTitle、dramaId、episodeId、source、finished、startTime、endTime |
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
### 2.4 下载管理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/downloads/page` | `download:list` | 按用户 / 设备 / 状态检索下载授权,含剧名与集号 |
| GET | `/admin/downloads/stats` | `download:list` | 授权与任务的分布统计 |
| PUT | `/admin/downloads/{id}/revoke` | `download:revoke` | 吊销授权,用户侧下次联网即失效 |
| POST | `/admin/downloads/cleanup` | `download:revoke` | 手动清理过期授权(默认每天凌晨自动执行) |
### 2.5 内容治理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/comments/page` | `comment:list` | 检索评论;`status` 为空返回全部,`pending` 即审核队列 |
| GET | `/admin/comments/{id}/audit-records` | `comment:list` | 审核留证:机审与人审的完整链路 |
| PUT | `/admin/comments/{id}/audit` | `comment:audit` | 通过 / 驳回(带原因码与说明) |
| DELETE | `/admin/comments/{id}` | `comment:audit` | 删除评论 |
| GET | `/admin/reports/page` | `report:list` | 举报工单检索(状态 / 类型 / 原因),返回举报人、剧名、集号、集标题与目标摘要 |
| PUT | `/admin/reports/{id}/handle` | `report:handle` | 处理工单;结论成立会联动驳回被举报评论,可选择同时封禁被举报用户 |
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
后台评论行除 `dramaId/episodeId` 外,还返回 `dramaTitle/episodeNo/episodeTitle`;
剧或集已删除时对应展示字段留空。后台评论与举报的 `status` 空值均表示全部状态。
### 2.6 订单与权益(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/orders/page` `/admin/orders/{orderNo}` `/{orderNo}/events` | `order:list` | 订单检索、详情、状态流转流水 |
| POST | `/admin/orders/{orderNo}/compensate` | `order:update` | 补发:已支付未交付的订单重新交付,已交付幂等返回 |
| POST | `/admin/orders/{orderNo}/refund` | `order:update` | 退款/撤销:回收权益 + 扣回金币(余额不足时留人工跟进备注) |
| GET | `/admin/entitlements/page` | `entitlement:list` | 权益检索 |
| POST | `/admin/entitlements` | `entitlement:update` | 人工补发权益(客服补偿) |
| PUT | `/admin/entitlements/{id}/revoke` | `entitlement:update` | 回收权益,播放判定实时生效 |
| GET | `/admin/wallets/{userId}/ledger` | `order:list` | 金币流水 |
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
**对账口径(B1-2 起生效,字段名不变,新增两个字段)**
| 字段 | 含义 |
| --- | --- |
| `totalCount` | 当期本地有效订单数(排除 `CREATED`/`CLOSED`) |
| `matchedCount` | 其中能在 `store_receipt` 找到同一 `(platform, transaction_id)` 的笔数 |
| `diffCount` | 差异笔数 = 本地缺票据 + 票据无本地订单 + **商店已作废但本地无单** |
| `amountSum` | 本地订单金额合计(**本地口径**,不是商店结算口径) |
| `storeAmountSum` | 商店侧结算金额。Developer API 不提供(要向 Play Console 财务报告取)→ **无数据源时为 `null`** |
| `notes` | 说明差异构成、本次退款回收情况、以及"缺哪些数据源"。**契约强制:取不到的指标用 `null` + `notes` 说明,不许用 0 冒充** |
| `status` | 商店侧调用失败时为 `FAILED`(不会写成 `SUCCESS` + 一堆 0) |
`POST /admin/reconcile/run` 同时会**拉取商店已作废交易并回收权益与金币**(退款/拒付回收),
回收是幂等的:同一笔作废交易重复出现在时间窗里,只回收一次。
口径细节:Google 一次性商品的作废是**整笔**,拿不到部分金额,因此按整单回收;
找不到本地订单的作废交易会计入 `diffCount` 并在 `notes` 里点名。
### 2.7 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creators/page` `/admin/creators/{id}` `/{id}/documents` | `creator:list` | 创作者检索、详情、资质文件 |
| PUT | `/admin/creators/{id}/audit` | `creator:audit` | 入驻审核:`APPROVE` / `REJECT`(必填原因+原因码)/ `FREEZE` |
| POST | `/admin/creators` | `creator:audit` | 手工录入创作者主体(C 端申请入驻未实现前的打通用口) |
| POST | `/admin/creators/{id}/account` | `creator:account` | 开通创作者账号(幂等) |
| POST | `/admin/creators/{id}/account/reset-password` | `creator:account` | 重置创作者账号密码(一次性明文返回) |
| GET | `/admin/submissions/page` `/admin/submissions/{id}` | `submission:list` | 内容提交检索与详情 |
| PUT | `/admin/submissions/{id}/audit` | `submission:audit` | 审核通过后按 `type` 落到短剧状态机(CREATE/UPDATE 留档、PUBLISH 上线、OFFLINE 下线) |
| GET · PUT | `/admin/prices` | `price:update` | 分集价格:整剧 `freeEpisodes`/`unlockCoin` + 逐集覆盖;改价立即影响播放与下载判定 |
**定时发布**:`DramaPublishJob` 每分钟扫一次,把 `publish_at` 到点的 `approved` 短剧推上线,
走的是短剧状态机(不直接改状态字段)。上下架也可通过内容提交的 `OFFLINE` 审核触发。
### 2.8 数据看板(已实现,只读)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/dashboard/overview` | `dashboard:view` | 总览:用户 / 内容 / 播放 / 收入 / 成本 |
| GET | `/admin/dashboard/transcode` | `dashboard:view` | 转码队列积压与失败任务明细(含 `errorMsg` / `retryCount`) |
**口径(与代码注释一致,前端 tooltip 可直接复用)**
| 分组 | 指标 | 口径 |
| --- | --- | --- |
| 用户 | `total` / `newToday` / `new7d` | `app_user` 未删除总数;按 `created_at` 落在当天 / 近 7 天 |
| 用户 | `active7d` | `last_login_at` 在近 7 天内。**不是 DAU**,日活需要逐日登录快照,当前无产出 |
| 用户 | `banned` / `limited` / `deactivated` | 按 `status` 计数 |
| 内容 | `drama*` | 短剧按 `status` 计数(`reviewing` 为待审、`published` 为已上线) |
| 内容 | `episodeTotal` / `Published` / `WithMedia` | 剧集总数 / 已上线数 / `video_url` 非空数(均排除逻辑删除) |
| 内容 | `commentPending` / `reportPending` | 待审评论、待处理举报 |
| 播放 | `viewTotal` / `playTotal` | 短剧表累计计数列求和(累计口径,不限时间) |
| 播放 | `watchedDramas` / `finishedDramas` / `finishRate` | `watch_progress` 行数 / 其中 `finished=1` / 完播率 |
| 播放 | `playEvents7d` / `playEventsTotal` | `watch_history` 流水口径(与累计口径不可相加) |
| 收入 | `order*` / `paidAmount` | 订单按状态计数;金额为已支付+已交付合计(商店未接入前恒为 0) |
| 收入 | `coinRecharged` / `coinConsumed` / `coinBalance` | 取自 `coin_ledger`(RECHARGE / UNLOCK)与 `wallet` 余额合计 |
| 收入 | `entitlementActive` | 有效权益数 |
| 成本 | `transcode*` / `renditions` | `transcode_task` 按状态计数、`media_rendition` 产物数 |
| 成本 | `otpSent7d` | 近 7 天验证码发送条数(`app_user_otp`),作为短信成本近似 |
| 成本 | `cdnTrafficBytes` | **暂无数据源**,返回 `null` 并在 `notes` 说明 |
取不到数据源的指标一律返回 `null` 并在 `notes` 里写明缺什么,**不用 0 冒充**。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/admin/comments/page` | 评论审核队列 |
| PUT | `/admin/comments/{id}/audit` | 通过/驳回(带原因码) |
| GET | `/admin/reports/page` | 举报工单 |
| PUT | `/admin/reports/{id}/handle` | 处理举报(可联动封禁) |
| GET/POST/PUT | `/admin/sensitive-words` | 敏感词库 |
| GET | `/admin/orders/page` | 订单检索(可按用户、状态、平台) |
| POST | `/admin/orders/{orderNo}/compensate` | 票据丢失的补发 |
| PUT | `/admin/entitlements/{id}/revoke` | 权益回收 |
| POST | `/admin/reconcile/run` | 手动触发对账 |
| GET | `/admin/creators/page` | 创作者审核队列 |
| PUT | `/admin/creators/{id}/audit` | 入驻审核 |
| GET | `/admin/submissions/page` | 内容提交审核 |
| PUT | `/admin/submissions/{id}/audit` | 内容审核(通过/驳回/驳回原因码) |
| PUT | `/admin/prices` | 分集价格与免费集配置 |
| GET | `/admin/dashboard/overview` | 经营大盘(用户/播放/收入/成本) |
| GET | `/admin/dashboard/transcode` | 转码队列与失败任务 |
### 2.9 后台前端页面(已实现)
`ReelShortWeb`(Vue3 + Element Plus,dev server `:3005`)的五个运营页面,统一沿用「api 封装 + 视图 + 样式」的写法;
表格列与下面这些接口的字段一一对应,**不在前端编字段**,时间列统一显示 `yyyy-MM-dd HH:mm:ss`。
| 页面 | 路由 | 菜单组件路径(`sys_menu.component`) | 依赖权限点 | 覆盖接口 |
| --- | --- | --- | --- | --- |
| 内容治理 | `/governance/content` | `views/governance/ContentGovernanceView` | `comment:list` `comment:audit` `report:list` `report:handle` `sensitive-word:list` `sensitive-word:update` | 评论检索与审核、举报工单处理、敏感词库增删改查 |
| 订单与权益 | `/commerce/orders` | `views/commerce/OrderManagementView` | `order:list` `order:update` `entitlement:list` `entitlement:update` `wallet:update` `reconcile:run` | 订单检索 / 流水 / 补发 / 退款、权益检索 / 人工补发 / 回收、金币流水 / 人工补币、对账运行与历史 |
| 创作者入驻 | `/creator/creators` | `views/creator/CreatorAuditView` | `creator:list` `creator:audit` | 创作者检索、资质查看、入驻审核(通过 / 驳回 / 冻结) |
| 内容审核 | `/creator/submissions` | `views/creator/SubmissionAuditView` | `submission:list` `submission:audit` | 内容提交检索、通过 / 驳回(带原因码) |
| 数据看板 | `/board/overview` | `views/dashboard/BusinessDashboardView` | `dashboard:view` | `overview` + `transcode`(纯只读) |
菜单、权限点与超管授权由 `sql/data-phase1.sql` 幂等落库(内容治理 / 订单与权益 / 数据看板挂菜单 id 20–25,创作者两条为 17–19)。
页面只读态与可操作态按权限点区分:没有 `*:update` / `*:audit` 的角色进入页面时按钮隐藏或显示「只读」。
### 2.10 创作者中心(批 1 ~ 批 3 已实现:工作台 / 内容编辑 / 上传挂载 / 收费配置)
创作者 Web 端做在后台管理里(`ReelShortWeb`),身份复用 `sys_admin_user`:
`creator.admin_user_id` 绑定后台账号(部分唯一索引,0 = 未绑定),角色 `creator` 只拿 `creator-center:*` 权限点。
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creator-center/profile` | `creator-center:view` | 当前创作者主体与入驻状态 |
| GET | `/admin/creator-center/dramas/page` | `creator-center:view` | 我的作品(含最近一次提交的审核进度:类型 / 状态 / 驳回原因码 / 时间) |
| GET | `/admin/creator-center/dramas/{id}` | `creator-center:view` | 我的作品详情 |
| GET | `/admin/creator-center/notices` | `creator-center:view` | 我的通知:入驻审核结果 + 我提交内容的审核结果 |
**契约红线**:以上接口一律**不接收 `creatorId`**,归属由登录态(`sys_admin_user.id` → `creator.admin_user_id`)解析,
查询强制带 `where creator_id = 当前创作者`;访问他人作品统一返回 `{"code":403,"message":"无权访问该作品"}`,
未绑定创作者主体返回 `{"code":403,"message":"当前账号未绑定创作者主体,无法进入创作者中心"}`。
通知不新建表:第一阶段由 `creator`(入驻审核)与 `content_submission`(内容提交审核)派生;
系统公告 `ops_notice` 目前没有产出方,等有公告功能再接。
**内容编辑与提交(批 2)**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| POST | `/admin/creator-center/dramas` | `creator-center:drama:edit` | 新建草稿(归属写自己,状态固定 `draft`) |
| PUT | `/admin/creator-center/dramas/{id}` | `creator-center:drama:edit` | 编辑作品信息(**未传的字段沿用现值**) |
| GET | `/admin/creator-center/dramas/{id}/episodes` | `creator-center:view` | 分集列表 |
| POST | `/admin/creator-center/dramas/{id}/episodes` | `creator-center:drama:edit` | 新增分集(不传集号则排到最大集号之后,默认免费) |
| PUT | `/admin/creator-center/dramas/{id}/episodes/{episodeId}` | `creator-center:drama:edit` | 编辑分集(时长 / 封面 / 片源 / 排序;未传字段沿用现值) |
| DELETE | `/admin/creator-center/dramas/{id}/episodes/{episodeId}` | `creator-center:drama:edit` | 删除分集 |
| PUT | `/admin/creator-center/dramas/{id}/episodes/sort` | `creator-center:drama:edit` | 批量排序(列表里出现不属于该剧的分集直接 403) |
| POST | `/admin/creator-center/dramas/{id}/submit` | `creator-center:drama:edit` | 提交审核,`type` = `CREATE` / `UPDATE` / `PUBLISH` / `OFFLINE` |
| POST | `/admin/creator-center/submissions/{id}/withdraw` | `creator-center:drama:edit` | 撤回自己的提交(撤回后作品退回草稿) |
| GET | `/admin/creator-center/dramas/{id}/submissions` | `creator-center:view` | 我的提交历史(审核进度) |
**可编辑状态闸**:只有 `draft`(草稿)与 `offline`(已下线)能直接改内容;`reviewing` 提示"提交正在审核中,请先撤回再改",
`approved` 不可编辑,`published` 提示"已上线的作品只读,请先提交「申请下线」"(错误码统一 `200003`)。
**提交矩阵**(状态 → 允许的提交类型):
| 作品状态 | 允许提交 | 提交后 |
| --- | --- | --- |
| `draft` | `CREATE`(仅首次)/ `UPDATE` / `PUBLISH` | 作品转 `reviewing` |
| `offline` | `UPDATE` / `PUBLISH` | 作品转 `reviewing` |
| `reviewing` | `PUBLISH`(内容审核通过后再申请上线) | 保持 `reviewing`,等后台上线审核 |
| `approved` | `PUBLISH` | 保持 `approved`,等后台上线审核 |
| `published` | `OFFLINE` | 保持 `published`,等后台下线审核 |
同一部剧**同时只能有一条在审提交**;提交后按其类型落到短剧状态机(审核动作仍在后台,创作者侧不新造状态)。
后台审核 `PUBLISH` 通过时,该剧仍处于 `draft` 的分集**一并上线**(否则会出现"剧上线了却一集都播不了");
后台**驳回**或创作者**撤回**时,处于 `reviewing` 的剧退回 `draft`,避免创作者卡在只读状态。
**上传挂载与收费配置(批 3)**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| POST | `/admin/creator-center/dramas/{id}/episodes/{episodeId}/attach` | `creator-center:upload` | 把已上传的媒资挂到自己的分集(写 `video_asset_id` / `video_url`,时长为 0 时用产物时长补齐) |
| GET | `/admin/creator-center/dramas/{id}/prices` | `creator-center:view` | 读整剧免费集数 + 逐集免费 / 收费 / 试看秒数 |
| PUT | `/admin/creator-center/dramas/{id}/prices` | `creator-center:price:update` | 保存收费与试看秒数,复用后台同一套 `PriceService` |
**上传走已有的 `/admin/media/upload/{init,chunk,complete}`**(创作者角色直接持 `cover:upload` / `episode:upload`,
不新造一套上传接口);挂载时三道校验:分集属于自己这部剧 → 作品可编辑 → **产物是本次登录的人自己上传的**
(`media_asset.create_by` 必须等于当前账号,否则 `{"code":403,"message":"无权使用该上传产物"}`)。
只接受 `bizType=EPISODE` 且状态为 `ready` / `uploaded` 的产物。
**收费配置**:`previewSeconds` 落在 `drama_episode.preview_seconds`(0 = 不限制),
与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求付费集返回
`{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`;免费集直接返回签名播放地址)。
定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格);
`dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。
**创作数据看板(批 4,只读)**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creator-center/dashboard` | `creator-center:stat:view` | 播放 / 完播 / 收藏 / 评论 / 解锁收入概览 |
**口径**(与代码注释一致,前端 tooltip 可直接复用;统计范围只含 `drama.creator_id = 当前创作者`)
| 分组 | 指标 | 口径 |
| --- | --- | --- |
| 作品 | `total` / `draft` / `reviewing` / `approved` / `published` / `offline` | 我的短剧按 `status` 计数(当前快照) |
| 作品 | `episodeTotal` / `episodeWithMedia` | 我的分集总数 / `video_url` 非空数 |
| 播放 | `viewTotal` / `playTotal` | 短剧表累计计数列求和(**累计口径,不限时间**) |
| 播放 | `watchedDramas` / `finishedDramas` / `finishRate` | `watch_progress` 是按「用户 × 剧」存的续看进度(唯一索引 `user_id + drama_id`),所以是**有多少人看过我的剧** / 其中最近一次标记完播的 / 完播率;**不是按集** |
| 播放 | `playEvents7d` / `playEventsTotal` | `watch_history` **流水口径**(近 7 天 / 全部),与累计口径不可相加 |
| 播放 | `finishEvents` | `watch_history` 中 `position_ms >= duration_ms` 的完播事件数(流水口径) |
| 互动 | `favoriteTotal` | `user_favorite` 指向我作品的行数 |
| 互动 | `commentTotal` / `commentPending` | `content_comment` 中属于我作品的评论数 / 其中待审数 |
| 收入 | `unlockCount` | `entitlement` 中 `source=ORDER` 且剧属于我的记录数(人工补发的 `ADMIN` 权益不计) |
| 收入 | `coinIncome` / `coinIncome7d` | `coin_ledger` 中 `biz_type=UNLOCK` 且 `biz_id` 命中我分集的扣币绝对值合计(全部 / 近 7 天) |
| 收入 | `paidOrderAmount` | **暂无数据源**,恒为 `null`:金币解锁不产生法币订单,真实金额要等 Apple/Google 商店接入 |
| 趋势 | `daily` | **暂无数据源**,恒为 `null`:`creator_stat_daily` 表已建但无产出任务 |
取不到的一律 `null` 并在 `notes` 说明缺什么、怎么补,**不用 0 冒充**(与运营大盘同口径)。
**创作者中心前端页面(批 5 已实现)**
`ReelShortWeb` 里新增四个页面(菜单 id 26–30,`sql/data-phase1.sql` 幂等落库),沿用 `AppUsersView` 的
「api 封装 + composable + 视图 + 样式」写法与 `console.css`:
| 页面 | 路由 | 菜单组件路径 | 依赖权限点 | 覆盖接口 |
| --- | --- | --- | --- | --- |
| 我的作品 | `/creator-center/workspace` | `views/creator-center/WorkspaceView` | `creator-center:view` `creator-center:drama:edit` | 主体信息、作品列表(含审核进度)、通知、新建草稿、提交 / 撤回 |
| 作品编辑 | `/creator-center/dramas/:id` | `views/creator-center/DramaEditView` | `creator-center:drama:edit` `creator-center:upload` | 作品信息、分集增删改与排序、分片上传(含断点续传)、产物挂载、提交记录 |
| 收费配置 | `/creator-center/prices` | `views/creator-center/PriceConfigView` | `creator-center:price:update` | 免费集数、解锁金币、试看秒数 |
| 数据看板 | `/creator-center/dashboard` | `views/creator-center/DashboardView` | `creator-center:stat:view` | 看板概览(只读,缺口显示「暂无数据源」) |
**创作者角色只挂这五条菜单**(含一条 `visible=0` 的隐藏路由):登录后侧边栏只有「创作者中心」,
看不到运营后台的任何菜单 —— 实测创作者菜单 JSON 里只有 `/creator-center` 及其子项,超管才拿得到全部。
「作品编辑」是详情页,用 `visible=0` 下发(后端把它翻译成 `meta.hidden`),路由照常注册、侧边栏不显示。
**本阶段进度**:07 全部 5 批完成,第一阶段 05 / 06 / 07 / 08 四块闭环。
**超管代看(只读)**
后台账号如果没绑定创作者主体,又点进了创作者中心,接口不再直接 403 白屏,而是三级解析:
| 情况 | 结果 |
| --- | --- |
| 账号绑定了 `creator.admin_user_id` | 用自己的身份(**创作者角色永远走这条**,代看逻辑不影响他) |
| 未绑定 + 有 `creator-center:impersonate` + 请求带 `X-Creator-Id` | 只读代看该创作者 |
| 其余 | `200004`(未绑定)/ `200005`(有代看权限但没选目标)—— 前端据此渲染引导页 |
**红线**:
- 目标创作者走**独立请求头 `X-Creator-Id`**,不做查询参数 —— creator-center 接口依旧不接收 `creatorId`;
- 这个头**只有**持有 `creator-center:impersonate` 的账号才生效,创作者自己带上没有任何作用(实测:带头上限仍只看到自己的数据,仍能正常编辑自己的作品);
- **代看只读**:作品编辑、分集增删改与排序、上传会话(`/admin/media/upload/*` 的 init/chunk/complete)与产物挂载、
提交 / 撤回、收费配置**全部拒绝**,错误码 `200006`;
- 代看进入写 `audit_log`(`biz_type=CREATOR`、`action=IMPERSONATE_VIEW`、操作人、IP、目标创作者),
同一操作人对同一目标 10 分钟内去重,避免翻页把审计表刷爆。
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) |
### 2.11 商品(金币包)配置(已实现)
> 背景:`product` 表 16 个字段早就建好,但后台一直没有入口,运营只能改库;而 C 端 `/app/orders/verify`
> 依赖 `store_product_id` 对齐商店商品(对不上就是 `200002 该商品未在后台配置`)。本节补齐 CRUD。
> **不改表结构。**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/products/page` | `product:list` | 分页检索(`type` / `status` / `platform` / `keyword`,分页沿用 `{total,list}`) |
| GET | `/admin/products/{id}` | `product:list` | 商品详情 |
| POST | `/admin/products` | `product:update` | 新建 |
| PUT | `/admin/products/{id}` | `product:update` | 编辑 |
| PUT | `/admin/products/{id}/status` | `product:update` | 上架 / 下架(`status`:1 上架、0 下架) |
| DELETE | `/admin/products/{id}` | `product:update` | 删除(逻辑删除) |
**字段**(与表一致,响应里额外带 `statusText` / `typeText`,前端不再写死中文):
| 字段 | 说明 |
| --- | --- |
| `code` | 商品编码,**全局唯一** |
| `name` | 展示名 |
| `type` | `COIN_PACK`(金币包)/ `VIP`(会员,第二阶段) |
| `platform` | `ALL` / `GOOGLE` / `APPLE` |
| `storeProductId` | 商店商品 id,**同一 `platform` 下唯一**(空串表示未绑定,允许多个) |
| `coinAmount` / `bonusCoin` | 金币数与赠送(`type=COIN_PACK` 时 `coinAmount > 0`) |
| `priceAmount` / `currency` / `displayPrice` | 价格与展示价 |
| `regionCodes` | 生效区域,逗号分隔,空=全区域 |
| `vipDays` | 会员天数(`type=VIP` 时 `> 0`) |
| `sort` / `status` | 排序 / 上下架 |
| `effectiveAt` / `expireAt` | 生效与失效时间(可空) |
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。
**校验与错误码**:
| 场景 | 结果 |
| --- | --- |
| `code` 已存在 | `200001 商品编码已存在:{code}` |
| `storeProductId` 在同 `platform` 下已存在 | `200001 该平台下商店商品 id 已被占用:{id}` |
| `type=COIN_PACK` 但 `coinAmount <= 0` | `400 金币包的金币数量必须大于 0` |
| `type=VIP` 但 `vipDays <= 0` | `400 会员商品的会员天数必须大于 0` |
| **`type=VIP` 且要启用**(`status=1`) | `400 会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)` |
| 商品不存在 | `200002 商品不存在` |
> **VIP 口径(2026-10-16 定,与 B1-1「VIP 订阅本轮不做」一致)**:**允许创建、不允许启用**。
> 运营可以先录好商品与商店 product id,等 B2 上线后直接上下架;**启用**才是会误导客户端的动作,拦在这里。
### 2.12 系统公告管理(已实现)
> 背景:`ops_notice` 表 0 行、契约原先写着"没有产出方";E1 已交付 C 端 `GET /app/notices`(匿名只读)。
> 本节补齐后台写入。**不改表结构。**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/notices/page` | `notice:list` | 分页检索(`status` / `target` / `type` / `keyword`) |
| GET | `/admin/notices/{id}` | `notice:list` | 公告详情 |
| POST | `/admin/notices` | `notice:update` | 新建 |
| PUT | `/admin/notices/{id}` | `notice:update` | 编辑 |
| PUT | `/admin/notices/{id}/status` | `notice:update` | 发布(`status=1`)/ 下架(`status=0`) |
| DELETE | `/admin/notices/{id}` | `notice:update` | 删除(逻辑删除) |
**字段**:`title` / `content` / `target`(`ALL` / `USER` / `CREATOR`)/ `type`(`SYSTEM` / `AUDIT` / `PAYMENT` / `ACTIVITY`)/
`status` / `publishAt` / `expireAt`;响应另带 `statusText` / `targetText` / `typeText`。
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**。
**与 C 端口径严格对齐(同一条查询实现)**:C 端 `GET /app/notices` 只返回
`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、`expire_at` 为空或未过期的公告,
按 `publish_at desc, id desc` 排序。**后台发布即 C 端可见;下架或过期后 C 端不可见。**
(实现上收敛在 `OpsNoticeService.pageForApp(...)` 一处,避免两端条件漂移。)
**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`;
`status` 只接受 0/1,其他值 → `400`。
## 3. 契约与实现状态对照
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
| 模块 | 表结构 | 接口 | 代码 |
| --- | --- | --- | --- |
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现;**S0 雪花主键**、**S1 Swagger 在线文档** 已完成 |
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
| 04 播放器与播放控制 | 已建 | 已冻结 | **已实现**:票据 + 签名 + 进度;多码率 HLS 由转码 worker(C1)产出 |
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** |
| 07 创作者中心 | 已建 | 已冻结 | **已实现**(**在后台管理 Web 端**):创作者工作台(我的作品 / 审核进度 / 通知)、内容编辑与分集管理、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;Web 端页面见 2.10,不含提现 / 结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 08 平台运营后台 | 已建 | 已冻结 | **已实现**:后台接口(用户管理、内容治理、订单与权益、创作者与内容审核、数据看板)与后台页面(内容治理 / 订单与权益 / 创作者审核 / 数据看板,见 2.9)全部落地 |
| 09 分集收费与支付 | 已建 | 已冻结 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,口径为"预留") |
| 10 质量、数据与上线 | 已建 | 已冻结 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测 未做** |