feat(r2): 前端 API 层——地址/设计清单切换到后端接口(阶段2)

- api/address.ts: fetchAddresses/createAddress/updateAddress/deleteAddress/
  setDefaultAddress;region[] ↔ province/city/district 双向转换唯一落点
- api/design.ts: fetchDesignList/createDesign/updateDesign/deleteDesign/
  deleteDesigns;状态映射 DRAFT↔undesigned、SUBMITTED↔designing、
  PROCESSING↔processing、DONE↔ordered;一条设计=一条清单(items 固定 1 元素,
  title=productName,productIcon 不上传由页面按 productId 兜底)
- types/index.ts: DesignItem.status 补 'processing'、productIcon 改 optional、
  AddressItem 补 createdAt?;全部消费点已确认兼容(均有兜底)
- docs/r2-workflow.md: R2 工作流程与阶段0 契约确认记录

验证:build:weapp 通过;Node 打桩 Taro.request 转发本地 3091 后端实测
13 项全过(region 往返/状态映射/designData 保留/越权 403/无效 token 401)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-12 03:10:51 +08:00
co-authored by Claude
parent 2b9c785bc4
commit 8438fea72f
9 changed files with 428 additions and 19 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+203
View File
@@ -0,0 +1,203 @@
# R2 地址 + 设计清单上链路工作流程
> 对应路线文档:`docs/routes/route-r2-address-design.md`
> 契约文档:后端 `wxmp_backend/docs/api-contract-v1.md` §4/§5、前端 `docs/design-data-contract-v1.md`
> 分支:前端 `wechat_wc` / 后端 `wxmp_backend` 均为 `feat/r2-address-design`
---
## 一、这条链路在做什么
把「收货地址」和「设计清单」两组数据从**小程序本地 Storage**openid 隔离的离线数据)
迁到**后端 PostgreSQL**(真实账号数据)。做完之后:
- 换手机、清缓存、卸载重装,地址和设计清单不丢;
- 多端(未来 web/管理后台)能看到同一份数据;
- R3 的下单闭环能直接消费服务端的地址 id 和设计清单 id(下单接口要传 `addressId``designListId`);
- R4 的 WCD 生产打包能从服务端读到完整的 `designData` JSON。
一句话:这是把"玩具数据"换成"账本数据"的一步,R3 整条交易链路都压在它上面。
---
### 阶段 0:契约确认 ✅(2026-09-11 已确认,零契约改动)
以下结论为 R2 实现的唯一依据,实现阶段不再讨论:
1. **清单映射:一条前端 DesignItem = 一条后端 DesignList 记录**`items` 数组固定 1 个元素。
依据:状态枚举是清单级实体字段(逐条映射的前提)、批量删除是 `{ids: string[]}`
`DesignList.orders` 逐条关联、R4 `wordcloud.service.ts` 已按 `items[0]` 消费。
POST /api/design-list 请求体 `{ title, items: [单个条目] }`
2. **designData.category 裁剪为 `{ id, mask, tone? }`**DIY 保存时只取这三个字段提交
(当前 `diy/index.tsx` 提交完整 ProductCategory,需改为裁剪),其余展示字段由客户端
按 productId 从 productConfig 推导。与冻结契约 §2 一致,后端白名单不放宽。
3. **productIcon 不入契约**:服务端不存图标,设计清单页继续用现有
`PRODUCT_ICON_MAP[productId]` 兜底推导(`designList/index.tsx:168` 逻辑已存在)。
4. **title 前端传 productName**:后端 `title` 保持必填不动,创建时填商品名。
5. **GET 两个列表接口不分页**:返回纯数组(地址、清单均为小数据量)。
6. **状态迁移触发点**:创建默认 `DRAFT`product 加入清单);DIY 保存设计 → `SUBMITTED`
`PROCESSING`/`DONE` 由 R3 订单流程驱动,前端不提交;`ordered``orderId != null` 派生,
前端只读。状态机单向推进,禁止回退。
7. **其他默认**:批量删除用单接口 `POST /api/design-list/batch-delete`
`productId` R2 不做存在性校验(R1 商品表可能未 seed);手机号只做非空字符串校验;
越权 403 / 不存在 404(契约既定)。
## 二、工作流程(按顺序)
### 原阶段 0 任务清单(存档)
1. 通读后端 `docs/api-contract-v1.md` §4(地址)、§5(设计清单)。
2. 确认状态映射表已定死(不再讨论):
| 前端显示 | 前端状态码 | 后端 DesignListStatus |
|---|---|---|
| 待设计 | `undesigned` | `DRAFT` |
| 设计中 | `designing` | `SUBMITTED` |
| 生产中 | `processing` | `PROCESSING` |
| 已下单 | `ordered`(派生) | `DONE` |
`ordered` **不落库**,由 `orderId != null` 派生;前端不提交这个状态。
3. 确认 Prisma `Address` / `DesignList` 模型字段与契约一致,缺字段先出迁移。
### 阶段 1:后端补齐 CRUD(先做,前端等它)
改动范围:`src/addresses/``src/design-list/`,两个模块均已注册,骨架已存在。
**addresses(当前只有 GET/POST/PATCH :id/default,且 service 全是 TODO):**
1. `PATCH /api/addresses/:id` —— 缺失,新增。
2. `DELETE /api/addresses/:id` —— 缺失,新增;若删的是默认地址,事务内把最新一条设为默认。
3. `POST` / `PATCH :id` / `PATCH :id/default` 的**默认地址唯一性事务**
`prisma.$transaction` 内先 `updateMany` 清掉该用户所有 `isDefault`,再设新默认。
4. `setDefault` 补 userId 归属校验(现在谁都能改任何人的地址,这是越权洞)。
5. 所有按 id 的路由统一:非本人 → 403,不存在 → 404。
**design-list(当前只有 GET/GET :id/POST):**
1. `PATCH /api/design-list/:id` —— 更新 `title`/`items`/状态迁移(状态机后端校验,
只允许 `DRAFT → SUBMITTED → PROCESSING → DONE` 单向推进)。
2. `DELETE /api/design-list/:id` + `POST /api/design-list/batch-delete`body `{ ids: string[] }`
一次事务删,返回实际删除数——不要让前端循环单删)。
3. `items` JSON 白名单校验:放行 `version/background/wordcloud/rotation/zIndex`
**原样透传不修改**`wordcloud` 分组是 R4 写入的,丢了 WCD 打包就失败);
单条 designData ≤ 1MB,超限 400。
4. `findOne` 补归属校验(当前 TODO)。
5. Swagger 补全以上全部接口。
### 阶段 2:前端 API 层(页面不动)
1. `src/utils/api/address.ts`(现为空占位):实现
`fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress`
**唯一一处**做 `region: [province, city, district]``province/city/district` 双向转换,
页面和 store 不允许出现第二次转换。
2. `src/utils/api/design.ts`:实现
`fetchDesignList / createDesign / updateDesign / deleteDesign(s)`
做后端 `DRAFT/SUBMITTED/...` ↔ 前端 `undesigned/designing/...` 的状态码映射。
3. `src/types/index.ts``AddressItem` 增加服务端字段(服务端 id、`createdAt` 等);
`DesignItem.status``'processing'``DesignItem.orderId` 派生 `ordered` 展示。
### 阶段 3:切换页面(两个页面 + 三个写入点)
| 文件 | 改动 |
|---|---|
| `pages/address/index.tsx` | 增删改查、设默认全部走 API;401 由 request 层统一抛出并引导登录;API 失败降级读本地缓存并提示 |
| `pages/designList/index.tsx` | 列表/筛选/批量删除走 API;状态筛选基于服务端返回的 `status` 计算,消灭魔法字符串 |
| `pages/product/index.tsx`(加入清单) | `addDesign` 写 API(需登录),失败降级本地 |
| `pages/diy/index.tsx`(保存 designData | `updateDesign` 走 API`designData.category` 裁剪为 `{id, mask, tone}`(决策#2);贴纸本地图路径允许暂存(R4 派单前才持久化,契约约束 #1 |
| `pages/diy/stickerEdit/index.tsx` | 同上,保存贴纸改动走 API |
### 阶段 4:本地 store 降级改造
`utils/store/address.ts``design.ts` 保留,但语义变为"离线兜底缓存":
- API 成功 → 把服务端数据写回本地缓存(下次冷启动先展示缓存再刷新);
- API 失败/断网 → 页面读缓存并可正常浏览,写操作提示失败;
- 登录后首次进入:只读服务端,**不自动合并**本地旧数据(`smart_design_list_<openid>`
与服务端并存的问题按路线文档风险节处理:忽略或提供一次性导入,默认忽略)。
### 阶段 5:联调与验收
按路线文档验收标准逐条过:
- 登录态下地址/清单全流程走真实后端;
- 越权访问返回 403、不存在返回 404(用两个账号互测);
- 默认地址永远唯一;删默认地址后自动产生新默认;
- 断网降级提示、401 引导登录;
- 前端 `npm run build:weapp` 通过、后端 `nest build` 通过;
- Swagger 涵盖全部接口;状态映射落进契约文档。
---
## 三、需要注意的部分(坑位清单)
### 后端
1. **越权是本分支最大风险**。每个按 id 操作的 service 必须带 `where: { id, userId }`
查不到时区分 404/403:先查存在性再查归属,或统一 404(避免枚举他人资源 id 时,
推荐统一 404,路线文档要求两者区分则按 403 处理——按契约文档走)。
2. **默认地址唯一性只能由后端事务保证**。前端传 `isDefault: true` 只是一个"请求"
不是规则;不要在 controller 之外有任何直接 update `isDefault` 的路径。
3. **删除默认地址的补偿**必须和删除在同一个事务里,否则会出现"全员无默认"的中间态。
4. **items JSON 是白名单透传,不是深校验**。后端只验结构和大小,不改内容;
特别是 PATCH 时要做**合并语义**确认:不传 `designData` 不得清掉已有值
(否则 R4 写入的 `wordcloud` 会被一次普通数量修改冲掉)。
5. **状态机校验**:拒绝 `DONE → DRAFT` 这类回退;`ordered` 不接受前端提交。
6. **批量删除**要么后端一个接口,要么明确部分失败语义——不要默认前端循环单删。
### 前端
1. **region 转换只写在 `api/address.ts` 一处**。散落到页面就会出现两套转换,
后续排查字段错位会花双倍时间。
2. **不要改 `checkout/index.tsx`**(红线:checkout 归 R3)。R2 只保证接口稳定,
checkout 里对本地 store 的读取暂不动,R3 接入时一并切换。
3. **不要把 `wxfile://` 临时路径当脏数据清洗掉**。DIY 保存时贴纸 src 暂为本地路径是
契约允许的(R4 在下单/派单前才持久化),后端白名单放行即可。
4. **`DesignItem.status` 增加 `processing` 后**`designList` 页的 `STATUS_STYLE`
筛选 tabs 要同步扩展,漏了会出现"生产中"条目渲染不出徽标。
5. **401 处理已有全局机制**`request.ts` 自动续登 + `onUnauthorized`),
页面里不要自己再写跳登录逻辑,重复处理会出现双弹窗。
6. **旧本地数据不迁移**。首次切换后以服务端为准,本地旧清单直接忽略;
提前和需求方确认这一点(用户可能反馈"我的设计没了"——是预期行为,需要文案兜底)。
### 跨路线影响
| 受影响方 | 影响 | 需要做的 |
|---|---|---|
| **R3 订单** | 下单接口依赖服务端 `addressId` / `designListId` | R2 保证 id 稳定、可查询;R3 接入时 checkout 改传服务端 id |
| **R4 词云/派单** | `designData` 结构和保留字段 | R2 PATCH 不得丢 `wordcloud` / `category.mask` / 贴纸本地路径 |
| **profile / settings / orderDetail 页** | 现在从本地 store 读地址和清单数 | R2 期间 API 成功会回写本地缓存,这些页面**暂不改也能读到底数据**(读的是缓存);R3/R4 后续各自切换 |
| **userDatabase 账号管理** | 靠 `getDesignListFor(openid)` 跨账号读本地清单 | 服务端化后该功能语义变化(读不到别人云端数据),属于已知降级,无需处理 |
---
## 四、影响面(改动文件总览)
**后端 `wxmp_backend`(分支 feat/r2-address-design):**
- `src/addresses/*`:补 PATCH :id、DELETE :id、默认事务、归属校验、DTO、Swagger
- `src/design-list/*`:补 PATCH/DELETE/批量删除、items 白名单与 1MB 校验、状态机、归属校验
- `prisma/`:仅当模型字段与契约不一致时才出迁移(预计不需要)
- `docs/api-contract-v1.md`:落地状态映射表与错误码(如尚未完整落地)
**前端 `wechat_wc`(分支 feat/r2-address-design):**
- `src/utils/api/address.ts``design.ts`:从空占位到完整实现(核心新增)
- `src/types/index.ts`AddressItem/DesignItem 对齐服务端结构
- `src/pages/address/index.tsx``designList/index.tsx`:数据源切 API
- `src/pages/product/index.tsx``diy/index.tsx``diy/stickerEdit/index.tsx`:写入点切 API
- `src/utils/store/address.ts``design.ts`:降级为缓存层,接口签名尽量不变以减少页面改动
**明确不动:** `checkout/index.tsx``orders`/`orderDetail` 页、`utils/store/order.ts`R3 所有)。
---
## 五、里程碑建议
| 步骤 | 产出 | 验证 |
|---|---|---|
| 1 | 后端 addresses 完整 CRUD + 事务 | Swagger 手测 + 双账号越权测试 |
| 2 | 后端 design-list 完整 CRUD + 校验 | 同上 + 超 1MB JSON 拒绝测试 |
| 3 | 前端 API 层 + 类型对齐 | `build:weapp` 通过 |
| 4 | 两个页面 + 三个写入点切换 | 真机全流程 |
| 5 | store 降级缓存 + 断网/401 场景 | 关服务端模拟断网 |
| 6 | 契约文档收口 + 双端构建 | 验收标准逐条打勾 |
+12 -4
View File
@@ -142,17 +142,23 @@ export interface WordCloudDispatchResult {
message?: string
}
/** 设计清单条目 */
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
export interface DesignItem {
id: string
productId: string
productName: string
productIcon: string
/**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
unitPrice: number
count: number
status: 'undesigned' | 'designing' | 'ordered'
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1
orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string
}
@@ -168,7 +174,7 @@ export interface OrderItem {
sku: string
}
/** 收货地址 */
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
export interface AddressItem {
id: string
name: string
@@ -176,6 +182,8 @@ export interface AddressItem {
region: string[] // [province, city, district]
detail: string // 门牌号/详细地址
isDefault: boolean
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
createdAt?: string
}
/** 编辑器中的图片状态(向后兼容) */
+91 -5
View File
@@ -1,7 +1,93 @@
/**
* R2 收货地址接口归属文件
* 后端 /api/addresses CRUD 就绪后,在这里补充:
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约
* R2 收货地址接口api-contract-v1 §4
*
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
* 双向转换的文件;页面与 store 不得出现第二次转换(route-r2 §3)
*/
export {}
import http from '../request'
import type { AddressItem } from '../../types'
/** 后端 Address 结构(契约 §4:四个独立地址字段) */
interface ServerAddress {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
/** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
function toAddressItem(rec: ServerAddress): AddressItem {
return {
id: rec.id,
name: rec.name,
phone: rec.phone,
region: [rec.province, rec.city, rec.district],
detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
/** 前端 region 数组 → 后端四个独立字段 */
function toServerFields(addr: {
name?: string
phone?: string
region?: string[]
detail?: string
isDefault?: boolean
}): Record<string, unknown> {
// 后端 ValidationPipe forbidNonWhitelistedundefined 字段会被 JSON 序列化丢弃,
// 这里显式展开保证只传后端声明的字段
const body: Record<string, unknown> = {}
if (addr.name !== undefined) body.name = addr.name
if (addr.phone !== undefined) body.phone = addr.phone
if (addr.region !== undefined) {
const [province = '', city = '', district = ''] = addr.region
body.province = province
body.city = city
body.district = district
}
if (addr.detail !== undefined) body.detail = addr.detail
if (addr.isDefault !== undefined) body.isDefault = addr.isDefault
return body
}
/** 我的收货地址列表(默认地址在前) */
export async function fetchAddresses(): Promise<AddressItem[]> {
const list = await http.get<ServerAddress[]>('/api/addresses')
return (list || []).map(toAddressItem)
}
/** 新增收货地址(首个地址后端自动设为默认) */
export async function createAddress(
addr: Omit<AddressItem, 'id' | 'createdAt'>,
): Promise<AddressItem> {
const rec = await http.post<ServerAddress>('/api/addresses', toServerFields(addr))
return toAddressItem(rec)
}
/** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
export async function updateAddress(
id: string,
patch: Partial<Omit<AddressItem, 'id' | 'createdAt'>>,
): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}`, toServerFields(patch))
return toAddressItem(rec)
}
/** 删除本人地址(若删的是默认地址,后端自动补偿最新一条为默认) */
export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`)
}
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}/default`)
return toAddressItem(rec)
}
+117 -5
View File
@@ -1,7 +1,119 @@
/**
* R2 设计清单接口归属文件
* 后端 /api/design-list CRUD 就绪后,在这里补充:
* fetchDesignList / createDesign / updateDesign / deleteDesign
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
* R2 设计清单接口api-contract-v1 §5
*
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
* - 一条前端 DesignItem = 一条后端 DesignList 记录,items 固定 1 个元素;
* - title 由 productName 承担(后端必填);
* - productIcon 不入库(后端 forbidNonWhitelisted 会拒绝),页面按 productId 兜底推导;
* - 状态映射:DRAFT↔undesigned、SUBMITTED↔designing、PROCESSING↔processing、DONE↔ordered
* - 前端只允许提交 designing(→SUBMITTED),PROCESSING/DONE 由 R3 驱动。
*/
export {}
import http from '../request'
import type { DesignDataV1, DesignItem } from '../../types'
/** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
SUBMITTED: 'designing',
PROCESSING: 'processing',
DONE: 'ordered',
}
/** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
/** 后端 DesignList 记录结构 */
interface ServerDesignList {
id: string
userId?: string
title: string
items: (Partial<DesignListEntryPayload> & Record<string, unknown>)[]
status: string
createdAt: string
updatedAt?: string
}
/** ServerDesignList → 前端 DesignItem */
function toDesignItem(rec: ServerDesignList): DesignItem {
const entry = rec.items && rec.items[0]
return {
id: rec.id,
productId: entry?.productId ?? '',
productName: entry?.productName || rec.title,
// productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: entry?.unitPrice ?? 0,
count: entry?.count ?? 1,
status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: entry?.designData,
createdAt: (rec.createdAt || '').slice(0, 10),
}
}
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> {
const list = await http.get<ServerDesignList[]>('/api/design-list')
return (list || []).map(toDesignItem)
}
/** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
export async function createDesign(entry: DesignListEntryPayload): Promise<DesignItem> {
const rec = await http.post<ServerDesignList>('/api/design-list', {
title: entry.productName,
items: [
{
productId: entry.productId,
productName: entry.productName,
unitPrice: entry.unitPrice,
count: entry.count,
...(entry.designData !== undefined ? { designData: entry.designData } : {}),
},
],
})
return toDesignItem(rec)
}
export interface UpdateDesignPayload {
/** 新标题(前端一般不传) */
title?: string
/** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
status?: 'designing'
/** 条目整体替换(items 是全量覆盖,修改 designData 时必须带全 4 个基本字段) */
item?: DesignListEntryPayload
}
/** 更新本人清单(部分更新:只传显式给出的字段) */
export async function updateDesign(id: string, payload: UpdateDesignPayload): Promise<DesignItem> {
const body: Record<string, unknown> = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.status !== undefined) body.status = 'SUBMITTED'
if (payload.item !== undefined) {
body.items = [
{
productId: payload.item.productId,
productName: payload.item.productName,
unitPrice: payload.item.unitPrice,
count: payload.item.count,
...(payload.item.designData !== undefined ? { designData: payload.item.designData } : {}),
},
]
}
const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
return toDesignItem(rec)
}
/** 删除本人清单 */
export async function deleteDesign(id: string): Promise<void> {
await http.del(`/api/design-list/${id}`)
}
/** 批量删除(后端只删本人条目,返回实际删除数;部分 id 无效不报错) */
export async function deleteDesigns(ids: string[]): Promise<number> {
const res = await http.post<{ deleted: number }>('/api/design-list/batch-delete', { ids })
return res?.deleted ?? 0
}