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
+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
}