diff --git a/.claude/skills/CLAUDE.md.template b/.claude/skills/CLAUDE.md.template new file mode 100644 index 0000000..50bce9c --- /dev/null +++ b/.claude/skills/CLAUDE.md.template @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + + +## 最重要的事情 + +1. **TDD 先行** - fix/feat 必须先写失败测试,红黄绿循环 +2. **原子提交** - 每个 commit 只做一件事,可独立回滚 +3. **文档驱动** - feat 改动关联 doc/ 下文档,多输出表格、流程图、ASCII 原型图 +4. **知识沉淀** - 有价值的迭代沉淀到 CLAUDE.md(拿捏不准主动问我) +5. **利用现有工具** - 不重复造轮子,会开车 > 会修车 +6. **有头有尾** - 头:确认清楚再动手,不清楚就一直问;尾:自己跑验证,不把验证甩给用户 +7. **任务结束后追加** - 主人,用不用我沉淀 or git 提交? + +## 项目概述 + +**{{项目名称}}** — {{一句话描述}} + +**产品目标**: +- {{目标1}} +- {{目标2}} +- {{目标3}} + +## 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 前端 | {{前端技术}} | {{说明}} | +| 后端 | {{后端技术}} | {{说明}} | +| 数据库 | {{数据库}} | {{说明}} | +| 缓存 | {{缓存方案,如无可删除此行}} | {{说明}} | +| AI 服务 | {{AI 服务,如无可删除此行}} | {{说明}} | +| 部署 | {{部署方案}} | {{说明}} | + +## 项目结构 + +``` +{{项目名称}}/ +├── {{目录1}}/ # {{说明}} +│ ├── {{子目录}}/ # {{说明}} +│ └── {{子目录}}/ # {{说明}} +├── doc/ # 项目文档 +│ ├── PRD.md +│ ├── DevelopmentPlan.md +│ └── tasks.md +└── {{其他文件}} # {{说明}} +``` + +## 常用命令 + +### 开发 + +```bash +{{开发启动命令}} +``` + +### 构建 + +```bash +{{构建命令}} +``` + +### 测试 + +```bash +{{测试命令}} +``` + +### 部署 + +```bash +{{部署命令}} +``` + +## 开发约定 + +- **包管理器**: 使用 {{包管理器}}(不是 {{其他包管理器}}) +- **TDD 流程**: 先写测试再实现,核心业务逻辑覆盖率 100% +- **日志规范**: 使用日志管理器,避免 console.log +- **知识沉淀**: 将有价值的对话迭代沉淀到文档中,包括: + - 重要技术决策和架构演进 → 更新 CLAUDE.md 相关章节 + - 新功能实现方案 → 更新组件职责、数据流等章节 + - 踩坑经验和解决方案 → 添加到踩坑经验章节 + - API 使用技巧和注意事项 → 更新相关技术栈说明 + +{{在此添加项目特定的开发约定}} + +## 交互准则 + +### 任务有头有尾 + +**头 — 确认清楚再动手**: +- 收到任务后,先复述理解、列出不确定的点 +- 不确定就问,一直问到双方对齐为止,**绝不带着假设开工** +- 确认范围边界:做什么、不做什么、验收标准 + +**尾 — 自己验证,说到做到**: +- 任务完成后,自己执行验证(跑测试、构建、截图、检查输出等) +- 把验证结果直接展示给用户,而不是列一堆步骤让用户自己验 +- 验证不通过就自己修,循环直到通过 +- 最终交付物 = 已通过的验证结果根据既定的方案(所以倒逼开始的时候更明确才执行,否则自己打自己的脸。) + +### 其他 + +- 任务彻底结束后,追加一句:**主人,用不用我沉淀 or git 提交?** + +## 踩坑经验 + + diff --git a/.claude/skills/RequirementsDoc.md b/.claude/skills/RequirementsDoc.md new file mode 100644 index 0000000..4039885 --- /dev/null +++ b/.claude/skills/RequirementsDoc.md @@ -0,0 +1 @@ +just empety... diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md new file mode 100644 index 0000000..d20c2c6 --- /dev/null +++ b/.claude/skills/changelog/SKILL.md @@ -0,0 +1,109 @@ +--- +name: changelog +description: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 +--- + +# Changelog - 一键发版 + +> **定位**:发版全流程。`/changelog 1.0.0227.1` 一个命令搞定日志生成 + commit + tag。 + +## 用法 + +``` +/changelog # 例: /changelog 1.0.0227.1 +/changelog # 不传版本号则自动推断 +``` + +## 执行流程 + +### 1. 确定版本号 + +**有参数**:直接使用用户传入的版本号。 + +**无参数**:自动推断。读取最新 tag,bump build 号 +1。例如当前最新 `v1.1.0211.1` → 推断为 `1.1.0211.2`。若无 tag 则提示用户输入。 + +版本格式:`{major}.{minor}.{MMDD}.{build}` + +### 2. 获取 Commits + +```bash +# 找到上一个 tag +git tag --sort=-creatordate + +# 读取 commits(从上一个 tag 到 HEAD) +git log {prev_tag}..HEAD --oneline --no-merges +``` + +如果没有上一个 tag,则从初始 commit 开始。 + +### 3. AI 总结 + +将 commit 列表总结为用户友好的中文更新说明: + +| type | emoji | 说明 | +|------|-------|------| +| feat | ✨ | 新功能 / 新 Skill | +| fix | 🐛 | Bug 修复 | +| refactor | ♻️ | 代码重构 | +| docs | 📝 | 文档更新 | +| chore | 🔧 | 杂项维护 | + +规则: +- 每条说明 1 句话,简洁明了 +- 合并相关的小 commit 为一条 +- 面向用户描述(不要出现技术细节如文件名、函数名) +- summary:一句话概括本次更新的核心主题 + +### 4. 展示草稿,等待确认 + +``` +📦 v{version} · {date} +{summary} + +✨ 新增 /go 执行按钮 Skill... +📝 更新 README 文档格式... +🐛 修复仓库 URL 配置... +``` + +**必须等用户确认或修改后才继续**。 + +### 5. 写入文件 + +用户确认后,更新 `CHANGELOG.md`: + +- 文件不存在时创建,头部加 `# Changelog` 标题 +- 将新条目插入已有内容**头部**(标题行之后,最新版本在前),保持现有条目不变 +- 条目格式: + +```markdown +## v{version} ({YYYY-MM-DD}) + +{summary} + +- ✨ xxx +- 🐛 xxx +- ♻️ xxx +``` + +### 6. Commit + Tag + +```bash +# Stage 变更文件 +git add CHANGELOG.md + +# Commit +git commit -m "release: v{version}" + +# 打 tag +git tag v{version} +``` + +### 7. 完成提示 + +``` +✅ 发版完成! +- 📝 CHANGELOG.md: 新增 v{version} 条目 +- 🏷️ git tag: v{version} + +需要 push 到远程吗?(git push && git push --tags) +``` diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md new file mode 100644 index 0000000..b99810c --- /dev/null +++ b/.claude/skills/deploy/SKILL.md @@ -0,0 +1,316 @@ +--- +name: deploy +description: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 +--- + +# Deploy - CI/CD 全流程部署引导 + +> **定位**:基于公司 Drone CI + 私有 Docker Registry + Docker Compose 的自动化部署方案,交互式引导用户从零完成 CI/CD 接入。 + +当用户调用 `/deploy` 或 `/deploy <指令>` 时,执行以下步骤: + +## 1. 收集项目信息 + +快速了解项目情况(已知的不重复问): + +| 项目 | 说明 | 示例 | +|------|------|------| +| 项目名称 | 用于镜像命名 | `douyin`, `crm`, `blog` | +| 需构建的服务 | 每个服务对应一个镜像 | `backend`, `frontend` | +| 各服务的 Dockerfile 路径 | Docker build context | `./backend`, `./frontend` | +| 生产服务器 SSH 端口 | 默认 22 | `22`, `3141` | +| 部署目录 | 生产服务器上的路径 | `/opt/docker/myproject` | +| 数据库迁移命令 | 如有 | `alembic upgrade head`, `npx prisma migrate deploy` | +| 健康检查方式 | 三选一 | python / curl / host | +| 健康检查 URL | 容器内地址 | `http://127.0.0.1:8000/health` | +| 通知 Webhook | 可选,不配则跳过 | 企业微信/钉钉/飞书 | + +确认后进入下一步。 + +## 2. 基础设施检查 + +输出 Checklist,让用户逐项确认(首次接入需全部完成,后续项目可跳过): + +``` +□ 基础设施(一次性,已完成则跳过) + □ Drone CI Server + Runner 已部署运行 + □ 私有 Docker Registry 已运行(默认 :5000) + □ insecure-registries 已配置(Drone CI 服务器 + 生产服务器) + □ SSH 密钥已配置(Drone CI 服务器 → 生产服务器免密登录) + □ 生产服务器用户已加入 docker 组 +``` + +如果用户表示基础设施未就绪,输出对应的一次性搭建指引(参见下方「附录:基础设施搭建」)。 + +## 3. 生成配置文件 + +基于收集到的信息,生成以下文件: + +### 3.1 `.drone.yml` + +核心原则(踩坑总结,不可违反): +1. 使用 `docker:27-cli` + 宿主机 Docker socket,**不用** `plugins/docker` DinD +2. 使用 `environment: { VAR: { from_secret: name } }` 注入密钥,**不用** `secrets:` 字段 +3. 使用 `${DRONE_TAG:-latest}` 作为镜像 tag,**不自定义中间变量**(Drone 变量替换冲突) +4. 触发条件只用 `event: [tag, cron]`,**不叠加** `cron: [name]`(AND 运算陷阱) + +生成内容包括: +- `trigger`: tag + cron +- `volumes`: 挂载宿主机 Docker socket +- 每个服务的 `build-` step +- `deploy` step(使用 `appleboy/drone-ssh`) +- `notify-success` / `notify-failure` step(如配置了 Webhook) + +### 3.2 `scripts/deploy-remote.sh` + +部署脚本要点: +- `set -euo pipefail` 严格模式 +- 部署锁(PID 文件防并发) +- 同时 export `IMAGE_TAG` 和 `VERSION`(兼容不同 compose 变量命名) +- 按顺序:pull → 停 beat → 更新核心服务 → 健康检查 → 数据库迁移 → 启动剩余服务 → 最终健康检查 +- 健康检查根据用户选择的方式生成(python / curl / host) + +### 3.3 生成后展示 + +``` +已生成: + 📄 .drone.yml — Drone CI 流水线配置 + 📄 scripts/deploy-remote.sh — 远程部署脚本 + +确认写入?[Y/n] +``` + +用户确认后写入文件。 + +## 4. Drone 面板配置引导 + +生成文件后,输出需要在 Drone 面板手动配置的清单: + +### 4.1 仓库设置 + +``` +在 Drone 面板完成以下配置: + +1. 激活仓库:SYNC → 找到仓库 → ACTIVATE +2. 开启 Trusted:Settings → General → Project Settings → 勾选 Trusted +``` + +### 4.2 Secrets 配置 + +根据收集到的信息,输出具体的 Secret 列表: + +``` +在 Drone 面板 → 仓库 Settings → Secrets 添加: + +| Secret 名称 | 填写内容 | +|-------------------|----------------------------------------------------| +| backend_repo | docker.internal.intelligrow.cn:5000/{project}-backend | +| frontend_repo | docker.internal.intelligrow.cn:5000/{project}-frontend | +| deploy_host | {生产服务器 IP} | +| deploy_user | {SSH 用户} | +| deploy_ssh_key | cat ~/.ssh/drone_deploy 的完整内容 | +| deploy_path | {部署目录} | +| wecom_webhook | {Webhook URL}(如已配置) | +``` + +### 4.3 Cron 配置(可选) + +``` +如需定时构建,在 Settings → Cron Jobs 添加: + +| 字段 | 值 | 说明 | +|----------|------------------|-------------------------| +| Name | nightly-build | 任务名称 | +| Branch | main | 构建分支 | +| Schedule | 0 16 * * * | UTC 16:00 = 北京 00:00 | +``` + +## 5. 生产服务器配置引导 + +``` +在生产服务器上确认: + +1. 部署目录结构: + {deploy_path}/ + ├── docker-compose.prod.yml + ├── .env + └── scripts/ + └── deploy-remote.sh ← 需从代码仓库复制 + +2. .env 至少包含: + DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000 + (其他数据库密码等生产配置) + +3. docker-compose.prod.yml 中镜像引用格式: + image: ${DOCKER_REGISTRY}/{project}-backend:${VERSION:-latest} + +⚠️ 变量一致性:Drone Secret 的镜像地址前缀 = .env 的 DOCKER_REGISTRY = compose 中的镜像名拼接结果 +``` + +## 6. 验证 + +自己执行可执行的验证,不能远程执行的给出命令让用户确认结果: + +### 6.1 本地验证(自己执行) + +```bash +# 检查 .drone.yml 语法合法性(YAML 解析) +# 检查 deploy-remote.sh 语法(bash -n) +# 检查文件是否已正确写入 +``` + +### 6.2 远程验证引导(输出命令,让用户在服务器上执行并反馈结果) + +```bash +# 推送 Tag 触发首次构建 +git tag v{version} +git push origin v{version} + +# 观察 Drone 面板 pipeline 状态 + +# 生产服务器检查 +ssh -p {port} {user}@{host} "cd {deploy_path} && docker compose -f docker-compose.prod.yml ps" +``` + +## 7. 完成输出 + +``` +✅ CI/CD 接入完成! + +📄 生成的文件: + - .drone.yml + - scripts/deploy-remote.sh + +🔧 Drone 面板配置(需手动): + - [x] 仓库已激活 + - [x] Trusted 已开启 + - [x] Secrets 已添加 + - [ ] Cron Job(可选) + +🖥️ 生产服务器: + - [ ] .env 已配置 + - [ ] deploy-remote.sh 已复制 + - [ ] 首次部署成功 + +📖 回滚方案: + 方式1: ssh 到生产服务器执行 bash scripts/deploy-remote.sh {旧版本tag} + 方式2: git tag {旧版本}-rollback {旧版本} && git push origin {旧版本}-rollback + +主人,用不用我沉淀 or git 提交? +``` + +--- + +## 附录:基础设施搭建 + +当用户表示基础设施未就绪时,按需输出以下指引: + +### A. Drone CI 部署 + +在 Drone CI 服务器创建 `~/drone/docker-compose.yml`: + +```yaml +services: + drone-server: + image: drone/drone:2 + container_name: drone-server + restart: always + ports: + - "3080:80" + environment: + - DRONE_GITEA_SERVER=https:// + - DRONE_GITEA_CLIENT_ID= + - DRONE_GITEA_CLIENT_SECRET= + - DRONE_SERVER_HOST= + - DRONE_SERVER_PROTO=https + - DRONE_RPC_SECRET= + - DRONE_USER_CREATE=username:,admin:true + volumes: + - ./data:/data + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + depends_on: + - drone-server + environment: + - DRONE_RPC_PROTO=http + - DRONE_RPC_HOST=drone-server + - DRONE_RPC_SECRET=<与 server 相同> + - DRONE_RUNNER_CAPACITY=2 + - DRONE_RUNNER_NAME=drone-runner-1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +关键注意: +- `DRONE_RPC_PROTO=http`:Runner 走 Docker 内网直连,不走 HTTPS +- `DRONE_USER_CREATE` 的 username 必须与 Gitea **登录用户名**完全一致(不是邮箱) + +### B. 私有 Registry + +```bash +docker run -d --name registry \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + --restart always \ + registry:2 +``` + +### C. insecure-registries 配置 + +在 Drone CI 服务器和生产服务器的 `/etc/docker/daemon.json` 添加: + +```json +{ + "insecure-registries": [":5000"] +} +``` + +**不要带 `http://` 前缀**,直接写 `host:port`。修改后 `sudo systemctl restart docker`。 + +### D. SSH 免密 + +```bash +# Drone CI 服务器上生成密钥 +ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N "" + +# 将公钥添加到生产服务器 +ssh-copy-id -i ~/.ssh/drone_deploy.pub -p @ + +# 验证 +ssh -i ~/.ssh/drone_deploy -p @ "echo ok" +``` + +--- + +## 踩坑清单(生成配置时必须规避) + +| # | 坑 | 正确做法 | +|---|-----|---------| +| 1 | `insecure-registries` 带 `http://` 前缀 | 直接写 `host:port` | +| 2 | Drone `${VAR}` 与 shell 变量冲突 | 直接用 `${DRONE_TAG:-latest}`,不赋中间变量 | +| 3 | 用 `secrets:` 字段注入 secret | 用 `environment: { VAR: { from_secret: name } }` | +| 4 | `plugins/docker` DinD 启动失败 | 用 `docker:27-cli` + 挂载 Docker socket | +| 5 | `DRONE_USER_CREATE` 填邮箱 | 必须填 Gitea 登录用户名 | +| 6 | `event + cron` 触发条件互斥 | 只用 `event: [tag, cron]`,不加 `cron:` 过滤 | +| 7 | Registry 地址不一致(IP vs 域名) | Drone Secret、`.env`、compose 三处统一 | +| 8 | SSH 端口不对 | `appleboy/drone-ssh` 显式指定 `port` | +| 9 | Docker 权限不足 | `sudo usermod -aG docker ` 后重新登录 | +| 10 | `daemon.json` 被覆盖 | 修改前先 cat 查看,合并内容 | + +--- + +## 故障排查速查表 + +| 现象 | 检查方向 | +|------|---------| +| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger | +| Step 一直 pending | Runner 是否连通 Server;仓库是否 Trusted | +| 构建报 secret 为空 | `environment: from_secret` 而非 `secrets:` | +| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 | +| SSH 部署超时 | 密钥是否正确;端口是否匹配;Docker 权限 | +| 镜像名 invalid reference | `.env` 的 `DOCKER_REGISTRY` 变量是否正确 | +| 数据库迁移失败 | `docker compose logs -f ` | +| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务启动日志 | diff --git a/.claude/skills/go/SKILL.md b/.claude/skills/go/SKILL.md new file mode 100644 index 0000000..2397129 --- /dev/null +++ b/.claude/skills/go/SKILL.md @@ -0,0 +1,325 @@ +--- +name: go +description: 终极执行按钮,激进模式一口气完成开发任务,兼容 0->1 和 1->100 场景。 +--- + +# Go - 发射按钮 + +> **定位**:执行按钮。无论是从零开始的 0->1,还是迭代优化的 1->100,按下 `/go` 就开始干活,不要停。 + +当用户调用 `/go` 或 `/go <任务范围>` 时,执行以下步骤: + +## 1. 前置检查 + +### 1.1 必要文档检查 + +检查以下文件是否存在: + +| 文件 | 必要性 | 用途 | +|------|--------|------| +| `doc/tasks.md` | **必须** | 任务清单,执行的圣经 | +| `doc/PRD.md` | **必须** | 产品需求,理解业务 | +| `doc/FeatureSummary.md` | 建议 | 功能契约 | +| `doc/DevelopmentPlan.md` | 建议 | 技术方案 | +| `doc/UIDesign.md` | 可选 | 界面设计 | +| `doc/tdd.md` | 可选 | 测试用例 | + +**缺少必要文档时**: + +``` +❌ 缺少必要文档: +- doc/tasks.md (必须) +- doc/PRD.md (必须) + +请先准备这些文档,或运行: +- /wp 生成 PRD +- /wt 生成 tasks +``` + +### 1.2 读取所有可用文档 + +读取存在的所有文档,建立完整上下文。 + +## 2. 智能判断执行范围 + +### 2.1 检测项目状态 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 项目状态检测 │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 检查 src/ 或主代码目录是否存在? │ +│ │ +│ ├── 不存在 ──▶ 0->1 模式(全新项目) │ +│ │ │ +│ └── 存在 ──▶ 检查 tasks.md 中的 ITER 标记 │ +│ │ │ +│ ├── 有 ITER 标记 ──▶ 1->100 模式 │ +│ │ │ +│ └── 无 ITER 标记 ──▶ 继续未完成任务 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 确定任务范围 + +**用户指定范围**: + +```bash +/go T-005 # 执行单个任务 +/go T-005~T-010 # 执行任务范围 +/go T-005 T-008 # 执行多个指定任务 +``` + +**自动判断范围**: + +| 场景 | 执行范围 | +|------|----------| +| 0->1 全新项目 | tasks.md 中的所有任务,从 T-001 开始 | +| 1->100 有 ITER 标记 | 优先执行 `` 标记的新任务 | +| 1->100 无 ITER 标记 | 执行所有状态为 pending/todo 的任务 | + +### 2.3 向用户确认范围(唯一一次交互) + +``` +检测到项目状态:{0->1 全新项目 / 1->100 迭代项目} + +即将执行任务: +- T-001: {任务名} +- T-002: {任务名} +- ... +- T-xxx: {任务名} + +共 X 个任务。确认执行?[Y/n] +``` + +**用户确认后,不再有任何交互,直到全部完成。** + +## 3. 激进模式执行 + +### 3.1 执行原则 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 激进模式执行原则 │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 1. 以 tasks.md 为圣经,严格按顺序执行 │ +│ │ +│ 2. 不要停下来问用户,自主决策 │ +│ │ +│ 3. 遇到问题自主修复,修复失败则记录并继续 │ +│ │ +│ 4. 发现文档冲突,基于架构经验选最优解,注释说明 │ +│ │ +│ 5. 利用所有可用工具:搜索、MCP、Skills │ +│ │ +│ 6. 每完成一个模块,Git 提交一次 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 任务执行流程 + +``` +对于每个任务 T-xxx: +│ +├── 1. 读取任务详情(描述、验收标准、依赖) +│ +├── 2. 检查依赖任务是否完成 +│ └── 未完成 → 先执行依赖任务 +│ +├── 3. 执行任务 +│ ├── 根据任务类型选择执行方式 +│ ├── 编写代码 / 配置 / 测试 +│ └── 验证验收标准 +│ +├── 4. 遇到问题? +│ ├── 尝试自主修复(最多 3 次) +│ ├── 修复成功 → 继续 +│ └── 修复失败 → 记录问题,跳过,继续下一个 +│ +└── 5. 标记任务完成,更新 tasks.md +``` + +### 3.3 自主修复策略 + +| 问题类型 | 修复策略 | +|----------|----------| +| 编译错误 | 分析错误信息,修复代码 | +| 类型错误 | 检查类型定义,修复类型 | +| 依赖缺失 | 安装依赖包 | +| 测试失败 | 修复功能代码使测试通过 | +| 文档冲突 | 基于架构经验选最优解 | +| 未知错误 | 搜索解决方案,尝试修复 | + +## 4. Git 提交规则 + +### 4.1 提交时机 + +每完成一个**模块/Sprint**后立即提交: + +``` +T-001 ~ T-004 → 提交一次(初始化模块) +T-005 ~ T-008 → 提交一次(核心功能模块) +T-009 ~ T-012 → 提交一次(扩展功能模块) +... +``` + +### 4.2 提交信息格式 + +``` +feat(): <简要描述> + +- 完成 T-xxx: {任务名} +- 完成 T-xxx: {任务名} +- ... + +Co-Authored-By: Claude +``` + +**示例**: + +``` +feat(auth): 完成用户认证模块 + +- 完成 T-005: 用户登录功能 +- 完成 T-006: 用户注册功能 +- 完成 T-007: JWT Token 管理 +- 完成 T-008: 权限验证中间件 + +Co-Authored-By: Claude +``` + +## 5. 进度汇报 + +### 5.1 模块完成汇报 + +每完成一个模块,简要汇报: + +``` +✅ 模块完成:{模块名} + - T-005: 用户登录 ✓ + - T-006: 用户注册 ✓ + - T-007: JWT 管理 ✓ + - T-008: 权限验证 ✓ + +Git 提交: feat(auth): 完成用户认证模块 + +继续执行下一模块... +``` + +### 5.2 最终汇报 + +全部完成后,输出完整报告: + +``` +## 🚀 执行完成 + +**执行模式**: {0->1 全新项目 / 1->100 迭代} + +**任务统计**: +| 状态 | 数量 | +|------|------| +| ✅ 完成 | X 个 | +| ⚠️ 跳过 | X 个 | +| ❌ 失败 | X 个 | + +**Git 提交记录**: +- feat(init): 项目初始化 +- feat(auth): 用户认证模块 +- feat(core): 核心功能模块 +- ... + +**跳过/失败的任务**(如有): +| 任务 | 原因 | +|------|------| +| T-xxx | {原因} | + +**下一步建议**: +- 运行 `npm run dev` 验证 +- 运行 `npm run test` 测试 +- 检查跳过的任务 +``` + +## 6. 特殊场景处理 + +### 6.1 技术栈识别 + +从文档中识别技术栈,自动适配: + +| 识别来源 | 技术决策 | +|----------|----------| +| package.json 存在 | Node.js 项目 | +| requirements.txt 存在 | Python 项目 | +| DevelopmentPlan 指定 | 按文档技术栈 | +| 无明确指定 | 询问用户(唯一例外) | + +### 6.2 测试策略 + +- 功能开发完成后执行测试任务 +- 测试失败 → **先修复功能代码使测试通过** +- 不跳过失败的测试继续部署 + +### 6.3 部署任务 + +- 先本地测试验证 +- 确保 build 和 start 正常 +- 远程部署需用户额外确认 + +--- + +## 工作流总览 + +``` +/go + │ + ├── 1. 前置检查 + │ ├── tasks.md 存在? ──▶ 必须 + │ └── PRD.md 存在? ──▶ 必须 + │ + ├── 2. 读取文档,建立上下文 + │ + ├── 3. 智能判断 + │ ├── 项目状态(0->1 / 1->100) + │ └── 任务范围 + │ + ├── 4. 确认执行范围(唯一交互) + │ + ├── 5. 激进模式执行 + │ ├── 按顺序执行任务 + │ ├── 自主修复问题 + │ ├── 模块完成 → Git 提交 + │ └── 汇报进度,继续下一个 + │ + └── 6. 最终汇报 + ├── 任务统计 + ├── Git 提交记录 + └── 下一步建议 +``` + +## 注意事项 + +- **tasks.md 是圣经**,严格按其顺序和内容执行 +- **不要停下来问用户**,自主决策,自主修复 +- **遇到无法解决的问题**,记录并跳过,最后汇报 +- **每完成模块立即提交**,避免大量代码丢失风险 +- **利用所有工具**:搜索、MCP、其他 Skills + +## 与其他 Skill 的关系 + +| 场景 | 使用方式 | +|------|----------| +| 准备文档 | `/wp` `/wf` `/wd` `/wu` `/wt` | +| 评审文档 | `/rp` `/rf` `/rd` `/ru` `/rt` | +| 修改文档 | `/mp` `/mf` `/md` `/mu` `/mt` | +| 迭代变更(更新文档) | `/iter` | +| **执行开发(本 Skill)** | `/go` | + +**典型工作流**: + +``` +0->1:需求 → /wp → /wf → /wd → /wt → /go +1->100:发现问题 → /iter → /go +``` diff --git a/.claude/skills/iter/SKILL.md b/.claude/skills/iter/SKILL.md new file mode 100644 index 0000000..f03c314 --- /dev/null +++ b/.claude/skills/iter/SKILL.md @@ -0,0 +1,210 @@ +--- +name: iter +description: 迭代变更入口,调研问题后更新 PRD.md 和 tasks.md,支持 Bug 修复、功能迭代、技术重构。 +--- + +# Iterate - 迭代变更 + +> **定位**:1-100 阶段的变更入口。项目已上线,需要修复问题或迭代功能时,通过此 skill 调研、澄清、更新文档。 + +当用户调用 `/iter` 或 `/iter <问题描述>` 时,执行以下步骤: +⚠️ 重要:本 skill 只修改文档(PRD.md、tasks.md),绝不执行代码、不运行命令、不修改源文件。 + +## 1. 获取变更描述 + +如果用户提供了参数,使用该描述。否则询问: +> 请描述需要迭代的内容(Bug/功能/重构) + +**示例输入**: +- "登录验证存在漏洞,token 过期后仍可访问" +- "列表页需要增加按时间筛选功能" +- "用户模块性能太差,需要重构缓存策略" + +## 2. 调研分析 + +### 2.1 读取现有文档 + +读取以下文件了解当前状态: + +1. `doc/PRD.md` - 了解产品定义 +2. `doc/tasks.md` - 了解任务现状 + +### 2.2 调研相关代码(可选) + +根据问题描述,定位相关代码文件: + +- 搜索关键词定位文件 +- 读取相关模块代码 +- 分析现有实现 + +### 2.3 分析变更类型 + +| 类型 | 特征 | 影响范围 | +|------|------|----------| +| Bug/漏洞 | 现有功能不符合预期 | 修复逻辑,可能涉及安全 | +| 功能迭代 | 在现有功能上增加/调整 | 新增或修改功能点 | +| 技术重构 | 不改功能,优化实现 | 性能、架构、代码质量 | + +## 3. 澄清确认 + +**【必须】向用户提出澄清问题**,确保理解准确: + +### 3.1 问题理解确认 + +向用户确认: +> 我理解的变更需求是:{一句话总结} +> +> 变更类型:{Bug修复 / 功能迭代 / 技术重构} +> +> 影响范围:{涉及的模块/功能} + +### 3.2 方案选择(如有多种) + +如果有多种解决方案,列出选项让用户选择: + +``` +方案 A:{描述} +- 优点:... +- 缺点:... + +方案 B:{描述} +- 优点:... +- 缺点:... + +请选择方案,或说明其他想法。 +``` + +### 3.3 边界确认 + +确认变更边界: +> 本次变更**包含**: +> - {范围1} +> - {范围2} +> +> 本次变更**不包含**: +> - {排除项} +> +> 是否确认? + +## 4. 用户确认后执行 + +**只有用户明确确认后**,才执行以下更新: + +### 4.1 更新 PRD.md + +使用增量修改标记: + +```markdown + + +新增内容... + +``` + +或修改现有内容: + +```markdown + + +修改后的内容 +``` + +**更新位置**: +- Bug 修复 → 更新对应功能的验收标准 +- 功能迭代 → 在 3.2 功能详情添加/修改功能点 +- 技术重构 → 在 4.x 非功能需求或 7.1 技术约束中说明 + +### 4.2 更新 tasks.md + +新增任务使用标记: + +```markdown + +| T-xxx | {任务名} | {描述} | {依赖} | {优先级} | {验收标准} | +``` + +**任务 ID 规则**: +- 查找现有最大 ID,递增分配 +- 格式:T-xxx(三位数字) + +### 4.3 标记规范 + +所有变更使用 `` 前缀,区分于 `/mp` `/mt` 的标记: + +- `` +- 便于追溯迭代历史 + +## 5. 输出摘要 + +完成后向用户展示: + +``` +## 迭代变更完成 + +**变更类型**: {Bug修复 / 功能迭代 / 技术重构} + +**变更摘要**: {一句话描述} + +**已更新文档**: +- doc/PRD.md: {更新位置} +- doc/tasks.md: 新增任务 T-xxx + +**新增任务**: +| ID | 任务 | 优先级 | +|----|------|--------| +| T-xxx | {任务名} | P0/P1/P2 | + +**下一步**: +- 执行任务 T-xxx +- 或运行 `/rp` `/rt` 评审变更 +``` + +--- + +## 工作流示意 + +``` +用户描述问题 + │ + ▼ +┌─────────────┐ +│ 调研分析 │ ──▶ 读取 PRD、tasks、相关代码 +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ 澄清确认 │ ──▶ 提问 → 用户回答 → 确认方案 +└──────┬──────┘ + │ + ▼ 用户确认 +┌─────────────┐ +│ 更新文档 │ ──▶ PRD.md + tasks.md +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ 输出摘要 │ +└─────────────┘ +``` + +## 注意事项 + +- **必须先澄清确认**,不要假设用户意图 +- 变更范围要明确,避免 scope creep +- 优先级根据问题严重程度判断: + - 安全漏洞 → P0 + - 功能 Bug → P0/P1 + - 功能迭代 → P1/P2 + - 技术重构 → P1/P2 +- 只更新 PRD + tasks,保持轻量 +- 如需更新其他文档,提示用户手动运行 `/mf` `/md` 等 + +## 与其他 skill 的关系 + +| 场景 | 使用 skill | +|------|------------| +| 迭代变更入口 | `/iter`(本 skill) | +| 需要更新 FeatureSummary | `/iter` 后运行 `/mf` | +| 需要更新 DevelopmentPlan | `/iter` 后运行 `/md` | +| 需要评审变更 | `/iter` 后运行 `/rp` `/rt` | +| 从头生成文档 | 使用 `/wp` `/wf` `/wd` 等 | diff --git a/.claude/skills/md/SKILL.md b/.claude/skills/md/SKILL.md new file mode 100644 index 0000000..47303bd --- /dev/null +++ b/.claude/skills/md/SKILL.md @@ -0,0 +1,112 @@ +--- +name: md +description: 增量修改 DevelopmentPlan.md,根据用户指令在现有内容基础上更新开发计划。 +--- + +# Modify DevelopmentPlan + +当用户调用 `/md` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取以下文件: + +1. `doc/DevelopmentPlan.md` - 目标文档(必须存在) +2. `doc/FeatureSummary.md` - 上游参考文档 +3. `doc/review-DevelopmentPlan-claude.md` - 评审报告(如果存在,自动作为修改依据) + +如果 DevelopmentPlan.md 不存在,提示用户: +> DevelopmentPlan.md 不存在,请先使用 `/wd` 生成开发计划。 + +## 2. 确定修改来源 + +按以下优先级确定修改内容: + +### 2.1 用户提供了修改指令 + +如果用户在调用 `/md` 时附带了参数或说明,直接使用该指令。 + +### 2.2 自动检测评审报告 + +如果用户未提供修改指令,**自动检测** `doc/review-DevelopmentPlan-claude.md` 是否存在: + +- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认: + > 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改? + +- **不存在**:询问用户: + > 请说明需要修改的内容,或先运行 `/rd` 生成评审报告。 + +## 3. 修改原则 + +### 3.1 增量修改 + +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节: + +```markdown + +新增内容... + +``` + +对于行内新增: + +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +```markdown + +修改后的内容 +``` + +### 3.4 与 FeatureSummary 一致性 + +- 开发任务必须覆盖所有功能 +- 技术方案必须支撑功能需求 +- 阶段划分必须合理 + +## 4. 执行修改 + +| 修改类型 | 处理方式 | +|----------|----------| +| 新增开发任务 | 在对应阶段表格中添加行 | +| 修改技术方案 | 更新技术方案章节,添加 MODIFIED 标记 | +| 调整阶段划分 | 移动任务到新阶段,标记变更 | +| 新增风险项 | 在风险管理表格中添加行 | +| 修改里程碑 | 更新里程碑表格 | + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/DevelopmentPlan.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: + +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 +- 与 FeatureSummary 的一致性确认 + +--- + +## 注意事项 + +- DevelopmentPlan 依赖于 FeatureSummary,修改时需确保与上游一致 +- 修改后,下游文档(UIDesign、tasks)可能需要同步更新 +- 技术方案修改需谨慎评估影响范围 +- 建议修改完成后运行 `/ru` 检查下游一致性 + +## 标记清理 + +用户确认修改无误后,可手动删除标记或保留作为变更历史参考。 diff --git a/.claude/skills/mf/SKILL.md b/.claude/skills/mf/SKILL.md new file mode 100644 index 0000000..fa10e2d --- /dev/null +++ b/.claude/skills/mf/SKILL.md @@ -0,0 +1,111 @@ +--- +name: mf +description: 增量修改 FeatureSummary.md,根据用户指令在现有内容基础上更新功能摘要。 +--- + +# Modify FeatureSummary + +当用户调用 `/mf` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取以下文件: + +1. `doc/FeatureSummary.md` - 目标文档(必须存在) +2. `doc/PRD.md` - 上游参考文档 +3. `doc/review-FeatureSummary-claude.md` - 评审报告(如果存在,自动作为修改依据) + +如果 FeatureSummary.md 不存在,提示用户: +> FeatureSummary.md 不存在,请先使用 `/wf` 生成功能摘要。 + +## 2. 确定修改来源 + +按以下优先级确定修改内容: + +### 2.1 用户提供了修改指令 + +如果用户在调用 `/mf` 时附带了参数或说明,直接使用该指令。 + +### 2.2 自动检测评审报告 + +如果用户未提供修改指令,**自动检测** `doc/review-FeatureSummary-claude.md` 是否存在: + +- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认: + > 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改? + +- **不存在**:询问用户: + > 请说明需要修改的内容,或先运行 `/rf` 生成评审报告。 + +## 3. 修改原则 + +### 3.1 增量修改 + +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节: + +```markdown + +新增内容... + +``` + +对于行内新增: + +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +```markdown + +修改后的内容 +``` + +### 3.4 与 PRD 一致性 + +- 所有功能必须来源于 PRD +- 修改后的功能描述必须与 PRD 一致 +- 优先级必须与 PRD 匹配 + +## 4. 执行修改 + +| 修改类型 | 处理方式 | +|----------|----------| +| 新增功能 | 在对应模块表格中添加行 | +| 修改描述 | 更新功能描述,添加 MODIFIED 标记 | +| 修改优先级 | 更新优先级列 | +| 新增模块 | 在功能清单中添加新章节 | +| 删除功能 | 标记为删除而非直接移除 | + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/FeatureSummary.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: + +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 +- 与 PRD 的一致性确认 + +--- + +## 注意事项 + +- FeatureSummary 依赖于 PRD,修改时需确保与上游一致 +- 修改后,下游文档(DevelopmentPlan 等)可能需要同步更新 +- 建议修改完成后运行 `/rd` 检查下游一致性 + +## 标记清理 + +用户确认修改无误后,可手动删除标记或保留作为变更历史参考。 diff --git a/.claude/skills/mp/SKILL.md b/.claude/skills/mp/SKILL.md new file mode 100644 index 0000000..5fdeb3f --- /dev/null +++ b/.claude/skills/mp/SKILL.md @@ -0,0 +1,144 @@ +--- +name: mp +description: 增量修改 PRD.md,根据用户指令在现有内容基础上更新产品需求文档。 +--- + +# Modify PRD + +当用户调用 `/mp` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取以下文件: + +1. `doc/PRD.md` - 目标文档(必须存在) +2. `doc/RequirementsDoc.md` - 上游参考文档 +3. `doc/review-PRD-claude.md` - 评审报告(如果存在,自动作为修改依据) + +如果 PRD.md 不存在,提示用户: +> PRD.md 不存在,请先使用 `/wp` 生成产品需求文档。 + +## 2. 确定修改来源 + +按以下优先级确定修改内容: + +### 2.1 用户提供了修改指令 + +如果用户在调用 `/mp` 时附带了参数或说明,直接使用该指令。 + +### 2.2 自动检测评审报告 + +如果用户未提供修改指令,**自动检测** `doc/review-PRD-claude.md` 是否存在: + +- **存在**:读取评审报告,提取其中的问题清单(Critical / Major / Minor),作为本次修改的依据。向用户确认: + > 检测到评审报告 `doc/review-PRD-claude.md`,包含 X 个问题。是否根据评审报告进行修改? + +- **不存在**:询问用户: + > 请说明需要修改的内容,或先运行 `/rp` 生成评审报告。 + +### 2.3 支持的修改来源 + +- 具体的修改描述(如"在功能需求中增加用户权限管理模块") +- 评审报告(自动检测或手动指定路径) +- 对应的 RequirementsDoc 变更(如"/mr 已更新需求,请同步 PRD") + +## 3. 修改原则 + +### 3.1 增量修改 +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节,使用 HTML 注释标记: + +```markdown + +新增内容... + +``` + +对于行内新增,使用: +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +对于修改的内容,保留原文作为注释: + +```markdown + +修改后的内容 +``` + +### 3.4 与 RequirementsDoc 一致性 + +- 所有 PRD 内容必须可追溯到 RequirementsDoc +- 如果修改涉及新功能,先确认 RequirementsDoc 中已有对应需求 +- 如果 RequirementsDoc 未包含相关需求,提醒用户先更新需求文档 + +## 4. 执行修改 + +按照用户指令修改文档: + +1. 定位到需要修改的位置 +2. 执行增量修改 +3. 添加相应的标记 +4. 保持文档格式一致性 +5. 确保修改内容与 RequirementsDoc 一致 + +### 4.1 修改类型处理 + +| 修改类型 | 处理方式 | +|----------|----------| +| 新增功能点 | 在对应功能模块表格中添加行,关联用户故事 | +| 新增用户故事 | 在 2.2 用户故事列表中添加,分配 US-xxx ID | +| 修改优先级 | 更新功能点优先级,必要时调整用户故事分类 | +| 修改验收标准 | 更新对应功能点的验收标准列 | +| 新增模块 | 在 3.2 功能详情中添加新的子章节 | +| 修改非功能需求 | 在对应章节更新指标或要求 | + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/PRD.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 +- 与 RequirementsDoc 的一致性确认 + +--- + +## 注意事项 + +- PRD 依赖于 RequirementsDoc,修改时需确保与上游文档一致 +- 修改 PRD 后,下游文档(FeatureSummary、DevelopmentPlan 等)可能需要同步更新 +- 保持现有文档风格(标题层级、表格格式、列表样式) +- 用户故事 ID 必须唯一且连续(US-001, US-002...) +- 所有功能点必须关联到用户故事 +- 重大修改建议先运行 `/rp` 评审确认影响范围 +- 修改完成后,建议用户运行 `/rf` 检查下游文档一致性 + +## 标记清理 + +当用户确认修改无误后,可手动删除 `` 和 `` 标记,或保留作为变更历史参考。 + +通过 git 可追溯完整修改历史。 + +## 质量检查 + +修改 PRD 后,自查以下项目: + +- [ ] 修改内容与 RequirementsDoc 一致 +- [ ] 新增用户故事有唯一 ID +- [ ] 新增功能点关联到用户故事 +- [ ] 新增功能点有明确优先级和验收标准 +- [ ] 标记格式正确(`` / ``) +- [ ] 文档结构完整,格式一致 diff --git a/.claude/skills/mr/SKILL.md b/.claude/skills/mr/SKILL.md new file mode 100644 index 0000000..8dd9ad8 --- /dev/null +++ b/.claude/skills/mr/SKILL.md @@ -0,0 +1,95 @@ +--- +name: mr +description: 增量修改 RequirementsDoc.md,根据用户指令在现有内容基础上更新需求文档。 +--- + +# Modify RequirementsDoc + +当用户调用 `/mr` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取 `doc/RequirementsDoc.md` 文件。 + +如果文件不存在,提示用户: +> RequirementsDoc.md 不存在,请先使用人工方式创建需求文档。 + +## 2. 获取修改指令 + +向用户确认修改内容。用户应提供以下信息之一: + +- 具体的修改描述(如"在第3节增加性能需求") +- 评审报告路径(如 `doc/review-RequirementsDoc-claude.md`) +- 直接的修改内容 + +如果用户未提供修改指令,询问: +> 请说明需要修改的内容,或提供评审报告路径。 + +## 3. 修改原则 + +### 3.1 增量修改 +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节,使用 HTML 注释标记: + +```markdown + +新增内容... + +``` + +对于行内新增,使用: +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +对于修改的内容,保留原文作为注释: + +```markdown + +修改后的内容 +``` + +## 4. 执行修改 + +按照用户指令修改文档: + +1. 定位到需要修改的位置 +2. 执行增量修改 +3. 添加相应的标记 +4. 保持文档格式一致性 + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/RequirementsDoc.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 + +--- + +## 注意事项 + +- RequirementsDoc 是文档链源头,修改会影响所有下游文档 +- 修改前确认用户意图,避免误改 +- 保持现有文档风格(标题层级、表格格式、列表样式) +- 重大修改建议先运行 `/rr` 评审确认影响范围 +- 修改完成后,建议用户检查下游文档是否需要同步更新 + +## 标记清理 + +当用户确认修改无误后,可手动删除 `` 和 `` 标记,或保留作为变更历史参考。 + +通过 git 可追溯完整修改历史。 diff --git a/.claude/skills/mt/SKILL.md b/.claude/skills/mt/SKILL.md new file mode 100644 index 0000000..670c00a --- /dev/null +++ b/.claude/skills/mt/SKILL.md @@ -0,0 +1,132 @@ +--- +name: mt +description: 增量修改 tasks.md,根据用户指令在现有内容基础上更新任务列表。 +--- + +# Modify Tasks + +当用户调用 `/mt` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取以下文件: + +1. `doc/tasks.md` - 目标文档(必须存在) +2. `doc/UIDesign.md` - 上游参考文档 +3. `doc/DevelopmentPlan.md` - 上游参考文档 +4. `doc/review-tasks-claude.md` - 评审报告(如果存在,自动作为修改依据) + +如果 tasks.md 不存在,提示用户: +> tasks.md 不存在,请先使用 `/wt` 生成任务列表。 + +## 2. 确定修改来源 + +按以下优先级确定修改内容: + +### 2.1 用户提供了修改指令 + +如果用户在调用 `/mt` 时附带了参数或说明,直接使用该指令。 + +### 2.2 自动检测评审报告 + +如果用户未提供修改指令,**自动检测** `doc/review-tasks-claude.md` 是否存在: + +- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认: + > 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改? + +- **不存在**:询问用户: + > 请说明需要修改的内容,或先运行 `/rt` 生成评审报告。 + +## 3. 修改原则 + +### 3.1 增量修改 + +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节: + +```markdown + +新增内容... + +``` + +对于行内新增: + +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +```markdown + +修改后的内容 +``` + +### 3.4 与上游文档一致性 + +- 任务必须覆盖 DevelopmentPlan 所有开发项 +- 任务必须覆盖 UIDesign 所有页面实现 +- 任务依赖关系必须合理 + +## 4. 执行修改 + +| 修改类型 | 处理方式 | +|----------|----------| +| 新增任务 | 在对应阶段表格中添加行,分配新 ID | +| 修改描述 | 更新任务描述,添加 MODIFIED 标记 | +| 修改优先级 | 更新优先级列 | +| 修改依赖 | 更新依赖列,检查循环依赖 | +| 修改验收标准 | 更新验收标准列 | +| 调整阶段 | 移动任务到新阶段,更新依赖图 | + +### 4.1 任务 ID 规则 + +- 新增任务 ID 必须唯一 +- ID 格式:T-XXX(三位数字,如 T-001) +- 在现有最大 ID 基础上递增 + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/tasks.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: + +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 +- 新增/修改的任务 ID 列表 +- 与上游文档的一致性确认 + +--- + +## 注意事项 + +- tasks.md 是文档链末端,修改不影响其他文档 +- 任务 ID 必须唯一,不可重复使用已删除的 ID +- 修改依赖关系时需检查是否产生循环依赖 +- 验收标准必须具体可测试 +- 任务粒度要适中 + +## 标记清理 + +用户确认修改无误后,可手动删除标记或保留作为变更历史参考。 + +## 质量检查 + +修改 tasks 后,自查以下项目: + +- [ ] 任务 ID 唯一且格式正确 +- [ ] 无循环依赖 +- [ ] 验收标准明确 +- [ ] 覆盖所有上游功能 +- [ ] 标记格式正确 diff --git a/.claude/skills/mu/SKILL.md b/.claude/skills/mu/SKILL.md new file mode 100644 index 0000000..e0d6061 --- /dev/null +++ b/.claude/skills/mu/SKILL.md @@ -0,0 +1,114 @@ +--- +name: mu +description: 增量修改 UIDesign.md,根据用户指令在现有内容基础上更新 UI 设计文档。 +--- + +# Modify UIDesign + +当用户调用 `/mu` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取以下文件: + +1. `doc/UIDesign.md` - 目标文档(必须存在) +2. `doc/DevelopmentPlan.md` - 上游参考文档 +3. `doc/review-UIDesign-claude.md` - 评审报告(如果存在,自动作为修改依据) + +如果 UIDesign.md 不存在,提示用户: +> UIDesign.md 不存在,请先使用 `/wu` 生成 UI 设计文档。 + +## 2. 确定修改来源 + +按以下优先级确定修改内容: + +### 2.1 用户提供了修改指令 + +如果用户在调用 `/mu` 时附带了参数或说明,直接使用该指令。 + +### 2.2 自动检测评审报告 + +如果用户未提供修改指令,**自动检测** `doc/review-UIDesign-claude.md` 是否存在: + +- **存在**:读取评审报告,提取其中的问题清单,作为本次修改的依据。向用户确认: + > 检测到评审报告,包含 X 个问题。是否根据评审报告进行修改? + +- **不存在**:询问用户: + > 请说明需要修改的内容,或先运行 `/ru` 生成评审报告。 + +## 3. 修改原则 + +### 3.1 增量修改 + +- 保留原有内容结构和格式 +- 仅修改/新增指定部分 +- 不删除未明确要求删除的内容 + +### 3.2 新增内容标记 + +对于新增的段落或章节: + +```markdown + +新增内容... + +``` + +对于行内新增: + +```markdown +原有内容 新增内容 +``` + +### 3.3 修改内容标记 + +```markdown + +修改后的内容 +``` + +### 3.4 与 DevelopmentPlan 一致性 + +- 页面设计必须覆盖所有功能模块 +- 交互流程必须支撑功能需求 +- 设计规范必须统一 + +## 4. 执行修改 + +| 修改类型 | 处理方式 | +|----------|----------| +| 新增页面 | 在页面设计章节添加新子章节 | +| 修改布局 | 更新布局描述,添加 MODIFIED 标记 | +| 修改组件 | 更新组件表格 | +| 修改交互 | 更新交互说明 | +| 新增状态 | 在状态列表中添加项目 | +| 修改设计规范 | 更新设计规范章节 | + +## 5. 保存并验证 + +1. 保存修改后的文档到 `doc/UIDesign.md` +2. 使用 git diff 展示变更内容 +3. 向用户确认修改是否符合预期 + +## 6. 输出摘要 + +向用户展示修改摘要: + +- 修改位置(章节/行号) +- 修改类型(新增/修改/删除) +- 修改内容概要 +- 与 DevelopmentPlan 的一致性确认 + +--- + +## 注意事项 + +- UIDesign 依赖于 DevelopmentPlan,修改时需确保与上游一致 +- 修改后,下游文档(tasks)可能需要同步更新 +- 页面修改需考虑对用户流程的影响 +- 设计规范修改需检查所有页面的一致性 +- 建议修改完成后运行 `/rt` 检查下游一致性 + +## 标记清理 + +用户确认修改无误后,可手动删除标记或保留作为变更历史参考。 diff --git a/.claude/skills/rd/SKILL.md b/.claude/skills/rd/SKILL.md new file mode 100644 index 0000000..665117c --- /dev/null +++ b/.claude/skills/rd/SKILL.md @@ -0,0 +1,101 @@ +--- +name: rd +description: 评审 DevelopmentPlan.md,检查技术可行性和与上游文档一致性,输出结构化评审报告。 +--- + +# Review DevelopmentPlan + +当用户调用 `/rd` 时,执行以下步骤: + +## 1. 读取文档 + +读取以下文件: + +1. `doc/DevelopmentPlan.md` - 目标文档(必须存在) +2. `doc/FeatureSummary.md` - 上游参照文档 + +如果 DevelopmentPlan.md 不存在,提示用户: +> DevelopmentPlan.md 不存在,请先使用 `/wd` 生成开发计划。 + +## 2. 评审维度 + +### 2.1 与 FeatureSummary 一致性检查 + +- 开发任务是否覆盖所有功能模块 +- 技术方案是否支撑功能需求 +- 排期是否合理 + +### 2.2 技术可行性检查 + +- 技术方案是否可行 +- 技术栈选择是否合理 +- 是否存在技术风险 +- 依赖关系是否明确 + +### 2.3 完整性检查 + +- 是否有明确的里程碑划分 +- 是否有资源分配说明 +- 是否有风险应对措施 + +## 3. 生成评审报告 + +输出到 `doc/review-DevelopmentPlan-claude.md`,结构如下: + +```markdown +# DevelopmentPlan 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:MM} | +| 目标文档 | doc/DevelopmentPlan.md | +| 参照文档 | doc/FeatureSummary.md | +| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 | + +## 功能覆盖分析 + +| FeatureSummary 功能 | DevelopmentPlan 对应 | 状态 | +|---------------------|----------------------|------| +| {功能名} | {对应任务/模块} | ✅/⚠️/❌ | + +## 技术风险分析 + +| 风险项 | 影响范围 | 严重程度 | 建议措施 | +|--------|----------|----------|----------| +| {风险} | {范围} | 高/中/低 | {措施} | + +## 问题清单 + +### 严重问题 (Critical) +{问题列表,含位置引用} + +### 一般问题 (Major) +{问题列表,含位置引用} + +### 改进建议 (Minor) +{建议列表} + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +### 下一步行动 +- [ ] {待办事项} +``` + +## 4. 输出规范 + +- 输出语言:中文 +- 问题分级:Critical / Major / Minor +- 包含文件引用(如 `doc/DevelopmentPlan.md:28`) +- 技术风险需明确影响范围和应对建议 + +--- + +## 注意事项 + +- 只做评审,不修改原文档 +- 重点关注技术可行性和风险 +- 评审报告保存后,建议用户根据问题运行 `/md` 修改 diff --git a/.claude/skills/rf/SKILL.md b/.claude/skills/rf/SKILL.md new file mode 100644 index 0000000..ad3e464 --- /dev/null +++ b/.claude/skills/rf/SKILL.md @@ -0,0 +1,96 @@ +--- +name: rf +description: 评审 FeatureSummary.md,对比 PRD 检查一致性,输出结构化评审报告。 +--- + +# Review FeatureSummary + +当用户调用 `/rf` 时,执行以下步骤: + +## 1. 读取文档 + +读取以下文件: + +1. `doc/FeatureSummary.md` - 目标文档(必须存在) +2. `doc/PRD.md` - 上游参照文档 + +如果 FeatureSummary.md 不存在,提示用户: +> FeatureSummary.md 不存在,请先使用 `/wf` 生成功能摘要。 + +## 2. 评审维度 + +### 2.1 与 PRD 一致性检查 + +- 功能模块是否完整覆盖 PRD 3.2 功能详情 +- 功能描述是否与 PRD 一致 +- 优先级标注是否与 PRD 匹配 + +### 2.2 完整性检查 + +- 每个功能模块是否有清晰的描述 +- 是否遗漏 PRD 中的功能点 +- 功能分类是否合理 + +### 2.3 质量检查 + +- 描述是否简洁准确 +- 是否有冗余或重复内容 +- 格式是否规范统一 + +## 3. 生成评审报告 + +输出到 `doc/review-FeatureSummary-claude.md`,结构如下: + +```markdown +# FeatureSummary 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:MM} | +| 目标文档 | doc/FeatureSummary.md | +| 参照文档 | doc/PRD.md | +| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 | + +## 覆盖度分析 + +| PRD 功能模块 | FeatureSummary 对应 | 状态 | +|--------------|---------------------|------| +| {模块名} | {对应位置} | ✅/⚠️/❌ | + +**覆盖率**: X/Y 完全覆盖 + +## 问题清单 + +### 严重问题 (Critical) +{问题列表,含位置引用} + +### 一般问题 (Major) +{问题列表,含位置引用} + +### 改进建议 (Minor) +{建议列表} + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +### 下一步行动 +- [ ] {待办事项} +``` + +## 4. 输出规范 + +- 输出语言:中文 +- 问题分级:Critical / Major / Minor +- 包含文件引用(如 `doc/FeatureSummary.md:15`) +- 问题按严重性排序 + +--- + +## 注意事项 + +- 只做评审,不修改原文档 +- 重点检查与 PRD 的一致性 +- 评审报告保存后,建议用户根据问题运行 `/mf` 修改 diff --git a/.claude/skills/rp/SKILL.md b/.claude/skills/rp/SKILL.md new file mode 100644 index 0000000..befe913 --- /dev/null +++ b/.claude/skills/rp/SKILL.md @@ -0,0 +1,177 @@ +--- +name: rp +description: 评审 PRD.md,对比 RequirementsDoc 检查一致性,输出结构化评审报告。 +--- + +# Review PRD + +当用户调用 `/rp` 时,执行以下步骤: + +## 1. 读取文档 + +读取以下文件: +- 目标文档:`doc/PRD.md` +- 上游文档:`doc/RequirementsDoc.md` + +如果 PRD.md 不存在,提示用户: +> PRD.md 不存在,请先使用 `/wp` 生成 PRD。 + +如果 RequirementsDoc.md 不存在,提示用户: +> RequirementsDoc.md 不存在,无法进行一致性检查。请先创建需求文档。 + +## 2. 评审维度 + +PRD 位于文档链的第二层,需要对比上游 RequirementsDoc 进行评审。 + +### 2.1 与 RequirementsDoc 的一致性 + +- [ ] PRD 是否覆盖了 RequirementsDoc 中的所有功能需求 +- [ ] PRD 是否覆盖了 RequirementsDoc 中的所有非功能需求 +- [ ] PRD 中是否有 RequirementsDoc 中未提及的需求(需标注来源) +- [ ] 术语定义是否与 RequirementsDoc 一致 +- [ ] 优先级划分是否与 RequirementsDoc 一致 + +### 2.2 用户故事质量 + +- [ ] 所有用户故事是否有唯一 ID(US-xxx) +- [ ] 用户故事是否符合格式:作为{角色},我想要{功能},以便{价值} +- [ ] 用户角色是否明确定义 +- [ ] 验收标准是否具体可测试 +- [ ] 用户旅程是否完整描述核心流程 + +### 2.3 功能需求完整性 + +- [ ] 功能架构是否清晰(模块划分合理) +- [ ] 所有功能点是否关联到用户故事 +- [ ] 功能点是否有明确的优先级 +- [ ] 功能点是否有验收标准 +- [ ] 是否遗漏边界情况和异常处理 + +### 2.4 非功能需求 + +- [ ] 性能需求是否有量化指标 +- [ ] 安全需求是否明确 +- [ ] 兼容性需求是否完整 +- [ ] 可用性需求是否可验证 + +### 2.5 文档结构 + +- [ ] 文档结构是否完整(无空章节) +- [ ] 格式是否统一(表格、列表、标题层级) +- [ ] 术语表是否完整 + +## 3. 生成评审报告 + +按以下格式输出评审报告: + +```markdown +# PRD 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:mm} | +| 目标文档 | doc/PRD.md | +| 参照文档 | doc/RequirementsDoc.md | +| 问题统计 | {critical} 个严重 / {major} 个一般 / {minor} 个建议 | + +## 一致性检查 + +### 需求覆盖分析 + +| RequirementsDoc 需求项 | PRD 对应位置 | 状态 | +|------------------------|--------------|------| +| {需求1} | {PRD章节/用户故事ID} | ✅ 已覆盖 / ⚠️ 部分覆盖 / ❌ 未覆盖 | + +### 差异说明 + +{列出 PRD 中新增的、RequirementsDoc 未提及的内容,需说明来源或理由} + +## 问题清单 + +### 严重问题 (Critical) + +> 必须修复,否则影响后续文档生成 + +1. **[位置: doc/PRD.md:行号]** 问题描述 + - 现状:... + - 与 RequirementsDoc 的差异:... + - 建议:... + +### 一般问题 (Major) + +> 建议修复,可提升文档质量 + +1. **[位置]** 问题描述 + - 建议:... + +### 改进建议 (Minor) + +> 可选优化项 + +1. **[位置]** 建议内容 + +## 用户故事评估 + +| 评估项 | 结果 | +|--------|------| +| 用户故事总数 | {数量} | +| 符合格式规范 | {数量} / {总数} | +| 有验收标准 | {数量} / {总数} | +| 关联功能点 | {数量} / {总数} | + +### 用户故事问题 + +{列出不符合规范的用户故事} + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +**结论说明**: +- 通过:PRD 与 RequirementsDoc 一致,可进入下一阶段 +- 需修改后通过:存在问题但不影响整体理解,修复后可继续 +- 不通过:存在严重一致性问题或遗漏,需重新生成 + +### 下一步行动 + +- [ ] 行动项1 +- [ ] 行动项2 +``` + +## 4. 保存报告 + +将评审报告保存到 `doc/review-PRD-claude.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示评审摘要: +- 一致性检查结果(覆盖率) +- 发现的问题数量(按严重程度分类) +- 用户故事评估结果 +- 评审结论 +- 报告文件路径 + +--- + +## 注意事项 + +- 评审时保持客观,聚焦于文档质量和一致性 +- 问题描述要具体,给出明确的位置引用(如 `doc/PRD.md:42`) +- 一致性检查要逐项对比,不能遗漏 +- 建议要可操作,避免模糊表述 +- 不要修改原文档,只输出评审报告 + +## 常见问题模式 + +在评审时重点关注以下常见问题: + +1. **需求遗漏**:RequirementsDoc 中有但 PRD 中没有的需求 +2. **需求偏离**:PRD 中的描述与 RequirementsDoc 不一致 +3. **凭空添加**:PRD 中有但 RequirementsDoc 中没有的需求(需要来源说明) +4. **用户故事缺陷**:格式不规范、缺少验收标准、角色不明确 +5. **功能孤立**:功能点未关联到任何用户故事 +6. **优先级冲突**:PRD 与 RequirementsDoc 的优先级划分不一致 diff --git a/.claude/skills/rr/SKILL.md b/.claude/skills/rr/SKILL.md new file mode 100644 index 0000000..7967a9b --- /dev/null +++ b/.claude/skills/rr/SKILL.md @@ -0,0 +1,111 @@ +--- +name: rr +description: 评审 RequirementsDoc.md,检查需求文档的完整性、清晰度和可执行性,输出结构化评审报告。 +--- + +# Review RequirementsDoc + +当用户调用 `/rr` 时,执行以下步骤: + +## 1. 读取目标文档 + +读取 `doc/RequirementsDoc.md` 文件。 + +如果文件不存在,提示用户: +> RequirementsDoc.md 不存在,请先创建需求文档。 + +## 2. 评审维度 + +RequirementsDoc 是文档链的源头,没有上游依赖。重点检查以下维度: + +### 2.1 完整性 +- [ ] 产品概述是否清晰(定位、目标用户、核心价值) +- [ ] 功能需求是否完整列出 +- [ ] 非功能需求是否涵盖(性能、安全、兼容性) +- [ ] 数据规范是否明确(输入输出格式、字段定义) +- [ ] 边界条件和异常情况是否考虑 + +### 2.2 清晰度 +- [ ] 术语定义是否一致,无歧义 +- [ ] 用例描述是否具体可理解 +- [ ] 优先级是否明确标注 +- [ ] 是否有模糊表述("等"、"可能"、"应该"等) + +### 2.3 可执行性 +- [ ] 需求是否可被验证(有明确的验收标准) +- [ ] 技术约束是否合理 +- [ ] 依赖项是否明确 + +### 2.4 结构规范 +- [ ] 文档结构是否清晰(章节划分合理) +- [ ] 格式是否统一(表格、列表、标题层级) + +## 3. 生成评审报告 + +按以下格式输出评审报告: + +```markdown +# RequirementsDoc 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:mm} | +| 目标文档 | doc/RequirementsDoc.md | +| 问题统计 | {critical} 个严重 / {major} 个一般 / {minor} 个建议 | + +## 问题清单 + +### 严重问题 (Critical) + +> 必须修复,否则影响后续文档生成 + +1. **[位置: 第X节/第Y行]** 问题描述 + - 现状:... + - 建议:... + +### 一般问题 (Major) + +> 建议修复,可提升文档质量 + +1. **[位置]** 问题描述 + - 建议:... + +### 改进建议 (Minor) + +> 可选优化项 + +1. **[位置]** 建议内容 + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +### 下一步行动 + +- [ ] 行动项1 +- [ ] 行动项2 +``` + +## 4. 保存报告 + +将评审报告保存到 `doc/review-RequirementsDoc-claude.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示评审摘要: +- 发现的问题数量(按严重程度分类) +- 评审结论 +- 报告文件路径 + +--- + +## 注意事项 + +- 评审时保持客观,聚焦于文档质量而非业务判断 +- 问题描述要具体,给出明确的位置引用 +- 建议要可操作,避免模糊表述 +- 不要修改原文档,只输出评审报告 diff --git a/.claude/skills/rt/SKILL.md b/.claude/skills/rt/SKILL.md new file mode 100644 index 0000000..7db4e44 --- /dev/null +++ b/.claude/skills/rt/SKILL.md @@ -0,0 +1,115 @@ +--- +name: rt +description: 评审 tasks.md,检查任务完整性和与上游文档一致性,输出结构化评审报告。 +--- + +# Review Tasks + +当用户调用 `/rt` 时,执行以下步骤: + +## 1. 读取文档 + +读取以下文件: + +1. `doc/tasks.md` - 目标文档(必须存在) +2. `doc/UIDesign.md` - 上游参照文档 +3. `doc/DevelopmentPlan.md` - 上游参照文档 + +如果 tasks.md 不存在,提示用户: +> tasks.md 不存在,请先使用 `/wt` 生成任务列表。 + +## 2. 评审维度 + +### 2.1 与上游文档一致性检查 + +- 任务是否覆盖 DevelopmentPlan 所有开发项 +- 任务是否覆盖 UIDesign 所有页面实现 +- 任务优先级是否与功能优先级匹配 + +### 2.2 任务完整性检查 + +- 每个任务是否有明确的描述 +- 任务粒度是否合适(不过大也不过小) +- 任务依赖关系是否明确 +- 验收标准是否清晰 + +### 2.3 可执行性检查 + +- 任务是否可直接开始执行 +- 是否有阻塞项未说明 +- 估时是否合理(如有) + +## 3. 生成评审报告 + +输出到 `doc/review-tasks-claude.md`,结构如下: + +```markdown +# Tasks 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:MM} | +| 目标文档 | doc/tasks.md | +| 参照文档 | doc/UIDesign.md, doc/DevelopmentPlan.md | +| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 | + +## 覆盖度分析 + +### DevelopmentPlan 覆盖 + +| 开发项 | 对应任务 | 状态 | +|--------|----------|------| +| {开发项} | {任务ID/名称} | ✅/⚠️/❌ | + +### UIDesign 覆盖 + +| UI 页面 | 对应任务 | 状态 | +|---------|----------|------| +| {页面名} | {任务ID/名称} | ✅/⚠️/❌ | + +**总覆盖率**: X/Y + +## 任务质量分析 + +| 检查项 | 通过数 | 总数 | +|--------|--------|------| +| 有明确描述 | X | Y | +| 有验收标准 | X | Y | +| 粒度合适 | X | Y | + +## 问题清单 + +### 严重问题 (Critical) +{问题列表,含位置引用} + +### 一般问题 (Major) +{问题列表,含位置引用} + +### 改进建议 (Minor) +{建议列表} + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +### 下一步行动 +- [ ] {待办事项} +``` + +## 4. 输出规范 + +- 输出语言:中文 +- 问题分级:Critical / Major / Minor +- 包含文件引用(如 `doc/tasks.md:12`) +- 任务问题需说明对开发执行的影响 + +--- + +## 注意事项 + +- 只做评审,不修改原文档 +- 重点检查任务覆盖度和可执行性 +- tasks.md 是文档链末端,必须覆盖所有上游功能 +- 评审报告保存后,建议用户根据问题运行 `/mt` 修改 diff --git a/.claude/skills/ru/SKILL.md b/.claude/skills/ru/SKILL.md new file mode 100644 index 0000000..8fbc125 --- /dev/null +++ b/.claude/skills/ru/SKILL.md @@ -0,0 +1,105 @@ +--- +name: ru +description: 评审 UIDesign.md,对比 DevelopmentPlan 检查设计一致性,输出结构化评审报告。 +--- + +# Review UIDesign + +当用户调用 `/ru` 时,执行以下步骤: + +## 1. 读取文档 + +读取以下文件: + +1. `doc/UIDesign.md` - 目标文档(必须存在) +2. `doc/DevelopmentPlan.md` - 上游参照文档 + +如果 UIDesign.md 不存在,提示用户: +> UIDesign.md 不存在,请先使用 `/wu` 生成 UI 设计文档。 + +## 2. 评审维度 + +### 2.1 与 DevelopmentPlan 一致性检查 + +- UI 页面是否覆盖所有功能模块 +- 交互流程是否与开发计划匹配 +- 页面结构是否支撑功能需求 + +### 2.2 设计完整性检查 + +- 页面列表是否完整 +- 每个页面是否有清晰的布局描述 +- 交互说明是否充分 +- 状态变化是否考虑全面(加载、错误、空状态等) + +### 2.3 可用性检查 + +- 用户流程是否顺畅 +- 信息架构是否合理 +- 是否有一致的设计规范 + +## 3. 生成评审报告 + +输出到 `doc/review-UIDesign-claude.md`,结构如下: + +```markdown +# UIDesign 评审报告 + +## 概要 + +| 项目 | 内容 | +|------|------| +| 评审时间 | {YYYY-MM-DD HH:MM} | +| 目标文档 | doc/UIDesign.md | +| 参照文档 | doc/DevelopmentPlan.md | +| 问题统计 | X 个严重 / Y 个一般 / Z 个建议 | + +## 页面覆盖分析 + +| DevelopmentPlan 功能 | UIDesign 页面 | 状态 | +|----------------------|---------------|------| +| {功能名} | {对应页面} | ✅/⚠️/❌ | + +**覆盖率**: X/Y 完全覆盖 + +## 设计一致性检查 + +| 检查项 | 结果 | +|--------|------| +| 页面命名规范 | ✅/❌ | +| 布局风格统一 | ✅/❌ | +| 交互模式一致 | ✅/❌ | + +## 问题清单 + +### 严重问题 (Critical) +{问题列表,含位置引用} + +### 一般问题 (Major) +{问题列表,含位置引用} + +### 改进建议 (Minor) +{建议列表} + +## 评审结论 + +{通过 / 需修改后通过 / 不通过} + +### 下一步行动 +- [ ] {待办事项} +``` + +## 4. 输出规范 + +- 输出语言:中文 +- 问题分级:Critical / Major / Minor +- 包含文件引用(如 `doc/UIDesign.md:45`) +- 设计问题需说明影响的用户体验 + +--- + +## 注意事项 + +- 只做评审,不修改原文档 +- 重点检查页面覆盖度和设计一致性 +- 评审报告保存后,建议用户根据问题运行 `/mu` 修改 diff --git a/.claude/skills/tdd-workflow/SKILL.md b/.claude/skills/tdd-workflow/SKILL.md new file mode 100644 index 0000000..90c0a6d --- /dev/null +++ b/.claude/skills/tdd-workflow/SKILL.md @@ -0,0 +1,410 @@ +--- +name: tdd-workflow +description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. +origin: ECC +--- + +# Test-Driven Development Workflow + +This skill ensures all code development follows TDD principles with comprehensive test coverage. + +## When to Activate + +- Writing new features or functionality +- Fixing bugs or issues +- Refactoring existing code +- Adding API endpoints +- Creating new components + +## Core Principles + +### 1. Tests BEFORE Code +ALWAYS write tests first, then implement code to make tests pass. + +### 2. Coverage Requirements +- Minimum 80% coverage (unit + integration + E2E) +- All edge cases covered +- Error scenarios tested +- Boundary conditions verified + +### 3. Test Types + +#### Unit Tests +- Individual functions and utilities +- Component logic +- Pure functions +- Helpers and utilities + +#### Integration Tests +- API endpoints +- Database operations +- Service interactions +- External API calls + +#### E2E Tests (Playwright) +- Critical user flows +- Complete workflows +- Browser automation +- UI interactions + +## TDD Workflow Steps + +### Step 1: Write User Journeys +``` +As a [role], I want to [action], so that [benefit] + +Example: +As a user, I want to search for markets semantically, +so that I can find relevant markets even without exact keywords. +``` + +### Step 2: Generate Test Cases +For each user journey, create comprehensive test cases: + +```typescript +describe('Semantic Search', () => { + it('returns relevant markets for query', async () => { + // Test implementation + }) + + it('handles empty query gracefully', async () => { + // Test edge case + }) + + it('falls back to substring search when Redis unavailable', async () => { + // Test fallback behavior + }) + + it('sorts results by similarity score', async () => { + // Test sorting logic + }) +}) +``` + +### Step 3: Run Tests (They Should Fail) +```bash +npm test +# Tests should fail - we haven't implemented yet +``` + +### Step 4: Implement Code +Write minimal code to make tests pass: + +```typescript +// Implementation guided by tests +export async function searchMarkets(query: string) { + // Implementation here +} +``` + +### Step 5: Run Tests Again +```bash +npm test +# Tests should now pass +``` + +### Step 6: Refactor +Improve code quality while keeping tests green: +- Remove duplication +- Improve naming +- Optimize performance +- Enhance readability + +### Step 7: Verify Coverage +```bash +npm run test:coverage +# Verify 80%+ coverage achieved +``` + +## Testing Patterns + +### Unit Test Pattern (Jest/Vitest) +```typescript +import { render, screen, fireEvent } from '@testing-library/react' +import { Button } from './Button' + +describe('Button Component', () => { + it('renders with correct text', () => { + render() + expect(screen.getByText('Click me')).toBeInTheDocument() + }) + + it('calls onClick when clicked', () => { + const handleClick = jest.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('is disabled when disabled prop is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) +}) +``` + +### API Integration Test Pattern +```typescript +import { NextRequest } from 'next/server' +import { GET } from './route' + +describe('GET /api/markets', () => { + it('returns markets successfully', async () => { + const request = new NextRequest('http://localhost/api/markets') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(Array.isArray(data.data)).toBe(true) + }) + + it('validates query parameters', async () => { + const request = new NextRequest('http://localhost/api/markets?limit=invalid') + const response = await GET(request) + + expect(response.status).toBe(400) + }) + + it('handles database errors gracefully', async () => { + // Mock database failure + const request = new NextRequest('http://localhost/api/markets') + // Test error handling + }) +}) +``` + +### E2E Test Pattern (Playwright) +```typescript +import { test, expect } from '@playwright/test' + +test('user can search and filter markets', async ({ page }) => { + // Navigate to markets page + await page.goto('/') + await page.click('a[href="/markets"]') + + // Verify page loaded + await expect(page.locator('h1')).toContainText('Markets') + + // Search for markets + await page.fill('input[placeholder="Search markets"]', 'election') + + // Wait for debounce and results + await page.waitForTimeout(600) + + // Verify search results displayed + const results = page.locator('[data-testid="market-card"]') + await expect(results).toHaveCount(5, { timeout: 5000 }) + + // Verify results contain search term + const firstResult = results.first() + await expect(firstResult).toContainText('election', { ignoreCase: true }) + + // Filter by status + await page.click('button:has-text("Active")') + + // Verify filtered results + await expect(results).toHaveCount(3) +}) + +test('user can create a new market', async ({ page }) => { + // Login first + await page.goto('/creator-dashboard') + + // Fill market creation form + await page.fill('input[name="name"]', 'Test Market') + await page.fill('textarea[name="description"]', 'Test description') + await page.fill('input[name="endDate"]', '2025-12-31') + + // Submit form + await page.click('button[type="submit"]') + + // Verify success message + await expect(page.locator('text=Market created successfully')).toBeVisible() + + // Verify redirect to market page + await expect(page).toHaveURL(/\/markets\/test-market/) +}) +``` + +## Test File Organization + +``` +src/ +├── components/ +│ ├── Button/ +│ │ ├── Button.tsx +│ │ ├── Button.test.tsx # Unit tests +│ │ └── Button.stories.tsx # Storybook +│ └── MarketCard/ +│ ├── MarketCard.tsx +│ └── MarketCard.test.tsx +├── app/ +│ └── api/ +│ └── markets/ +│ ├── route.ts +│ └── route.test.ts # Integration tests +└── e2e/ + ├── markets.spec.ts # E2E tests + ├── trading.spec.ts + └── auth.spec.ts +``` + +## Mocking External Services + +### Supabase Mock +```typescript +jest.mock('@/lib/supabase', () => ({ + supabase: { + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(() => Promise.resolve({ + data: [{ id: 1, name: 'Test Market' }], + error: null + })) + })) + })) + } +})) +``` + +### Redis Mock +```typescript +jest.mock('@/lib/redis', () => ({ + searchMarketsByVector: jest.fn(() => Promise.resolve([ + { slug: 'test-market', similarity_score: 0.95 } + ])), + checkRedisHealth: jest.fn(() => Promise.resolve({ connected: true })) +})) +``` + +### OpenAI Mock +```typescript +jest.mock('@/lib/openai', () => ({ + generateEmbedding: jest.fn(() => Promise.resolve( + new Array(1536).fill(0.1) // Mock 1536-dim embedding + )) +})) +``` + +## Test Coverage Verification + +### Run Coverage Report +```bash +npm run test:coverage +``` + +### Coverage Thresholds +```json +{ + "jest": { + "coverageThresholds": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } + } +} +``` + +## Common Testing Mistakes to Avoid + +### ❌ WRONG: Testing Implementation Details +```typescript +// Don't test internal state +expect(component.state.count).toBe(5) +``` + +### ✅ CORRECT: Test User-Visible Behavior +```typescript +// Test what users see +expect(screen.getByText('Count: 5')).toBeInTheDocument() +``` + +### ❌ WRONG: Brittle Selectors +```typescript +// Breaks easily +await page.click('.css-class-xyz') +``` + +### ✅ CORRECT: Semantic Selectors +```typescript +// Resilient to changes +await page.click('button:has-text("Submit")') +await page.click('[data-testid="submit-button"]') +``` + +### ❌ WRONG: No Test Isolation +```typescript +// Tests depend on each other +test('creates user', () => { /* ... */ }) +test('updates same user', () => { /* depends on previous test */ }) +``` + +### ✅ CORRECT: Independent Tests +```typescript +// Each test sets up its own data +test('creates user', () => { + const user = createTestUser() + // Test logic +}) + +test('updates user', () => { + const user = createTestUser() + // Update logic +}) +``` + +## Continuous Testing + +### Watch Mode During Development +```bash +npm test -- --watch +# Tests run automatically on file changes +``` + +### Pre-Commit Hook +```bash +# Runs before every commit +npm test && npm run lint +``` + +### CI/CD Integration +```yaml +# GitHub Actions +- name: Run Tests + run: npm test -- --coverage +- name: Upload Coverage + uses: codecov/codecov-action@v3 +``` + +## Best Practices + +1. **Write Tests First** - Always TDD +2. **One Assert Per Test** - Focus on single behavior +3. **Descriptive Test Names** - Explain what's tested +4. **Arrange-Act-Assert** - Clear test structure +5. **Mock External Dependencies** - Isolate unit tests +6. **Test Edge Cases** - Null, undefined, empty, large +7. **Test Error Paths** - Not just happy paths +8. **Keep Tests Fast** - Unit tests < 50ms each +9. **Clean Up After Tests** - No side effects +10. **Review Coverage Reports** - Identify gaps + +## Success Metrics + +- 80%+ code coverage achieved +- All tests passing (green) +- No skipped or disabled tests +- Fast test execution (< 30s for unit tests) +- E2E tests cover critical user flows +- Tests catch bugs before production + +--- + +**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability. diff --git a/.claude/skills/up/SKILL.md b/.claude/skills/up/SKILL.md new file mode 100644 index 0000000..4b1ca96 --- /dev/null +++ b/.claude/skills/up/SKILL.md @@ -0,0 +1,78 @@ +--- +name: update +aliases: [up] +description: 收集用户反馈并更新最近使用的 skill。可通过 /update 或 /up 调用。在用完某个 skill 后调用此命令来优化该 skill。 +disable-model-invocation: true +argument-hint: [skill-name] +--- + +# Skill 更新助手 + +当用户调用 `/up` 或 `/up ` 时,执行以下步骤: + +## 1. 识别目标 Skill + +**如果用户提供了参数 `$ARGUMENTS`**: +- 直接使用指定的 skill 名称作为更新目标 + +**如果没有提供参数**: +分析当前对话历史,找出最近使用的 skill: +- 搜索对话中的 `` 标签,识别调用过的 skill +- 如果找到多个 skill,让用户确认要更新哪一个 +- 如果没有找到任何 skill 调用记录,提示用户先使用一个 skill + +## 2. 收集用户反馈 + +向用户询问以下问题(使用 AskUserQuestion 工具): + +**问题 1:这次使用体验如何?** +- 很好,skill 完全满足需求 +- 基本满足,但有改进空间 +- 不太满意,需要较大调整 + +**问题 2:具体需要改进的方面?**(多选) +- 执行步骤不够清晰 +- 缺少某些功能 +- 输出格式需要调整 +- 提示词需要优化 +- 其他(用户自定义输入) + +## 3. 分析优化点 + +基于用户反馈和本次 skill 使用过程,分析以下方面: + +1. **执行流程**:哪些步骤可以简化或合并? +2. **指令清晰度**:哪些指令描述不够明确? +3. **遗漏功能**:有哪些场景没有覆盖到? +4. **输出质量**:输出格式是否符合用户预期? + +## 4. 定位 Skill 文件 + +按以下优先级搜索 skill 文件: +1. 项目级:`.claude/skills//SKILL.md` +2. 用户级:`~/.claude/skills//SKILL.md` + +## 5. 更新 Skill + +读取现有的 SKILL.md 文件内容,根据分析结果进行更新: + +- 保持 frontmatter 格式不变(除非需要修改 description) +- 优化执行步骤的描述 +- 添加缺失的功能说明 +- 改进提示词的表达方式 +- 添加必要的注意事项或边界情况处理 + +## 6. 确认更新 + +在更新前,向用户展示: +- 修改前后的对比(diff 格式) +- 说明每处修改的原因 + +用户确认后才执行实际的文件更新。 + +## 注意事项 + +- 如果 skill 文件不存在或路径无法确定,提示用户手动指定路径 +- 更新时保持 skill 的原有风格和结构 +- 重大修改需要用户明确确认 +- 保留原有的有效内容,只做增量优化 diff --git a/.claude/skills/wd/SKILL.md b/.claude/skills/wd/SKILL.md new file mode 100644 index 0000000..9343961 --- /dev/null +++ b/.claude/skills/wd/SKILL.md @@ -0,0 +1,323 @@ +--- +name: wd +description: 从上游文档生成 DevelopmentPlan.md,包含技术方案和开发排期。 +--- + +# Write DevelopmentPlan + +> **文档定位**:DevelopmentPlan 是「执行蓝图」文档,偏技术语言和时间约束。定义技术架构、实现方案、开发阶段、里程碑,是开发团队的行动指南。 + +当用户调用 `/wd` 时,执行以下步骤: + +## 1. 读取源文档 + +读取以下文件: + +1. `doc/RequirementsDoc.md` - 必须存在 +2. `doc/PRD.md` - 必须存在 +3. `doc/FeatureSummary.md` - 必须存在 + +如果文件不存在,提示用户: +> 缺少上游文档,请先确保 RequirementsDoc.md、PRD.md 和 FeatureSummary.md 存在。 + +如果已存在 `doc/DevelopmentPlan.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析开发需求 + +从上游文档中提取以下信息: + +### 2.1 功能需求 + +- 从 FeatureSummary 获取功能清单和契约 +- 从 PRD 获取功能详情和验收标准 + +### 2.2 技术约束 + +- 从 PRD 获取技术约束 +- 从 RequirementsDoc 获取技术决策 + +### 2.3 优先级排序 + +- 按 P0 → P1 → P2 顺序规划开发 +- 考虑功能依赖关系 + +## 3. 生成 DevelopmentPlan + +按以下结构生成文档: + +```markdown +# {产品名称} - 开发计划 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 来源文档 | FeatureSummary.md | + +## 1. 项目概述 + +### 1.1 项目目标 + +{从 PRD 提取的项目目标} + +### 1.2 技术栈 + +| 层级 | 技术选型 | 版本 | 说明 | +|------|----------|------|------| +| 前端 | {技术} | {版本} | {说明} | +| 后端 | {技术} | {版本} | {说明} | +| 数据库 | {技术} | {版本} | {说明} | +| 基础设施 | {技术} | {版本} | {说明} | + +### 1.3 开发原则 + +{开发规范和原则} + +## 2. 技术架构 + +### 2.1 系统架构图 + +**【必须】使用架构图展示系统整体结构:** + +``` +┌─────────────────────────────────────────────────────────┐ +│ 客户端层 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Web App │ │ Mobile App │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +└─────────┼─────────────────┼─────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ API 网关层 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ API Gateway / Load Balancer │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 服务层 │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ 服务 A │ │ 服务 B │ │ 服务 C │ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +└────────┼───────────────┼───────────────┼────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ 数据库 │ │ 缓存 │ │ 消息队列 │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块依赖图 + +**【必须】使用依赖图展示模块间关系:** + +``` +┌──────────────┐ +│ 模块 A │ +│ (核心模块) │ +└──────┬───────┘ + │ + ┌───┴───┐ + ▼ ▼ +┌──────┐ ┌──────┐ +│模块B │ │模块C │ +└──┬───┘ └──┬───┘ + │ │ + ▼ ▼ +┌──────────────┐ +│ 模块 D │ +│ (基础设施) │ +└──────────────┘ +``` + +### 2.3 数据流图 + +**【必须】使用数据流图展示关键数据流转:** + +``` +用户请求 ──▶ API Gateway ──▶ 服务A ──▶ 数据库 + │ + ▼ + 缓存层 + │ + ▼ + 服务B ──▶ 外部API +``` + +## 3. 开发阶段 + +### 3.1 阶段时间线 + +**【必须】使用时间线展示开发阶段:** + +``` + Phase 1 Phase 2 Phase 3 + │ │ │ + {起止日期} {起止日期} {起止日期} + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ 基础 │ ────▶ │ 核心 │ ────▶ │ 优化 │ + │ 架构 │ │ 功能 │ │ 扩展 │ + └─────────┘ └─────────┘ └─────────┘ + + 交付物: 交付物: 交付物: + • {交付1} • {交付1} • {交付1} + • {交付2} • {交付2} • {交付2} +``` + +### 3.2 Phase 1: {阶段名称} + +**目标**: {阶段目标} + +**时间**: {起止日期} + +| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 | +|--------|------|------|------|--------|----------| +| T-001 | {任务名} | {描述} | - | P0 | F-001 | +| T-002 | {任务名} | {描述} | T-001 | P0 | F-002 | + +**阶段依赖图:** + +``` +T-001 ──▶ T-002 ──▶ T-003 + │ + └──▶ T-004 +``` + +{重复以上结构覆盖所有阶段} + +## 4. 技术方案 + +### 4.1 {模块名称} + +**功能**: {功能描述} + +**技术选型**: + +| 组件 | 技术 | 选型理由 | +|------|------|----------| +| {组件} | {技术} | {理由} | + +**架构设计**: + +``` +┌─────────────────────────────────────┐ +│ {模块名称} │ +├─────────────────────────────────────┤ +│ ┌─────────┐ ┌─────────┐ │ +│ │ 组件A │ ───▶ │ 组件B │ │ +│ └─────────┘ └─────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ 数据层 │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +**接口设计**: + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| {接口名} | GET/POST | /api/xxx | {说明} | + +**实现要点**: + +- {技术要点1} +- {技术要点2} + +{重复以上结构覆盖所有模块} + +## 5. 风险管理 + +| 风险 | 可能性 | 影响 | 应对措施 | 负责人 | +|------|--------|------|----------|--------| +| {风险} | 高/中/低 | 高/中/低 | {措施} | {负责人} | + +## 6. 里程碑 + +**【必须】使用里程碑图展示关键节点:** + +``` +M1 M2 M3 M4 +│ │ │ │ +▼ ▼ ▼ ▼ +◆───────────────◆───────────────◆───────────────◆ +│ │ │ │ +{日期} {日期} {日期} {日期} +{里程碑名} {里程碑名} {里程碑名} {里程碑名} +``` + +| 里程碑 | 日期 | 目标 | 交付物 | 验收标准 | +|--------|------|------|--------|----------| +| M1 | {日期} | {目标} | {交付物} | {标准} | + +## 7. 资源需求 + +| 角色 | 人数 | 职责 | 参与阶段 | +|------|------|------|----------| +| {角色} | {人数} | {职责} | Phase 1-2 | +``` + +## 4. 保存文档 + +将生成的 DevelopmentPlan 保存到 `doc/DevelopmentPlan.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- DevelopmentPlan 文件路径 +- 开发阶段数量 +- 技术方案模块数量 +- 建议的下一步操作(运行 `/rd` 评审) + +--- + +## 可视化输出要求 + +DevelopmentPlan 作为「执行蓝图」文档,需要清晰传达技术方案和时间安排,**必须包含**: + +| 章节 | 可视化形式 | 必要性 | +|------|------------|--------| +| 2.1 系统架构图 | 架构图(ASCII) | **必须** | +| 2.2 模块依赖图 | 依赖图(ASCII) | **必须** | +| 2.3 数据流图 | 数据流图(ASCII) | **必须** | +| 3.1 阶段时间线 | 时间线(ASCII) | **必须** | +| 3.x 阶段依赖图 | 任务依赖图 | **必须** | +| 4.x 模块架构 | 模块架构图 | 建议 | +| 6. 里程碑 | 里程碑图 | **必须** | + +## 注意事项 + +- DevelopmentPlan 使用**技术语言**,面向开发团队 +- 开发计划必须覆盖 FeatureSummary 所有功能 +- 技术方案要具体可执行,避免过于抽象 +- 阶段划分要合理,考虑依赖关系 +- 时间安排要务实,预留缓冲 +- 风险评估要全面,有应对措施 + +## 质量检查 + +生成 DevelopmentPlan 后,自查以下项目: + +- [ ] 覆盖 FeatureSummary 所有功能 +- [ ] **系统架构图清晰展示整体结构** +- [ ] **模块依赖图清晰展示依赖关系** +- [ ] **数据流图展示关键数据流转** +- [ ] **开发阶段有时间线图** +- [ ] **每个阶段有任务依赖图** +- [ ] **里程碑有里程碑图** +- [ ] 技术方案具体可执行 +- [ ] 任务 ID 唯一(T-xxx) +- [ ] 任务与功能 ID 关联 diff --git a/.claude/skills/wf/SKILL.md b/.claude/skills/wf/SKILL.md new file mode 100644 index 0000000..c8ac3c4 --- /dev/null +++ b/.claude/skills/wf/SKILL.md @@ -0,0 +1,234 @@ +--- +name: wf +description: 从 RequirementsDoc.md 和 PRD.md 生成 FeatureSummary.md,提供功能全貌概览。 +--- + +# Write FeatureSummary + +> **文档定位**:FeatureSummary 是「功能契约」文档,是产品与开发的桥梁。精确定义功能边界、输入输出、依赖关系,确保双方对"做什么"达成共识。 + +当用户调用 `/wf` 时,执行以下步骤: + +## 1. 读取源文档 + +读取以下文件: + +1. `doc/RequirementsDoc.md` - 必须存在 +2. `doc/PRD.md` - 必须存在 + +如果文件不存在,提示用户: +> 缺少上游文档,请先确保 RequirementsDoc.md 和 PRD.md 存在。 + +如果已存在 `doc/FeatureSummary.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析功能需求 + +从 PRD 中提取以下信息: + +### 2.1 功能模块 + +- 从 PRD 3.1 功能架构提取模块结构 +- 从 PRD 3.2 功能详情提取各模块功能点 + +### 2.2 功能分类 + +按以下维度整理功能: + +- 按模块分组 +- 按优先级标注(P0/P1/P2) +- 按用户角色关联 + +### 2.3 功能边界 + +明确每个功能的: + +- 输入:触发条件、输入数据 +- 输出:预期结果、输出数据 +- 边界:不包含什么、异常情况 + +## 3. 生成 FeatureSummary + +按以下结构生成文档: + +```markdown +# {产品名称} - 功能摘要 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 来源文档 | PRD.md | + +## 1. 功能总览 + +### 1.1 功能统计 + +| 类别 | 数量 | +|------|------| +| 功能模块 | X 个 | +| P0 功能 | X 个 | +| P1 功能 | X 个 | +| P2 功能 | X 个 | + +### 1.2 功能架构图 + +**【必须】使用模块图展示功能架构和模块关系:** + +``` +┌─────────────────────────────────────────────────┐ +│ {产品名称} │ +├─────────────────────────────────────────────────┤ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 模块A │ │ 模块B │ │ 模块C │ │ +│ │ ──────── │ │ ──────── │ │ ──────── │ │ +│ │ • 功能1 │ │ • 功能1 │ │ • 功能1 │ │ +│ │ • 功能2 │ │ • 功能2 │ │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +### 1.3 模块依赖关系 + +**【必须】使用依赖图展示模块间关系:** + +``` +┌──────────┐ +│ 模块A │ +└────┬─────┘ + │ 依赖 + ▼ +┌──────────┐ ┌──────────┐ +│ 模块B │ ◀── │ 模块C │ +└──────────┘ └──────────┘ +``` + +## 2. 功能清单 + +### 2.1 {模块名称} + +**模块职责**: {一句话描述模块职责} + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联用户故事 | +|----|------|------|--------|--------------| +| F-001 | {功能名} | {简要描述} | P0 | US-xxx | + +#### 功能契约详情 + +**F-001: {功能名}** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | {什么情况下触发此功能} | +| **输入** | {输入数据/参数} | +| **处理逻辑** | {核心处理步骤} | +| **输出** | {输出结果/返回值} | +| **异常情况** | {可能的错误及处理} | +| **边界说明** | {不包含什么、限制条件} | + +{重复以上结构覆盖所有功能} + +{重复以上结构覆盖所有模块} + +## 3. 功能依赖矩阵 + +**【必须】使用矩阵表格展示功能间依赖:** + +| 功能 | 依赖 F-001 | 依赖 F-002 | 依赖 F-003 | +|------|------------|------------|------------| +| F-001 | - | | | +| F-002 | ✓ | - | | +| F-003 | | ✓ | - | + +说明: +- ✓ 表示行功能依赖列功能 +- 空白表示无依赖 + +## 4. 功能流程图 + +**【必须】使用流程图展示核心功能流程:** + +### 4.1 {核心流程名称} + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ F-001 │ ──▶ │ F-002 │ ──▶ │ F-003 │ ──▶ │ 完成 │ +│ {功能} │ │ {功能} │ │ {功能} │ │ │ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ + ▼ 异常 + ┌─────────┐ + │ 错误处理 │ + └─────────┘ +``` + +## 5. 版本规划 + +| 版本 | 包含功能 | 功能ID | 目标 | +|------|----------|--------|------| +| MVP | {功能列表} | F-001, F-002 | {目标} | +| v1.1 | {功能列表} | F-003, F-004 | {目标} | +| v2.0 | {功能列表} | F-005+ | {目标} | + +## 6. 接口契约预览 + +> 详细接口定义在 DevelopmentPlan 中,此处仅列出关键接口 + +| 功能 | 接口类型 | 简要说明 | +|------|----------|----------| +| F-001 | API | {说明} | +| F-002 | Event | {说明} | +``` + +## 4. 保存文档 + +将生成的 FeatureSummary 保存到 `doc/FeatureSummary.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- FeatureSummary 文件路径 +- 功能模块数量 +- 各优先级功能数量 +- 建议的下一步操作(运行 `/rf` 评审) + +--- + +## 可视化输出要求 + +FeatureSummary 作为「功能契约」文档,需要精确传达功能定义,**必须包含**: + +| 章节 | 可视化形式 | 必要性 | +|------|------------|--------| +| 1.2 功能架构图 | 模块图(ASCII) | **必须** | +| 1.3 模块依赖关系 | 依赖图(ASCII) | **必须** | +| 3. 功能依赖矩阵 | 矩阵表格 | **必须** | +| 4. 功能流程图 | 流程图(ASCII) | **必须** | + +## 注意事项 + +- FeatureSummary 是产品与开发的**桥梁**,语言要精确、无歧义 +- 功能摘要必须完全来源于 PRD,不要臆造功能 +- 每个功能必须有明确的**输入、输出、边界** +- 功能 ID 必须唯一(F-xxx 格式) +- 优先级必须与 PRD 一致 +- 功能依赖关系必须明确,避免循环依赖 + +## 质量检查 + +生成 FeatureSummary 后,自查以下项目: + +- [ ] 所有功能都有唯一 ID(F-xxx) +- [ ] 所有功能都有契约详情(输入/输出/边界) +- [ ] **功能架构图清晰展示模块结构** +- [ ] **模块依赖图清晰展示依赖关系** +- [ ] **功能依赖矩阵完整** +- [ ] **核心流程有流程图** +- [ ] 优先级与 PRD 一致 +- [ ] 无遗漏 PRD 中的功能 diff --git a/.claude/skills/wp/SKILL.md b/.claude/skills/wp/SKILL.md new file mode 100644 index 0000000..e647daf --- /dev/null +++ b/.claude/skills/wp/SKILL.md @@ -0,0 +1,318 @@ +--- +name: wp +description: 从 RequirementsDoc.md 生成 PRD.md,将需求文档转化为结构化的产品需求文档。 +--- + +# Write PRD + +> **文档定位**:PRD 是「价值主张」文档,使用业务语言描述产品要解决什么问题、为谁创造什么价值。面向产品、业务、管理层沟通。 + +当用户调用 `/wp` 时,执行以下步骤: + +## 1. 读取源文档 + +读取 `doc/RequirementsDoc.md` 文件。 + +如果文件不存在,提示用户: +> RequirementsDoc.md 不存在,请先创建需求文档。 + +如果已存在 `doc/PRD.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析需求文档 + +从 RequirementsDoc 中提取以下信息: + +### 2.1 产品定位 + +- 产品名称 +- 目标用户 +- 核心价值主张 +- 竞品对比(如有) + +### 2.2 功能需求 + +- 功能模块划分 +- 各模块详细需求 +- 功能优先级(P0/P1/P2) + +### 2.3 非功能需求 + +- 性能要求 +- 安全要求 +- 兼容性要求 +- 可用性要求 + +### 2.4 约束条件 + +- 技术约束 +- 业务约束 +- 时间约束 + +## 3. 生成 PRD + +按以下结构生成 PRD 文档: + +```markdown +# {产品名称} - 产品需求文档 (PRD) + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 状态 | 草稿 | + +## 1. 产品概述 + +### 1.1 产品背景 + +{从 RequirementsDoc 提取,说明产品解决的问题和市场机会} + +### 1.2 产品定位 + +{目标用户、核心价值、差异化优势} + +### 1.3 产品目标 + +| 目标 | 指标 | 衡量方式 | +|------|------|----------| +| {业务目标} | {量化指标} | {如何衡量} | + +## 2. 用户故事 + +PRD 以用户故事为核心驱动,所有功能需求都应对应到具体的用户故事。 + +### 2.1 用户角色定义 + +| 角色 | 描述 | 核心目标 | 痛点 | +|------|------|----------|------| +| {角色1} | {角色描述} | {核心目标} | {当前痛点} | + +### 2.2 用户故事列表 + +按优先级排列的用户故事: + +#### P0 - 核心故事 + +| ID | 用户故事 | 验收标准 | +|----|----------|----------| +| US-001 | 作为{角色},我想要{功能},以便{价值} | {验收标准} | + +#### P1 - 重要故事 + +| ID | 用户故事 | 验收标准 | +|----|----------|----------| +| US-xxx | 作为{角色},我想要{功能},以便{价值} | {验收标准} | + +#### P2 - 次要故事 + +| ID | 用户故事 | 验收标准 | +|----|----------|----------| +| US-xxx | 作为{角色},我想要{功能},以便{价值} | {验收标准} | + +### 2.3 用户旅程 + +**【必须】使用流程图展示核心用户旅程:** + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 触发点 │ ──▶ │ 关键步骤 │ ──▶ │ 目标达成 │ +│ {描述} │ │ {描述} │ │ {描述} │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + ▼ ▼ ▼ + {用户感受} {用户感受} {用户感受} +``` + +{描述用户完成核心任务的完整流程,从触发点到目标达成} + +## 3. 功能需求 + +> 功能需求与用户故事的对应关系 + +### 3.1 功能架构 + +**【必须】使用树状图或模块图展示功能架构:** + +``` +{产品名称} +├── {模块A} +│ ├── {功能A1} +│ └── {功能A2} +├── {模块B} +│ ├── {功能B1} +│ └── {功能B2} +└── {模块C} + └── {功能C1} +``` + +### 3.2 功能详情 + +#### 3.2.1 {模块名称} + +| 功能点 | 描述 | 关联用户故事 | 优先级 | 验收标准 | +|--------|------|--------------|--------|----------| +| {功能1} | {描述} | US-001 | P0 | {标准} | + +{重复以上结构覆盖所有模块} + +## 4. 非功能需求 + +### 4.1 性能需求 + +| 指标 | 要求 | 说明 | +|------|------|------| +| {响应时间} | {要求} | {场景说明} | + +### 4.2 安全需求 + +{数据安全、访问控制、合规要求} + +### 4.3 兼容性需求 + +| 平台/环境 | 支持版本 | +|-----------|----------| +| {平台} | {版本} | + +### 4.4 可用性需求 + +{SLA、故障恢复、监控告警} + +## 5. 数据需求 + +### 5.1 数据模型 + +**【建议】使用 ER 图或表格展示核心实体关系:** + +``` +┌──────────┐ ┌──────────┐ +│ 实体A │ 1───n │ 实体B │ +├──────────┤ ├──────────┤ +│ 字段1 │ │ 字段1 │ +│ 字段2 │ │ 字段2 │ +└──────────┘ └──────────┘ +``` + +### 5.2 数据规范 + +| 字段 | 类型 | 说明 | 校验规则 | +|------|------|------|----------| +| {字段名} | {类型} | {说明} | {规则} | + +## 6. 接口需求 + +### 6.1 外部接口 + +| 接口 | 用途 | 提供方 | +|------|------|--------| +| {接口名} | {用途} | {第三方} | + +### 6.2 内部接口 + +{模块间接口规范} + +## 7. 约束与依赖 + +### 7.1 技术约束 + +| 约束 | 说明 | 影响 | +|------|------|------| +| {约束} | {说明} | {影响范围} | + +### 7.2 业务约束 + +{法规、政策、合同限制} + +### 7.3 外部依赖 + +{第三方服务、团队依赖} + +## 8. 里程碑规划 + +**【建议】使用时间线展示里程碑:** + +``` +Phase 1 Phase 2 Phase 3 + │ │ │ + ▼ ▼ ▼ +┌──────┐ ┌──────┐ ┌──────┐ +│ MVP │ ────▶ │ v1.1 │ ────▶ │ v2.0 │ +└──────┘ └──────┘ └──────┘ +{日期} {日期} {日期} +``` + +| 阶段 | 目标 | 交付物 | +|------|------|--------| +| {阶段1} | {目标} | {交付物} | + +## 9. 风险评估 + +| 风险 | 可能性 | 影响 | 应对措施 | +|------|--------|------|----------| +| {风险1} | 高/中/低 | 高/中/低 | {措施} | + +## 附录 + +### A. 术语表 + +| 术语 | 定义 | +|------|------| +| {术语} | {定义} | + +### B. 参考文档 + +- RequirementsDoc.md +``` + +## 4. 保存文档 + +将生成的 PRD 保存到 `doc/PRD.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- PRD 文件路径 +- 包含的功能模块数量 +- 主要章节概览 +- 建议的下一步操作(运行 `/rp` 评审) + +--- + +## 可视化输出要求 + +PRD 作为「价值主张」文档,需要便于业务沟通理解,**必须包含**: + +| 章节 | 可视化形式 | 必要性 | +|------|------------|--------| +| 2.3 用户旅程 | 流程图(ASCII) | **必须** | +| 3.1 功能架构 | 树状图/模块图 | **必须** | +| 5.1 数据模型 | ER 图 | 建议 | +| 8. 里程碑规划 | 时间线 | 建议 | + +## 注意事项 + +- PRD 使用**业务语言**,避免过多技术术语 +- PRD 内容必须完全来源于 RequirementsDoc,不要臆造需求 +- 如果 RequirementsDoc 信息不完整,在对应章节标注"待补充" +- 保持语言风格与现有文档一致 +- 优先级标注遵循 P0 > P1 > P2 规则 +- 验收标准要具体可测试 + +## 质量检查 + +生成 PRD 后,自查以下项目: + +- [ ] 所有用户故事都有唯一 ID(US-xxx) +- [ ] 所有用户故事都符合格式:作为{角色},我想要{功能},以便{价值} +- [ ] 所有功能点都关联到用户故事 +- [ ] 所有功能点都有明确的优先级 +- [ ] 所有功能点都有验收标准 +- [ ] **用户旅程有流程图** +- [ ] **功能架构有模块图** +- [ ] 非功能需求有量化指标 +- [ ] 无遗漏 RequirementsDoc 中的重要需求 +- [ ] 文档结构完整,无空章节(或标注"待补充") diff --git a/.claude/skills/writeTest/SKILL.md b/.claude/skills/writeTest/SKILL.md new file mode 100644 index 0000000..d1829cc --- /dev/null +++ b/.claude/skills/writeTest/SKILL.md @@ -0,0 +1,135 @@ +--- +name: writeTest +description: 从上游文档生成 tdd.md,创建覆盖所有功能的测试用例文档。 +--- + +# Write Test Cases + +> **文档定位**:tdd.md 是测试驱动开发的核心文档,基于 PRD 和功能文档生成结构化测试用例,确保所有功能点有对应的测试覆盖。 + +当用户调用 `/writeTest` 时,执行以下步骤: + +## 1. 读取源文档 + +读取以下文件: + +1. `doc/PRD.md` - 必须存在(功能需求和验收标准的主要来源) +2. `doc/FeatureSummary.md` - 必须存在(功能模块全貌) +3. `doc/DevelopmentPlan.md` - 必须存在(技术方案参考) +4. `doc/tasks.md` - 可选(任务粒度参考) +5. `doc/UIDesign.md` - 可选(界面交互测试参考) + +如果必要文件不存在,提示用户: +> 缺少必要文档,请先确保 PRD.md、DevelopmentPlan.md 和 FeatureSummary.md 存在。 + +如果已存在 `doc/tdd.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析测试需求 + +从上游文档中提取以下信息: + +### 2.1 功能测试点 + +- 从 PRD 的用户故事和验收标准提取功能测试点 +- 从 FeatureSummary 的功能模块提取模块级测试点 + +### 2.2 边界与异常场景 + +- 分析各功能的边界条件 +- 识别异常输入和错误处理场景 + +### 2.3 非功能测试点 + +- 从 PRD 非功能需求提取性能、安全、兼容性测试点 + +## 3. 生成 tdd.md + +按以下结构生成文档: + +```markdown +# {产品名称} - 测试用例文档 (TDD) + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 来源文档 | PRD.md, FeatureSummary.md | + +## 1. 测试总览 + +| 统计项 | 数量 | +|--------|------| +| 总用例数 | X | +| 功能测试 | X | +| 边界测试 | X | +| 异常测试 | X | +| 非功能测试 | X | + +## 2. {模块名称} 测试 + +### 2.1 {功能名称} + +| ID | 测试用例 | 前置条件 | 测试步骤 | 预期结果 | 优先级 | 关联用户故事 | +|----|----------|----------|----------|----------|--------|--------------| +| TC-001 | {用例名} | {前置条件} | {步骤} | {预期结果} | P0 | US-001 | + +{重复以上结构覆盖所有模块和功能} + +## N. 非功能测试 + +### N.1 性能测试 + +| ID | 测试用例 | 测试条件 | 预期指标 | 优先级 | +|----|----------|----------|----------|--------| +| TC-xxx | {用例名} | {条件} | {指标} | P0/P1 | + +### N.2 安全测试 + +| ID | 测试用例 | 测试方法 | 预期结果 | 优先级 | +|----|----------|----------|----------|--------| +| TC-xxx | {用例名} | {方法} | {结果} | P0/P1 | + +## 附录:测试覆盖矩阵 + +| 用户故事 | 功能测试 | 边界测试 | 异常测试 | 覆盖状态 | +|----------|----------|----------|----------|----------| +| US-001 | TC-001 | TC-xxx | TC-xxx | ✅ | +``` + +## 4. 保存文档 + +将生成的测试用例文档保存到 `doc/tdd.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- tdd.md 文件路径 +- 测试用例总数 +- 各模块用例分布 +- 测试覆盖率概览 +- 建议的下一步操作(运行‘wt’生成开发任务文档) + +--- + +## 注意事项 + +- 测试用例必须覆盖 PRD 中所有用户故事的验收标准 +- 用例 ID 必须唯一(TC-001, TC-002...) +- 每个用例必须有明确的预期结果 +- 优先级与对应功能优先级一致(P0 功能 → P0 测试) +- 关注边界条件和异常路径,不仅仅是正向流程 +- 测试步骤要具体可执行,无歧义 + +## 与其他 Skill 的关系 + +| 场景 | 使用方式 | +|------|----------| +| 准备上游文档 | `/wp` `/wf` `/wd` `/wu` `/wt` | +| 生成测试用例(本 Skill) | `/writeTest` | +| 执行开发(含测试) | `/go` | +| 迭代后更新测试 | `/iter` 后重新运行 `/writeTest` | diff --git a/.claude/skills/wt/SKILL.md b/.claude/skills/wt/SKILL.md new file mode 100644 index 0000000..4db42cb --- /dev/null +++ b/.claude/skills/wt/SKILL.md @@ -0,0 +1,129 @@ +--- +name: wt +description: 从上游文档生成 tasks.md,创建可直接执行的任务列表。 +--- + +# Write Tasks + +当用户调用 `/wt` 时,执行以下步骤: + +## 1. 读取源文档 + +读取以下文件: + +1. `doc/RequirementsDoc.md` - 建议存在 +2. `doc/PRD.md` - 必须存在 +3. `doc/FeatureSummary.md` - 必须存在 +4. `doc/DevelopmentPlan.md` - 必须存在 +5. `doc/UIDesign.md` - 必须存在 +6. `doc/tdd.md` - 建议存在 + +如果文件不存在,提示用户: +> 缺少上游文档,请确保所有上游文档存在。 + +如果已存在 `doc/tasks.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析任务需求 + +从上游文档中提取以下信息: + +### 2.1 开发任务 + +- 从 DevelopmentPlan 获取开发阶段和任务 +- 从 UIDesign 获取页面实现任务 + +### 2.2 任务依赖 + +- 分析任务间的依赖关系 +- 确定任务执行顺序 + +### 2.3 验收标准 + +- 从 PRD 获取功能验收标准 +- 转化为任务级别的完成标准 + +## 3. 生成 Tasks + +按以下结构生成文档: + +```markdown +# {产品名称} - 任务列表 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 来源文档 | UIDesign.md, DevelopmentPlan.md | + +## 1. 任务总览 + +| 统计项 | 数量 | +|--------|------| +| 总任务数 | X | +| P0 任务 | X | +| P1 任务 | X | +| P2 任务 | X | + +## 2. Phase 1 任务 + +### 2.1 {模块/功能名} + +| ID | 任务 | 描述 | 优先级 | 依赖 | 验收标准 | +|----|------|------|--------|------|----------| +| T-001 | {任务名} | {描述} | P0 | - | {标准} | +| T-002 | {任务名} | {描述} | P0 | T-001 | {标准} | + +{重复以上结构覆盖所有模块} + +## 3. Phase 2 任务 + +{同上结构} + +## 4. Phase N 任务 + +{同上结构} + +## 5. 任务依赖图 + +``` +T-001 (基础设施) + ├── T-002 (功能A) + │ └── T-005 (功能A优化) + └── T-003 (功能B) + └── T-004 (功能B扩展) +``` + +## 6. 执行检查清单 + +- [ ] T-001: {任务名} +- [ ] T-002: {任务名} +{所有任务的检查清单} +``` + +## 4. 保存文档 + +将生成的 tasks 保存到 `doc/tasks.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- tasks 文件路径 +- 任务总数 +- 各阶段任务分布 +- 建议的下一步操作(运行 `/rt` 评审) + +--- + +## 注意事项 + +- 任务必须覆盖 DevelopmentPlan 和 UIDesign 所有内容 +- 任务 ID 必须唯一(T-001, T-002...) +- 每个任务必须有明确的验收标准 +- 任务粒度要适中,可在合理时间内完成 +- 依赖关系要明确,避免循环依赖 +- 任务应可直接执行,无歧义 diff --git a/.claude/skills/wu/SKILL.md b/.claude/skills/wu/SKILL.md new file mode 100644 index 0000000..3b17b58 --- /dev/null +++ b/.claude/skills/wu/SKILL.md @@ -0,0 +1,352 @@ +--- +name: wu +description: 从上游文档生成 UIDesign.md,覆盖所有用户界面设计。 +--- + +# Write UIDesign + +> **文档定位**:UIDesign 是「界面蓝图」文档,用 ASCII 原型图精确传达页面布局、组件结构、交互流程,是前端开发的直接参考。 + +当用户调用 `/wu` 时,执行以下步骤: + +## 1. 读取源文档 + +读取以下文件: + +1. `doc/RequirementsDoc.md` - 必须存在 +2. `doc/PRD.md` - 必须存在 +3. `doc/FeatureSummary.md` - 必须存在 +4. `doc/DevelopmentPlan.md` - 必须存在 + +如果文件不存在,提示用户: +> 缺少上游文档,请确保所有上游文档存在。 + +如果已存在 `doc/UIDesign.md`,同时读取作为参考(保持风格一致)。 + +## 2. 分析 UI 需求 + +从上游文档中提取以下信息: + +### 2.1 页面需求 + +- 从 PRD 用户旅程分析所需页面 +- 从 FeatureSummary 获取功能对应的界面 +- 从 DevelopmentPlan 获取技术实现约束 + +### 2.2 用户流程 + +- 主要用户旅程 +- 页面跳转关系 +- 交互流程 + +## 3. 生成 UIDesign + +按以下结构生成文档: + +```markdown +# {产品名称} - UI 设计文档 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | {YYYY-MM-DD} | +| 来源文档 | DevelopmentPlan.md | + +## 1. 设计概述 + +### 1.1 设计原则 + +{UI 设计原则和规范} + +### 1.2 页面总览 + +| 页面ID | 页面名称 | 描述 | 对应功能 | 优先级 | +|--------|----------|------|----------|--------| +| P-001 | {页面名} | {描述} | F-001 | P0 | + +### 1.3 页面导航图 + +**【必须】使用导航图展示页面跳转关系:** + +``` + ┌─────────────┐ + │ 首页 │ + │ P-001 │ + └──────┬──────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ 功能A页 │ │ 功能B页 │ │ 设置页 │ + │ P-002 │ │ P-003 │ │ P-004 │ + └──────┬──────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ 详情页 │ + │ P-005 │ + └─────────────┘ +``` + +## 2. 页面设计 + +### 2.1 P-001: {页面名称} + +**页面信息** + +| 属性 | 值 | +|------|-----| +| 页面ID | P-001 | +| 对应功能 | F-001, F-002 | +| 入口 | {从哪些页面可进入} | +| 出口 | {可跳转到哪些页面} | + +**【必须】页面布局 - ASCII 原型图** + +``` +┌────────────────────────────────────────────────────────┐ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Header │ │ +│ │ [Logo] [Nav Item] [Nav Item] [用户]│ │ +│ └─────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ │ │ │ │ +│ │ Sidebar │ │ Main Content │ │ +│ │ │ │ │ │ +│ │ • Menu Item 1 │ │ ┌─────────────────────┐ │ │ +│ │ • Menu Item 2 │ │ │ Card 1 │ │ │ +│ │ • Menu Item 3 │ │ │ [Title] │ │ │ +│ │ │ │ │ [Description...] │ │ │ +│ │ │ │ │ [Action Button] │ │ │ +│ │ │ │ └─────────────────────┘ │ │ +│ │ │ │ │ │ +│ │ │ │ ┌─────────────────────┐ │ │ +│ │ │ │ │ Card 2 │ │ │ +│ │ │ │ └─────────────────────┘ │ │ +│ │ │ │ │ │ +│ └──────────────────┘ └───────────────────────────┘ │ +│ │ +├────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Footer │ │ +│ └─────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────┘ +``` + +**组件清单** + +| 组件ID | 组件名称 | 类型 | 说明 | 交互 | +|--------|----------|------|------|------| +| C-001 | Header | 导航栏 | 顶部固定 | 点击 Logo 回首页 | +| C-002 | Sidebar | 侧边栏 | 左侧固定 | 点击菜单切换内容 | +| C-003 | Card | 卡片 | 内容展示 | 点击进入详情 | + +**交互说明** + +| 触发 | 动作 | 结果 | +|------|------|------| +| 点击 Card | 跳转 | 进入详情页 P-005 | +| 点击 Menu Item | 切换 | 更新 Main Content | + +**页面状态** + +| 状态 | 说明 | 展示 | +|------|------|------| +| 默认 | 正常加载完成 | 显示数据列表 | +| 加载中 | 数据请求中 | 骨架屏/Loading | +| 空状态 | 无数据 | 空状态插图+引导文案 | +| 错误 | 请求失败 | 错误提示+重试按钮 | + +**空状态原型** + +``` +┌─────────────────────────────────────┐ +│ │ +│ ┌─────────────┐ │ +│ │ (空图标) │ │ +│ └─────────────┘ │ +│ │ +│ 暂无数据 │ +│ │ +│ [去添加数据] │ +│ │ +└─────────────────────────────────────┘ +``` + +{重复以上结构覆盖所有页面} + +## 3. 用户流程 + +### 3.1 {流程名称} + +**【必须】使用流程图展示用户操作流程:** + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Step 1 │ ──▶ │ Step 2 │ ──▶ │ Step 3 │ ──▶ │ 完成 │ +│ {操作} │ │ {操作} │ │ {操作} │ │ │ +│ P-001 │ │ P-002 │ │ P-003 │ │ P-004 │ +└─────────┘ └────┬────┘ └─────────┘ └─────────┘ + │ + ▼ 取消 + ┌─────────┐ + │ 返回 │ + │ P-001 │ + └─────────┘ +``` + +**流程步骤** + +| 步骤 | 页面 | 用户操作 | 系统响应 | +|------|------|----------|----------| +| 1 | P-001 | {操作} | {响应} | +| 2 | P-002 | {操作} | {响应} | + +## 4. 组件规范 + +### 4.1 基础组件 + +**Button 按钮** + +``` +主按钮: ┌──────────────┐ + │ 确认提交 │ (填充色背景) + └──────────────┘ + +次按钮: ┌──────────────┐ + │ 取消 │ (边框样式) + └──────────────┘ + +禁用态: ┌──────────────┐ + │ 不可点击 │ (灰色) + └──────────────┘ +``` + +**Input 输入框** + +``` +默认态: ┌────────────────────────┐ + │ 请输入... │ + └────────────────────────┘ + +聚焦态: ┌────────────────────────┐ + │ 输入内容 │ (高亮边框) + └────────────────────────┘ + +错误态: ┌────────────────────────┐ + │ 错误输入 │ (红色边框) + └────────────────────────┘ + ⚠ 错误提示信息 +``` + +### 4.2 业务组件 + +{项目特有的业务组件} + +## 5. 设计规范 + +### 5.1 色彩规范 + +| 用途 | 色值 | 示例 | +|------|------|------| +| 主色 | #1890FF | 主按钮、链接 | +| 成功 | #52C41A | 成功提示 | +| 警告 | #FAAD14 | 警告提示 | +| 错误 | #FF4D4F | 错误提示 | +| 文字主色 | #262626 | 标题 | +| 文字次色 | #8C8C8C | 描述 | + +### 5.2 字体规范 + +| 用途 | 字号 | 字重 | +|------|------|------| +| 大标题 | 24px | Bold | +| 标题 | 18px | Medium | +| 正文 | 14px | Regular | +| 辅助文字 | 12px | Regular | + +### 5.3 间距规范 + +| 间距 | 值 | 用途 | +|------|-----|------| +| xs | 4px | 紧凑间距 | +| sm | 8px | 小间距 | +| md | 16px | 标准间距 | +| lg | 24px | 大间距 | +| xl | 32px | 特大间距 | + +### 5.4 响应式断点 + +| 断点 | 宽度 | 布局说明 | +|------|------|----------| +| Mobile | < 768px | 单栏布局 | +| Tablet | 768px - 1024px | 双栏布局 | +| Desktop | > 1024px | 多栏布局 | +``` + +## 4. 保存文档 + +将生成的 UIDesign 保存到 `doc/UIDesign.md`。 + +如果文件已存在,覆盖原文件(历史版本通过 git 追溯)。 + +## 5. 输出摘要 + +向用户展示生成摘要: + +- UIDesign 文件路径 +- 页面数量 +- 用户流程数量 +- 建议的下一步操作(运行 `/ru` 评审) + +--- + +## 可视化输出要求 + +UIDesign 作为「界面蓝图」文档,**必须大量使用 ASCII 原型图**: + +| 章节 | 可视化形式 | 必要性 | +|------|------------|--------| +| 1.3 页面导航图 | 导航关系图(ASCII) | **必须** | +| 2.x 页面布局 | **ASCII 原型图** | **必须(每页)** | +| 2.x 空状态 | ASCII 原型图 | 建议 | +| 3.x 用户流程 | 流程图(ASCII) | **必须** | +| 4.x 组件规范 | 组件示意图 | **必须** | + +**ASCII 原型图要求**: + +- 每个页面**必须**有完整的布局原型图 +- 原型图要体现:页面结构、组件位置、内容区域 +- 使用 `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼` 等字符绘制边框 +- 使用 `[ ]` 表示按钮 +- 使用 `▼ ▶ ◀ ▲` 表示方向/展开 +- 关键交互点要标注 + +## 注意事项 + +- UI 设计必须覆盖 DevelopmentPlan 所有功能模块 +- **每个页面必须有 ASCII 原型图** +- 页面设计要考虑各种状态(默认、加载、空、错误) +- 交互说明要清晰具体 +- 设计规范要统一一致 +- 页面 ID 格式:P-xxx +- 组件 ID 格式:C-xxx + +## 质量检查 + +生成 UIDesign 后,自查以下项目: + +- [ ] 覆盖 DevelopmentPlan 所有功能模块 +- [ ] **页面导航图清晰展示页面关系** +- [ ] **每个页面都有 ASCII 原型图** +- [ ] **原型图展示了完整的页面结构** +- [ ] **用户流程有流程图** +- [ ] 每个页面都有状态说明 +- [ ] 组件清单完整 +- [ ] 交互说明清晰 +- [ ] 设计规范统一 diff --git a/.gitignore b/.gitignore index 5ef6a52..4ddbf3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies -/node_modules +node_modules /.pnp .pnp.* .yarn/* @@ -11,14 +9,15 @@ !.yarn/versions # testing -/coverage +coverage # next.js -/.next/ -/out/ +.next/ +out/ # production -/build +build +dist # misc .DS_Store @@ -30,8 +29,10 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# env files +.env +.env.* +!.env.example # vercel .vercel @@ -39,3 +40,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +test-results/ +playwright-report/ +blob-report/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..36dc7fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。 + +## 常用命令 + +```bash +# 开发 — 同时启动前端 (3000) 和后端 (3001) +pnpm dev + +# 构建所有包 +pnpm build + +# 单元测试(所有包,Vitest) +pnpm test # 运行一次 +pnpm test:watch # 监听模式 +pnpm test:coverage # 含覆盖率(阈值 80%) + +# 运行单个包的测试 +pnpm --filter @muse/backend test +pnpm --filter @muse/frontend test +pnpm --filter @muse/shared test + +# 运行单个测试文件 +npx vitest run packages/backend/src/lib/tikhub.test.ts + +# 代码检查(仅前端) +pnpm lint + +# E2E 测试(Playwright,mock 后端) +pnpm test:e2e # 无头模式 +pnpm test:e2e:ui # 交互式 UI + +# E2E 测试(真实后端 + TikHub API,仅在明确要求时运行) +pnpm test:e2e:real +``` + +## 架构 + +pnpm monorepo,包含三个包: + +``` +packages/ + frontend/ @muse/frontend Next.js 16 (App Router, React 19) → localhost:3000 + backend/ @muse/backend Hono 4 on Node → localhost:3001 + shared/ @muse/shared 类型定义与平台配置 +``` + +**请求链路**:浏览器 → Next.js 前端 → Hono 后端 → TikHub API (api.tikhub.io) + +前端通过 CORS 直接请求后端(不经过 Next.js API Routes 代理)。API 基础地址来自环境变量 `NEXT_PUBLIC_API_URL`。 + +### 后端 + +- **入口**:`src/index.ts` → `src/app.ts`(Hono + CORS 中间件) +- **路由**:`/api/tikhub/:platform`(热门内容)、`/api/tikhub/:platform/detail`(内容详情)、`/api/settings`(API Key 管理) +- **适配器**:`src/lib/adapters/{douyin,tiktok,xiaohongshu,youtube,instagram,twitter,bilibili,weibo}.ts` — 各自实现 `PlatformAdapter` 接口,将平台特定响应标准化为 `ContentItem`(共 8 个平台) +- **限流**:`src/lib/rate-limiter.ts` 滑动窗口限流,10 请求/秒 +- **API Key**:运行时 Key(通过 POST /api/settings 设置)优先于 `process.env.TIKHUB_API_KEY`;后端不会自动加载 `.env`(未使用 dotenv) + +### 前端 + +- **状态管理**:Zustand store + localStorage 持久化 — `settings`(API Key、刷新间隔)和 `favorites`(收藏列表) +- **数据获取**:TanStack React Query — `useContentQuery`(热门列表)和 `useDetailQuery`(单条详情,带缓存查找) +- **页面**:`/`(首页,含平台标签页 + 排序)、`/detail/[platform]/[id]`、`/favorites`、`/settings` +- **样式**:Tailwind CSS v4、shadcn/ui 组件、Lucide 图标、Sonner toast 提示 + +### Shared(共享包) + +导出 `Platform` 类型、`ContentItem` 接口、`PlatformAdapter` 接口、`MVP_PLATFORMS` 配置数组和 `getPlatformConfig()` 函数。 + +## 关键模式 + +- 平台适配器是集成边界 — 新增平台只需在 `packages/backend/src/lib/adapters/` 下创建适配器并在 `index.ts` 中注册 +- 前端组件使用 `data-testid` 属性作为 E2E 选择器(如 `content-grid`、`content-card`、`favorite-btn`、`platform-tab-{id}`、`sort-select`、`sort-order`) +- `e2e/` 中的 E2E 测试通过 `page.route()` mock 所有 `/api/tikhub/` 请求 — 仅测试前端逻辑 +- `e2e-real/` 中的 E2E 测试走真实后端和 TikHub API — 必须串行运行(`workers: 1`)以避免共享状态污染 +- 覆盖率配置在各包的 `vitest.config.ts` 中;前端仅覆盖 `src/lib/**` 和 `src/stores/**` + +## 环境变量 + +后端 `.env`: +``` +TIKHUB_API_KEY=... +CORS_ORIGIN=http://localhost:3000 +PORT=3001 +``` + +前端 `.env.local`: +``` +NEXT_PUBLIC_API_URL=http://localhost:3001 +``` diff --git a/doc/DevelopmentPlan.md b/doc/DevelopmentPlan.md new file mode 100644 index 0000000..fe3f6ec --- /dev/null +++ b/doc/DevelopmentPlan.md @@ -0,0 +1,669 @@ +# Muse Creative Hotspots — 开发计划 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | 2026-03-02 | +| 来源文档 | FeatureSummary.md, PRD.md | + +## 1. 项目概述 + +### 1.1 项目目标 + +构建面向个人创意工作者的全平台热点内容聚合浏览器,MVP 阶段实现抖音 + TikTok + 小红书三个平台的热点内容聚合浏览,包含卡片信息流、筛选排序、内容详情、收藏系统、数据刷新和设置管理。 + +### 1.2 技术栈 + +| 层级 | 技术选型 | 版本 | 说明 | +|------|----------|------|------| +| 框架 | Next.js (App Router) | 14+ | 全栈能力,API Routes 做后端代理,Vercel 部署 | +| UI 库 | Tailwind CSS + shadcn/ui | Tailwind 3.x | 简约现代风格,组件丰富 | +| 状态管理 | Zustand | 4.x | 轻量状态管理,persist 中间件支持持久化 | +| 数据请求 | TanStack Query | 5.x | 缓存、自动刷新、loading/error 状态管理 | +| 本地存储 | localStorage | - | MVP 阶段收藏/设置持久化 | +| 语言 | TypeScript | 5.x | 类型安全 | +| 包管理器 | pnpm | 8+ | 快速、节省磁盘 | +| 部署 | localhost → Vercel | - | 先本地开发,后期线上部署 | + +### 1.3 开发原则 + +- **渐进式开发**:先跑通数据链路,再完善 UI 和交互 +- **适配器模式**:平台差异封装在适配器内,新增平台零侵入 +- **安全优先**:API Key 仅存在于服务端,前端不暴露 +- **类型驱动**:先定义 TypeScript 类型,再实现逻辑 +- **组件化**:UI 组件遵循单一职责,可独立测试 + +--- + +## 2. 技术架构 + +### 2.1 系统架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端(浏览器) │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Next.js App (React) │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ │ │ +│ │ │ 首页 │ │ 详情页 │ │ 收藏页 │ │ 设置页 │ │ │ + +│ │ │ page.tsx│ │[plt]/[id]│ │favorites│ │ settings │ │ │ +│ │ └────┬────┘ └────┬─────┘ └────┬────┘ └─────┬─────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ┌────▼────────────▼─────────────▼──────────────▼─────┐ │ │ +│ │ │ TanStack Query (缓存 + 自动刷新) │ │ │ +│ │ └────────────────────────┬───────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────────────▼───────────────────────────┐ │ │ +│ │ │ Zustand Stores (settings / favorites) │ │ │ +│ │ │ ↕ localStorage │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────┬───────────────────────────────┘ │ +└──────────────────────────────┼──────────────────────────────────┘ + │ fetch /api/tikhub/[platform] + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Next.js API Routes (服务端) │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ /api/tikhub/[platform]/route.ts │ │ +│ │ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ +│ │ │ 请求验证 │→│ 频率限制 │→│ 平台适配器分发 │ │ │ +│ │ │ API Key │ │ 10 req/s │ │ douyin/tiktok/xhs │ │ │ +│ │ └──────────┘ └──────────────┘ └─────────┬─────────┘ │ │ +│ └────────────────────────────────────────────┼─────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 平台适配器层 │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 抖音 │ │ TikTok │ │ 小红书 │ ... (扩展) │ │ +│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │ +│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ +│ │ │ │ │ │ │ +│ │ └─────────────┼─────────────┘ │ │ +│ │ ▼ │ │ +│ │ ContentItem[] 统一数据模型 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────┼───────────────────────────────────┘ + │ Bearer Token + ▼ + ┌─────────────────────┐ + │ TikHub API │ + │ api.tikhub.io │ + │ 10 req/s 限制 │ + │ $0.001/请求 │ + └─────────────────────┘ +``` + +### 2.2 模块依赖图 + +``` +┌───────────────────────────────────────────────────────┐ +│ 页面层 (Pages) │ +│ ┌──────────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ │ +│ │ 首页 │ │ 详情页 │ │收藏页│ │ 设置页 │ │ +│ └────┬─────┘ └────┬─────┘ └──┬───┘ └────┬─────┘ │ +└───────┼─────────────┼───────────┼───────────┼─────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌───────────────────────────────────────────────────────┐ +│ 组件层 (Components) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ CardGrid │ │ DetailPnl│ │ Toolbar │ ... │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +└───────┼─────────────┼──────────────┼──────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────────────────────────────────────────────┐ +│ 数据层 (Hooks + Stores) │ +│ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ TanStack Query │ │ Zustand Stores │ │ +│ │ useContentQuery │ │ useFavoritesStore │ │ +│ │ useDetailQuery │ │ useSettingsStore │ │ +│ └────────┬─────────┘ └────────────┬─────────────┘ │ +└───────────┼─────────────────────────┼─────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────┐ ┌────────────────────────────┐ +│ API 代理层 │ │ 本地存储 │ +│ /api/tikhub/[plat] │ │ localStorage │ +│ │ │ └────────────────────────────┘ +│ ▼ │ +│ 平台适配器层 │ +│ adapters/*.ts │ +│ │ │ +│ ▼ │ +│ ContentItem 类型 │ +└───────────────────────┘ +``` + +### 2.3 数据流图 + +``` +用户操作 前端 API Route TikHub + │ │ │ │ + │ 1.打开首页/切换Tab │ │ │ + ├─────────────────────▶│ │ │ + │ │ 2.useContentQuery() │ │ + │ ├───────────────────────▶│ │ + │ │ │ 3.读取 API Key │ + │ │ │ 4.选择适配器 │ + │ │ ├────────────────────▶│ + │ │ │ 5.TikHub原始响应 │ + │ │ │◀────────────────────┤ + │ │ │ 6.适配器转换 │ + │ │ │ → ContentItem[] │ + │ │ 7.返回标准化数据 │ │ + │ │◀───────────────────────┤ │ + │ │ 8.TanStack Query缓存 │ │ + │ │ 9.前端排序/筛选 │ │ + │ 10.渲染卡片网格 │ │ │ + │◀─────────────────────┤ │ │ + │ │ │ │ + │ 11.点击收藏 │ │ │ + ├─────────────────────▶│ │ │ + │ │ 12.Zustand更新 │ │ + │ │ 13.localStorage持久化 │ │ + │ 14.收藏状态反馈 │ │ │ + │◀─────────────────────┤ │ │ +``` + +--- + +## 3. 开发阶段 + +### 3.1 阶段时间线 + +``` + Phase 1 Phase 2 Phase 3 + 基础架构搭建 核心功能实现 辅助功能 & 联调 + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ 项目初始化│ │ 内容展示 │ │ 收藏系统 │ + │ 类型定义 │ ──────▶ │ 筛选排序 │ ──────▶ │ 设置页面 │ + │ API代理层 │ │ 详情页 │ │ 性能优化 │ + │ 适配器 │ │ 刷新机制 │ │ 联调验收 │ + └──────────┘ └──────────┘ └──────────┘ + + 交付物: 交付物: 交付物: + • 项目骨架 • 首页卡片信息流 • 收藏功能 + • 类型系统 • 平台Tab切换 • 收藏夹页面 + • 3平台适配器 • 排序功能 • 设置页面 + • API代理可用 • 详情页 • 图片懒加载 + • 全局布局 • 自动/手动刷新 • 全链路验收 +``` + +### 3.2 Phase 1: 基础架构搭建 + +**目标**: 搭建项目骨架,打通 API 代理 → 适配器 → 统一数据模型的完整链路,确保能从 TikHub 获取到标准化的 ContentItem 数据。 + +| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 | +|--------|------|------|------|--------|----------| + +| T-001 | 项目初始化 | Next.js 14+ App Router + Tailwind + shadcn/ui + pnpm;配置 next.config.ts images.remotePatterns(各平台图片 CDN 域名白名单) | - | P0 | - | +| T-002 | TypeScript 类型定义 | ContentItem、Platform、PlatformAdapter 接口定义 | T-001 | P0 | F-015 | +| T-003 | API 代理层实现 | `/api/tikhub/[platform]/route.ts`,Bearer Token 认证,频率限制 | T-001 | P0 | F-014 | +| T-004 | TikHub API 客户端 | 封装 HTTP 请求,错误处理,10 req/s 限流 | T-003 | P0 | F-014 | +| T-005 | 平台适配器 — 抖音 | 热搜榜 + 内容详情 API,字段映射为 ContentItem | T-002, T-004 | P0 | F-016 | +| T-006 | 平台适配器 — TikTok | 趋势内容 + 内容详情 API,字段映射为 ContentItem | T-002, T-004 | P0 | F-016 | +| T-007 | 平台适配器 — 小红书 | 推荐内容 + 笔记详情 API,字段映射为 ContentItem | T-002, T-004 | P0 | F-016 | +| T-008 | Zustand Store 基础 | settingsStore(API Key、刷新间隔)+ favoritesStore 骨架 | T-001 | P0 | F-009, F-010 | +| T-009 | 全局布局组件 | Header(Logo + 平台 Tab + 设置入口)+ 主内容区域 | T-001 | P0 | - | + +**阶段依赖图:** + +``` +T-001 (项目初始化) + ├──▶ T-002 (类型定义) ──┐ + ├──▶ T-003 (API代理层) │ + │ └──▶ T-004 (API客户端) ──┐ + ├──▶ T-008 (Zustand) │ │ + └──▶ T-009 (全局布局) │ │ + ▼ ▼ + T-005 (抖音适配器) + T-006 (TikTok适配器) + T-007 (小红书适配器) +``` + +**Phase 1 验收**: `GET /api/tikhub/douyin` 返回标准化 ContentItem[] JSON。 + +--- + +### 3.3 Phase 2: 核心功能实现 + +**目标**: 实现首页卡片信息流、平台 Tab 切换、排序、详情页、自动/手动刷新,完成核心浏览体验。 + +| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 | +|--------|------|------|------|--------|----------| +| T-010 | TanStack Query 集成 | 配置 QueryClient,封装 `useContentQuery(platform)` hook | T-005~T-007 | P0 | F-001 | +| T-011 | 内容卡片组件 | ContentCard 组件:封面图、标题、平台图标、数据指标、作者信息 | T-002, T-009 | P0 | F-002 | +| T-012 | 卡片网格布局 | 响应式网格布局(CSS Grid),支持不同屏幕尺寸 | T-011 | P0 | F-002 | +| T-013 | 平台 Tab 切换 | 顶部 Tab 栏,切换平台触发数据重新获取,支持"全部"聚合视图 | T-010, T-012 | P0 | F-003 | +| T-014 | 排序功能 | 工具栏排序控件,支持 play_count/like_count/comment_count/publish_time + asc/desc | T-010 | P0 | F-003 | + +| T-015 | 内容详情页 | `/detail/[platform]/[id]` 页面,完整信息展示 + "查看原文"跳转 + 收藏按钮 | T-010 | P0 | F-004 | +| T-016 | 自动定时刷新 | TanStack Query refetchInterval,读取设置中的刷新间隔,页面不可见时暂停 | T-010, T-008 | P0 | F-005 | + +| T-017 | 手动刷新 + 刷新时间 | 工具栏刷新按钮,invalidateQueries,loading 状态,防抖处理,重置自动刷新计时器;工具栏显示"上次刷新: HH:MM",刷新后更新 | T-010, T-016 | P0 | F-005, F-006 | + +**阶段依赖图:** + +``` +T-010 (TanStack Query) ──┬──▶ T-013 (平台Tab) + ├──▶ T-014 (排序) + ├──▶ T-015 (详情页) + ├──▶ T-016 (自动刷新) ──▶ T-017 (手动刷新+刷新时间) + │ +T-011 (卡片组件) ──▶ T-012 (网格布局) ──▶ T-013 +``` + +**Phase 2 验收**: 首页展示三个平台的热点内容卡片网格,可切换平台/排序,点击进入详情页,自动/手动刷新正常。 + +--- + +### 3.4 Phase 3: 辅助功能 & 联调 + +**目标**: 完成收藏系统、设置页面、错误处理、性能优化,通过全部 MVP 验收标准。 + +| 任务ID | 任务 | 描述 | 依赖 | 优先级 | 关联功能 | +|--------|------|------|------|--------|----------| +| T-019 | 收藏功能实现 | favoritesStore 完善,addFavorite/removeFavorite,persist 到 localStorage | T-008, T-011 | P0 | F-007, F-009 | +| T-020 | 卡片收藏按钮 | ContentCard + DetailPage 中添加收藏按钮,实心/空心状态切换 | T-019, T-015 | P0 | F-007 | +| T-021 | 收藏夹页面 | `/favorites` 页面,网格展示收藏内容,支持取消收藏和跳转详情 | T-019, T-012 | P0 | F-008 | +| T-022 | 设置页面 | `/settings` 页面,API Key 输入框 + 刷新间隔选择 | T-008 | P0 | F-010, F-011 | +| T-023 | 错误处理 & 空状态 | API 错误提示、Key 未配置引导、数据为空提示、封面图加载失败占位 | T-010~T-022 | P0 | - | +| T-024 | 图片懒加载 | Next.js Image 组件 + loading="lazy",首屏性能优化 | T-012 | P0 | - | +| T-025 | 全链路联调 & 验收 | 按 PRD 第8节 MVP 验收标准逐项测试 | T-023, T-024 | P0 | 全部 | + +**阶段依赖图:** + +``` +T-019 (收藏Store) ──┬──▶ T-020 (收藏按钮) + └──▶ T-021 (收藏夹页面) + +T-022 (设置页面) + +T-023 (错误处理) ◀── T-019~T-022 全部完成 + +T-024 (图片懒加载) + +T-025 (联调验收) ◀── T-023 + T-024 +``` + +**Phase 3 验收**: 通过 PRD 第8节全部 MVP 验收标准。 + +--- + +## 4. 技术方案 + +### 4.1 统一数据模型(F-015) + +**功能**: 定义 ContentItem TypeScript 类型,作为全系统的数据契约。 + +**类型定义**: + +```typescript +// src/types/content.ts +interface ContentItem { + id: string; + title: string; + cover_url?: string; + video_url?: string; + author_name: string; + author_avatar?: string; + play_count?: number; + like_count?: number; + comment_count?: number; + share_count?: number; + publish_time: string; + platform: Platform; + original_url: string; + tags?: string[]; +} + +type Platform = + | 'douyin' | 'tiktok' | 'xiaohongshu' // MVP + | 'youtube' | 'instagram' | 'twitter' // P1 + | 'bilibili' | 'weibo' // P1 + | string; // P2 扩展 + +interface PlatformConfig { + id: Platform; + name: string; + icon: string; + color: string; + enabled: boolean; + endpoints: { + trending: string; + detail: string; + }; +} + +interface PlatformAdapter { + fetchTrending(count: number): Promise; + fetchDetail(id: string): Promise; +} +``` + +### 4.2 API 代理层(F-014) + +**功能**: Next.js API Routes 代理 TikHub 请求,隐藏 API Key。 + +**接口设计**: + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 获取热榜 | GET | `/api/tikhub/[platform]?count=20` | 返回 ContentItem[] | +| 获取详情 | GET | `/api/tikhub/[platform]/detail?id=xxx` | 返回 ContentItem | +| 保存设置 | POST | `/api/settings` | 保存 API Key(服务端) | +| 调用统计 | GET | `/api/stats` | 返回当日调用次数 | + +**架构设计**: + +``` +┌───────────────────────────────────────────────────┐ +│ /api/tikhub/[platform]/route.ts │ +├───────────────────────────────────────────────────┤ +│ │ +│ 1. 解析 platform 参数 │ +│ │ │ +│ ▼ │ +│ 2. 读取 API Key (环境变量 / settings) │ +│ │ │ +│ ▼ │ +│ 3. 频率限制检查 (10 req/s) │ +│ │ │ +│ ▼ │ +│ 4. 选择 PlatformAdapter │ +│ ┌─────┼─────┐ │ +│ ▼ ▼ ▼ │ +│ douyin tiktok xiaohongshu │ +│ │ │ │ │ +│ └─────┼─────┘ │ +│ ▼ │ +│ 5. 调用 TikHub API + 转换为 ContentItem[] │ +│ │ │ +│ ▼ │ +│ 6. 返回 JSON Response │ +│ │ +└───────────────────────────────────────────────────┘ +``` + +**实现要点**: + + +- API Key 读取优先级:① 运行时内存变量(设置页面覆盖值)→ ② `.env.local` 的 `TIKHUB_API_KEY` 环境变量(预配置) +- 设置页面保存 API Key 时,通过 `POST /api/settings` 将 Key 写入服务端内存变量(进程生命周期内有效),不写入 `.env.local`(运行时无法修改);服务重启后回退到 `.env.local` 配置 +- MVP 阶段(localhost):推荐在 `.env.local` 中预配置 Key,设置页面仅作为运行时覆盖手段 +- 使用简单的内存计数器实现 10 req/s 限流(滑动窗口) +- 错误码映射:TikHub 401 → 前端提示配置 Key;429 → 提示稍后重试;5xx → 通用错误 + +### 4.3 平台适配器(F-016) + +**功能**: 各平台 API 调用和数据格式转换。 + +**架构设计**: + +``` +┌────────────────────────────────────────────────┐ +│ PlatformAdapter 接口 │ +│ fetchTrending(count) → ContentItem[] │ +│ fetchDetail(id) → ContentItem │ +└──────────────────┬─────────────────────────────┘ + │ implements + ┌─────────┼─────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Douyin │ │ TikTok │ │ Xhs │ + │ Adapter │ │ Adapter │ │ Adapter │ + ├──────────┤ ├──────────┤ ├──────────┤ + │ 端点: │ │ 端点: │ │ 端点: │ + │ fetch_hot │ │ trending │ │ fetch_ │ + │ _search_ │ │ _post │ │ feed │ + │ result │ │ │ │ │ + │ │ │ │ │ │ + │ 映射: │ │ 映射: │ │ 映射: │ + │ 平台特定 │ │ 平台特定 │ │ 平台特定 │ + │ → Content │ │ → Content│ │ → Content│ + │ Item │ │ Item │ │ Item │ + └──────────┘ └──────────┘ └──────────┘ +``` + +**MVP 平台端点配置**: + +| 平台 | 热榜端点 | 详情端点 | +|------|----------|----------| +| 抖音 | `/api/v1/douyin/web/fetch_hot_search_result` | `/api/v1/douyin/web/fetch_one_video` | +| TikTok | `/api/v1/tiktok/web/fetch_trending_post` | `/api/v1/tiktok/web/fetch_post_detail` | +| 小红书 | `/api/v1/xiaohongshu/app/v2/fetch_feed` | `/api/v1/xiaohongshu/app/v2/fetch_note_detail` | + +**实现要点**: + +- 每个适配器独立文件:`src/lib/adapters/douyin.ts`、`tiktok.ts`、`xiaohongshu.ts` +- 适配器注册表:`src/lib/adapters/index.ts` 导出 `getAdapter(platform): PlatformAdapter` +- 字段映射中缺失字段使用合理默认值(如 `author_name: "未知作者"`) + +### 4.4 内容展示层(F-002, F-003) + +**功能**: 卡片网格布局 + 平台 Tab 切换 + 排序。 + +**组件结构**: + +``` +┌────────────────────────────────────────────────┐ +│ Header │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Logo [全部][抖音][TikTok][小红书] ⚙️ │ │ +│ └──────────────────────────────────────────┘ │ +├────────────────────────────────────────────────┤ +│ Toolbar │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 排序: [▼最热|最新] 🔄刷新 上次:10:30│ │ +│ └──────────────────────────────────────────┘ │ +├────────────────────────────────────────────────┤ +│ ContentGrid │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ │Content │ │Content │ │Content │ ... │ │ +│ │ │Card │ │Card │ │Card │ │ │ +│ │ └────────┘ └────────┘ └────────┘ │ │ +│ └──────────────────────────────────────────┘ │ +└────────────────────────────────────────────────┘ +``` + +**接口设计**: + +| 组件 | Props | 说明 | +|------|-------|------| +| `PlatformTabs` | `platforms`, `active`, `onChange` | 平台切换 Tab 栏 | +| `SortToolbar` | `sortBy`, `sortOrder`, `onSort`, `onRefresh`, `lastRefresh` | 排序 + 刷新工具栏 | +| `ContentGrid` | `items: ContentItem[]`, `loading`, `error` | 卡片网格容器 | +| `ContentCard` | `item: ContentItem`, `onFavorite`, `isFavorited` | 单个内容卡片 | + +**实现要点**: + +- 网格布局使用 CSS Grid:`grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))` +- 排序在前端内存中完成(useMemo),不重新请求 API +- 平台 Tab 切换触发 TanStack Query 的 queryKey 变更,自动重新获取数据 + +- "全部"视图使用 `Promise.all` 并发请求所有已启用平台,通过 rate-limiter 确保不超 10 req/s + +### 4.5 收藏系统(F-007, F-008, F-009) + +**功能**: Zustand + persist 实现收藏功能。 + +**接口设计**: + +```typescript +// src/stores/favorites.ts +interface FavoritesStore { + items: ContentItem[]; + addFavorite: (item: ContentItem) => void; + removeFavorite: (id: string, platform: Platform) => void; + isFavorited: (id: string, platform: Platform) => boolean; +} +``` + +**实现要点**: + +- 使用 Zustand `persist` 中间件,存储到 localStorage key `muse-favorites` +- 收藏去重:以 `id + platform` 组合作为唯一键 +- 收藏夹页面复用 `ContentGrid` + `ContentCard` 组件 + +### 4.6 设置管理(F-010, F-011) + +**功能**: API Key 配置 + 刷新间隔设置。 + +**接口设计**: + +```typescript +// src/stores/settings.ts +interface SettingsStore { + apiKey: string; + refreshInterval: 5 | 10 | 15 | 30 | 60; // 分钟 + enabledPlatforms: Record; + displayCount: number; + setApiKey: (key: string) => void; + setRefreshInterval: (minutes: number) => void; + togglePlatform: (platform: Platform) => void; + setDisplayCount: (count: number) => void; +} +``` + +**实现要点**: + + +- API Key 通过 `/api/settings` POST 接口保存到服务端内存变量(非 localStorage),读取优先级见 4.2 节 +- 刷新间隔变更后,立即更新 TanStack Query 的 refetchInterval +- 设置页 UI 使用 shadcn/ui 的 Input、Select、Switch 组件 + +--- + +## 5. 项目目录结构 + +``` +src/ +├── app/ +│ ├── layout.tsx # 全局布局 (Header + Main) +│ ├── page.tsx # 首页 (ContentGrid + Toolbar) + +│ ├── detail/[platform]/[id]/page.tsx # 详情页 +│ ├── favorites/page.tsx # 收藏夹页面 +│ ├── settings/page.tsx # 设置页面 +│ └── api/ +│ ├── tikhub/ +│ │ └── [platform]/ +│ │ ├── route.ts # 热榜内容代理 +│ │ └── detail/route.ts # 内容详情代理 +│ ├── settings/route.ts # 设置保存接口 +│ └── stats/route.ts # API 调用统计 +├── components/ +│ ├── layout/ +│ │ ├── Header.tsx # 顶部导航 +│ │ ├── PlatformTabs.tsx # 平台 Tab 栏 +│ │ └── SortToolbar.tsx # 排序 + 刷新工具栏 +│ ├── card/ +│ │ ├── ContentCard.tsx # 内容卡片 +│ │ ├── ContentGrid.tsx # 卡片网格容器 +│ │ └── CardSkeleton.tsx # 加载骨架屏 +│ ├── detail/ +│ │ └── DetailPanel.tsx # 详情信息面板 +│ ├── common/ +│ │ ├── EmptyState.tsx # 空状态组件 +│ │ ├── ErrorState.tsx # 错误状态组件 +│ │ └── FavoriteButton.tsx # 收藏按钮 +│ └── ui/ # shadcn/ui 组件 +├── lib/ +│ ├── tikhub.ts # TikHub HTTP 客户端 +│ ├── rate-limiter.ts # 请求频率限制 +│ ├── adapters/ +│ │ ├── index.ts # 适配器注册表 +│ │ ├── douyin.ts # 抖音适配器 +│ │ ├── tiktok.ts # TikTok 适配器 +│ │ └── xiaohongshu.ts # 小红书适配器 +│ ├── platforms.ts # 平台配置 +│ └── utils.ts # 工具函数 +├── hooks/ +│ ├── useContentQuery.ts # 内容查询 hook +│ └── useDetailQuery.ts # 详情查询 hook +├── stores/ +│ ├── favorites.ts # 收藏 store +│ └── settings.ts # 设置 store +└── types/ + └── content.ts # 类型定义 +``` + +--- + +## 6. 风险管理 + +| 风险 | 可能性 | 影响 | 应对措施 | +|------|--------|------|----------| +| TikHub API 端点变更 | 中 | 高 | 适配器模式隔离变更,仅需修改对应适配器文件 | +| TikHub API 响应格式变化 | 中 | 高 | 字段映射做容错处理,缺失字段使用默认值 | + +| API 频率限制触发 (10 req/s) | 高 | 中 | 实现 rate-limiter 请求排队机制,确保并发请求不超 10 req/s;MVP 仅 3 平台,并发风险低 | +| API 成本失控 | 低 | 中 | 默认 30 分钟刷新间隔;页面不可见暂停刷新;F-017 成本监控 | +| localStorage 容量限制 (5MB) | 低 | 低 | 收藏数据量预估较小(数百条 ContentItem ≈ 几百 KB) | +| 跨域图片加载失败 | 高 | 中 | 使用 Next.js Image 组件配置 remotePatterns;加载失败显示占位图 | +| P1/P2 平台 API 端点不确定 | 高 | 低 | MVP 不涉及;P1 阶段前在 TikHub 控制台确认最新端点 | + +--- + +## 7. 里程碑 + +``` +M1 M2 M3 M4 +│ │ │ │ +▼ ▼ ▼ ▼ +◆─────────────────◆─────────────────◆─────────────────◆ +│ │ │ │ +Phase 1 完成 Phase 2 完成 Phase 3 完成 MVP 发布 +数据链路打通 核心浏览体验 全功能可用 验收通过 +``` + +| 里程碑 | 目标 | 交付物 | 验收标准 | +|--------|------|--------|----------| +| M1 — 数据链路打通 | API 代理层 + 3 平台适配器可用 | T-001 ~ T-009 | `GET /api/tikhub/douyin` 返回标准化 JSON | + +| M2 — 核心浏览体验 | 首页可浏览、可切换、可排序 | T-010 ~ T-017 | 首页展示卡片网格,Tab 切换 + 排序 + 详情页 + 刷新正常 | +| M3 — 全功能可用 | 收藏 + 设置 + 错误处理 | T-019 ~ T-024 | 收藏功能可用,设置页可配置 Key 和刷新间隔 | +| M4 — MVP 发布 | 全链路验收通过 | T-025 | 通过 PRD 第8节全部 8 条 MVP 验收标准 | + +--- + +## 8. 任务与功能映射 + +| 功能ID | 功能名 | 实现任务 | +|--------|--------|----------| +| F-001 | 内容获取 | T-010 | +| F-002 | 卡片信息流展示 | T-011, T-012 | +| F-003 | 内容筛选与排序 | T-013, T-014 | +| F-004 | 内容详情页 | T-015 | + +| F-005 | 自动定时刷新 | T-016, T-017 | +| F-006 | 手动刷新 | T-017 | +| F-007 | 内容收藏 | T-019, T-020 | +| F-008 | 收藏夹管理 | T-021 | +| F-009 | 收藏数据持久化 | T-019 | +| F-010 | API Key 配置 | T-022 | +| F-011 | 刷新间隔设置 | T-022 | +| F-014 | API 请求代理 | T-003, T-004 | +| F-015 | 统一数据模型 | T-002 | +| F-016 | 平台适配器 | T-005, T-006, T-007 | + +> F-012(平台管理)、F-013(展示数量设置)、F-017(API 调用量统计)为 v1.1/v2.0 功能,不在 MVP 任务中。 + +--- + +## 9. 资源需求 + +| 角色 | 人数 | 职责 | 参与阶段 | +|------|------|------|----------| +| 全栈开发 | 1 | 前后端全部实现 | Phase 1-3 | + +> 本项目为个人项目,由单人全栈完成。 diff --git a/doc/FeatureSummary.md b/doc/FeatureSummary.md new file mode 100644 index 0000000..ee3aee9 --- /dev/null +++ b/doc/FeatureSummary.md @@ -0,0 +1,497 @@ +# Muse Creative Hotspots — 功能摘要 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | 2026-03-02 | +| 来源文档 | PRD.md | + +## 1. 功能总览 + +### 1.1 功能统计 + +| 类别 | 数量 | +|------|------| +| 功能模块 | 5 个 | + +| P0 功能 | 14 个 | +| P1 功能 | 2 个 | +| P2 功能 | 1 个 | +| **功能总计** | **17 个** | + +### 1.2 功能架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Muse Creative Hotspots(秒思创意热点) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ A. 热点内容聚合 │ │ B. 数据刷新管理 │ │ C. 收藏/书签系统 │ │ +│ │ ──────────────── │ │ ──────────────── │ │ ──────────────── │ │ +│ │ • F-001 内容获取 │ │ • F-005 自动刷新 │ │ • F-007 内容收藏 │ │ +│ │ • F-002 卡片展示 │ │ • F-006 手动刷新 │ │ • F-008 收藏管理 │ │ +│ │ • F-003 筛选排序 │ │ │ │ • F-009 数据持久化 │ │ +│ │ • F-004 详情页 │ │ │ │ │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ D. 设置管理 │ │ E. API 代理层 │ │ +│ │ ──────────────── │ │ ──────────────── │ │ +│ │ • F-010 Key配置 │ │ • F-014 请求代理 │ │ +│ │ • F-011 刷新间隔 │ │ • F-015 数据模型 │ │ +│ │ • F-012 平台管理 │ │ • F-016 平台适配 │ │ +│ │ • F-013 数量设置 │ │ │ │ +│ │ • F-017 调用统计 │ │ │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 模块依赖关系 + +``` +┌──────────────────┐ +│ E. API 代理层 │ ◀─── 所有数据请求的底层基础 +│ F-014/F-015/F-016│ +└───────┬──────────┘ + │ 被依赖 + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ A. 热点内容聚合 │ ◀──── │ B. 数据刷新管理 │ +│ F-001~F-004 │ │ F-005/F-006 │ +└───────┬──────────┘ └──────────────────┘ + │ 被依赖 ▲ + ▼ │ 读取配置 +┌──────────────────┐ ┌──────────────────┐ +│ C. 收藏/书签系统 │ │ D. 设置管理 │ +│ F-007~F-009 │ │ F-010~F-013 │ +└──────────────────┘ └──────────────────┘ + │ 提供 API Key + ▼ + ┌──────────────────┐ + │ E. API 代理层 │ + └──────────────────┘ +``` + +**依赖说明:** +- 模块 E 是基础设施层,模块 A/B 依赖其提供数据 +- 模块 B 触发模块 A 的数据重新获取 +- 模块 C 依赖模块 A 提供可收藏的内容 +- 模块 D 为模块 B(刷新间隔)和模块 E(API Key)提供配置 + +--- + +## 2. 功能清单 + +### 2.1 模块 A — 热点内容聚合浏览 + +**模块职责**: 从各平台获取热点内容并以卡片信息流形式展示,支持筛选、排序和详情查看。 + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联场景 | +|----|------|------|--------|----------| +| F-001 | 内容获取 | 获取各平台官方热榜/推荐内容 | P0 | US-001/002/003 | +| F-002 | 卡片信息流展示 | 瀑布流/网格卡片布局展示内容 | P0 | US-001/004 | +| F-003 | 内容筛选与排序 | 按平台切换、按数据指标排序 | P0 | US-002/003 | +| F-004 | 内容详情页 | 站内详情页展示完整内容信息 | P0 | US-001/003/004 | + +#### 功能契约详情 + +**F-001: 内容获取** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 1. 页面首次加载;2. 自动定时刷新触发;3. 手动点击刷新按钮;4. 切换平台 Tab | +| **输入** | 平台标识(platform)、展示数量(count,默认 20-50)、API Key(从设置读取) | +| **处理逻辑** | 1. 通过 API 代理层调用 TikHub 对应平台端点;2. 适配器将原始数据转换为统一 ContentItem;3. 缓存结果供前端展示 | +| **输出** | ContentItem[] 数组,包含 title、cover_url、author_name、play_count、like_count 等标准字段 | +| **异常情况** | API Key 无效 → 提示配置;请求超时 → 显示重试按钮;频率超限(10 req/s)→ 排队等待;平台未启用 → 跳过 | +| **边界说明** | 不包含内容的全文/完整视频获取;不做内容缓存持久化(刷新后替换);不支持分页加载更多 | + +**F-002: 卡片信息流展示** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 内容数据加载完成(F-001 返回结果) | +| **输入** | ContentItem[] 数组 | +| **处理逻辑** | 1. 按瀑布流/网格布局渲染卡片;2. 每张卡片展示:封面图/缩略图、标题(截断)、平台图标+名称、关键数据(播放量/点赞/评论/分享)、发布时间、作者头像+昵称;3. 支持 hover 预览更多信息 | +| **输出** | 可视化的卡片网格界面 | +| **异常情况** | 封面图加载失败 → 显示占位图;数据为空 → 显示空状态提示 | +| **边界说明** | 不包含视频内联播放;不包含无限滚动(展示固定 Top N);卡片内标题截断展示,完整内容在详情页 | + +**F-003: 内容筛选与排序** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户点击平台 Tab 切换或选择排序方式 | +| **输入** | 筛选条件:platform(平台标识 / "all");排序条件:sortBy(play_count / like_count / comment_count / publish_time)+ sortOrder(asc / desc) | +| **处理逻辑** | 1. 按平台筛选:切换 Tab 时过滤或重新请求对应平台数据;2. 按指标排序:前端对当前列表重新排序;3. "全部"视图聚合所有已启用平台的内容 | +| **输出** | 重新排列后的 ContentItem[] → 更新卡片信息流 | +| **异常情况** | 某平台数据为空 → Tab 上标注"暂无数据";排序字段缺失 → 该条目排到末尾 | +| **边界说明** | 不包含关键词搜索功能;不包含多条件组合筛选;排序为前端内存排序,不重新请求 API | + +**F-004: 内容详情页** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户点击内容卡片 | +| **输入** | ContentItem 的完整数据(或 contentId + platform 用于请求详情 API) | +| **处理逻辑** | 1. 展示完整内容信息(标题、描述、标签);2. 展示完整数据指标面板(播放/点赞/评论/分享);3. 展示作者信息(头像+昵称);4. 提供"查看原文"按钮(跳转原平台页面);5. 提供收藏按钮 | +| **输出** | 站内详情页面 | +| **异常情况** | 详情数据加载失败 → 显示错误提示 + 重试按钮;原文链接失效 → 提示"原文可能已被删除" | + +| **边界说明** | 不包含站内视频播放器(视频内容通过"查看原文"按钮跳转原平台播放,video_url 不直接嵌入播放);不包含评论区展示;不包含相关推荐 | + +--- + +### 2.2 模块 B — 数据刷新管理 + +**模块职责**: 管理内容数据的自动定时刷新和手动触发刷新机制。 + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联场景 | +|----|------|------|--------|----------| +| F-005 | 自动定时刷新 | 按设定间隔自动获取最新内容 | P0 | US-001/002 | +| F-006 | 手动刷新 | 用户点击按钮立即刷新内容 | P0 | US-001/002 | + +#### 功能契约详情 + +**F-005: 自动定时刷新** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 页面加载后自动启动定时器 | +| **输入** | 刷新间隔(从设置读取,默认 30 分钟) | +| **处理逻辑** | 1. 启动定时器(setInterval / TanStack Query refetchInterval);2. 到达间隔时间后自动调用 F-001 重新获取所有已启用平台的内容;3. 刷新时更新"上次刷新时间"显示 | +| **输出** | 更新后的内容列表 + 更新刷新时间戳 | +| **异常情况** | 刷新失败 → 保留上一次数据,显示刷新失败提示;页面后台(不可见)→ 暂停刷新节省 API 调用 | +| **边界说明** | 不包含增量更新(每次全量替换);不包含推送通知新内容;最小间隔限制 5 分钟(防止 API 滥用) | + +**F-006: 手动刷新** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户点击工具栏刷新按钮 🔄 | +| **输入** | 当前选中平台(或"全部") | +| **处理逻辑** | 1. 触发 F-001 重新获取内容;2. 刷新按钮显示 loading 状态;3. 完成后重置自动刷新计时器;4. 更新"上次刷新时间" | +| **输出** | 更新后的内容列表 + loading 状态反馈 | +| **异常情况** | 连续快速点击 → 防抖处理(2 秒内忽略重复点击);刷新中再次点击 → 忽略 | + +| **边界说明** | 【设计决策】不包含单平台独立刷新(刷新所有已启用平台,PRD 未明确此约束,后续可按需调整);手动刷新会重置自动刷新倒计时 | + +--- + +### 2.3 模块 C — 收藏/书签系统 + +**模块职责**: 允许用户收藏感兴趣的内容,构建个人灵感库。 + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联场景 | +|----|------|------|--------|----------| + +| F-007 | 内容收藏 | 将任意内容卡片添加到收藏夹 | P0 | US-001/004 | +| F-008 | 收藏夹管理 | 独立页面查看和管理收藏内容 | P0 | US-004 | +| F-009 | 收藏数据持久化 | 收藏数据本地持久化存储 | P0 | US-004 | + +#### 功能契约详情 + +**F-007: 内容收藏** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户在卡片上或详情页中点击收藏按钮 | +| **输入** | ContentItem 完整数据 | +| **处理逻辑** | 1. 检查是否已收藏(防重复);2. 已收藏 → 取消收藏;未收藏 → 添加到收藏列表;3. 更新收藏按钮状态(实心/空心);4. 触发 F-009 持久化存储 | +| **输出** | 收藏状态变更 + 视觉反馈(按钮状态切换) | +| **异常情况** | 存储空间不足 → 提示清理旧收藏 | +| **边界说明** | 不包含收藏分类/文件夹功能;不包含收藏备注;不包含收藏分享 | + +**F-008: 收藏夹管理** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户点击导航中的"收藏"入口 | +| **输入** | 本地存储的收藏列表 | +| **处理逻辑** | 1. 从本地存储读取收藏列表;2. 以卡片网格形式展示所有收藏内容;3. 支持取消收藏(删除);4. 点击卡片可跳转详情页 | +| **输出** | 收藏内容列表页面 | +| **异常情况** | 收藏为空 → 显示空状态引导 | +| **边界说明** | 不包含收藏搜索;不包含收藏排序;不包含收藏导出;MVP 阶段不支持云同步 | + +**F-009: 收藏数据持久化** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 收藏列表发生变更(添加/删除) | +| **输入** | 完整的收藏列表数据 | +| **处理逻辑** | 1. 使用 Zustand persist 中间件;2. 将收藏数据序列化存储到 localStorage;3. 页面加载时自动恢复收藏状态 | +| **输出** | 持久化的收藏数据 | +| **异常情况** | localStorage 不可用 → 降级为内存存储(关闭页面丢失);数据损坏 → 重置收藏列表并提示 | +| **边界说明** | MVP 仅支持 localStorage,不包含 IndexedDB;不包含数据库后端存储;不包含跨设备同步 | + +--- + +### 2.4 模块 D — 设置管理 + +**模块职责**: 提供应用配置界面,允许用户自定义 API 认证、刷新策略和平台偏好。 + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联场景 | +|----|------|------|--------|----------| +| F-010 | API Key 配置 | 配置 TikHub API Key | P0 | - | + +| F-011 | 刷新间隔设置 | 自定义自动刷新频率 | P0 | US-001 | +| F-012 | 平台管理 | 启用/禁用各平台 | P1 | US-002 | +| F-013 | 展示数量设置 | 配置每平台默认展示数量 | P2 | US-003 | + +| F-017 | API 调用量统计 | 展示当日 API 调用次数及成本估算 | P1 | - | + + +#### 功能契约详情 + +**F-010: API Key 配置** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户进入设置页面 | +| **输入** | 用户输入的 TikHub API Key 字符串 | +| **处理逻辑** | 1. 提供文本输入框供用户粘贴 API Key;2. 保存时验证 Key 格式(非空检查);3. 存储到本地(加密或 env);4. API 代理层读取此 Key 发起请求 | +| **输出** | API Key 保存成功/失败提示 | + +| **异常情况** | Key 为空 → 提示必填;Key 无效 → 首次请求内容时 API 返回 401,引导用户检查 Key 配置 | +| **边界说明** | 保存时仅做非空校验,不测试 API 连通性(无效 Key 在实际请求时暴露);不包含多 Key 轮换;Key 仅存储于服务端环境变量或加密本地存储 | + +**F-011: 刷新间隔设置** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户在设置页面调整刷新间隔 | +| **输入** | 刷新间隔值(分钟),可选范围:5 / 10 / 15 / 30 / 60 | +| **处理逻辑** | 1. 提供下拉选择或滑块控件;2. 保存后立即更新 F-005 的定时器间隔;3. 持久化存储设置 | +| **输出** | 新的刷新间隔生效 | +| **异常情况** | 无特殊异常 | +| **边界说明** | 最小 5 分钟(API 成本控制);不支持自定义任意分钟数 | + +**F-012: 平台管理** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户在设置页面切换平台开关 | +| **输入** | 平台标识 + 启用/禁用状态 | +| **处理逻辑** | 1. 展示所有支持的平台列表,每个平台配有开关;2. 切换开关后持久化设置;3. 禁用的平台不再出现在首页 Tab 栏中;4. 禁用平台的数据不再自动刷新 | +| **输出** | 平台启用状态更新 | +| **异常情况** | 至少保留一个平台启用 → 否则提示 | +| **边界说明** | 不包含平台顺序自定义;不包含自定义添加新平台 | + +**F-013: 展示数量设置** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户在设置页面调整展示数量 | +| **输入** | 每平台展示数量(默认 20,可选 10 / 20 / 30 / 50) | +| **处理逻辑** | 1. 提供数量选择控件;2. 保存后下次刷新时按新数量请求;3. 影响 F-001 的请求参数 | +| **输出** | 新的展示数量配置生效 | +| **异常情况** | 无特殊异常 | +| **边界说明** | 不支持每个平台独立设置不同数量(全局统一);更改后需等待下次刷新才生效 | + + +**F-017: API 调用量统计** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 用户进入设置页面或首页工具栏查看 | +| **输入** | API 代理层的请求计数器数据 | +| **处理逻辑** | 1. API 代理层(F-014)每次转发请求时累加计数器;2. 按日期维度统计调用次数;3. 根据 $0.001/请求 计算估算成本;4. 在设置页面或工具栏展示"今日调用: N 次(≈$X.XX)" | +| **输出** | 当日 API 调用次数 + 成本估算显示 | +| **异常情况** | 计数器数据丢失(页面刷新)→ 重新从 0 计数并提示"本次会话统计" | +| **边界说明** | 仅统计当前会话/当日数据,不做历史统计;计数存储于内存或 localStorage;成本为估算值,不保证与实际账单一致 | + + +--- + +### 2.5 模块 E — API 代理层 + +**模块职责**: 通过 Next.js API Routes 代理 TikHub 请求,隐藏 API Key 并统一数据格式。 + +#### 功能列表 + +| ID | 功能 | 描述 | 优先级 | 关联场景 | +|----|------|------|--------|----------| +| F-014 | API 请求代理 | 通过服务端代理 TikHub API 请求 | P0 | - | +| F-015 | 统一数据模型 | 所有平台数据映射为 ContentItem | P0 | - | +| F-016 | 平台适配器 | 各平台 API 调用与数据格式转换 | P0 | - | + +#### 功能契约详情 + +**F-014: API 请求代理** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 前端发起内容获取请求 | +| **输入** | 平台标识、请求类型(热榜/详情)、请求参数 | +| **处理逻辑** | 1. Next.js API Route 接收前端请求;2. 从环境变量/设置读取 API Key;3. 构建 TikHub API 请求(Bearer Token 认证);4. 转发请求并返回结果;5. 遵守 10 req/s 频率限制 | +| **输出** | TikHub API 原始响应数据 | +| **异常情况** | API Key 未配置 → 返回 401 + 提示配置;TikHub 返回错误 → 透传错误信息;请求超时 → 返回超时错误;频率超限 → 排队或返回 429 | +| **边界说明** | 不包含响应缓存(由 TanStack Query 在前端处理);不包含请求日志记录;代理层仅做转发,不做业务逻辑 | + +**F-015: 统一数据模型** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | 适配器处理 API 响应时 | +| **输入** | 各平台原始 API 响应数据 | +| **处理逻辑** | 定义统一 ContentItem 类型,包含:title、cover_url、video_url、author_name、author_avatar、play_count、like_count、comment_count、share_count、publish_time、platform、original_url、tags | +| **输出** | 标准化的 ContentItem 对象 | +| **异常情况** | 必填字段缺失 → 使用默认值(如"未知作者");数据类型不匹配 → 类型转换或置空 | +| **边界说明** | ContentItem 为只读展示模型,不包含编辑能力;字段定义以 PRD 4.3 为准 | + +**F-016: 平台适配器** + +| 契约项 | 说明 | +|--------|------| +| **触发条件** | F-001 调用内容获取时 | +| **输入** | 平台标识 + API 原始响应 | +| **处理逻辑** | 1. 根据平台标识选择对应适配器;2. 适配器调用平台特定的 TikHub API 端点;3. 将原始响应字段映射为 ContentItem(参照 PRD 中各平台的字段映射规则);4. MVP 实现抖音/TikTok/小红书三个适配器 | +| **输出** | ContentItem[] 标准化数组 | +| **异常情况** | 平台无适配器 → 返回空数组 + 日志警告;API 端点变更 → 适配器更新 | +| **边界说明** | 每个适配器独立实现,互不影响;新增平台只需新增适配器,无需修改已有代码 | + +--- + +## 3. 功能依赖矩阵 + + +| 功能 | F-001 | F-002 | F-003 | F-004 | F-005 | F-006 | F-007 | F-008 | F-009 | F-010 | F-011 | F-012 | F-013 | F-014 | F-015 | F-016 | F-017 | +|------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------|-------| +| **F-001** 内容获取 | - | | | | | | | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | | +| **F-002** 卡片展示 | ✓ | - | ✓ | | | | | | | | | | | | | | | +| **F-003** 筛选排序 | ✓ | | - | | | | | | | | | | | | | | | +| **F-004** 详情页 | ✓ | | | - | | | | | | | | | | ✓ | ✓ | ✓ | | +| **F-005** 自动刷新 | ✓ | | | | - | | | | | | ✓ | | | | | | | +| **F-006** 手动刷新 | ✓ | | | | | - | | | | | | | | | | | | +| **F-007** 内容收藏 | ✓ | | | | | | - | | ✓ | | | | | | | | | +| **F-008** 收藏管理 | | | | | | | | - | ✓ | | | | | | | | | +| **F-009** 数据持久化 | | | | | | | | | - | | | | | | | | | +| **F-010** Key配置 | | | | | | | | | | - | | | | | | | | +| **F-011** 刷新间隔 | | | | | | | | | | | - | | | | | | | +| **F-012** 平台管理 | | | | | | | | | | | | - | | | | | | +| **F-013** 数量设置 | | | | | | | | | | | | | - | | | | | +| **F-014** 请求代理 | | | | | | | | | | ✓ | | | | - | | | | +| **F-015** 数据模型 | | | | | | | | | | | | | | | - | | | +| **F-016** 平台适配 | | | | | | | | | | | | | | ✓ | ✓ | - | | +| **F-017** 调用统计 | | | | | | | | | | | | | | ✓ | | | - | + +说明: +- ✓ 表示**行功能**依赖**列功能** +- 空白表示无依赖 +- `-` 表示自身 + +--- + +## 4. 功能流程图 + +### 4.1 核心流程:内容浏览主流程 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ F-010 │ ──▶ │ F-014 │ ──▶ │ F-016 │ ──▶ │ F-015 │ ──▶ │ F-001 │ +│ Key配置 │ │ 请求代理 │ │ 平台适配 │ │ 数据模型 │ │ 内容获取 │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────┬────┘ + │ + ┌───────────────────────────────────────────────────┘ + ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ F-002 │ ──▶ │ F-003 │ ──▶ │ F-004 │ + │ 卡片展示 │ │ 筛选排序 │ │ 详情页 │ + └──────────┘ └──────────┘ └──────────┘ +``` + +### 4.2 数据刷新流程 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 刷新触发 │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ F-005 │ │ F-006 │ │ +│ │ 自动刷新 │ │ 手动刷新 │ │ +│ │ (定时器) │ │ (按钮) │ │ +│ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ +│ └──────────┬───────────┘ │ +│ ▼ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ F-001 │ ──▶ │ F-002 │ │ +│ │ 内容获取 │ │ 更新展示 │ │ +│ └────┬─────┘ └──────────┘ │ +│ │ │ +│ ▼ 失败 │ +│ ┌────────────┐ │ +│ │ 保留旧数据 │ │ +│ │ 显示错误提示│ │ +│ └────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4.3 收藏流程 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ F-002 │ ──▶ │ F-007 │ ──▶ │ F-009 │ +│ 卡片展示 │ │ 点击收藏 │ │ 持久化 │ +└──────────┘ └──────────┘ └──────────┘ + 或 │ +┌──────────┐ ▼ +│ F-004 │ ──▶ (同上) ┌──────────┐ +│ 详情页 │ │ F-008 │ +└──────────┘ │ 收藏管理 │ + └──────────┘ +``` + +--- + +## 5. 版本规划 + +| 版本 | 包含功能 | 功能ID | 目标 | +|------|----------|--------|------| + +| **MVP** | 3平台内容获取、卡片展示、筛选排序、详情页、自动/手动刷新、收藏系统、API Key配置、刷新间隔设置、API代理层 | F-001 ~ F-011, F-014 ~ F-016 | 跑通核心链路:抖音+TikTok+小红书热点聚合浏览 | +| **v1.1** | 平台管理、API调用量统计、新增5个P1平台适配器 | F-012, F-017 + P1平台适配 | 扩展至8个平台,完善运维功能 | +| **v2.0** | 展示数量设置、剩余9个P2平台适配器 | F-013 + P2平台适配 | 全平台覆盖17个平台 | + +--- + +## 6. 接口契约预览 + +> 详细接口定义在 DevelopmentPlan 中,此处仅列出关键接口 + +| 功能 | 接口类型 | 简要说明 | +|------|----------|----------| +| F-001 | API (GET) | `GET /api/tikhub/[platform]` — 获取指定平台热榜内容 | +| F-004 | API (GET) | `GET /api/tikhub/[platform]/detail?id=xxx` — 获取内容详情 | +| F-005 | Event | TanStack Query `refetchInterval` 定时触发 | +| F-006 | Event | 用户点击 → `queryClient.invalidateQueries()` | +| F-007 | Store | Zustand `useFavoritesStore.addFavorite(item)` | +| F-009 | Storage | Zustand persist → localStorage | +| F-010 | API (POST) | `POST /api/settings` — 保存 API Key | +| F-014 | API (Proxy) | Next.js API Route → TikHub API(Bearer Token) | +| F-016 | Module | `PlatformAdapter.fetchTrending(platform)` → `ContentItem[]` | + +| F-017 | API (GET) | `GET /api/stats` — 获取当日 API 调用次数及成本估算 | + + +--- + +## 附录:用户场景映射 + +| ID | 场景 | 描述 | 关联功能 | +|----|------|------|----------| +| US-001 | 创意灵感获取 | 每天浏览各平台热点趋势,发现创意表达和内容形式 | F-001~F-006 | +| US-002 | 竞品/行业监控 | 跟踪特定领域在各平台的热门内容表现 | F-001, F-003, F-005, F-006, F-012 | +| US-003 | 数据分析研究 | 对比同类内容在不同平台的数据差异 | F-001, F-003, F-004, F-013 | +| US-004 | 内容搬运/分发 | 发现优质内容后进行跨平台二次创作 | F-002, F-004, F-007, F-008 | diff --git a/doc/PRD.md b/doc/PRD.md new file mode 100644 index 0000000..1e83841 --- /dev/null +++ b/doc/PRD.md @@ -0,0 +1,350 @@ +# Muse Creative Hotspots — 产品需求文档 (PRD) + +## 1. 产品概述 + +### 1.1 产品名称 +Muse Creative Hotspots(秒思创意热点) + +### 1.2 产品定位 +面向个人创意工作者的**全平台热点内容聚合浏览器**,一站式查看 17 个主流社交媒体平台的热门内容及数据表现。 + +### 1.3 目标用户 +个人独立使用者(创意工作者/内容创作者/自媒体从业者) + +### 1.4 核心价值 +- **效率提升**:告别逐个打开 17 个平台 APP/网站的低效模式 +- **全局视野**:跨平台热点趋势一目了然 +- **数据洞察**:热门内容的关键数据指标集中展示、可排序对比 +- **灵感沉淀**:收藏感兴趣的内容,构建个人灵感库 + +### 1.5 核心使用场景 +| 场景 | 描述 | +|------|------| +| 创意灵感获取 | 每天浏览各平台热点趋势,发现创意表达和内容形式 | +| 竞品/行业监控 | 跟踪特定领域在各平台的热门内容表现 | +| 数据分析研究 | 对比同类内容在不同平台的数据差异 | +| 内容搬运/分发 | 发现优质内容后进行跨平台二次创作 | + +--- + +## 2. 平台覆盖范围 + +### 2.1 支持平台清单(共 17 个) + +| 分类 | 平台 | 内容类型 | 优先级 | +|------|------|----------|--------| +| 国内短视频 | 抖音 | 短视频 | **MVP** | +| 国际短视频 | TikTok | 短视频 | **MVP** | +| 国内图文 | 小红书 | 图文+短视频 | **MVP** | +| 国际视频 | YouTube | 中长视频 | P1 | +| 国际图文 | Instagram | 图文+Reels | P1 | +| 国际社交 | Twitter/X | 文字+图文 | P1 | +| 国内视频 | 哔哩哔哩 | 中长视频 | P1 | +| 国内社交 | 微博 | 文字+图文+视频 | P1 | +| 国际社交 | Threads | 文字+图文 | P2 | +| 国际社交 | Reddit | 文字+图文+视频 | P2 | +| 国际职场 | 领英 (LinkedIn) | 文字+图文 | P2 | +| 国内问答 | 知乎 | 文字+图文 | P2 | +| 国际图文 | Lemon8 | 图文 | P2 | +| 国内短视频 | 快手 | 短视频 | P2 | +| 国内社交 | 微信 (公众号+视频号) | 图文+短视频 | P2 | +| 国内趣味 | 皮皮虾 | 短视频+图文 | P2 | +| AI 视频 | Sora | AI 生成视频 | P2 | + +### 2.2 开发节奏 +- **MVP 阶段**:抖音 + TikTok + 小红书(3 个平台) +- **P1 阶段**:YouTube + Instagram + Twitter/X + 哔哩哔哩 + 微博(5 个平台) +- **P2 阶段**:其余 9 个平台 + +--- + +## 3. 功能需求 + +### 3.1 核心功能:热点内容聚合浏览 + +#### 3.1.1 内容获取 +- 获取各平台**官方热榜/推荐内容**(如抖音热榜、微博热搜等) +- 每个平台展示热榜 Top N 条内容(默认 20-50 条) +- 数据刷新策略:**自动定时刷新(默认 30 分钟)+ 手动刷新按钮** + +#### 3.1.2 内容展示 — 卡片信息流 +- 采用**瀑布流/网格卡片**布局(类似小红书/Pinterest) +- 每张卡片包含: + - 封面图/视频缩略图 + - 内容标题/描述(截断展示) + - 来源平台标识(图标+名称) + - 关键数据指标(播放量/浏览量、点赞数、评论数、分享数) + - 发布时间 + - 作者头像+昵称 +- 卡片支持 hover 预览更多信息 + +#### 3.1.3 内容筛选与排序 +- **按平台切换**:顶部 Tab 栏或侧边导航切换不同平台,支持"全部"聚合视图 +- **按数据指标排序**:按播放量/浏览量、点赞数、评论数、发布时间等排序 +- 支持切换排序方向(升序/降序) + +#### 3.1.4 内容详情页 +- 点击卡片进入**站内详情页**,展示: + - 完整的内容信息(标题、描述、标签等) + - 完整的数据指标面板 + - 作者信息 + - "查看原文"按钮(跳转原平台页面) + - 收藏按钮 + +### 3.2 辅助功能 + +#### 3.2.1 收藏/书签 +- 支持将任意内容卡片添加到收藏夹 +- 收藏夹独立页面查看 +- 收藏数据持久化存储(本地存储/后端数据库) + +#### 3.2.2 设置页面 +- TikHub API Key 配置 +- 自动刷新间隔设置 +- 每个平台的启用/禁用开关 +- 每个平台默认展示数量设置 + +--- + +## 4. 数据需求 + +### 4.1 数据源 +- **API 提供商**:TikHub (https://www.tikhub.io) +- **认证方式**:Bearer Token(已有 API Key) +- **调用限制**:10 请求/秒 +- **计费方式**:$0.001/请求 + +### 4.2 各平台核心 API 端点 + +#### MVP 平台 + +##### 抖音 +| 功能 | 端点 | +|------|------| +| 热搜榜 | `/api/v1/douyin/web/fetch_hot_search_result` | +| 内容详情 | `/api/v1/douyin/web/fetch_one_video` | +| 用户信息 | `/api/v1/douyin/web/fetch_user_profile` | + +##### TikTok +| 功能 | 端点 | +|------|------| +| 趋势内容 | `/api/v1/tiktok/web/fetch_trending_post` | +| 探索内容 | `/api/v1/tiktok/web/fetch_explore_post` | +| 内容详情 | `/api/v1/tiktok/web/fetch_post_detail` | + +##### 小红书 +| 功能 | 端点 | +|------|------| +| 推荐内容 | `/api/v1/xiaohongshu/app/v2/fetch_feed` (App V2 API) | +| 内容详情 | `/api/v1/xiaohongshu/app/v2/fetch_note_detail` | +| 用户信息 | `/api/v1/xiaohongshu/app/v2/fetch_user_info` | + +#### P1 平台 + +> 以下端点来自 TikHub API 文档,实现前建议在 TikHub 控制台确认最新版本。 + +##### YouTube +| 功能 | 端点 | +|------|------| +| 趋势视频 | `/api/v1/youtube/web/fetch_trending_video` | +| 视频详情 | `/api/v1/youtube/web/fetch_video_detail` | + +**字段映射**: `videoId` → `id`, `title` → `title`, `thumbnails.high.url` → `cover_url`, `contentDetails.videoId` → `video_url` (需拼接播放页 URL), `statistics.viewCount` → `play_count`, `statistics.likeCount` → `like_count`, `statistics.commentCount` → `comment_count`, `snippet.publishedAt` → `publish_time` + +**内容类型**: 中长视频,封面比例 16:9 + +##### Instagram +| 功能 | 端点 | +|------|------| +| 探索内容 | `/api/v1/instagram/web/fetch_explore_feed` | +| 内容详情 | `/api/v1/instagram/web/fetch_post_detail` | + +**字段映射**: `code` → `id`, `caption.text` → `title`, `image_versions2.candidates[0].url` / `thumbnail_url` → `cover_url`, `video_url` → `video_url` (Reels 有值,图文为 undefined), `user.username` → `author_name`, `like_count` → `like_count`, `comment_count` → `comment_count`, `taken_at` → `publish_time` + +**内容类型**: 图文 (3:4) + Reels (9:16),通过是否有 `video_url` 区分 + +##### Twitter/X +| 功能 | 端点 | +|------|------| +| 热搜话题 | `/api/v1/twitter/web/fetch_trending_topics` | +| 推文详情 | `/api/v1/twitter/web/fetch_tweet_detail` | + +**字段映射**: `id_str` → `id`, `full_text` → `title`, `entities.media[0].media_url_https` → `cover_url` (纯文字推文为 undefined), `user.name` → `author_name`, `user.profile_image_url_https` → `author_avatar`, `favorite_count` → `like_count`, `reply_count` → `comment_count`, `retweet_count` → `share_count`, `created_at` → `publish_time` + +**内容类型**: 以文字卡片为主(无封面图时使用文字卡片样式) + +##### 哔哩哔哩 +| 功能 | 端点 | +|------|------| +| 热门视频 | `/api/v1/bilibili/web/fetch_popular_video_list` | +| 视频详情 | `/api/v1/bilibili/web/fetch_video_detail` | + +**字段映射**: `bvid` → `id`, `title` → `title`, `pic` → `cover_url`, `owner.name` → `author_name`, `owner.face` → `author_avatar`, `stat.view` → `play_count`, `stat.like` → `like_count`, `stat.reply` → `comment_count`, `stat.share` → `share_count`, `pubdate` (Unix 时间戳) → `publish_time` + +**内容类型**: 中长视频,封面比例 16:9 + +##### 微博 +| 功能 | 端点 | +|------|------| +| 热搜榜 | `/api/v1/weibo/app/fetch_hot_search` | +| 微博详情 | `/api/v1/weibo/app/fetch_post_detail` | + +**字段映射**: `id` → `id`, `text` (去 HTML 标签) → `title`, `pic_ids[0]` 拼接图片 URL → `cover_url` (无图时为 undefined), `user.screen_name` → `author_name`, `user.avatar_hd` → `author_avatar`, `attitudes_count` → `like_count`, `comments_count` → `comment_count`, `reposts_count` → `share_count`, `created_at` → `publish_time` + +**内容类型**: 文字+图文,热搜词条无封面图时展示文字卡片 + +#### P2 平台 + +> 以下平台的 API 端点待根据 TikHub 最新文档确认,适配器实现时以实际接口为准。 + +| 平台 | 推测热榜端点 | 推测详情端点 | +|------|-------------|-------------| +| Threads | `/api/v1/threads/web/fetch_trending_post` | `/api/v1/threads/web/fetch_post_detail` | +| Reddit | `/api/v1/reddit/web/fetch_hot_post` | `/api/v1/reddit/web/fetch_post_detail` | +| LinkedIn | `/api/v1/linkedin/web/fetch_trending_post` | `/api/v1/linkedin/web/fetch_post_detail` | +| 知乎 | `/api/v1/zhihu/app/fetch_hot_question` | `/api/v1/zhihu/app/fetch_question_detail` | +| Lemon8 | `/api/v1/lemon8/web/fetch_trending_post` | `/api/v1/lemon8/web/fetch_post_detail` | +| 快手 | `/api/v1/kuaishou/app/fetch_hot_video` | `/api/v1/kuaishou/app/fetch_video_detail` | +| 微信 | `/api/v1/wechat/mp/fetch_hot_article` | `/api/v1/wechat/mp/fetch_article_detail` | +| 皮皮虾 | `/api/v1/pipix/app/fetch_hot_video` | `/api/v1/pipix/app/fetch_video_detail` | +| Sora | `/api/v1/sora/web/fetch_featured_video` | `/api/v1/sora/web/fetch_video_detail` | + +### 4.3 每条内容需采集的数据字段 + +| 字段 | 说明 | 展示位置 | +|------|------|----------| +| title | 标题/描述 | 卡片+详情页 | +| cover_url | 封面图 URL | 卡片 | +| video_url | 视频播放地址 | 详情页 | +| author_name | 作者昵称 | 卡片+详情页 | +| author_avatar | 作者头像 | 卡片+详情页 | +| play_count | 播放量/浏览量 | 卡片+详情页 | +| like_count | 点赞数 | 卡片+详情页 | +| comment_count | 评论数 | 卡片+详情页 | +| share_count | 分享/转发数 | 详情页 | +| publish_time | 发布时间 | 卡片+详情页 | +| platform | 来源平台 | 卡片+详情页 | +| original_url | 原文链接 | 详情页 | +| tags | 标签/话题 | 详情页 | + +--- + +## 5. 技术方案(推荐) + +### 5.1 技术栈选择 + +| 层级 | 技术 | 理由 | +|------|------|------| +| 框架 | **Next.js 14+ (App Router)** | 全栈能力、API Routes 做后端代理、SSR 支持、Vercel 一键部署 | +| UI 库 | **Tailwind CSS + shadcn/ui** | 简约现代风格、组件丰富、高度可定制 | +| 状态管理 | **Zustand** | 轻量、简洁、适合中小型项目 | +| 数据请求 | **TanStack Query (React Query)** | 缓存管理、自动刷新、loading/error 状态 | +| 本地存储 | **localStorage / IndexedDB** | 收藏数据持久化(MVP 阶段无需数据库) | +| 包管理器 | **pnpm** | 速度快、磁盘占用小 | + +### 5.2 项目架构 + +``` +muse_creative_hotspots/ +├── src/ +│ ├── app/ # Next.js App Router +│ │ ├── layout.tsx # 全局布局 +│ │ ├── page.tsx # 首页(热点内容流) +│ │ ├── detail/[id]/ # 内容详情页 +│ │ ├── favorites/ # 收藏页 +│ │ ├── settings/ # 设置页 +│ │ └── api/ # API Routes(代理 TikHub) +│ │ └── tikhub/ +│ │ └── [platform]/route.ts +│ ├── components/ # UI 组件 +│ │ ├── layout/ # 布局组件(Header, Sidebar, etc.) +│ │ ├── card/ # 内容卡片组件 +│ │ └── ui/ # shadcn/ui 基础组件 +│ ├── lib/ # 工具库 +│ │ ├── tikhub.ts # TikHub API 封装 +│ │ ├── platforms.ts # 平台配置与适配器 +│ │ └── utils.ts # 通用工具函数 +│ ├── stores/ # Zustand 状态管理 +│ │ ├── favorites.ts # 收藏状态 +│ │ └── settings.ts # 设置状态 +│ └── types/ # TypeScript 类型定义 +│ └── content.ts # 统一内容数据结构 +├── public/ # 静态资源 +├── tailwind.config.ts +├── next.config.ts +├── package.json +└── PRD.md +``` + +### 5.3 关键设计决策 + +1. **API 代理层**:通过 Next.js API Routes 代理 TikHub 请求,避免前端暴露 API Key +2. **统一数据模型**:所有平台的内容映射为统一的 `ContentItem` 类型,平台差异在适配器层处理 +3. **平台适配器模式**:每个平台实现一个适配器,负责 API 调用和数据格式转换,方便后续扩展新平台 + +--- + +## 6. 页面设计规格 + +### 6.1 设计风格 +- **简约现代**(参考 Notion/Linear 风格) +- 大量留白,信息层次清晰 +- 浅色主题为主,后期可扩展暗色模式 +- 平台图标采用各平台官方 logo 配色,便于快速识别 + +### 6.2 页面结构 + +``` +┌─────────────────────────────────────────────┐ +│ Logo [全部][抖音][TikTok][小红书]... ⚙️ │ ← 顶部导航:平台 Tab 切换 +├─────────────────────────────────────────────┤ +│ 排序: [最热] [最新] 🔄 刷新 上次: 10:30 │ ← 工具栏:排序+刷新 +├─────────────────────────────────────────────┤ +│ │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │封面│ │封面│ │封面│ │封面│ │封面│ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │标题│ │标题│ │标题│ │标题│ │标题│ │ ← 卡片网格信息流 +│ │数据│ │数据│ │数据│ │数据│ │数据│ │ +│ └────┘ └────┘ └────┘ └────┘ └────┘ │ +│ │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │ .. │ │ .. │ │ .. │ │ .. │ │ .. │ │ +│ └────┘ └────┘ └────┘ └────┘ └────┘ │ +│ │ +└─────────────────────────────────────────────┘ +``` + +--- + +## 7. 非功能需求 + +| 项目 | 要求 | +|------|------| +| 部署方式 | 先本地开发(localhost),后期部署到 Vercel 或云服务器 | +| 响应式 | 桌面端优先,基本适配平板 | +| 性能 | 首屏加载 < 3s,支持图片懒加载 | +| API 成本控制 | 默认 30 分钟刷新一次,支持手动调整;需展示当日 API 调用量 | + +--- + +## 8. MVP 验收标准 + +- [ ] 成功接入抖音、TikTok、小红书三个平台的热榜/推荐内容 +- [ ] 卡片流展示内容,包含封面图、标题、关键数据 +- [ ] 可按平台 Tab 切换查看 +- [ ] 可按播放量/点赞数排序 +- [ ] 点击卡片进入站内详情页,展示完整数据+原文链接 +- [ ] 收藏功能可用,收藏数据本地持久化 +- [ ] 支持手动刷新 + 自动定时刷新 +- [ ] 设置页可配置 API Key 和刷新间隔 + +--- + +## 验证方式 + +1. `pnpm dev` 启动本地开发服务器 +2. 访问首页,确认三个平台的热点内容正常加载和展示 +3. 测试平台切换、排序、收藏、详情页等功能 +4. 检查 API 代理层是否正确隐藏了 API Key +5. 测试自动刷新和手动刷新功能 diff --git a/doc/UIDesign.md b/doc/UIDesign.md new file mode 100644 index 0000000..a363b4a --- /dev/null +++ b/doc/UIDesign.md @@ -0,0 +1,995 @@ +# Muse Creative Hotspots — UI 设计文档 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v1.0 | +| 创建日期 | 2026-03-02 | +| 来源文档 | DevelopmentPlan.md, FeatureSummary.md, PRD.md | + +## 1. 设计概述 + +### 1.1 设计原则 + +| 原则 | 说明 | +|------|------| +| 简约现代 | 参考 Notion/Linear 风格,大量留白,信息层次清晰 | +| 内容优先 | 卡片封面图和数据指标是核心,UI 元素不喧宾夺主 | +| 平台可识别 | 平台图标采用官方配色,用户可快速辨别内容来源 | +| 状态完备 | 每个页面覆盖默认、加载中、空状态、错误四种状态 | +| 响应式 | 桌面端优先,CSS Grid 自适应屏幕宽度 | + +### 1.2 页面总览 + +| 页面ID | 页面名称 | 路由 | 描述 | 对应功能 | 优先级 | +|--------|----------|------|------|----------|--------| +| P-001 | 首页 | `/` | 热点内容卡片信息流,含平台 Tab、排序、刷新 | F-001, F-002, F-003, F-005, F-006 | P0 | +| P-002 | 详情页 | `/detail/[platform]/[id]` | 单条内容完整信息展示 | F-004, F-007 | P0 | +| P-003 | 收藏夹 | `/favorites` | 已收藏内容的网格展示与管理 | F-007, F-008, F-009 | P0 | +| P-004 | 设置页 | `/settings` | API Key 配置、刷新间隔设置 | F-010, F-011 | P0 | + +### 1.3 页面导航图 + +``` + ┌──────────────────┐ + │ P-001 │ + │ 首页 │ + │ (默认入口页面) │ + └───────┬──────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ P-002 │ │ P-003 │ │ P-004 │ + │ 详情页 │ │ 收藏夹 │ │ 设置页 │ + │ 点击卡片进入 │ │ Header导航进入 │ │ Header ⚙ 进入 │ + └────────┬───────┘ └────────┬───────┘ └────────────────┘ + │ │ + │ ┌─────────────┘ + │ │ 点击收藏卡片 + ▼ ▼ + ┌────────────────┐ + │ P-002 │ + │ 详情页(复用) │ + └────────────────┘ +``` + +**导航说明**: +- Header 常驻所有页面,提供全局导航(Logo 回首页、收藏夹入口、设置入口) +- 首页 → 详情页:点击任意内容卡片 +- 首页 → 收藏夹:点击 Header 收藏入口 +- 首页 → 设置页:点击 Header ⚙ 图标 +- 收藏夹 → 详情页:点击收藏的内容卡片 +- 详情页 → 返回上一页:浏览器 Back / 返回按钮 + +--- + +## 2. 页面设计 + +### 2.1 P-001: 首页 + +**页面信息** + +| 属性 | 值 | +|------|-----| +| 页面ID | P-001 | +| 路由 | `/` | +| 对应功能 | F-001, F-002, F-003, F-005, F-006 | +| 入口 | 应用默认页面;Header Logo 点击 | +| 出口 | P-002(点击卡片)、P-003(Header 收藏)、P-004(Header 设置) | + +**页面布局 — ASCII 原型图** + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Header │ │ +│ │ │ │ +│ │ Muse [全部] [抖音] [TikTok] [小红书] [♡ 收藏] [⚙] │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Toolbar │ │ +│ │ │ │ +│ │ 排序: [▼ 播放量] [↓ 降序] [🔄 刷新] 上次: 10:30 │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ +│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ 📱 抖音 │ │ 🎵 TikTok │ │ 📕 小红书 │ │ +│ │ 标题文字截断展 │ │ Title text tr.. │ │ 标题文字截断展 │ │ +│ │ 示最多两行... │ │ uncated to two.. │ │ 示最多两行... │ │ +│ │ │ │ │ │ │ │ +│ │ 👤 作者昵称 │ │ 👤 Author Name │ │ 👤 作者昵称 │ │ +│ │ ▶1.2M ❤5.3K │ │ ▶800K ❤3.1K │ │ ❤2.1K 💬156 │ │ +│ │ 💬 203 [♡] │ │ 💬 150 [♡] │ │ [♡] │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ +│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │ +│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │ +│ │ 📱 抖音 │ │ 📕 小红书 │ │ 🎵 TikTok │ │ +│ │ 标题文字... │ │ 标题文字... │ │ Title text... │ │ +│ │ 👤 作者 ▶ ❤ 💬 │ │ 👤 作者 ❤ 💬 │ │ 👤 Author ▶ ❤ │ │ +│ │ [♡] │ │ [♡] │ │ [♡] │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ... 更多卡片 ... │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**组件清单** + +| 组件ID | 组件名称 | 类型 | 说明 | 交互 | +|--------|----------|------|------|------| +| C-001 | Header | 导航栏 | 顶部固定,含 Logo、平台 Tab、收藏入口、设置入口 | Logo 回首页;Tab 切换平台 | +| C-002 | PlatformTabs | Tab 栏 | Header 内嵌,展示"全部"+各平台 Tab | 点击切换平台,触发数据重新获取 | +| C-003 | SortToolbar | 工具栏 | 排序选择器 + 排序方向 + 刷新按钮 + 上次刷新时间 | 选择排序字段/方向;点击刷新 | +| C-004 | ContentCard | 卡片 | 单条内容:封面图、平台标识、标题、作者、数据指标、收藏按钮 | 点击进入详情页;点击 ♡ 收藏 | +| C-005 | ContentGrid | 网格容器 | CSS Grid 响应式网格 `repeat(auto-fill, minmax(280px, 1fr))` | - | +| C-006 | CardSkeleton | 骨架屏 | 数据加载时的占位动画 | - | +| C-008 | FavoriteButton | 收藏按钮 | 卡片右下角,♡ 空心/♥ 实心切换 | 点击切换收藏状态 | + +**交互说明** + +| 触发 | 动作 | 结果 | +|------|------|------| +| 点击平台 Tab | 切换 queryKey | 重新获取对应平台数据,"全部"聚合所有平台 | +| 选择排序字段/方向 | 前端内存排序(useMemo) | 卡片网格重新排列,不请求 API | +| 点击刷新按钮 | invalidateQueries + 重置自动刷新计时器 | 按钮显示 loading 旋转,完成后更新"上次刷新"时间 | +| 点击卡片(非收藏按钮区域) | 路由跳转 | 进入 `/detail/[platform]/[id]` 详情页 | +| 点击卡片收藏按钮 ♡ | Zustand addFavorite/removeFavorite | ♡ ↔ ♥ 状态切换,数据持久化到 localStorage | +| 自动定时刷新 | refetchInterval 触发 | 静默刷新数据,更新"上次刷新"时间 | +| 页面不可见(切换 Tab) | 暂停自动刷新 | 节省 API 调用 | + +**页面状态** + +| 状态 | 说明 | 展示 | +|------|------|------| +| 默认 | 数据加载完成 | 卡片网格正常展示 | +| 加载中 | 首次加载或切换平台 | 骨架屏(CardSkeleton x N) | +| 刷新中 | 手动/自动刷新 | 刷新按钮旋转,保留当前卡片 | +| 空状态 | 平台无数据返回 | 空状态组件 + "暂无热点内容" | +| 错误 — API Key 未配置 | 未设置 API Key | 引导提示 + "去配置" 按钮跳转设置页 | +| 错误 — 请求失败 | 网络/服务端错误 | 错误提示 + "重试" 按钮 | +| 错误 — 频率超限 | 429 响应 | 提示"请求过于频繁,请稍后重试" | + +**加载态原型** + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░│ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +│ ░░░░░░ │ │ ░░░░░░ │ │ ░░░░░░ │ +│ ░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░ │ │ ░░░░░░░░░░ │ │ ░░░░░░░░░░ │ +│ ░░░░ ░░░░ ░░░░ │ │ ░░░░ ░░░░ ░░░░ │ │ ░░░░ ░░░░ ░░░░ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +**空状态原型** + +``` +┌──────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────┐ │ +│ │ 📭 │ │ +│ └──────────┘ │ +│ │ +│ 暂无热点内容 │ +│ 当前平台暂时没有热门内容 │ +│ │ +│ [🔄 刷新试试] │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +**API Key 未配置引导原型** + +``` +┌──────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────┐ │ +│ │ 🔑 │ │ +│ └──────────┘ │ +│ │ +│ 请先配置 API Key │ +│ 需要配置 TikHub API Key 才能获取内容 │ +│ │ +│ [⚙ 前往设置] │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +### 2.2 P-002: 详情页 + +**页面信息** + +| 属性 | 值 | +|------|-----| +| 页面ID | P-002 | +| 路由 | `/detail/[platform]/[id]` | +| 对应功能 | F-004, F-007 | +| 入口 | P-001(点击卡片)、P-003(点击收藏卡片) | +| 出口 | 返回上一页;外部跳转(查看原文) | + +**页面布局 — ASCII 原型图** + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Header(同首页 C-001) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ [← 返回] [♡ 收藏] │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────┐ ┌──────────────┐ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ 平台信息 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ 封面图 │ │ 📱 抖音 │ │ │ +│ │ │ (大图展示) │ │ │ │ │ +│ │ │ │ │ 发布时间 │ │ │ +│ │ │ │ │ 2026-03-01 │ │ │ +│ │ │ │ │ 14:30 │ │ │ +│ │ │ │ │ │ │ │ +│ │ └──────────────────────────────────────────┘ └──────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 内容标题(完整展示,不截断) │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 作者信息 │ │ │ +│ │ │ ┌────┐ │ │ │ +│ │ │ │头像│ 作者昵称 │ │ │ +│ │ │ └────┘ │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 数据指标面板 │ │ │ +│ │ │ │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ +│ │ │ │ ▶ 播放量 │ │ ❤ 点赞 │ │ 💬 评论 │ │ ↗ 分享 │ │ │ │ +│ │ │ │ 1,234,567│ │ 53,210 │ │ 2,031 │ │ 8,456 │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 标签 │ │ │ +│ │ │ [#热门话题] [#创意] [#内容创作] │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ [🔗 查看原文] │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**组件清单** + +| 组件ID | 组件名称 | 类型 | 说明 | 交互 | +|--------|----------|------|------|------| +| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 | +| C-007 | DetailPanel | 信息面板 | 封面大图 + 标题 + 作者 + 数据指标 + 标签 | - | +| C-008 | FavoriteButton | 收藏按钮 | 右上角,大号 ♡/♥ | 点击切换收藏 | + +**交互说明** + +| 触发 | 动作 | 结果 | +|------|------|------| +| 点击 [← 返回] | 路由 back | 返回上一页面(首页或收藏夹) | +| 点击 [♡ 收藏] | Zustand toggle | 收藏状态切换 | +| 点击 [🔗 查看原文] | window.open | 新窗口打开原平台页面 | +| 封面图加载失败 | 显示占位图 | 灰色占位 + 平台图标 | + +**页面状态** + +| 状态 | 说明 | 展示 | +|------|------|------| +| 默认 | 详情数据加载完成 | 完整信息面板 | +| 加载中 | 请求详情 API | 骨架屏(大图区域 + 文字行) | +| 错误 | 详情加载失败 | 错误提示 + "重试" + "返回首页" 按钮 | + + +**错误态原型** + +``` +┌────────────────────────────────────────────────────────────┐ +│ [← 返回] │ +│ │ +│ │ +│ ┌──────────┐ │ +│ │ ⚠️ │ │ +│ └──────────┘ │ +│ │ +│ 内容加载失败 │ +│ 请检查网络连接或稍后重试 │ +│ │ +│ [🔄 重试] [🏠 返回首页] │ +│ │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + + +**加载态原型** + +``` +┌────────────────────────────────────────────────────────────┐ +│ [← 返回] │ +│ │ +│ ┌──────────────────────────────────┐ ┌──────────────┐ │ +│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ └──────────────┘ │ +│ └──────────────────────────────────┘ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░ ░░░░░░░░░░░ │ +│ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ │ +│ ░░░░░░░░░ ░░░░░░ ░░░░░░░ │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +### 2.3 P-003: 收藏夹页面 + +**页面信息** + +| 属性 | 值 | +|------|-----| +| 页面ID | P-003 | +| 路由 | `/favorites` | +| 对应功能 | F-007, F-008, F-009 | +| 入口 | Header 收藏入口 | +| 出口 | P-002(点击卡片);Header 导航至其他页面 | + +**页面布局 — ASCII 原型图** + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Header(同首页 C-001) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 我的收藏 共 12 条 │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ +│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ │ 封面图 │ │ │ +│ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │ +│ │ 📱 抖音 │ │ 🎵 TikTok │ │ 📕 小红书 │ │ +│ │ 标题文字... │ │ Title text... │ │ 标题文字... │ │ +│ │ 👤 作者 │ │ 👤 Author │ │ 👤 作者 │ │ +│ │ ▶1.2M ❤5.3K │ │ ▶800K ❤3.1K │ │ ❤2.1K 💬156 │ │ +│ │ [♥] │ │ [♥] │ │ [♥] │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ +│ │ │ 封面图 │ │ │ │ 封面图 │ │ │ +│ │ └─────────────┘ │ │ └─────────────┘ │ │ +│ │ ... │ │ ... │ │ +│ │ [♥] │ │ [♥] │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**组件清单** + +| 组件ID | 组件名称 | 类型 | 说明 | 交互 | +|--------|----------|------|------|------| +| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 | +| C-005 | ContentGrid | 网格容器 | 复用首页网格布局 | - | +| C-004 | ContentCard | 卡片 | 复用首页卡片,收藏按钮为实心 ♥ | 点击进入详情;点击 ♥ 取消收藏 | + +**交互说明** + +| 触发 | 动作 | 结果 | +|------|------|------| +| 点击卡片 | 路由跳转 | 进入 `/detail/[platform]/[id]` | +| 点击 ♥ 取消收藏 | Zustand removeFavorite | 卡片从列表移除 | + +**页面状态** + +| 状态 | 说明 | 展示 | +|------|------|------| +| 默认 | 有收藏内容 | 卡片网格展示 | +| 空状态 | 无收藏内容 | 空状态引导 | + +**空状态原型** + +``` +┌──────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────┐ │ +│ │ ♡ │ │ +│ └──────────┘ │ +│ │ +│ 还没有收藏内容 │ +│ 浏览热点内容时,点击 ♡ 收藏感兴趣的内容 │ +│ │ +│ [去浏览热点] │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +### 2.4 P-004: 设置页面 + +**页面信息** + +| 属性 | 值 | +|------|-----| +| 页面ID | P-004 | +| 路由 | `/settings` | +| 对应功能 | F-010, F-011 | +| 入口 | Header ⚙ 图标 | +| 出口 | Header 导航至其他页面 | + +**页面布局 — ASCII 原型图** + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Header(同首页 C-001) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 设置 │ │ +│ │ │ │ +│ │ ─────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ API 配置 │ │ +│ │ │ │ +│ │ TikHub API Key │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ sk-xxxxxxxxxxxxxxxxxxxx │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ API Key 保存在服务端,不会暴露到浏览器。 │ │ +│ │ 优先使用 .env.local 中的预配置值。 │ │ +│ │ │ │ +│ │ [保存 API Key] │ │ +│ │ │ │ +│ │ ─────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 刷新设置 │ │ +│ │ │ │ +│ │ 自动刷新间隔 │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ 30 分钟 ▼ │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ 可选: 5 / 10 / 15 / 30 / 60 分钟 │ │ +│ │ 最低 5 分钟,防止 API 成本过高。 │ │ +│ │ │ │ +│ │ ─────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 关于 │ │ +│ │ │ │ +│ │ Muse Creative Hotspots v1.0 │ │ +│ │ 数据来源: TikHub API (api.tikhub.io) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**组件清单** + +| 组件ID | 组件名称 | 类型 | 说明 | 交互 | +|--------|----------|------|------|------| +| C-001 | Header | 导航栏 | 复用全局 Header | 同首页 | +| C-011 | ApiKeyInput | 输入框 | password 类型输入框 + 保存按钮 | 输入 Key → 点击保存 | +| C-012 | IntervalSelect | 下拉选择 | 刷新间隔选择器,选项: 5/10/15/30/60 分钟 | 选择后立即生效 | + +**交互说明** + +| 触发 | 动作 | 结果 | +|------|------|------| +| 输入 API Key + 点击保存 | POST /api/settings | 保存到服务端内存变量;成功/失败 Toast 提示 | +| 选择刷新间隔 | Zustand setRefreshInterval | 立即更新 TanStack Query refetchInterval | +| API Key 为空点击保存 | 前端校验 | 输入框红色边框 + "请输入 API Key" 提示 | + +**页面状态** + +| 状态 | 说明 | 展示 | +|------|------|------| +| 默认 | 正常展示设置表单 | 当前 Key(掩码)+ 当前间隔 | +| 保存中 | API Key 保存请求中 | 保存按钮 loading | +| 保存成功 | 保存完成 | Toast "API Key 已保存" | +| 保存失败 | 保存请求失败 | Toast "保存失败,请重试" | + +--- + +## 3. 用户流程 + +### 3.1 内容浏览主流程 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 打开应用 │ ──▶ │ 浏览首页 │ ──▶ │ 筛选/排序 │ ──▶ │ 查看详情 │ +│ │ │ 卡片信息流│ │ 切换平台 │ │ 完整信息 │ +│ P-001 │ │ P-001 │ │ P-001 │ │ P-002 │ +└──────────┘ └────┬─────┘ └──────────┘ └────┬─────┘ + │ │ + │ API Key 未配置 │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ 配置Key │ │ 查看原文 │ + │ P-004 │ │ (外部跳转)│ + └──────────┘ └──────────┘ +``` + +**流程步骤** + +| 步骤 | 页面 | 用户操作 | 系统响应 | +|------|------|----------|----------| +| 1 | P-001 | 打开应用 | 自动获取默认平台(全部)的热点内容 | +| 2 | P-001 | 浏览卡片信息流 | 展示卡片网格(封面图+标题+数据) | +| 3 | P-001 | 点击平台 Tab 切换 | 重新获取对应平台数据,更新卡片网格 | +| 4 | P-001 | 选择排序方式 | 前端内存排序,卡片重新排列 | +| 5 | P-001 | 点击感兴趣的卡片 | 路由跳转到详情页 | +| 6 | P-002 | 查看完整信息 | 展示大图+标题+数据面板+标签 | +| 7 | P-002 | 点击"查看原文" | 新窗口打开原平台页面 | + +### 3.2 收藏管理流程 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 浏览内容 │ ──▶ │ 点击收藏 │ ──▶ │ 打开收藏夹│ ──▶ │ 查看详情 │ +│ P-001 │ │ ♡ → ♥ │ │ P-003 │ │ P-002 │ +└──────────┘ └──────────┘ └──────────┘ └────┬─────┘ + │ │ + │ 也可在详情页收藏 │ + ▼ │ + ┌──────────┐ │ + │ 详情页收藏│ │ + │ P-002 │ ◀────────────────────────────┘ + └──────────┘ +``` + +**流程步骤** + +| 步骤 | 页面 | 用户操作 | 系统响应 | +|------|------|----------|----------| +| 1 | P-001/P-002 | 点击卡片收藏按钮 ♡ | ♡ → ♥ 切换,存入 localStorage | +| 2 | P-003 | 点击 Header 收藏入口 | 展示所有已收藏内容的卡片网格 | +| 3 | P-003 | 点击卡片 | 跳转详情页 | +| 4 | P-003 | 点击 ♥ 取消收藏 | 卡片从收藏列表移除 | + +### 3.3 设置配置流程 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 点击 ⚙ │ ──▶ │ 输入Key │ ──▶ │ 保存Key │ ──▶ │ 设置间隔 │ +│ P-001 │ │ P-004 │ │ P-004 │ │ P-004 │ +└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ 保存成功 │ │ 立即生效 │ + │ Toast提示 │ │ 更新定时器│ + └──────────┘ └──────────┘ +``` + +**流程步骤** + +| 步骤 | 页面 | 用户操作 | 系统响应 | +|------|------|----------|----------| +| 1 | P-001 | 点击 Header ⚙ 图标 | 跳转设置页 | +| 2 | P-004 | 输入 TikHub API Key | 输入框显示内容(password 掩码) | +| 3 | P-004 | 点击"保存 API Key" | POST /api/settings,成功后 Toast 提示 | +| 4 | P-004 | 选择刷新间隔(如 15 分钟) | Zustand 更新,TanStack Query refetchInterval 立即变更 | +| 5 | P-001 | 返回首页 | 使用新 Key 获取数据,按新间隔自动刷新 | + +### 3.4 数据刷新流程 + +``` + ┌─────────────────────────────────────┐ + │ 刷新触发 │ + │ │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ 自动刷新 │ │ 手动刷新 │ │ + │ │ 定时器 │ │ 点击 🔄 │ │ + │ └────┬─────┘ └────┬─────┘ │ + │ │ │ │ + │ └────────┬─────────┘ │ + │ ▼ │ + │ ┌──────────┐ │ + │ │ 获取数据 │ │ + │ │ F-001 │ │ + │ └────┬─────┘ │ + │ │ │ + │ ┌────┴────┐ │ + │ ▼ ▼ │ + │ ┌────────┐ ┌────────┐ │ + │ │ 成功 │ │ 失败 │ │ + │ └───┬────┘ └───┬────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ 更新卡片 │ │ 保留旧数据│ │ + │ │ 更新时间 │ │ 错误提示 │ │ + │ └──────────┘ └──────────┘ │ + │ │ + └─────────────────────────────────────┘ +``` + +--- + +## 4. 组件规范 + +### 4.1 全局组件 + +**C-001: Header 导航栏** + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ Muse [全部] [抖音] [TikTok] [小红书] [♡ 收藏] [⚙] │ +│ ─────── │ +│ (当前Tab下划线高亮) │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + +说明: +- 左侧: Logo 文字 "Muse",点击回首页 +- 中部: 平台 Tab 栏(仅首页展示),当前选中 Tab 有下划线高亮 +- 右侧: 收藏入口 + 设置图标 +- 固定在页面顶部 (sticky top) +- 背景: 白色,底部 1px 边框线 +``` + + +**C-001 变体: 非首页 Header(详情页、收藏夹、设置页使用)** + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ Muse [♡ 收藏] [⚙] │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + +说明: +- 平台 Tab 栏在非首页隐藏,Logo 与右侧图标之间保持留白 +- 其余元素位置不变(Logo 左对齐,图标右对齐) +- 适用于: P-002 详情页、P-003 收藏夹、P-004 设置页 +``` + + +**C-002: PlatformTabs 平台 Tab 栏** + +``` +默认态: [全部] [抖音] [TikTok] [小红书] + ───── + (选中态: 下划线 + 文字加粗) + +各 Tab 含平台图标: + 全部: 🌐 全部 + 抖音: 📱 抖音 (品牌色: #000000) + TikTok: 🎵 TikTok (品牌色: #00F2EA) + 小红书: 📕 小红书 (品牌色: #FF2442) + +Tab 切换时,下划线平滑滑动过渡 +``` + +**C-003: SortToolbar 工具栏** + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ │ +│ 排序: [▼ 播放量] [↓ 降序] [🔄 刷新] 上次: 10:30│ +│ ────────── ──────── ──────── │ +│ 下拉选择 切换按钮 图标按钮 │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + +排序字段选项: + - 播放量 (play_count) + - 点赞数 (like_count) + - 评论数 (comment_count) + - 发布时间 (publish_time) + +排序方向: + - ↓ 降序 (desc) — 默认 + - ↑ 升序 (asc) + +刷新按钮状态: + - 默认: 🔄 图标 + - 刷新中: 旋转动画 + - 防抖: 2 秒内重复点击忽略 +``` + +### 4.2 业务组件 + +**C-004: ContentCard 内容卡片** + +``` +┌─────────────────────┐ +│ ┌─────────────────┐ │ +│ │ │ │ ← 封面图区域 (aspect-ratio: 4/3 或 3/4) +│ │ Cover Image │ │ 加载失败 → 灰色占位 + 平台图标 +│ │ │ │ 使用 Next.js Image + loading="lazy" +│ └─────────────────┘ │ +│ │ +│ 📱 抖音 │ ← 平台图标 + 平台名 (品牌色小标签) +│ │ +│ 标题文字截断展示最 │ ← 标题 (最多 2 行, line-clamp-2) +│ 多两行省略号... │ +│ │ +│ ┌──┐ │ +│ │头│ 作者昵称 │ ← 作者头像 (24px 圆形) + 昵称 +│ └──┘ │ +│ │ +│ ▶ 1.2M ❤ 5.3K │ ← 数据指标 (数字缩写: K/M) +│ 💬 203 [♡] │ ← 评论数 + 收藏按钮 +│ │ +└─────────────────────┘ + +宽度: minmax(280px, 1fr) +圆角: 8px (rounded-lg) +阴影: hover 时提升阴影 +边框: 1px solid border色 +过渡: hover 时 translateY(-2px) + +收藏按钮 [♡]: + 未收藏: ♡ 空心,灰色 + 已收藏: ♥ 实心,红色 + 点击区域: 足够大 (44x44px),防止误触卡片跳转 + +指标展示规则: + 指标值为空(undefined)时隐藏该指标项,而非展示 0。 + 与 DevelopmentPlan ContentItem 类型中 play_count?: number(可选字段)一致。 + +Hover 行为: + MVP 阶段: hover 仅做视觉反馈(阴影提升 + translateY(-2px))。 + v1.1 迭代: 考虑增加 hover tooltip/popover 展示更多信息(如完整标题、分享数)。 +``` + +**C-006: CardSkeleton 骨架屏** + +``` +┌─────────────────────┐ +│ ┌─────────────────┐ │ +│ │░░░░░░░░░░░░░░░░░│ │ ← 封面占位 (pulse 动画) +│ │░░░░░░░░░░░░░░░░░│ │ +│ │░░░░░░░░░░░░░░░░░│ │ +│ └─────────────────┘ │ +│ ░░░░░░░ │ ← 平台标签占位 +│ ░░░░░░░░░░░░░░░░░░ │ ← 标题行占位 +│ ░░░░░░░░░░░░░ │ +│ ░░ ░░░░░░░░░ │ ← 作者占位 +│ ░░░░ ░░░░ ░░░░ │ ← 数据指标占位 +└─────────────────────┘ + +动画: Tailwind animate-pulse +颜色: bg-slate-200 +``` + +**C-008: FavoriteButton 收藏按钮** + +``` +未收藏态: ♡ (text-slate-400, hover: text-red-400) +已收藏态: ♥ (text-red-500) +过渡动画: 点击时 scale 弹跳效果 (scale-110 → scale-100) + +点击区域: 44px x 44px (无障碍最小触摸区域) +阻止冒泡: e.stopPropagation() 防止触发卡片跳转 +``` + +**C-009: EmptyState 空状态** + +``` +┌──────────────────────────────────────┐ +│ │ +│ ┌──────────┐ │ +│ │ {icon} │ │ +│ └──────────┘ │ +│ │ +│ {主要文案} │ ← text-slate-800, 16px, medium +│ {辅助文案} │ ← text-slate-500, 14px, regular +│ │ +│ [{action button}] │ ← 可选操作按钮 +│ │ +└──────────────────────────────────────┘ + +Props: + icon: ReactNode (图标) + title: string (主要文案) + description: string (辅助文案) + action?: { label: string, onClick: () => void } +``` + + +**C-013: Toast 通知** + +``` +成功态 (右上角弹出): +┌──────────────────────────────┐ +│ ✅ API Key 已保存 │ +└──────────────────────────────┘ + +失败态 (右上角弹出): +┌──────────────────────────────┐ +│ ❌ 保存失败,请重试 │ +└──────────────────────────────┘ + +规范: +- 使用 shadcn/ui Toast 组件 +- 位置: 页面右上角 (top-right) +- 自动消失时间: 3 秒 +- 成功态: 绿色左边框 (border-l-4 border-green-500) +- 失败态: 红色左边框 (border-l-4 border-red-500) +- 支持手动关闭 (点击 × 按钮) +- 背景: white,阴影: shadow-lg +- 进入动画: slide-in-from-right +- 退出动画: fade-out +``` + + +**C-010: ErrorState 错误状态** + +``` +┌──────────────────────────────────────┐ +│ │ +│ ┌──────────┐ │ +│ │ ⚠️ │ │ +│ └──────────┘ │ +│ │ +│ {错误描述} │ ← text-red-600 +│ │ +│ [🔄 重试] │ ← 主按钮 +│ │ +└──────────────────────────────────────┘ +``` + +--- + +## 5. 设计规范 + +### 5.1 色彩规范 + +| 用途 | 色值 | Tailwind Class | 示例 | +|------|------|----------------|------| +| 主色 | #2563EB | `blue-600` | 主按钮、链接、选中态 | +| 主色悬停 | #1D4ED8 | `blue-700` | 按钮 hover | +| 成功 | #16A34A | `green-600` | 保存成功提示 | +| 警告 | #D97706 | `amber-600` | 频率限制提示 | +| 错误 | #DC2626 | `red-600` | 错误提示、必填校验 | +| 收藏红 | #EF4444 | `red-500` | ♥ 已收藏状态 | +| 文字主色 | #1E293B | `slate-800` | 标题、正文 | +| 文字次色 | #64748B | `slate-500` | 描述、辅助信息 | +| 文字弱色 | #94A3B8 | `slate-400` | 占位文字、禁用态 | +| 背景色 | #FFFFFF | `white` | 页面背景 | +| 表面色 | #F8FAFC | `slate-50` | 卡片背景、工具栏 | +| 边框色 | #E2E8F0 | `slate-200` | 卡片边框、分割线 | + +**平台品牌色** + +| 平台 | 色值 | 用途 | +|------|------|------| +| 抖音 | #000000 | Tab 标签、平台图标背景 | +| TikTok | #00F2EA | Tab 标签、平台图标背景 | +| 小红书 | #FF2442 | Tab 标签、平台图标背景 | + +### 5.2 字体规范 + +| 用途 | 字号 | 字重 | Tailwind Class | +|------|------|------|----------------| +| 页面标题 | 24px | Bold (700) | `text-2xl font-bold` | +| 区域标题 | 20px | Semibold (600) | `text-xl font-semibold` | +| 卡片标题 | 14px | Medium (500) | `text-sm font-medium` | +| 正文 | 14px | Regular (400) | `text-sm` | +| 数据指标 | 12px | Medium (500) | `text-xs font-medium` | +| 辅助文字 | 12px | Regular (400) | `text-xs` | + +- 字体族: 系统默认字体栈 (Inter, system-ui, sans-serif) + +### 5.3 间距规范 + +| 间距 | 值 | Tailwind | 用途 | +|------|-----|----------|------| +| xs | 4px | `1` | 图标与文字间距 | +| sm | 8px | `2` | 卡片内元素间距 | +| md | 12px | `3` | 卡片内部 padding | +| lg | 16px | `4` | 组件间距、网格 gap | +| xl | 24px | `6` | 区域间距 | +| 2xl | 32px | `8` | 页面 padding | + +### 5.4 圆角规范 + +| 元素 | 圆角 | Tailwind | +|------|------|----------| +| 卡片 | 8px | `rounded-lg` | +| 按钮 | 6px | `rounded-md` | +| 输入框 | 6px | `rounded-md` | +| 头像 | 50% | `rounded-full` | +| 平台标签 | 4px | `rounded` | + +### 5.5 阴影规范 + +| 场景 | 阴影 | Tailwind | +|------|------|----------| +| 卡片默认 | 0 1px 2px rgba(0,0,0,0.05) | `shadow-sm` | +| 卡片悬停 | 0 4px 6px rgba(0,0,0,0.1) | `shadow-md` | +| Header | 0 1px 3px rgba(0,0,0,0.1) | `shadow-sm` | +| 弹窗/Toast | 0 10px 15px rgba(0,0,0,0.1) | `shadow-lg` | + +### 5.6 响应式断点 + +| 断点 | 宽度 | Tailwind | 布局说明 | +|------|------|----------|----------| +| Mobile | < 640px | `sm:` | 单栏,卡片全宽 | +| Tablet | 640px - 1024px | `md:` / `lg:` | 2 列卡片网格 | +| Desktop | 1024px - 1280px | `xl:` | 3 列卡片网格 | +| Wide | > 1280px | `2xl:` | 4-5 列卡片网格 | + +**网格响应式规则**: + +``` +CSS Grid: grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) + +< 640px: 1 列 (卡片宽度 100%) +640-960: 2 列 +960-1240: 3 列 +1240-1520: 4 列 +> 1520: 5 列 +``` + +--- + +## 6. 页面与功能映射 + +| 功能ID | 功能名称 | 所在页面 | 主要组件 | +|--------|----------|----------|----------| +| F-001 | 内容获取 | P-001 | TanStack Query (数据层) | +| F-002 | 卡片信息流展示 | P-001 | C-005 ContentGrid + C-004 ContentCard | +| F-003 | 内容筛选与排序 | P-001 | C-002 PlatformTabs + C-003 SortToolbar | +| F-004 | 内容详情页 | P-002 | C-007 DetailPanel | +| F-005 | 自动定时刷新 | P-001 | C-003 SortToolbar (时间显示) | +| F-006 | 手动刷新 | P-001 | C-003 SortToolbar (刷新按钮) | +| F-007 | 内容收藏 | P-001, P-002 | C-008 FavoriteButton | +| F-008 | 收藏夹管理 | P-003 | C-005 ContentGrid + C-004 ContentCard | +| F-009 | 收藏数据持久化 | P-003 | Zustand persist (数据层) | +| F-010 | API Key 配置 | P-004 | C-011 ApiKeyInput | +| F-011 | 刷新间隔设置 | P-004 | C-012 IntervalSelect | +| F-014 | API 请求代理 | - | 后端 API Route (无 UI) | +| F-015 | 统一数据模型 | - | TypeScript 类型 (无 UI) | +| F-016 | 平台适配器 | - | 后端适配器 (无 UI) | diff --git a/doc/tasks.md b/doc/tasks.md new file mode 100644 index 0000000..beefea2 --- /dev/null +++ b/doc/tasks.md @@ -0,0 +1,148 @@ +# Muse Creative Hotspots — 任务列表 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| 版本 | v2.0 | +| 创建日期 | 2026-03-03 | +| 架构 | 前后端分离 Monorepo(@muse/shared + @muse/backend + @muse/frontend) | + +## 1. 架构概览 + +``` +museCreativeHotspots/ +├── pnpm-workspace.yaml +├── packages/ +│ ├── shared/ # @muse/shared — 共享类型和平台配置 +│ ├── backend/ # @muse/backend — Hono API 服务器 (port 3001) +│ └── frontend/ # @muse/frontend — Next.js 前端 (port 3000) +``` + +--- + +## 2. Phase 1 — 基础架构搭建(已完成 ✅) + +**目标**: 搭建 Monorepo 骨架,共享类型系统,API 代理链路,平台适配器。 + +| ID | 任务 | 包 | 状态 | +|----|------|----|------| +| T-001 | Monorepo 初始化 (pnpm workspace + 三个包) | root | ✅ | +| T-002 | TypeScript 类型定义 (ContentItem, Platform, PlatformAdapter) | @muse/shared | ✅ | +| T-003 | 平台配置 (MVP_PLATFORMS, getPlatformConfig) | @muse/shared | ✅ | +| T-004 | TikHub API 客户端与限流 (tikhubFetch, waitForSlot) | @muse/backend | ✅ | +| T-005 | 平台适配器 — 抖音 | @muse/backend | ✅ | +| T-006 | 平台适配器 — TikTok | @muse/backend | ✅ | +| T-007 | 平台适配器 — 小红书 | @muse/backend | ✅ | +| T-008 | 适配器注册表 (getAdapter, getSupportedPlatforms) | @muse/backend | ✅ | +| T-009 | Hono API 路由 — 热榜 (GET /:platform) | @muse/backend | ✅ | +| T-010 | Hono API 路由 — 详情 (GET /:platform/detail) | @muse/backend | ✅ | +| T-011 | Hono API 路由 — 设置 (GET /, POST /) | @muse/backend | ✅ | + +--- + +## 3. Phase 2 — 核心功能实现(已完成 ✅) + +**目标**: 前端页面、组件、数据查询、状态管理。 + +| ID | 任务 | 包 | 状态 | +|----|------|----|------| +| T-012 | Zustand Store — settings (apiKey, refreshInterval, persist) | @muse/frontend | ✅ | +| T-013 | Zustand Store — favorites (addFavorite, removeFavorite, persist) | @muse/frontend | ✅ | +| T-014 | TanStack Query 集成 (useContentQuery, useDetailQuery) | @muse/frontend | ✅ | +| T-015 | API_BASE_URL 前缀 (所有 fetch 指向 backend 3001) | @muse/frontend | ✅ | +| T-016 | 内容卡片组件 (ContentCard + ContentGrid + CardSkeleton) | @muse/frontend | ✅ | +| T-017 | 平台 Tab 切换 (PlatformTabs) | @muse/frontend | ✅ | +| T-018 | 排序功能 (SortToolbar) | @muse/frontend | ✅ | +| T-019 | 内容详情页 (DetailPanel + DetailSkeleton) | @muse/frontend | ✅ | +| T-020 | 收藏按钮组件 (FavoriteButton) | @muse/frontend | ✅ | +| T-021 | 首页页面组装 (page.tsx) | @muse/frontend | ✅ | +| T-022 | 设置页面 (settings/page.tsx) | @muse/frontend | ✅ | +| T-023 | 收藏夹页面 (favorites/page.tsx) | @muse/frontend | ✅ | +| T-024 | 全局布局 (layout.tsx + Header + QueryProvider) | @muse/frontend | ✅ | + +--- + +## 4. Phase 3 — 测试与验证(已完成 ✅) + +**目标**: 各包独立测试,80% 覆盖率阈值。 + +| ID | 任务 | 包 | 状态 | +|----|------|----|------| +| T-025 | 共享包测试 (platforms.test.ts) | @muse/shared | ✅ | +| T-026 | 后端单元测试 — 限流器、API 客户端 | @muse/backend | ✅ | +| T-027 | 后端单元测试 — 适配器 (douyin, tiktok, xiaohongshu) | @muse/backend | ✅ | +| T-028 | 后端集成测试 — Hono 路由 (app.request 风格) | @muse/backend | ✅ | +| T-029 | 前端单元测试 — stores (favorites, settings) | @muse/frontend | ✅ | +| T-030 | 前端单元测试 — format.ts | @muse/frontend | ✅ | + +--- + +## 5. Phase 4 — 新增平台适配器(已完成 ✅) + +**目标**: 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台的适配器、配置和测试。 + +| ID | 任务 | 包 | 状态 | +|----|------|----|------| +| T-031 | 平台适配器 — YouTube | @muse/backend | ✅ | +| T-032 | 平台适配器 — Instagram | @muse/backend | ✅ | +| T-033 | 平台适配器 — Twitter/X | @muse/backend | ✅ | +| T-034 | 平台适配器 — 哔哩哔哩 | @muse/backend | ✅ | +| T-035 | 平台适配器 — 微博 | @muse/backend | ✅ | +| T-036 | 适配器注册表更新 (8 个平台) | @muse/backend | ✅ | +| T-037 | 共享包平台配置更新 (MVP_PLATFORMS 8 项) | @muse/shared | ✅ | +| T-038 | YouTube 适配器单元测试 | @muse/backend | ✅ | +| T-039 | Instagram 适配器单元测试 | @muse/backend | ✅ | +| T-040 | Twitter/X 适配器单元测试 | @muse/backend | ✅ | +| T-041 | 哔哩哔哩适配器单元测试 | @muse/backend | ✅ | +| T-042 | 微博适配器单元测试 | @muse/backend | ✅ | +| T-043 | E2E Mock 测试更新 (fixtures + home.spec) | e2e/ | ✅ | +| T-044 | E2E 真实测试更新 (新平台切换) | e2e-real/ | ✅ | + +--- + +## 6. 启动方式 + +```bash +# 安装依赖 +pnpm install + +# 同时启动前后端 +pnpm dev + +# 仅启动后端 (port 3001) +pnpm --filter @muse/backend dev + +# 仅启动前端 (port 3000) +pnpm --filter @muse/frontend dev + +# 运行全部测试 +pnpm test + +# 运行单包测试 +pnpm --filter @muse/backend test +pnpm --filter @muse/frontend test +pnpm --filter @muse/shared test +``` + +--- + +## 7. 关键设计决策 + +1. **shared 包不构建**: 直接导出 `.ts` 源文件,消费方各自编译 +2. **app.ts 与 index.ts 分离**: app.ts 只导出 Hono 实例,测试可直接 import +3. **NEXT_PUBLIC_ 前缀**: 前端 API URL 使用此前缀确保客户端可读 +4. **CORS 中间件**: 后端配置 CORS 允许前端跨域调用 +5. **API 路由前缀**: 保持 `/api/tikhub/...` 和 `/api/settings` 路径不变 + +--- + +## 8. 未来计划 (v1.1+) + +| 功能 | 描述 | +|------|------| +| F-012 平台管理 | 前端可启用/禁用平台 | +| F-013 展示数量设置 | 用户自定义每页展示数量 | +| F-017 API 调用量统计 | 后端统计 API 调用次数 | +| Docker 部署 | 容器化部署方案 | +| E2E 测试 | Playwright 端到端测试 | diff --git a/doc/tikhub_api.md b/doc/tikhub_api.md new file mode 100644 index 0000000..699df98 --- /dev/null +++ b/doc/tikhub_api.md @@ -0,0 +1,901 @@ +# TikHub API 参考文档 + +> 版本:V5.2.9(2025-03-08) +> Swagger UI:https://api.tikhub.io +> ReDoc:https://api.tikhub.io/docs +> Apifox 文档:https://docs.tikhub.io + +--- + +## 基础信息 + +| 项目 | 说明 | +|------|------| +| Base URL(国际)| `https://api.tikhub.io` | +| Base URL(中国大陆)| `https://api.tikhub.dev`(路径和参数完全相同,仅域名不同)| +| 认证方式 | `Authorization: Bearer {API_TOKEN}` | +| 计费 | $0.001 / 请求(按量付费,支持批量折扣)| +| 速率限制 | 令牌桶 10 req/s(本项目在 `src/lib/tikhub.ts` 中已实现)| +| 超时 | 15s(本项目已配置)| + +所有端点均为 `GET` 请求(除特别标注),返回 JSON。 + +--- + +## 错误码 + +| HTTP 状态码 | 含义 | +|------------|------| +| 400 | 参数错误 | +| 401 | API Key 无效或未提供 | +| 402 | 余额不足 | +| 403 | 无权限访问该端点 | +| 404 | 内容不存在 | +| 429 | 请求过于频繁 | +| 500 | 服务器内部错误 | + +--- + +## 抖音(Douyin) + +### 抖音网页版 API — `/api/v1/douyin/web/` + +#### ★ `GET /api/v1/douyin/web/fetch_hot_search_result` + +> **本项目使用** · 抖音热搜榜 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构: +```json +{ + "data": { + "data": { + "trending_list": [ + { + "sentence_id": "string", // 热搜词 ID(用作 ContentItem.id) + "word": "string", // 热搜词文本 + "word_cover": { + "url_list": ["string"] // 封面图列表 + }, + "hot_value": 10000, // 热度值(→ like_count) + "view_count": 500000, // 阅读量(→ play_count) + "discuss_video_count": 200, // 讨论视频数(→ comment_count) + "event_time": 1709000000 // Unix 时间戳(秒) + } + ], + "word_list": [/* 同上格式,普通热词 */] + } + } +} +``` + +> **注意**:`trending_list` 为置顶热搜,`word_list` 为普通热词。本项目合并两者并按 `sentence_id` 去重。 + +--- + +#### ★ `GET /api/v1/douyin/web/fetch_one_video` + +> **本项目使用** · 抖音视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `aweme_id` | string | 是 | 视频 ID | + +响应结构: +```json +{ + "data": { + "aweme_detail": { + "aweme_id": "string", + "desc": "string", // 视频标题/描述 + "video": { + "cover": { "url_list": ["string"] }, + "play_addr": { "url_list": ["string"] } + }, + "author": { + "nickname": "string", + "avatar_thumb": { "url_list": ["string"] } + }, + "statistics": { + "play_count": 100000, + "digg_count": 5000, // 点赞数(→ like_count) + "comment_count": 200, + "share_count": 300 + }, + "create_time": 1709000000, + "text_extra": [{ "hashtag_name": "string" }] + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/douyin/web/fetch_user_profile` | 获取用户信息 | `unique_id`(用户名)或 `sec_user_id` | +| `GET /api/v1/douyin/web/fetch_user_post` | 获取用户作品 | `sec_user_id`, `cursor`(分页), `count` | +| `GET /api/v1/douyin/web/fetch_general_search_result` | 综合搜索 | `keyword`, `count`, `offset` | +| `GET /api/v1/douyin/web/fetch_video_search_result` | 视频搜索 | `keyword`, `count`, `offset` | +| `GET /api/v1/douyin/web/fetch_user_search_result_v2` | 用户搜索 | `keyword` | +| `GET /api/v1/douyin/web/fetch_post_comment` | 获取评论 | `aweme_id`, `cursor`, `count` | +| `GET /api/v1/douyin/app/v3/fetch_hot_search_list` | App 热搜榜(App V3 推荐)| 无 | +| `GET /api/v1/douyin/billboard/*` | 各类榜单(热门/音乐/挑战等)| 参见 Swagger | + +--- + +## TikTok + +### TikTok 网页版 API — `/api/v1/tiktok/web/` + +#### ★ `GET /api/v1/tiktok/web/fetch_explore_post` + +> **本项目使用** · TikTok 探索/热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构: +```json +{ + "data": { + "itemList": [ + { + "id": "string", // 视频 ID + "desc": "string", // 视频描述/标题 + "video": { + "cover": "string", // 封面图 URL(直接字符串) + "playAddr": "string" // 播放地址 + }, + "author": { + "nickname": "string", + "avatarThumb": "string", // 头像 URL + "uniqueId": "string" // 用户名(用于构建主页链接) + }, + "stats": { + "playCount": 500000, + "diggCount": 25000, // 点赞数(→ like_count) + "commentCount": 1000, + "shareCount": 500 + }, + "createTime": 1709000000, // Unix 时间戳(秒) + "challenges": [{ "title": "string" }] // 话题标签(→ tags) + } + ] + } +} +``` + +> **注意**:`video.cover` 是直接字符串,不是数组(与抖音不同)。 + +--- + +#### ★ `GET /api/v1/tiktok/web/fetch_post_detail` + +> **本项目使用** · TikTok 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `itemId` | string | 是 | 视频 ID | + +响应结构: +```json +{ + "data": { + "itemInfo": { + "itemStruct": { /* 同 itemList 中的单条格式 */ } + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/tiktok/web/fetch_user_profile` | 用户信息 | `uniqueId` 或 `secUid` | +| `GET /api/v1/tiktok/web/fetch_user_post` | 用户作品 | `secUid`, `cursor`, `count`, `post_item_list_request_type`(0=默认/1=热门/2=最旧) | +| `GET /api/v1/tiktok/web/fetch_user_like` | 用户喜欢(需公开)| `secUid`, `cursor`, `count` | +| `GET /api/v1/tiktok/web/fetch_post_comment` | 视频评论 | `aweme_id`, `cursor`, `count` | +| `GET /api/v1/tiktok/web/fetch_search_video` | 视频搜索 | `keyword`, `count`, `offset`, `search_id` | +| `GET /api/v1/tiktok/web/fetch_search_user` | 用户搜索 | `keyword`, `cursor`, `search_id` | +| `GET /api/v1/tiktok/web/fetch_tag_detail` | 话题详情 | `tag_name` | +| `GET /api/v1/tiktok/web/fetch_user_fans` | 粉丝列表 | `secUid`, `count`(默认30) | +| `GET /api/v1/tiktok/web/fetch_user_follow` | 关注列表 | `secUid`, `count`(默认30) | +| `POST /api/v1/tiktok/web/fetch_home_feed` | 首页推荐 | `count`, `cookie` | + +--- + +## 小红书(Xiaohongshu) + +### 小红书 Web V2 API — `/api/v1/xiaohongshu/web_v2/` + +#### ★ `GET /api/v1/xiaohongshu/web_v2/fetch_hot_list` + +> **本项目使用** · 获取热榜关键词列表(第一步) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构: +```json +{ + "data": { + "data": { + "items": [ + { "title": "string" } // 热词标题,取第一个作为搜索关键词 + ] + } + } +} +``` + +--- + +### 小红书 App V2 API — `/api/v1/xiaohongshu/app_v2/`(推荐) + +#### ★ `GET /api/v1/xiaohongshu/app_v2/search_notes` + +> **本项目使用** · 按关键词搜索笔记(第二步) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `keyword` | string | 是 | 搜索关键词 | +| `page` | integer | 否 | 页码,默认 1 | + +响应结构: +```json +{ + "data": { + "data": { + "items": [ + { + "model_type": "note", // 只处理 model_type==='note' 的条目 + "note": { + "id": "string", + "title": "string", + "desc": "string", + "type": "normal | video", + "timestamp": 1709000000, + "images_list": [ + { + "url": "string", + "url_size_large": "string" // 优先使用大图 + } + ], + "user": { + "nickname": "string", + "images": "string", // 头像 URL + "user_id": "string" + }, + "liked_count": 5000, // 点赞数(数字类型) + "comments_count": 200, // 评论数(注意:有 's') + "shared_count": 100, + "collected_count": 300, + "tag_info": [{ "name": "string" }] + } + } + ] + } + } +} +``` + +> **注意**: +> - `model_type` 不为 `"note"` 的条目(如广告)需过滤掉 +> - 搜索结果中视频笔记的 `video_url` 不可用,仅详情接口可获取视频链接 + +--- + +#### ★ `GET /api/v1/xiaohongshu/app_v2/get_mixed_note_detail` + +> **本项目使用** · 获取笔记详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `note_id` | string | 是 | 笔记 ID | + +响应结构: +```json +{ + "data": { + "id": "string", + "title": "string", + "name": "string", // 备用标题字段 + "desc": "string", + "type": "normal | video", + "timestamp": 1709000000, + "likes": 5000, // 点赞数(注意:详情与搜索字段名不同) + "comments_count": 200, + "shared_count": 100, + "collected_count": 300, + "images_list": [ + { "url": "string", "url_size_large": "string" } + ], + "user": { + "nickname": "string", + "images": "string" + }, + "tag_info": [{ "name": "string" }], + "video_info_v2": { + "url": "string" // 仅视频笔记有此字段 + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/xiaohongshu/app_v2/fetch_note_detail` | 笔记详情(旧版)| `note_id` | +| `GET /api/v1/xiaohongshu/web_v2/fetch_user_info` | 用户信息 | `user_id` 或 `username` | +| `GET /api/v1/xiaohongshu/web_v2/fetch_user_notes` | 用户笔记列表 | `user_id`, `cursor` | +| `GET /api/v1/xiaohongshu/app_v2/fetch_feed` | 推荐信息流 | `count` | + +--- + +## 哔哩哔哩(Bilibili) + +### Bilibili 网页版 API — `/api/v1/bilibili/web/` + +#### ★ `GET /api/v1/bilibili/web/fetch_popular_video_list` + +> **本项目使用** · 综合热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pn` | integer | 否 | 页码,默认 1 | +| `ps` | integer | 否 | 每页数量,默认 20 | + +响应结构: +```json +{ + "data": { + "list": [ + { + "bvid": "string", // 视频 BV 号(主要 ID) + "aid": 123456, // av 号(备用) + "title": "string", + "pic": "string", // 封面图 URL(直接字符串) + "desc": "string", + "owner": { + "mid": 123456, + "name": "string", + "face": "string" // UP 主头像 + }, + "stat": { + "view": 100000, // 播放量(→ play_count) + "like": 5000, // 点赞数 + "reply": 200, // 评论数(→ comment_count) + "share": 300, + "danmaku": 500, // 弹幕数(未映射) + "favorite": 1000, // 收藏数(未映射) + "coin": 800 // 投币数(未映射) + }, + "pubdate": 1709000000, // Unix 时间戳(秒) + "duration": 300, // 时长(秒) + "tname": "string", // 分区名(→ tags[0]) + "tags": [{ "tag_id": 1, "tag_name": "string" }] + } + ] + } +} +``` + +--- + +#### ★ `GET /api/v1/bilibili/web/fetch_video_detail` + +> **本项目使用** · 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `bvid` | string | 是 | 视频 BV 号(如 `BV1xx411c7mD`)| + +响应结构: +```json +{ + "data": { /* 同 list 中单条格式 */ } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/bilibili/web/fetch_hot_search` | 热搜词 | 无 | +| `GET /api/v1/bilibili/web/fetch_user_info` | UP 主信息 | `mid`(用户 ID)| +| `GET /api/v1/bilibili/web/fetch_user_videos` | UP 主视频列表 | `mid`, `pn`, `ps` | +| `GET /api/v1/bilibili/web/fetch_video_comment` | 视频评论 | `oid`(aid), `pn`, `ps` | +| `GET /api/v1/bilibili/web/fetch_ranking` | 全站排行榜(每日更新)| `tid`(分区ID), `day`(1/3/7) | + +--- + +## 微博(Weibo) + +### 微博 App API — `/api/v1/weibo/app/` + +#### ★ `GET /api/v1/weibo/app/fetch_hot_search` + +> **本项目使用** · 微博热搜榜 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构(两种格式,取其一): + +**格式 1:实际微博状态** +```json +{ + "data": { + "statuses": [ + { + "id": "string | number", + "mid": "string", + "text": "string", // HTML 格式,需 stripHtml 处理 + "raw_text": "string", + "user": { + "screen_name": "string", + "profile_image_url": "string", + "avatar_hd": "string", + "avatar_large": "string" + }, + "pic_ids": ["string"], // 图片 ID 列表 + "pic_infos": { + "PIC_ID": { + "url": "string", + "large": { "url": "string" } + } + }, + "attitudes_count": 5000, // 点赞数(→ like_count) + "comments_count": 200, + "reposts_count": 300, // 转发数(→ share_count) + "reads_count": 100000, // 阅读数(→ play_count) + "created_at": "Mon Jan 01 00:00:00 +0800 2024" + } + ] + } +} +``` + +**格式 2:热搜话题** +```json +{ + "data": { + "band_list": [ + { + "word": "string", // 热搜词 + "num": 10000, // 热度(→ play_count) + "label_name": "string", // 标签(热、沸) + "mid": "string", + "rank": 1 + } + ], + "realtime": [/* 同上 */] + } +} +``` + +> **注意**: +> - `text` 字段含 HTML 标签(``, `` 等),需 strip 处理 +> - 图片 URL 构建:`https://ww1.sinaimg.cn/large/{pic_id}.jpg` +> - 时间格式:`"Mon Jan 01 00:00:00 +0800 2024"`,可直接用 `new Date()` 解析 + +--- + +#### ★ `GET /api/v1/weibo/app/fetch_post_detail` + +> **本项目使用** · 微博详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `mid` | string | 是 | 微博 ID | + +响应结构: +```json +{ + "data": { /* 同 statuses 中单条格式 */ } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/weibo/web/fetch_hot_search` | 网页版热搜榜 | 无 | +| `GET /api/v1/weibo/web_v2/fetch_hot_search` | 网页版 V2 热搜榜 | 无 | +| `GET /api/v1/weibo/web/fetch_user_info` | 用户信息 | `uid` 或 `screen_name` | +| `GET /api/v1/weibo/web/fetch_user_statuses` | 用户微博列表 | `uid`, `page`, `count` | + +--- + +## YouTube + +### YouTube 网页版 API — `/api/v1/youtube/web/` + +#### ★ `GET /api/v1/youtube/web/fetch_trending_video` + +> **本项目使用** · YouTube 热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构: +```json +{ + "data": { + "items": [ + { + "id": "string | { videoId: string }", // 视频 ID(可能是字符串或对象) + "snippet": { + "title": "string", + "description": "string", + "channelTitle": "string", + "publishedAt": "2024-01-15T10:00:00Z", // ISO 8601 + "thumbnails": { + "maxres": { "url": "string" }, + "high": { "url": "string" }, + "medium": { "url": "string" }, + "default": { "url": "string" } + }, + "tags": ["string"] + }, + "statistics": { + "viewCount": "100000", // 注意:YouTube 统计字段是字符串类型 + "likeCount": "5000", // 需 parseInt() 转换 + "commentCount": "200" + } + } + ] + } +} +``` + +> **注意**: +> - `statistics.viewCount` 等字段为**字符串类型**,需用 `parseInt()` 转换 +> - `id` 字段可能是字符串或 `{ videoId: string }` 对象,需兼容处理 +> - 缩略图优先级:`maxres > high > medium > default` + +--- + +#### ★ `GET /api/v1/youtube/web/fetch_video_detail` + +> **本项目使用** · YouTube 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `video_id` | string | 是 | 视频 ID(如 `dQw4w9WgXcQ`)| + +响应结构: +```json +{ + "data": { + "items": [{ /* 同 trending 中单条格式 */ }] + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/youtube/web/fetch_channel_info` | 频道信息 | `channel_id` 或 `username` | +| `GET /api/v1/youtube/web/fetch_channel_videos` | 频道视频列表 | `channel_id`, `count`, `page_token` | +| `GET /api/v1/youtube/web/fetch_search_results` | 视频搜索 | `keyword`, `count`, `page_token` | +| `GET /api/v1/youtube/web/fetch_playlist_videos` | 播放列表视频 | `playlist_id`, `count`, `page_token` | + +--- + +## Instagram + +### Instagram 网页版 API — `/api/v1/instagram/web/` + +#### ★ `GET /api/v1/instagram/web/fetch_explore_feed` + +> **本项目使用** · Instagram 探索页内容 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构(两种格式,需同时处理): + +**格式 1:扁平 items 数组** +```json +{ + "data": { + "items": [ + { + "media": { /* InstagramMediaItem */ } + } + ] + } +} +``` + +**格式 2:分区 sectional_items** +```json +{ + "data": { + "sectional_items": [ + { + "layout_content": { + "medias": [ + { "media": { /* InstagramMediaItem */ } } + ] + } + } + ] + } +} +``` + +**InstagramMediaItem 结构**: +```json +{ + "pk": "string", + "id": "string", + "code": "string", // shortcode(用于构建 URL 和作为 ID) + "media_type": 1, // 1=图片, 2=视频, 8=轮播 + "caption": { "text": "string" } | "string" | null, + "image_versions2": { + "candidates": [ + { "url": "string", "width": 1080, "height": 1080 } + ] + }, + "thumbnail_url": "string", // 视频封面(备用) + "video_url": "string", // 仅 media_type=2 时有值 + "user": { + "username": "string", + "full_name": "string", + "profile_pic_url": "string" + }, + "like_count": 5000, + "comment_count": 200, + "taken_at": 1709000000 // Unix 时间戳(秒) +} +``` + +> **注意**: +> - 以 `code`(shortcode)作为内容 ID,URL 格式:`https://www.instagram.com/p/{code}/` +> - `caption` 字段格式不固定,可能是对象、字符串或 null,需兼容处理 + +--- + +#### ★ `GET /api/v1/instagram/web/fetch_post_detail` + +> **本项目使用** · Instagram 帖子详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `shortcode` | string | 是 | 帖子 shortcode(即内容 ID)| + +响应结构: +```json +{ + "data": { + "items": [{ /* InstagramMediaItem */ }] + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/instagram/web/fetch_user_info` | 用户信息 | `username` | +| `GET /api/v1/instagram/web/fetch_user_posts` | 用户帖子 | `username`, `cursor`, `count` | +| `GET /api/v1/instagram/web/fetch_search_result` | 搜索用户/标签 | `keyword` | +| `GET /api/v1/instagram/web/fetch_hashtag_posts` | 标签内容 | `hashtag`, `cursor` | + +--- + +## Twitter / X + +### Twitter 网页版 API — `/api/v1/twitter/web/` + +#### ★ `GET /api/v1/twitter/web/fetch_trending_topics` + +> **本项目使用** · Twitter 热门话题和趋势推文 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构(三种格式,需全部处理): + +**格式 1:推文数组** +```json +{ + "data": { + "tweets": [ + { + "id_str": "string", + "full_text": "string", // 优先使用 + "text": "string", // 备用 + "user": { + "name": "string", + "screen_name": "string", // 用户名(@后面的部分) + "profile_image_url_https": "string" + }, + "favorite_count": 5000, // 点赞数(→ like_count) + "reply_count": 200, // 回复数(→ comment_count) + "retweet_count": 300, // 转推数(→ share_count) + "created_at": "Mon Jan 01 00:00:00 +0000 2024", + "entities": { + "media": [ + { "media_url_https": "string", "type": "photo | video" } + ], + "hashtags": [{ "text": "string" }] + }, + "extended_entities": { + "media": [/* 同上,优先使用 */] + } + } + ] + } +} +``` + +**格式 2:GraphQL 时间线(Timeline Instructions)** +```json +{ + "data": { + "timeline": { + "instructions": [ + { + "entries": [ + { + "content": { + "itemContent": { + "tweet_results": { + "result": { + "legacy": { /* 同 TwitterTweet 格式 */ }, + "core": { + "user_results": { + "result": { + "legacy": { /* TwitterUser 格式 */ } + } + } + } + } + } + } + } + } + ] + } + ] + } + } +} +``` + +**格式 3:热门话题列表** +```json +{ + "data": { + "trends": [ + { + "name": "string", // 话题名称 + "tweet_volume": 10000, // 推文量(→ play_count) + "query": "string" // URL 编码的搜索查询 + } + ] + } +} +``` + +> **注意**: +> - 时间格式:`"Mon Jan 01 00:00:00 +0000 2024"`,可直接用 `new Date()` 解析 +> - 封面图来自 `extended_entities.media`(优先)或 `entities.media`,找 `type === 'photo'` 的条目 +> - 原帖 URL 格式:`https://x.com/{screen_name}/status/{id_str}` + +--- + +#### ★ `GET /api/v1/twitter/web/fetch_tweet_detail` + +> **本项目使用** · 推文详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `tweet_id` | string | 是 | 推文 ID | + +响应结构(两种格式): + +```json +// 格式 1(GraphQL) +{ + "data": { + "tweetResult": { + "result": { + "legacy": { /* TwitterTweet */ }, + "core": { "user_results": { "result": { "legacy": { /* TwitterUser */ } } } } + } + } + } +} + +// 格式 2(直接对象) +{ + "data": { + "tweet": { /* TwitterTweet */ } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/twitter/web/fetch_user_info` | 用户信息 | `screen_name` | +| `GET /api/v1/twitter/web/fetch_user_tweets` | 用户推文 | `screen_name`, `cursor`, `count` | +| `GET /api/v1/twitter/web/fetch_search_timeline` | 搜索推文 | `keyword`, `cursor`, `count` | +| `GET /api/v1/twitter/web/fetch_tweet_replies` | 推文回复 | `tweet_id`, `cursor` | + +--- + +## 通用说明 + +### 本项目中使用的端点汇总 + +| 平台 | fetchHotList 端点 | fetchDetail 端点 | +|------|------------------|-----------------| +| 抖音 | `douyin/web/fetch_hot_search_result` | `douyin/web/fetch_one_video` → 回退 `fetch_hot_search_result` | +| TikTok | `tiktok/web/fetch_explore_post` | `tiktok/web/fetch_post_detail` | +| 小红书 | `xiaohongshu/web_v2/fetch_hot_list` + `xiaohongshu/app_v2/search_notes` | `xiaohongshu/app_v2/get_mixed_note_detail` | +| YouTube | `youtube/web/fetch_trending_video` | `youtube/web/fetch_video_detail` | +| Instagram | `instagram/web/fetch_explore_feed` | `instagram/web/fetch_post_detail` | +| Twitter | `twitter/web/fetch_trending_topics` | `twitter/web/fetch_tweet_detail` | +| 哔哩哔哩 | `bilibili/web/fetch_popular_video_list` | `bilibili/web/fetch_video_detail` | +| 微博 | `weibo/app/fetch_hot_search` | `weibo/app/fetch_post_detail` | + +### 时间戳处理 + +| 平台 | 时间格式 | 处理方式 | +|------|---------|----------| +| 抖音、TikTok、小红书、B站、微博、Instagram | Unix 时间戳(秒)| `new Date(timestamp * 1000).toISOString()` | +| YouTube | ISO 8601 字符串 | 直接使用 | +| Twitter | `"Mon Jan 01 00:00:00 +0000 2024"` | `new Date(dateStr).toISOString()` | +| 微博 | `"Mon Jan 01 00:00:00 +0800 2024"` | `new Date(dateStr).toISOString()` | + +### 图片 URL 类型 + +| 平台 | 图片字段类型 | +|------|------------| +| 抖音 | `url_list: string[]`(取 `[0]`)| +| TikTok | `cover: string`(直接字符串)| +| 小红书 | `images_list[0].url_size_large \|\| url` | +| B站 | `pic: string`(直接字符串)| +| YouTube | `thumbnails.maxres.url`(对象嵌套)| +| Instagram | `image_versions2.candidates[0].url` | +| Twitter | `entities/extended_entities.media[0].media_url_https` | +| 微博 | `pic_infos[id].large.url` 或 `https://ww1.sinaimg.cn/large/{id}.jpg` | + +--- + +*文档基于项目实际使用端点 + TikHub OpenAPI 规范整理。完整端点列表(700+)请访问 https://api.tikhub.io* diff --git a/e2e-real/real-e2e.spec.ts b/e2e-real/real-e2e.spec.ts new file mode 100644 index 0000000..8846dac --- /dev/null +++ b/e2e-real/real-e2e.spec.ts @@ -0,0 +1,496 @@ +/** + * 真实端到端测试 — 不隔离后端依赖 + * + * 与 mock 版 E2E 不同,这些测试走完完整链路: + * 浏览器 → Next.js 前端 → Hono 后端 → TikHub API + * + * 前置条件: + * 1. 后端运行在 localhost:3001 且已配置有效的 TikHub API Key + * 2. 前端运行在 localhost:3000 + * + * 注意: + * - 因依赖外部 API,测试结果可能受网络状态和 API 配额影响 + * - 串行执行以避免并行请求导致的 API 限流和状态污染 + * - 断言基于数据结构而非具体内容(因真实数据不可预测) + */ +import { test, expect } from "@playwright/test"; + +const API_BASE = "http://localhost:3001"; +const VALID_API_KEY = + process.env.TIKHUB_API_KEY || + "gM8fC5kp2QikmV7IMZuR8D/TEAyDQbtE7jCE7n3bTuaTjmyeN1uNeh6AYA=="; + +// 全局串行执行 — 真实 E2E 共享后端状态,并行会互相干扰 +test.describe.configure({ mode: "serial" }); + +// 每个测试给足 60 秒(外部 API 可能慢) +test.setTimeout(60_000); + +// 确保后端已配置有效 API Key +test.beforeAll(async ({ request }) => { + await request.post(`${API_BASE}/api/settings`, { + data: { apiKey: VALID_API_KEY }, + }); +}); + +// ─── 首页流程 ─────────────────────────────────────────────── + +test.describe("真实 E2E: 首页", () => { + test("首页加载并渲染真实内容卡片", async ({ page }) => { + await page.goto("/"); + + // 等待内容网格出现(真实 API 可能较慢) + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 应至少有 1 张卡片(真实数据量不可预测,但不应为空) + const cards = page.getByTestId("content-card"); + const count = await cards.count(); + expect(count).toBeGreaterThan(0); + + // 每张卡片应包含标题文本(非空) + const firstCardText = await cards.first().textContent(); + expect(firstCardText?.trim().length).toBeGreaterThan(0); + }); + + test("平台切换后数据真实刷新", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 记录 "全部" 模式下的卡片数 + const allCount = await page.getByTestId("content-card").count(); + + // 切换到抖音平台 — 需要等待新数据加载 + await page.getByTestId("platform-tab-douyin").click(); + + // 等内容网格重新出现(切换平台会触发新请求) + // content-grid 在 loading 时不渲染,所以等它出现即表示新数据已到 + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + const douyinCount = await page.getByTestId("content-card").count(); + expect(douyinCount).toBeGreaterThan(0); + + // 抖音数据应少于或等于全部 + expect(douyinCount).toBeLessThanOrEqual(allCount); + }); + + for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) { + test(`平台 ${platform}: 切换加载内容`, async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 切换到目标平台 + const tab = page.getByTestId(`platform-tab-${platform}`); + await expect(tab).toBeVisible({ timeout: 10_000 }); + await tab.click(); + + // 等待内容加载:content-grid 出现或 error/empty 出现 + const grid = page.getByTestId("content-grid"); + const errorState = page.getByText("加载失败"); + const emptyState = page.getByTestId("empty-state"); + + await expect( + grid.or(errorState).or(emptyState) + ).toBeVisible({ timeout: 45_000 }); + + // 如果成功加载了内容,验证卡片结构 + if (await grid.isVisible()) { + const cards = page.getByTestId("content-card"); + const count = await cards.count(); + if (count > 0) { + // 卡片应包含非空文本 + const firstText = await cards.first().textContent(); + expect(firstText?.trim().length).toBeGreaterThan(0); + } + } + }); + } + + test("排序功能改变卡片顺序", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 获取默认排序下第一张卡片标题 + const firstTitleBefore = await page + .getByTestId("content-card") + .first() + .textContent(); + + // 切换排序字段到 "发布时间" + await page.getByTestId("sort-select").selectOption("publish_time"); + + // 切换后第一张卡片可能不同(不是必然,但结构应完整) + const firstTitleAfter = await page + .getByTestId("content-card") + .first() + .textContent(); + + // 至少卡片仍然存在 + expect(firstTitleAfter?.trim().length).toBeGreaterThan(0); + + // 切换排序顺序(升序/降序) + await page.getByTestId("sort-order").click(); + const firstTitleToggled = await page + .getByTestId("content-card") + .first() + .textContent(); + expect(firstTitleToggled?.trim().length).toBeGreaterThan(0); + }); +}); + +// ─── 详情页流程 ────────────────────────────────────────────── + +test.describe("真实 E2E: 详情页", () => { + test("从首页点击卡片进入详情页", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 点击第一张卡片 + await page.getByTestId("content-card").first().click(); + + // URL 应跳转到 /detail/平台/ID + await expect(page).toHaveURL(/\/detail\/\w+\/.+/, { timeout: 10_000 }); + + // 详情页应显示:标题、作者、统计数据 + await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("点赞")).toBeVisible(); + await expect(page.getByText("收藏")).toBeVisible(); + + // 查看原文按钮应存在 + await expect(page.getByTestId("view-original")).toBeVisible(); + await expect(page.getByTestId("view-original")).toContainText("查看原文"); + }); + + test("详情页返回按钮回到首页", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 进入详情 + await page.getByTestId("content-card").first().click(); + await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 }); + await expect(page.getByTestId("detail-back")).toBeVisible({ + timeout: 30_000, + }); + + // 点击返回 + await page.getByTestId("detail-back").click(); + await expect(page).toHaveURL("/", { timeout: 10_000 }); + }); + + for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) { + test(`平台 ${platform}: 详情页可正常打开`, async ({ page }) => { + // 平台详情测试需要更长时间:页面加载 + 平台切换 + 详情导航 + test.setTimeout(120_000); + + await page.goto("/"); + + // 首页可能因限流而加载失败,接受 grid/error/empty 任一状态 + await expect( + page.getByTestId("content-grid") + .or(page.getByText("加载失败")) + .or(page.getByTestId("empty-state")) + ).toBeVisible({ timeout: 45_000 }); + + // 切换到目标平台 + await page.getByTestId(`platform-tab-${platform}`).click(); + + // 等待卡片链接更新为目标平台(或确认无内容) + let hasContent = false; + try { + await page.waitForFunction( + (p: string) => { + const card = document.querySelector('[data-testid="content-card"]'); + if (!card) return false; + const href = card.getAttribute("href") || ""; + return href.includes(`/detail/${p}/`); + }, + platform, + { timeout: 45_000 } + ); + hasContent = true; + } catch { + // 超时说明平台无数据或加载失败 + hasContent = false; + } + + if (!hasContent) { + test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过详情页验证`); + return; + } + + const cards = page.getByTestId("content-card"); + + // 点击第一张卡片 + await cards.first().click(); + + // URL 应跳转到对应平台的详情页 + await expect(page).toHaveURL( + new RegExp(`/detail/${platform}/.+`), + { timeout: 10_000 } + ); + + // 详情页应有查看原文按钮和返回按钮 + await expect(page.getByTestId("view-original")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("detail-back")).toBeVisible(); + + // 返回首页 + await page.getByTestId("detail-back").click(); + await expect(page).toHaveURL("/", { timeout: 10_000 }); + }); + } +}); + +// ─── 收藏流程 ──────────────────────────────────────────────── + +test.describe("真实 E2E: 收藏", () => { + test("收藏真实内容并在收藏页查看", async ({ page }) => { + // 先清除可能残留的 localStorage + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 点击第一张卡片的收藏按钮 + const firstFavBtn = page.getByTestId("favorite-btn").first(); + await expect(firstFavBtn).toHaveAttribute("aria-label", "收藏"); + await firstFavBtn.click(); + await expect(firstFavBtn).toHaveAttribute("aria-label", "取消收藏"); + + // 跳转到收藏页 + await page.goto("/favorites"); + await expect(page.getByTestId("favorites-count")).toContainText("1 个内容"); + await expect(page.getByTestId("content-card")).toHaveCount(1); + }); + + test("取消收藏后收藏页为空", async ({ page }) => { + // 清理后重新收藏 + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 添加收藏 + await page.getByTestId("favorite-btn").first().click(); + await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( + "aria-label", + "取消收藏" + ); + + // 进入收藏页 + await page.goto("/favorites"); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // 取消收藏 + await page.getByTestId("favorite-btn").first().click(); + await expect(page.getByTestId("content-card")).toHaveCount(0); + await expect(page.getByText("还没有收藏")).toBeVisible(); + }); +}); + +// ─── 设置流程 ──────────────────────────────────────────────── +// 设置测试放在最后,因为 "保存 API Key" 会污染后端状态 + +test.describe("真实 E2E: 设置", () => { + test("设置页加载并可配置 API Key", async ({ page }) => { + await page.goto("/settings"); + + // 页面基本结构 + await expect(page.getByRole("heading", { name: "设置" })).toBeVisible(); + await expect(page.getByText("TikHub API Key")).toBeVisible(); + await expect(page.getByTestId("apikey-input")).toBeVisible(); + await expect(page.getByTestId("apikey-save")).toBeVisible(); + }); + + test("空 Key 提交被前端拦截", async ({ page }) => { + await page.goto("/settings"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + await page.getByTestId("apikey-save").click(); + await expect(page.getByText("请输入 API Key")).toBeVisible(); + }); + + test("刷新间隔选择器工作正常", async ({ page }) => { + await page.goto("/settings"); + + // 默认 30 分钟被选中 + const thirtyBtn = page.getByRole("button", { name: "30 分钟" }); + await expect(thirtyBtn).toHaveClass(/border-blue-500/); + + // 点击 10 分钟 + const tenBtn = page.getByRole("button", { name: "10 分钟" }); + await tenBtn.click(); + await expect(tenBtn).toHaveClass(/border-blue-500/); + await expect(thirtyBtn).not.toHaveClass(/border-blue-500/); + }); + + test("保存 API Key 走真实后端并收到成功反馈", async ({ page, request }) => { + await page.goto("/settings"); + + // 输入一个测试 Key 并保存(走真实 POST /api/settings) + await page.getByTestId("apikey-input").fill("test-real-e2e-key"); + await page.getByTestId("apikey-save").click(); + + // 验证成功 toast + await expect(page.getByText("API Key 已保存")).toBeVisible({ + timeout: 10_000, + }); + + // 恢复有效的 API Key,避免污染后续测试或开发环境 + await request.post(`${API_BASE}/api/settings`, { + data: { apiKey: VALID_API_KEY }, + }); + }); +}); + +// ─── 全链路冒烟测试 ────────────────────────────────────────── + +test.describe("真实 E2E: 全链路冒烟", () => { + for (const platform of ["youtube", "bilibili", "weibo"] as const) { + test(`平台 ${platform}: 完整用户旅程 (列表 → 详情 → 收藏)`, async ({ page }) => { + // 完整旅程需要更长时间 + test.setTimeout(120_000); + + // 清理状态 + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + + // 1. 切换到目标平台 + await page.getByTestId(`platform-tab-${platform}`).click(); + + // 等待内容加载或空/错误状态 + const grid = page.getByTestId("content-grid"); + const errorState = page.getByText("加载失败"); + const emptyState = page.getByTestId("empty-state"); + + await expect( + grid.or(errorState).or(emptyState) + ).toBeVisible({ timeout: 45_000 }); + + // 等待卡片链接更新为目标平台(或确认无内容) + let hasContent = false; + try { + await page.waitForFunction( + (p: string) => { + const card = document.querySelector('[data-testid="content-card"]'); + if (!card) return false; + const href = card.getAttribute("href") || ""; + return href.includes(`/detail/${p}/`); + }, + platform, + { timeout: 15_000 } + ); + hasContent = true; + } catch { + hasContent = false; + } + + if (!hasContent) { + test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过完整旅程验证`); + return; + } + + const cards = page.getByTestId("content-card"); + + // 2. 收藏第一张卡片 + const favBtn = page.getByTestId("favorite-btn").first(); + await favBtn.click(); + await expect(favBtn).toHaveAttribute("aria-label", "取消收藏"); + + // 3. 点击卡片进入详情 + await cards.first().click(); + await expect(page).toHaveURL( + new RegExp(`/detail/${platform}/.+`), + { timeout: 10_000 } + ); + + // 4. 详情页应有查看原文按钮和返回按钮 + await expect(page.getByTestId("view-original")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("detail-back")).toBeVisible(); + + // 5. 返回首页 + await page.getByTestId("detail-back").click(); + await expect(page).toHaveURL("/", { timeout: 10_000 }); + + // 6. 进入收藏页验证 + await page.goto("/favorites"); + await expect(page.getByTestId("content-card")).toHaveCount(1); + }); + } + + test("完整用户旅程: 首页 → 详情 → 收藏 → 收藏页", async ({ page }) => { + // 清理状态 + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // 1. 首页加载内容 + await expect(page.getByTestId("content-grid")).toBeVisible({ + timeout: 45_000, + }); + const cardCount = await page.getByTestId("content-card").count(); + expect(cardCount).toBeGreaterThan(0); + + // 2. 记住第一张卡片的标题用于后续验证 + const firstCardTitle = await page + .getByTestId("content-card") + .first() + .locator("h3") + .textContent(); + + // 3. 收藏第一张卡片 + await page.getByTestId("favorite-btn").first().click(); + await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( + "aria-label", + "取消收藏" + ); + + // 4. 点击第一张卡片进入详情 + await page.getByTestId("content-card").first().click(); + await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 }); + + // 5. 详情页应显示统计信息 + await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("view-original")).toBeVisible(); + + // 6. 返回首页 + await page.getByTestId("detail-back").click(); + await expect(page).toHaveURL("/", { timeout: 10_000 }); + + // 7. 进入收藏页查看已收藏内容 + await page.goto("/favorites"); + await expect(page.getByTestId("favorites-count")).toContainText("1 个内容"); + const favCard = page.getByTestId("content-card"); + await expect(favCard).toHaveCount(1); + + // 8. 收藏页的卡片标题应与首页收藏的一致 + if (firstCardTitle) { + await expect(favCard.first().locator("h3")).toContainText(firstCardTitle); + } + }); +}); diff --git a/e2e/detail.spec.ts b/e2e/detail.spec.ts new file mode 100644 index 0000000..c02b164 --- /dev/null +++ b/e2e/detail.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, type Page } from "@playwright/test"; +import { mockContentList, mockDetailItem } from "./fixtures"; + +async function mockAPIs(page: Page) { + await page.route(/\/api\/tikhub\//, async (route) => { + const url = route.request().url(); + if (url.includes("/detail")) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: mockDetailItem }), + }); + } else { + const match = url.match(/\/api\/tikhub\/(\w+)/); + const platform = match?.[1]; + const items = mockContentList.filter((i) => i.platform === platform); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: items }), + }); + } + }); +} + +test.describe("详情页流程", () => { + test("详情加载", async ({ page }) => { + await mockAPIs(page); + await page.goto("/detail/douyin/dy-001"); + + // Verify title + await expect(page.getByText("创意摄影技巧")).toBeVisible(); + // Verify author + await expect(page.getByText("摄影师小明")).toBeVisible(); + // Verify stats panel + await expect(page.getByText("播放")).toBeVisible(); + await expect(page.getByText("点赞")).toBeVisible(); + }); + + test("标签显示", async ({ page }) => { + await mockAPIs(page); + await page.goto("/detail/douyin/dy-001"); + + // mockDetailItem has tags: ['摄影', '创意', '技巧'] + await expect(page.getByText("#摄影")).toBeVisible(); + await expect(page.getByText("#创意")).toBeVisible(); + await expect(page.getByText("#技巧")).toBeVisible(); + }); + + test("查看原文按钮", async ({ page }) => { + await mockAPIs(page); + await page.goto("/detail/douyin/dy-001"); + + await expect(page.getByTestId("view-original")).toBeVisible(); + await expect(page.getByTestId("view-original")).toContainText("查看原文"); + }); + + test("返回导航", async ({ page }) => { + await mockAPIs(page); + // Navigate to home first to build browser history + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Then navigate to detail + await page.goto("/detail/douyin/dy-001"); + await expect(page.getByText("创意摄影技巧")).toBeVisible(); + + // Click back button + await page.getByTestId("detail-back").click(); + await expect(page).toHaveURL("/"); + }); + + test("详情加载失败", async ({ page }) => { + await page.route(/\/api\/tikhub\//, async (route) => { + const url = route.request().url(); + if (url.includes("/detail")) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "服务器错误" }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: [] }), + }); + } + }); + + await page.goto("/detail/douyin/dy-001"); + await expect(page.getByText("加载失败")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("重试")).toBeVisible(); + await expect(page.getByText("返回首页")).toBeVisible(); + }); +}); diff --git a/e2e/favorites.spec.ts b/e2e/favorites.spec.ts new file mode 100644 index 0000000..66d81f2 --- /dev/null +++ b/e2e/favorites.spec.ts @@ -0,0 +1,104 @@ +import { test, expect, type Page } from "@playwright/test"; +import { mockContentList } from "./fixtures"; + +async function mockTrendingAPI(page: Page) { + await page.route(/\/api\/tikhub\//, async (route) => { + const match = route.request().url().match(/\/api\/tikhub\/(\w+)/); + const platform = match?.[1]; + const items = mockContentList.filter((i) => i.platform === platform); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: items }), + }); + }); +} + +test.describe("收藏流程", () => { + test("添加收藏", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // First favorite button should be "收藏" (not favorited) + const firstFavBtn = page.getByTestId("favorite-btn").first(); + await expect(firstFavBtn).toHaveAttribute("aria-label", "收藏"); + + // Click to favorite + await firstFavBtn.click(); + + // Should now be "取消收藏" (favorited) + await expect(firstFavBtn).toHaveAttribute("aria-label", "取消收藏"); + }); + + test("收藏页面显示收藏内容", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Favorite the first card + await page.getByTestId("favorite-btn").first().click(); + + // Navigate to favorites + await page.goto("/favorites"); + + // Verify count + await expect(page.getByTestId("favorites-count")).toContainText("1 个内容"); + // Verify the card is shown + await expect(page.getByTestId("content-card")).toHaveCount(1); + }); + + test("取消收藏", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Add favorite + await page.getByTestId("favorite-btn").first().click(); + await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( + "aria-label", + "取消收藏" + ); + + // Navigate to favorites + await page.goto("/favorites"); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Remove favorite + await page.getByTestId("favorite-btn").first().click(); + + // Card should be gone, empty state shown + await expect(page.getByTestId("content-card")).toHaveCount(0); + await expect(page.getByText("还没有收藏")).toBeVisible(); + }); + + test("空收藏状态", async ({ page }) => { + await page.goto("/favorites"); + + await expect(page.getByText("还没有收藏")).toBeVisible(); + await expect(page.getByText("去发现")).toBeVisible(); + }); + + test("收藏持久化", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Add favorite + await page.getByTestId("favorite-btn").first().click(); + await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( + "aria-label", + "取消收藏" + ); + + // Reload the page — localStorage should persist + await page.reload(); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Verify favorite persisted + await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( + "aria-label", + "取消收藏" + ); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..6444216 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,148 @@ +export const mockContentList = [ + { + id: "dy-001", + title: "抖音热门视频测试内容 - 创意摄影技巧", + cover_url: "", + author_name: "摄影师小明", + author_avatar: "", + play_count: 1_500_000, + like_count: 80_000, + collect_count: 12_000, + comment_count: 3_500, + share_count: 5_000, + publish_time: "2024-01-15T08:00:00Z", + platform: "douyin", + original_url: "https://www.douyin.com/video/dy-001", + tags: ["摄影", "创意", "技巧"], + }, + { + id: "tt-002", + title: "TikTok Viral Dance Challenge 2024", + cover_url: "", + author_name: "DanceMaster", + author_avatar: "", + play_count: 5_000_000, + like_count: 250_000, + collect_count: 45_000, + comment_count: 12_000, + share_count: 80_000, + publish_time: "2024-01-14T12:00:00Z", + platform: "tiktok", + original_url: "https://www.tiktok.com/@user/video/tt-002", + tags: ["dance", "viral", "challenge"], + }, + { + id: "xhs-003", + title: "小红书美食探店 | 隐藏在街角的宝藏咖啡馆", + cover_url: "", + author_name: "美食家小红", + author_avatar: "", + play_count: 300_000, + like_count: 45_000, + collect_count: 28_000, + comment_count: 8_000, + share_count: 3_000, + publish_time: "2024-01-13T16:00:00Z", + platform: "xiaohongshu", + original_url: "https://www.xiaohongshu.com/explore/xhs-003", + tags: ["美食", "探店", "咖啡"], + }, + { + id: "dy-004", + title: "抖音生活小妙招合集", + cover_url: "", + author_name: "生活达人", + author_avatar: "", + play_count: 800_000, + like_count: 35_000, + collect_count: 20_000, + comment_count: 5_000, + share_count: 15_000, + publish_time: "2024-01-12T10:00:00Z", + platform: "douyin", + original_url: "https://www.douyin.com/video/dy-004", + tags: [], + }, + { + id: "yt-005", + title: "YouTube Top Music Video of 2024", + cover_url: "", + author_name: "MusicChannel", + author_avatar: "", + play_count: 10_000_000, + like_count: 500_000, + collect_count: 0, + comment_count: 50_000, + share_count: 100_000, + publish_time: "2024-01-16T10:00:00Z", + platform: "youtube", + original_url: "https://www.youtube.com/watch?v=yt-005", + tags: ["music", "trending"], + }, + { + id: "ig-006", + title: "Instagram Explore: Stunning Photography", + cover_url: "", + author_name: "photoartist", + author_avatar: "", + play_count: 0, + like_count: 120_000, + collect_count: 30_000, + comment_count: 8_000, + share_count: 15_000, + publish_time: "2024-01-17T14:00:00Z", + platform: "instagram", + original_url: "https://www.instagram.com/p/ig-006/", + tags: [], + }, + { + id: "tw-007", + title: "Breaking: Major tech announcement shakes the industry", + cover_url: "", + author_name: "TechReporter", + author_avatar: "", + play_count: 0, + like_count: 200_000, + collect_count: 50_000, + comment_count: 25_000, + share_count: 80_000, + publish_time: "2024-01-18T09:00:00Z", + platform: "twitter", + original_url: "https://twitter.com/i/status/tw-007", + tags: ["tech", "breaking"], + }, + { + id: "bl-008", + title: "B站年度最佳科技视频解析", + cover_url: "", + author_name: "科技UP主", + author_avatar: "", + play_count: 3_000_000, + like_count: 150_000, + collect_count: 80_000, + comment_count: 20_000, + share_count: 40_000, + publish_time: "2024-01-19T11:00:00Z", + platform: "bilibili", + original_url: "https://www.bilibili.com/video/bl-008", + tags: ["科技"], + }, + { + id: "wb-009", + title: "微博热搜:春节档电影票房破纪录", + cover_url: "", + author_name: "娱乐博主", + author_avatar: "", + play_count: 2_000_000, + like_count: 90_000, + collect_count: 10_000, + comment_count: 35_000, + share_count: 60_000, + publish_time: "2024-01-20T08:00:00Z", + platform: "weibo", + original_url: "https://weibo.com/u/wb-009", + tags: [], + }, +]; + +export const mockDetailItem = mockContentList[0]; diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..b6729c8 --- /dev/null +++ b/e2e/home.spec.ts @@ -0,0 +1,134 @@ +import { test, expect, type Page } from "@playwright/test"; +import { mockContentList, mockDetailItem } from "./fixtures"; + +async function mockTrendingAPI(page: Page, items = mockContentList) { + await page.route(/\/api\/tikhub\//, async (route) => { + const url = route.request().url(); + if (url.includes("/detail")) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: mockDetailItem }), + }); + return; + } + const match = url.match(/\/api\/tikhub\/(\w+)/); + const platform = match?.[1]; + const filtered = items.filter((i) => i.platform === platform); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: filtered }), + }); + }); +} + +test.describe("首页浏览流程", () => { + test("首页加载并显示内容卡片", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + + await expect(page.getByTestId("content-grid")).toBeVisible(); + const cards = page.getByTestId("content-card"); + await expect(cards).toHaveCount(9); + }); + + test("平台切换", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Switch to douyin — 2 items (dy-001, dy-004) + await page.getByTestId("platform-tab-douyin").click(); + await expect(page.getByTestId("content-card")).toHaveCount(2); + + // Switch to tiktok — 1 item (tt-002) + await page.getByTestId("platform-tab-tiktok").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to xiaohongshu — 1 item (xhs-003) + await page.getByTestId("platform-tab-xiaohongshu").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to youtube — 1 item (yt-005) + await page.getByTestId("platform-tab-youtube").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to instagram — 1 item (ig-006) + await page.getByTestId("platform-tab-instagram").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to twitter — 1 item (tw-007) + await page.getByTestId("platform-tab-twitter").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to bilibili — 1 item (bl-008) + await page.getByTestId("platform-tab-bilibili").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch to weibo — 1 item (wb-009) + await page.getByTestId("platform-tab-weibo").click(); + await expect(page.getByTestId("content-card")).toHaveCount(1); + + // Switch back to all — 9 items + await page.getByTestId("platform-tab-all").click(); + await expect(page.getByTestId("content-card")).toHaveCount(9); + }); + + test("排序功能", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Default: play_count desc — YouTube (10M) first + await expect(page.getByTestId("content-card").first()).toContainText( + "YouTube Top Music" + ); + + // Switch sort field to like_count + await page.getByTestId("sort-select").selectOption("like_count"); + // like_count desc: yt-005(500K) first + await expect(page.getByTestId("content-card").first()).toContainText( + "YouTube Top Music" + ); + + // Toggle to ascending + await page.getByTestId("sort-order").click(); + // like_count asc: dy-004(35K) first + await expect(page.getByTestId("content-card").first()).toContainText( + "生活小妙招" + ); + }); + + test("卡片导航到详情页", async ({ page }) => { + await mockTrendingAPI(page); + await page.goto("/"); + await expect(page.getByTestId("content-grid")).toBeVisible(); + + // Default sort: play_count desc → first card is YouTube (yt-005, 10M) + await page.getByTestId("content-card").first().click(); + await expect(page).toHaveURL(/\/detail\/youtube\/yt-005/); + }); + + test("空状态", async ({ page }) => { + await mockTrendingAPI(page, []); + await page.goto("/"); + + await expect(page.getByTestId("empty-state")).toBeVisible(); + await expect(page.getByText("暂无内容")).toBeVisible(); + }); + + test("错误状态和重试", async ({ page }) => { + await page.route(/\/api\/tikhub\//, async (route) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "服务器错误" }), + }); + }); + + await page.goto("/"); + await expect(page.getByText("加载失败")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("error-retry")).toBeVisible(); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..f6b2276 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; + +test.describe("设置流程", () => { + test("导航到设置页", async ({ page }) => { + await page.goto("/settings"); + + await expect(page.getByRole("heading", { name: "设置" })).toBeVisible(); + await expect(page.getByText("TikHub API Key")).toBeVisible(); + }); + + test("保存 API Key", async ({ page }) => { + await page.route(/\/api\/settings/, async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true }), + }); + } + }); + + await page.goto("/settings"); + await page.getByTestId("apikey-input").fill("test-api-key-123"); + await page.getByTestId("apikey-save").click(); + + // Verify success toast + await expect(page.getByText("API Key 已保存")).toBeVisible(); + }); + + test("空 Key 校验", async ({ page }) => { + await page.goto("/settings"); + await page.getByTestId("apikey-save").click(); + + // Verify error toast + await expect(page.getByText("请输入 API Key")).toBeVisible(); + }); + + test("保存失败", async ({ page }) => { + await page.route(/\/api\/settings/, async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "服务器错误" }), + }); + } + }); + + await page.goto("/settings"); + await page.getByTestId("apikey-input").fill("test-api-key-123"); + await page.getByTestId("apikey-save").click(); + + // Verify failure toast + await expect(page.getByText("保存失败,请重试")).toBeVisible(); + }); + + test("刷新间隔切换", async ({ page }) => { + await page.goto("/settings"); + + // Default is 30 minutes — it should have selected styles + const thirtyMinBtn = page.getByRole("button", { name: "30 分钟" }); + await expect(thirtyMinBtn).toHaveClass(/border-blue-500/); + + // Click 10 minutes + const tenMinBtn = page.getByRole("button", { name: "10 分钟" }); + await tenMinBtn.click(); + + // 10 min should now be selected + await expect(tenMinBtn).toHaveClass(/border-blue-500/); + // 30 min should be deselected + await expect(thirtyMinBtn).not.toHaveClass(/border-blue-500/); + }); +}); diff --git a/externaldocs/cicd_integration_updated.md b/externaldocs/cicd_integration_updated.md new file mode 100644 index 0000000..c02ff6f --- /dev/null +++ b/externaldocs/cicd_integration_updated.md @@ -0,0 +1,641 @@ +# CI/CD 集成方案(最终落地版) + +## 概述 + +本文档记录抖音评论管理系统的 CI/CD 方案:**Drone CI + 私有 Docker Registry + Docker Compose 自动部署**。 + +目标: + +- 每天凌晨 0 点(北京时间)自动拉取 `main` 最新代码并部署 +- 创建 Tag(如 `v1.9.0210.7`)时自动构建并部署 +- 不在每次 `push` 时触发 +- 生产环境继续沿用 `docker-compose.prod.yml` + +--- + +## 基础设施 + +| 角色 | 位置 | 说明 | +|------|------|------| +| Gitea 服务器 | 腾讯云 `git.internal.intelligrow.cn` | 代码仓库 | +| Drone CI 服务器 | Ubuntu x64 `192.168.31.107` | 执行构建任务 + 运行私有 Docker Registry | +| 生产服务器 | Ubuntu x64 `192.168.31.48` | 运行 Docker Compose 生产服务,SSH 端口 3141 | + +--- + +## 架构流程 + +```text +定时触发(cron)或 创建 tag + | + v + Drone 拉取代码 + | + +-- 构建 backend 镜像并推送 Registry + +-- 构建 frontend 镜像并推送 Registry + v + SSH 到生产服务器执行 deploy-remote.sh + | + +-- pull 新镜像 + +-- 停止 celery_beat + +-- 更新 backend + celery_worker + +-- 健康检查(容器内 /health) + +-- alembic upgrade head(失败即中断) + +-- 启动 celery_beat + frontend + +-- 最终健康检查 + v + 发送企业微信通知(成功/失败) +``` + +--- + +## 文件结构 + +```text +.drone.yml # Drone CI 流水线 +scripts/ ++-- deploy.sh # 原有手动部署脚本 ++-- deploy-remote.sh # Drone 远程调用的自动部署脚本 ++-- server-setup.sh # 原有服务器初始化脚本 +docker-compose.prod.yml # 生产环境 compose 配置 +docs/ ++-- cicd_integration_updated.md # 本文档 +``` + +--- + +## Phase 1:Drone CI 服务器准备(一次性) + +### 1.1 Drone CI 服务 (docker-compose.yml) + +在 Drone CI 服务器 `~/drone/docker-compose.yml`: + +```yaml +services: + drone-server: + image: drone/drone:2 + container_name: drone-server + restart: always + ports: + - "3080:80" + environment: + - DRONE_GITEA_SERVER=https://git.internal.intelligrow.cn + - DRONE_GITEA_CLIENT_ID= + - DRONE_GITEA_CLIENT_SECRET= + - DRONE_SERVER_HOST=drone.internal.intelligrow.cn + - DRONE_SERVER_PROTO=https + - DRONE_RPC_SECRET= + - DRONE_USER_CREATE=username:,admin:true + volumes: + - ./data:/data + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + depends_on: + - drone-server + environment: + - DRONE_RPC_PROTO=http + - DRONE_RPC_HOST=drone-server + - DRONE_RPC_SECRET= + - DRONE_RUNNER_CAPACITY=2 + - DRONE_RUNNER_NAME=drone-runner-1 + - DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +关键配置说明: + +- **`DRONE_RPC_PROTO=http`**:Runner 在 Docker 内网直连 drone-server 容器的 80 端口,不走 HTTPS。外部通过反向代理 `drone.internal.intelligrow.cn` 访问时才用 HTTPS。 +- **`DRONE_USER_CREATE`**:`username` 必须与 Gitea 登录用户名完全一致(不是邮箱),否则管理员权限不生效。 +- **`DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker`**:允许 plugins/docker 以特权模式运行(本方案最终未使用 plugins/docker,但保留配置以备后用)。 + +### 1.2 启动私有 Registry + +在 Drone CI 服务器执行: + +```bash +docker run -d --name registry \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + --restart always \ + registry:2 +``` + +验证: + +```bash +curl http://localhost:5000/v2/_catalog +# 预期输出: {"repositories":[]} +``` + +### 1.3 配置 insecure registry + +**Drone CI 服务器** `/etc/docker/daemon.json`: + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live", + "https://docker.1panel.dev", + "https://docker.1ms.run" + ], + "insecure-registries": [ + "docker.internal.intelligrow.cn:5000", + "192.168.31.107:5000" + ] +} +``` + +**生产服务器** `/etc/docker/daemon.json`: + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live", + "https://docker.1panel.dev", + "https://docker.1ms.run" + ], + "insecure-registries": ["docker.internal.intelligrow.cn:5000"] +} +``` + +修改后重启 Docker: + +```bash +sudo systemctl restart docker +``` + +> 注意:`insecure-registries` 中不要带 `http://` 前缀,直接写 `host:port` 格式。 + +### 1.4 配置 SSH 免密 + +在 Drone CI 服务器生成密钥并添加到生产服务器: + +```bash +ssh-keygen -t ed25519 -C "drone-ci" -f ~/.ssh/drone_deploy -N "" +ssh-copy-id -i ~/.ssh/drone_deploy.pub -p 3141 miaosi@192.168.31.48 +``` + +验证: + +```bash +ssh -i ~/.ssh/drone_deploy -p 3141 miaosi@192.168.31.48 "echo ok" +``` + +--- + +## Phase 2:Drone 仓库设置 + +### 2.1 开启 Trusted 模式 + +在 Drone 面板 → 仓库 Settings → General → Project Settings → 勾选 **Trusted**。 + +> 需要管理员权限。如果看不到 Trusted 选项,检查 `DRONE_USER_CREATE` 的 username 是否与 Gitea 用户名一致。 + +### 2.2 配置 Secrets + +在 Drone 面板(仓库设置 → Secrets)添加: + +| Secret 名称 | 用途 | 实际值示例 | +|-------------|------|-----------| +| `backend_repo` | 后端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-backend` | +| `frontend_repo` | 前端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-frontend` | +| `deploy_host` | 生产服务器 IP | `192.168.31.48` | +| `deploy_user` | SSH 用户 | `miaosi` | +| `deploy_ssh_key` | SSH 私钥内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----` | +| `deploy_path` | 生产服务器部署目录 | `/opt/docker/douyin_comments_management` | +| `wecom_webhook` | 企业微信 Webhook(可选) | `https://qyapi.weixin.qq.com/...` | + +> 注意:`backend_repo` 和 `frontend_repo` 的 Registry 地址必须与生产服务器 `.env` 中的 `DOCKER_REGISTRY` 使用相同的主机名(都用域名或都用 IP),否则 Docker 会认为是不同的镜像。 + +### 2.3 配置 Cron Job + +在 Drone 面板(仓库设置 → Cron Jobs)添加: + +| 字段 | 值 | +|------|------| +| Name | `nightly-build` | +| Branch | `main` | +| Schedule | `0 16 * * *` | + +说明: + +- Drone 默认按 UTC 解释 Cron +- `0 16 * * *` = UTC 16:00 = 北京时间次日 00:00 + +--- + +## Phase 3:配置文件(最终落地版本) + +### 3.1 `.drone.yml` + +```yaml +kind: pipeline +type: docker +name: build-and-deploy + +trigger: + event: + - tag + - cron + +volumes: + - name: dockersock + host: + path: /var/run/docker.sock + +steps: + - name: build-backend + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + BACKEND_REPO: + from_secret: backend_repo + commands: + - '[ -n "$BACKEND_REPO" ] || (echo "backend_repo secret is empty" && exit 1)' + - echo "Building backend image tag:${DRONE_TAG:-latest}" + - docker build -t "$BACKEND_REPO:${DRONE_TAG:-latest}" -t "$BACKEND_REPO:latest" ./backend + - docker push "$BACKEND_REPO:${DRONE_TAG:-latest}" + - docker push "$BACKEND_REPO:latest" + + - name: build-frontend + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + FRONTEND_REPO: + from_secret: frontend_repo + commands: + - '[ -n "$FRONTEND_REPO" ] || (echo "frontend_repo secret is empty" && exit 1)' + - echo "Building frontend image tag:${DRONE_TAG:-latest}" + - docker build -t "$FRONTEND_REPO:${DRONE_TAG:-latest}" -t "$FRONTEND_REPO:latest" ./frontend + - docker push "$FRONTEND_REPO:${DRONE_TAG:-latest}" + - docker push "$FRONTEND_REPO:latest" + + - name: deploy + image: appleboy/drone-ssh + environment: + DEPLOY_PATH: + from_secret: deploy_path + settings: + host: + from_secret: deploy_host + username: + from_secret: deploy_user + key: + from_secret: deploy_ssh_key + port: 3141 + command_timeout: 1800s + script_stop: true + envs: + - DRONE_TAG + - DEPLOY_PATH + script: + - IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest" + - cd "$DEPLOY_PATH" + - bash scripts/deploy-remote.sh "$IMAGE_TAG" + + - name: notify-success + image: curlimages/curl + environment: + WECOM_WEBHOOK: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WECOM_WEBHOOK:-}" ]; then + VERSION="$DRONE_TAG" + [ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)" + curl -sS -X POST "$WECOM_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}" + fi + when: + status: + - success + + - name: notify-failure + image: curlimages/curl + environment: + WECOM_WEBHOOK: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WECOM_WEBHOOK:-}" ]; then + VERSION="$DRONE_TAG" + [ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)" + curl -sS -X POST "$WECOM_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n构建: ${DRONE_BUILD_LINK}\"}}" + fi + when: + status: + - failure +``` + +### 3.2 `scripts/deploy-remote.sh` + +```bash +#!/usr/bin/env bash +# ======================================== +# 远程部署脚本 - 被 Drone CI SSH 调用 +# ======================================== +# 用法: bash scripts/deploy-remote.sh [image_tag] + +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } + +DEPLOY_PATH="${DEPLOY_PATH:-/opt/docker/douyin_comments_management}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +IMAGE_TAG="${1:-latest}" +LOCK_FILE="/tmp/douyin-deploy.lock" +MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}" + +cleanup_lock() { + rm -f "$LOCK_FILE" +} + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local old_pid + old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + log_error "已有部署进程运行中 (PID: $old_pid)" + exit 1 + fi + rm -f "$LOCK_FILE" + fi + + echo "$$" > "$LOCK_FILE" + trap cleanup_lock EXIT +} + +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} + +wait_backend_healthy() { + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T backend python -c "import sys,urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);sys.exit(0)" >/dev/null 2>&1; then + log_info "后端健康检查通过" + return 0 + fi + + log_info "等待后端就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep 3 + attempt=$((attempt + 1)) + done + + return 1 +} + +main() { + acquire_lock + + [ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; } + cd "$DEPLOY_PATH" + + [ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; } + + export IMAGE_TAG + export VERSION="$IMAGE_TAG" + + log_info "开始部署,镜像版本: $IMAGE_TAG" + + log_info "拉取最新镜像..." + compose pull backend celery_worker celery_beat frontend + + log_info "停止 celery_beat..." + compose stop celery_beat || true + + log_info "更新 backend 与 celery_worker..." + compose up -d --no-deps backend celery_worker + + if ! wait_backend_healthy; then + log_error "后端未在预期时间内就绪" + compose logs --tail=200 backend || true + exit 1 + fi + + log_info "执行数据库迁移..." + compose exec -T backend alembic upgrade head + + log_info "启动 celery_beat..." + compose up -d --no-deps celery_beat + + log_info "更新 frontend..." + compose up -d --no-deps frontend + + log_info "清理孤儿容器..." + compose up -d --remove-orphans + + if ! wait_backend_healthy; then + log_error "部署完成后健康检查失败" + compose logs --tail=200 backend || true + exit 1 + fi + + docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过" + + log_info "部署完成!版本: $IMAGE_TAG" + compose ps +} + +main "$@" +``` + +> 注意:脚本同时 export `IMAGE_TAG` 和 `VERSION`,因为生产服务器的 `docker-compose.prod.yml` 使用 `${VERSION:-latest}` 作为镜像 tag 变量。 + +--- + +## 生产服务器 `.env` 配置 + +确保 `/opt/docker/douyin_comments_management/.env` 至少包含: + +```bash +DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000 + +POSTGRES_PASSWORD=xxx +REDIS_PASSWORD=xxx +RABBITMQ_PASSWORD=xxx +# 其余生产配置保持现有值 +``` + +> `DOCKER_REGISTRY` 的值必须与 Drone Secret 中 `backend_repo` / `frontend_repo` 的 Registry 主机名一致。 + +--- + +## 回滚方案 + +### 方式 1:推送旧版本 tag 触发重新部署 + +```bash +git tag v1.8.1-rollback v1.8.1 +git push origin v1.8.1-rollback +``` + +### 方式 2:生产服务器手动回滚 + +```bash +ssh -p 3141 miaosi@192.168.31.48 +cd /opt/docker/douyin_comments_management +bash scripts/deploy-remote.sh v1.8.1 +``` + +### 方式 3:手动 compose 回滚 + +```bash +cd /opt/docker/douyin_comments_management +export VERSION=v1.8.1 +docker compose -f docker-compose.prod.yml pull backend celery_worker celery_beat frontend +docker compose -f docker-compose.prod.yml up -d --no-deps backend celery_worker celery_beat frontend +``` + +--- + +## 验证方式 + +### 验证 Registry + +```bash +# Drone CI 服务器 +curl http://localhost:5000/v2/_catalog +curl http://localhost:5000/v2/douyin-backend/tags/list +curl http://localhost:5000/v2/douyin-frontend/tags/list +``` + +### 验证构建与部署触发 + +```bash +# 方式1:Tag 触发 +git tag v1.9.0210.8 +git push origin v1.9.0210.8 + +# 方式2:Drone 面板手动触发 cron 或点击 NEW BUILD +``` + +### 验证生产服务 + +```bash +# 检查容器状态 +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml ps" + +# 后端健康检查(容器内) +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);print('ok')\"" + +# 数据库迁移状态 +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend alembic current" +``` + +--- + +## 故障排查 + +| 问题 | 排查方式 | +|------|---------| +| YAML 解析错误 | 检查 `.drone.yml` 语法,Drone 对 `volumes`/`environment` 格式敏感 | +| 构建失败 | Drone 面板查看 pipeline 日志 | +| 镜像推送失败 (HTTPS) | 确认两台服务器 `insecure-registries` 配置正确(不带 `http://` 前缀) | +| Secret 为空 | 使用 `environment: { VAR: { from_secret: name } }` 而非 `secrets` 字段 | +| Drone 变量替换冲突 | `${DRONE_TAG}` 是 Drone 变量可直接使用;自定义 shell 变量用 `$$VAR` 转义 | +| Tag 不触发构建 | 检查 Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 不要加 `cron: [name]` | +| Step is pending | 检查 Runner 是否连通 Server;仓库是否开启 Trusted | +| DinD 启动失败 | 改用 `docker:27-cli` + 挂载宿主机 Docker socket(本方案采用的方式) | +| deploy 找不到脚本 | 确认 `deploy-remote.sh` 已复制到生产服务器部署目录的 `scripts/` 下 | +| Docker 权限不足 | 生产服务器执行 `sudo usermod -aG docker ` 后重新登录 | +| 镜像名 invalid reference | 检查生产服务器 `.env` 中 `DOCKER_REGISTRY` 变量是否正确设置 | +| 数据库迁移失败 | `docker compose -f docker-compose.prod.yml logs -f backend` | +| Cron 未触发 | 核对 Drone Cron 名称、分支是否 `main`、Schedule 是否正确 | + +--- + +## 踩坑记录 + +以下是实际部署过程中遇到的问题及解决方案,供后续参考: + +### 1. Drone YAML 解析错误 (`cannot unmarshal !!map into string`) + +**原因**:Drone Docker pipeline 对 `environment` 中 `from_secret` 语法和 `volumes` 格式有特定要求。早期版本同时使用 `volumes` + `environment: from_secret` 会触发解析错误。 + +**解决**:确保仓库开启 Trusted 模式后,`volumes` 和 `environment: from_secret` 可以正常共存。 + +### 2. Tag 推送不触发构建 + +**原因**:`.drone.yml` 中同时配置了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 将触发条件做 AND 运算。Tag 事件无法满足 cron 条件,导致永远不触发。 + +**解决**:移除 `cron: [nightly-build]` 过滤,只保留 `event: [tag, cron]`。 + +### 3. plugins/docker DinD 启动失败 + +**原因**:`plugins/docker` 插件内部启动 Docker 守护进程(Docker-in-Docker),可能因 cgroup/存储驱动兼容性问题无法启动。 + +**解决**:放弃 `plugins/docker`,改用 `docker:27-cli` 镜像 + 挂载宿主机 Docker socket 的方式构建镜像。 + +### 4. plugins/docker 要求 semver 格式 tag + +**原因**:`auto_tag: true` 配置要求 Git tag 符合语义化版本(如 `v1.0.0`),非标准格式(如 `v1.9.0210.1`)会解析失败。 + +**解决**:移除 `auto_tag`,改用 `${DRONE_TAG:-latest}` 手动指定镜像 tag。 + +### 5. Drone 变量替换与 Shell 变量冲突 + +**原因**:Drone 会在执行前对 `${VAR}` 语法做自身的变量替换。自定义 shell 变量(如 `TAG="xxx"; echo ${TAG}`)中的 `${TAG}` 会被 Drone 替换为空。 + +**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,避免中间 shell 变量。 + +### 6. `secrets` 字段注入环境变量不生效 + +**原因**:Drone 步骤级 `secrets:` 字段在某些场景下不会将 secret 注入为环境变量。 + +**解决**:改用 `environment: { VAR: { from_secret: name } }` 显式声明。 + +### 7. Runner 连接 Server 失败 + +**原因**:Runner 配置 `DRONE_RPC_PROTO=https` 但 drone-server 容器内部只监听 80 端口(HTTPS 由外部反向代理终止)。 + +**解决**:Runner 通过外部域名 `drone.internal.intelligrow.cn` 连接(走反向代理的 HTTPS),而非直连容器内网。 + +### 8. 管理员权限不生效(看不到 Trusted 选项) + +**原因**:`DRONE_USER_CREATE=username:zhanghuayu@intelligrow.ai,admin:true` 中的 username 使用了邮箱而非 Gitea 登录用户名。 + +**解决**:改为 `username:zhanghuayu,admin:true`(与 Gitea 用户名完全一致)。 + +### 9. 生产服务器镜像名 invalid reference + +**原因**:`docker-compose.prod.yml` 使用 `${DOCKER_REGISTRY}` 变量,但 `.env` 中未设置或变量名不匹配(曾误设为 `REGISTRY_HOST`)。 + +**解决**:在 `.env` 中添加 `DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000`,确保变量名与 compose 文件一致。 + +### 10. insecure-registries 格式错误 + +**原因**:`/etc/docker/daemon.json` 中 `insecure-registries` 配置了 `http://docker.internal.intelligrow.cn:5000`(带协议前缀),Docker 不识别。 + +**解决**:去掉 `http://` 前缀,直接写 `docker.internal.intelligrow.cn:5000`。 + +--- + +## 注意事项 + +1. **Registry 无认证**:仅建议内网使用;公网请加 TLS + 认证(或使用 Harbor)。 +2. **Docker Socket 挂载**:构建步骤挂载宿主机 Docker socket,需仓库开启 Trusted 模式。 +3. **并发部署保护**:`deploy-remote.sh` 使用 `/tmp/douyin-deploy.lock` 防止并发部署。 +4. **迁移失败即中断**:`alembic upgrade head` 失败会使整个部署失败,防止"假成功"。 +5. **健康检查不依赖 curl**:采用容器内 Python 请求 `/health`,与当前镜像一致。 +6. **deploy-remote.sh 需手动同步**:该脚本存放在生产服务器上,代码更新后需手动复制或通过部署流程同步。 +7. **VERSION 与 IMAGE_TAG**:`deploy-remote.sh` 同时 export 两个变量,兼容不同 compose 文件的命名。 diff --git a/externaldocs/drone_cicd_deployment_guide.md b/externaldocs/drone_cicd_deployment_guide.md new file mode 100644 index 0000000..a77249c --- /dev/null +++ b/externaldocs/drone_cicd_deployment_guide.md @@ -0,0 +1,947 @@ +# Drone CI/CD 通用部署指南 + +基于 **Drone CI + 私有 Docker Registry + Docker Compose** 的自动化部署方案。适用于任何基于 Docker 镜像部署的项目。 + +本指南基于抖音评论管理系统的实际落地经验编写,涵盖从零搭建到生产可用的全流程。 + +--- + +## 目录 + +- [前提条件](#前提条件) +- [架构概览](#架构概览) +- [第一步:基础设施准备(一次性)](#第一步基础设施准备一次性) +- [第二步:项目接入 Drone CI](#第二步项目接入-drone-ci) +- [第三步:编写 .drone.yml](#第三步编写-droneyml) +- [第四步:编写部署脚本](#第四步编写部署脚本) +- [第五步:生产服务器配置](#第五步生产服务器配置) +- [第六步:配置 Drone 仓库设置](#第六步配置-drone-仓库设置) +- [第七步:验证](#第七步验证) +- [回滚方案](#回滚方案) +- [多项目管理](#多项目管理) +- [踩坑清单](#踩坑清单) +- [故障排查速查表](#故障排查速查表) +- [附录:完整模板文件](#附录完整模板文件) + +--- + +## 前提条件 + +| 组件 | 要求 | +|------|------| +| Gitea 服务器 | 已有,可通过 HTTPS 访问 | +| Drone CI 服务器 | Ubuntu x64,已安装 Docker,已部署 Drone Server + Runner | +| 生产服务器 | Ubuntu x64,已安装 Docker + Docker Compose | +| 项目 | 包含 Dockerfile,可通过 `docker compose` 部署 | + +如果 Drone CI 尚未部署,参见 [附录 A:Drone CI 部署](#附录-a-drone-ci-部署)。 + +--- + +## 架构概览 + +```text +触发方式: + A) 创建 Git Tag(如 v1.0.0)→ 自动构建部署指定版本 + B) Drone Cron 定时任务 → 自动构建部署 latest + +流水线: + Drone CI 拉取代码 + | + +-- 构建镜像 1 (如 backend) → 推送到 Registry + +-- 构建镜像 2 (如 frontend) → 推送到 Registry + +-- 构建镜像 N ... + | + v + SSH 到生产服务器 → 执行部署脚本 + | + +-- docker compose pull (拉取新镜像) + +-- docker compose up -d (滚动更新) + +-- 健康检查 + +-- 数据库迁移(如有) + | + v + 发送通知(企业微信/钉钉/飞书,可选) +``` + +三台服务器各司其职: + +| 角色 | 职责 | +|------|------| +| Gitea 服务器 | 代码仓库,通过 Webhook 触发 Drone | +| Drone CI 服务器 | 执行构建 + 运行私有 Docker Registry | +| 生产服务器 | 运行 Docker Compose 部署的生产服务 | + +--- + +## 第一步:基础设施准备(一次性) + +以下操作只需做一次,所有项目共享。 + +### 1.1 启动私有 Docker Registry + +在 **Drone CI 服务器**上执行: + +```bash +docker run -d --name registry \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + --restart always \ + registry:2 +``` + +验证: + +```bash +curl http://localhost:5000/v2/_catalog +# 预期输出: {"repositories":[]} +``` + +### 1.2 配置 insecure-registries + +由于 Registry 未配置 TLS,需要在 **所有需要访问 Registry 的服务器** 上配置 insecure-registries。 + +编辑 `/etc/docker/daemon.json`: + +```json +{ + "insecure-registries": [":5000"] +} +``` + +然后重启 Docker: + +```bash +sudo systemctl restart docker +``` + +> **重要**: +> - `insecure-registries` 的值 **不要** 带 `http://` 前缀,直接写 `host:port` +> - 如果已有 `registry-mirrors` 等其他配置,合并到同一个 JSON 文件中,不要覆盖 +> - Drone CI 服务器和生产服务器 **都需要** 配置 + +**示例**(假设 Drone CI 服务器 IP 为 `192.168.1.100`,域名为 `ci.example.com`): + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live" + ], + "insecure-registries": [ + "ci.example.com:5000", + "192.168.1.100:5000" + ] +} +``` + +### 1.3 配置 SSH 免密登录 + +在 **Drone CI 服务器**上生成部署专用密钥: + +```bash +ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N "" +``` + +将公钥添加到 **生产服务器**: + +```bash +# 如果 SSH 端口是 22 +ssh-copy-id -i ~/.ssh/drone_deploy.pub @ + +# 如果 SSH 端口不是 22(如 3141) +ssh-copy-id -i ~/.ssh/drone_deploy.pub -p @ +``` + +验证: + +```bash +ssh -i ~/.ssh/drone_deploy -p @ "echo ok" +``` + +### 1.4 确保生产服务器用户有 Docker 权限 + +```bash +# 在生产服务器上 +sudo usermod -aG docker +# 需要重新登录生效 +``` + +--- + +## 第二步:项目接入 Drone CI + +### 2.1 在 Drone 面板激活仓库 + +1. 打开 Drone CI 面板(如 `https://drone.example.com`) +2. 点击 **SYNC** 同步 Gitea 仓库列表 +3. 找到目标仓库,点击 **ACTIVATE** + +### 2.2 开启 Trusted 模式 + +仓库 Settings → General → Project Settings → 勾选 **Trusted**。 + +> 这是必须的,因为构建步骤需要挂载宿主机 Docker socket。如果看不到 Trusted 选项,说明当前用户不是 Drone 管理员。检查 Drone Server 的 `DRONE_USER_CREATE` 环境变量,`username` 必须与 Gitea 登录用户名完全一致(不是邮箱)。 + +### 2.3 配置 Secrets + +在仓库 Settings → Secrets 添加以下密钥: + +| Secret 名称 | 说明 | 填写示例 | +|-------------|------|---------| +| `image_repo_` | 每个需构建的服务的完整镜像地址 | `ci.example.com:5000/myproject-backend` | +| `deploy_host` | 生产服务器 IP 或域名 | `192.168.1.200` | +| `deploy_user` | SSH 登录用户名 | `ubuntu` | +| `deploy_ssh_key` | SSH **私钥**完整内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...` | +| `deploy_path` | 生产服务器上的项目部署目录 | `/opt/myproject` | +| `wecom_webhook` | 通知 Webhook URL(可选) | `https://qyapi.weixin.qq.com/...` | + +> **关于 `image_repo_`**:每个需要构建的服务单独一个 secret。例如,项目有 backend 和 frontend,就创建 `backend_repo` 和 `frontend_repo`。Registry 地址必须与生产服务器 `.env` 中的镜像地址一致。 + +### 2.4 配置 Cron 定时构建(可选) + +仓库 Settings → Cron Jobs 添加: + +| 字段 | 值 | 说明 | +|------|------|------| +| Name | `nightly-build` | 任务名称 | +| Branch | `main` | 构建分支 | +| Schedule | `0 16 * * *` | UTC 16:00 = 北京时间 00:00 | + +> Drone 使用 UTC 时区解释 Cron 表达式。北京时间 = UTC + 8。 + +--- + +## 第三步:编写 .drone.yml + +在项目根目录创建 `.drone.yml`。 + +### 核心原则 + +1. **使用 `docker:27-cli` + 宿主机 Docker socket**,不用 `plugins/docker` DinD(避免 DinD 启动失败问题) +2. **使用 `environment: from_secret`** 注入密钥,不用 `secrets:` 字段 +3. **使用 `${DRONE_TAG:-latest}`** 作为镜像 tag,不自定义中间变量(避免 Drone 变量替换冲突) +4. **触发条件只用 `event`**,不叠加 `cron: [name]`(Drone 触发条件是 AND 运算) + +### 模板 + +```yaml +kind: pipeline +type: docker +name: build-and-deploy + +trigger: + event: + - tag # Git Tag 触发 + - cron # 定时触发 + +volumes: + - name: dockersock + host: + path: /var/run/docker.sock + +steps: + # ========================================== + # 构建步骤 - 每个服务一个 step + # ========================================== + - name: build- + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + IMAGE_REPO: + from_secret: _repo + commands: + - '[ -n "$IMAGE_REPO" ] || (echo "_repo secret is empty" && exit 1)' + - echo "Building , tag=${DRONE_TAG:-latest}" + - docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./ + - docker push "$IMAGE_REPO:${DRONE_TAG:-latest}" + - docker push "$IMAGE_REPO:latest" + + - name: build- + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + IMAGE_REPO: + from_secret: _repo + commands: + - '[ -n "$IMAGE_REPO" ] || (echo "_repo secret is empty" && exit 1)' + - echo "Building , tag=${DRONE_TAG:-latest}" + - docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./ + - docker push "$IMAGE_REPO:${DRONE_TAG:-latest}" + - docker push "$IMAGE_REPO:latest" + + # ========================================== + # 部署步骤 + # ========================================== + - name: deploy + image: appleboy/drone-ssh + environment: + DEPLOY_PATH: + from_secret: deploy_path + settings: + host: + from_secret: deploy_host + username: + from_secret: deploy_user + key: + from_secret: deploy_ssh_key + port: 22 # ← 改成实际 SSH 端口 + command_timeout: 1800s + script_stop: true + envs: + - DRONE_TAG + - DEPLOY_PATH + script: + - IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest" + - cd "$DEPLOY_PATH" + - bash scripts/deploy-remote.sh "$IMAGE_TAG" + + # ========================================== + # 通知步骤(可选) + # ========================================== + - name: notify-success + image: curlimages/curl + environment: + WEBHOOK_URL: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WEBHOOK_URL:-}" ]; then + VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}" + curl -sS -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}" + fi + when: + status: [success] + + - name: notify-failure + image: curlimages/curl + environment: + WEBHOOK_URL: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WEBHOOK_URL:-}" ]; then + VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}" + curl -sS -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n构建: ${DRONE_BUILD_LINK}\"}}" + fi + when: + status: [failure] +``` + +**替换占位符**: + +| 占位符 | 替换为 | 示例 | +|--------|--------|------| +| ``, `` | 你的服务名 | `backend`, `frontend`, `api`, `web` | +| `` | Docker build context 路径 | `./backend`, `./frontend`, `.` | +| `port: 22` | 生产服务器 SSH 端口 | `22`, `3141` | + +--- + +## 第四步:编写部署脚本 + +在项目中创建 `scripts/deploy-remote.sh`,这个脚本由 Drone SSH 步骤在生产服务器上调用。 + +### 通用模板 + +```bash +#!/usr/bin/env bash +# ======================================== +# 远程部署脚本 - 被 Drone CI SSH 调用 +# ======================================== +# 用法: bash scripts/deploy-remote.sh [image_tag] + +set -euo pipefail + +# ---------- 日志 ---------- +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } + +# ---------- 配置(可通过环境变量覆盖) ---------- +DEPLOY_PATH="${DEPLOY_PATH:-/opt/myproject}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +IMAGE_TAG="${1:-latest}" +LOCK_FILE="/tmp/$(basename "$DEPLOY_PATH")-deploy.lock" +MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}" +HEALTH_CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-3}" + +# ---------- 部署锁 ---------- +cleanup_lock() { rm -f "$LOCK_FILE"; } + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local old_pid + old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + log_error "已有部署进程运行中 (PID: $old_pid)" + exit 1 + fi + rm -f "$LOCK_FILE" + fi + echo "$$" > "$LOCK_FILE" + trap cleanup_lock EXIT +} + +# ---------- 工具函数 ---------- +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} + +# 健康检查 - 根据项目实际情况修改 +# 方式 1:容器内用 python 请求(适用于 Python 后端镜像) +wait_healthy_python() { + local service="$1" + local url="${2:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T "$service" python -c \ + "import sys,urllib.request;urllib.request.urlopen('${url}', timeout=3);sys.exit(0)" \ + >/dev/null 2>&1; then + log_info "${service} 健康检查通过" + return 0 + fi + log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# 方式 2:容器内用 curl 请求(适用于内置 curl 的镜像) +wait_healthy_curl() { + local service="$1" + local url="${2:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T "$service" curl -sf "$url" >/dev/null 2>&1; then + log_info "${service} 健康检查通过" + return 0 + fi + log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# 方式 3:从宿主机请求(适用于有端口映射的服务) +wait_healthy_host() { + local url="${1:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if curl -sf "$url" >/dev/null 2>&1; then + log_info "健康检查通过: $url" + return 0 + fi + log_info "等待服务就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# ---------- 主流程 ---------- +main() { + acquire_lock + + [ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; } + cd "$DEPLOY_PATH" + [ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; } + + # 导出镜像 tag 变量 - 根据 compose 文件中使用的变量名按需调整 + export IMAGE_TAG + export VERSION="$IMAGE_TAG" + # export TAG="$IMAGE_TAG" # 如果你的 compose 用 ${TAG} + + log_info "========== 开始部署 ==========" + log_info "镜像版本: $IMAGE_TAG" + log_info "部署目录: $DEPLOY_PATH" + log_info "Compose: $COMPOSE_FILE" + + # --- 1. 拉取新镜像 --- + log_info "[1/5] 拉取新镜像..." + compose pull + # 或者只拉取特定服务: compose pull backend frontend + + # --- 2. 滚动更新服务 --- + log_info "[2/5] 更新服务..." + compose up -d --remove-orphans + # 如果需要控制更新顺序,拆分为多步: + # compose up -d --no-deps backend + # compose up -d --no-deps frontend + + # --- 3. 健康检查 --- + log_info "[3/5] 健康检查..." + # 根据你的项目选择合适的健康检查方式,以下是示例: + # wait_healthy_python "backend" "http://127.0.0.1:8000/health" + # wait_healthy_curl "api" "http://127.0.0.1:3000/health" + # wait_healthy_host "http://127.0.0.1:8080/health" + # + # 如果健康检查失败,取消注释下面的代码: + # if ! wait_healthy_python "backend"; then + # log_error "健康检查失败" + # compose logs --tail=200 backend || true + # exit 1 + # fi + + # --- 4. 数据库迁移(如有) --- + log_info "[4/5] 数据库迁移..." + # 根据项目使用的迁移工具选择: + # compose exec -T backend alembic upgrade head # Python Alembic + # compose exec -T api npx prisma migrate deploy # Node Prisma + # compose exec -T api npm run migrate # 自定义脚本 + # compose exec -T backend python manage.py migrate # Django + # 如果不需要迁移,注释掉即可 + + # --- 5. 清理 --- + log_info "[5/5] 清理旧镜像..." + docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过" + + log_info "========== 部署完成 ==========" + log_info "版本: $IMAGE_TAG" + compose ps +} + +main "$@" +``` + +### 脚本要点 + +1. **部署锁**:通过 PID 文件防止并发部署 +2. **`set -euo pipefail`**:任何命令失败立即退出,防止"假成功" +3. **变量导出**:同时 export `IMAGE_TAG` 和 `VERSION`,兼容不同 compose 文件的命名习惯 +4. **健康检查**:提供三种方式,根据项目镜像内置的工具选择 +5. **数据库迁移**:在健康检查通过后执行,迁移失败会中断部署 + +--- + +## 第五步:生产服务器配置 + +### 5.1 目录结构 + +确保生产服务器上部署目录结构如下: + +```text +/opt/myproject/ # DEPLOY_PATH +├── docker-compose.prod.yml # 生产 compose 配置 +├── .env # 环境变量(镜像地址、数据库密码等) +└── scripts/ + └── deploy-remote.sh # 部署脚本(从代码仓库复制) +``` + +### 5.2 docker-compose.prod.yml 中的镜像引用 + +compose 文件中使用变量引用 Registry 中的镜像: + +```yaml +services: + backend: + image: ${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest} + # ... + + frontend: + image: ${DOCKER_REGISTRY}/myproject-frontend:${VERSION:-latest} + # ... +``` + +### 5.3 .env 文件 + +```bash +# 镜像仓库地址 - 必须与 Drone Secret 中的 image_repo 前缀一致 +DOCKER_REGISTRY=ci.example.com:5000 + +# 其他生产环境配置 +POSTGRES_PASSWORD=xxx +REDIS_PASSWORD=xxx +# ... +``` + +> **变量一致性**:假设 Drone Secret `backend_repo` = `ci.example.com:5000/myproject-backend`,那么 `.env` 中的 `DOCKER_REGISTRY` 必须是 `ci.example.com:5000`,compose 中的镜像名必须是 `${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}`。三者拼起来的镜像全名必须完全一致。 + +### 5.4 首次部署 + +首次部署需要手动初始化: + +```bash +cd /opt/myproject + +# 确认 .env 和 compose 文件就位 +ls -la .env docker-compose.prod.yml + +# 手动拉取并启动 +export VERSION=latest +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d + +# 运行数据库迁移(如有) +docker compose -f docker-compose.prod.yml exec -T backend alembic upgrade head +``` + +--- + +## 第六步:配置 Drone 仓库设置 + +### Secrets 快速清单 + +针对你的新项目,在 Drone 面板创建以下 Secrets: + +```text +backend_repo = :5000/-backend +frontend_repo = :5000/-frontend # 如果有 +deploy_host = +deploy_user = +deploy_ssh_key = +deploy_path = /opt/ +wecom_webhook = <通知 webhook url> # 可选 +``` + +> `deploy_ssh_key` 是第一步中生成的 SSH 私钥内容,所有项目可以共用同一个密钥(只要目标生产服务器相同)。 + +### Cron 配置 + +如果需要定时构建: + +| 字段 | 值 | +|------|------| +| Name | `nightly-build` | +| Branch | `main` | +| Schedule | `0 16 * * *`(北京时间 00:00) | + +--- + +## 第七步:验证 + +### 7.1 Registry 验证 + +```bash +# 在 Drone CI 服务器上 +curl http://localhost:5000/v2/_catalog +``` + +### 7.2 Tag 触发构建 + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +在 Drone 面板观察 pipeline 执行状态。 + +### 7.3 生产服务验证 + +```bash +# 检查容器状态 +ssh -p @ "cd /opt/myproject && docker compose -f docker-compose.prod.yml ps" + +# 检查镜像版本 +ssh -p @ "docker images | grep myproject" +``` + +--- + +## 回滚方案 + +### 方式 1:手动指定版本回滚 + +```bash +ssh -p @ +cd /opt/myproject +bash scripts/deploy-remote.sh v1.0.0 # 指定要回滚到的版本 +``` + +### 方式 2:直接 compose 回滚 + +```bash +ssh -p @ +cd /opt/myproject +export VERSION=v1.0.0 +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +``` + +### 方式 3:通过 Git Tag 触发 + +```bash +git tag v1.0.0-rollback v1.0.0 +git push origin v1.0.0-rollback +``` + +--- + +## 多项目管理 + +当多个项目共用同一套 Drone CI + Registry 基础设施时: + +### 共享部分(不需要重复) + +- Drone CI Server + Runner(已部署) +- Docker Registry(已运行在 :5000) +- SSH 密钥(可共用同一个 `drone_deploy` 密钥) +- `insecure-registries` 配置(已在两台服务器上配置) + +### 每个新项目需要做的 + +1. 在 Drone 面板激活仓库 + 开启 Trusted +2. 在 Drone 面板添加该仓库的 Secrets(镜像地址、部署路径) +3. 项目代码中添加 `.drone.yml` 和 `scripts/deploy-remote.sh` +4. 生产服务器创建部署目录 + 放置 compose 文件和 `.env` +5. (可选)配置 Cron 定时构建 + +### Registry 镜像命名规范 + +建议统一命名格式: + +```text +:5000/-: +``` + +示例: + +```text +ci.example.com:5000/douyin-backend:v1.0.0 +ci.example.com:5000/douyin-frontend:v1.0.0 +ci.example.com:5000/crm-api:v2.1.0 +ci.example.com:5000/crm-web:v2.1.0 +ci.example.com:5000/blog-app:latest +``` + +查看所有仓库: + +```bash +curl http://localhost:5000/v2/_catalog +``` + +查看某个镜像的所有 tag: + +```bash +curl http://localhost:5000/v2/-/tags/list +``` + +--- + +## 踩坑清单 + +以下是实际部署中遇到的问题,按出现频率排序: + +### 1. `insecure-registries` 格式错误 + +**错误**:Docker push 报 `server gave HTTP response to HTTPS client` + +**原因**:`/etc/docker/daemon.json` 中写了 `http://host:5000` + +**正确写法**:直接写 `host:5000`,不带协议前缀 + +### 2. Drone 变量替换冲突 + +**错误**:构建命令中的 shell 变量值为空 + +**原因**:Drone 在执行命令前会对 `${VAR}` 做自身的变量替换,自定义 shell 变量 `${TAG}` 被 Drone 替换为空 + +**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,不要赋值给中间变量 + +### 3. Secret 注入不生效 + +**错误**:环境变量为空,步骤因 secret 缺失而失败 + +**原因**:使用了步骤级 `secrets:` 字段 + +**解决**:改用 `environment: { VAR: { from_secret: name } }` + +### 4. plugins/docker DinD 启动失败 + +**错误**:`Unable to reach Docker Daemon after 15 attempts` + +**原因**:`plugins/docker` 内部启动 Docker 守护进程失败(cgroup / 存储驱动兼容性) + +**解决**:使用 `docker:27-cli` + 挂载宿主机 `/var/run/docker.sock` + +### 5. Trusted 选项不可见 + +**错误**:仓库 Settings 中找不到 Trusted 选项 + +**原因**:`DRONE_USER_CREATE` 的 `username` 填了邮箱,不是 Gitea 用户名 + +**解决**:`username` 必须与 Gitea 登录用户名完全一致 + +### 6. Tag 触发与 Cron 触发互相排斥 + +**错误**:推送 Tag 后 Drone 不触发构建 + +**原因**:`.drone.yml` 同时配了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 触发条件是 **AND** 运算 + +**解决**:只保留 `event: [tag, cron]`,不加 `cron:` 过滤 + +### 7. 生产服务器镜像名不匹配 + +**错误**:`docker compose pull` 报 invalid reference 或拉取到错误镜像 + +**原因**:Drone Secret 中的 Registry 地址(如 IP)与生产服务器 `.env` 中的(如域名)不一致 + +**解决**:统一使用同一个地址格式(建议统一用域名或统一用 IP) + +### 8. SSH 端口不对 + +**错误**:deploy 步骤超时或连接拒绝 + +**原因**:`appleboy/drone-ssh` 默认使用 22 端口 + +**解决**:在 `.drone.yml` 的 deploy settings 中显式指定 `port: ` + +### 9. Docker 权限不足 + +**错误**:生产服务器 `permission denied while trying to connect to the Docker daemon socket` + +**解决**:`sudo usermod -aG docker ` 后重新登录 + +### 10. daemon.json 被覆盖 + +**错误**:修改 `insecure-registries` 时丢失了已有的 `registry-mirrors` 配置 + +**预防**:修改前先 `cat /etc/docker/daemon.json` 查看现有内容,合并修改 + +--- + +## 故障排查速查表 + +| 现象 | 检查方向 | +|------|---------| +| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 配置 | +| Step 一直 pending | Runner 是否连通 Server;仓库是否 Trusted | +| 构建报 secret 为空 | 使用 `environment: from_secret` 而非 `secrets:` | +| Docker build 失败 | Dockerfile 是否正确;build context 路径是否对 | +| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 | +| Docker push 失败 (连接拒绝) | Registry 容器是否运行:`docker ps \| grep registry` | +| SSH 部署失败 | 密钥是否正确;端口是否匹配;用户是否有 Docker 权限 | +| 找不到部署脚本 | `deploy-remote.sh` 是否已复制到生产服务器的对应目录 | +| 镜像名 invalid reference | 生产服务器 `.env` 的 `DOCKER_REGISTRY` 变量是否正确 | +| compose pull 拉到旧镜像 | 检查 Registry 地址和镜像 tag 是否一致 | +| 数据库迁移失败 | `docker compose logs -f ` 查看报错 | +| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务是否正常启动 | + +--- + +## 附录 A:Drone CI 部署 + +如果 Drone CI 尚未部署,在 Drone CI 服务器上创建 `~/drone/docker-compose.yml`: + +```yaml +services: + drone-server: + image: drone/drone:2 + container_name: drone-server + restart: always + ports: + - "3080:80" # Drone Web UI 端口,通过反向代理暴露 HTTPS + environment: + - DRONE_GITEA_SERVER=https:// + - DRONE_GITEA_CLIENT_ID= + - DRONE_GITEA_CLIENT_SECRET= + - DRONE_SERVER_HOST= + - DRONE_SERVER_PROTO=https + - DRONE_RPC_SECRET=<随机生成的长字符串> + - DRONE_USER_CREATE=username:,admin:true + volumes: + - ./data:/data + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + depends_on: + - drone-server + environment: + # Runner 通过 Docker 内网直连 server 的 80 端口 + - DRONE_RPC_PROTO=http + - DRONE_RPC_HOST=drone-server + - DRONE_RPC_SECRET=<与 server 相同的 secret> + - DRONE_RUNNER_CAPACITY=2 + - DRONE_RUNNER_NAME=drone-runner-1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +启动: + +```bash +cd ~/drone +docker compose up -d +``` + +### Gitea OAuth App 配置 + +1. Gitea → 站点管理 → 应用 → 创建 OAuth2 应用 +2. 重定向 URI:`https:///login` +3. 将 Client ID 和 Client Secret 填入上面的 compose 配置 + +### 生成 RPC Secret + +```bash +openssl rand -hex 16 +``` + +### 反向代理配置(Nginx 示例) + +```nginx +server { + listen 443 ssl; + server_name drone.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +--- + +## 附录 B:新项目接入 Checklist + +为新项目接入 CI/CD 时,按此清单逐项完成: + +```text +□ 基础设施(一次性,已完成则跳过) + □ Registry 运行中 + □ insecure-registries 已配置(Drone CI 服务器 + 生产服务器) + □ SSH 密钥已配置 + +□ Drone 面板 + □ 仓库已激活(ACTIVATE) + □ Trusted 已勾选 + □ Secrets 已添加(image_repo, deploy_host, deploy_user, deploy_ssh_key, deploy_path) + □ Cron Job 已配置(如需定时构建) + +□ 项目代码 + □ .drone.yml 已创建 + □ scripts/deploy-remote.sh 已创建 + +□ 生产服务器 + □ 部署目录已创建 + □ docker-compose.prod.yml 已放置 + □ .env 已配置(DOCKER_REGISTRY 等) + □ scripts/deploy-remote.sh 已复制到部署目录 + □ 首次手动部署成功 + +□ 验证 + □ 推送 Tag 后 Drone 自动构建并部署成功 + □ 生产服务正常运行 + □ 回滚流程测试通过 +``` diff --git a/externaldocs/tikhub_api.md b/externaldocs/tikhub_api.md new file mode 100644 index 0000000..699df98 --- /dev/null +++ b/externaldocs/tikhub_api.md @@ -0,0 +1,901 @@ +# TikHub API 参考文档 + +> 版本:V5.2.9(2025-03-08) +> Swagger UI:https://api.tikhub.io +> ReDoc:https://api.tikhub.io/docs +> Apifox 文档:https://docs.tikhub.io + +--- + +## 基础信息 + +| 项目 | 说明 | +|------|------| +| Base URL(国际)| `https://api.tikhub.io` | +| Base URL(中国大陆)| `https://api.tikhub.dev`(路径和参数完全相同,仅域名不同)| +| 认证方式 | `Authorization: Bearer {API_TOKEN}` | +| 计费 | $0.001 / 请求(按量付费,支持批量折扣)| +| 速率限制 | 令牌桶 10 req/s(本项目在 `src/lib/tikhub.ts` 中已实现)| +| 超时 | 15s(本项目已配置)| + +所有端点均为 `GET` 请求(除特别标注),返回 JSON。 + +--- + +## 错误码 + +| HTTP 状态码 | 含义 | +|------------|------| +| 400 | 参数错误 | +| 401 | API Key 无效或未提供 | +| 402 | 余额不足 | +| 403 | 无权限访问该端点 | +| 404 | 内容不存在 | +| 429 | 请求过于频繁 | +| 500 | 服务器内部错误 | + +--- + +## 抖音(Douyin) + +### 抖音网页版 API — `/api/v1/douyin/web/` + +#### ★ `GET /api/v1/douyin/web/fetch_hot_search_result` + +> **本项目使用** · 抖音热搜榜 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构: +```json +{ + "data": { + "data": { + "trending_list": [ + { + "sentence_id": "string", // 热搜词 ID(用作 ContentItem.id) + "word": "string", // 热搜词文本 + "word_cover": { + "url_list": ["string"] // 封面图列表 + }, + "hot_value": 10000, // 热度值(→ like_count) + "view_count": 500000, // 阅读量(→ play_count) + "discuss_video_count": 200, // 讨论视频数(→ comment_count) + "event_time": 1709000000 // Unix 时间戳(秒) + } + ], + "word_list": [/* 同上格式,普通热词 */] + } + } +} +``` + +> **注意**:`trending_list` 为置顶热搜,`word_list` 为普通热词。本项目合并两者并按 `sentence_id` 去重。 + +--- + +#### ★ `GET /api/v1/douyin/web/fetch_one_video` + +> **本项目使用** · 抖音视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `aweme_id` | string | 是 | 视频 ID | + +响应结构: +```json +{ + "data": { + "aweme_detail": { + "aweme_id": "string", + "desc": "string", // 视频标题/描述 + "video": { + "cover": { "url_list": ["string"] }, + "play_addr": { "url_list": ["string"] } + }, + "author": { + "nickname": "string", + "avatar_thumb": { "url_list": ["string"] } + }, + "statistics": { + "play_count": 100000, + "digg_count": 5000, // 点赞数(→ like_count) + "comment_count": 200, + "share_count": 300 + }, + "create_time": 1709000000, + "text_extra": [{ "hashtag_name": "string" }] + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/douyin/web/fetch_user_profile` | 获取用户信息 | `unique_id`(用户名)或 `sec_user_id` | +| `GET /api/v1/douyin/web/fetch_user_post` | 获取用户作品 | `sec_user_id`, `cursor`(分页), `count` | +| `GET /api/v1/douyin/web/fetch_general_search_result` | 综合搜索 | `keyword`, `count`, `offset` | +| `GET /api/v1/douyin/web/fetch_video_search_result` | 视频搜索 | `keyword`, `count`, `offset` | +| `GET /api/v1/douyin/web/fetch_user_search_result_v2` | 用户搜索 | `keyword` | +| `GET /api/v1/douyin/web/fetch_post_comment` | 获取评论 | `aweme_id`, `cursor`, `count` | +| `GET /api/v1/douyin/app/v3/fetch_hot_search_list` | App 热搜榜(App V3 推荐)| 无 | +| `GET /api/v1/douyin/billboard/*` | 各类榜单(热门/音乐/挑战等)| 参见 Swagger | + +--- + +## TikTok + +### TikTok 网页版 API — `/api/v1/tiktok/web/` + +#### ★ `GET /api/v1/tiktok/web/fetch_explore_post` + +> **本项目使用** · TikTok 探索/热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构: +```json +{ + "data": { + "itemList": [ + { + "id": "string", // 视频 ID + "desc": "string", // 视频描述/标题 + "video": { + "cover": "string", // 封面图 URL(直接字符串) + "playAddr": "string" // 播放地址 + }, + "author": { + "nickname": "string", + "avatarThumb": "string", // 头像 URL + "uniqueId": "string" // 用户名(用于构建主页链接) + }, + "stats": { + "playCount": 500000, + "diggCount": 25000, // 点赞数(→ like_count) + "commentCount": 1000, + "shareCount": 500 + }, + "createTime": 1709000000, // Unix 时间戳(秒) + "challenges": [{ "title": "string" }] // 话题标签(→ tags) + } + ] + } +} +``` + +> **注意**:`video.cover` 是直接字符串,不是数组(与抖音不同)。 + +--- + +#### ★ `GET /api/v1/tiktok/web/fetch_post_detail` + +> **本项目使用** · TikTok 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `itemId` | string | 是 | 视频 ID | + +响应结构: +```json +{ + "data": { + "itemInfo": { + "itemStruct": { /* 同 itemList 中的单条格式 */ } + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/tiktok/web/fetch_user_profile` | 用户信息 | `uniqueId` 或 `secUid` | +| `GET /api/v1/tiktok/web/fetch_user_post` | 用户作品 | `secUid`, `cursor`, `count`, `post_item_list_request_type`(0=默认/1=热门/2=最旧) | +| `GET /api/v1/tiktok/web/fetch_user_like` | 用户喜欢(需公开)| `secUid`, `cursor`, `count` | +| `GET /api/v1/tiktok/web/fetch_post_comment` | 视频评论 | `aweme_id`, `cursor`, `count` | +| `GET /api/v1/tiktok/web/fetch_search_video` | 视频搜索 | `keyword`, `count`, `offset`, `search_id` | +| `GET /api/v1/tiktok/web/fetch_search_user` | 用户搜索 | `keyword`, `cursor`, `search_id` | +| `GET /api/v1/tiktok/web/fetch_tag_detail` | 话题详情 | `tag_name` | +| `GET /api/v1/tiktok/web/fetch_user_fans` | 粉丝列表 | `secUid`, `count`(默认30) | +| `GET /api/v1/tiktok/web/fetch_user_follow` | 关注列表 | `secUid`, `count`(默认30) | +| `POST /api/v1/tiktok/web/fetch_home_feed` | 首页推荐 | `count`, `cookie` | + +--- + +## 小红书(Xiaohongshu) + +### 小红书 Web V2 API — `/api/v1/xiaohongshu/web_v2/` + +#### ★ `GET /api/v1/xiaohongshu/web_v2/fetch_hot_list` + +> **本项目使用** · 获取热榜关键词列表(第一步) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构: +```json +{ + "data": { + "data": { + "items": [ + { "title": "string" } // 热词标题,取第一个作为搜索关键词 + ] + } + } +} +``` + +--- + +### 小红书 App V2 API — `/api/v1/xiaohongshu/app_v2/`(推荐) + +#### ★ `GET /api/v1/xiaohongshu/app_v2/search_notes` + +> **本项目使用** · 按关键词搜索笔记(第二步) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `keyword` | string | 是 | 搜索关键词 | +| `page` | integer | 否 | 页码,默认 1 | + +响应结构: +```json +{ + "data": { + "data": { + "items": [ + { + "model_type": "note", // 只处理 model_type==='note' 的条目 + "note": { + "id": "string", + "title": "string", + "desc": "string", + "type": "normal | video", + "timestamp": 1709000000, + "images_list": [ + { + "url": "string", + "url_size_large": "string" // 优先使用大图 + } + ], + "user": { + "nickname": "string", + "images": "string", // 头像 URL + "user_id": "string" + }, + "liked_count": 5000, // 点赞数(数字类型) + "comments_count": 200, // 评论数(注意:有 's') + "shared_count": 100, + "collected_count": 300, + "tag_info": [{ "name": "string" }] + } + } + ] + } + } +} +``` + +> **注意**: +> - `model_type` 不为 `"note"` 的条目(如广告)需过滤掉 +> - 搜索结果中视频笔记的 `video_url` 不可用,仅详情接口可获取视频链接 + +--- + +#### ★ `GET /api/v1/xiaohongshu/app_v2/get_mixed_note_detail` + +> **本项目使用** · 获取笔记详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `note_id` | string | 是 | 笔记 ID | + +响应结构: +```json +{ + "data": { + "id": "string", + "title": "string", + "name": "string", // 备用标题字段 + "desc": "string", + "type": "normal | video", + "timestamp": 1709000000, + "likes": 5000, // 点赞数(注意:详情与搜索字段名不同) + "comments_count": 200, + "shared_count": 100, + "collected_count": 300, + "images_list": [ + { "url": "string", "url_size_large": "string" } + ], + "user": { + "nickname": "string", + "images": "string" + }, + "tag_info": [{ "name": "string" }], + "video_info_v2": { + "url": "string" // 仅视频笔记有此字段 + } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/xiaohongshu/app_v2/fetch_note_detail` | 笔记详情(旧版)| `note_id` | +| `GET /api/v1/xiaohongshu/web_v2/fetch_user_info` | 用户信息 | `user_id` 或 `username` | +| `GET /api/v1/xiaohongshu/web_v2/fetch_user_notes` | 用户笔记列表 | `user_id`, `cursor` | +| `GET /api/v1/xiaohongshu/app_v2/fetch_feed` | 推荐信息流 | `count` | + +--- + +## 哔哩哔哩(Bilibili) + +### Bilibili 网页版 API — `/api/v1/bilibili/web/` + +#### ★ `GET /api/v1/bilibili/web/fetch_popular_video_list` + +> **本项目使用** · 综合热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pn` | integer | 否 | 页码,默认 1 | +| `ps` | integer | 否 | 每页数量,默认 20 | + +响应结构: +```json +{ + "data": { + "list": [ + { + "bvid": "string", // 视频 BV 号(主要 ID) + "aid": 123456, // av 号(备用) + "title": "string", + "pic": "string", // 封面图 URL(直接字符串) + "desc": "string", + "owner": { + "mid": 123456, + "name": "string", + "face": "string" // UP 主头像 + }, + "stat": { + "view": 100000, // 播放量(→ play_count) + "like": 5000, // 点赞数 + "reply": 200, // 评论数(→ comment_count) + "share": 300, + "danmaku": 500, // 弹幕数(未映射) + "favorite": 1000, // 收藏数(未映射) + "coin": 800 // 投币数(未映射) + }, + "pubdate": 1709000000, // Unix 时间戳(秒) + "duration": 300, // 时长(秒) + "tname": "string", // 分区名(→ tags[0]) + "tags": [{ "tag_id": 1, "tag_name": "string" }] + } + ] + } +} +``` + +--- + +#### ★ `GET /api/v1/bilibili/web/fetch_video_detail` + +> **本项目使用** · 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `bvid` | string | 是 | 视频 BV 号(如 `BV1xx411c7mD`)| + +响应结构: +```json +{ + "data": { /* 同 list 中单条格式 */ } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/bilibili/web/fetch_hot_search` | 热搜词 | 无 | +| `GET /api/v1/bilibili/web/fetch_user_info` | UP 主信息 | `mid`(用户 ID)| +| `GET /api/v1/bilibili/web/fetch_user_videos` | UP 主视频列表 | `mid`, `pn`, `ps` | +| `GET /api/v1/bilibili/web/fetch_video_comment` | 视频评论 | `oid`(aid), `pn`, `ps` | +| `GET /api/v1/bilibili/web/fetch_ranking` | 全站排行榜(每日更新)| `tid`(分区ID), `day`(1/3/7) | + +--- + +## 微博(Weibo) + +### 微博 App API — `/api/v1/weibo/app/` + +#### ★ `GET /api/v1/weibo/app/fetch_hot_search` + +> **本项目使用** · 微博热搜榜 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 无 | — | — | 无需参数 | + +响应结构(两种格式,取其一): + +**格式 1:实际微博状态** +```json +{ + "data": { + "statuses": [ + { + "id": "string | number", + "mid": "string", + "text": "string", // HTML 格式,需 stripHtml 处理 + "raw_text": "string", + "user": { + "screen_name": "string", + "profile_image_url": "string", + "avatar_hd": "string", + "avatar_large": "string" + }, + "pic_ids": ["string"], // 图片 ID 列表 + "pic_infos": { + "PIC_ID": { + "url": "string", + "large": { "url": "string" } + } + }, + "attitudes_count": 5000, // 点赞数(→ like_count) + "comments_count": 200, + "reposts_count": 300, // 转发数(→ share_count) + "reads_count": 100000, // 阅读数(→ play_count) + "created_at": "Mon Jan 01 00:00:00 +0800 2024" + } + ] + } +} +``` + +**格式 2:热搜话题** +```json +{ + "data": { + "band_list": [ + { + "word": "string", // 热搜词 + "num": 10000, // 热度(→ play_count) + "label_name": "string", // 标签(热、沸) + "mid": "string", + "rank": 1 + } + ], + "realtime": [/* 同上 */] + } +} +``` + +> **注意**: +> - `text` 字段含 HTML 标签(``, `` 等),需 strip 处理 +> - 图片 URL 构建:`https://ww1.sinaimg.cn/large/{pic_id}.jpg` +> - 时间格式:`"Mon Jan 01 00:00:00 +0800 2024"`,可直接用 `new Date()` 解析 + +--- + +#### ★ `GET /api/v1/weibo/app/fetch_post_detail` + +> **本项目使用** · 微博详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `mid` | string | 是 | 微博 ID | + +响应结构: +```json +{ + "data": { /* 同 statuses 中单条格式 */ } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/weibo/web/fetch_hot_search` | 网页版热搜榜 | 无 | +| `GET /api/v1/weibo/web_v2/fetch_hot_search` | 网页版 V2 热搜榜 | 无 | +| `GET /api/v1/weibo/web/fetch_user_info` | 用户信息 | `uid` 或 `screen_name` | +| `GET /api/v1/weibo/web/fetch_user_statuses` | 用户微博列表 | `uid`, `page`, `count` | + +--- + +## YouTube + +### YouTube 网页版 API — `/api/v1/youtube/web/` + +#### ★ `GET /api/v1/youtube/web/fetch_trending_video` + +> **本项目使用** · YouTube 热门视频列表 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构: +```json +{ + "data": { + "items": [ + { + "id": "string | { videoId: string }", // 视频 ID(可能是字符串或对象) + "snippet": { + "title": "string", + "description": "string", + "channelTitle": "string", + "publishedAt": "2024-01-15T10:00:00Z", // ISO 8601 + "thumbnails": { + "maxres": { "url": "string" }, + "high": { "url": "string" }, + "medium": { "url": "string" }, + "default": { "url": "string" } + }, + "tags": ["string"] + }, + "statistics": { + "viewCount": "100000", // 注意:YouTube 统计字段是字符串类型 + "likeCount": "5000", // 需 parseInt() 转换 + "commentCount": "200" + } + } + ] + } +} +``` + +> **注意**: +> - `statistics.viewCount` 等字段为**字符串类型**,需用 `parseInt()` 转换 +> - `id` 字段可能是字符串或 `{ videoId: string }` 对象,需兼容处理 +> - 缩略图优先级:`maxres > high > medium > default` + +--- + +#### ★ `GET /api/v1/youtube/web/fetch_video_detail` + +> **本项目使用** · YouTube 视频详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `video_id` | string | 是 | 视频 ID(如 `dQw4w9WgXcQ`)| + +响应结构: +```json +{ + "data": { + "items": [{ /* 同 trending 中单条格式 */ }] + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/youtube/web/fetch_channel_info` | 频道信息 | `channel_id` 或 `username` | +| `GET /api/v1/youtube/web/fetch_channel_videos` | 频道视频列表 | `channel_id`, `count`, `page_token` | +| `GET /api/v1/youtube/web/fetch_search_results` | 视频搜索 | `keyword`, `count`, `page_token` | +| `GET /api/v1/youtube/web/fetch_playlist_videos` | 播放列表视频 | `playlist_id`, `count`, `page_token` | + +--- + +## Instagram + +### Instagram 网页版 API — `/api/v1/instagram/web/` + +#### ★ `GET /api/v1/instagram/web/fetch_explore_feed` + +> **本项目使用** · Instagram 探索页内容 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构(两种格式,需同时处理): + +**格式 1:扁平 items 数组** +```json +{ + "data": { + "items": [ + { + "media": { /* InstagramMediaItem */ } + } + ] + } +} +``` + +**格式 2:分区 sectional_items** +```json +{ + "data": { + "sectional_items": [ + { + "layout_content": { + "medias": [ + { "media": { /* InstagramMediaItem */ } } + ] + } + } + ] + } +} +``` + +**InstagramMediaItem 结构**: +```json +{ + "pk": "string", + "id": "string", + "code": "string", // shortcode(用于构建 URL 和作为 ID) + "media_type": 1, // 1=图片, 2=视频, 8=轮播 + "caption": { "text": "string" } | "string" | null, + "image_versions2": { + "candidates": [ + { "url": "string", "width": 1080, "height": 1080 } + ] + }, + "thumbnail_url": "string", // 视频封面(备用) + "video_url": "string", // 仅 media_type=2 时有值 + "user": { + "username": "string", + "full_name": "string", + "profile_pic_url": "string" + }, + "like_count": 5000, + "comment_count": 200, + "taken_at": 1709000000 // Unix 时间戳(秒) +} +``` + +> **注意**: +> - 以 `code`(shortcode)作为内容 ID,URL 格式:`https://www.instagram.com/p/{code}/` +> - `caption` 字段格式不固定,可能是对象、字符串或 null,需兼容处理 + +--- + +#### ★ `GET /api/v1/instagram/web/fetch_post_detail` + +> **本项目使用** · Instagram 帖子详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `shortcode` | string | 是 | 帖子 shortcode(即内容 ID)| + +响应结构: +```json +{ + "data": { + "items": [{ /* InstagramMediaItem */ }] + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/instagram/web/fetch_user_info` | 用户信息 | `username` | +| `GET /api/v1/instagram/web/fetch_user_posts` | 用户帖子 | `username`, `cursor`, `count` | +| `GET /api/v1/instagram/web/fetch_search_result` | 搜索用户/标签 | `keyword` | +| `GET /api/v1/instagram/web/fetch_hashtag_posts` | 标签内容 | `hashtag`, `cursor` | + +--- + +## Twitter / X + +### Twitter 网页版 API — `/api/v1/twitter/web/` + +#### ★ `GET /api/v1/twitter/web/fetch_trending_topics` + +> **本项目使用** · Twitter 热门话题和趋势推文 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `count` | integer | 否 | 返回数量,默认 20 | + +响应结构(三种格式,需全部处理): + +**格式 1:推文数组** +```json +{ + "data": { + "tweets": [ + { + "id_str": "string", + "full_text": "string", // 优先使用 + "text": "string", // 备用 + "user": { + "name": "string", + "screen_name": "string", // 用户名(@后面的部分) + "profile_image_url_https": "string" + }, + "favorite_count": 5000, // 点赞数(→ like_count) + "reply_count": 200, // 回复数(→ comment_count) + "retweet_count": 300, // 转推数(→ share_count) + "created_at": "Mon Jan 01 00:00:00 +0000 2024", + "entities": { + "media": [ + { "media_url_https": "string", "type": "photo | video" } + ], + "hashtags": [{ "text": "string" }] + }, + "extended_entities": { + "media": [/* 同上,优先使用 */] + } + } + ] + } +} +``` + +**格式 2:GraphQL 时间线(Timeline Instructions)** +```json +{ + "data": { + "timeline": { + "instructions": [ + { + "entries": [ + { + "content": { + "itemContent": { + "tweet_results": { + "result": { + "legacy": { /* 同 TwitterTweet 格式 */ }, + "core": { + "user_results": { + "result": { + "legacy": { /* TwitterUser 格式 */ } + } + } + } + } + } + } + } + } + ] + } + ] + } + } +} +``` + +**格式 3:热门话题列表** +```json +{ + "data": { + "trends": [ + { + "name": "string", // 话题名称 + "tweet_volume": 10000, // 推文量(→ play_count) + "query": "string" // URL 编码的搜索查询 + } + ] + } +} +``` + +> **注意**: +> - 时间格式:`"Mon Jan 01 00:00:00 +0000 2024"`,可直接用 `new Date()` 解析 +> - 封面图来自 `extended_entities.media`(优先)或 `entities.media`,找 `type === 'photo'` 的条目 +> - 原帖 URL 格式:`https://x.com/{screen_name}/status/{id_str}` + +--- + +#### ★ `GET /api/v1/twitter/web/fetch_tweet_detail` + +> **本项目使用** · 推文详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `tweet_id` | string | 是 | 推文 ID | + +响应结构(两种格式): + +```json +// 格式 1(GraphQL) +{ + "data": { + "tweetResult": { + "result": { + "legacy": { /* TwitterTweet */ }, + "core": { "user_results": { "result": { "legacy": { /* TwitterUser */ } } } } + } + } + } +} + +// 格式 2(直接对象) +{ + "data": { + "tweet": { /* TwitterTweet */ } + } +} +``` + +--- + +#### 其他常用端点 + +| 端点 | 说明 | 关键参数 | +|------|------|----------| +| `GET /api/v1/twitter/web/fetch_user_info` | 用户信息 | `screen_name` | +| `GET /api/v1/twitter/web/fetch_user_tweets` | 用户推文 | `screen_name`, `cursor`, `count` | +| `GET /api/v1/twitter/web/fetch_search_timeline` | 搜索推文 | `keyword`, `cursor`, `count` | +| `GET /api/v1/twitter/web/fetch_tweet_replies` | 推文回复 | `tweet_id`, `cursor` | + +--- + +## 通用说明 + +### 本项目中使用的端点汇总 + +| 平台 | fetchHotList 端点 | fetchDetail 端点 | +|------|------------------|-----------------| +| 抖音 | `douyin/web/fetch_hot_search_result` | `douyin/web/fetch_one_video` → 回退 `fetch_hot_search_result` | +| TikTok | `tiktok/web/fetch_explore_post` | `tiktok/web/fetch_post_detail` | +| 小红书 | `xiaohongshu/web_v2/fetch_hot_list` + `xiaohongshu/app_v2/search_notes` | `xiaohongshu/app_v2/get_mixed_note_detail` | +| YouTube | `youtube/web/fetch_trending_video` | `youtube/web/fetch_video_detail` | +| Instagram | `instagram/web/fetch_explore_feed` | `instagram/web/fetch_post_detail` | +| Twitter | `twitter/web/fetch_trending_topics` | `twitter/web/fetch_tweet_detail` | +| 哔哩哔哩 | `bilibili/web/fetch_popular_video_list` | `bilibili/web/fetch_video_detail` | +| 微博 | `weibo/app/fetch_hot_search` | `weibo/app/fetch_post_detail` | + +### 时间戳处理 + +| 平台 | 时间格式 | 处理方式 | +|------|---------|----------| +| 抖音、TikTok、小红书、B站、微博、Instagram | Unix 时间戳(秒)| `new Date(timestamp * 1000).toISOString()` | +| YouTube | ISO 8601 字符串 | 直接使用 | +| Twitter | `"Mon Jan 01 00:00:00 +0000 2024"` | `new Date(dateStr).toISOString()` | +| 微博 | `"Mon Jan 01 00:00:00 +0800 2024"` | `new Date(dateStr).toISOString()` | + +### 图片 URL 类型 + +| 平台 | 图片字段类型 | +|------|------------| +| 抖音 | `url_list: string[]`(取 `[0]`)| +| TikTok | `cover: string`(直接字符串)| +| 小红书 | `images_list[0].url_size_large \|\| url` | +| B站 | `pic: string`(直接字符串)| +| YouTube | `thumbnails.maxres.url`(对象嵌套)| +| Instagram | `image_versions2.candidates[0].url` | +| Twitter | `entities/extended_entities.media[0].media_url_https` | +| 微博 | `pic_infos[id].large.url` 或 `https://ww1.sinaimg.cn/large/{id}.jpg` | + +--- + +*文档基于项目实际使用端点 + TikHub OpenAPI 规范整理。完整端点列表(700+)请访问 https://api.tikhub.io* diff --git a/package.json b/package.json index c5cd1d4..4d8fc19 100644 --- a/package.json +++ b/package.json @@ -3,35 +3,17 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint" - }, - "dependencies": { - "@tanstack/react-query": "^5.90.21", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.576.0", - "next": "16.1.6", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "19.2.3", - "react-dom": "19.2.3", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "zustand": "^5.0.11" + "dev": "pnpm -r --parallel dev", + "build": "pnpm -r build", + "test": "pnpm -r test", + "test:watch": "pnpm -r --parallel test:watch", + "test:coverage": "pnpm -r test:coverage", + "lint": "pnpm --filter @muse/frontend lint", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:real": "playwright test --config=playwright-real.config.ts" }, "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.1.6", - "shadcn": "^3.8.5", - "tailwindcss": "^4", - "tw-animate-css": "^1.4.0", - "typescript": "^5" + "@playwright/test": "^1.58.2" } } diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..042ca4f --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,25 @@ +{ + "name": "@muse/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@muse/shared": "workspace:*", + "hono": "^4.7.0", + "@hono/node-server": "^1.14.0" + }, + "devDependencies": { + "typescript": "^5", + "tsx": "^4.19.0", + "vitest": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", + "@types/node": "^20" + } +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts new file mode 100644 index 0000000..9f7cbe0 --- /dev/null +++ b/packages/backend/src/app.ts @@ -0,0 +1,20 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { tikhubRoutes } from "./routes/tikhub"; +import { settingsRoutes } from "./routes/settings"; + +const app = new Hono(); + +app.use( + "*", + cors({ + origin: process.env.CORS_ORIGIN || "http://localhost:3000", + }) +); + +app.route("/api/tikhub", tikhubRoutes); +app.route("/api/settings", settingsRoutes); + +app.get("/health", (c) => c.json({ status: "ok" })); + +export { app }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000..552b7d1 --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1,8 @@ +import { serve } from "@hono/node-server"; +import { app } from "./app"; + +const port = parseInt(process.env.PORT || "3001", 10); + +serve({ fetch: app.fetch, port }, () => { + console.log(`🚀 Muse Backend running on http://localhost:${port}`); +}); diff --git a/packages/backend/src/lib/adapters/bilibili.test.ts b/packages/backend/src/lib/adapters/bilibili.test.ts new file mode 100644 index 0000000..e526ee3 --- /dev/null +++ b/packages/backend/src/lib/adapters/bilibili.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { BilibiliAdapter } from "./bilibili"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("BilibiliAdapter", () => { + let adapter: BilibiliAdapter; + + beforeEach(() => { + adapter = new BilibiliAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from popular video list", async () => { + // Bilibili double-wraps: tikhubFetch unwraps outer, inner is { code, data: { list } } + mockFetch.mockResolvedValueOnce({ + code: 0, + message: "OK", + data: { + list: [ + { + aid: 123456789, + bvid: "BV1xx411c7mD", + title: "B站热门视频", + pic: "https://i0.hdslb.com/cover.jpg", + owner: { + name: "UP主小明", + face: "https://i0.hdslb.com/avatar.jpg", + }, + stat: { + aid: 123456789, + view: 1000000, + like: 50000, + favorite: 20000, + reply: 3000, + share: 5000, + }, + pubdate: 1709424000, + tname: "科技", + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("123456789"); + expect(items[0].title).toBe("B站热门视频"); + expect(items[0].platform).toBe("bilibili"); + expect(items[0].author_name).toBe("UP主小明"); + expect(items[0].play_count).toBe(1000000); + expect(items[0].like_count).toBe(50000); + expect(items[0].collect_count).toBe(20000); + expect(items[0].comment_count).toBe(3000); + expect(items[0].share_count).toBe(5000); + expect(items[0].cover_url).toBe("https://i0.hdslb.com/cover.jpg"); + expect(items[0].tags).toEqual(["科技"]); + expect(items[0].original_url).toBe("https://www.bilibili.com/video/BV1xx411c7mD"); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("uses default values for missing fields", async () => { + mockFetch.mockResolvedValueOnce({ + data: { list: [{}] }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("无标题"); + expect(items[0].author_name).toBe("未知作者"); + expect(items[0].play_count).toBeUndefined(); + }); + + it("slices results to requested count", async () => { + const list = Array.from({ length: 30 }, (_, i) => ({ + bvid: `BV${i}`, + title: `Video ${i}`, + })); + mockFetch.mockResolvedValueOnce({ data: { list } }); + + const items = await adapter.fetchTrending(5); + expect(items).toHaveLength(5); + }); + + it("maps aid as primary ID", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + list: [ + { + aid: 999888, + bvid: "BV1abc", + title: "AID Test", + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].id).toBe("999888"); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from video detail", async () => { + // Detail response: { code, message, data: { View: {...} } } + mockFetch.mockResolvedValueOnce({ + code: 0, + message: "OK", + data: { + View: { + aid: 111222333, + bvid: "BV1detail", + title: "详情视频", + pic: "https://i0.hdslb.com/detail.jpg", + owner: { name: "详情UP主", face: "https://face.jpg" }, + stat: { + view: 500000, + like: 25000, + favorite: 10000, + reply: 1500, + share: 3000, + }, + pubdate: 1709424000, + }, + }, + }); + + const item = await adapter.fetchDetail("111222333"); + + expect(item.id).toBe("111222333"); + expect(item.title).toBe("详情视频"); + expect(item.platform).toBe("bilibili"); + expect(item.play_count).toBe(500000); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("BV999"); + expect(item.title).toBe("无标题"); + expect(item.author_name).toBe("未知作者"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/bilibili.ts b/packages/backend/src/lib/adapters/bilibili.ts new file mode 100644 index 0000000..749ab54 --- /dev/null +++ b/packages/backend/src/lib/adapters/bilibili.ts @@ -0,0 +1,66 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class BilibiliAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/bilibili/web/fetch_com_popular" + ); + + // tikhubFetch unwraps outer { code, data } envelope, but Bilibili wraps again: + // data = { code: 0, message: "OK", data: { list: [...] } } + const inner = data?.data || data; + const list = inner?.list || data?.list || []; + const items = Array.isArray(list) ? list : []; + + return items + .slice(0, count) + .map((item: Record, index: number) => + this.mapToContentItem(item, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/bilibili/web/fetch_video_detail", + { aid: id } + ); + + // Response: { code, message, data: { View: {...} } } + const inner = data?.data || data; + const videoData = inner?.View || inner || {}; + return this.mapToContentItem(videoData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number): ContentItem { + const stat = raw?.stat || {}; + const owner = raw?.owner || {}; + const bvid = raw?.bvid || raw?.id || `bl-${index}`; + const aid = raw?.aid || stat?.aid || ""; + + return { + id: String(aid || bvid), + title: raw?.title || "无标题", + cover_url: raw?.pic || undefined, + video_url: undefined, + author_name: owner?.name || raw?.author || "未知作者", + author_avatar: owner?.face || undefined, + play_count: stat?.view ?? undefined, + like_count: stat?.like ?? undefined, + collect_count: stat?.favorite ?? undefined, + comment_count: stat?.reply ?? undefined, + share_count: stat?.share ?? undefined, + publish_time: raw?.pubdate + ? new Date(raw.pubdate * 1000).toISOString() + : raw?.ctime + ? new Date(raw.ctime * 1000).toISOString() + : new Date().toISOString(), + platform: "bilibili", + original_url: `https://www.bilibili.com/video/${bvid}`, + tags: raw?.tname ? [raw.tname] : undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/douyin.test.ts b/packages/backend/src/lib/adapters/douyin.test.ts new file mode 100644 index 0000000..f135ac0 --- /dev/null +++ b/packages/backend/src/lib/adapters/douyin.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DouyinAdapter } from "./douyin"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("DouyinAdapter", () => { + let adapter: DouyinAdapter; + + beforeEach(() => { + adapter = new DouyinAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from hot video list", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + objs: [ + { + item_id: "123", + item_title: "测试视频", + item_cover_url: "https://img.douyin.com/cover.jpg", + item_url: "https://douyin.com/video/123", + nick_name: "测试作者", + avatar_url: "https://img.douyin.com/avatar.jpg", + play_cnt: 10000, + like_cnt: 500, + publish_time: 1709424000, + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("123"); + expect(items[0].title).toBe("测试视频"); + expect(items[0].platform).toBe("douyin"); + expect(items[0].author_name).toBe("测试作者"); + expect(items[0].play_count).toBe(10000); + expect(items[0].like_count).toBe(500); + expect(items[0].cover_url).toBe("https://img.douyin.com/cover.jpg"); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({ data: {} }); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("uses default values for missing fields", async () => { + mockFetch.mockResolvedValueOnce({ + data: { objs: [{}] }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("无标题"); + expect(items[0].author_name).toBe("未知作者"); + expect(items[0].play_count).toBeUndefined(); + }); + + it("slices results to requested count", async () => { + const objs = Array.from({ length: 30 }, (_, i) => ({ + item_id: String(i), + item_title: `Video ${i}`, + })); + mockFetch.mockResolvedValueOnce({ data: { objs } }); + + const items = await adapter.fetchTrending(5); + expect(items).toHaveLength(5); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from video detail", async () => { + mockFetch.mockResolvedValueOnce({ + aweme_detail: { + aweme_id: "456", + desc: "详情视频描述", + video: { + cover: { url_list: ["https://cover.jpg"] }, + play_addr: { url_list: ["https://video.mp4"] }, + }, + author: { + nickname: "作者名", + avatar_thumb: { url_list: ["https://avatar.jpg"] }, + }, + statistics: { + play_count: 50000, + digg_count: 2000, + comment_count: 100, + share_count: 50, + collect_count: 300, + }, + create_time: 1709424000, + share_url: "https://www.douyin.com/video/456", + text_extra: [{ hashtag_name: "热门" }, { hashtag_name: "创意" }], + }, + }); + + const item = await adapter.fetchDetail("456"); + + expect(item.id).toBe("456"); + expect(item.title).toBe("详情视频描述"); + expect(item.cover_url).toBe("https://cover.jpg"); + expect(item.video_url).toBe("https://video.mp4"); + expect(item.author_name).toBe("作者名"); + expect(item.play_count).toBe(50000); + expect(item.like_count).toBe(2000); + expect(item.tags).toEqual(["热门", "创意"]); + expect(item.platform).toBe("douyin"); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("999"); + expect(item.id).toBe("unknown"); + expect(item.title).toBe("无标题"); + expect(item.author_name).toBe("未知作者"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/douyin.ts b/packages/backend/src/lib/adapters/douyin.ts new file mode 100644 index 0000000..627cf34 --- /dev/null +++ b/packages/backend/src/lib/adapters/douyin.ts @@ -0,0 +1,88 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class DouyinAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/douyin/billboard/fetch_hot_total_video_list", + undefined, + "POST" + ); + + const list = data?.data?.objs || data?.data?.list || data?.data || []; + const items = Array.isArray(list) ? list : []; + + return items.slice(0, count).map((item: Record, index: number) => + this.mapToContentItem(item, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/douyin/web/fetch_one_video", + { aweme_id: id } + ); + + const videoData = data?.aweme_detail || data?.data?.aweme_detail || data?.data || {}; + return this.mapDetailItem(videoData); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number): ContentItem { + return { + id: String(raw?.item_id || `douyin-${index}`), + title: raw?.item_title || "无标题", + cover_url: raw?.item_cover_url || undefined, + video_url: raw?.item_url || undefined, + author_name: raw?.nick_name || "未知作者", + author_avatar: raw?.avatar_url || undefined, + play_count: raw?.play_cnt ?? undefined, + like_count: raw?.like_cnt ?? undefined, + collect_count: undefined, + comment_count: undefined, + share_count: undefined, + publish_time: raw?.publish_time + ? new Date(raw.publish_time * 1000).toISOString() + : new Date().toISOString(), + platform: "douyin", + original_url: `https://www.douyin.com/video/${raw?.item_id || ""}`, + tags: undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapDetailItem(raw: any): ContentItem { + const stats = raw?.statistics || raw?.stats || {}; + const author = raw?.author || {}; + + return { + id: String(raw?.aweme_id || raw?.id || "unknown"), + title: raw?.desc || raw?.title || "无标题", + cover_url: + raw?.video?.cover?.url_list?.[0] || + raw?.video?.dynamic_cover?.url_list?.[0] || + undefined, + video_url: raw?.video?.play_addr?.url_list?.[0] || undefined, + author_name: author?.nickname || "未知作者", + author_avatar: author?.avatar_thumb?.url_list?.[0] || undefined, + play_count: stats?.play_count ?? undefined, + like_count: stats?.digg_count ?? undefined, + collect_count: stats?.collect_count ?? undefined, + comment_count: stats?.comment_count ?? undefined, + share_count: stats?.share_count ?? undefined, + publish_time: raw?.create_time + ? new Date(raw.create_time * 1000).toISOString() + : new Date().toISOString(), + platform: "douyin", + original_url: + raw?.share_url || + `https://www.douyin.com/video/${raw?.aweme_id || raw?.id || ""}`, + tags: + raw?.text_extra + ?.map((t: { hashtag_name?: string }) => t.hashtag_name) + .filter(Boolean) || undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/index.test.ts b/packages/backend/src/lib/adapters/index.test.ts new file mode 100644 index 0000000..308b442 --- /dev/null +++ b/packages/backend/src/lib/adapters/index.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from "vitest"; +import { getAdapter, getSupportedPlatforms } from "./index"; + +describe("getAdapter", () => { + it("returns DouyinAdapter for douyin", () => { + const adapter = getAdapter("douyin"); + expect(adapter).not.toBeNull(); + expect(adapter!.fetchTrending).toBeDefined(); + expect(adapter!.fetchDetail).toBeDefined(); + }); + + it("returns TikTokAdapter for tiktok", () => { + const adapter = getAdapter("tiktok"); + expect(adapter).not.toBeNull(); + }); + + it("returns XiaohongshuAdapter for xiaohongshu", () => { + const adapter = getAdapter("xiaohongshu"); + expect(adapter).not.toBeNull(); + }); + + it("returns YouTubeAdapter for youtube", () => { + const adapter = getAdapter("youtube"); + expect(adapter).not.toBeNull(); + }); + + it("returns InstagramAdapter for instagram", () => { + const adapter = getAdapter("instagram"); + expect(adapter).not.toBeNull(); + }); + + it("returns TwitterAdapter for twitter", () => { + const adapter = getAdapter("twitter"); + expect(adapter).not.toBeNull(); + }); + + it("returns BilibiliAdapter for bilibili", () => { + const adapter = getAdapter("bilibili"); + expect(adapter).not.toBeNull(); + }); + + it("returns WeiboAdapter for weibo", () => { + const adapter = getAdapter("weibo"); + expect(adapter).not.toBeNull(); + }); +}); + +describe("getSupportedPlatforms", () => { + it("returns all registered platforms", () => { + const platforms = getSupportedPlatforms(); + expect(platforms).toContain("douyin"); + expect(platforms).toContain("tiktok"); + expect(platforms).toContain("xiaohongshu"); + expect(platforms).toContain("youtube"); + expect(platforms).toContain("instagram"); + expect(platforms).toContain("twitter"); + expect(platforms).toContain("bilibili"); + expect(platforms).toContain("weibo"); + expect(platforms).toHaveLength(8); + }); +}); diff --git a/src/lib/adapters/index.ts b/packages/backend/src/lib/adapters/index.ts similarity index 58% rename from src/lib/adapters/index.ts rename to packages/backend/src/lib/adapters/index.ts index c98f502..40e8d94 100644 --- a/src/lib/adapters/index.ts +++ b/packages/backend/src/lib/adapters/index.ts @@ -1,12 +1,22 @@ -import type { Platform, PlatformAdapter } from "@/types/content"; +import type { Platform, PlatformAdapter } from "@muse/shared"; import { DouyinAdapter } from "./douyin"; import { TikTokAdapter } from "./tiktok"; import { XiaohongshuAdapter } from "./xiaohongshu"; +import { YouTubeAdapter } from "./youtube"; +import { InstagramAdapter } from "./instagram"; +import { TwitterAdapter } from "./twitter"; +import { BilibiliAdapter } from "./bilibili"; +import { WeiboAdapter } from "./weibo"; const adapters: Partial> = { douyin: new DouyinAdapter(), tiktok: new TikTokAdapter(), xiaohongshu: new XiaohongshuAdapter(), + youtube: new YouTubeAdapter(), + instagram: new InstagramAdapter(), + twitter: new TwitterAdapter(), + bilibili: new BilibiliAdapter(), + weibo: new WeiboAdapter(), }; export function getAdapter(platform: Platform): PlatformAdapter | null { diff --git a/packages/backend/src/lib/adapters/instagram.test.ts b/packages/backend/src/lib/adapters/instagram.test.ts new file mode 100644 index 0000000..6ca47c1 --- /dev/null +++ b/packages/backend/src/lib/adapters/instagram.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { InstagramAdapter } from "./instagram"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("InstagramAdapter", () => { + let adapter: InstagramAdapter; + + beforeEach(() => { + adapter = new InstagramAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from flat items format", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "ABC123", + caption: { text: "Beautiful sunset photo" }, + image_versions2: { + candidates: [{ url: "https://ig.com/photo.jpg" }], + }, + user: { username: "photographer", profile_pic_url: "https://ig.com/avatar.jpg" }, + like_count: 5000, + comment_count: 200, + taken_at: 1709424000, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("ABC123"); + expect(items[0].title).toBe("Beautiful sunset photo"); + expect(items[0].platform).toBe("instagram"); + expect(items[0].author_name).toBe("photographer"); + expect(items[0].like_count).toBe(5000); + expect(items[0].cover_url).toBe("https://ig.com/photo.jpg"); + }); + + it("returns mapped ContentItem[] from sections format", async () => { + mockFetch.mockResolvedValueOnce({ + sections: [ + { + section_id: "123", + name: "Fashion", + subsections: [ + { + medias: [ + { + code: "SEC001", + caption: "Section post", + user: { username: "user1" }, + like_count: 1000, + }, + ], + }, + ], + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("SEC001"); + expect(items[0].title).toBe("Section post"); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("handles caption as object with text", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "cap-obj", + caption: { text: "Caption from object" }, + user: { username: "test" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("Caption from object"); + }); + + it("handles caption as string", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "cap-str", + caption: "String caption", + user: { username: "test" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("String caption"); + }); + + it("handles null caption", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "cap-null", + caption: null, + user: { username: "test" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("Untitled"); + }); + + it("uses thumbnail_url as fallback cover", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "thumb-test", + thumbnail_url: "https://ig.com/thumb.jpg", + user: { username: "test" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg"); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from post detail", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + code: "detail-789", + caption: { text: "Detail post" }, + user: { username: "detail_user", full_name: "Detail User" }, + like_count: 10000, + comment_count: 500, + taken_at: 1709424000, + }, + ], + }); + + const item = await adapter.fetchDetail("detail-789"); + + expect(item.id).toBe("detail-789"); + expect(item.title).toBe("Detail post"); + expect(item.platform).toBe("instagram"); + expect(item.like_count).toBe(10000); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("999"); + expect(item.title).toBe("Untitled"); + expect(item.author_name).toBe("Unknown"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/instagram.ts b/packages/backend/src/lib/adapters/instagram.ts new file mode 100644 index 0000000..7d18675 --- /dev/null +++ b/packages/backend/src/lib/adapters/instagram.ts @@ -0,0 +1,96 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class InstagramAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/instagram/v1/fetch_explore_sections" + ); + + // Response: { sections: [{ subsections: [{ medias: [...] }] }] } + // Or flat: { items: [...] } or { sectional_items: [...] } + let items: unknown[] = []; + + if (Array.isArray(data?.sections)) { + for (const section of data.sections) { + const subsections = section?.subsections || []; + for (const sub of subsections) { + const medias = sub?.medias || []; + for (const m of medias) { + items.push(m?.media || m); + } + } + } + } else if (Array.isArray(data?.items)) { + items = data.items; + } else if (Array.isArray(data?.sectional_items)) { + for (const section of data.sectional_items) { + const medias = section?.layout_content?.medias || []; + for (const m of medias) { + items.push(m?.media || m); + } + } + } + + return items + .slice(0, count) + .map((item: unknown, index: number) => + this.mapToContentItem(item as Record, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/instagram/v2/fetch_post_info", + { shortcode: id } + ); + + const postData = data?.items?.[0] || data?.data?.items?.[0] || data || {}; + return this.mapToContentItem(postData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number): ContentItem { + const postId = raw?.code || raw?.shortcode || raw?.pk || raw?.id || `ig-${index}`; + + // caption can be { text }, string, or null + let title = ""; + const caption = raw?.caption; + if (caption && typeof caption === "object" && caption.text) { + title = caption.text; + } else if (typeof caption === "string") { + title = caption; + } + if (!title) title = raw?.title || "Untitled"; + + const coverUrl = + raw?.image_versions2?.candidates?.[0]?.url || + raw?.thumbnail_url || + raw?.display_url || + undefined; + + const user = raw?.user || raw?.owner || {}; + + return { + id: String(postId), + title: title.slice(0, 200), + cover_url: coverUrl, + video_url: raw?.video_url || undefined, + author_name: user?.username || user?.full_name || "Unknown", + author_avatar: user?.profile_pic_url || undefined, + play_count: raw?.video_view_count ?? raw?.play_count ?? undefined, + like_count: raw?.like_count ?? undefined, + collect_count: raw?.saved_count ?? undefined, + comment_count: raw?.comment_count ?? undefined, + share_count: raw?.reshare_count ?? undefined, + publish_time: raw?.taken_at + ? new Date(raw.taken_at * 1000).toISOString() + : new Date().toISOString(), + platform: "instagram", + original_url: `https://www.instagram.com/p/${postId}/`, + tags: undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/tiktok.test.ts b/packages/backend/src/lib/adapters/tiktok.test.ts new file mode 100644 index 0000000..21610ad --- /dev/null +++ b/packages/backend/src/lib/adapters/tiktok.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TikTokAdapter } from "./tiktok"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("TikTokAdapter", () => { + let adapter: TikTokAdapter; + + beforeEach(() => { + adapter = new TikTokAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from explore posts", async () => { + mockFetch.mockResolvedValueOnce({ + itemList: [ + { + id: "tt-123", + desc: "Trending video", + video: { + cover: "https://tiktok.com/cover.jpg", + playAddr: "https://tiktok.com/play.mp4", + }, + author: { + nickname: "Creator", + uniqueId: "creator123", + avatarThumb: "https://tiktok.com/avatar.jpg", + }, + stats: { + playCount: 100000, + diggCount: 5000, + commentCount: 200, + shareCount: 80, + collectCount: 150, + }, + createTime: 1709424000, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("tt-123"); + expect(items[0].title).toBe("Trending video"); + expect(items[0].platform).toBe("tiktok"); + expect(items[0].author_name).toBe("Creator"); + expect(items[0].play_count).toBe(100000); + expect(items[0].like_count).toBe(5000); + expect(items[0].cover_url).toBe("https://tiktok.com/cover.jpg"); + }); + + it("handles empty response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("handles alternative data shapes", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + itemList: [ + { + id: "alt-1", + desc: "Alt format", + video: {}, + author: {}, + stats: {}, + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items).toHaveLength(1); + expect(items[0].id).toBe("alt-1"); + }); + + it("uses default values for missing fields", async () => { + mockFetch.mockResolvedValueOnce({ + itemList: [{ video: {}, author: {}, stats: {} }], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("Untitled"); + expect(items[0].author_name).toBe("Unknown"); + }); + + it("slices results to count", async () => { + const itemList = Array.from({ length: 25 }, (_, i) => ({ + id: String(i), + desc: `Video ${i}`, + video: {}, + author: {}, + stats: {}, + })); + mockFetch.mockResolvedValueOnce({ itemList }); + + const items = await adapter.fetchTrending(10); + expect(items).toHaveLength(10); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from post detail", async () => { + mockFetch.mockResolvedValueOnce({ + itemInfo: { + itemStruct: { + id: "detail-1", + desc: "Detail video", + video: { cover: "https://cover.jpg" }, + author: { nickname: "Author" }, + stats: { playCount: 999, diggCount: 100 }, + createTime: 1709424000, + }, + }, + }); + + const item = await adapter.fetchDetail("detail-1"); + expect(item.id).toBe("detail-1"); + expect(item.title).toBe("Detail video"); + expect(item.play_count).toBe(999); + }); + + it("extracts tags from challenges array", async () => { + mockFetch.mockResolvedValueOnce({ + itemInfo: { + itemStruct: { + id: "tag-1", + video: {}, + author: {}, + stats: {}, + challenges: [{ title: "trending" }, { title: "viral" }], + }, + }, + }); + + const item = await adapter.fetchDetail("tag-1"); + expect(item.tags).toEqual(["trending", "viral"]); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/tiktok.ts b/packages/backend/src/lib/adapters/tiktok.ts new file mode 100644 index 0000000..d39f013 --- /dev/null +++ b/packages/backend/src/lib/adapters/tiktok.ts @@ -0,0 +1,89 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class TikTokAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/tiktok/web/fetch_explore_post" + ); + + const list = + data?.itemList || + data?.data?.itemList || + data?.items || + []; + + const items = Array.isArray(list) ? list : []; + + return items.slice(0, count).map((item: Record, index: number) => + this.mapToContentItem(item, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/tiktok/web/fetch_post_detail", + { itemId: id } + ); + + const videoData = + data?.data?.aweme_detail || data?.itemInfo?.itemStruct || data?.data || data || {}; + return this.mapToContentItem(videoData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number): ContentItem { + const stats = raw?.stats || raw?.statistics || {}; + const author = raw?.author || {}; + const video = raw?.video || {}; + + const coverUrl = + (typeof video?.cover === "string" ? video.cover : null) || + video?.cover?.url_list?.[0] || + video?.originCover || + video?.dynamicCover || + raw?.cover || + undefined; + + return { + id: String(raw?.id || raw?.aweme_id || `tiktok-${index}`), + title: raw?.desc || raw?.title || "Untitled", + cover_url: coverUrl, + video_url: + (typeof video?.playAddr === "string" ? video.playAddr : null) || + video?.playAddr?.url_list?.[0] || + video?.play_addr?.url_list?.[0] || + undefined, + author_name: + author?.nickname || author?.uniqueId || author?.unique_id || "Unknown", + author_avatar: + author?.avatarThumb || author?.avatarMedium || + author?.avatar_thumb?.url_list?.[0] || + undefined, + play_count: stats?.playCount ?? stats?.play_count ?? undefined, + like_count: stats?.diggCount ?? stats?.digg_count ?? undefined, + collect_count: stats?.collectCount ?? stats?.collect_count ?? undefined, + comment_count: stats?.commentCount ?? stats?.comment_count ?? undefined, + share_count: stats?.shareCount ?? stats?.share_count ?? undefined, + publish_time: raw?.createTime + ? new Date(raw.createTime * 1000).toISOString() + : raw?.create_time + ? new Date(raw.create_time * 1000).toISOString() + : new Date().toISOString(), + platform: "tiktok", + original_url: + raw?.share_url || + `https://www.tiktok.com/@${author?.uniqueId || author?.unique_id || "user"}/video/${raw?.id || raw?.aweme_id || ""}`, + tags: + raw?.textExtra + ?.map((t: { hashtagName?: string; hashtag_name?: string }) => t.hashtagName || t.hashtag_name) + .filter(Boolean) || + raw?.challenges + ?.map((c: { title?: string }) => c.title) + .filter(Boolean) || + undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/twitter.test.ts b/packages/backend/src/lib/adapters/twitter.test.ts new file mode 100644 index 0000000..cd3519f --- /dev/null +++ b/packages/backend/src/lib/adapters/twitter.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TwitterAdapter } from "./twitter"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("TwitterAdapter", () => { + let adapter: TwitterAdapter; + + beforeEach(() => { + adapter = new TwitterAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from tweets format", async () => { + mockFetch.mockResolvedValueOnce({ + tweets: [ + { + id_str: "1234567890", + full_text: "This is a trending tweet!", + user: { + name: "Test User", + screen_name: "testuser", + profile_image_url_https: "https://pbs.twimg.com/avatar.jpg", + }, + favorite_count: 5000, + retweet_count: 2000, + reply_count: 300, + created_at: "Mon Jan 15 08:00:00 +0000 2024", + entities: { + hashtags: [{ text: "trending" }, { text: "test" }], + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("1234567890"); + expect(items[0].title).toBe("This is a trending tweet!"); + expect(items[0].platform).toBe("twitter"); + expect(items[0].author_name).toBe("Test User"); + expect(items[0].like_count).toBe(5000); + expect(items[0].share_count).toBe(2000); + expect(items[0].tags).toEqual(["trending", "test"]); + }); + + it("returns mapped ContentItem[] from GraphQL format", async () => { + mockFetch.mockResolvedValueOnce({ + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + legacy: { + id_str: "gql-001", + full_text: "GraphQL tweet", + user: { name: "GQL User" }, + favorite_count: 100, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("gql-001"); + expect(items[0].title).toBe("GraphQL tweet"); + }); + + it("returns mapped ContentItem[] from trends format", async () => { + mockFetch.mockResolvedValueOnce({ + trends: [ + { + name: "#TrendingTopic", + tweet_volume: 50000, + url: "https://twitter.com/search?q=%23TrendingTopic", + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].title).toBe("#TrendingTopic"); + expect(items[0].play_count).toBe(50000); + expect(items[0].author_name).toBe("Twitter Trending"); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("strips HTML from tweet text", async () => { + mockFetch.mockResolvedValueOnce({ + tweets: [ + { + id_str: "html-001", + full_text: "Check out this link!", + user: { name: "HTML User" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("Check out this link!"); + }); + + it("extracts media from extended_entities", async () => { + mockFetch.mockResolvedValueOnce({ + tweets: [ + { + id_str: "media-001", + full_text: "Tweet with media", + user: { name: "Media User" }, + extended_entities: { + media: [ + { + media_url_https: "https://pbs.twimg.com/media/test.jpg", + video_info: { variants: [{ url: "https://video.twimg.com/test.mp4" }] }, + }, + ], + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg"); + expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4"); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from GraphQL detail format", async () => { + mockFetch.mockResolvedValueOnce({ + tweetResult: { + result: { + legacy: { + id_str: "detail-001", + full_text: "Detail tweet content", + favorite_count: 1000, + retweet_count: 500, + created_at: "Wed Feb 01 12:00:00 +0000 2024", + }, + core: { + user_results: { + result: { + legacy: { + name: "Detail Author", + screen_name: "detailauthor", + }, + }, + }, + }, + }, + }, + }); + + const item = await adapter.fetchDetail("detail-001"); + + expect(item.id).toBe("detail-001"); + expect(item.title).toBe("Detail tweet content"); + expect(item.author_name).toBe("Detail Author"); + expect(item.like_count).toBe(1000); + }); + + it("returns mapped ContentItem from direct tweet format", async () => { + mockFetch.mockResolvedValueOnce({ + tweet: { + legacy: { + id_str: "direct-001", + full_text: "Direct tweet", + user: { name: "Direct User" }, + favorite_count: 200, + }, + }, + }); + + const item = await adapter.fetchDetail("direct-001"); + + expect(item.id).toBe("direct-001"); + expect(item.title).toBe("Direct tweet"); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("999"); + expect(item.title).toBe("Untitled"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/twitter.ts b/packages/backend/src/lib/adapters/twitter.ts new file mode 100644 index 0000000..c9159a6 --- /dev/null +++ b/packages/backend/src/lib/adapters/twitter.ts @@ -0,0 +1,133 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +function stripHtml(text: string): string { + return text.replace(/<[^>]*>/g, "").trim(); +} + +function parseTwitterDate(dateStr: string): string { + // Twitter format: "Mon Jan 01 00:00:00 +0000 2024" + const d = new Date(dateStr); + return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); +} + +export class TwitterAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/twitter/web/fetch_trending" + ); + + // Response formats: + // 1. { trends: [{ name, description, context }] } — trending topics + // 2. { tweets: [...] } — tweet objects + // 3. GraphQL timeline.instructions[].entries[] + let items: unknown[] = []; + + if (Array.isArray(data?.trends)) { + items = data.trends; + } else if (Array.isArray(data?.tweets)) { + items = data.tweets; + } else if (data?.timeline?.instructions) { + const instructions = data.timeline.instructions; + for (const inst of instructions) { + const entries = inst?.entries || []; + for (const entry of entries) { + const tweet = + entry?.content?.itemContent?.tweet_results?.result?.legacy || + entry?.content?.itemContent?.tweet_results?.result || + null; + if (tweet) items.push(tweet); + } + } + } + + return items + .slice(0, count) + .map((item: unknown, index: number) => + this.mapToContentItem(item as Record, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/twitter/web/fetch_tweet_detail", + { tweet_id: id } + ); + + // Two formats: GraphQL tweetResult.result or direct tweet object + const tweetData = + data?.tweetResult?.result?.legacy || + data?.tweetResult?.result || + data?.tweet?.legacy || + data?.tweet || + data || {}; + + const userResult = + data?.tweetResult?.result?.core?.user_results?.result?.legacy || + data?.tweet?.core?.user_results?.result?.legacy || + null; + + return this.mapToContentItem(tweetData, 0, userResult); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem { + const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`; + + // For trend items (from fetch_trending: { name, description, context }) + if (raw?.name && !raw?.full_text && !raw?.text) { + return { + id: String(tweetId || `tw-trend-${index}`), + title: raw.name, + cover_url: undefined, + video_url: undefined, + author_name: raw?.context || "Twitter Trending", + author_avatar: undefined, + play_count: raw?.tweet_volume ?? undefined, + like_count: undefined, + collect_count: undefined, + comment_count: undefined, + share_count: undefined, + publish_time: new Date().toISOString(), + platform: "twitter", + original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`, + tags: undefined, + }; + } + + const text = raw?.full_text || raw?.text || ""; + const title = stripHtml(text).slice(0, 200) || "Untitled"; + + const user = userOverride || raw?.user || {}; + const media = + raw?.extended_entities?.media?.[0] || + raw?.entities?.media?.[0] || + null; + + const coverUrl = media?.media_url_https || media?.media_url || undefined; + + return { + id: String(tweetId), + title, + cover_url: coverUrl, + video_url: media?.video_info?.variants?.[0]?.url || undefined, + author_name: user?.name || user?.screen_name || "Unknown", + author_avatar: user?.profile_image_url_https || undefined, + play_count: undefined, + like_count: raw?.favorite_count ?? undefined, + collect_count: raw?.bookmark_count ?? undefined, + comment_count: raw?.reply_count ?? undefined, + share_count: raw?.retweet_count ?? undefined, + publish_time: raw?.created_at + ? parseTwitterDate(raw.created_at) + : new Date().toISOString(), + platform: "twitter", + original_url: `https://twitter.com/i/status/${tweetId}`, + tags: raw?.entities?.hashtags + ?.map((h: { text?: string }) => h.text) + .filter(Boolean) || undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/weibo.test.ts b/packages/backend/src/lib/adapters/weibo.test.ts new file mode 100644 index 0000000..8cd429e --- /dev/null +++ b/packages/backend/src/lib/adapters/weibo.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { WeiboAdapter } from "./weibo"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("WeiboAdapter", () => { + let adapter: WeiboAdapter; + + beforeEach(() => { + adapter = new WeiboAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from statuses format", async () => { + mockFetch.mockResolvedValueOnce({ + statuses: [ + { + mid: "5012345678", + text: "今天天气真好 链接", + user: { + screen_name: "微博用户", + id: "1234567", + profile_image_url: "https://tvax.sinaimg.cn/avatar.jpg", + }, + attitudes_count: 5000, + comments_count: 1200, + reposts_count: 800, + created_at: "Mon Jan 15 08:00:00 +0800 2024", + pic_infos: { + "pic001": { + large: { url: "https://ww1.sinaimg.cn/large/pic001.jpg" }, + }, + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("5012345678"); + expect(items[0].title).toBe("今天天气真好 链接"); + expect(items[0].platform).toBe("weibo"); + expect(items[0].author_name).toBe("微博用户"); + expect(items[0].like_count).toBe(5000); + expect(items[0].comment_count).toBe(1200); + expect(items[0].share_count).toBe(800); + expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/pic001.jpg"); + }); + + it("returns mapped ContentItem[] from band_list format", async () => { + mockFetch.mockResolvedValueOnce({ + band_list: [ + { + word: "热搜话题一", + num: 1500000, + category: "社会", + realpos: 1, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].title).toBe("热搜话题一"); + expect(items[0].play_count).toBe(1500000); + expect(items[0].author_name).toBe("微博热搜"); + expect(items[0].tags).toEqual(["社会"]); + }); + + it("returns mapped ContentItem[] from realtime format", async () => { + mockFetch.mockResolvedValueOnce({ + realtime: [ + { + word: "实时热搜", + raw_hot: 2000000, + realpos: 2, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].title).toBe("实时热搜"); + expect(items[0].play_count).toBe(2000000); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("strips HTML from weibo text", async () => { + mockFetch.mockResolvedValueOnce({ + statuses: [ + { + mid: "html-001", + text: "粗体链接文字", + user: { screen_name: "测试" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("粗体和链接文字"); + }); + + it("constructs image URL from pic_ids when pic_infos missing", async () => { + mockFetch.mockResolvedValueOnce({ + statuses: [ + { + mid: "picid-001", + text: "有图微博", + user: { screen_name: "test" }, + pic_ids: ["abc123def"], + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/abc123def.jpg"); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from post detail", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + mid: "detail-001", + text: "详情微博内容", + user: { + screen_name: "详情作者", + id: "9876543", + }, + attitudes_count: 10000, + comments_count: 2000, + reposts_count: 1500, + created_at: "Wed Feb 01 12:00:00 +0800 2024", + }, + }); + + const item = await adapter.fetchDetail("detail-001"); + + expect(item.id).toBe("detail-001"); + expect(item.title).toBe("详情微博内容"); + expect(item.platform).toBe("weibo"); + expect(item.like_count).toBe(10000); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("999"); + expect(item.title).toBe("无标题"); + expect(item.author_name).toBe("未知作者"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/weibo.ts b/packages/backend/src/lib/adapters/weibo.ts new file mode 100644 index 0000000..6bfb25e --- /dev/null +++ b/packages/backend/src/lib/adapters/weibo.ts @@ -0,0 +1,152 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +function stripHtml(text: string): string { + return text.replace(/<[^>]*>/g, "").trim(); +} + +function parseWeiboDate(dateStr: string): string { + // Weibo format: "Mon Jan 01 00:00:00 +0800 2024" + const d = new Date(dateStr); + return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); +} + +export class WeiboAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/weibo/app/fetch_hot_search" + ); + + // Response formats: + // 1. { items: [{ type: "vertical", category: "group", items: [{ data: { desc, ... } }] }] } + // — App hot search with nested card structure + // 2. { statuses: [...] } — weibo posts + // 3. { band_list: [...] } or { realtime: [...] } — flat hot topics + + if (Array.isArray(data?.statuses)) { + return data.statuses + .slice(0, count) + .map((item: unknown, index: number) => + this.mapStatusToContentItem(item as Record, index) + ); + } + + // App nested card format: items[] > group items > card data with desc + if (Array.isArray(data?.items)) { + const topics: unknown[] = []; + for (const item of data.items) { + if (item?.type === "vertical" && item?.category === "group") { + const innerItems = item?.items || []; + for (const inner of innerItems) { + const cardData = inner?.data; + if (cardData?.desc) { + topics.push(cardData); + } + } + } + } + if (topics.length > 0) { + return topics + .slice(0, count) + .map((item: unknown, index: number) => + this.mapTopicToContentItem(item as Record, index) + ); + } + } + + const topics = data?.band_list || data?.realtime || []; + if (Array.isArray(topics) && topics.length > 0) { + return topics + .slice(0, count) + .map((item: unknown, index: number) => + this.mapTopicToContentItem(item as Record, index) + ); + } + + return []; + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/weibo/app/fetch_status_detail", + { status_id: id } + ); + + const postData = data?.data || data || {}; + return this.mapStatusToContentItem(postData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapStatusToContentItem(raw: any, index: number): ContentItem { + const user = raw?.user || {}; + const postId = raw?.mid || raw?.id || raw?.idstr || `wb-${index}`; + + const text = raw?.text || raw?.text_raw || ""; + const title = stripHtml(text).slice(0, 200) || "无标题"; + + // Extract image URL from pic_infos or pic_ids + let coverUrl: string | undefined; + if (raw?.pic_infos) { + const firstPicId = Object.keys(raw.pic_infos)[0]; + if (firstPicId) { + coverUrl = raw.pic_infos[firstPicId]?.large?.url || raw.pic_infos[firstPicId]?.original?.url; + } + } + if (!coverUrl && raw?.pic_ids?.[0]) { + coverUrl = `https://ww1.sinaimg.cn/large/${raw.pic_ids[0]}.jpg`; + } + if (!coverUrl) { + coverUrl = raw?.thumbnail_pic || undefined; + } + + return { + id: String(postId), + title, + cover_url: coverUrl, + video_url: raw?.page_info?.media_info?.mp4_720p_mp4 || undefined, + author_name: user?.screen_name || user?.name || "未知作者", + author_avatar: user?.profile_image_url || user?.avatar_large || undefined, + play_count: raw?.reads_count ?? raw?.page_info?.play_count ?? undefined, + like_count: raw?.attitudes_count ?? undefined, + collect_count: undefined, + comment_count: raw?.comments_count ?? undefined, + share_count: raw?.reposts_count ?? undefined, + publish_time: raw?.created_at + ? parseWeiboDate(raw.created_at) + : new Date().toISOString(), + platform: "weibo", + original_url: `https://weibo.com/${user?.id || "u"}/${postId}`, + tags: undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapTopicToContentItem(raw: any, index: number): ContentItem { + // App card format: { desc, scheme, pic, icon, ... } + // Band list format: { word, num, category, ... } + const word = raw?.desc || raw?.word || raw?.note || raw?.query || ""; + const category = raw?.category || raw?.label_name || ""; + + return { + id: String(raw?.mid || raw?.realpos || index), + title: word || "热搜话题", + cover_url: raw?.pic || raw?.icon?.url || undefined, + video_url: undefined, + author_name: "微博热搜", + author_avatar: undefined, + play_count: raw?.num || raw?.raw_hot || undefined, + like_count: undefined, + collect_count: undefined, + comment_count: undefined, + share_count: undefined, + publish_time: new Date().toISOString(), + platform: "weibo", + original_url: raw?.scheme + ? `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}` + : `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}`, + tags: category ? [category] : undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/xiaohongshu.test.ts b/packages/backend/src/lib/adapters/xiaohongshu.test.ts new file mode 100644 index 0000000..bb4ae26 --- /dev/null +++ b/packages/backend/src/lib/adapters/xiaohongshu.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { XiaohongshuAdapter } from "./xiaohongshu"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("XiaohongshuAdapter", () => { + let adapter: XiaohongshuAdapter; + + beforeEach(() => { + adapter = new XiaohongshuAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from hot inspiration feed", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + items: [ + { + hot_id: "xhs-001", + title: "热门话题测试", + cover: "https://xhs.com/cover.jpg", + score: 5000000, + score_text: "500万人在看", + type: "美妆", + }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("xhs-001"); + expect(items[0].title).toBe("热门话题测试"); + expect(items[0].platform).toBe("xiaohongshu"); + expect(items[0].cover_url).toBe("https://xhs.com/cover.jpg"); + expect(items[0].play_count).toBe(5000000); + expect(items[0].tags).toEqual(["美妆"]); + }); + + it("parses score_text with 万 unit", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + items: [ + { hot_id: "1", title: "Topic", score_text: "1000万人在看" }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].play_count).toBe(10000000); + }); + + it("parses score_text with 亿 unit", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + items: [ + { hot_id: "2", title: "Topic", score_text: "1.5亿人在看" }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].play_count).toBe(150000000); + }); + + it("filters out items with title 无标题", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + items: [ + { hot_id: "1", title: "有标题" }, + { hot_id: "2" }, + ], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items.every((i) => i.title !== "无标题")).toBe(true); + }); + + it("extracts title from deeplink when title is missing", async () => { + const deeplink = encodeURIComponent( + '{"content":"#春天穿搭[话题]"}' + ); + mockFetch.mockResolvedValueOnce({ + data: { + items: [{ hot_id: "3", deeplink }], + }, + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("春天穿搭"); + }); + + it("handles empty response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from note detail", async () => { + mockFetch.mockResolvedValueOnce({ + data: { + note_list: [ + { + note_id: "note-123", + display_title: "笔记标题", + images_list: [{ url: "https://xhs.com/img.jpg" }], + user: { + nickname: "小红书博主", + avatar: "https://xhs.com/avatar.jpg", + }, + interact_info: { + liked_count: 300, + collected_count: 50, + comment_count: 20, + share_count: 10, + }, + time: 1709424000, + tag_list: [{ name: "穿搭" }, { name: "日常" }], + }, + ], + }, + }); + + const item = await adapter.fetchDetail("note-123"); + + expect(item.id).toBe("note-123"); + expect(item.title).toBe("笔记标题"); + expect(item.author_name).toBe("小红书博主"); + expect(item.like_count).toBe(300); + expect(item.collect_count).toBe(50); + expect(item.tags).toEqual(["穿搭", "日常"]); + expect(item.platform).toBe("xiaohongshu"); + }); + + it("handles missing detail data", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("missing"); + expect(item.title).toBe("无标题"); + expect(item.author_name).toBe("未知作者"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/xiaohongshu.ts b/packages/backend/src/lib/adapters/xiaohongshu.ts new file mode 100644 index 0000000..6f41298 --- /dev/null +++ b/packages/backend/src/lib/adapters/xiaohongshu.ts @@ -0,0 +1,158 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class XiaohongshuAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/xiaohongshu/app_v2/get_creator_hot_inspiration_feed", + { cursor: "" } + ); + + const list = + data?.data?.items || + data?.items || + []; + + const items = Array.isArray(list) ? list : []; + + return items + .slice(0, count) + .map((item: Record, index: number) => + this.mapHotItemToContentItem(item, index) + ) + .filter((item: ContentItem) => item.title !== "无标题"); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/xiaohongshu/app/get_note_info", + { note_id: id } + ); + + const noteData = + data?.data?.note_list?.[0] || + data?.data?.items?.[0]?.note || + data?.data || + data || + {}; + return this.mapNoteToContentItem(noteData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapHotItemToContentItem(raw: any, index: number): ContentItem { + let title = raw?.title || ""; + if (!title && raw?.deeplink) { + try { + const decoded = decodeURIComponent(raw.deeplink); + const match = decoded.match(/"content":"([^"]+)"/); + if (match) { + title = match[1] + .trim() + .replace(/#/g, "") + .replace(/\[话题\]/g, "") + .trim(); + } + } catch { + // ignore decode errors + } + } + if (!title) title = "无标题"; + + let viewCount: number | undefined; + const score = raw?.score; + const scoreText = raw?.score_text || ""; + if (typeof score === "number" && score > 0) { + viewCount = score; + } else if (scoreText) { + const numMatch = scoreText.match(/([\d.]+)/); + if (numMatch) { + const num = parseFloat(numMatch[1]); + if (scoreText.includes("亿")) { + viewCount = Math.round(num * 100000000); + } else if (scoreText.includes("万")) { + viewCount = Math.round(num * 10000); + } else { + viewCount = Math.round(num); + } + } + } + + const hotId = raw?.hot_id || raw?.id || ""; + const coverUrl = raw?.cover || undefined; + + return { + id: String(hotId || `xhs-${index}`), + title, + cover_url: coverUrl, + video_url: undefined, + author_name: "小红书热榜", + author_avatar: undefined, + play_count: viewCount, + like_count: undefined, + collect_count: undefined, + comment_count: undefined, + share_count: undefined, + publish_time: new Date().toISOString(), + platform: "xiaohongshu", + original_url: `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(title)}&type=51`, + tags: raw?.type ? [raw.type] : undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapNoteToContentItem(note: any, index: number): ContentItem { + const user = note?.user || {}; + const interactInfo = note?.interact_info || {}; + + const images = note?.images_list || note?.image_list || []; + const coverFromImages = images[0]?.url || images[0]?.url_default || undefined; + const coverObj = note?.cover || {}; + const coverFromCover = + (typeof coverObj === "string" ? coverObj : null) || + coverObj?.url || + coverObj?.url_default || + coverObj?.url_pre || + undefined; + + return { + id: String(note?.note_id || note?.id || `xhs-${index}`), + title: + note?.display_title || note?.title || note?.desc?.slice(0, 60) || "无标题", + cover_url: coverFromImages || coverFromCover || undefined, + video_url: note?.video?.url || undefined, + author_name: user?.nickname || user?.name || "未知作者", + author_avatar: user?.avatar || user?.image || undefined, + play_count: undefined, + like_count: + interactInfo?.liked_count ?? + note?.liked_count ?? + note?.likes ?? + undefined, + collect_count: + interactInfo?.collected_count ?? + note?.collected_count ?? + undefined, + comment_count: + interactInfo?.comment_count ?? + note?.comment_count ?? + undefined, + share_count: + interactInfo?.share_count ?? + note?.share_count ?? + undefined, + publish_time: note?.time + ? new Date(note.time * 1000).toISOString() + : note?.timestamp + ? new Date(note.timestamp * 1000).toISOString() + : new Date().toISOString(), + platform: "xiaohongshu", + original_url: `https://www.xiaohongshu.com/explore/${note?.note_id || note?.id || ""}`, + tags: + note?.tag_list + ?.map((t: { name?: string }) => t.name) + .filter(Boolean) || undefined, + }; + } +} diff --git a/packages/backend/src/lib/adapters/youtube.test.ts b/packages/backend/src/lib/adapters/youtube.test.ts new file mode 100644 index 0000000..e00d5dc --- /dev/null +++ b/packages/backend/src/lib/adapters/youtube.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { YouTubeAdapter } from "./youtube"; + +vi.mock("../tikhub", () => ({ + tikhubFetch: vi.fn(), +})); + +import { tikhubFetch } from "../tikhub"; +const mockFetch = vi.mocked(tikhubFetch); + +describe("YouTubeAdapter", () => { + let adapter: YouTubeAdapter; + + beforeEach(() => { + adapter = new YouTubeAdapter(); + mockFetch.mockReset(); + }); + + describe("fetchTrending", () => { + it("returns mapped ContentItem[] from trending videos", async () => { + mockFetch.mockResolvedValueOnce({ + videos: [ + { + video_id: "abc123", + snippet: { + title: "Test Video", + channelTitle: "Test Channel", + publishedAt: "2024-01-15T08:00:00Z", + thumbnails: { + high: { url: "https://img.youtube.com/high.jpg" }, + default: { url: "https://img.youtube.com/default.jpg" }, + }, + tags: ["music", "trending"], + }, + statistics: { + viewCount: "1500000", + likeCount: "80000", + commentCount: "3500", + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("abc123"); + expect(items[0].title).toBe("Test Video"); + expect(items[0].platform).toBe("youtube"); + expect(items[0].author_name).toBe("Test Channel"); + expect(items[0].play_count).toBe(1500000); + expect(items[0].like_count).toBe(80000); + expect(items[0].comment_count).toBe(3500); + expect(items[0].cover_url).toBe("https://img.youtube.com/high.jpg"); + expect(items[0].tags).toEqual(["music", "trending"]); + }); + + it("handles empty API response", async () => { + mockFetch.mockResolvedValueOnce({}); + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + + it("uses default values for missing fields", async () => { + mockFetch.mockResolvedValueOnce({ + videos: [{}], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].title).toBe("Untitled"); + expect(items[0].author_name).toBe("Unknown"); + expect(items[0].play_count).toBeUndefined(); + }); + + it("handles id as object with videoId", async () => { + mockFetch.mockResolvedValueOnce({ + videos: [ + { + id: { videoId: "obj-id-123" }, + snippet: { title: "Object ID Video" }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].id).toBe("obj-id-123"); + }); + + it("parses string statistics correctly", async () => { + mockFetch.mockResolvedValueOnce({ + videos: [ + { + video_id: "stat-test", + statistics: { + viewCount: "999", + likeCount: "50", + commentCount: "10", + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].play_count).toBe(999); + expect(items[0].like_count).toBe(50); + expect(items[0].comment_count).toBe(10); + }); + + it("prefers maxres thumbnail", async () => { + mockFetch.mockResolvedValueOnce({ + videos: [ + { + video_id: "thumb-test", + snippet: { + thumbnails: { + maxres: { url: "https://img.youtube.com/maxres.jpg" }, + high: { url: "https://img.youtube.com/high.jpg" }, + }, + }, + }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items[0].cover_url).toBe("https://img.youtube.com/maxres.jpg"); + }); + + it("slices results to requested count", async () => { + const ytItems = Array.from({ length: 30 }, (_, i) => ({ + video_id: `yt-${i}`, + title: `Video ${i}`, + })); + mockFetch.mockResolvedValueOnce({ videos: ytItems }); + + const items = await adapter.fetchTrending(5); + expect(items).toHaveLength(5); + }); + }); + + describe("fetchDetail", () => { + it("returns mapped ContentItem from video detail", async () => { + mockFetch.mockResolvedValueOnce({ + items: [ + { + id: "detail-456", + snippet: { + title: "Detail Video", + channelTitle: "Detail Channel", + publishedAt: "2024-02-01T12:00:00Z", + thumbnails: { + high: { url: "https://img.youtube.com/detail.jpg" }, + }, + }, + statistics: { + viewCount: "50000", + likeCount: "2000", + }, + }, + ], + }); + + const item = await adapter.fetchDetail("detail-456"); + + expect(item.id).toBe("detail-456"); + expect(item.title).toBe("Detail Video"); + expect(item.platform).toBe("youtube"); + expect(item.play_count).toBe(50000); + }); + + it("handles missing detail data gracefully", async () => { + mockFetch.mockResolvedValueOnce({}); + + const item = await adapter.fetchDetail("999"); + expect(item.title).toBe("Untitled"); + expect(item.author_name).toBe("Unknown"); + }); + }); +}); diff --git a/packages/backend/src/lib/adapters/youtube.ts b/packages/backend/src/lib/adapters/youtube.ts new file mode 100644 index 0000000..2dca56f --- /dev/null +++ b/packages/backend/src/lib/adapters/youtube.ts @@ -0,0 +1,80 @@ +import type { ContentItem, PlatformAdapter } from "@muse/shared"; +import { tikhubFetch } from "../tikhub"; + +export class YouTubeAdapter implements PlatformAdapter { + async fetchTrending(count: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/youtube/web/get_trending_videos" + ); + + // Response: { videos: [...], number_of_videos, country, ... } + const list = data?.videos || data?.items || []; + const items = Array.isArray(list) ? list : []; + + return items + .slice(0, count) + .map((item: Record, index: number) => + this.mapToContentItem(item, index) + ); + } + + async fetchDetail(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await tikhubFetch( + "/api/v1/youtube/web/get_video_info", + { video_id: id } + ); + + const videoData = data?.items?.[0] || data || {}; + return this.mapToContentItem(videoData, 0); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToContentItem(raw: any, index: number): ContentItem { + // get_trending_videos format: { video_id, title, channel, views, ... } + // get_video_info / YouTube Data API format: { id, snippet: {...}, statistics: {...} } + const snippet = raw?.snippet || {}; + const stats = raw?.statistics || {}; + + const videoId = + raw?.video_id || + (typeof raw?.id === "string" ? raw.id : raw?.id?.videoId) || + raw?.videoId || + `yt-${index}`; + + // Thumbnails: trending format uses direct fields, Data API uses snippet.thumbnails + const thumbs = snippet?.thumbnails || raw?.thumbnails || {}; + const coverUrl = + thumbs?.maxres?.url || + thumbs?.high?.url || + thumbs?.medium?.url || + thumbs?.default?.url || + raw?.thumbnail || + undefined; + + // Views/likes: trending format may use direct number fields + const viewCount = raw?.views ?? stats?.viewCount; + const likeCount = raw?.likes ?? stats?.likeCount; + const commentCount = stats?.commentCount; + + return { + id: String(videoId), + title: raw?.title || snippet?.title || "Untitled", + cover_url: coverUrl, + video_url: `https://www.youtube.com/watch?v=${videoId}`, + author_name: + raw?.channel || snippet?.channelTitle || raw?.channelTitle || "Unknown", + author_avatar: undefined, + play_count: viewCount != null ? parseInt(String(viewCount), 10) || undefined : undefined, + like_count: likeCount != null ? parseInt(String(likeCount), 10) || undefined : undefined, + collect_count: undefined, + comment_count: commentCount != null ? parseInt(String(commentCount), 10) || undefined : undefined, + share_count: undefined, + publish_time: raw?.published_at || snippet?.publishedAt || raw?.publishedAt || new Date().toISOString(), + platform: "youtube", + original_url: `https://www.youtube.com/watch?v=${videoId}`, + tags: snippet?.tags || raw?.tags || undefined, + }; + } +} diff --git a/packages/backend/src/lib/rate-limiter.test.ts b/packages/backend/src/lib/rate-limiter.test.ts new file mode 100644 index 0000000..b2f1cde --- /dev/null +++ b/packages/backend/src/lib/rate-limiter.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +let canMakeRequest: () => boolean; +let recordRequest: () => void; +let waitForSlot: () => Promise; + +beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + const mod = await import("./rate-limiter"); + canMakeRequest = mod.canMakeRequest; + recordRequest = mod.recordRequest; + waitForSlot = mod.waitForSlot; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("canMakeRequest", () => { + it("returns true when no requests have been made", () => { + expect(canMakeRequest()).toBe(true); + }); + + it("returns true for fewer than 10 requests in window", () => { + for (let i = 0; i < 9; i++) { + recordRequest(); + } + expect(canMakeRequest()).toBe(true); + }); + + it("returns false when 10 requests made within 1 second", () => { + for (let i = 0; i < 10; i++) { + recordRequest(); + } + expect(canMakeRequest()).toBe(false); + }); + + it("returns true after window expires", () => { + for (let i = 0; i < 10; i++) { + recordRequest(); + } + expect(canMakeRequest()).toBe(false); + vi.advanceTimersByTime(1001); + expect(canMakeRequest()).toBe(true); + }); +}); + +describe("recordRequest", () => { + it("records a request timestamp", () => { + expect(canMakeRequest()).toBe(true); + for (let i = 0; i < 10; i++) { + recordRequest(); + } + expect(canMakeRequest()).toBe(false); + }); +}); + +describe("waitForSlot", () => { + it("resolves immediately when a slot is available", async () => { + await waitForSlot(); + expect(true).toBe(true); + }); + + it("waits until a slot opens when at capacity", async () => { + for (let i = 0; i < 9; i++) { + recordRequest(); + } + await waitForSlot(); + expect(canMakeRequest()).toBe(false); + + const promise = waitForSlot(); + vi.advanceTimersByTime(1100); + await promise; + expect(true).toBe(true); + }); +}); diff --git a/src/lib/rate-limiter.ts b/packages/backend/src/lib/rate-limiter.ts similarity index 100% rename from src/lib/rate-limiter.ts rename to packages/backend/src/lib/rate-limiter.ts diff --git a/packages/backend/src/lib/tikhub.test.ts b/packages/backend/src/lib/tikhub.test.ts new file mode 100644 index 0000000..35303b7 --- /dev/null +++ b/packages/backend/src/lib/tikhub.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + TikHubError, + getApiKey, + setRuntimeApiKey, + tikhubFetch, +} from "./tikhub"; + +vi.mock("./rate-limiter", () => ({ + waitForSlot: vi.fn().mockResolvedValue(undefined), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("TikHubError", () => { + it("creates error with statusCode and message", () => { + const err = new TikHubError(401, "Unauthorized"); + expect(err.statusCode).toBe(401); + expect(err.message).toBe("Unauthorized"); + expect(err.name).toBe("TikHubError"); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("getApiKey / setRuntimeApiKey", () => { + const originalEnv = process.env.TIKHUB_API_KEY; + + afterEach(() => { + setRuntimeApiKey(""); + if (originalEnv !== undefined) { + process.env.TIKHUB_API_KEY = originalEnv; + } else { + delete process.env.TIKHUB_API_KEY; + } + }); + + it("returns null when no key is configured", () => { + setRuntimeApiKey(""); + delete process.env.TIKHUB_API_KEY; + expect(getApiKey()).toBeNull(); + }); + + it("returns env variable when set", () => { + process.env.TIKHUB_API_KEY = "env-key-123"; + setRuntimeApiKey(""); + expect(getApiKey()).toBe("env-key-123"); + }); + + it("returns runtime key with priority over env", () => { + process.env.TIKHUB_API_KEY = "env-key-123"; + setRuntimeApiKey("runtime-key-456"); + expect(getApiKey()).toBe("runtime-key-456"); + }); +}); + +describe("tikhubFetch", () => { + beforeEach(() => { + mockFetch.mockReset(); + setRuntimeApiKey("test-api-key"); + }); + + afterEach(() => { + setRuntimeApiKey(""); + }); + + it("throws TikHubError 401 when no API key", async () => { + setRuntimeApiKey(""); + delete process.env.TIKHUB_API_KEY; + await expect(tikhubFetch("/test")).rejects.toThrow(TikHubError); + await expect(tikhubFetch("/test")).rejects.toMatchObject({ + statusCode: 401, + }); + }); + + it("makes GET request with correct headers", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: "ok" }), + }); + + await tikhubFetch("/api/v1/test"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/test"), + expect.objectContaining({ + method: "GET", + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + }, + }) + ); + }); + + it("appends query params for GET requests", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + }); + + await tikhubFetch("/api/v1/test", { foo: "bar", count: "20" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("foo=bar"); + expect(calledUrl).toContain("count=20"); + }); + + it("makes POST request with body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ code: 0, data: { items: [] } }), + }); + + await tikhubFetch("/api/v1/test", undefined, "POST", { key: "value" }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ key: "value" }), + }) + ); + }); + + it("unwraps TikHub envelope { code, data }", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ code: 0, data: { items: [1, 2, 3] } }), + }); + + const result = await tikhubFetch("/api/v1/test"); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + + it("returns raw json when no envelope", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: [1, 2, 3] }), + }); + + const result = await tikhubFetch("/api/v1/test"); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + + it("throws TikHubError 401 on unauthorized response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + await expect(tikhubFetch("/test")).rejects.toMatchObject({ + statusCode: 401, + }); + }); + + it("throws TikHubError 429 on rate limit response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 429 }); + await expect(tikhubFetch("/test")).rejects.toMatchObject({ + statusCode: 429, + }); + }); + + it("throws TikHubError on other HTTP errors", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + await expect(tikhubFetch("/test")).rejects.toMatchObject({ + statusCode: 500, + }); + }); +}); diff --git a/src/lib/tikhub.ts b/packages/backend/src/lib/tikhub.ts similarity index 76% rename from src/lib/tikhub.ts rename to packages/backend/src/lib/tikhub.ts index 7715edf..517cfeb 100644 --- a/src/lib/tikhub.ts +++ b/packages/backend/src/lib/tikhub.ts @@ -15,7 +15,9 @@ export function getApiKey(): string | null { export async function tikhubFetch( endpoint: string, - params?: Record + params?: Record, + method: "GET" | "POST" = "GET", + body?: Record ): Promise { const apiKey = getApiKey(); if (!apiKey) { @@ -30,11 +32,12 @@ export async function tikhubFetch( } const res = await fetch(url.toString(), { + method, headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, - cache: "no-store", + ...(method === "POST" ? { body: JSON.stringify(body || {}) } : {}), }); if (!res.ok) { @@ -47,7 +50,14 @@ export async function tikhubFetch( throw new TikHubError(res.status, `TikHub API 错误: ${res.status}`); } - return res.json(); + const json = await res.json(); + + // TikHub wraps all responses in { code, data, ... } envelope + // Unwrap to return the inner data directly + if (json?.code !== undefined && json?.data !== undefined) { + return json.data as T; + } + return json as T; } export class TikHubError extends Error { diff --git a/packages/backend/src/routes/settings.test.ts b/packages/backend/src/routes/settings.test.ts new file mode 100644 index 0000000..6e3b61b --- /dev/null +++ b/packages/backend/src/routes/settings.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../lib/tikhub", () => ({ + setRuntimeApiKey: vi.fn(), + getApiKey: vi.fn(), +})); + +import { setRuntimeApiKey, getApiKey } from "../lib/tikhub"; +import { app } from "../app"; + +const mockSetKey = vi.mocked(setRuntimeApiKey); +const mockGetKey = vi.mocked(getApiKey); + +describe("POST /api/settings", () => { + beforeEach(() => { + mockSetKey.mockReset(); + }); + + it("saves valid API key and returns success", async () => { + const res = await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "test-key-123" }), + }); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSetKey).toHaveBeenCalledWith("test-key-123"); + }); + + it("trims whitespace from API key", async () => { + await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: " key-with-spaces " }), + }); + expect(mockSetKey).toHaveBeenCalledWith("key-with-spaces"); + }); + + it("returns 400 for empty API key", async () => { + const res = await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for whitespace-only API key", async () => { + const res = await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: " " }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when apiKey field is missing", async () => { + const res = await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 500 for invalid JSON body", async () => { + const res = await app.request("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(500); + }); +}); + +describe("GET /api/settings", () => { + it("returns hasKey: true when key is configured", async () => { + mockGetKey.mockReturnValue("some-key"); + const res = await app.request("/api/settings"); + const data = await res.json(); + expect(data.hasKey).toBe(true); + }); + + it("returns hasKey: false when no key configured", async () => { + mockGetKey.mockReturnValue(null); + const res = await app.request("/api/settings"); + const data = await res.json(); + expect(data.hasKey).toBe(false); + }); +}); diff --git a/packages/backend/src/routes/settings.ts b/packages/backend/src/routes/settings.ts new file mode 100644 index 0000000..f668588 --- /dev/null +++ b/packages/backend/src/routes/settings.ts @@ -0,0 +1,29 @@ +import { Hono } from "hono"; +import { setRuntimeApiKey, getApiKey } from "../lib/tikhub"; + +const settingsRoutes = new Hono(); + +// POST / — save API Key +settingsRoutes.post("/", async (c) => { + try { + const body = await c.req.json(); + const { apiKey } = body; + + if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { + return c.json({ error: "请输入有效的 API Key" }, 400); + } + + setRuntimeApiKey(apiKey.trim()); + return c.json({ success: true, message: "API Key 已保存" }); + } catch { + return c.json({ error: "保存失败,请重试" }, 500); + } +}); + +// GET / — check if API Key is configured +settingsRoutes.get("/", (c) => { + const hasKey = !!getApiKey(); + return c.json({ hasKey }); +}); + +export { settingsRoutes }; diff --git a/packages/backend/src/routes/tikhub.test.ts b/packages/backend/src/routes/tikhub.test.ts new file mode 100644 index 0000000..2959632 --- /dev/null +++ b/packages/backend/src/routes/tikhub.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../lib/adapters", () => ({ + getAdapter: vi.fn(), +})); + +vi.mock("../lib/tikhub", () => { + class TikHubError extends Error { + constructor( + public statusCode: number, + message: string + ) { + super(message); + this.name = "TikHubError"; + } + } + return { TikHubError }; +}); + +import { getAdapter } from "../lib/adapters"; +import { TikHubError } from "../lib/tikhub"; +import { app } from "../app"; + +const mockGetAdapter = vi.mocked(getAdapter); + +describe("GET /api/tikhub/:platform", () => { + beforeEach(() => { + mockGetAdapter.mockReset(); + }); + + it("returns content items for valid platform", async () => { + const mockItems = [{ id: "1", title: "Test", platform: "douyin" }]; + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn().mockResolvedValue(mockItems), + fetchDetail: vi.fn(), + }); + + const res = await app.request("/api/tikhub/douyin"); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.data).toEqual(mockItems); + }); + + it("passes count parameter to adapter", async () => { + const mockFetchTrending = vi.fn().mockResolvedValue([]); + mockGetAdapter.mockReturnValue({ + fetchTrending: mockFetchTrending, + fetchDetail: vi.fn(), + }); + + await app.request("/api/tikhub/tiktok?count=50"); + expect(mockFetchTrending).toHaveBeenCalledWith(50); + }); + + it("defaults count to 20", async () => { + const mockFetchTrending = vi.fn().mockResolvedValue([]); + mockGetAdapter.mockReturnValue({ + fetchTrending: mockFetchTrending, + fetchDetail: vi.fn(), + }); + + await app.request("/api/tikhub/douyin"); + expect(mockFetchTrending).toHaveBeenCalledWith(20); + }); + + it("returns 400 for unsupported platform", async () => { + mockGetAdapter.mockReturnValue(null); + + const res = await app.request("/api/tikhub/unknown"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("不支持的平台"); + }); + + it("returns TikHub error status on TikHubError", async () => { + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn().mockRejectedValue( + new TikHubError(401, "API Key 无效") + ), + fetchDetail: vi.fn(), + }); + + const res = await app.request("/api/tikhub/douyin"); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toContain("API Key"); + }); + + it("returns 500 for unexpected errors", async () => { + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn().mockRejectedValue(new Error("unexpected")), + fetchDetail: vi.fn(), + }); + + const res = await app.request("/api/tikhub/douyin"); + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBe("服务器内部错误"); + }); +}); + +describe("GET /api/tikhub/:platform/detail", () => { + beforeEach(() => { + mockGetAdapter.mockReset(); + }); + + it("returns detail for valid platform and id", async () => { + const mockItem = { id: "123", title: "Detail Item", platform: "douyin" }; + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn(), + fetchDetail: vi.fn().mockResolvedValue(mockItem), + }); + + const res = await app.request("/api/tikhub/douyin/detail?id=123"); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.data).toEqual(mockItem); + }); + + it("returns 400 when id parameter is missing", async () => { + const res = await app.request("/api/tikhub/douyin/detail"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("id"); + }); + + it("returns 400 for unsupported platform", async () => { + mockGetAdapter.mockReturnValue(null); + + const res = await app.request("/api/tikhub/youtube/detail?id=123"); + expect(res.status).toBe(400); + }); + + it("returns TikHub error status on TikHubError", async () => { + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn(), + fetchDetail: vi.fn().mockRejectedValue( + new TikHubError(429, "请求过于频繁") + ), + }); + + const res = await app.request("/api/tikhub/douyin/detail?id=123"); + expect(res.status).toBe(429); + }); + + it("returns 500 for unexpected errors", async () => { + mockGetAdapter.mockReturnValue({ + fetchTrending: vi.fn(), + fetchDetail: vi.fn().mockRejectedValue(new Error("fail")), + }); + + const res = await app.request("/api/tikhub/douyin/detail?id=123"); + expect(res.status).toBe(500); + }); +}); diff --git a/packages/backend/src/routes/tikhub.ts b/packages/backend/src/routes/tikhub.ts new file mode 100644 index 0000000..71e061a --- /dev/null +++ b/packages/backend/src/routes/tikhub.ts @@ -0,0 +1,54 @@ +import { Hono } from "hono"; +import { getAdapter } from "../lib/adapters"; +import { TikHubError } from "../lib/tikhub"; +import type { Platform } from "@muse/shared"; + +const tikhubRoutes = new Hono(); + +// GET /:platform — trending content +tikhubRoutes.get("/:platform", async (c) => { + try { + const platform = c.req.param("platform"); + const count = parseInt(c.req.query("count") || "20", 10); + + const adapter = getAdapter(platform as Platform); + if (!adapter) { + return c.json({ error: `不支持的平台: ${platform}` }, 400); + } + + const items = await adapter.fetchTrending(count); + return c.json({ data: items }); + } catch (error) { + if (error instanceof TikHubError) { + return c.json({ error: error.message }, error.statusCode as 400); + } + return c.json({ error: "服务器内部错误" }, 500); + } +}); + +// GET /:platform/detail — content detail +tikhubRoutes.get("/:platform/detail", async (c) => { + try { + const platform = c.req.param("platform"); + const id = c.req.query("id"); + + if (!id) { + return c.json({ error: "缺少参数: id" }, 400); + } + + const adapter = getAdapter(platform as Platform); + if (!adapter) { + return c.json({ error: `不支持的平台: ${platform}` }, 400); + } + + const item = await adapter.fetchDetail(id); + return c.json({ data: item }); + } catch (error) { + if (error instanceof TikHubError) { + return c.json({ error: error.message }, error.statusCode as 400); + } + return c.json({ error: "服务器内部错误" }, 500); + } +}); + +export { tikhubRoutes }; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100644 index 0000000..be7d3ba --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "paths": { + "@muse/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts new file mode 100644 index 0000000..1dab4f0 --- /dev/null +++ b/packages/backend/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@muse/shared": path.resolve(__dirname, "../shared/src/index.ts"), + }, + }, + test: { + globals: true, + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "text-summary", "lcov"], + include: ["src/**"], + exclude: ["src/**/*.test.*"], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/components.json b/packages/frontend/components.json similarity index 100% rename from components.json rename to packages/frontend/components.json diff --git a/eslint.config.mjs b/packages/frontend/eslint.config.mjs similarity index 100% rename from eslint.config.mjs rename to packages/frontend/eslint.config.mjs diff --git a/next.config.ts b/packages/frontend/next.config.ts similarity index 64% rename from next.config.ts rename to packages/frontend/next.config.ts index d17ea60..29958c3 100644 --- a/next.config.ts +++ b/packages/frontend/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + transpilePackages: ["@muse/shared"], images: { remotePatterns: [ // 抖音 @@ -10,14 +11,21 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "p*.douyinpic.com" }, // TikTok { protocol: "https", hostname: "*.tiktokcdn.com" }, + { protocol: "https", hostname: "*.tiktokcdn-us.com" }, { protocol: "https", hostname: "p16-sign-sg.tiktokcdn.com" }, + { protocol: "https", hostname: "p16-common-sign.tiktokcdn-us.com" }, // 小红书 { protocol: "https", hostname: "*.xhscdn.com" }, { protocol: "https", hostname: "sns-webpic-qc.xhscdn.com" }, { protocol: "https", hostname: "sns-avatar-qc.xhscdn.com" }, + { protocol: "https", hostname: "sns-na-i6.xhscdn.com" }, + { protocol: "https", hostname: "ci.xiaohongshu.com" }, + { protocol: "http", hostname: "ci.xiaohongshu.com" }, + { protocol: "https", hostname: "picasso-static.xiaohongshu.com" }, // 通用 CDN { protocol: "https", hostname: "*.pstatp.com" }, { protocol: "https", hostname: "*.snssdk.com" }, + { protocol: "https", hostname: "*.douyinvod.com" }, ], }, }; diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000..23520c2 --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "@muse/frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@muse/shared": "workspace:*", + "@tanstack/react-query": "^5.90.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.576.0", + "next": "16.1.6", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "happy-dom": "^20.8.3", + "shadcn": "^3.8.5", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5", + "vitest": "^4.0.18" + } +} diff --git a/postcss.config.mjs b/packages/frontend/postcss.config.mjs similarity index 100% rename from postcss.config.mjs rename to packages/frontend/postcss.config.mjs diff --git a/src/app/detail/[platform]/[id]/page.tsx b/packages/frontend/src/app/detail/[platform]/[id]/page.tsx similarity index 100% rename from src/app/detail/[platform]/[id]/page.tsx rename to packages/frontend/src/app/detail/[platform]/[id]/page.tsx diff --git a/src/app/favicon.ico b/packages/frontend/src/app/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to packages/frontend/src/app/favicon.ico diff --git a/src/app/favorites/page.tsx b/packages/frontend/src/app/favorites/page.tsx similarity index 94% rename from src/app/favorites/page.tsx rename to packages/frontend/src/app/favorites/page.tsx index 6167d2a..9205fb1 100644 --- a/src/app/favorites/page.tsx +++ b/packages/frontend/src/app/favorites/page.tsx @@ -21,7 +21,7 @@ export default function FavoritesPage() {

我的收藏

- + {favorites.length} 个内容 diff --git a/src/app/globals.css b/packages/frontend/src/app/globals.css similarity index 100% rename from src/app/globals.css rename to packages/frontend/src/app/globals.css diff --git a/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx similarity index 100% rename from src/app/layout.tsx rename to packages/frontend/src/app/layout.tsx diff --git a/src/app/page.tsx b/packages/frontend/src/app/page.tsx similarity index 74% rename from src/app/page.tsx rename to packages/frontend/src/app/page.tsx index fa7164a..42ab6ac 100644 --- a/src/app/page.tsx +++ b/packages/frontend/src/app/page.tsx @@ -5,7 +5,7 @@ import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery"; import { PlatformTabs } from "@/components/layout/PlatformTabs"; import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar"; import { ContentGrid } from "@/components/card/ContentGrid"; -import type { ContentItem } from "@/types/content"; +import type { ContentItem } from "@muse/shared"; function sortItems( items: ContentItem[], @@ -34,7 +34,7 @@ export default function Home() { const [sortOrder, setSortOrder] = useState("desc"); const [lastRefreshTime, setLastRefreshTime] = useState(null); - const { data, isLoading, isFetching } = useContentQuery(platform); + const { data, isLoading, isFetching, isError, error, refetch } = useContentQuery(platform); const { refresh } = useRefreshContent(); const sortedItems = useMemo(() => { @@ -69,8 +69,25 @@ export default function Home() { lastRefreshTime={lastRefreshTime} /> - {!isLoading && sortedItems.length === 0 ? ( + {isError ? (
+

😵

+

+ 加载失败 +

+

+ {error?.message || "请求出错,请稍后重试"} +

+ +
+ ) : !isLoading && sortedItems.length === 0 ? ( +

📭

暂无内容 diff --git a/src/app/settings/page.tsx b/packages/frontend/src/app/settings/page.tsx similarity index 96% rename from src/app/settings/page.tsx rename to packages/frontend/src/app/settings/page.tsx index 3c53f17..b501503 100644 --- a/src/app/settings/page.tsx +++ b/packages/frontend/src/app/settings/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ArrowLeft, Eye, EyeOff, Check } from "lucide-react"; import Link from "next/link"; import { useSettingsStore } from "@/stores/settings"; +import { API_BASE_URL } from "@/lib/api"; import { toast } from "sonner"; const REFRESH_OPTIONS: { value: 5 | 10 | 15 | 30 | 60; label: string }[] = [ @@ -31,7 +32,7 @@ export default function SettingsPage() { setSaving(true); try { - const res = await fetch("/api/settings", { + const res = await fetch(`${API_BASE_URL}/api/settings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: trimmed }), @@ -83,6 +84,7 @@ export default function SettingsPage() {
setInputKey(e.target.value)} @@ -102,6 +104,7 @@ export default function SettingsPage() {
{/* Stats panel */} -
+
{STAT_ITEMS.map(({ key, label, icon: Icon }) => { const value = item[key]; return ( @@ -141,6 +145,7 @@ export function DetailPanel({ item }: DetailPanelProps) { {/* Actions */}