Web 端设计与双端互通方案
- 日期: 2026-07-30
- 状态: Draft (待用户审阅)
- 作者: TRAE Brainstorming Session
- 关联: xhs-miniapp-saas 项目,新增用户端 Web 应用
1. 背景与目标
1.1 现状
xhs-miniapp-saas 是一个多租户 SaaS 项目,已有三端:
- backend (FastAPI + asyncpg + pgvector):JWT 鉴权、HMAC 请求签名、RLS 多租户隔离、LLM 文章生成流水线、RAG 检索、封面 AI 生成
- miniapp (uni-app + Vue3):3 个 tab(生成文章 / 文章管理 / 封面神器)+ 登录子包,自实现 SHA256/HMAC
- admin (React + Vite + Antd):内网隔离 + noindex,仅管理员可用
1.2 目标
新增用户端 Web 应用,与小程序形成双端互通:
- 功能定位:PC 增强版(小程序聚焦移动端轻量场景,Web 聚焦批量管理、长文编辑、数据看板)
- 双端互通能力:数据互通、会话互通(扫码 SSO)、任务跨端继续、内容一键发布
- 部署:公网独立子域名(当前仅有公网 IP,域名备案中,先走 IP 过渡)
- 技术栈:React + Vite + Antd(与 admin 一致,工程范式可复用)
1.3 非目标
- 不替换小程序,双端并存
- 不重构 backend,仅按需扩展
- 不深度对接小红书开放平台(资质审批周期长,留待后续)
- 不开放 Web 端内网隔离(用户端必须公网可用)
2. 整体架构
2.1 工程结构
xhs-miniapp-saas/
├── backend/ # 已有,扩展少量接口
├── miniapp/ # 已有,Phase 2 扫码登录时改造
├── admin/ # 已有,不动(保持内网隔离)
├── web/ # 新增:用户端 Web
│ ├── src/
│ │ ├── api/ # HTTP 客户端 + WebCrypto 签名
│ │ ├── stores/ # Zustand: auth/session
│ │ ├── layouts/
│ │ ├── pages/
│ │ │ ├── Login/
│ │ │ ├── ArticleList/
│ │ │ ├── ArticleEditor/
│ │ │ ├── TaskCenter/ # Phase 2
│ │ │ └── CoverTool/ # Phase 3
│ │ ├── hooks/ # useArticleWizard / useTaskPolling(重写为 React)
│ │ └── components/
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
└── shared/ # 新增(极轻量):仅放双端共用的 OpenAPI/types 契约
└── types/2.2 三端职责边界
| 端 | 部署 | 鉴权 | 数据范围 |
|---|---|---|---|
| miniapp | 微信/小红书平台 | JWT + 自实现HMAC签名 | 当前 merchant(RLS) |
| web(新) | 公网独立子域名 app.example.com | JWT + WebCrypto HMAC签名 + Refresh Cookie | 当前 merchant(RLS) |
| admin | 内网隔离 | 管理员JWT + 内网IP白名单 | 全量数据(无RLS) |
2.3 后端改动原则
- 不新建服务:所有用户端接口共用现有 FastAPI
- CORS 扩展:
CORS_ORIGINS增加 Web 端域名/IP - 签名机制完全复用:
verify_request_signature不动,Web 端用浏览器原生crypto.subtle实现 HMAC-SHA256 - 新增接口按 Phase 拆分:Phase 1 仅新增 auth refresh/logout;Phase 2 加
/api/auth/qr-code/*、/api/task扩展;Phase 3 加/api/cover/batch
2.4 Phase 路线图
- Phase 1 (MVP):登录 + 文章管理 + 富文本编辑器 + 数据互通
- Phase 2:扫码登录 + 任务中心 + 任务跨端继续
- Phase 3:封面神器 Web 版(批量/模板,复用 mini-program 现有模型配置,不独立维护) + 内容一键发布(轻量版:剪贴板 + 图片下载)
- Phase 4:数据看板(可选)
2.5 部署演进(IP→域名过渡)
阶段 0:本地开发(立即开始)
- backend:
http://127.0.0.1:8000 - web:
http://localhost:5175(vite dev,与 admin 5173、小程序 5174 错开) - CORS:
.env中CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
阶段 1:公网 IP 临时上线(域名备案中)
http://<公网IP>:8080(Web) +https://<公网IP>:8443(API,自签名证书)- CORS 白名单加
http://<公网IP>:8080 - JWT 内存存储 + Authorization Header(不依赖 Cookie),保证 IP+HTTP 下可用
- 安全降级:仅内部测试账号,禁真实业务数据(通过
APP_ENV=staging时手机号白名单校验)
阶段 2:域名备案通过后切换
https://app.example.com(Web) +https://api.example.com(API)- Let's Encrypt 正规证书,启用 HSTS
- CORS 白名单替换为域名
- 代码零改动:BASE_URL 走
import.meta.env.VITE_API_BASE,仅改环境变量 + Nginx 配置 - 此时启用 HttpOnly Refresh Cookie(阶段 1 不启用,因 HTTP 下 Cookie 不安全)
工程实现
web/.env.example三套模板:.env.development/.env.staging(IP) /.env.production(域名)- Nginx 配置抽出为模板:
web/deploy/nginx-web.conf.template,用环境变量替换server_name - 阶段 1 的"临时配置"集中放在
web/DEPLOYMENT.md(仅运维文档,不进入代码逻辑)
3. 组件设计(Phase 1)
3.1 模块划分
web/src/
├── api/
│ ├── client.ts # axios 实例 + 拦截器(401清理token、统一错误处理)
│ ├── signature.ts # WebCrypto HMAC-SHA256(替代小程序的纯JS实现)
│ ├── auth.ts # login / register / me / refresh / logout
│ ├── article.ts # list / get / update / delete
│ └── task.ts # 异步生成任务查询
├── stores/
│ ├── auth.ts # Zustand: token(内存) / user / login / logout
│ └── ui.ts # 主题、侧边栏折叠等 UI 状态
├── layouts/
│ ├── AppLayout.tsx # 顶栏(用户菜单) + 侧边栏 + 内容区
│ └── BlankLayout.tsx # 登录页用
├── router/
│ ├── index.tsx # react-router v6 路由表
│ └── guard.tsx # 路由守卫:未登录跳 /login
├── pages/
│ ├── Login/ # 登录/注册(与小程序复用同一后端接口)
│ ├── ArticleList/ # 文章管理(ProTable)
│ ├── ArticleEditor/ # 富文本编辑器
│ └── NotFound/
├── components/
│ ├── ArticleTable.tsx # 列表 + 批量操作 + 搜索筛选
│ ├── ArticleEditor.tsx # 富文本编辑器封装
│ ├── TaskStatusTag.tsx # 任务状态徽标
│ └── PageContainer.tsx # 统一页面容器
├── hooks/
│ ├── useAuth.ts # 包装 auth store + 自动刷新
│ ├── useArticle.ts # 列表/详情/保存
│ └── useTaskPolling.ts # 轮询异步任务(参考小程序同名 composable)
└── types/
└── api.ts # 与小程序 types/api.ts 对齐的 TypeScript 类型3.2 状态管理(Zustand)
为什么不用 Redux/Context? 项目状态简单(auth + 少量UI),Zustand 比 Redux 轻,比 Context 不会触发无关重渲染。
interface AuthState {
token: string | null; // 仅内存,刷新页丢失
user: MerchantMe | null;
login: (token: string) => void;
logout: () => void;
refresh: () => Promise<void>;
}3.3 路由表(Phase 1)
/login → Login(BlankLayout)
/ → 重定向到 /articles
/articles → ArticleList(AppLayout, 需登录)
/articles/:id/edit → ArticleEditor(AppLayout, 需登录)
/tasks → (Phase 2 占位)
* → NotFound3.4 关键复用策略
| 小程序概念 | Web 端处理 |
|---|---|
useArticleWizard composable | 重写为 useArticle hook,逻辑可借鉴但 API 不同 |
useTaskPolling composable | 重写为同名 hook,复用后端 /api/task/:id 轮询协议 |
security/signature.ts(纯JS SHA256) | 重写为 WebCrypto API,性能更好、代码量减半 |
types/api.ts | 直接复制对齐,双端类型一致 |
stores/auth.ts(Pinia) | 重写为 Zustand,API 风格保持类似 |
3.5 富文本编辑器选型
Tiptap(基于 ProseMirror):
- 模块化、可定制、原生 TypeScript 支持
- 可粘贴 HTML/Markdown/纯文本,与小红书正文格式契合
- 图片上传对接现有
/api/upload
不选:Quill (API 陈旧)、Draft.js (Meta 维护停滞)、WangEditor (TS 支持弱)
3.6 文章管理(ProTable)核心交互
- 批量操作:多选 → 批量删除 / 批量导出(Markdown/纯文本)
- 搜索筛选:标题模糊、状态(草稿/已生成/已发布)、创建时间范围、关键词标签
- 列设置:用户自定义显示列、排序
- 行内操作:编辑、复制、删除、查看生成任务
- 跨端继续:列表显示"来源端"列(小程序/Web),点"继续编辑"直接打开编辑器
3.7 错误边界与加载态
- 路由级 Suspense + lazy import
- 请求级 loading(
useArticle返回{ data, loading, error }) - 全局 axios 拦截器统一 toast(Antd
message),401 触发 logout + 跳登录 <ErrorBoundary>包裹路由出口,捕获渲染异常显示友好兜底
4. 数据流与双端互通
4.1 数据互通(Phase 1,天然支持)
双端用同一套 /api/article/* 接口 + 同一 merchant_id,后端 RLS 自动隔离,Web 端零额外改动即可看到小程序生成的文章。
小程序 ─┐
├─→ POST /api/article ─→ PostgreSQL (RLS: merchant_id)
Web ─┘ ↑
│ set_config('app.current_merchant_id', ...)
│ (deps.py:get_db_session 已实现)字段约定(新增):article 表加 source_client 列,标记来源端
- 取值:
mp_weixin/mp_xhs/web - 用途:列表显示"来源端"标签,便于用户识别
- 后端在
article_service.create_article自动注入(从X-Client-Type请求头读取)
4.2 任务跨端继续(Phase 2)
任务状态机扩展
现有 task_service 主要是生成流程状态。新增"消费态"字段 consumer_state:
生成流程(已有):pending → running → succeeded / failed
消费状态(新增):unclaimed → claimed → completed跨端数据流
[小程序] [后端] [Web]
│ │ │
├─ POST /api/article/generate ─→ task_id=T1 │
│ (source_client=mp) │ │
│ 用户离开小程序 │ │
│ │←─ GET /api/task?state=running&mine=1 ─┤
│ │ 返回 T1 (running) │
│ │ ├─ 展示在"任务中心"
│ │ [LLM 完成] T1 → succeeded
│ │←─ GET /api/task/T1 ──────┤
│ │ 返回 succeeded + article_id
│ │←─ PUT /api/article/:id ──┤
│ │ (source_client=web) 继续编辑关键 API(Phase 2 新增)
| Method | Path | 用途 |
|---|---|---|
| GET | /api/task?state=running&mine=1 | 我未完成的任务列表 |
| GET | /api/task/:id | 任务详情(含 article_id) |
| POST | /api/task/:id/claim | 标记任务被某端继续(避免双端冲突) |
| DELETE | /api/task/:id | 取消任务 |
冲突避免:双端同时打开同一草稿时,用 version 字段做乐观锁
PUT /api/article/:id必带If-Match: <version>- 后端
UPDATE ... WHERE id=$1 AND version=$2 RETURNING version+1 - 失败返回 409,前端提示"文章已被另一端修改,请刷新"
4.3 会话互通(Phase 2,扫码登录)
[Web端] [后端] [小程序]
│ │ │
├─ POST /api/auth/qr-code ─→ 生成 qr_token=R │
│ 返回 { qr_token, expires_at } │
│ 展示二维码 + 轮询 GET /api/auth/qr-code/R/status│
│ status=pending │ │
│ │←── POST /api/auth/qr-code/R/confirm ─┤
│ │ (Authorization: Bearer <mp_token>)
│ │ 校验 R 未过期 + mp token 有效
│ │ 关联 merchant_id 到 R, status=confirmed
│ 轮询到 confirmed │ │
├─ POST /api/auth/qr-code/R/exchange │
│ 用 R 换取 Web 端 JWT (merchant_id 同源) │
│ 登录成功 │ │关键约束
qr_token一次性、5分钟过期、确认后立即失效- Web 端 JWT 和小程序 JWT 同一签名密钥,但 payload 加
client_type字段区分 - 双端会话独立:Web 端 logout 不影响小程序会话,反之亦然
- 同一 merchant 可同时存在多端会话(不做互踢,避免误伤)
4.4 内容一键发布(Phase 3,轻量版)
[Web端 编辑器] [后端] [小红书]
├─ 用户点"复制到小红书"
│ ① 提取正文纯文本 → 剪贴板 (navigator.clipboard.writeText)
│ ② 提取封面图 → 下载 (fetch + Blob + URL.createObjectURL)
│ ③ 提取标题 → 剪贴板(带分隔)
│ 弹窗引导:"已复制,请前往小红书发布"
│ 按钮:https://creator.xiaohongshu.com (新标签页)
└─ 后端记录:article.publish_action (audit log)
POST /api/article/:id/publish-action (仅审计,不参与实际发布)Phase 3 不深度对接小红书开放平台(资质审批 + OAuth 周期长,留待 Phase 4+)
4.5 错误处理策略
| 场景 | 处理 |
|---|---|
| 401(token 过期) | 拦截器自动调 refresh,失败再跳登录 |
| 409(版本冲突) | 提示"另一端已修改",强制刷新 |
| 422(业务校验失败) | toast 显示 detail 字段 |
| 5xx | toast "服务异常,请稍后重试" + Sentry 上报(可选) |
| 网络超时 | 普通接口 60s,AI 接口 180s(与小程序对齐) |
| 扫码登录轮询超时 | qr_token 过期,提示"刷新二维码" |
| 任务轮询期间断网 | 退避重试(1s→2s→4s→8s,上限 30s),3 次失败提示 |
4.6 数据一致性
- 强一致:文章 CRUD 走事务 + RLS,双端读取最新数据
- 最终一致:任务状态通过轮询(Web 端)+ 刷新(手动)同步,Phase 4 可升级为 SSE
- 冲突处理:乐观锁 + 409 强制刷新,避免双端覆盖
5. 安全模型(公网加固)
5.1 威胁面分析
| 威胁 | 风险 | 缓解策略 |
|---|---|---|
| XSS 窃取 token | 高 | JWT 内存存储 + CSP 严格策略 |
| CSRF(同源 cookie 场景) | 中 | 不依赖 Cookie 传 access token;Refresh Cookie 带 SameSite=Strict |
| 钓鱼站点仿冒 | 中 | CSP frame-ancestors 'none' + X-Frame-Options DENY |
| 暴力破解登录 | 高 | 复用现有 rate_limiter(IP+phone 双维度限流) |
| 中间人攻击 | 高 | 强制 HTTPS + HSTS(阶段 2 后启用) |
| 点击劫持 | 中 | X-Frame-Options: DENY |
| MIME 嗅探 | 低 | X-Content-Type-Options: nosniff |
| 请求重放 | 高 | 复用现有 verify_request_signature(HMAC+nonce+timestamp) |
5.2 JWT 双 Token 机制(新增)
Access Token (短期) Refresh Token (长期)
───────────────────── ──────────────────────
存内存(不落盘) 存 HttpOnly Cookie(阶段2起)
TTL: 2 小时 TTL: 7 天
走 Authorization: Bearer 走 Cookie 自动携带
每个请求带 HMAC 签名 仅 /api/auth/refresh 使用,不签名
泄漏窗口小 SameSite=Strict 防CSRFToken 生成与配置
- 两者均使用
jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_alg)签发,同一密钥同一算法 - payload 通过
type字段区分:type=access或type=refresh - access token payload 含
sub(merchant_id)、type=access、iat、exp - refresh token payload 含
sub(merchant_id)、type=refresh、client_type、iat、exp(无jti,撤销依据是 DB 中的token_hash = SHA256(refresh_token)) - 后端
decode_access_token增加校验:type=access才允许作为 Authorization Bearer 使用;type=refresh仅/api/auth/refresh接受
配置项扩展
# backend/app/config.py 新增
access_token_ttl_hours: int = int(os.getenv("ACCESS_TOKEN_TTL_HOURS", "2"))
refresh_token_ttl_days: int = int(os.getenv("REFRESH_TOKEN_TTL_DAYS", "7"))
# 现有 jwt_ttl_hours (168h=7d) 保留用于向后兼容小程序(小程序暂未升级双token)后端新增接口
| Method | Path | 用途 |
|---|---|---|
| POST | /api/auth/refresh | 用 Cookie 里的 refresh_token 换新 access_token |
| POST | /api/auth/logout | 撤销 refresh_token(DB 黑名单) |
DB schema 扩展
CREATE TABLE merchant_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
merchant_id BIGINT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL, -- SHA256(refresh_token)
client_type VARCHAR(16) NOT NULL, -- mp_weixin / mp_xhs / web
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ, -- 登出时填充
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_merchant
ON merchant_refresh_tokens(merchant_id) WHERE revoked_at IS NULL;为什么不存原 token? 数据库泄漏后不能直接复用,必须配合后端密钥再签名验证。
Refresh 流程
1. POST /api/auth/refresh
Cookie: refresh_token=xxx (HttpOnly, SameSite=Strict)
2. 后端:
- 读 Cookie refresh_token
- decode_access_token 校验 type=refresh + 未过期
- 计算 token_hash = SHA256(refresh_token),查 DB 确认未撤销
- 签发新 access_token (TTL=access_token_ttl_hours)
- 推荐:轮换 refresh_token(签发新 refresh + 撤销旧 token_hash 行 + Set-Cookie 新值)
3. 返回 { access_token, merchant_id }
4. 失败场景(均清 Cookie + 返回 401):
- Cookie 缺失 / refresh token 解析失败 / type 错误 / 已过期 / 已撤销 / DB 中找不到 token_hash5.3 CSP 策略
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline'; /* Antd 内联样式需要 */
img-src 'self' https: data:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';通过 Nginx 注入(避免 Vite dev 模式冲突),生产环境硬编码。
5.4 请求签名复用(WebCrypto)
async function hmacSha256(key: string, message: string): Promise<string> {
const enc = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey(
'raw', enc.encode(key),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
return Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
async function bodySha256(data: unknown): Promise<string> {
const text = stableStringify(data);
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0')).join('');
}后端 verify_request_signature 完全不改,因为算的是同一个 HMAC-SHA256。
5.5 限流复用
# 登录限流(已有,无需改)
rate_limiter.check(f"ip:{ip}:login", RateLimit(limit=5, window_seconds=60))
rate_limiter.check(f"phone:{phone}:login", RateLimit(limit=10, window_seconds=3600))
# 新增:refresh 限流(防暴力枚举 refresh_token)
rate_limiter.check(f"ip:{ip}:refresh", RateLimit(limit=10, window_seconds=60))5.6 Nginx 加固(生产环境)
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 完整 CSP 见 §5.3,此处通过环境变量注入避免转义
add_header Content-Security-Policy $csp_header always;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
location / {
root /var/www/web;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
location /api/ {
proxy_pass http://127.0.0.1:8000;
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;
}
}5.7 与 admin 安全模型对比
| 维度 | admin | web |
|---|---|---|
| 网络层 | 内网 IP 白名单 | 公网 + HTTPS |
| 鉴权 | 管理员 JWT | 商家 JWT + Refresh Cookie |
| 请求签名 | 不需要(内网可信) | 必须(HMAC+nonce+timestamp) |
| noindex | 必须(已实现) | 必须(同等要求,防用户内容被收录) |
| CSP | 严格 | 严格 |
| 限流 | 仅登录 | 登录 + refresh + 通用 API |
5.8 Phase 1 安全交付清单
Token 存储策略(按部署阶段切换,不混用):
| 部署阶段 | Access Token | Refresh Token |
|---|---|---|
| 阶段 0 (本地 dev) | 内存 | 内存(与 access 同生命周期,刷新页即重新登录,可接受) |
| 阶段 1 (公网 IP + HTTP) | 内存 | 不启用 refresh(HTTP 下 Cookie 不安全),刷新页即重新登录 |
| 阶段 2 (域名 + HTTPS) | 内存 | HttpOnly Cookie(SameSite=Strict) |
阶段 1 不启用 refresh 是有意的安全降级 — 避免在 HTTP 下泄漏 refresh token。代价是用户刷新页面需重新登录,对内部测试场景可接受。
- [ ] JWT 双 token 机制(access 内存 + refresh cookie,按上表分阶段启用)
- [ ]
/api/auth/refresh+/api/auth/logout接口(阶段 1 实现但不暴露给前端,阶段 2 启用) - [ ]
merchant_refresh_tokens表 + migration - [ ] Web 端 WebCrypto 签名实现
- [ ] CSP / HSTS / X-Frame-Options 等 Nginx 头(部署文档)
- [ ] 登录限流复用(无改动)
- [ ] refresh 限流新增
6. 测试策略
6.1 测试金字塔
┌─────────┐
│ E2E │ 10% 关键路径冒烟
┌─┴─────────┴─┐
│ Integration │ 30% API + DB
┌─┴─────────────┴─┐
│ Unit │ 60% 纯函数 / hooks / store
└─────────────────┘6.2 后端测试(扩展 backend/tests/)
新增测试文件:
test_auth_refresh.py:refresh 全流程test_auth_logout.py:logout 撤销test_article_source_client.py:source_client 字段注入test_article_version_conflict.py:乐观锁 409test_qr_code_auth.py:Phase 2 占位
关键用例(Phase 1 必须通过):
- Auth refresh:正常 refresh、过期、已撤销、不存在、限流、Cookie 缺失、跨 merchant 不可用
- Article source_client:Web 端创建 → source_client=web、缺失头 → 默认 web、非法值 → 422
- Article version conflict:并发 PUT → 后者 409、version 不匹配 → 409、正常更新 → version +1
复用现有 conftest.py 的 merchant_token / auth_client fixture,签名头由 fixture 自动生成。
6.3 Web 端测试
技术栈:Vitest + @testing-library/react + MSW (Mock Service Worker)
测试文件:
api/signature.test.ts:WebCrypto 签名正确性(NIST 测试向量 + 与后端对拍)stores/auth.test.ts:login/logout/refresh 状态流转hooks/useArticle.test.ts:列表/详情/保存逻辑hooks/useTaskPolling.test.ts:轮询退避重试pages/Login.test.tsx:表单校验 + 提交pages/ArticleList.test.tsx:加载/搜索/批量选择/409 冲突/空状态/错误态
6.4 集成测试
双端数据互通冒烟
async def test_web_can_read_mp_article(auth_client_web, auth_client_mp, test_db):
# 1. 小程序端创建文章
resp = await auth_client_mp.post("/api/article", json={...}, headers={"X-Client-Type": "mp_xhs"})
article_id = resp.json()["id"]
# 2. Web 端读取同一文章
resp = await auth_client_web.get(f"/api/article/{article_id}", headers={"X-Client-Type": "web"})
assert resp.status_code == 200
assert resp.json()["source_client"] == "mp_xhs"
# 3. Web 端更新
resp = await auth_client_web.put(
f"/api/article/{article_id}",
json={"title": "edited by web"},
headers={"X-Client-Type": "web", "If-Match": "1"}
)
assert resp.json()["version"] == 2
# 4. 小程序端读取,确认看到 web 的修改
resp = await auth_client_mp.get(f"/api/article/{article_id}")
assert resp.json()["title"] == "edited by web"签名跨端一致性
用小程序签名样本 + Web 端签名样本分别请求同一接口,验证后端 verify_request_signature 均能通过。
6.5 E2E 测试(Playwright,可选)
Phase 1 不强制,仅覆盖关键路径作为冒烟:
test('用户登录并编辑文章', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid=phone]', '13800138000');
await page.fill('[data-testid=password]', 'test1234');
await page.click('[data-testid=submit]');
await page.waitForURL('/articles');
await page.click('text=编辑');
await page.waitForURL(/\/articles\/.*\/edit/);
await page.fill('[data-testid=title]', 'E2E测试文章');
await page.click('text=保存');
await expect(page.locator('.ant-message')).toContainText('保存成功');
});6.6 CI 集成
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres: pgvector/pgvector:pg16
steps:
- run: cd backend && pytest -v --cov=app --cov-fail-under=70
web-test:
runs-on: ubuntu-latest
steps:
- run: cd web && npm ci && npm test -- --coverage6.7 Phase 1 验收标准(DoD)
- [ ] 后端测试覆盖率 ≥ 70%
- [ ] Web 端测试覆盖率 ≥ 70%(statements)
- [ ] 双端数据互通集成测试通过
- [ ] 签名跨端一致性测试通过
- [ ] 手动冒烟:小程序创建文章 → Web 端编辑 → 小程序看到修改
- [ ] 手动冒烟:登录限流触发正确
- [ ] 手动冒烟:refresh token 流程正常
6.8 不在 Phase 1 测试范围
- 扫码登录(Phase 2)
- 任务跨端继续(Phase 2)
- 封面神器 Web 版(Phase 3)
- 一键发布(Phase 3)
- E2E 自动化(视团队需要)
- 性能压测(视上线后反馈)
7. 风险与缓解
| 风险 | 等级 | 缓解 |
|---|---|---|
| 域名备案延迟超过预期 | 中 | 阶段 1 IP + 自签证书可承载内部测试;上线必须等域名 |
| HttpOnly Cookie 在阶段 1 不可用 | 中 | 阶段 1 access token 内存 + 退出登录即丢失(可接受),阶段 2 启用 Cookie |
| 双端并发编辑冲突 | 中 | 乐观锁 + 409 强制刷新,Phase 1 已覆盖 |
| WebCrypto 在老浏览器不可用 | 低 | 显式声明支持 Chrome 90+ / Edge 90+ / Firefox 90+ / Safari 14+ |
| 后端 RLS 在 NoRls 接口绕过 | 高 | Phase 1 仅 /api/auth/* 用 NoRls,其余接口强制走 RLS(已有约束) |
| 扫码登录被钓鱼 | 中 | qr_token 一次性 + 5 分钟过期 + 小程序端二次确认 UI |
| 内容一键发布被小红书风控 | 中 | Phase 3 仅剪贴板 + 下载,不自动发布;后续深度对接需走官方 API |
8. Open Questions(待 Phase 1 实施时确认)
- Refresh Token 是否每次轮换? 推荐轮换(每次 refresh 同时签发新 refresh + 撤销旧的),实施时确认。
- CSP
style-src 'unsafe-inline'能否进一步收紧?Antd 5 的内联样式依赖,可评估升级 Antd 6 后是否可去除。 - 任务状态 SSE 升级时机? Phase 4 看板上线时一并评估。
- Web 端是否需要离线支持? 当前不做,PWA 留待用户反馈。
- 多账号切换? Phase 1 不支持,单一登录态;后续按反馈评估。
9. 附录
9.1 术语表
- RLS: PostgreSQL Row-Level Security,行级安全
- JWT: JSON Web Token
- HMAC: Hash-based Message Authentication Code
- CSP: Content Security Policy
- HSTS: HTTP Strict Transport Security
- MVP: Minimum Viable Product
- DoD: Definition of Done
- SSE: Server-Sent Events
- SSO: Single Sign-On
9.2 参考文件
backend/app/deps.py:现有 JWT + 签名依赖注入backend/app/security/request_signing.py:HMAC 签名验证逻辑backend/app/security/intranet.py:admin 内网隔离中间件(参考其安全模式)miniapp/src/security/signature.ts:小程序签名实现(Web 端参考重写)miniapp/src/api/request.ts:小程序 HTTP 客户端(Web 端参考重写)admin/src/api/client.ts:admin HTTP 客户端(Web 端工程范式参考)
9.3 Phase 1 工作量预估(粗略,不作为承诺)
- 后端:3 个新接口 + 1 张表 + 测试(约 8-12 个工作日)
- Web 端工程脚手架:1-2 个工作日
- Web 端 Phase 1 功能:5-8 个工作日
- 测试 + 联调:3-5 个工作日
- 部署文档 + Nginx 配置:1 个工作日
Phase 1 总计:约 18-28 个工作日(具体以 writing-plans skill 生成的实施计划为准)