txagent-y/docs/dev/api/API-04-内容系统.md

902 lines
36 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.

# API-04 内容系统CONTENT
> 共 17 个接口CONTENT-01~17
### 内容类型枚举post.type
| DB值 | API值 | 说明 |
|------|------|------|
| 1 | post | 动态图文1-9 张图)|
| 2 | video | 视频(不区分时长)|
> PPV 素材**不在此枚举**,独立走 API-06 PPV 系统(数据模型完全不同)。发布页 UI 上"发布 PPV 素材"按钮直接跳转到 PPV 创建流程。
### 状态枚举映射post.status
| DB值 | API响应值 | 说明 |
|------|----------|------|
| 0 | draft | 草稿(仅创作者本人可见) |
| 1 | reviewing | 审核中 |
| 2 | published | 已上架 |
| 3 | rejected | 审核拒绝 |
| 4 | removed | 已下架 |
### 可见性与解锁关系
| visibilityAPI值 | DB值 | 额外字段 | 解锁规则 | 说明 |
|---------------------|------|----------|----------|------|
| free | 1 | 无 | 所有人可见 | 免费公开内容 |
| subscriber | 2 | `requiredTierLevel` | 当前用户存在有效订阅,且 `tier_level >= requiredTierLevel` | 订阅可见内容 |
| ppv | 3 | `price` | 作者本人或已存在 `content_unlock` 记录 | 内容单独付费解锁 |
> **发布约束:**
> 1. `visibility=subscriber` 时,必须额外传 `requiredTierLevel`,且该档位必须已在订阅设置中启用。
> 2. `visibility=ppv` 时,必须额外传 `price`,且 `price > 0`。
> 3. 非 `ppv` 内容不允许传正数 `price`。
### 关于评论commentCount
> 评论系统已在 P1 阶段上线,对应接口为 CONTENT-13~17。`commentCount` 由 `post.comment_count` 字段实时返回。
---
#### CONTENT-01 发布内容(含草稿)
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content |
| 接口用途 | 创作者发布动态/视频,或保存为草稿 |
| 权限要求 | 创作者user.role=2 |
| 涉及表 | post, post_media, post_tag, post_mention, post_highlight, media, tag |
**接口逻辑:**
1. 校验当前用户 user.role=2创作者权限
2. 校验 mediaIds查询 `media` 表 确认 process_status=2 且 user_id=当前用户
3.`type=video`:校验 `coverMediaId` 必传,且对应 `media` 必须为当前创作者自己的已处理完成图片媒体;非视频内容可不传,传了也按同样规则校验
4. 校验 tags1-3 个):每项 `id``name` 二选一。传 `id``tag` 表存在性校验;只传 `name` 时若同名 tag 已存在则复用,否则当场创建 source=2 的自定义标签(等价于内联 CONTENT-12
5. 校验 mentions查询 `user` 表 确认被 @ 用户存在
6. 若 type=2video且包含 highlights校验 time_offset_seconds < 视频时长
7. 校验可见性
- `visibility=subscriber`校验 `requiredTierLevel` 必填且该档位在当前创作者的 `subscription_tier` 中存在且 `is_active=true`
- `visibility=ppv`校验 `price > 0`
- 其它可见性不允许传正数 `price`
8. 写入 `post` status=isDraft?0:1写入 location_name/codevisibilityrequired_tier_levelprice_cents `coverMediaId` 时写入 `post.cover_media_id`否则回退到首个内容媒体
9. 写入关系表post_media / post_tag / post_mention / post_highlight
10. 草稿isDraft=true跳过审核流程非草稿进入审核队列
11. 返回新创建的内容信息
**请求参数Body**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| title | string | | 标题最长 30 |
| content | string | | 正文内容最长 200 |
| type | string | | 内容类型post/video |
| mediaIds | int64[] | | 媒体ID数组图片最多9张或1个视频 |
| coverMediaId | string | | 显式封面图媒体ID。`type=video` 时必填必须是当前创作者自己的已处理完成图片媒体其它类型可不传 |
| tags | object[] | | 标签数组1-3 每项 `id` `name` 二选一 |
| tags[].id | string | | 已存在的标签 ID系统预设或已建好的自定义标签 |
| tags[].name | string | | 仅在不传 `id` 时使用同名复用否则新建自定义标签source=2长度 1~32 |
| mentions | string[] | | @ 用户的 userId 数组最多 10 |
| location | object | | 地点信息 |
| location.name | string | | 地点显示名"上海 静安区"最长 64 |
| location.code | string | | 行政区划代码GB/T 2260最长 20 |
| visibility | string | | 可见性`free` / `subscriber` / `ppv` |
| requiredTierLevel | number | | `visibility=subscriber` 时必填表示最低可见订阅档位 |
| price | number | | `visibility=ppv` 时必填单位分其它可见性不传或传 0 |
| highlights | array | | 视频章节标记数组type=video 用于在播放进度条上打点跳转 |
| highlights[].timeOffsetSeconds | int | | 标记时间点必须 < 视频时长 |
| highlights[].label | string | | 标记说明文字最长 40 |
| isDraft | boolean | | 是否保存为草稿默认 false |
**校验返回码:**
- 422: 字段超长 / 标签数量不在 1-3 / 媒体处理未完成 / highlights 时间点超出视频时长 / subscriber 档位未启用
- 403: 非创作者 / 媒体不属于当前用户
- 404: 标签/@用户不存在
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| contentId | string | 内容ID |
| title | string | 标题 |
| content | string | 正文 |
| type | string | 内容类型 |
| mediaIds | int64[] | 媒体ID列表 |
| tags | object[] | 标签列表 [{id,name,source}] |
| mentions | object[] | @ 用户列表 [{userId,nickname}] |
| location | object | 地点信息 |
| visibility | string | 可见性free / subscriber / ppv |
| requiredTierLevel | number | subscriber 内容的最低可见档位 |
| visibilityTierName | string | subscriber 内容对应档位的展示名free/ppv 时为空 |
| visibilityTierPrice | number | subscriber 内容对应档位价格free/ppv 时为 0 |
| highlights | array | 视频章节标记列表 |
| status | string | 状态draft / reviewing |
| isDraft | boolean | 是否草稿 |
| createdAt | timestamp | 创建时间 |
---
#### CONTENT-02 获取广场 Feed
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/feed |
| 接口用途 | 获取广场信息流双列瀑布流 |
| 权限要求 | 公开游客可访问 |
| 涉及表 | post, post_tag, post_media, tag, category, category_tag, user, media, content_boost, recommend_slot |
**接口逻辑:**
1. 根据 sort 参数选择排序策略
- recommend70%算法推荐 + 20%新创作者池 + 10%运营位查询 `recommend_slot`
- latest post.created_at 倒序
- hot按浏览量+点赞数加权排序
2. 处理分类筛选categoryId tag 二选一
- 如果传 categoryId`SELECT post.* FROM post JOIN post_tag pt ON pt.post_id = post.id JOIN category_tag ct ON ct.tag_id = pt.tag_id WHERE ct.category_id = :categoryId`(读)
- 如果传 tag`SELECT post.* FROM post JOIN post_tag pt ON pt.post_id = post.id JOIN tag t ON t.id = pt.tag_id WHERE t.name = :tag`(读)
- 如果都不传不加标签过滤返回全部
3. 查询 `post` WHERE status=2已上架
4. 关联查询 `user` 获取创作者信息profile.display_nameprofile.avatar_url
5. 关联查询 `media` 获取封面图/缩略图)。优先使用 `post.cover_media_id` 对应媒体生成 `coverUrl`未设置时回退到内容首张图片
6. 关联查询 `content_boost` 获取内容注水数据外显数据 = 真实值 + boost值
> 注:`content_boost` 用于单条内容的注水view/like/favorite`creator_boost` 用于创作者主页的注水follower/subscriber后者在 API-08 CREATOR-02 中使用。
7. 如果当前用户已登录批量查询 `user_subscription` `content_unlock`
- `subscriber` 内容按 `tier_level >= requiredTierLevel` 判断是否解锁
- `ppv` 内容仅按 `content_unlock` 判断是否已购
- 返回 `isLocked / isUnlocked / ppvPrice`
8. 分页返回内容卡片列表
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| sort | string | | 排序方式recommend/latest/hot默认recommend |
| categoryId | string | | 分类ID首页 Tab 分类筛选内部通过 `category_tag` JOIN `post_tag` 匹配帖子 |
| tag | string | | 标签筛选tag.name categoryId 二选一 |
| page | number | | 页码默认1 |
| pageSize | number | | 每页条数默认20 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| list | array | 内容卡片列表 |
| list[].contentId | string | 内容ID |
| list[].title | string | 标题 |
| list[].coverUrl | url | 封面图URL |
| list[].blurCoverUrl | url | 模糊封面URL |
| list[].type | string | 类型post/video |
| list[].duration | number | 视频时长)。type video 时返回非视频或无媒体时省略 |
| list[].videoUrl | url | 视频播放签名URL广场/关注 Feed 中仅在 `isLocked=false` 时返回 |
| list[].visibility | string | 可见性free / subscriber / ppv |
| list[].visibilityTierName | string | subscriber 内容的档位展示名free/ppv 时为空 |
| list[].visibilityTierPrice | number | subscriber 内容档位价格free/ppv 时为 0 |
| list[].ppvPrice | number | `visibility=ppv` 时返回内容价格 |
| list[].isUnlocked | boolean | 当前用户是否已解锁 |
| list[].isPinned | boolean | 是否置顶 |
| list[].hasHighlights | boolean | 是否包含视频章节标记视频类型时 |
| list[].creator | object | 创作者信息 |
| list[].creator.userId | string | 创作者ID |
| list[].creator.nickname | string | 创作者昵称 |
| list[].creator.avatar | url | 创作者头像 |
| list[].creator.isOnline | boolean | 是否在线 |
| list[].viewCount | number | 浏览量含boost |
| list[].likeCount | number | 点赞数含boost |
| list[].favoriteCount | number | 收藏数含boost |
| list[].commentCount | number | 评论数 |
| list[].isLocked | boolean | 是否需订阅/付费 |
| list[].tags | object[] | 标签列表 [{id,name}] |
| list[].createdAt | timestamp | 发布时间 |
| pagination | object | 分页信息 |
---
#### CONTENT-02b 获取关注 Feed
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/feed/following |
| 接口用途 | 首页"关注" tab返回当前用户已关注创作者发布的内容 |
| 权限要求 | 需登录JWT |
| 涉及表 | user_follow, post, post_media, media, user_profile, subscription_tier, user_subscription |
**接口逻辑:**
1. JWT viewer_id
2. 查询 `user_follow` 取当前用户全部 `followee_id``deleted_at IS NULL`若为空直接返回空列表
3. 调用与 CONTENT-02 相同的 `ListFeed` `post` 上追加 `author_id = ANY($followeeIds)` 过滤status=2 published_at 倒序
4. 组装逻辑与 CONTENT-02 4~7 步一致作者资料媒体封面/时长订阅档位isLocked 计算
5. 分页返回
**请求参数Query** CONTENT-02 一致sort/categoryId/tag/page/pageSize
**响应数据:** CONTENT-02 `ContentFeedResp` 完全一致list[] FeedItem
---
#### CONTENT-02c 获取视频播放流
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/video-feed |
| 接口用途 | 从首页/分类页点击某个视频后进入沉浸式视频流返回列表首条为当前点击的视频 |
| 权限要求 | 公开游客可访问 |
| 涉及表 | post, post_tag, tag, category, post_media, media, user_profile, subscription_tier, user_subscription |
**接口逻辑:**
1. 根据 `seedContentId` 查询种子内容不存在返回 40401
2. 校验种子内容必须为 `video`否则返回 40001
3. 校验种子内容状态必须为已发布未发布/已下架按不存在处理
4. 复用广场 Feed 的上下文参数`sort/categoryId/tag`
5. `post` 中查询同上下文的候选视频流`status=2 AND type=video`,并排除 `seedContentId`
6. `offset=0` 返回列表首条固定插入 seed 视频其余 `pageSize-1` 条由候选视频补足
7. `offset>0` 不再重复返回 seed 视频只返回后续候选视频
8. 列表项组装逻辑与 CONTENT-02 一致复用 `FeedItem` 字段口径封面时长锁态创作者信息等
9. 为兼容沉浸式播放器预加载`video-feed` 会补充 `videoUrl`前端应以 `isLocked / isUnlocked` 判断是否真正解锁
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| seedContentId | string | | 当前点击的视频内容 ID |
| sort | string | | 排序方式recommend/latest/hot默认 recommend |
| categoryId | string | | 分类 ID沿用首页/分类页上下文 |
| tag | string | | 标签筛选沿用来源页上下文 |
| offset | number | | 候选视频偏移量默认 0 |
| pageSize | number | | 每页条数默认 20最大 50 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| list | array | 视频播放流列表`list[0]` `offset=0` 时固定为 seed 视频 |
| list[] | FeedItem | 字段结构同 CONTENT-02 |
| nextOffset | number | 下一次请求应继续使用的 offset |
| hasMore | boolean | 是否还有后续视频 |
---
#### CONTENT-03 获取内容详情
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/:contentId |
| 接口用途 | 获取单条内容详情 |
| 权限要求 | 公开作者本人带登录态时可查看自己未下架的草稿/审核中/拒绝内容 |
| 涉及表 | post, post_tag, tag, post_media, media, user_profile, post_like, post_favorite, subscription_tier, user_subscription |
**接口逻辑:**
1. 查询 `post` 根据 contentId 获取内容
2. 访问控制
- `status=published`公开可访问
- `status!=published`仅作者本人带登录态且内容未下架`status!=removed`时可访问
- 其它情况统一返回 `40401 内容不存在`
3. 浏览计数
- `published` 内容计入公开浏览量
- 作者查看自己的草稿/审核中/拒绝内容不增加 `viewCount`
4. 关联查询 `user_profile` 获取创作者信息
5. 查询 `media` 获取关联媒体信息
6. 查询 `user_subscription` 判断当前用户订阅状态
- **有权限**返回图片原图 URL / 视频播放 URL
- **无权限**图片不返回原图 URL仅返回 `blurUrl`视频不返回 `videoUrl`返回 `videoBlurPreviewUrl`
7. 查询 `subscription_tier` 回填当前内容的 `visibilityTierName/visibilityTierPrice`
8. 查询 `post_like` 检查 isLikedWHERE user_id=me AND post_id=contentId
9. 查询 `post_favorite` 检查 isFavorited
10. 组装完整响应并返回当前内容 `status`
11. `subscriber` 内容按有效订阅 + `requiredTierLevel` 判断解锁`ppv` 内容只认 `content_unlock`不再因为存在任意订阅而放行
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | | 内容ID |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| contentId | string | 内容ID |
| creator | object | 创作者信息 `{userId,username,nickname,avatar,isOnline,isFollowing}` |
| title | string | 标题 |
| content | string | 正文 |
| type | string | 内容类型 post/video |
| status | string | 内容状态draft / reviewing / published / rejected / removed |
| images | array | 图片列表图文内容返回视频内容通常为空数组 |
| images[].mediaId | string | 媒体ID |
| images[].url | url | 原图签名URL已解锁时返回 |
| images[].blurUrl | url | 模糊图URL未解锁时返回 |
| images[].thumbnailUrl | url | 缩略图URL |
| images[].width | number | 图片宽度 |
| images[].height | number | 图片高度 |
| images[].isLocked | boolean | 是否锁定 |
| videoUrl | url | 视频签名URL已解锁时返回 |
| videoBlurPreviewUrl | url | 视频预览字段未解锁时返回 |
| videoCoverUrl | url | 视频封面URL |
| videoWidth | number | 视频宽度 |
| videoHeight | number | 视频高度 |
| tags | string[] | 标签名称列表 |
| visibility | string | 可见性free / subscriber / ppv |
| requiredTierLevel | number | subscriber 内容的最低可见档位 |
| visibilityTierName | string | subscriber 内容的档位展示名free/ppv 时为空 |
| visibilityTierPrice | number | subscriber 内容的档位价格free/ppv 时为 0 |
| ppvPrice | number | `visibility=ppv` 时返回内容价格 |
| isUnlocked | boolean | 当前用户是否已解锁ppv 内容仅按 `content_unlock` 判断 |
| isPinned | boolean | 是否置顶 |
| viewCount | number | 浏览量 |
| likeCount | number | 点赞数 |
| favoriteCount | number | 收藏数 |
| commentCount | number | 评论数 |
| isLiked | boolean | 是否已点赞 |
| isFavorited | boolean | 是否已收藏 |
| createdAt | timestamp | 发布时间 |
---
#### CONTENT-03A 解锁 PPV 内容
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/:contentId/unlock |
| 接口用途 | 解锁内容侧 PPV 内容 |
| 权限要求 | 需登录 |
| 涉及表 | post, content_unlock, wallet, wallet_transaction, revenue_share_record |
**接口逻辑:**
1. 查询并锁定 `post`
2. 校验内容存在已发布且 `visibility=ppv`
3. 校验当前用户不是作者本人
4. 查询 `content_unlock` 做幂等判断
- 已解锁直接返回成功`amount=0`
- 未解锁 `post.price_cents` 扣款写入 `content_unlock`写入分润记录
5. 返回最新余额与解锁结果
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | | 内容 ID |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| contentId | string | 内容 ID |
| amount | number | 本次实际扣费金额幂等命中时为 0 |
| remainingBalance | number | 扣费后的钱包余额 |
| isUnlocked | boolean | 是否已解锁固定返回 true |
---
#### CONTENT-04 获取预设标签列表
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/tags |
| 接口用途 | 获取平台标签系统预设 + 用户自定义热门标签 |
| 权限要求 | 公开 |
| 涉及表 | tag |
**接口逻辑:**
1. 未传 `keyword` 查询 `tag` 表中启用的系统预设标签`is_active=TRUE AND source=1`
2. 传入 `keyword` 按标签名称做模糊匹配`ILIKE %keyword%`返回命中的启用标签
3. keyword 搜索默认返回前 20 条结果
4. 返回标签列表
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| keyword | string | | 标签关键字按名称模糊搜索不传则返回默认预设标签列表 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| tags | array | 标签列表 |
| tags[].id | string | 标签ID |
| tags[].name | string | 标签名称 |
---
#### CONTENT-05 内容管理列表(创作者)
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/manage |
| 接口用途 | 创作者查看自己的内容列表 |
| 权限要求 | 创作者 |
| 涉及表 | post, media |
**接口逻辑:**
1. 校验当前用户为创作者
2. 查询 `post` WHERE creator_id=当前用户:
- 若显式传 `status`按该状态精确筛选
- 若不传 `status`默认排除 `removed`避免已删除/已下架内容仍出现在默认管理列表中
3. 关联查询 `media` 获取封面图
4. 分页返回 created_at 倒序
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| status | string | | 筛选draft/reviewing/published/rejected/removed不传时默认返回除 removed 外的内容 |
| page | number | | 页码 |
| pageSize | number | | 每页条数 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| list | array | 内容列表 |
| list[].contentId | string | 内容ID |
| list[].title | string | 标题 |
| list[].coverUrl | url | 封面图URL |
| list[].type | string | 类型 |
| list[].status | string | 状态 |
| list[].rejectReason | string | 拒绝原因rejected时 |
| list[].visibility | string | 可见性档位 keyfree/junior/basic/senior/supreme |
| list[].visibilityTierName | string | 档位展示名free 时为 null |
| list[].viewCount | number | 浏览量 |
| list[].likeCount | number | 点赞数 |
| list[].favoriteCount | number | 收藏数 |
| list[].commentCount | number | 评论数 |
| list[].isPinned | boolean | 是否置顶 |
| list[].createdAt | timestamp | 发布时间 |
| pagination | object | 分页信息 |
---
#### CONTENT-06 编辑内容
| 项目 | 说明 |
|------|------|
| 接口路径 | PUT /client/api/v1/content/:contentId |
| 接口用途 | 创作者修改已发布的内容 |
| 权限要求 | 创作者只能编辑自己的 |
| 涉及表 | post, post_tag, post_media, category_tag |
**接口逻辑(全程单事务):**
1. 查询 `post` 根据 contentId 获取内容
2. 校验 post.creator_id = 当前用户
3. 如果传 tags在同一事务内
- `DELETE FROM post_tag WHERE post_id = :contentId`
- 批量 `INSERT INTO post_tag (post_id, tag_id) VALUES ...`
4. 如果传 mediaIds同样 `DELETE FROM post_media WHERE post_id = :contentId` 然后批量 INSERT 重建
5. 更新 `post` title/content/visibility/required_tier_level/price_cents 等字段更新
6. 返回更新后的内容信息
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | | 内容ID |
**请求参数Body**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| title | string | | 标题最长 30 |
| content | string | | 正文内容最长 200 |
| tags | object[] | | 标签数组1-3 结构同 CONTENT-01每项 `id` `name` 二选一仅传 `name` 时同名复用或新建自定义标签 |
| mentions | string[] | | @ 用户的 userId 数组 |
| location | object | | 地点 {name, code} null 清空 |
| visibility | string | | 可见性`free` / `subscriber` / `ppv`不传则保持原值 |
| requiredTierLevel | number | | `visibility=subscriber` 时必填不传则保持原值 |
| price | number | | `visibility=ppv` 时必填且 > 0其它可见性传 0 或不传 |
| highlights | array | 否 | 视频撸点(仅视频类型) |
| publishDraft | boolean | 否 | 草稿提交发布true 时把 status 从 0 改为 1 进入审核 |
**响应数据:** 同 CONTENT-01 响应结构
---
#### CONTENT-07 删除/下架内容
| 项目 | 说明 |
|------|------|
| 接口路径 | DELETE /client/api/v1/content/:contentId |
| 接口用途 | 创作者删除或下架内容 |
| 权限要求 | 创作者(只能操作自己的) |
| 涉及表 | post |
**接口逻辑:**
1. 查询 `post` 表 根据 contentId 获取内容(读)
2. 校验 post.creator_id = 当前用户
3. 更新 `post` 表 的 status=4已下架更新
4. 返回成功
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | 是 | 内容ID |
**响应数据:** null
---
#### CONTENT-08 点赞/取消点赞
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/:contentId/like |
| 接口用途 | 用户对内容点赞或取消Toggle |
| 权限要求 | 需登录 |
| 涉及表 | post_like, post |
**接口逻辑:**
1. 查询 `post_like` 表 检查是否已有记录WHERE user_id=me AND post_id=contentId
2. **已点赞 → 取消**
- 软删除 `post_like` 表 中的记录(更新 deleted_at
- 更新 `post` 表 的 like_count-1更新
3. **未点赞 → 点赞**
- 写入 `post_like` 表(写)
- 更新 `post` 表 的 like_count+1更新
4. 返回当前点赞状态和最新点赞数
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | 是 | 内容ID |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| isLiked | boolean | 当前点赞状态 |
| likeCount | number | 最新点赞数 |
---
#### CONTENT-09 收藏/取消收藏
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/:contentId/favorite |
| 接口用途 | 用户收藏或取消收藏内容Toggle |
| 权限要求 | 需登录 |
| 涉及表 | post_favorite, post |
**接口逻辑:**
1. 查询 `post_favorite` 表 检查是否已有记录WHERE user_id=me AND post_id=contentId
2. **已收藏 → 取消**
- 软删除 `post_favorite` 表 中的记录(更新 deleted_at
- 更新 `post` 表 的 favorite_count-1更新
3. **未收藏 → 收藏**
- 写入 `post_favorite` 表(写)
- 更新 `post` 表 的 favorite_count+1更新
4. 返回当前收藏状态和最新收藏数
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | 是 | 内容ID |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| isFavorited | boolean | 当前收藏状态 |
| favoriteCount | number | 最新收藏数 |
---
#### CONTENT-10 获取收藏列表
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/favorites |
| 接口用途 | 用户查看自己收藏的内容 |
| 权限要求 | 需登录 |
| 涉及表 | post_favorite, post, media, user |
**接口逻辑:**
1. 查询 `post_favorite` 表 WHERE user_id=当前用户,按 created_at 倒序(读)
2. 关联查询 `post` 表 获取内容信息(读)
3. 关联查询 `media` 表 获取封面图(读)
4. 关联查询 `user` 表 获取创作者信息(读)
5. 分页返回,结构同 CONTENT-02 列表,多一个 favoritedAt 字段
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码 |
| pageSize | number | 否 | 每页条数 |
**响应数据:** 同 CONTENT-02 列表结构,额外增加 `favoritedAt`timestamp字段
---
#### CONTENT-10A 批量取消收藏
| 项目 | 说明 |
|------|------|
| 接口路径 | DELETE /client/api/v1/content/favorites |
| 接口用途 | 收藏列表场景下批量取消收藏 |
| 权限要求 | 需登录 |
| 涉及表 | post_favorite, post |
**接口逻辑:**
1. 读取请求体 `ids`,要求至少 1 个,最多 100 个
2.`ids` 做去重;若存在空值则返回 `40001`
3. 将当前用户在这些 `post_id` 上仍处于有效状态的收藏记录批量软删除(更新 `deleted_at`
4. 仅对本次实际删除成功的内容执行 `post.favorite_count - 1`
5. 对于未收藏、已取消收藏或重复传入的 id不报错直接忽略
6. 返回实际删除成功的 `removedIds``removedCount`
**请求参数Body**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| ids | string[] | 是 | 要批量取消收藏的内容 ID 列表,最多 100 个 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| removedIds | string[] | 本次实际取消收藏成功的内容 ID 列表 |
| removedCount | number | 本次实际取消收藏成功的数量 |
---
#### CONTENT-11 获取首页分类列表
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/categories |
| 接口用途 | 获取首页分类 Tab 列表(美妆/旅行/美食等),用于首页顶部 Tab 渲染 |
| 权限要求 | 公开 |
| 涉及表 | category |
**接口逻辑:**
1. 查询 `category` 表 WHERE is_active=1按 sort_order 正序(读)
2. 返回分类列表(仅返回 id/name/icon 等展示字段,不返回标签关联细节)
3. 前端拿到 categoryId 后,请求 CONTENT-02 `/client/api/v1/content/feed?categoryId=xxx` 获取该分类下的内容列表
> **分类与帖子的关联方式:**
> - 分类在后台配置时绑定多个预设标签(关系存于 `category_tag` 关联表)
> - 帖子的标签存于 `post_tag` 关联表,帖子本身不直接绑定 category
> - 首页按分类取内容 = `JOIN post_tag pt ON pt.post_id = post.id JOIN category_tag ct ON ct.tag_id = pt.tag_id WHERE ct.category_id = :categoryId`
**请求参数:**
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| categories | array | 分类列表 |
| categories[].categoryId | string | 分类ID |
| categories[].name | string | 分类名称(如:美妆、旅行、美食) |
| categories[].icon | url | 分类图标URL可空 |
| categories[].sortOrder | number | 排序序号 |
---
#### CONTENT-12 创建自定义标签
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/tags/custom |
| 接口用途 | 标签管理场景下预先创建自定义标签。发布/编辑内容CONTENT-01/06已支持在 `tags[].name` 内联新建,常规发布流程无需先调本接口 |
| 权限要求 | 创作者 |
| 涉及表 | tag |
**接口逻辑:**
1. 校验当前用户为创作者
2. 校验 name 长度 1-32 字
3. 查询 `tag` 表 WHERE name=输入值,若已存在直接返回该 tag去重
4. 命中敏感词库 → 422 拒绝
5. 写入 `tag`source=2created_by=当前用户is_active=1
6. 返回新创建的 tag
**请求参数Body**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| name | string | 是 | 标签名1-32 字 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int64 | 标签ID |
| name | string | 标签名 |
| source | number | 1=系统 2=自定义 |
---
### 评论系统CONTENT-13~17
#### CommentVO 结构
所有评论接口返回的评论对象结构如下:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 评论IDint64 序列化为字符串) |
| postId | string | 所属帖子ID |
| parentId | string | 父评论ID顶层评论时为空串 |
| userId | string | 评论者用户ID |
| nickname | string | 评论者昵称 |
| avatar | string | 评论者头像URL |
| content | string | 评论内容 |
| likeCount | number | 点赞数 |
| isLiked | boolean | 当前用户是否已点赞;匿名访问时固定 false |
| replyCount | number | 回复数;仅顶层评论列表有效,回复列表中固定 0 |
| isAuthor | boolean | 评论者是否为帖子作者 |
| isMine | boolean | 是否为当前用户所发;匿名访问时固定 false |
| createdAt | number | 发布时间Unix 秒) |
---
#### CONTENT-13 获取帖子评论列表
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/:contentId/comments |
| 接口用途 | 获取指定帖子的顶层评论列表,支持分页 |
| 权限要求 | 可匿名(登录后返回 isLiked/isMine 真实值) |
| 涉及表 | post_comment, post_comment_like, user_profile |
**接口逻辑:**
1. 校验 contentId 非空,查询 `post` 表确认帖子存在(读)
2. 查询 `post_comment` 表 WHERE post_id=contentId AND parent_id IS NULL AND deleted_at IS NULL按 created_at 倒序分页(读)
3. 批量查询 `user_profile` 获取评论者昵称/头像(读)
4. 批量统计各顶层评论的 reply_count子查询 WHERE parent_id IN (...))(读)
5. 若已登录,批量查询 `post_comment_like` 获取当前用户点赞状态;匿名时 isLiked/isMine 固定 false
6. 返回分页列表
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | 是 | 帖子ID |
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页条数,默认 20 |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| list | CommentVO[] | 评论列表,见上方 CommentVO 结构 |
| pagination.total | number | 总条数 |
| pagination.page | number | 当前页码 |
| pagination.pageSize | number | 每页条数 |
---
#### CONTENT-14 获取评论回复列表
| 项目 | 说明 |
|------|------|
| 接口路径 | GET /client/api/v1/content/comments/:commentId/replies |
| 接口用途 | 获取指定顶层评论下的一级回复列表,支持分页 |
| 权限要求 | 可匿名(登录后返回 isLiked/isMine 真实值) |
| 涉及表 | post_comment, post_comment_like, user_profile |
**接口逻辑:**
1. 解析 commentIdint64查询 `post_comment` 表确认评论存在(读)
2. 校验该评论为顶层评论parent_id IS NULL否则返回 400
3. 查询 `post_comment` 表 WHERE parent_id=commentId AND deleted_at IS NULL按 created_at 正序分页(读)
4. 批量查询 `user_profile` 获取昵称/头像(读)
5. 若已登录,批量查询 `post_comment_like` 获取点赞状态;匿名时固定 false
6. 返回分页列表replyCount 固定为 0不递归嵌套
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| commentId | string | 是 | 顶层评论ID |
**请求参数Query**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页条数,默认 20 |
**响应数据:** 同 CONTENT-13replyCount 固定为 0
---
#### CONTENT-15 发表评论
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/:contentId/comments |
| 接口用途 | 用户对帖子发表评论或回复顶层评论(一级嵌套) |
| 权限要求 | 需登录 |
| 涉及表 | post_comment, post, user_profile |
**接口逻辑:**
1. 校验 content 非空且不超过 500 字Unicode 字符数)
2. 查询 `post` 表确认帖子存在(读)
3. 若传了 parentId
- 查询 `post_comment` 表确认父评论存在(读)
- 校验父评论必须为顶层评论(不允许回复回复,只支持一级嵌套)
- 校验父评论的 post_id 与当前 contentId 一致,防跨帖回复
4. 写入 `post_comment` 表(写)
5. 返回新评论的 CommentVO
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| contentId | string | 是 | 帖子ID |
**请求参数Body**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| content | string | 是 | 评论内容,最长 500 字 |
| parentId | string | 否 | 父评论ID不传或传空串表示顶层评论 |
**响应数据:** CommentVO见上方结构
---
#### CONTENT-16 删除评论
| 项目 | 说明 |
|------|------|
| 接口路径 | DELETE /client/api/v1/content/comments/:commentId |
| 接口用途 | 评论作者删除自己的评论(软删除) |
| 权限要求 | 需登录;仅评论作者本人可删 |
| 涉及表 | post_comment |
**接口逻辑:**
1. 解析 commentId查询 `post_comment` 表确认评论存在(读)
2. 校验当前用户 = 评论的 user_id不匹配返回 403
3. 软删除:更新 `post_comment.deleted_at`,同步更新 `post.comment_count - 1`(更新)
4. 返回 null
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| commentId | string | 是 | 评论ID |
**响应数据:** null
---
#### CONTENT-17 评论点赞/取消点赞
| 项目 | 说明 |
|------|------|
| 接口路径 | POST /client/api/v1/content/comments/:commentId/like |
| 接口用途 | 对评论点赞或取消点赞Toggle |
| 权限要求 | 需登录 |
| 涉及表 | post_comment_like, post_comment |
**接口逻辑:**
1. 解析 commentId查询 `post_comment` 表确认评论存在(读)
2. 查询 `post_comment_like` 表检查是否已点赞WHERE user_id=me AND comment_id=commentId
3. **已点赞 → 取消**
- 删除 `post_comment_like` 记录(更新)
- `post_comment.like_count - 1`(更新)
4. **未点赞 → 点赞**
- 写入 `post_comment_like`(写)
- `post_comment.like_count + 1`(更新)
5. 返回当前点赞状态和最新点赞数
**路径参数:**
| 字段 | 类型 | 必选 | 说明 |
|------|------|------|------|
| commentId | string | 是 | 评论ID |
**响应数据:**
| 字段 | 类型 | 说明 |
|------|------|------|
| isLiked | boolean | 当前点赞状态 |
| likeCount | number | 最新点赞数 |