feat: monorepo 重构 + 新增 5 个平台适配器
项目从单体结构重构为 pnpm monorepo (shared/backend/frontend), 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器, 包含完整的单元测试和 E2E 测试覆盖。 - 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试 - 重构前后端分离: Hono 后端 + Next.js 前端 - 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E - 适配器基于真实 TikHub API 响应结构实现 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ce736f197d
commit
6cc703ada2
122
.claude/skills/CLAUDE.md.template
Normal file
122
.claude/skills/CLAUDE.md.template
Normal file
@ -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 提交?**
|
||||
|
||||
## 踩坑经验
|
||||
|
||||
<!-- 格式示例:
|
||||
|
||||
### {{问题简述}}
|
||||
|
||||
**问题现象**:{{描述现象}}
|
||||
|
||||
**根因**:{{分析根因}}
|
||||
|
||||
**解决方案**:{{解决方案}}
|
||||
|
||||
**注意事项**:{{补充说明}}
|
||||
|
||||
-->
|
||||
1
.claude/skills/RequirementsDoc.md
Normal file
1
.claude/skills/RequirementsDoc.md
Normal file
@ -0,0 +1 @@
|
||||
just empety...
|
||||
109
.claude/skills/changelog/SKILL.md
Normal file
109
.claude/skills/changelog/SKILL.md
Normal file
@ -0,0 +1,109 @@
|
||||
---
|
||||
name: changelog
|
||||
description: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。
|
||||
---
|
||||
|
||||
# Changelog - 一键发版
|
||||
|
||||
> **定位**:发版全流程。`/changelog 1.0.0227.1` 一个命令搞定日志生成 + commit + tag。
|
||||
|
||||
## 用法
|
||||
|
||||
```
|
||||
/changelog <version> # 例: /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)
|
||||
```
|
||||
316
.claude/skills/deploy/SKILL.md
Normal file
316
.claude/skills/deploy/SKILL.md
Normal file
@ -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-<service>` 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://<your-gitea-domain>
|
||||
- DRONE_GITEA_CLIENT_ID=<gitea-oauth-client-id>
|
||||
- DRONE_GITEA_CLIENT_SECRET=<gitea-oauth-client-secret>
|
||||
- DRONE_SERVER_HOST=<your-drone-domain>
|
||||
- DRONE_SERVER_PROTO=https
|
||||
- DRONE_RPC_SECRET=<openssl rand -hex 16 生成>
|
||||
- DRONE_USER_CREATE=username:<gitea用户名>,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": ["<registry-host>: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 <port> <user>@<production-ip>
|
||||
|
||||
# 验证
|
||||
ssh -i ~/.ssh/drone_deploy -p <port> <user>@<production-ip> "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 <user>` 后重新登录 |
|
||||
| 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 <service>` |
|
||||
| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务启动日志 |
|
||||
325
.claude/skills/go/SKILL.md
Normal file
325
.claude/skills/go/SKILL.md
Normal file
@ -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 标记 | 优先执行 `<!-- 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(<scope>): <简要描述>
|
||||
|
||||
- 完成 T-xxx: {任务名}
|
||||
- 完成 T-xxx: {任务名}
|
||||
- ...
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
feat(auth): 完成用户认证模块
|
||||
|
||||
- 完成 T-005: 用户登录功能
|
||||
- 完成 T-006: 用户注册功能
|
||||
- 完成 T-007: JWT Token 管理
|
||||
- 完成 T-008: 权限验证中间件
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
210
.claude/skills/iter/SKILL.md
Normal file
210
.claude/skills/iter/SKILL.md
Normal file
@ -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
|
||||
<!-- ITER: {日期} - {变更简述} -->
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
或修改现有内容:
|
||||
|
||||
```markdown
|
||||
<!-- ITER: {日期} - {变更简述} -->
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
**更新位置**:
|
||||
- Bug 修复 → 更新对应功能的验收标准
|
||||
- 功能迭代 → 在 3.2 功能详情添加/修改功能点
|
||||
- 技术重构 → 在 4.x 非功能需求或 7.1 技术约束中说明
|
||||
|
||||
### 4.2 更新 tasks.md
|
||||
|
||||
新增任务使用标记:
|
||||
|
||||
```markdown
|
||||
<!-- ITER: {日期} - {变更简述} -->
|
||||
| T-xxx | {任务名} | {描述} | {依赖} | {优先级} | {验收标准} |
|
||||
```
|
||||
|
||||
**任务 ID 规则**:
|
||||
- 查找现有最大 ID,递增分配
|
||||
- 格式:T-xxx(三位数字)
|
||||
|
||||
### 4.3 标记规范
|
||||
|
||||
所有变更使用 `<!-- ITER: -->` 前缀,区分于 `/mp` `/mt` 的标记:
|
||||
|
||||
- `<!-- ITER: 2026-01-23 - 修复登录验证漏洞 -->`
|
||||
- 便于追溯迭代历史
|
||||
|
||||
## 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` 等 |
|
||||
112
.claude/skills/md/SKILL.md
Normal file
112
.claude/skills/md/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增:
|
||||
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
### 3.4 与 FeatureSummary 一致性
|
||||
|
||||
- 开发任务必须覆盖所有功能
|
||||
- 技术方案必须支撑功能需求
|
||||
- 阶段划分必须合理
|
||||
|
||||
## 4. 执行修改
|
||||
|
||||
| 修改类型 | 处理方式 |
|
||||
|----------|----------|
|
||||
| 新增开发任务 | 在对应阶段表格中添加行 |
|
||||
| 修改技术方案 | 更新技术方案章节,添加 MODIFIED 标记 |
|
||||
| 调整阶段划分 | 移动任务到新阶段,标记变更 |
|
||||
| 新增风险项 | 在风险管理表格中添加行 |
|
||||
| 修改里程碑 | 更新里程碑表格 |
|
||||
|
||||
## 5. 保存并验证
|
||||
|
||||
1. 保存修改后的文档到 `doc/DevelopmentPlan.md`
|
||||
2. 使用 git diff 展示变更内容
|
||||
3. 向用户确认修改是否符合预期
|
||||
|
||||
## 6. 输出摘要
|
||||
|
||||
向用户展示修改摘要:
|
||||
|
||||
- 修改位置(章节/行号)
|
||||
- 修改类型(新增/修改/删除)
|
||||
- 修改内容概要
|
||||
- 与 FeatureSummary 的一致性确认
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- DevelopmentPlan 依赖于 FeatureSummary,修改时需确保与上游一致
|
||||
- 修改后,下游文档(UIDesign、tasks)可能需要同步更新
|
||||
- 技术方案修改需谨慎评估影响范围
|
||||
- 建议修改完成后运行 `/ru` 检查下游一致性
|
||||
|
||||
## 标记清理
|
||||
|
||||
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。
|
||||
111
.claude/skills/mf/SKILL.md
Normal file
111
.claude/skills/mf/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增:
|
||||
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
### 3.4 与 PRD 一致性
|
||||
|
||||
- 所有功能必须来源于 PRD
|
||||
- 修改后的功能描述必须与 PRD 一致
|
||||
- 优先级必须与 PRD 匹配
|
||||
|
||||
## 4. 执行修改
|
||||
|
||||
| 修改类型 | 处理方式 |
|
||||
|----------|----------|
|
||||
| 新增功能 | 在对应模块表格中添加行 |
|
||||
| 修改描述 | 更新功能描述,添加 MODIFIED 标记 |
|
||||
| 修改优先级 | 更新优先级列 |
|
||||
| 新增模块 | 在功能清单中添加新章节 |
|
||||
| 删除功能 | 标记为删除而非直接移除 |
|
||||
|
||||
## 5. 保存并验证
|
||||
|
||||
1. 保存修改后的文档到 `doc/FeatureSummary.md`
|
||||
2. 使用 git diff 展示变更内容
|
||||
3. 向用户确认修改是否符合预期
|
||||
|
||||
## 6. 输出摘要
|
||||
|
||||
向用户展示修改摘要:
|
||||
|
||||
- 修改位置(章节/行号)
|
||||
- 修改类型(新增/修改/删除)
|
||||
- 修改内容概要
|
||||
- 与 PRD 的一致性确认
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- FeatureSummary 依赖于 PRD,修改时需确保与上游一致
|
||||
- 修改后,下游文档(DevelopmentPlan 等)可能需要同步更新
|
||||
- 建议修改完成后运行 `/rd` 检查下游一致性
|
||||
|
||||
## 标记清理
|
||||
|
||||
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。
|
||||
144
.claude/skills/mp/SKILL.md
Normal file
144
.claude/skills/mp/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增,使用:
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
对于修改的内容,保留原文作为注释:
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
### 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` 检查下游文档一致性
|
||||
|
||||
## 标记清理
|
||||
|
||||
当用户确认修改无误后,可手动删除 `<!-- NEW -->` 和 `<!-- MODIFIED -->` 标记,或保留作为变更历史参考。
|
||||
|
||||
通过 git 可追溯完整修改历史。
|
||||
|
||||
## 质量检查
|
||||
|
||||
修改 PRD 后,自查以下项目:
|
||||
|
||||
- [ ] 修改内容与 RequirementsDoc 一致
|
||||
- [ ] 新增用户故事有唯一 ID
|
||||
- [ ] 新增功能点关联到用户故事
|
||||
- [ ] 新增功能点有明确优先级和验收标准
|
||||
- [ ] 标记格式正确(`<!-- NEW -->` / `<!-- MODIFIED -->`)
|
||||
- [ ] 文档结构完整,格式一致
|
||||
95
.claude/skills/mr/SKILL.md
Normal file
95
.claude/skills/mr/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增,使用:
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
对于修改的内容,保留原文作为注释:
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
## 4. 执行修改
|
||||
|
||||
按照用户指令修改文档:
|
||||
|
||||
1. 定位到需要修改的位置
|
||||
2. 执行增量修改
|
||||
3. 添加相应的标记
|
||||
4. 保持文档格式一致性
|
||||
|
||||
## 5. 保存并验证
|
||||
|
||||
1. 保存修改后的文档到 `doc/RequirementsDoc.md`
|
||||
2. 使用 git diff 展示变更内容
|
||||
3. 向用户确认修改是否符合预期
|
||||
|
||||
## 6. 输出摘要
|
||||
|
||||
向用户展示修改摘要:
|
||||
- 修改位置(章节/行号)
|
||||
- 修改类型(新增/修改/删除)
|
||||
- 修改内容概要
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- RequirementsDoc 是文档链源头,修改会影响所有下游文档
|
||||
- 修改前确认用户意图,避免误改
|
||||
- 保持现有文档风格(标题层级、表格格式、列表样式)
|
||||
- 重大修改建议先运行 `/rr` 评审确认影响范围
|
||||
- 修改完成后,建议用户检查下游文档是否需要同步更新
|
||||
|
||||
## 标记清理
|
||||
|
||||
当用户确认修改无误后,可手动删除 `<!-- NEW -->` 和 `<!-- MODIFIED -->` 标记,或保留作为变更历史参考。
|
||||
|
||||
通过 git 可追溯完整修改历史。
|
||||
132
.claude/skills/mt/SKILL.md
Normal file
132
.claude/skills/mt/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增:
|
||||
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
### 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 唯一且格式正确
|
||||
- [ ] 无循环依赖
|
||||
- [ ] 验收标准明确
|
||||
- [ ] 覆盖所有上游功能
|
||||
- [ ] 标记格式正确
|
||||
114
.claude/skills/mu/SKILL.md
Normal file
114
.claude/skills/mu/SKILL.md
Normal file
@ -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
|
||||
<!-- NEW START -->
|
||||
新增内容...
|
||||
<!-- NEW END -->
|
||||
```
|
||||
|
||||
对于行内新增:
|
||||
|
||||
```markdown
|
||||
原有内容 <!-- NEW --> 新增内容
|
||||
```
|
||||
|
||||
### 3.3 修改内容标记
|
||||
|
||||
```markdown
|
||||
<!-- MODIFIED: 原内容为 "xxx" -->
|
||||
修改后的内容
|
||||
```
|
||||
|
||||
### 3.4 与 DevelopmentPlan 一致性
|
||||
|
||||
- 页面设计必须覆盖所有功能模块
|
||||
- 交互流程必须支撑功能需求
|
||||
- 设计规范必须统一
|
||||
|
||||
## 4. 执行修改
|
||||
|
||||
| 修改类型 | 处理方式 |
|
||||
|----------|----------|
|
||||
| 新增页面 | 在页面设计章节添加新子章节 |
|
||||
| 修改布局 | 更新布局描述,添加 MODIFIED 标记 |
|
||||
| 修改组件 | 更新组件表格 |
|
||||
| 修改交互 | 更新交互说明 |
|
||||
| 新增状态 | 在状态列表中添加项目 |
|
||||
| 修改设计规范 | 更新设计规范章节 |
|
||||
|
||||
## 5. 保存并验证
|
||||
|
||||
1. 保存修改后的文档到 `doc/UIDesign.md`
|
||||
2. 使用 git diff 展示变更内容
|
||||
3. 向用户确认修改是否符合预期
|
||||
|
||||
## 6. 输出摘要
|
||||
|
||||
向用户展示修改摘要:
|
||||
|
||||
- 修改位置(章节/行号)
|
||||
- 修改类型(新增/修改/删除)
|
||||
- 修改内容概要
|
||||
- 与 DevelopmentPlan 的一致性确认
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- UIDesign 依赖于 DevelopmentPlan,修改时需确保与上游一致
|
||||
- 修改后,下游文档(tasks)可能需要同步更新
|
||||
- 页面修改需考虑对用户流程的影响
|
||||
- 设计规范修改需检查所有页面的一致性
|
||||
- 建议修改完成后运行 `/rt` 检查下游一致性
|
||||
|
||||
## 标记清理
|
||||
|
||||
用户确认修改无误后,可手动删除标记或保留作为变更历史参考。
|
||||
101
.claude/skills/rd/SKILL.md
Normal file
101
.claude/skills/rd/SKILL.md
Normal file
@ -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` 修改
|
||||
96
.claude/skills/rf/SKILL.md
Normal file
96
.claude/skills/rf/SKILL.md
Normal file
@ -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` 修改
|
||||
177
.claude/skills/rp/SKILL.md
Normal file
177
.claude/skills/rp/SKILL.md
Normal file
@ -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 的优先级划分不一致
|
||||
111
.claude/skills/rr/SKILL.md
Normal file
111
.claude/skills/rr/SKILL.md
Normal file
@ -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. 输出摘要
|
||||
|
||||
向用户展示评审摘要:
|
||||
- 发现的问题数量(按严重程度分类)
|
||||
- 评审结论
|
||||
- 报告文件路径
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 评审时保持客观,聚焦于文档质量而非业务判断
|
||||
- 问题描述要具体,给出明确的位置引用
|
||||
- 建议要可操作,避免模糊表述
|
||||
- 不要修改原文档,只输出评审报告
|
||||
115
.claude/skills/rt/SKILL.md
Normal file
115
.claude/skills/rt/SKILL.md
Normal file
@ -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` 修改
|
||||
105
.claude/skills/ru/SKILL.md
Normal file
105
.claude/skills/ru/SKILL.md
Normal file
@ -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` 修改
|
||||
410
.claude/skills/tdd-workflow/SKILL.md
Normal file
410
.claude/skills/tdd-workflow/SKILL.md
Normal file
@ -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(<Button>Click me</Button>)
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const handleClick = jest.fn()
|
||||
render(<Button onClick={handleClick}>Click</Button>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Click</Button>)
|
||||
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.
|
||||
78
.claude/skills/up/SKILL.md
Normal file
78
.claude/skills/up/SKILL.md
Normal file
@ -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 <skill-name>` 时,执行以下步骤:
|
||||
|
||||
## 1. 识别目标 Skill
|
||||
|
||||
**如果用户提供了参数 `$ARGUMENTS`**:
|
||||
- 直接使用指定的 skill 名称作为更新目标
|
||||
|
||||
**如果没有提供参数**:
|
||||
分析当前对话历史,找出最近使用的 skill:
|
||||
- 搜索对话中的 `<command-name>` 标签,识别调用过的 skill
|
||||
- 如果找到多个 skill,让用户确认要更新哪一个
|
||||
- 如果没有找到任何 skill 调用记录,提示用户先使用一个 skill
|
||||
|
||||
## 2. 收集用户反馈
|
||||
|
||||
向用户询问以下问题(使用 AskUserQuestion 工具):
|
||||
|
||||
**问题 1:这次使用体验如何?**
|
||||
- 很好,skill 完全满足需求
|
||||
- 基本满足,但有改进空间
|
||||
- 不太满意,需要较大调整
|
||||
|
||||
**问题 2:具体需要改进的方面?**(多选)
|
||||
- 执行步骤不够清晰
|
||||
- 缺少某些功能
|
||||
- 输出格式需要调整
|
||||
- 提示词需要优化
|
||||
- 其他(用户自定义输入)
|
||||
|
||||
## 3. 分析优化点
|
||||
|
||||
基于用户反馈和本次 skill 使用过程,分析以下方面:
|
||||
|
||||
1. **执行流程**:哪些步骤可以简化或合并?
|
||||
2. **指令清晰度**:哪些指令描述不够明确?
|
||||
3. **遗漏功能**:有哪些场景没有覆盖到?
|
||||
4. **输出质量**:输出格式是否符合用户预期?
|
||||
|
||||
## 4. 定位 Skill 文件
|
||||
|
||||
按以下优先级搜索 skill 文件:
|
||||
1. 项目级:`.claude/skills/<skill-name>/SKILL.md`
|
||||
2. 用户级:`~/.claude/skills/<skill-name>/SKILL.md`
|
||||
|
||||
## 5. 更新 Skill
|
||||
|
||||
读取现有的 SKILL.md 文件内容,根据分析结果进行更新:
|
||||
|
||||
- 保持 frontmatter 格式不变(除非需要修改 description)
|
||||
- 优化执行步骤的描述
|
||||
- 添加缺失的功能说明
|
||||
- 改进提示词的表达方式
|
||||
- 添加必要的注意事项或边界情况处理
|
||||
|
||||
## 6. 确认更新
|
||||
|
||||
在更新前,向用户展示:
|
||||
- 修改前后的对比(diff 格式)
|
||||
- 说明每处修改的原因
|
||||
|
||||
用户确认后才执行实际的文件更新。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 如果 skill 文件不存在或路径无法确定,提示用户手动指定路径
|
||||
- 更新时保持 skill 的原有风格和结构
|
||||
- 重大修改需要用户明确确认
|
||||
- 保留原有的有效内容,只做增量优化
|
||||
323
.claude/skills/wd/SKILL.md
Normal file
323
.claude/skills/wd/SKILL.md
Normal file
@ -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 关联
|
||||
234
.claude/skills/wf/SKILL.md
Normal file
234
.claude/skills/wf/SKILL.md
Normal file
@ -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 中的功能
|
||||
318
.claude/skills/wp/SKILL.md
Normal file
318
.claude/skills/wp/SKILL.md
Normal file
@ -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 中的重要需求
|
||||
- [ ] 文档结构完整,无空章节(或标注"待补充")
|
||||
135
.claude/skills/writeTest/SKILL.md
Normal file
135
.claude/skills/writeTest/SKILL.md
Normal file
@ -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` |
|
||||
129
.claude/skills/wt/SKILL.md
Normal file
129
.claude/skills/wt/SKILL.md
Normal file
@ -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...)
|
||||
- 每个任务必须有明确的验收标准
|
||||
- 任务粒度要适中,可在合理时间内完成
|
||||
- 依赖关系要明确,避免循环依赖
|
||||
- 任务应可直接执行,无歧义
|
||||
352
.claude/skills/wu/SKILL.md
Normal file
352
.claude/skills/wu/SKILL.md
Normal file
@ -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 原型图**
|
||||
- [ ] **原型图展示了完整的页面结构**
|
||||
- [ ] **用户流程有流程图**
|
||||
- [ ] 每个页面都有状态说明
|
||||
- [ ] 组件清单完整
|
||||
- [ ] 交互说明清晰
|
||||
- [ ] 设计规范统一
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@ -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/
|
||||
|
||||
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal file
@ -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
|
||||
```
|
||||
669
doc/DevelopmentPlan.md
Normal file
669
doc/DevelopmentPlan.md
Normal file
@ -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) │ │
|
||||
│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ │ │
|
||||
│ │ │ 首页 │ │ 详情页 │ │ 收藏页 │ │ 设置页 │ │ │
|
||||
<!-- MODIFIED: 原内容为 "[id]/",补充 platform 参数(M-001) -->
|
||||
│ │ │ 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 | 任务 | 描述 | 依赖 | 优先级 | 关联功能 |
|
||||
|--------|------|------|------|--------|----------|
|
||||
<!-- MODIFIED: 补充 next.config.ts 图片域名配置(S-002) -->
|
||||
| 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 |
|
||||
<!-- MODIFIED: 路由改为 /detail/[platform]/[id],语义更清晰(M-001) -->
|
||||
| T-015 | 内容详情页 | `/detail/[platform]/[id]` 页面,完整信息展示 + "查看原文"跳转 + 收藏按钮 | T-010 | P0 | F-004 |
|
||||
| T-016 | 自动定时刷新 | TanStack Query refetchInterval,读取设置中的刷新间隔,页面不可见时暂停 | T-010, T-008 | P0 | F-005 |
|
||||
<!-- MODIFIED: 合并 T-018 到 T-017,T-018 粒度过细(S-003) -->
|
||||
| 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<ContentItem[]>;
|
||||
fetchDetail(id: string): Promise<ContentItem>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**实现要点**:
|
||||
|
||||
<!-- MODIFIED: 明确 API Key 运行时存储方案和读取优先级(M-002) -->
|
||||
- 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 变更,自动重新获取数据
|
||||
<!-- MODIFIED: 补充 rate-limiter 保护说明,与风险管理一致(S-001) -->
|
||||
- "全部"视图使用 `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<Platform, boolean>;
|
||||
displayCount: number;
|
||||
setApiKey: (key: string) => void;
|
||||
setRefreshInterval: (minutes: number) => void;
|
||||
togglePlatform: (platform: Platform) => void;
|
||||
setDisplayCount: (count: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**实现要点**:
|
||||
|
||||
<!-- MODIFIED: 与 4.2 保持一致,明确 API Key 存储为服务端内存变量(M-002) -->
|
||||
- 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)
|
||||
<!-- MODIFIED: 路由补充 platform 参数(M-001) -->
|
||||
│ ├── 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 响应格式变化 | 中 | 高 | 字段映射做容错处理,缺失字段使用默认值 |
|
||||
<!-- MODIFIED: 统一并发策略描述,移除"串行请求"矛盾说法(S-001) -->
|
||||
| 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 |
|
||||
<!-- MODIFIED: T-018 合并到 T-017,范围调整(S-003) -->
|
||||
| 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 |
|
||||
<!-- MODIFIED: T-017 合并了 T-018 的刷新时间展示,补充映射(S-003) -->
|
||||
| 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 |
|
||||
|
||||
> 本项目为个人项目,由单人全栈完成。
|
||||
497
doc/FeatureSummary.md
Normal file
497
doc/FeatureSummary.md
Normal file
@ -0,0 +1,497 @@
|
||||
# Muse Creative Hotspots — 功能摘要
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 版本 | v1.0 |
|
||||
| 创建日期 | 2026-03-02 |
|
||||
| 来源文档 | PRD.md |
|
||||
|
||||
## 1. 功能总览
|
||||
|
||||
### 1.1 功能统计
|
||||
|
||||
| 类别 | 数量 |
|
||||
|------|------|
|
||||
| 功能模块 | 5 个 |
|
||||
<!-- MODIFIED: 原内容为 "P0: 10, P1: 5, P2: 1, 总计: 16"。F-007~F-009 升 P0,F-011 升 P0,新增 F-017(P1) -->
|
||||
| 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. 提供收藏按钮 |
|
||||
| **输出** | 站内详情页面 |
|
||||
| **异常情况** | 详情数据加载失败 → 显示错误提示 + 重试按钮;原文链接失效 → 提示"原文可能已被删除" |
|
||||
<!-- MODIFIED: 明确 video_url 的展示方式 -->
|
||||
| **边界说明** | 不包含站内视频播放器(视频内容通过"查看原文"按钮跳转原平台播放,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 秒内忽略重复点击);刷新中再次点击 → 忽略 |
|
||||
<!-- MODIFIED: 标注单平台刷新为设计决策而非 PRD 约束 -->
|
||||
| **边界说明** | 【设计决策】不包含单平台独立刷新(刷新所有已启用平台,PRD 未明确此约束,后续可按需调整);手动刷新会重置自动刷新倒计时 |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 模块 C — 收藏/书签系统
|
||||
|
||||
**模块职责**: 允许用户收藏感兴趣的内容,构建个人灵感库。
|
||||
|
||||
#### 功能列表
|
||||
|
||||
| ID | 功能 | 描述 | 优先级 | 关联场景 |
|
||||
|----|------|------|--------|----------|
|
||||
<!-- MODIFIED: 原内容为 "P1",PRD MVP 验收标准要求"收藏功能可用",升级为 P0 以匹配 MVP 纳入 -->
|
||||
| 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 | - |
|
||||
<!-- MODIFIED: 原内容为 "P1",根据 PRD 第8节 MVP 验收标准"设置页可配置 API Key 和刷新间隔"升级为 P0 -->
|
||||
| F-011 | 刷新间隔设置 | 自定义自动刷新频率 | P0 | US-001 |
|
||||
| F-012 | 平台管理 | 启用/禁用各平台 | P1 | US-002 |
|
||||
| F-013 | 展示数量设置 | 配置每平台默认展示数量 | P2 | US-003 |
|
||||
<!-- NEW START -->
|
||||
| F-017 | API 调用量统计 | 展示当日 API 调用次数及成本估算 | P1 | - |
|
||||
<!-- NEW END -->
|
||||
|
||||
#### 功能契约详情
|
||||
|
||||
**F-010: API Key 配置**
|
||||
|
||||
| 契约项 | 说明 |
|
||||
|--------|------|
|
||||
| **触发条件** | 用户进入设置页面 |
|
||||
| **输入** | 用户输入的 TikHub API Key 字符串 |
|
||||
| **处理逻辑** | 1. 提供文本输入框供用户粘贴 API Key;2. 保存时验证 Key 格式(非空检查);3. 存储到本地(加密或 env);4. API 代理层读取此 Key 发起请求 |
|
||||
| **输出** | API Key 保存成功/失败提示 |
|
||||
<!-- MODIFIED: 原异常情况"Key 无效(API 返回 401)"与边界"保存时不测试连通性"矛盾,明确 401 在请求内容时触发 -->
|
||||
| **异常情况** | 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 的请求参数 |
|
||||
| **输出** | 新的展示数量配置生效 |
|
||||
| **异常情况** | 无特殊异常 |
|
||||
| **边界说明** | 不支持每个平台独立设置不同数量(全局统一);更改后需等待下次刷新才生效 |
|
||||
|
||||
<!-- NEW START -->
|
||||
**F-017: API 调用量统计**
|
||||
|
||||
| 契约项 | 说明 |
|
||||
|--------|------|
|
||||
| **触发条件** | 用户进入设置页面或首页工具栏查看 |
|
||||
| **输入** | API 代理层的请求计数器数据 |
|
||||
| **处理逻辑** | 1. API 代理层(F-014)每次转发请求时累加计数器;2. 按日期维度统计调用次数;3. 根据 $0.001/请求 计算估算成本;4. 在设置页面或工具栏展示"今日调用: N 次(≈$X.XX)" |
|
||||
| **输出** | 当日 API 调用次数 + 成本估算显示 |
|
||||
| **异常情况** | 计数器数据丢失(页面刷新)→ 重新从 0 计数并提示"本次会话统计" |
|
||||
| **边界说明** | 仅统计当前会话/当日数据,不做历史统计;计数存储于内存或 localStorage;成本为估算值,不保证与实际账单一致 |
|
||||
<!-- NEW END -->
|
||||
|
||||
---
|
||||
|
||||
### 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. 功能依赖矩阵
|
||||
|
||||
<!-- MODIFIED: 修正 F-002/F-003 依赖方向;新增 F-017 行列 -->
|
||||
| 功能 | 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 | 目标 |
|
||||
|------|----------|--------|------|
|
||||
<!-- MODIFIED: MVP 纳入 F-011;v1.1 纳入 F-017;v2.0 暗色模式无功能 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[]` |
|
||||
<!-- NEW START -->
|
||||
| F-017 | API (GET) | `GET /api/stats` — 获取当日 API 调用次数及成本估算 |
|
||||
<!-- NEW END -->
|
||||
|
||||
---
|
||||
|
||||
## 附录:用户场景映射
|
||||
|
||||
| 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 |
|
||||
350
doc/PRD.md
Normal file
350
doc/PRD.md
Normal file
@ -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. 测试自动刷新和手动刷新功能
|
||||
995
doc/UIDesign.md
Normal file
995
doc/UIDesign.md
Normal file
@ -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 | 骨架屏(大图区域 + 文字行) |
|
||||
| 错误 | 详情加载失败 | 错误提示 + "重试" + "返回首页" 按钮 |
|
||||
|
||||
<!-- NEW START -->
|
||||
**错误态原型**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ [← 返回] │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ ⚠️ │ │
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
│ 内容加载失败 │
|
||||
│ 请检查网络连接或稍后重试 │
|
||||
│ │
|
||||
│ [🔄 重试] [🏠 返回首页] │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
<!-- NEW END -->
|
||||
|
||||
**加载态原型**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ [← 返回] │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ ┌──────────────┐ │
|
||||
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
|
||||
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
|
||||
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░│ │
|
||||
│ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ └──────────────┘ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ░░░░ ░░░░░░░░░░░ │
|
||||
│ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ │
|
||||
│ ░░░░░░░░░ ░░░░░░ ░░░░░░░ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 边框线
|
||||
```
|
||||
|
||||
<!-- NEW START -->
|
||||
**C-001 变体: 非首页 Header(详情页、收藏夹、设置页使用)**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Muse [♡ 收藏] [⚙] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
说明:
|
||||
- 平台 Tab 栏在非首页隐藏,Logo 与右侧图标之间保持留白
|
||||
- 其余元素位置不变(Logo 左对齐,图标右对齐)
|
||||
- 适用于: P-002 详情页、P-003 收藏夹、P-004 设置页
|
||||
```
|
||||
<!-- NEW END -->
|
||||
|
||||
**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),防止误触卡片跳转
|
||||
|
||||
指标展示规则: <!-- NEW -->
|
||||
指标值为空(undefined)时隐藏该指标项,而非展示 0。
|
||||
与 DevelopmentPlan ContentItem 类型中 play_count?: number(可选字段)一致。
|
||||
|
||||
Hover 行为: <!-- NEW -->
|
||||
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 }
|
||||
```
|
||||
|
||||
<!-- NEW START -->
|
||||
**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
|
||||
```
|
||||
<!-- NEW END -->
|
||||
|
||||
**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) |
|
||||
148
doc/tasks.md
Normal file
148
doc/tasks.md
Normal file
@ -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 端到端测试 |
|
||||
901
doc/tikhub_api.md
Normal file
901
doc/tikhub_api.md
Normal file
@ -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 标签(`<a>`, `<span>` 等),需 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*
|
||||
496
e2e-real/real-e2e.spec.ts
Normal file
496
e2e-real/real-e2e.spec.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
96
e2e/detail.spec.ts
Normal file
96
e2e/detail.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
104
e2e/favorites.spec.ts
Normal file
104
e2e/favorites.spec.ts
Normal file
@ -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",
|
||||
"取消收藏"
|
||||
);
|
||||
});
|
||||
});
|
||||
148
e2e/fixtures.ts
Normal file
148
e2e/fixtures.ts
Normal file
@ -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];
|
||||
134
e2e/home.spec.ts
Normal file
134
e2e/home.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
73
e2e/settings.spec.ts
Normal file
73
e2e/settings.spec.ts
Normal file
@ -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/);
|
||||
});
|
||||
});
|
||||
641
externaldocs/cicd_integration_updated.md
Normal file
641
externaldocs/cicd_integration_updated.md
Normal file
@ -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=<your-client-id>
|
||||
- DRONE_GITEA_CLIENT_SECRET=<your-client-secret>
|
||||
- DRONE_SERVER_HOST=drone.internal.intelligrow.cn
|
||||
- DRONE_SERVER_PROTO=https
|
||||
- DRONE_RPC_SECRET=<your-rpc-secret>
|
||||
- DRONE_USER_CREATE=username:<your-gitea-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=<your-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 <user>` 后重新登录 |
|
||||
| 镜像名 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 文件的命名。
|
||||
947
externaldocs/drone_cicd_deployment_guide.md
Normal file
947
externaldocs/drone_cicd_deployment_guide.md
Normal file
@ -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": ["<DRONE_CI_IP_OR_DOMAIN>: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 <user>@<production-server-ip>
|
||||
|
||||
# 如果 SSH 端口不是 22(如 3141)
|
||||
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p <port> <user>@<production-server-ip>
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/drone_deploy -p <port> <user>@<production-server-ip> "echo ok"
|
||||
```
|
||||
|
||||
### 1.4 确保生产服务器用户有 Docker 权限
|
||||
|
||||
```bash
|
||||
# 在生产服务器上
|
||||
sudo usermod -aG docker <deploy-user>
|
||||
# 需要重新登录生效
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二步:项目接入 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_<service>` | 每个需构建的服务的完整镜像地址 | `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_<service>`**:每个需要构建的服务单独一个 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-<service-1>
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
IMAGE_REPO:
|
||||
from_secret: <service-1>_repo
|
||||
commands:
|
||||
- '[ -n "$IMAGE_REPO" ] || (echo "<service-1>_repo secret is empty" && exit 1)'
|
||||
- echo "Building <service-1>, tag=${DRONE_TAG:-latest}"
|
||||
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-1-context>
|
||||
- docker push "$IMAGE_REPO:${DRONE_TAG:-latest}"
|
||||
- docker push "$IMAGE_REPO:latest"
|
||||
|
||||
- name: build-<service-2>
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
IMAGE_REPO:
|
||||
from_secret: <service-2>_repo
|
||||
commands:
|
||||
- '[ -n "$IMAGE_REPO" ] || (echo "<service-2>_repo secret is empty" && exit 1)'
|
||||
- echo "Building <service-2>, tag=${DRONE_TAG:-latest}"
|
||||
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-2-context>
|
||||
- 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]
|
||||
```
|
||||
|
||||
**替换占位符**:
|
||||
|
||||
| 占位符 | 替换为 | 示例 |
|
||||
|--------|--------|------|
|
||||
| `<service-1>`, `<service-2>` | 你的服务名 | `backend`, `frontend`, `api`, `web` |
|
||||
| `<service-1-context>` | 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 = <registry-host>:5000/<project>-backend
|
||||
frontend_repo = <registry-host>:5000/<project>-frontend # 如果有
|
||||
deploy_host = <production-server-ip>
|
||||
deploy_user = <ssh-username>
|
||||
deploy_ssh_key = <SSH 私钥完整内容,cat ~/.ssh/drone_deploy>
|
||||
deploy_path = /opt/<project>
|
||||
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 <port> <user>@<prod-ip> "cd /opt/myproject && docker compose -f docker-compose.prod.yml ps"
|
||||
|
||||
# 检查镜像版本
|
||||
ssh -p <port> <user>@<prod-ip> "docker images | grep myproject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 方式 1:手动指定版本回滚
|
||||
|
||||
```bash
|
||||
ssh -p <port> <user>@<prod-ip>
|
||||
cd /opt/myproject
|
||||
bash scripts/deploy-remote.sh v1.0.0 # 指定要回滚到的版本
|
||||
```
|
||||
|
||||
### 方式 2:直接 compose 回滚
|
||||
|
||||
```bash
|
||||
ssh -p <port> <user>@<prod-ip>
|
||||
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
|
||||
<registry-host>:5000/<project-name>-<service>:<tag>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```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/<project>-<service>/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: <actual-port>`
|
||||
|
||||
### 9. Docker 权限不足
|
||||
|
||||
**错误**:生产服务器 `permission denied while trying to connect to the Docker daemon socket`
|
||||
|
||||
**解决**:`sudo usermod -aG docker <user>` 后重新登录
|
||||
|
||||
### 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 <service>` 查看报错 |
|
||||
| 健康检查超时 | 增大 `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://<your-gitea-domain>
|
||||
- DRONE_GITEA_CLIENT_ID=<gitea-oauth-app-client-id>
|
||||
- DRONE_GITEA_CLIENT_SECRET=<gitea-oauth-app-client-secret>
|
||||
- DRONE_SERVER_HOST=<your-drone-domain>
|
||||
- DRONE_SERVER_PROTO=https
|
||||
- DRONE_RPC_SECRET=<随机生成的长字符串>
|
||||
- DRONE_USER_CREATE=username:<your-gitea-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://<your-drone-domain>/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 自动构建并部署成功
|
||||
□ 生产服务正常运行
|
||||
□ 回滚流程测试通过
|
||||
```
|
||||
901
externaldocs/tikhub_api.md
Normal file
901
externaldocs/tikhub_api.md
Normal file
@ -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 标签(`<a>`, `<span>` 等),需 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*
|
||||
38
package.json
38
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"
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/backend/package.json
Normal file
25
packages/backend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
20
packages/backend/src/app.ts
Normal file
20
packages/backend/src/app.ts
Normal file
@ -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 };
|
||||
8
packages/backend/src/index.ts
Normal file
8
packages/backend/src/index.ts
Normal file
@ -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}`);
|
||||
});
|
||||
155
packages/backend/src/lib/adapters/bilibili.test.ts
Normal file
155
packages/backend/src/lib/adapters/bilibili.test.ts
Normal file
@ -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("未知作者");
|
||||
});
|
||||
});
|
||||
});
|
||||
66
packages/backend/src/lib/adapters/bilibili.ts
Normal file
66
packages/backend/src/lib/adapters/bilibili.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index: number) =>
|
||||
this.mapToContentItem(item, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
129
packages/backend/src/lib/adapters/douyin.test.ts
Normal file
129
packages/backend/src/lib/adapters/douyin.test.ts
Normal file
@ -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("未知作者");
|
||||
});
|
||||
});
|
||||
});
|
||||
88
packages/backend/src/lib/adapters/douyin.ts
Normal file
88
packages/backend/src/lib/adapters/douyin.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index: number) =>
|
||||
this.mapToContentItem(item, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
packages/backend/src/lib/adapters/index.test.ts
Normal file
61
packages/backend/src/lib/adapters/index.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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<Record<Platform, PlatformAdapter>> = {
|
||||
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 {
|
||||
175
packages/backend/src/lib/adapters/instagram.test.ts
Normal file
175
packages/backend/src/lib/adapters/instagram.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/backend/src/lib/adapters/instagram.ts
Normal file
96
packages/backend/src/lib/adapters/instagram.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
148
packages/backend/src/lib/adapters/tiktok.test.ts
Normal file
148
packages/backend/src/lib/adapters/tiktok.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/backend/src/lib/adapters/tiktok.ts
Normal file
89
packages/backend/src/lib/adapters/tiktok.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index: number) =>
|
||||
this.mapToContentItem(item, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
213
packages/backend/src/lib/adapters/twitter.test.ts
Normal file
213
packages/backend/src/lib/adapters/twitter.test.ts
Normal file
@ -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 <a href='https://t.co/test'>this link</a>!",
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/backend/src/lib/adapters/twitter.ts
Normal file
133
packages/backend/src/lib/adapters/twitter.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
packages/backend/src/lib/adapters/weibo.test.ts
Normal file
167
packages/backend/src/lib/adapters/weibo.test.ts
Normal file
@ -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: "今天天气真好 <a href='https://t.cn/test'>链接</a>",
|
||||
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: "<b>粗体</b>和<a href='#'>链接</a>文字",
|
||||
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("未知作者");
|
||||
});
|
||||
});
|
||||
});
|
||||
152
packages/backend/src/lib/adapters/weibo.ts
Normal file
152
packages/backend/src/lib/adapters/weibo.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, 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<string, unknown>, 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<string, unknown>, index)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
153
packages/backend/src/lib/adapters/xiaohongshu.test.ts
Normal file
153
packages/backend/src/lib/adapters/xiaohongshu.test.ts
Normal file
@ -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("未知作者");
|
||||
});
|
||||
});
|
||||
});
|
||||
158
packages/backend/src/lib/adapters/xiaohongshu.ts
Normal file
158
packages/backend/src/lib/adapters/xiaohongshu.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index: number) =>
|
||||
this.mapHotItemToContentItem(item, index)
|
||||
)
|
||||
.filter((item: ContentItem) => item.title !== "无标题");
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
178
packages/backend/src/lib/adapters/youtube.test.ts
Normal file
178
packages/backend/src/lib/adapters/youtube.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
80
packages/backend/src/lib/adapters/youtube.ts
Normal file
80
packages/backend/src/lib/adapters/youtube.ts
Normal file
@ -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<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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<string, unknown>, index: number) =>
|
||||
this.mapToContentItem(item, index)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
77
packages/backend/src/lib/rate-limiter.test.ts
Normal file
77
packages/backend/src/lib/rate-limiter.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
let canMakeRequest: () => boolean;
|
||||
let recordRequest: () => void;
|
||||
let waitForSlot: () => Promise<void>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
166
packages/backend/src/lib/tikhub.test.ts
Normal file
166
packages/backend/src/lib/tikhub.test.ts
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -15,7 +15,9 @@ export function getApiKey(): string | null {
|
||||
|
||||
export async function tikhubFetch<T>(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
method: "GET" | "POST" = "GET",
|
||||
body?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
@ -30,11 +32,12 @@ export async function tikhubFetch<T>(
|
||||
}
|
||||
|
||||
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<T>(
|
||||
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 {
|
||||
92
packages/backend/src/routes/settings.test.ts
Normal file
92
packages/backend/src/routes/settings.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
29
packages/backend/src/routes/settings.ts
Normal file
29
packages/backend/src/routes/settings.ts
Normal file
@ -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 };
|
||||
157
packages/backend/src/routes/tikhub.test.ts
Normal file
157
packages/backend/src/routes/tikhub.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
54
packages/backend/src/routes/tikhub.ts
Normal file
54
packages/backend/src/routes/tikhub.ts
Normal file
@ -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 };
|
||||
17
packages/backend/tsconfig.json
Normal file
17
packages/backend/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
26
packages/backend/vitest.config.ts
Normal file
26
packages/backend/vitest.config.ts
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
48
packages/frontend/package.json
Normal file
48
packages/frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@ -21,7 +21,7 @@ export default function FavoritesPage() {
|
||||
<h1 className="text-lg font-semibold text-slate-800">
|
||||
我的收藏
|
||||
</h1>
|
||||
<span className="text-sm text-slate-400">
|
||||
<span data-testid="favorites-count" className="text-sm text-slate-400">
|
||||
{favorites.length} 个内容
|
||||
</span>
|
||||
</div>
|
||||
@ -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<SortOrder>("desc");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(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 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-4xl mb-4">😵</p>
|
||||
<h2 className="text-lg font-medium text-slate-700 mb-2">
|
||||
加载失败
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
{error?.message || "请求出错,请稍后重试"}
|
||||
</p>
|
||||
<button
|
||||
data-testid="error-retry"
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !isLoading && sortedItems.length === 0 ? (
|
||||
<div data-testid="empty-state" className="text-center py-20">
|
||||
<p className="text-4xl mb-4">📭</p>
|
||||
<h2 className="text-lg font-medium text-slate-700 mb-2">
|
||||
暂无内容
|
||||
@ -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() {
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
data-testid="apikey-input"
|
||||
type={showKey ? "text" : "password"}
|
||||
value={inputKey}
|
||||
onChange={(e) => setInputKey(e.target.value)}
|
||||
@ -102,6 +104,7 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-testid="apikey-save"
|
||||
onClick={handleSaveApiKey}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Play, Heart, MessageCircle } from "lucide-react";
|
||||
import { getPlatformConfig } from "@/lib/platforms";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { Play, Heart, Bookmark, Clock } from "lucide-react";
|
||||
import { getPlatformConfig } from "@muse/shared";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import type { ContentItem } from "@/types/content";
|
||||
import type { ContentItem } from "@muse/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ContentCardProps {
|
||||
@ -19,11 +19,12 @@ export function ContentCard({ item }: ContentCardProps) {
|
||||
|
||||
const playCount = formatCount(item.play_count);
|
||||
const likeCount = formatCount(item.like_count);
|
||||
const commentCount = formatCount(item.comment_count);
|
||||
const collectCount = formatCount(item.collect_count);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/detail/${item.platform}/${encodeURIComponent(item.id)}`}
|
||||
data-testid="content-card"
|
||||
className="group block rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
{/* Cover image */}
|
||||
@ -33,6 +34,7 @@ export function ContentCard({ item }: ContentCardProps) {
|
||||
src={item.cover_url}
|
||||
alt={item.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw"
|
||||
@ -47,18 +49,26 @@ export function ContentCard({ item }: ContentCardProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
{/* Platform tag */}
|
||||
{platform && (
|
||||
<span
|
||||
className="inline-block text-xs px-1.5 py-0.5 rounded mb-1.5"
|
||||
style={{
|
||||
backgroundColor: `${platform.color}15`,
|
||||
color: platform.color,
|
||||
}}
|
||||
>
|
||||
{platform.icon} {platform.name}
|
||||
</span>
|
||||
)}
|
||||
{/* Platform tag + publish time */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
{platform && (
|
||||
<span
|
||||
className="inline-block text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${platform.color}15`,
|
||||
color: platform.color,
|
||||
}}
|
||||
>
|
||||
{platform.icon} {platform.name}
|
||||
</span>
|
||||
)}
|
||||
{item.publish_time && (
|
||||
<span className="flex items-center gap-0.5 text-[11px] text-slate-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(item.publish_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium text-slate-800 line-clamp-2 mb-2 leading-snug">
|
||||
@ -73,6 +83,7 @@ export function ContentCard({ item }: ContentCardProps) {
|
||||
alt={item.author_name}
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
@ -103,10 +114,10 @@ export function ContentCard({ item }: ContentCardProps) {
|
||||
{likeCount}
|
||||
</span>
|
||||
)}
|
||||
{commentCount && (
|
||||
{collectCount && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{commentCount}
|
||||
<Bookmark className="w-3 h-3" />
|
||||
{collectCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { ContentCard } from "./ContentCard";
|
||||
import { CardSkeleton } from "./CardSkeleton";
|
||||
import type { ContentItem } from "@/types/content";
|
||||
import type { ContentItem } from "@muse/shared";
|
||||
|
||||
interface ContentGridProps {
|
||||
items: ContentItem[];
|
||||
@ -26,7 +26,7 @@ export function ContentGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||
<div data-testid="content-grid" className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||
{items.map((item) => (
|
||||
<ContentCard key={`${item.platform}-${item.id}`} item={item} />
|
||||
))}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { Heart } from "lucide-react";
|
||||
import { useFavoritesStore } from "@/stores/favorites";
|
||||
import type { ContentItem } from "@/types/content";
|
||||
import type { ContentItem } from "@muse/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
@ -34,6 +34,7 @@ export function FavoriteButton({ item, size = "sm" }: FavoriteButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
data-testid="favorite-btn"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md transition-colors",
|
||||
@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Play, Heart, MessageCircle, Share2, ExternalLink } from "lucide-react";
|
||||
import { ArrowLeft, Play, Heart, Bookmark, MessageCircle, Share2, ExternalLink } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getPlatformConfig } from "@/lib/platforms";
|
||||
import { getPlatformConfig } from "@muse/shared";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
import type { ContentItem } from "@/types/content";
|
||||
import type { ContentItem } from "@muse/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DetailPanelProps {
|
||||
@ -16,6 +16,7 @@ interface DetailPanelProps {
|
||||
const STAT_ITEMS = [
|
||||
{ key: "play_count" as const, label: "播放", icon: Play },
|
||||
{ key: "like_count" as const, label: "点赞", icon: Heart },
|
||||
{ key: "collect_count" as const, label: "收藏", icon: Bookmark },
|
||||
{ key: "comment_count" as const, label: "评论", icon: MessageCircle },
|
||||
{ key: "share_count" as const, label: "分享", icon: Share2 },
|
||||
];
|
||||
@ -29,6 +30,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Back button */}
|
||||
<button
|
||||
data-testid="detail-back"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 mb-4 transition-colors"
|
||||
>
|
||||
@ -43,6 +45,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
|
||||
src={item.cover_url}
|
||||
alt={item.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
@ -83,6 +86,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
|
||||
alt={item.author_name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
@ -106,7 +110,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Stats panel */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{STAT_ITEMS.map(({ key, label, icon: Icon }) => {
|
||||
const value = item[key];
|
||||
return (
|
||||
@ -141,6 +145,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
data-testid="view-original"
|
||||
onClick={() => window.open(item.original_url, "_blank")}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MVP_PLATFORMS } from "@/lib/platforms";
|
||||
import { MVP_PLATFORMS } from "@muse/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlatformTabsProps {
|
||||
@ -18,6 +18,7 @@ export function PlatformTabs({ active, onChange }: PlatformTabsProps) {
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
data-testid={`platform-tab-${tab.id}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
"relative px-3 py-1.5 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
|
||||
@ -3,12 +3,13 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SortField = "play_count" | "like_count" | "comment_count" | "publish_time";
|
||||
export type SortField = "play_count" | "like_count" | "collect_count" | "comment_count" | "publish_time";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: "play_count", label: "播放量" },
|
||||
{ value: "like_count", label: "点赞数" },
|
||||
{ value: "collect_count", label: "收藏量" },
|
||||
{ value: "comment_count", label: "评论数" },
|
||||
{ value: "publish_time", label: "发布时间" },
|
||||
];
|
||||
@ -37,6 +38,7 @@ export function SortToolbar({
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">排序:</span>
|
||||
<select
|
||||
data-testid="sort-select"
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortByChange(e.target.value as SortField)}
|
||||
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
@ -48,6 +50,7 @@ export function SortToolbar({
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
data-testid="sort-order"
|
||||
onClick={onSortOrderChange}
|
||||
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
@ -57,6 +60,7 @@ export function SortToolbar({
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-testid="refresh-btn"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 disabled:opacity-50 transition-colors"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user