# 第一阶段接口清单(后台管理 + 客户端) > 版本 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 打到客户端接口会直接被拒。 客户端 `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,密码登录不改变该状态 | | 国家 / 语言 / 时区 / 币种 | 优先按客户端 IP(`X-Forwarded-For` / `X-Real-IP`,离线 ip2region)解析国家;内网或解析失败才用请求体 `countryCode`(ISO-2),最后兜底 `ID`。`language` / `timezone` / `currency` 随国家映射,不再写死 `id-ID` / `IDR` | | 登录时修正国家 | `/app/auth/login`、`/app/auth/google` 会用 IP 重新解析国家并更新 `country_code` / `region_code`(历史账号下次登录即自愈);`language` / `timezone` / `currency` 只在仍是旧国家映射值时才同步,用户自己改过就保留 | 频控口径落库在 `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` | 已启用首页分类。`name` 为中文,`locales` 为 `{zh,en,id}` | | GET | `/app/categories/{id}/dramas?pageIndex=&pageSize=` | 分类下已上线短剧,分页。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50 | | GET | `/app/drama-categories` | 已启用类型。`locales` 为 `{zh,en,id}` | | GET | `/app/tags` | 已启用标签。`locales` 为 `{zh,en,id}` | | POST | `/app/dramas/page` | 全部短剧分页。请求体 `pageIndex`(默认 1)、`pageSize`(默认 20、最大 50)、`categoryIds`、`classificationIds`、`tagIds`。id 用 JSON 数组,不放查询串。同一组多选为或,组与组之间为且 | | GET | `/app/playlists?pageIndex=&pageSize=` | 已启用剧单列表,分页。`pageIndex` 默认 1,`pageSize` 默认 10、最大 20 | | GET | `/app/playlists/{id}?pageIndex=&pageSize=` | 剧单详情。短剧按页返回。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50。下架或不存在返回 `200002` | | GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | | GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | `POST /app/dramas/page` 请求体: ```json { "pageIndex": 1, "pageSize": 20, "categoryIds": ["2112443900437336065"], "classificationIds": ["2112807182243336193"], "tagIds": ["2113500000000000002"] } ``` 省略的 id 数组表示这一组不筛选。`pageIndex` 小于 1 按 1,`pageSize` 小于 1 按 1,大于 50 按 50。响应仍是 `{total, list}`,卡片结构与首页一致。 > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 > > 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。 > `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的 > `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`); > `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。 > > 卡片文案只放语言表:`titleLocales`、`synopsisLocales`、`types`、`tagLocales`。 > `tagColor` 是标签背景色。中文取各表的 `zh`。 > > 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。 > 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`, > App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。 > > `GET /app/playlists` 与 `GET /app/playlists/{id}` 匿名可访问。列表返回 `{total, list}`。 > 列表项与详情的名称在 `locales`,描述在 `descriptionLocales`,key 为 `zh` / `en` / `id`。 > `name` 与 `description` 等于中文。客户端按当前语言取对应 key,没有则用 `zh`。 > 其余字段:`id`、`dramaCount`(已上线短剧条数), > 列表里每条剧单的 `list` 只是短剧第 1 页、每页 10 条。短剧翻页走详情: > `pageIndex`、`pageSize` 会原样回在详情里,`dramaCount` 是短剧总数,`list` 是当前页卡片。 > 只返回 `status = 1` 的剧单,排序与后台剧单排序一致,卡片顺序与后台关联顺序一致。 > 剧单列表越界页返回空 `list`,`total` 仍是全部剧单数。详情越界页同样返回空 `list`。 #### 推荐类型流(`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>`,卡片字段与首页一致,包含互动计数、`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` | 免费集可选;锁定集必须登录 | 签发播放票据与签名地址 | | GET | `/app/episodes/{id}/stats` | 可选 | 播放页轻量计数快照,不签发地址、不写票据缓存 | ```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", "720p", "1080p"], "tracks": [ {"quality": "AUTO", "playUrl": "/files/transcoded/88/master.m3u8?e=1760000000&s=9f2c..."}, {"quality": "720p", "playUrl": "/files/transcoded/88/720p/index.m3u8?e=1760000000&s=a1b2..."} ], "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`(已登录用户访问的剧集还 没上传片源或未转码完成)。未登录请求锁定集在查询片源前直接返回 `UNAUTHORIZED`,不会 按设备身份取票或降级为匿名播放。 **判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 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/原文件回落。 **`GET /app/episodes/{id}/stats`(播放页计数刷新)** 只读返回本集点赞数、本集未删除评论数、所属剧收藏数,以及当前用户对 本集/所属剧的互动状态。已登录时按账号读取;匿名请求时按 `X-Device-Id` 读取设备互动状态,未带设备号时 `liked`、`favorited` 均为 `false`。 接口不生成播放签名地址、不写 `playback_ticket` 缓存,计数变化后立即可读。 ```json { "code": 0, "message": "ok", "data": { "episodeId": "12", "dramaId": "5", "likeCount": "45", "commentCount": "8", "favoriteCount": "1800", "liked": true, "favorited": false } } ``` `likeCount`、`commentCount`、`favoriteCount` 与其他计数一样按 Long 字符串下发。 ### 1.5 观看记录与收藏(已实现) | 方法 | 路径 | 鉴权 | 说明 | | --- | --- | --- | --- | | POST | `/app/watch/progress` | 可选登录 | 上报进度;匿名按 `X-Device-Id`,建议每 10s 一次,切集时补一次 | | GET | `/app/watch/progress?dramaId=` | 可选登录 | 某剧续看位置,匿名按设备读取 | | GET | `/app/watch/recent` | 可选登录 | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/watch/progress/{dramaId}` | 可选登录 | 删除当前账号或设备的某剧记录 | | DELETE | `/app/watch/progress` | 可选登录 | 清空当前账号或设备的记录 | | POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` | | GET | `/app/favorites` | 可选登录 | 收藏分页;匿名按设备读取,`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` | | POST | `/app/favorites/remove` | 可选登录 | 批量取消收藏;匿名按 `X-Device-Id`,返回实际删除数量 | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 这样用户换设备不会看到进度倒退。 收藏与观看数据的身份解析顺序统一为登录 `userId` → `X-Device-Id` → 请求参数或请求体 `deviceId`,全部缺失时返回业务码 `200007`。匿名读取不按设备限流;上报、删除和清空等写操作 沿用设备级限流。登录或注册成功后,同一事务会把设备收藏、观看进度和观看流水合并到账号: 同一部剧的进度先比较集号,同集再比较播放位置,最后用更新时间兜底,只保留更靠前的一条; 观看流水按剧集去重并保留更靠后的位置。`playCount` 只取最终保留记录,不重复累加。 #### 批量取消收藏 `POST /app/favorites/remove` ```json { "dramaIds": ["12", "15"], "deviceId": "optional-device-id" } ``` | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `dramaIds` | array<Long> | 是 | 待取消的短剧 ID,最多 200 条,服务端去重后删除 | | `deviceId` | string | 否 | 未登录且未携带 `X-Device-Id` 时的设备号兜底 | 身份解析优先登录用户,其次 `X-Device-Id`,最后请求体 `deviceId`。登录用户按 `user_id` 删除,匿名用户按 `device_id` 删除;匿名写操作沿用设备级限流。 所有删除和剧级收藏数回退在单一事务内完成,其中任一步失败会整体回滚。 响应 `data` 为实际删除的收藏关系数: ```json { "code": 0, "message": "ok", "data": 2 } ``` 空列表返回业务码 `400`;超过 200 条同样返回 `400`。 #### 收藏分页 `GET /app/favorites?pageIndex=1&pageSize=20` 请求参数: | 参数 | 类型 | 必填 | 默认值 | 说明 | | --- | --- | --- | --- | --- | | `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 | | `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 | 响应 `data` 为 `PageResult`: ```json { "total": "23", "list": [ { "id": "12", "title": "替嫁新娘:总裁的掌心宠", "cover": "/files/drama_cover/12.jpg", "preview": {} } ] } ``` 排序固定为收藏时间倒序 `created_at DESC`,收藏时间相同时按 `drama_id DESC`。 仅统计和返回仍处于已上线状态的短剧。 #### 最近观看分页 `GET /app/watch/recent?pageIndex=1&pageSize=20` 请求参数: | 参数 | 类型 | 必填 | 默认值 | 说明 | | --- | --- | --- | --- | --- | | `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 | | `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 | 分页响应 `data` 为 `PageResult`: ```json { "total": "18", "list": [ { "dramaId": "12", "episodeId": "88", "dramaTitle": "替嫁新娘:总裁的掌心宠", "titleLocales": { "zh": "替嫁新娘:总裁的掌心宠", "en": "The Stand-in Bride: The CEO's Beloved", "id": "Pengantin Pengganti: Kesayangan Presiden Direktur" }, "dramaCover": "/files/drama_cover/12.jpg", "episodeNumber": 6, "totalEpisodes": 68, "viewCount": "1800", "positionMs": "80000", "durationMs": "120000", "finished": false, "playCount": 3, "updatedAt": "2026-10-22 10:00:00" } ] } ``` 排序固定为 `updated_at DESC, id DESC`。每页记录通过批量 IN 查询装配短剧和分集, 不逐条访问数据库。越界页返回空 `list` 且 `total` 保持正确。 `ProgressRespVO` 展示字段: | 字段 | 类型 | 说明 | | --- | --- | --- | | `dramaTitle` | string / null | 短剧中文标题,旧客户端回退用 | | `titleLocales` | object / null | 短剧标题语言表,key 为 `zh` / `en` / `id`。客户端按当前语言取,没有对应语言时回退 `zh` | | `dramaCover` | string / null | 短剧封面 | | `episodeNumber` | int / null | 当前续看集号 `ep_no` | | `totalEpisodes` | int / null | 短剧总集数 | | `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 | ### 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` | 可选登录 | 发表评论/回复;匿名按 `X-Device-Id`,过敏感词后按策略发布或转待审 | | 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`;匿名按 `X-Device-Id` | | POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | **非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` → `X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号 每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端 在同一登录事务内把该设备未归属的点赞、收藏、评论、观看进度和观看流水迁移到账号; 账号已有同内容时保留账号记录并删除设备重复记录,计数同步回退,不重复累加。 匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内, 仍必须登录。 **敏感词三档处置**:`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": "" } ``` | 字段 | 说明 | | --- | --- | | `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.2a 协议公开页(匿名 HTML) 客户端按当前界面语言打开,不走 `{code,message,data}` 包装。正文在后台「协议设置」用 Markdown 维护,页面不另加标题。 | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/privacy/{lang}` | 隐私协议。`lang` 为 `zh` / `en` / `id` | | GET | `/terms/{lang}` | 服务条款。语言同上 | 响应 `Content-Type: text/html;charset=UTF-8`。语言或路径不在上表时 HTTP 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/legal-documents` | `system:role` / `system:menu` | | 协议 | PUT | `/admin/legal-documents` | `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,%'` 整词匹配。 - 短剧另有单选标签 `tagId`。不传保持原值,传 `0` 清空。列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。后台编辑不再提交内容类型。 **标签** | 方法 | 路径 | 权限点 | 说明 | | --- | --- | --- | --- | | GET | `/admin/content-tags?enabledOnly=` | `drama:list` | 标签列表。`enabledOnly=true` 只返回启用项 | | POST | `/admin/content-tags` | `drama:create` | 新建。`locales` 勾选语言,中文必填,英文和印尼语可选 | | PUT | `/admin/content-tags/{id}` | `drama:update` | 编辑 | | DELETE | `/admin/content-tags/{id}` | `drama:delete` | 删除,并清空已选该标签的短剧 | **剧集** | 方法 | 路径 | 权限点 | 说明 | | --- | --- | --- | --- | | 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、platform(ANDROID/IOS,按用户**最近一次**会话平台筛选) | | 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、platform(ANDROID/IOS,按**最近一次会话**) | | GET | `/admin/favorites/page` | `favorite:list` | 收藏只读分页,含游客;支持 platform 筛选(ANDROID/IOS,按**最近一次会话**) | | GET | `/admin/likes/page` | `like:list` | 点赞只读分页,含游客及多态目标;支持 platform 筛选(ANDROID/IOS,按**最近一次会话**) | | PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned | 状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。 收藏和点赞分页参数:`pageIndex` 默认 1,`pageSize` 默认 10、最大 100; `keyword` 模糊匹配 uid/昵称,`userId`、`deviceId`、`dramaId` 精确匹配, `dramaTitle` 模糊匹配;`startTime/endTime` 按创建时间闭区间过滤, 格式 `yyyy-MM-dd HH:mm:ss`。点赞另支持 `targetType`(DRAMA/EPISODE/COMMENT)、 `targetId` 和 `episodeId` 精确过滤,短剧和剧集过滤会解析评论及剧集所属关系。 按 `createdAt DESC, id DESC` 排序,用户/短剧预检索无结果返回 total=0、list=[]。 点赞的三种目标语义:客户端点赞按钮作用于当前播放剧集,落库为 `targetType=EPISODE`; `DRAMA` 是早期按整部剧点赞的历史数据;`COMMENT` 为评论点赞。筛选行为: `dramaTitle` / `dramaId` 同时命中「该剧本身的点赞」与「该剧下各集的点赞」, `episodeId` 只命中「该集本身的点赞」与该集下的评论点赞,**不包含对短剧本身的点赞**; 不传 `episodeId` 时不受此限制。 收藏行字段:`id,userId,uid,nickname,deviceId,anonymous,dramaId,dramaTitle,dramaCover, dramaStatus,dramaPublished,createdAt`。点赞行字段:`id,userId,uid,nickname,deviceId, anonymous,targetType,targetId,dramaId,dramaTitle,dramaCover,episodeId,episodeNo,episodeTitle, commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评论摘要最多 30 字。 关联资料逻辑删除后展示字段为空,不排除原始互动记录。 ### 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`) | | GET | `/admin/notifications/transcode` | `drama:list` | 转码通知:`running` / `success` / `failed` 角标,以及各状态最近 20 条(含 `dramaTitle`、`episodeNo`、`episodeTitle`) | **口径(与代码注释一致,前端 tooltip 可直接复用)** | 分组 | 指标 | 口径 | | --- | --- | --- | | 用户 | `total` / `newToday` / `new7d` | `app_user` 未删除总数;按 `created_at` 落在当天 / 近 7 天 | | 用户 | `active7d` | `last_login_at` 在近 7 天内。**不是 DAU**,日活需要逐日登录快照,当前无产出 | | 用户 | `androidActive7d` / `iosActive7d` | `app_user_device` 中 `status=active` 且 `last_seen_at` 在近 7 天的去重设备,平台分别为 `ANDROID` / `IOS` | | 用户 | `retention1d` / `retention7d` | 次日留存:昨日注册且今天仍登录的比例。7 日留存:8 至 14 天前注册、近 7 天仍登录的比例。队列为空返回 `null`,取值 0 到 1 | | 用户 | `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` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求锁定集直接 返回 `UNAUTHORIZED`;已登录未解锁用户返回 `{"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`。 ### 2.13 运营分类管理(已实现) > 「分类」是运营自定义的分组,与短剧**类型**(`/admin/drama-categories`,写 `drama_category`)是两套独立概念。 > 分类存在 `drama_classification`,通过 `drama_classification_drama` 关联任意多部短剧。 > 后台页面:菜单 **分类管理**(`/content/category-dramas` → `CategoryDramaView.vue`)。 **接口清单**(前缀 `/admin/drama-classifications`) | 方法 | 路径 | 权限点 | 说明 | | --- | --- | --- | --- | | GET | `/admin/drama-classifications` | `drama:list` | 分类列表(带关联短剧数量) | | POST | `/admin/drama-classifications` | `drama:create` | 新建分类,返回新分类 id | | PUT | `/admin/drama-classifications/{id}` | `drama:update` | 编辑分类 | | DELETE | `/admin/drama-classifications/{id}` | `drama:delete` | 删除分类(级联删关联,短剧本身保留) | | GET | `/admin/drama-classifications/{id}/dramas` | `drama:list` | 分类下已关联的短剧(分页) | | GET | `/admin/drama-classifications/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) | | POST | `/admin/drama-classifications/{id}/dramas` | `drama:update` | 批量关联短剧,返回实际新增数量 | | DELETE | `/admin/drama-classifications/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) | **列表响应** ```json { "code": 0, "message": "ok", "data": [ { "id": "2112807182243336193", "name": "本周必看", "sort": 1, "status": 1, "dramaCount": 12, "updatedAt": "2026-10-22T19:41:02" } ] } ``` 排序规则固定为 `sort asc, id desc`;`dramaCount` 是关联表按分类聚合的实时数量。 时间字段为 `LocalDateTime` 的 ISO 字符串(`yyyy-MM-ddTHH:mm:ss`,秒级),前端按需替换 `T` 为空格。 **新建 / 编辑请求体** ```json { "name": "本周必看", "sort": 1, "status": 1 } ``` | 字段 | 必填 | 说明 | | --- | --- | --- | | `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写分类名称」 | | `sort` | 否 | 新建不传时取 `现有条数 + 1`;编辑不传(null)时保持原值 | | `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1`,编辑不传保持原值 | **关联短剧** ```json POST /admin/drama-classifications/2112807182243336193/dramas { "dramaIds": ["2112807182243336194", "2112807182243336195"] } ``` 返回 `data` 为实际新增条数(整数)。已关联的、以及不存在的 `dramaId` 会被静默跳过,不报错; 全部跳过时返回 `0`。`dramaIds` 为空数组 → `400 请选择短剧`。 **短剧列表项(`GET /{id}/dramas` 与 `/dramas/candidates` 共用)** ```json { "total": 12, "list": [ { "id": "2112807182243336194", "title": "闪婚后首富老公竟是我老板", "cover": "https://.../cover.jpg", "status": "published", "totalEpisodes": 80, "sort": 100, "updatedAt": "2026-10-22T18:00:00" } ] } ``` - 查询参数:`keyword`(按 `title` 模糊匹配,可空)、`pageIndex`(默认 1)、`pageSize`(默认 10,上限 100) - 排序:`sort desc, id desc` - `status` 取值:`draft` 草稿 / `reviewing` 审核中 / `approved` 待发布 / `published` 已上线 / `offline` 已下线 - `cover` 取自短剧 `cover_url` **错误码** | 场景 | 返回 | | --- | --- | | 分类名称重复(新建或改名撞已有) | `200001 分类名称已存在` | | 分类 id 不存在(编辑/删除/查询/关联/移出) | `200002 分类不存在` | | `name` 为空 | `400 请填写分类名称` | | `dramaIds` 为空 | `400 请选择短剧` | > 所有写操作带 `@OperationLog(module = "内容管理")` 操作日志。ID 下发口径见 **§0**。 ### 2.14 剧单管理(已实现) > 剧单使用**自己的一套分类**(`drama_playlist_category`),与「分类管理」和「类型」都没有关系。 > **没有「剧单」这一层实体**:分类直接挂短剧,关联表 `drama_playlist_category_drama(category_id, drama_id)`。 > 后台页面:菜单 **剧单管理**(`/content/drama-playlists` → `DramaPlaylistView.vue`)。 **接口清单**(前缀 `/admin/drama-playlist-categories`) | 方法 | 路径 | 权限点 | 说明 | | --- | --- | --- | --- | | GET | `/admin/drama-playlist-categories` | `drama:list` | 剧单分类列表(带短剧数量) | | POST | `/admin/drama-playlist-categories` | `drama:create` | 新建剧单分类,返回新分类 id | | PUT | `/admin/drama-playlist-categories/{id}` | `drama:update` | 编辑剧单分类 | | DELETE | `/admin/drama-playlist-categories/{id}` | `drama:delete` | 删除剧单分类(级联删关联,短剧本身保留) | | GET | `/admin/drama-playlist-categories/{id}/dramas` | `drama:list` | 分类下已关联的短剧(分页) | | GET | `/admin/drama-playlist-categories/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) | | POST | `/admin/drama-playlist-categories/{id}/dramas` | `drama:update` | 批量把短剧加入分类,返回实际新增数量 | | DELETE | `/admin/drama-playlist-categories/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) | **列表响应** ```json { "code": 0, "message": "ok", "data": [ { "id": "2112807182243336196", "name": "高甜先婚后爱", "description": "站内投放位使用,按周更新", "sort": 1, "status": 1, "dramaCount": 8 } ] } ``` 排序规则 `sort asc, id desc`;`dramaCount` 为该分类下已关联短剧数量。 **新建 / 编辑请求体** ```json { "name": "高甜先婚后爱", "description": "站内投放位使用,按周更新", "sort": 1, "status": 1 } ``` | 字段 | 必填 | 说明 | | --- | --- | --- | | `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写剧单分类名称」 | | `description` | 否 | 最长 255 字符(超长 → `400 描述最多 255 个字符`);新建不传存空串,编辑不传保持原值 | | `sort` | 否 | 新建不传取 `现有条数 + 1`;编辑不传保持原值 | | `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1` | **关联短剧** ```json POST /admin/drama-playlist-categories/2112807182243336196/dramas { "dramaIds": ["2112807182243336194"] } ``` 返回实际新增条数;已关联或不存在的短剧自动跳过;`dramaIds` 为空 → `400 请选择短剧`。 `GET /{id}/dramas` 与 `GET /{id}/dramas/candidates` 的查询参数和响应结构与 **§2.13** 完全一致 (`keyword` / `pageIndex` / `pageSize`,响应 `{ total, list }`,列表项含 `id` / `title` / `cover` / `status` / `totalEpisodes` / `sort` / `updatedAt`)。 **错误码** | 场景 | 返回 | | --- | --- | | 剧单分类名称重复 | `200001 剧单分类名称已存在` | | 剧单分类 id 不存在 | `200002 剧单分类不存在` | | `name` 为空 | `400 请填写剧单分类名称` | | `description` 超过 255 | `400 描述最多 255 个字符` | | `dramaIds` 为空 | `400 请选择短剧` | **与「分类管理」的差异** | 项 | 运营分类(2.13) | 剧单分类(2.14) | | --- | --- | --- | | 数据表 | `drama_classification` + `drama_classification_drama` | `drama_playlist_category` + `drama_playlist_category_drama` | | 描述字段 | 无 | 有(`description`,≤255) | | 列表返回 | 含 `updatedAt` | 含 `description`,不含 `updatedAt` | | 菜单 | 分类管理(`/content/category-dramas`) | 剧单管理(`/content/drama-playlists`) | | 用途 | 内容侧分组 | 运营侧投放/剧单位分组 | ## 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)、压测 未做** |