Skip to content

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.comJWT + 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: .envCORS_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 不安全)

工程实现

  1. web/.env.example 三套模板:.env.development / .env.staging (IP) / .env.production (域名)
  2. Nginx 配置抽出为模板:web/deploy/nginx-web.conf.template,用环境变量替换 server_name
  3. 阶段 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 不会触发无关重渲染。

ts
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 占位)
*                           → NotFound

3.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 新增)

MethodPath用途
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 字段
5xxtoast "服务异常,请稍后重试" + 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 窃取 tokenJWT 内存存储 + 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 防CSRF

Token 生成与配置

  • 两者均使用 jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_alg) 签发,同一密钥同一算法
  • payload 通过 type 字段区分:type=accesstype=refresh
  • access token payload 含 sub(merchant_id)、type=accessiatexp
  • refresh token payload 含 sub(merchant_id)、type=refreshclient_typeiatexp(无 jti,撤销依据是 DB 中的 token_hash = SHA256(refresh_token)
  • 后端 decode_access_token 增加校验:type=access 才允许作为 Authorization Bearer 使用;type=refresh/api/auth/refresh 接受

配置项扩展

python
# 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)

后端新增接口

MethodPath用途
POST/api/auth/refresh用 Cookie 里的 refresh_token 换新 access_token
POST/api/auth/logout撤销 refresh_token(DB 黑名单)

DB schema 扩展

sql
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_hash

5.3 CSP 策略

http
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)

ts
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 限流复用

python
# 登录限流(已有,无需改)
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 加固(生产环境)

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 安全模型对比

维度adminweb
网络层内网 IP 白名单公网 + HTTPS
鉴权管理员 JWT商家 JWT + Refresh Cookie
请求签名不需要(内网可信)必须(HMAC+nonce+timestamp)
noindex必须(已实现)必须(同等要求,防用户内容被收录)
CSP严格严格
限流仅登录登录 + refresh + 通用 API

5.8 Phase 1 安全交付清单

Token 存储策略(按部署阶段切换,不混用)

部署阶段Access TokenRefresh 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:乐观锁 409
  • test_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.pymerchant_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 集成测试

双端数据互通冒烟

python
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 不强制,仅覆盖关键路径作为冒烟:

ts
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 集成

yaml
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 -- --coverage

6.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 实施时确认)

  1. Refresh Token 是否每次轮换? 推荐轮换(每次 refresh 同时签发新 refresh + 撤销旧的),实施时确认。
  2. CSP style-src 'unsafe-inline' 能否进一步收紧?Antd 5 的内联样式依赖,可评估升级 Antd 6 后是否可去除。
  3. 任务状态 SSE 升级时机? Phase 4 看板上线时一并评估。
  4. Web 端是否需要离线支持? 当前不做,PWA 留待用户反馈。
  5. 多账号切换? 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 生成的实施计划为准)

基于 VitePress 构建 · 由 GitHub Actions 自动部署