Browse Source

docs(spec): public about page and media refs ownerType

Made-with: Cursor
main
npmrun 6 hours ago
parent
commit
b337a00c38
  1. 108
      docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md

108
docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md

@ -0,0 +1,108 @@
# 设计:公开「关于我」详情页 + 资料媒体引用(ownerType)
**日期**:2026-04-18
**状态**:已定稿(与产品选择一致:方案 B + 公开路由 ①)
## 1. 背景与目标
- 公开主页 `/@:publicSlug` 已展示 `users.bio_markdown`(在 `bioVisibility === public` 时经聚合接口下发),但**没有独立路由**承载「生平」长文的阅读体验。
- 文章图片的引用真相在 `post_media_refs`;**个人简介 Markdown 与头像 URL** 若包含本站 `/public/assets/...` 资源,当前**不会**同步到引用表,孤儿审查可能误判或漏判。
**目标**:
1. 新增公开路由 **`/@:publicSlug/about`**,用于专注展示已公开的生平 Markdown(与现有 `bio` 数据同源,不复制到 `posts`)。
2. 扩展媒体引用模型:用 **`ownerType` + `ownerId`** 统一表示「引用主体」,使 **文章****个人资料(简介 + 头像)** 共用同一套孤儿判定与时间戳调和逻辑。
## 2. 产品规则
| 维度 | 规则 |
|------|------|
| 路由 | `/@:publicSlug/about`,`layout: 'public'`,与现有 `@[publicSlug]/*` 一致。 |
| 可访问性 | 仅当 **`bioVisibility === 'public'`** 且 **`bioMarkdown` 非空(经 trim)** 时返回页面 **200**;否则 **404**(与「无公开简介则不提供该 URL」一致,避免空壳与权限歧义)。 |
| `publicSlug` | 无效、用户非 `active`**404**,与现公开 profile 行为一致。 |
| 数据真相 | 生平内容仍为 **`users.bio_markdown`**;about 页不做第二份存储。 |
| 主页关系 | 公开主页可继续展示当前 bio 区块;**建议**在 bio 区域增加指向 **`/about` 的入口**(文案如「查看全文」),当且仅当 about 页本会 200 时显示。具体文案与组件位置在实现计划中锁定。 |
## 3. 媒体引用模型(方案 B)
### 3.1 表结构方向
将现有 **`post_media_refs`** 演进为通用引用表(实现可选用 **重命名**`media_refs` 或保留表名但扩展列;**推荐重命名**以减少「post_」误导)。
建议列:
| 列 | 说明 |
|----|------|
| `owner_type` | `text`,取值 **`post`** \| **`profile`**(实现可用常量 + 校验;禁止其他值写入)。 |
| `owner_id` | `integer`,与 `owner_type` 联合解释:`'post'` → `posts.id`;`'profile'` → **`users.id`**(该用户的资料级引用集合,每个用户至多一条 profile 引用集合,由 `(owner_type, owner_id)` 唯一标识)。 |
| `asset_id` | `integer`,`NOT NULL`,外键 **`media_assets.id`**,`ON DELETE CASCADE`(与现行为一致)。 |
**主键**:`(owner_type, owner_id, asset_id)`。
**索引**:保留 **`asset_id`** 上索引,供「某 asset 是否仍被引用」批量查询。
**外键说明**:SQLite 难以对 `owner_id` 做多态外键;**不**为 `owner_id` 声明指向 `posts`/`users` 的双向外键。参照完整性由应用层保证:
- **`post`**:`owner_id` 必须对应当前存在的 `posts.id`,且在 **删除文章** 时**必须**删除 `owner_type='post' AND owner_id=<postId>` 的全部行(再删 post 或依赖事务顺序),并照常对涉及 `asset_id` 调用 **`reconcileAssetTimestampsAfterRefChange`**。
- **`profile`**:`owner_id` 必须等于该资料所属 `users.id`;用户删除若已级联删除其 `media_assets`,则依赖 `asset_id` 级联清理 ref 行即可。
### 3.2 URL 解析范围(profile)
与文章一致:从 **`bioMarkdown`** 中按现有规则提取本站图片 URL;从 **`avatar`** 字段提取封面式 URL(与 `mergePostMediaUrls` / `extractMediaUrlsFromCover` 同一前缀与规范化规则)。**外链忽略**,不写入引用。
新增 **`syncProfileMediaRefs(userId, bioMarkdown, avatarUrl)`**(命名以实现为准):
- 删除 `owner_type='profile' AND owner_id=userId` 的旧行;
- 解析 URL → `storageKey` → 在 **`media_assets``user_id=userId`** 下解析 `asset_id`
- 插入新行;对变更涉及的 `asset_id` 调用 **`reconcileAssetTimestampsAfterRefChange`**。
**`updateProfile`**(或 `PUT /api/me/profile` 成功路径)中,当 **`bioMarkdown``avatar`** 任一参与更新时,在事务提交后调用上述同步(顺序与现 `syncPostMediaRefs` 一致:先落库用户字段再同步引用)。
### 3.3 孤儿与删除
- **孤儿定义**:`media_assets` 中某行 **不存在** 任意 `media_refs` 行以其为 `asset_id`(与现「无 post 引用」等价,扩展为「无任何 owner 引用」)。
- **`reconcileAssetTimestampsAfterRefChange`**:对每个 `assetId` 统计 **全表** `media_refs``asset_id` 计数,替代现仅查 `post_media_refs` 的逻辑。
- **手动删除媒体 / 全局清扫**:删除前「仍被引用」判定改为 **任意 `media_refs`** 计数 > 0;错误文案可泛化为「资源仍被引用」或区分 owner(可选,非必须)。
### 3.4 数据迁移
- 将现有 `post_media_refs` 全量迁移为 `owner_type='post'`,`owner_id=post_id`,`asset_id` 不变。
- 部署后可选:**后台任务或懒执行** 对已有用户跑一次 `syncProfileMediaRefs`,修复历史简介/头像中的站内图未登记问题(实现计划写明是否必须上线即跑)。
## 4. API
**推荐**新增轻量接口(避免 about 页拉全量 profile 聚合):
| 方法 | 路径 | 行为 |
|------|------|------|
| GET | `/api/public/profile/:publicSlug/about` | 当且仅当 slug 有效、用户 active、且公开 bio 非空时返回渲染所需字段(如 `user` 展示子集、`bio: { markdown }`);否则 **404**。 |
字段形状与现 `GET .../profile/:publicSlug``user` / `bio` 对齐即可,减少前端类型分叉。
## 5. 前端
| 文件 | 职责 |
|------|------|
| `app/pages/@[publicSlug]/about/index.vue` | 请求上述 GET;渲染 Markdown(与主页 bio 使用同一套渲染组件或工具,避免两套样式分叉);处理 404(`UAlert` / 现站一致)。 |
| `app/pages/@[publicSlug]/index.vue` | 条件展示链向 `/${slug}/about` 的入口(见 §2)。 |
**SEO**:轻量 `title` / `description`(如昵称 + 「关于」),不做重投入。
## 6. 非目标
- 时光机 `body_markdown`、评论等非 posts/profile 的引用扩展(可另开 spec)。
- 将生平迁入 `posts` 表或单独 slug 文章。
- 公开 bio 为空时仍展示占位 about 页。
## 7. 测试与验收
- **手工**:公开用户含站内图的 bio,保存资料后孤儿列表**不**将该图标为孤儿;从 bio 中移除图片并保存后,按宽限期规则进入孤儿流。
- **手工**:`/@slug/about` 在 bio 公开且非空时可访问;bio 改 private 或清空后该 URL **404**
- **回归**:文章创建/更新/删除后引用与孤儿行为与改版前一致;删除 post 后对应 `owner_type=post` 引用清空。
## 8. 自检记录
- **占位符**:无 TBD。
- **一致性**:about 与 bio 同源;404 规则与「仅公开非空」一致;引用与孤儿定义全局统一。
- **范围**:单页 + 引用表演进 + profile 同步;不含其他 owner 类型。
- **歧义**:`owner_id` 在 `profile` 下等于 `users.id`;在 `post` 下等于 `posts.id`。实现须在代码层用枚举约束 `owner_type`
Loading…
Cancel
Save