Merge feature R4 (upload + wordcloud + WCD dispatch) into master

This commit is contained in:
2026-08-13 01:51:19 +08:00
46 changed files with 2096 additions and 493 deletions
+87
View File
@@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概览
智绘微刻(Smart-Engraving)微信小程序:词云生成 + 个性化激光雕刻定制。基于 **Taro 3.6.31 + React 18 + TypeScript**,SCSS 变量驱动主题,构建产物运行在微信小程序(webpack5 编译到 `dist/`)。
## 常用命令
```bash
npm install # 安装依赖
npm run dev:weapp # 微信小程序开发模式(watch,配合微信开发者工具)
npm run build:weapp # 生产构建;也是提交前 / 验收的标准验证命令
npm run dev:h5 # H5 预览调试(H5 下事件用 onClick,见下文)
node --test tools/homeTopMask.test.mjs # 运行 node:test 单测(目前唯一测试,无需 jest)
```
没有配置 lint/test 脚本。提交前验证 = `npm run build:weapp` 通过(见 `docs/parallel-development-guide.md` §6)。
## 数据访问层(核心架构,务必先读)
页面**不得**直接读写 Storage 或直接发请求。所有数据操作取道两个聚合入口,二者都只 re-export、不堆业务:
- `../../utils/store`(DA 层,本地 Storage,按域拆文件):页面从 `src/utils/store/index.ts` 导入 `getDesignList()``addAddress()``designToOrder()` 等。
- `../../utils/api`HTTP 层,按域拆文件):页面从 `src/utils/api/index.ts` 导入 `login()``fetchProducts()` 等。
每个域文件(`store/keys|user|design|address|order|theme.ts``api/auth|user|product|address|design|order|upload.ts`)按领域职责划分,新增逻辑加到对应域文件里(不要新建 `xxx2.ts`),新文件需在聚合入口显式 re-export。后端替换 = 把 `store/<domain>.ts` 的函数换成 `api/<domain>.ts` 的 HTTP 调用,页面层零改动。
**openid 作用域隔离**`store/keys.ts`):storage key 形如 `${scope}_${openid}`,通过 `key(scope)`(当前用户)与 `keyFor(scope, openid)`(指定用户)生成,实现多用户数据隔离。
类型统一放 `src/types/index.ts``src/types/<domain>.ts`,不要散落在页面里。
## 协作约定(R0–R4 并行路线)
仓库按四人并行开发组织,主干为 `master`,禁止直接推主干,合入走 PR。每人一条端到端路线(前端 + 后端、同名分支):
| 路线 | 分支 | 页面归属 | 数据域 |
|---|---|---|---|
| R1 商品目录 | `feat/r1-catalog` | `index``shop``shop/detail``product` | `api/product` |
| R2 地址+设计清单 | `feat/r2-address-design` | `address``designList` | `store/design``store/address``api/address``api/design` |
| R3 订单+支付占位 | `feat/r3-order-pay` | `checkout``orders``orderDetail` | `store/order``api/order` |
| R4 上传+词云+线稿 | `feat/r4-upload-wordcloud` | `wordcloud``diy/stickerEdit` | `api/upload` |
`store/index.ts``api/index.ts` 由 R0(本分支 `feat/r0-dev-foundation`)维护,**只加不删、不堆业务**。完整规则见 `docs/team-collaboration-guide.md``docs/parallel-development-guide.md`(含合并顺序:R1 → R2/R4 → R3)。
## 主题系统
`ThemeMode``light` / `dark` / `auto``store/theme.ts`)。`ThemeContext``src/context/ThemeContext.tsx`)产出最终生效的 `resolvedTheme: 'light' | 'dark'`
- 页面根节点 `className={theme-${resolvedTheme}}`;所有颜色/字号来自 `app.scss` 里的 CSS 变量(浅色 `:root` + `.theme-dark`),**禁止在页面里硬编码颜色/字号/组件形态**,token 语义见 `DESIGN.md`,落地规则见 `设计变更文档.md`
- 每个页面都挂 `<ThemedPageMeta />``PageMeta + NavigationBar`),按 `resolvedTheme` 强制覆盖顶部/状态栏颜色;`useStatusBar(resolvedTheme)` 在页面显示时再同步一次。`app.config.ts` 里的 `darkmode: true` 只作为系统主题探测通道(`onThemeChange`/`getAppBaseInfo`),窗口颜色不依赖它。
- 自定义 tabBar 无法继承 React Context,通过 `Taro.eventCenter` 订阅 `themeChange` 事件 (`THEME_CHANGE_EVENT`) 同步主题。
- 图标一律用 `src/icon/` 下的 PNG/SVG**禁止用 emoji 当图标**。
## 事件与生命周期(重要兼容坑)
- **一律用 `onTap`,不要用 `onClick`**。微信基础库 3.17.0 + Taro React 下动态更新 handler 会触发 `TaroElement.removeEventListener(undefined)`,报 `Cannot read properties of undefined (reading '_num')`。仅在 H5 构建里可用 `onClick`
- 页面显示刷新用 `useDidShow``@tarojs/taro`),不要手动覆盖 `page.onShow`(热更新会重复叠加导致异常)。
## 请求与登录
- `src/utils/request.ts``BASE_URL = https://wxbackend.tokenleaping.com`;后端统一响应 `{ code, message, data }``code === 0` 成功;token 存 `smart_access_token`,走 `Authorization: Bearer`。HTTP 401 会自动触发一次会话续登(`app.tsx` 注册的 `refreshSession`),失败则清 token 并调 `onUnauthorized`
- `session.ts``refreshSession``wx.login` 换新 code → `/api/auth/login` 续签 → `/api/users/me` 校验 openid 与本地账号一致才放行(防串号)。`mock_*` 开头的 openid 是历史残留,`isMockOpenid` 会识别并拒绝。
- 登录态校验有缓存(`src/utils/authState.ts`),`LoginGuard` 监听 `authStateChanged`/`tabBarChange`/`themeChange` 事件自动刷新。
## 静态资源与 OSS
- 产品大图已迁到阿里云 OSS(bucket `wordcloudwechat`,杭州),包体积需保持在微信 2MB 限制内(当前 `dist/` 约 1MB)。
- 基址在根目录 `.env``OSS_BASE_URL`git 忽略),由 `config/index.js` 注入为 `__OSS_BASE_URL__` 常量。
- 所有资源路径经 `assetUrl()``src/utils/asset.ts`)处理:`/img/*` 大图拼 OSS 远程地址,`/icon/*` 小图标与 `src/icon``theme.json` 通过 `config/index.js` 的 copy patterns 打包进 `dist/`
- 商品/品类配置集中在 `src/utils/productConfig.ts``PRODUCTS` 数组 + icon 映射 + `tone` 主题色),遮罩尺寸说明见 `docs/mask-config-guide.md`
## 页面结构说明
路由与 tabBar 见 `src/app.config.ts`(全局 `navigationStyle: custom`5 个 tab 的自定义 tabBar)。几个非平凡的页面:
- `pages/shop/index`:单页双容器——2 列商品网格 + 固定 overlay 沉浸式详情(滚动视差、产品 `tone` 主题色联动),点击卡片在当前页内过渡,不跳转;底部 CTA 跳 `/pages/product/index?id=xxx` 衔接购买链路。
- `pages/diy/index`:DIY 工作台(贴纸拖拽/缩放/碰撞检测),后续 `diy/stickerEdit` 为贴纸编辑画板。
- 共享组件在 `src/components/``ThemedPageMeta``ScrollTopMask``TopBarGradient``BottomActionBar``LoginGuard``LoginModal` 等)。
## 已知遗留问题
README「已知遗留问题」表记载(改动相关页面时注意):
- checkout 页在设计中新增地址后无法及时同步(design 数据写入与读取生命周期不一致)。
- `diy/stickerEdit` 页面无法运行:Canvas 2D `createImage` 在部分基础库版本返回 `undefined`,且线稿 AI 接口未接入。
+5 -5
View File
@@ -30,8 +30,8 @@ smart-engraving-miniapp/
│ │ └── index.ts # TypeScript 类型定义(ProductCategory、DesignItem、OrderItem 等)
│ ├── utils/
│ │ ├── productConfig.ts # 产品品类配置(5 个品类 + iconImg 映射)
│ │ ├── store.ts # Data Access 层(Storage 读写openid 隔离)
│ │ ├── api.ts / request.ts # 后端 HTTP 封装(wx.request + token
│ │ ├── store/ # Data Access 层(按域拆分openid 隔离)
│ │ ├── api/ # 后端 HTTP 封装(按域拆分,wx.request + token
│ │ ├── authState.ts # 登录态校验缓存(isAuthVerified / invalidateAuthCache
│ │ └── themeBackground.ts # 页面窗口背景同步(backgroundColorTop/Bottom
│ ├── context/
@@ -139,16 +139,16 @@ smart-engraving-miniapp/
### 前端 DA 层设计
所有数据操作通过 `src/utils/store.ts` 抽象:
所有数据操作通过 `src/utils/store/` 抽象(按域拆分,详见 `docs/parallel-development-guide.md`
```
pages/
└─ 调用 store.getDesignList() / addDesign() / designToOrder() 等业务接口
└─ store.ts 内封装 Taro.getStorageSync / setStorageSync
└─ store/<domain>.ts 内封装 Taro.getStorageSync / setStorageSync
└─ key = ${scope}_${openid} 实现多用户数据隔离
```
**后端替换方案**上线时只需重写 `store.ts` 中的函数为 `wx.request` HTTP 调用,页面层零改动。
**后端替换方案**按域在 `store/<domain>.ts``api/<domain>.ts` 中替换为 HTTP 调用,页面层零改动。
### Icon 系统(emoji 已完全移除)
Vendored
+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
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{653:function(e,n,t){var a=t(7842),i=t(5544),c=t(118),o=t(758),r=t.n(o),s=t(6540),u=t(8594),f=t(9703),g=t(6606),h=t(8199),l=t(4848),v=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}];function p(e){var n=v.findIndex(function(n){return e.endsWith(n.pagePath)});return v[n>=0?n:0].pagePath}function b(){var e=r().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return p(n)}function m(){var e=(0,s.useState)((0,u.eW)((0,u.O4)())),n=(0,i.A)(e,2),t=n[0],a=n[1],o=(0,s.useState)(b),h=(0,i.A)(o,2),m=h[0],d=h[1];(0,s.useEffect)(function(){var e=function(e){return a(e)};return r().eventCenter.on(f.Wo,e),function(){r().eventCenter.off(f.Wo,e)}},[]),(0,s.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,u.Rq)(e.theme),"auto"===(0,u.O4)()&&a(e.theme))},n=r();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,s.useEffect)(function(){var e=function(e){var n=r().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";d(p(e||t))};return r().eventCenter.on("tabBarChange",e),function(){r().eventCenter.off("tabBarChange",e)}},[]),(0,s.useEffect)(function(){(0,g.G)(t)},[t]);var x=function(e){d(p(e)),r().switchTab({url:e}),r().eventCenter.trigger("tabBarChange",e)};return(0,l.jsxs)(c.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,l.jsx)(c.Ss,{className:"tab-bar-gradient"}),(0,l.jsx)(c.Ss,{className:"custom-tab-bar theme-".concat(t),children:v.map(function(e){var n=e.pagePath===m;return(0,l.jsxs)(c.Ss,{className:"tab-item ".concat(n?"active":""),onTap:function(){return x(e.pagePath)},children:[n&&(0,l.jsx)(c.Ss,{className:"tab-indicator"}),(0,l.jsx)(c._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,l.jsx)(c.EY,{className:"tab-label",children:e.label})]},e.pagePath)})})]})}Component((0,a.createComponentConfig)(m,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(653)});e.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{653:function(e,n,t){var a=t(7842),i=t(5544),c=t(118),o=t(758),r=t.n(o),s=t(6540),u=t(1457),f=t(9703),g=t(6606),h=t(8199),l=t(4848),v=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}];function p(e){var n=v.findIndex(function(n){return e.endsWith(n.pagePath)});return v[n>=0?n:0].pagePath}function b(){var e=r().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return p(n)}function m(){var e=(0,s.useState)((0,u.eW)((0,u.O4)())),n=(0,i.A)(e,2),t=n[0],a=n[1],o=(0,s.useState)(b),h=(0,i.A)(o,2),m=h[0],d=h[1];(0,s.useEffect)(function(){var e=function(e){return a(e)};return r().eventCenter.on(f.Wo,e),function(){r().eventCenter.off(f.Wo,e)}},[]),(0,s.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,u.Rq)(e.theme),"auto"===(0,u.O4)()&&a(e.theme))},n=r();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,s.useEffect)(function(){var e=function(e){var n=r().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";d(p(e||t))};return r().eventCenter.on("tabBarChange",e),function(){r().eventCenter.off("tabBarChange",e)}},[]),(0,s.useEffect)(function(){(0,g.G)(t)},[t]);var x=function(e){d(p(e)),r().switchTab({url:e}),r().eventCenter.trigger("tabBarChange",e)};return(0,l.jsxs)(c.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,l.jsx)(c.Ss,{className:"tab-bar-gradient"}),(0,l.jsx)(c.Ss,{className:"custom-tab-bar theme-".concat(t),children:v.map(function(e){var n=e.pagePath===m;return(0,l.jsxs)(c.Ss,{className:"tab-item ".concat(n?"active":""),onTap:function(){return x(e.pagePath)},children:[n&&(0,l.jsx)(c.Ss,{className:"tab-indicator"}),(0,l.jsx)(c._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,l.jsx)(c.EY,{className:"tab-label",children:e.label})]},e.pagePath)})})]})}Component((0,a.createComponentConfig)(m,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(653)});e.O()}]);
+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
+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
+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
+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
+120
View File
@@ -0,0 +1,120 @@
# 设计数据 → WCD 生产任务契约 v1
> 跨路线契约:**R2(数据生产者)与本路线 R4(WCD 打包消费者)共同遵守**。
> 冻结目的:R4 下单后要把设计清单以 `.wcd` 包投递到词云平台形成生产任务
> (见 `docs/routes/route-r4-upload-wordcloud.md` §3)。R4 能打包,取决于 R2
> 保存的 `designData` 携带哪些字段。本文档把这些字段**先定死**。
>
> 版本规则:字段只能新增 optional 字段;改名/改语义/改类型必须升版本并同步本文件。
## 1. 为什么需要本契约
词云平台用 `.wcd`Zip`manifest.json` + `document.json` + `assets/`)还原整套画布。
还原需要三样东西全部可用:
1. **画布尺寸 / 背景** ← 来自 `category.mask`
2. **贴纸布局**(位置、大小、旋转、层级)← 来自 `stickers[]`
3. **贴纸图片字节** ← 来自每个贴纸的**持久 URL**(COS)。
第 3 点是关键约束:如果贴纸图是 `wxfile://`/`tmp` 临时路径,后端打包时下载不到字节,
WCD 生产任务直接失败。因此本契约把"图片必须持久化"从 R2 的已知限制升级为**冻结约束**。
## 2. 冻结的 `designData` 结构(v1
与现有前端 `DesignItem.designData` 对齐,保留 `imageSrc/imagePos` 兼容旧版,**新增**如下字段:
```ts
/** 设计数据 v1 —— 支持 R4 WCD 生产任务打包 */
interface DesignDataV1 {
/** 结构版本,缺省按 v1 处理;旧数据可由后端/前端按版本迁移 */
version?: 1
/** 商品品类(已有)。mask 是画布尺寸来源,保存清单时必须保留 */
category?: {
id: string
mask: { shape: 'rect' | 'circle'; width: number; height: number; borderRadius?: number }
tone?: [number, number, number]
}
/** 底图(已有 imageSrc 语义的规范化):src 必须是持久 URL */
background?: {
src: string // COS 持久 URL;禁止 wxfile:// / tmp 临时路径
color?: string // 画布底色(缺省由 mask/主题决定)
pos?: { x: number; y: number; scale: number } // 兼容旧 imagePos
}
/**
* 词云信息(R4 在词云生成后写入;R2 保存/更新清单时原样保留这组字段,
* 后端 items JSON 白名单放行)。
*/
wordcloud?: {
jobId?: string // 生成该词云的 wxmp_backend 侧任务 id
imageUrl: string // 词云结果图持久 URL(COS)
names: string[] // 名单快照(≤200),生产/重建用,可为空
}
/** 贴纸列表(已有)。每个贴纸的 src 升级为持久 URL 约束 */
stickers?: Array<{
id: string
src: string // ★ 必须为 COS 持久 URLhttps),禁止 wxfile:// / tmp / 空
x: number
y: number
scale: number
width: number
height: number
rotation?: number // 已冻结(2026-08-12 决策#2):本期 DIY 持久化;无旋转则省略
zIndex?: number // 已冻结(2026-08-12 决策#2):图层顺序;WCD 按 zIndex 排列元素
isOverlapping?: boolean
edits?: {
brightness?: number // -100~100
hue?: number // -180~180
contrast?: number // -100~100
sketchSrc?: string
}
}>
}
```
关系说明:
- `DesignItem.designData`(前端类型文件 `src/types/index.ts`)与后端 `design-list.items[].designData`
(服务端 JSON)为**同一份**结构;前端类型以本契约为准,后端只做白名单校验不改业务 JSON。
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`
WCD 打包器同时兜底读旧字段。
## 3. 冻结约束(R2 需落地)
| # | 约束 | 说明 |
|---|---|---|
| 1 | **贴纸图持久化(R4 负责)** | 决策#4:**贴纸持久化由 R4 完成**。R2 允许保存/更新清单时贴纸 `src` 暂为本地路径(`wxfile://`/`tmp`);**R4 在下单/派单前**把本地图上传为 COS 持久 URL 并回写 `designData.stickers[].src`。WCD 打包只消费持久 URL。R2 需保证该字段可被 R4 回写 |
| 2 | **词云字段保留** | `designData.wordcloud` 由 R4 写入后,R2 的 PATCH/列表接口不得丢弃该分组 |
| 3 | **mask 必须存在** | `designData.category.mask` 是画布尺寸来源;缺 mask 的旧数据 WCD 打包按产品默认尺寸兜底 |
| 4 | **布局字段持久化** | 决策#2`rotation`/`zIndex` 本期由 DIY 随贴纸持久化(当前 `StickerItem` 缺 rotation,需补) |
| 5 | **白名单放行** | 后端 `design-list` 的 items JSON 白名单放行 `version/background/wordcloud/rotation/zIndex` 字段;单条 ≤1MB 上限以新结构复核(图片在远端 URL,JSON 本体仍应很小) |
| 6 | **状态映射不变** | `ordered` 继续由 `orderId != null` 派生,本契约不改变 R2 状态机 |
## 4. 边界(本期不做)
- 贴纸 `edits`(亮度/色相/对比度/`sketchSrc` 线稿)无法由词云平台 `.wcd`
`document.json` 表达,本期不随 WCD 携带;仅记录在 `manifest.meta`。若后续生产需要
线稿还原,需单独升 WCD 契约(wordcloud 侧支持后再谈)。
- 字体不随包携带(wordcloud 第一阶段不做字体打包)。
- `.wcd` 只做"订单 → wordcloud"单向投递;暂不做"词云平台 → 小程序"的反向导出。
## 5. 落地位置
| 文件 | 内容 |
|---|---|
| 前端类型 | `wechat_wc/src/types/index.ts``DesignItem.designData` 按本契约补齐字段 |
| 前端页面 | `diy`(保存 designData)、`checkout`(下单前确认 designData 完整) |
| 后端白名单 | `wxmp_backend/src/design-list/*` 的 items 校验放行新字段 |
| 契约发行 | `wxmp_backend/docs/api-contract-v1.md` §5 引用本文档 |
## 6. 决策记录(2026-08-12 冻结)
| 评审点 | 决策 |
|---|---|
| 1. `background`/`wordcloud` 命名 | **接受**,按 §2 结构冻结 |
| 2. `rotation`/`zIndex` 是否本期持久化 | **本期持久化**DIY 随贴纸保存(见约束 #4 |
| 3. 名单快照 `wordcloud.names` 是否必须 | **小程序可不必须**`names` 保持 optional,小程序可不带;R4 生成词云后按实际写入,生产侧不强依赖 |
| 4. 贴纸图持久化由谁触发 | **R4 负责**:下单/派单前把本地贴纸图上传为 COS 持久 URL 并回写 `src`(见约束 #1 |
+133
View File
@@ -0,0 +1,133 @@
# 并行开发指南(R0
团队的仓库、分工、Git 规范与开发节奏以
[团队协作总文档](team-collaboration-guide.md) 为准;本页专注代码修改约定。
本文档是四名成员并行开发时的代码修改约定。目标是:每个人在自己路线的文件里改动,
不与其他人发生文件级冲突;共享文件只由 R0 聚合入口维护,平时不再堆业务代码。
## 1. 目录约定
页面不得直接读写 Storage 或跨过聚合入口调用底层模块,统一从下面两个入口导入:
```ts
import { getAddressList, addAddress } from '../../utils/store'
import { fetchProducts, createOrder } from '../../utils/api'
```
`src/utils/store/`DA 层,按域拆分:
| 文件 | 归属 | 内容 |
|---|---|---|
| `store/keys.ts` | R0,尽量不改 | openid 作用域 key 与 mock 判断 |
| `store/user.ts` | R0/R3 | 用户信息、账号切换、注册表 |
| `store/design.ts` | R2 | 设计清单本地数据 |
| `store/address.ts` | R2 | 收货地址本地数据 |
| `store/order.ts` | R3 | 订单本地数据与 designToOrder |
| `store/theme.ts` | R0 | 主题模式读写 |
| `store/index.ts` | R0 | 聚合 re-export,只加不删 |
`src/utils/api/`HTTP 接口层,按域拆分:
| 文件 | 归属 | 内容 |
|---|---|---|
| `api/auth.ts` | R0 | 登录、登出、token |
| `api/user.ts` | R0 | 用户资料 |
| `api/product.ts` | R1 | 商品、分类 |
| `api/address.ts` | R2 | 收货地址(待补) |
| `api/design.ts` | R2 | 设计清单(待补) |
| `api/order.ts` | R3 | 订单、支付(待补) |
| `api/upload.ts` | R4 | 上传、词云、线稿(待补) |
| `api/index.ts` | R0 | 聚合 re-export |
类型统一放在 `src/types/index.ts`,或按域新增 `src/types/<domain>.ts`
不要把页面私有类型散落在各个页面里。
## 2. 路线归属
每条路线的完整开发内容、设计 DO/DON'T 与验收标准见 `docs/routes/`
| 文档 | 分支 |
|---|---|
| [R1 商品目录动态化](routes/route-r1-product-catalog.md) | `feat/r1-catalog` |
| [R2 地址 + 设计清单](routes/route-r2-address-design.md) | `feat/r2-address-design` |
| [R3 订单 + 支付占位](routes/route-r3-order-pay.md) | `feat/r3-order-pay` |
| [R4 上传 + 词云 + 线稿](routes/route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` |
| 路线 | 页面 | 后端 | 词云项目 |
|---|---|---|---|
| R1 商品目录 | `index``shop``product` | products/categories、schema 扩展、seed | 不用 |
| R2 地址 + 设计清单 | `address``designList` | addresses CRUD、design-list CRUD、状态映射 | 不用 |
| R3 订单 + 支付占位 | `checkout``orders``orderDetail`、付款按钮 | orders 重算/事务、payments 占位 | 不用 |
| R4 上传 + 词云 + 线稿 | `wordcloud``diy/stickerEdit` | upload、词云适配器、sketch、队列 | 复用,需冻结最小契约 |
文件所有权约定:
- `checkout` 页归 R3R2 只做 `address``designList` 页。
- `profile` 页是只读统计消费者,等 R2/R3 合入后再统一收尾。
- `wordcloud` 项目只有 R4 会碰,其余路线不依赖它。
## 3. 新增接口怎么改
1. 后端先把 Swagger/DTO 定下来,确认字段与状态枚举。
2. 在对应的 `api/<domain>.ts` 里写函数,返回类型对齐 `src/types`
3. 不需要改页面里的导入路径:聚合入口已 re-export 所有域文件,
新函数会自动从 `../../utils/api` 导出。
4. 页面只调用 `api` 层的函数,不在页面里直接写 `http.get`
## 4. 新增本地业务怎么改
1. 判断属于哪个域,写进 `store/<domain>.ts`
2. 若该域已有文件,直接在文件内追加函数;不要新建 `xxx2.ts`
3. 新域需在 `store/index.ts` 增加一行显式 re-export。
4. 页面通过 `../../utils/store` 导入,不直接 import 内部模块路径。
## 5. Git 协作
每路线在 `wechat_wc``wxmp_backend` 使用同名分支:
| 路线 | 分支名 |
|---|---|
| R1 | `feat/r1-catalog` |
| R2 | `feat/r2-address-design` |
| R3 | `feat/r3-order-pay` |
| R4 | `feat/r4-upload-wordcloud` |
日常循环:
```bash
git checkout main && git pull
git checkout -b feat/r1-catalog
# 提交并推送自己的分支
git add -A
git commit -m "feat(catalog): xxx"
git push -u origin feat/r1-catalog
# 主干有更新时,变基到最新
git fetch origin
git rebase origin/main
# 合入:只有合入负责人执行
git checkout main && git pull
git merge --no-ff feat/r1-catalog
git push origin main
```
规则:
- 主干 `main` 保持可运行,禁止直接 push,合入走 Pull Request。
- 后端与前端同名分支是一对,评审时成对看,后端 Swagger 先定契约。
- 不跨路线互相拉分支;主干更新只通过 `rebase origin/main` 获取。
- 合入顺序:R1 先合(R3 服务端金额重算依赖商品表),R2/R4 随后,R3 最后。
## 6. 验证
每次提交前至少保证:
```bash
npm run build:weapp
```
R1 合入前额外跑后端接口 smoke;R2/R3 合入前跑对应页面在微信开发者工具里的手测;
R4 合入前跑一次词云契约 smoke。
+130
View File
@@ -0,0 +1,130 @@
# R4 交付报告(feat/r4-upload-wordcloud
> 日期:2026-08-13 | 范围:上传 / 词云生成 / 线稿 / **下单后 WCD 生产任务投递**
> 涉及仓库:`wechat_wc`(小程序前端)、`wxmp_backend`NestJS 后端)、`wordcloud`FastAPI 词云平台)
---
## 1. 目标与架构
把「用户上传底图 + 名单 → 词云生成 → DIY 贴纸 → 下单」链路中的上传、词云、线稿全部接到真实后端,
并**在下单后把订单对应的整套设计以 `.wcd` 包投递到词云平台形成生产任务**。
```
小程序 designData(贴纸持久化为 COS URL
→ POST /api/ordersdesignListId 关联,R3
→ POST /api/orders/:id/dispatch(幂等)
→ WordCloudService.buildWcdPackage(designData) → .wcdZip: manifest.json+document.json+assets/
→ POST {WORDCLOUD_API_URL}/api/jobs wcd_fileMODE=WCD
→ 词云平台还原设计 + 合成生产 PNG → 后端轮询同步 CustomizationTask/WordCloudJob → 产物转存 COS
```
小程序只与 `wxmp_backend` 通信;`WORDCLOUD_API_URL` 未配置时返回结构化 `not_configured`,不做假成功。
---
## 2. 前端交付(wechat_wc / feat/r4-upload-wordcloud
| 模块 | 内容 |
|---|---|
| `src/types/index.ts` | `DesignDataV1``version/background/wordcloud`)、`StickerItem``rotation/zIndex`;新增 `WordCloudJob/WordCloudStatus/CosCredentials/SketchResult/WordCloudDispatchStatus/WordCloudDispatchResult` |
| `src/utils/api/upload.ts` | `getUploadCredentials``uploadToCos`(预签名 PUT 直传)、`sketchImage``createWordCloudJob``getWordCloudJob``getWordCloudResult``persistDesignMedia`(贴纸持久化,决策#4)、`dispatchOrder`multipart 统一带 Bearer token、解析 `{code,message,data}` 信封 |
| `src/pages/wordcloud/index.tsx` | 去掉 `setInterval`+`Math.random` 假进度 → 真实「上传底图→建任务→轮询→取结果→保存相册」;新增生成失败/未配置提示块 |
| `src/pages/diy/stickerEdit/index.tsx` | 删 `your-api-domain.com` 占位 → 走 `sketchImage``POST /api/sketch`),失败仍降级前端灰度 |
| `src/pages/diy/index.tsx` | `handleComplete` 保存前调用 `persistDesignMedia`:本地贴纸/底图 → COS 持久 URL 写回 `designData` |
| 文档 | `docs/routes/route-r4-upload-wordcloud.md``docs/design-data-contract-v1.md``CLAUDE.md` |
验证:`npm run build:weapp` 通过。
## 3. 后端交付(wxmp_backend / feat/r4-upload-wordcloud
| 模块 | 内容 |
|---|---|
| `config` | `wordcloud: { apiUrl, timeoutMs }` 配置组 + Joi `WORDCLOUD_API_URL/WORDCLOUD_TIMEOUT_MS`(可选) |
| `cos` | `CosService`:预签名 PUT URL 签发、服务端 `putObject`、公网 URL;未配置 → `configured=false` |
| `upload` | `GET /api/upload/credentials?key=` 返回预签名 URL 并记录 `Upload` 归属(当前用户) |
| `wordcloud` | `POST /api/wordcloud/generate`multipart image+names → xlsx → 投递 `/api/jobs`)、`GET /api/wordcloud/jobs/:id`(归属校验+实时代理轮询)、`GET /api/wordcloud/jobs/:id/result` |
| `wordcloud` 派单 | `buildWcdPackage`designData→.wcd,下载素材字节、sha256)、`dispatchToWordcloud`**幂等**、未配置→`not_configured`)、`POST /api/orders/:id/dispatch` |
| `wordcloud` 同步 | `onModuleInit` 后台 30s 轮询进行中任务 → 成功时结果图转存 COS、同步 `CustomizationTask→SUCCESS/resultUrl` |
| `sketch` | `POST /api/sketch`multipart image,类型/大小校验 ≤10MB)→ 转存 COS 返回 URL |
| `orders` | `OrdersService.create` 保存 `designList` 关联(R3 契约字段) |
**Prisma 迁移**3 个):
| 迁移 | 内容 |
|---|---|
| `add_word_cloud_job` | `WordCloudJob` 模型 + `WordCloudJobStatus` 枚举 |
| `add_remote_job_id` | `WordCloudJob.remoteJobId`wordcloud 外部 job_id |
| `r4_order_design_and_dispatch` | `Order.designListId`+`DesignList.orders``CustomizationTask.orderId/wordcloudJobId` |
验证:`nest build` 通过、`prisma validate` 通过、迁移已应用。
## 4. wordcloud 平台交付(wordcloud / feat/r4-wcd-job,仅工作区)
`POST /api/jobs` 新增**可选 `wcd_file`**`MODE=WCD`):
- 校验 `format=wordcloud-canvas``version=1`;素材按 SHA-256 去重注册(复用 `/api/design-templates/import` 逻辑);`document.json` 落库为生产设计。
- 后台线程用 Pillow 把 CanvasDocument 合成**扁平生产 PNG**(背景 + 按 zIndex 叠贴纸)→ `set_artifacts({png})``queued/running/success`,产物经 `GET /api/jobs/{id}/files/png` 可下载。
- 对既有 `.xlsx` 名单模式完全向后兼容(纯新增可选字段)。
验证:`py_compile` 通过;合成算法用独立脚本验证(贴纸正确合成到对应坐标)。**未提交**——该仓库 `app.py` 有他人大量在途改动且无远程,改动留在 `feat/r4-wcd-job` 工作区,由 wordcloud 维护者合入其分支后提交。
## 5. 配置(后端 `.env`
```dotenv
# ── 词云平台 ──
WORDCLOUD_API_URL= # 词云服务地址;留空 → 派单返回 not_configured
WORDCLOUD_TIMEOUT_MS=30000
# ── COS ──
COS_SECRET_ID=
COS_SECRET_KEY=
COS_BUCKET=wordcloudwechat
COS_REGION=ap-guangzhou
```
## 6. 验证情况
| 项 | 结果 |
|---|---|
| `wechat_wc npm run build:weapp` | ✅ 通过(多次) |
| `wxmp_backend nest build` | ✅ 通过(EXIT=00 error |
| `wxmp_backend prisma validate` + 迁移应用 | ✅ 通过 |
| `wordcloud app.py py_compile` + 合成算法单测 | ✅ 通过(算法级) |
| `wordcloud pytest` | ⚠️ 系统 python 缺 `pandas/pytest`(项目 venv 未装),**完整测试需团队环境** |
诚实说明:COS 与 wordcloud 的本地 env 均为空,**运行期未做真实端到端联调**(见 §7 注意事项)。
## 7. 注意事项(上线前必读)
1. **微信后台域名**:小程序 `downloadFile`/`uploadFile` 合法域名需加入 COS 桶域名(`https://wordcloudwechat.cos.ap-guangzhou.myqcloud.com`)与后端域名(`https://wxbackend.tokenleaping.com`);否则真机上传/下载失败。
2. **COS**`wordcloudwechat` 桶需公共读;`COS_*` 密钥只存后端 env。直传用预签名 URL,小程序不持永久密钥。
3. **wordcloud 部署**`WORDCLOUD_API_URL` 指向真实部署实例;联调前锁定一个版本(它正在大量变更)。`wcd_file` 实现需 wordcloud 侧合入其分支(当前在工作区)。
4. **贴纸持久化依赖**`buildWcdPackage` 只消费持久 URL;若 designData 里的贴纸仍是本地/临时路径会被跳过。前端保存时已调用 `persistDesignMedia`,但**必须在 COS 已配置**下才生效。
5. **任务同步**:当前用服务内 30s 后台轮询推进状态(未依赖 Redis/BullMQ queue);生产量大时可换 queue processor(占位仍在 `src/queue/`)。
6. **安全**:所有 wordcloud 任务查询带 userId 归属校验;未配置时结构化返回,不做假成功;名单 ≤200、底图 ≤10MB、任务超时清理。
7. **测试环境**:跑 wordcloud 测试需安装 `pandas`/`pytest`
## 8. 对接需求
| 对接方 | 需求 |
|---|---|
| **R2(设计清单)** | `designData``docs/design-data-contract-v1.md` 冻结:放行 `version/background/wordcloud/rotation/zIndex``category.mask` 必须保留;贴纸图 `src` 由 R4 持久化(R2 允许保存时暂为本地路径)。4 项决策已冻结(见契约 §6) |
| **R3(订单)** | 创建订单时传 `designListId``OrdersService.create` 已支持存储);订单进入 `PROCESSING` 时可自动触发 `POST /api/orders/:id/dispatch`;支付未配置期用该端点做联调/运营触发 |
| **wordcloud 团队** | 合入 `feat/r4-wcd-job` 工作区的 `create_job` wcd_file 改动;对外部署;契约 v1.1 已冻结(`wxmp_backend/docs/wordcloud-contract.md` |
| **运维** | 配置 `COS_*``WORDCLOUD_API_URL`;微信后台加域名;跑一次迁移 `prisma migrate deploy` |
| **前端(R4 自身)** | 词云页/线稿页联调需后端接口真实可用;下单后派单状态展示挂在 R3 的订单详情页 |
## 9. 分支与提交
| 仓库 | 分支 | 提交 |
|---|---|---|
| wechat_wc | `feat/r4-upload-wordcloud` | docs(契约/路线/CLAUDE.md) → 前端类型/api/词云页/线稿 → 贴纸持久化+dispatch |
| wxmp_backend | `feat/r4-upload-wordcloud` | docs(契约) → config → WordCloudJob+适配器 → COS → WCD 派单 → sketch/Upload/同步 → jobs/:id/result |
| wordcloud | `feat/r4-wcd-job` | 工作区改动(未提交,无远程) |
远端 `master`/`main` 全程复查:无新提交需合并,分支已随开发保持同步。
## 10. 剩余 / 交接
- wordcloud 侧 `wcd_file` 合入其分支 + 部署锁定版本 → 端到端联调(下单 → WCD 生产 PNG)。
- R3 完成后接入"订单进 PROCESSING 自动派单"与订单详情派单状态展示。
- 生产化:换 Redis/BullMQ 队列做任务同步;加任务超时清理、单账号频率限制。
+26
View File
@@ -0,0 +1,26 @@
# 四条并行开发路线
四份文档分别对应四个开发分支,覆盖前端 `wechat_wc`、后端 `wxmp_backend` 与词云项目
`wordcloud` 的分工、开发内容、设计 DO/DON'T 和验收标准。以本目录文档为准,开发时
不要随意扩大分支边界。
| 文档 | 分支 | 内容 |
|---|---|---|
| [R1 商品目录动态化](route-r1-product-catalog.md) | `feat/r1-catalog` | 商品/分类 API、seed、首页/商品/详情页切换 |
| [R2 地址 + 设计清单](route-r2-address-design.md) | `feat/r2-address-design` | 地址 CRUD、默认地址事务、设计 JSON 与状态映射 |
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
## 合并顺序与依赖
- R1 是交易链路的前置,优先合入。
- R2 依赖已有登录体系,可与 R1 并行。
- R3 依赖 R1 的商品表和 R2 的地址/设计清单接口,最后合入。
- R4 独立并行,但依赖 wordcloud 最小契约冻结。
## 通用红线
- 前后端同名分支成对评审,后端 Swagger 先定契约,前端再实现。
- 每个分支合入主干前必须通过各自仓库的构建验证。
- 页面统一从 `../../utils/store``../../utils/api` 聚合入口导入。
- 不得跨路线修改其他分支的文件所有权。
+93
View File
@@ -0,0 +1,93 @@
# R1 商品目录动态化(feat/r1-catalog
**Goal:** 让首页、商品列表、沉浸式详情、商品详情四个页面改从后端商品/分类 API 取数,
前端不再直接依赖静态 `productConfig.ts` 作为线上数据源。
**Architecture:** 前端新增按域的 `api/product.ts` 与页面级加载状态;后端扩展 Product 表字段、
补齐 products/categories 查询能力、写入 5 个真实品类 seed;前后端通过 `docs/api-contract-v1.md`
冻结响应结构;静态 `productConfig.ts` 降级为离线兜底与图标映射,R1 合并验证前不删除。
**Tech Stack:** Taro 3.6 + React 18 + TypeScriptNestJS 11 + Prisma/PostgreSQL。
## 1. 本分支要开发的内容
### 前端 `wechat_wc`
| 文件 | 职责 |
|---|---|
| `src/types/index.ts``src/types/product.ts` | ProductCategory 与后端字段对齐,新增 `subtitle/tone/story/scene/tags/specs/mask/originalPrice/leadTime/status` |
| `src/utils/api/product.ts` | `fetchProducts(params)``fetchProduct(id)``fetchCategories()`,返回类型显式声明 |
| `src/hooks/useProducts.ts`(新增) | 商品列表/详情加载、loading/error/retry、可选 60s 内存缓存 |
| `src/pages/index/index.tsx` | 品类网格与搜索改走 API;成品轮播仍可静态,但价格/图片建议取商品数据 |
| `src/pages/shop/index.tsx` | 商品网格改走 API,补加载骨架、空态、失败重试 |
| `src/pages/shop/detail/index.tsx` | 按 `id` 拉详情,补 loading/error,保留沉浸式滚动动画 |
| `src/pages/product/index.tsx` | 按 `id` 拉详情,`addDesign` 继续复用 `store/design.ts` |
| `src/utils/productConfig.ts` | 保留为 seed 参考与离线兜底,不参与线上主流程 |
### 后端 `wxmp_backend`
| 模块 | 内容 |
|---|---|
| `prisma/schema.prisma` | Product 增加 `subtitle/leadTime/originalPrice/tone/tags/specs/mask/story/scene/iconImg`,其中 `tone Int[]``specs Json``mask Json``tags String[]` |
| `prisma/seed.ts` | 写入 5 个真实品类(笔记本小/大、杯垫、笔盒、书灯),图片用 OSS URL,mask 与前端现有一致 |
| `products` | `GET /api/products`:分页、`categoryId``keyword``status=ON_SALE``GET /api/products/:id`404 处理 |
| `categories` | `GET /api/categories`:稳定排序返回,暂做扁平结构 |
| `docs/api-contract-v1.md` | 商品/分类接口、分页结构、Decimal 数字格式、错误码 |
### 词云项目 `wordcloud`
不参与本分支。
## 2. 设计注意事项
### DO
- DO 前端只消费后端返回的 `price`,下单链路以后也不允许前端自算金额。
- DO 在 API 层把 Decimal 字符串规范成本地 `number`,页面组件里不做字符串拼接运算。
- DO 所有列表/详情页补齐 loading、空态、失败重试,失败时给出可操作文案。
- DO 图片加载失败使用 `product.iconImg``四角星.svg` 兜底,单张图失败不阻塞页面。
- DO `mask``specs``tone` 等富字段作为后端 JSON 返回,前端类型用判别联合描述。
- DO 后端 seed 和前端 `productConfig.ts` 使用同一套稳定的业务 ID(如 `notebook-small`),方便前后端联调。
- DO 在 R1 分支内同步更新前端 `types` 与 Swagger 文档,一个 PR 成对评审。
- DO 商品列表接口先给分页参数,前端第一版可以每次取全部,但接口结构要能扩展。
- DO 公共接口统一 `auth:false`,不要携带 token。
- DO 保持项目事件规范:所有点击使用 `onTap`,不使用 `onClick`
### DON'T
- DON'T 直接删除 `productConfig.ts`,R1 合并前它仍是离线兜底和图标映射来源。
- DON'T 在页面里直接写 `http.get('/api/products')`,必须收口到 `api/product.ts`
- DON'T 在前端硬编码新的图片 CDN 地址,继续走 `assetUrl``OSS_BASE_URL`
- DON'T 让前端根据 `originalPrice` 自行计算促销逻辑,促销后续由后端字段统一表达。
- DON'T 依赖数据库插入顺序,列表必须有 `sort/createdAt` 稳定排序。
- DON'T 在 seed 中用随机 cuid 造成不同环境商品 ID 漂移。
- DON'T 在 R1 里顺手改主题、TabBar、DIY 等无关文件。
- DON'T 把 `description` 塞进 JSON 大字段混用,普通段落继续用字符串字段。
## 3. 补充内容(用户未列但建议纳入)
### 验收标准
- 前后端本地联调:首页/商品列表/沉浸式详情/商品详情四个页面数据来自 API,静态配置失效时页面有兜底而不会白屏。
- 后端 `npm run build`、前端 `npm run build:weapp` 通过。
- Swagger 中商品列表/详情/分类文档完整;前端类型与 Swagger 字段一一对应。
- seed 后数据库包含 5 个在售品类,图片 URL 可访问。
### 合并与依赖
- 分支名:前端与后端均为 `feat/r1-catalog`
- 合并顺序:R1 是整个交易链路的前提,优先合入;R3 的服务端金额重算依赖本分支的商品表。
- 合入前跑一次后端接口 smoke`GET /api/categories``GET /api/products``GET /api/products/:id`
### 风险
- OSS 图片域名若未配到微信后台 `downloadFile` 合法域名,商品图会在真机白图,联调时先确认。
- `tone``mask` 等富字段若后端 JSON 序列化方式不统一,前端类型会悄悄失效,合入前要跑真实返回样例。
- 首页成品轮播目前混用静态文案与商品数据,R1 建议只把“热门品类”切 API,轮播数据源单独决策。
### 测试要求
- 后端:至少补 products/categories 的 e2e(正常列表、空列表、404、keyword 过滤)。
- 前端:三个页面手测加载态、断网重试、空数据、关键词搜索无结果、图片 404 兜底。
- 不需要在 R1 引入自动化 UI 测试,保持手测清单即可。
+124
View File
@@ -0,0 +1,124 @@
# R2 收货地址 + 设计清单(feat/r2-address-design
**Goal:** 把收货地址和设计清单从本地 Storage 切换到后端 CRUD,完成字段契约、接口权限、
默认地址事务、设计数据 JSON 与状态映射,让 R3 的订单闭环可以直接消费这两组能力。
**Architecture:** 前端 `api/address.ts``api/design.ts` 负责 HTTP 契约,本地
`store/address.ts``store/design.ts` 在联调期保留为兜底;后端补齐 addresses 与 design-list
的完整 CRUD、事务与本人数据校验;`checkout` 页归 R3 所有,R2 只定义接口不修改该页面。
**Tech Stack:** Taro 3.6 + React 18 + TypeScriptNestJS 11 + Prisma/PostgreSQL。
## 1. 本分支要开发的内容
### 前端 `wechat_wc`
| 文件 | 职责 |
|---|---|
| `src/utils/api/address.ts` | `fetchAddresses/createAddress/updateAddress/deleteAddress/setDefaultAddress`,负责 `region[]``province/city/district` 双向转换 |
| `src/utils/api/design.ts` | `fetchDesignList/createDesign/updateDesign/deleteDesigns` |
| `src/pages/address/index.tsx` | 列表、新增、编辑、删除、设为默认全部走 API;保留表单校验与区域 Picker |
| `src/pages/designList/index.tsx` | 列表、状态筛选、批量删除走 API;保留 LoginGuard |
| `src/types/index.ts` | AddressItem/DesignItem 与后端结构对齐,新增服务端 id、状态码、时间字段 |
| `src/utils/store/address.ts``design.ts` | 降级为“本地缓存/离线兜底”,API 调用成功后同步刷新 |
### 后端 `wxmp_backend`
| 模块 | 内容 |
|---|---|
| `addresses` | `GET/POST /api/addresses``PATCH /api/addresses/:id``PATCH /api/addresses/:id/default``DELETE /api/addresses/:id`;全部带 userId 归属校验 |
| `addresses` 事务 | 设置默认时事务内先清旧默认再设新默认;删除默认地址后自动指定最新一条为默认 |
| `design-list` | `GET/POST /api/design-list``PATCH /api/design-list/:id``DELETE /api/design-list/:id`、批量删除 |
| `design-list` 校验 | `items` JSON 结构与大小校验、状态机转换、本人专属查询 |
| `docs/api-contract-v1.md` | 地址字段映射、设计清单状态枚举、错误码 |
### 词云项目 `wordcloud`
不参与本分支,但 `designData` 中的本地贴纸图片路径问题会影响后续 R4,见风险一节。
## 2. 状态映射(必须先定死)
后端 `DesignListStatus` 目前是 `DRAFT/SUBMITTED/PROCESSING/DONE`,前端是
`undesigned/designing/ordered`。R2 不要在两边各造一套值,建议按显示语义映射:
| 前端显示 | 后端状态 | 前端状态码 |
|---|---|---|
| 待设计 | DRAFT | `undesigned` |
| 设计中 | SUBMITTED | `designing` |
| 生产中 | PROCESSING | `processing` |
| 已下单 | DONE | `ordered` |
“已下单”不要用独立状态表达,改为 `orderId != null` 派生;前端只显示,不提交这个状态。
状态码收口到 `docs/api-contract-v1.md`,后端校验状态迁移,前端不传自由字符串。
## 3. 字段契约
### 地址
后端 `province/city/district/detail` 对应前端 `region: [province, city, district] + detail`
转换只放在 `api/address.ts`,页面和 store 不得散落第二次转换。
### 设计数据
`DesignList.items` 是服务端 JSON,前端提交结构建议与当前 `DesignItem.designData` 对齐:
```ts
interface DesignData {
stickers?: StickerItem[]
category?: ProductCategory
imageSrc?: string
imagePos?: { x: number; y: number; scale: number }
}
```
服务端只做白名单字段校验,不修改业务 JSON 内容;单条设计数据建议限制在 1MB 以内。
## 4. 设计注意事项
### DO
- DO 地址的增删改查和默认切换全部要求登录态,401 时引导去个人中心登录。
- DO 所有按 id 操作的服务端路由同时带 `userId` 条件,404 与 403 区分清楚。
- DO 默认地址切换与删除补偿放在同一个 Prisma transaction 里。
- DO 前端继续沿用户已熟悉的表单校验,但最终校验提示以后端返回为准。
- DO 设计清单状态通过后端枚举返回,前端状态筛选基于返回的 `status` 计算。
- DO 批量删除设计条目提供单个接口或循环调用时保证部分失败可恢复;推荐后端一次批量删除。
- DO 本地 store 只在 API 失败时做降级读取,成功写回后以服务端数据为准。
### DON'T
- DON'T 把前端 `region` 数组原样 POST 给后端,后端契约是四个独立地址字段。
- DON'T 让前端直接写 `isDefault` 的两条规则,默认地址唯一性由后端事务保证。
- DON'T 在路由/service 层漏掉归属校验;这是本分支最容易出的越权洞。
- DON'T 新增前端自造状态 `ordered` 之外的字符串,状态值必须在契约文档里可枚举。
- DON'T 在本分支修改 `checkout/index.tsx`,地址选择器与下单集成留给 R3。
- DON'T 把 `wxfile://` 临时贴纸路径当作可持久化 URL 直接入库,R4 之前它只是本地预览。
- DON'T 用设计清单字段承载订单状态,`orderId` 派生展示即可。
## 5. 补充内容
### 验收标准
- 地址页与设计清单页在登录态下增删改查、默认切换、批量删除全部走真实后端并刷新。
- 后端所有变更接口带本人校验,越权访问返回 403,不存在资源返回 404。
- 默认地址永远唯一;删除默认地址后列表自动产生新的默认地址。
- 前端构建与后端构建通过;Swagger 涵盖地址与设计清单全部接口。
- 状态映射表在 `docs/api-contract-v1.md` 中落地,前端页面不再出现魔法字符串状态。
### 合并与依赖
- 分支名:前端与后端均为 `feat/r2-address-design`
- R2 依赖已有登录体系(已完成),不依赖 R1;可与 R1 并行开发。
- checkout 对地址 API 的调用在 R3 接入,R2 只需保证接口稳定即可。
### 风险
- 设计数据中的贴纸是本地临时路径,一旦真机重启或清理缓存,旧设计预览会裂图;在 R4 上传能力完成前,文档明确这是已知限制。
- 旧版本本地 `smart_design_list_<openid>` 数据与后端数据可能并存,首次切换登录时避免双写冲突;可做一次性“导入本地清单”或直接忽略旧数据。
- 批量删除接口若后端未实现,前端循环删除会遇到部分失败,联调时先约定失败语义。
### 测试要求
- 后端:地址 CRUD、默认地址唯一性事务、越权访问、设计清单状态迁移、超大 JSON 拒绝。
- 前端:地址新增/编辑/删除/默认切换、设计清单筛选/批量删除、断网降级提示、401 引导登录。
+132
View File
@@ -0,0 +1,132 @@
# R3 订单闭环 + 支付接口占位(feat/r3-order-pay
**Goal:** 打通“设计清单 → 结算确认 → 创建订单 → 订单列表/详情 → 状态流转”的服务端闭环,
微信支付只留接口与配置位,不填真实密钥;支付不可用时流程能明确回到“待付款/支付未配置”。
**Architecture:** 前端 `api/order.ts` 承接下单与查询,`checkout/orders/orderDetail` 三个页面
改为服务端数据源;后端订单创建在事务内重算金额、快照地址、生成幂等的订单号并创建 PENDING
支付记录;`payments` 在密钥未配置时返回显式占位响应,不做假成功。
**Tech Stack:** Taro 3.6 + React 18 + TypeScriptNestJS 11 + Prisma/PostgreSQL + Redis/BullMQ(可选)。
## 1. 本分支要开发的内容
### 前端 `wechat_wc`
| 文件 | 职责 |
|---|---|
| `src/utils/api/order.ts` | `createOrder/fetchOrders/fetchOrderDetail/payOrder`;请求只带商品项、地址 id、designListId,不带金额 |
| `src/types/index.ts` | 服务端 Order 类型:`orderNo/status/totalAmount/items/addressSnapshot/createdAt/paidAt` |
| `src/pages/checkout/index.tsx` | 从服务端读设计清单,选择地址(用 R2 的地址接口),提交下单;展示服务端总价;支付未配置时提示后回订单列表 |
| `src/pages/orders/index.tsx` | 订单列表按状态 Tab 拉取;付款/确认收货等按钮调用对应接口或显示未配置 |
| `src/pages/orderDetail/index.tsx` | 按订单 id 拉详情;地址快照、商品、状态条、物流占位 |
| `src/utils/store/order.ts` | 标记 deprecated,仅保留为本地兜底,不再被 checkout 主流程调用 |
| `src/pages/profile/index.tsx` | 状态统计改为基于服务端订单列表(R2/R3 合入后统一收尾) |
### 后端 `wxmp_backend`
| 模块 | 内容 |
|---|---|
| `orders` | `POST /api/orders`:服务端按 Product/design-list 重算金额、事务创建订单+items+Payment、生成唯一 orderNo、快照地址 |
| `orders` 查询 | `GET /api/orders`(状态筛选/分页)、`GET /api/orders/:id`(仅本人)、`PATCH /api/orders/:id/confirm``POST /api/orders/:id/cancel`(按需) |
| `payments` | `POST /api/payments/:orderId/pay``POST /api/payments/notify` 保留签名;密钥为空时返回 `{ configured:false, message:'支付未配置' }` |
| `wechat` | `createUnifiedOrder/verifyPayNotify` 保持占位,不填入假商户参数 |
| `queue` | 可选:订单创建后入队定制任务,处理器先只更新 `CustomizationTask` 状态 |
| `docs/api-contract-v1.md` | 订单创建请求/响应、状态机、金额字段精度、错误码、幂等键 |
### 词云项目 `wordcloud`
不参与本分支。
## 2. 订单状态机
服务端 `OrderStatus` 与前端展示映射:
| 后端 | 前端 Tab | 说明 |
|---|---|---|
| PENDING | 待付款 | 允许取消;支付未配置时长期停留 |
| PAID | 待发货 | 由支付回调推进,占位期不出现 |
| PROCESSING | 待发货 | 定制生产中 |
| SHIPPED | 待收货 | 需要发货/物流数据,本分支可展示占位 |
| COMPLETED | 已完成 | 用户确认收货推进 |
| CANCELLED | 已取消 | 待付款状态用户取消 |
前端只读状态数组,不做状态转移判断;转移一律走后端接口或支付回调。
## 3. 接口设计要点
创建订单请求:
```ts
{
designListId: string,
addressId: string,
items: [{ productId, quantity }]
}
```
服务端响应:
```ts
{
id: string,
orderNo: string,
status: "PENDING",
totalAmount: number,
items: [],
addressSnapshot: {},
createdAt: string
}
```
## 4. 设计注意事项
### DO
- DO 订单金额、单价、总价全部由服务端计算,前端提交只给 `productId + quantity`
- DO 创建订单使用 `prisma.$transaction`,订单、明细、支付记录任一步失败整体回滚。
- DO 给创建订单接口支持幂等键(客户端 `requestId``designListId` 防重复下单),双击提交只产生一单。
- DO 订单号 `orderNo` 唯一,生成规则包含日期与随机位并在冲突时重试。
- DO 收货地址在创建订单时直接快照到 `addressSnapshot`,订单创建后不再跟随地址变更。
- DO 支付占位返回结构化 `configured:false`,前端据此显示“暂不支持支付”而不是错误弹窗。
- DO 后端从配置读取微信支付密钥,密钥缺失时支付接口直接返回占位,不使用默认值或硬编码。
- DO 订单列表/详情都校验当前用户 openid,越权访问返回 403。
- DO 金额相关字段用 Decimal 精确类型持久化,序列化时统一成字符串或 number,避免浮点误差。
### DON'T
- DON'T 信任前端传的 `totalAmount`/`price`,即使前端为了展示计算过也一律忽略。
- DON'T 让客户端参数直接决定 `status`,状态只能由服务端接口和支付回调推进。
- DON'T 在代码里填占位商户号、密钥、证书路径;密钥缺失是合法运行状态而非 bug。
- DON'T 模拟支付成功,包括开发环境;未配置就是未配置,避免上线前“假流程”掩盖问题。
- DON'T 支付回调先解锁订单再验签;本分支只留入口,不实现任何未验签的落库逻辑。
- DON'T 在本分支删除 `store/order.ts`,把它标记 deprecated 即可,本地兜底退出要留到全链路验证后。
- DON'T 修改 R2 的地址/设计清单接口签名,R3 只消费。
## 5. 补充内容
### 验收标准
- 指定一个已登录用户,从设计清单进入结算页,选地址后创建订单;服务端返回的 `totalAmount` 与商品单价×数量一致。
- 双击提交只生成一单,订单号唯一。
- 订单列表/详情展示服务端数据,越权用户拿不到他人订单。
- 支付按钮在密钥未配置时返回 `configured:false` 并给出明确文案,不调用微信、不写成功记录。
- 后端 `npm run build`、前端 `npm run build:weapp` 通过。
### 合并与依赖
- 分支名:前端与后端均为 `feat/r3-order-pay`
- 依赖 R1 的商品表和 R2 的地址/设计清单接口,合并顺序放在 R1、R2 之后。
- 支付实现作为一个独立后续里程碑,不阻塞本分支交付。
### 风险
- 微信小程序对订单支付有场景与类目要求,目前未备案时不要尝试真支付,容易触发审核风险。
- 物流信息当前为假数据,SHIPPED/COMPLETED 的物流时间轴要标注占位。
- 若 R2 未按约定提供地址接口,checkout 集成会被卡住;R3 开发时先按 `api-contract-v1.md` 编写,联调阶段再对齐实现。
### 测试要求
- 后端:订单创建事务、金额重算、重复提交幂等、越权查询、状态流转、支付未配置占位响应。
- 前端:结算页创建订单、双击防抖、订单空态、状态 Tab 筛选、越权场景提示、支付未配置提示。
+226
View File
@@ -0,0 +1,226 @@
# R4 上传 + 词云生成 + 线稿 + 下单后 WCD 生产任务(feat/r4-upload-wordcloud
**Goal:** 打通用户图片上传、词云任务生成、DIY 贴纸线稿处理、异步任务状态轮询,
并在**下单后把订单对应的设计以 WCD 格式投递到词云平台**形成生产任务;
这是四条路线中唯一复用 `wordcloud` 项目的路线,必须通过冻结接口契约的方式隔离它的大量变更。
**Architecture:** 小程序只与 `wxmp_backend` 通信;wxmp_backend 实现 COS 上传凭证、词云适配器、
sketch 接口、任务记录/轮询,以及**由设计数据构造 `.wcd` 包并投递给词云平台**的派单服务;
`wordcloud` FastAPI 服务作为外部引擎,只暴露并冻结最小契约。
小程序页面对词云使用轮询进度,不使用 SSE/EventSource。
**Tech Stack:** Taro 3.6 + React 18 + TypeScriptNestJS 11 + Prisma/PostgreSQL + Redis/BullMQ
腾讯云 COSwordcloud FastAPI + EfficientWordCloud。
## 1. 本分支要开发的内容
### 前端 `wechat_wc`
| 文件 | 职责 |
|---|---|
| `src/utils/api/upload.ts` | `getUploadCredentials/uploadToCos/sketchImage/createWordCloudJob/getWordCloudJob/getWordCloudResult` |
| `src/pages/wordcloud/index.tsx` | 真实上传底图、提交名字清单、创建任务、轮询进度、展示结果图并保存相册 |
| `src/pages/diy/stickerEdit/index.tsx` | 把占位域名 `https://your-api-domain.com/api/sketch` 换成 `BASE_URL/api/sketch`;保留前端灰度降级 |
| `src/types/index.ts``src/types/upload.ts` | `WordCloudJob/WordCloudStatus/SketchResult/CosCredentials` |
| `src/utils/request.ts` 或新增 `upload.ts` | 上传文件统一带 token 与业务 header |
| `src/types/index.ts`designData | 按 `docs/design-data-contract-v1.md` 冻结结构补充字段(详见下文 R2 依赖) |
> 下单后的 WCD 投递完全发生在后端,**前端不新增页面与接口**;前端只需保证
> `designData` 按冻结契约携带持久图片 URL、布局与名单快照。
### 后端 `wxmp_backend`
| 模块 | 内容 |
|---|---|
| `upload` | `GET /api/upload/credentials?key=...` 返回 STS 临时凭证或 COS 预签名 URL;`Upload` 表记录 |
| `wordcloud`(新增) | `POST /api/wordcloud/generate``GET /api/wordcloud/jobs/:id``GET /api/wordcloud/jobs/:id/result` |
| `wordcloud` 适配器 | 把小程序传入的底图+名字列表转成 wordcloud 契约请求;任务状态入库,用户归属校验 |
| **`wordcloud` 派单服务(新增)** | `buildWcdPackage(designData)` 构造 `.wcd``dispatchToWordcloud(orderId)` 投递 → 落 `CustomizationTask` → 轮询产物并转存 COS(见 §3) |
| `sketch`(新增) | `POST /api/sketch` 接收图片,返回处理后图片 URL;内部可先做基础处理或接外部 AI |
| `queue` | BullMQ 消费者把 wordcloud/sketch/生产派单任务状态同步到 `CustomizationTask`/`WordCloudJob` |
| `docs/wordcloud-contract.md` | 冻结 wordcloud 接口,含**可选的 WCD 任务输入**,含版本号 |
| `config` | `.env` 增加 `WORDCLOUD_API_URL`(词云平台地址),见 §3.4 |
| `prisma` | `CustomizationTask` 增加 `orderId`/`wordcloudJobId`migration |
### 词云项目 `wordcloud`
- 需要冻结的接口:`POST /api/jobs`(现有:`name_list(.xlsx)` 必填;**新增可选 `wcd_file`**)、
`GET /api/jobs/{id}``GET /api/jobs/{id}/result``GET /api/jobs/{id}/files/{kind}`
- 其余 canvas、assets、projects、templates 等接口继续按它自己的节奏演进,小程序后端不依赖。
- 若契约不变,wordcloud 内部重构无需通知 R4;契约变更时先升版本号,R4 单独出适配器更新。
## 2. 词云任务模型
小程序侧采用异步任务模型:
```ts
interface WordCloudJob {
id: string
status: 'queued' | 'running' | 'success' | 'failed'
progress: number
imageUrl?: string
error?: string
}
```
流程:
1. 前端上传底图,拿到 COS URL 或临时文件。
2. `POST /api/wordcloud/generate` 提交底图 + 名字文本,后端创建任务并返回 `jobId`
3. 前端每 1-2 秒 `GET /api/wordcloud/jobs/:id` 轮询,`success` 后取 `imageUrl`
4. 结果图建议由 wxmp_backend 转存 COS 后返回微信可下载域名链接。
## 3. 下单后生产任务:WCD 投递(本分支新增功能)
> 场景:用户在 DIY 过程中生成的词云图、贴纸布局最终落在一条设计清单(`design-list`)里。
> 下单之后,订单对应的整套设计必须能**原样恢复到词云平台**,形成可追溯、可复用的
> **生产任务**(用于后续加工/激光雕刻)。传输格式采用词云平台已定义的 `.wcd`
> 画布导入导出包(Zip`manifest.json` + `document.json` + `assets/`)。
### 3.1 端到端链路
```
设计清单 designDataR2 按 design-data-contract-v1.md 冻结结构保存)
→ R3 POST /api/orders 创建订单
→ 订单进入生产(PENDING → PROCESSING;支付未配置期可走幂等触发,见 3.3)
→ queue 入队 customization 生产任务
→ WordCloudService.dispatchToWordcloud(orderId)
1. 读订单关联 design-list 的 designData
2. 贴纸图片持久化(R4 职责,契约束 #1):本地 `wxfile://`/`tmp` 图 → 上传 COS → 回写 `stickers[].src` 为持久 URL
3. buildWcdPackage(designData) → 内存构造 .wcd(布局 → document.json,图片字节 → assets/
4. POST {WORDCLOUD_API_URL}/api/jobs multipart wcd_file + params
5. 落 CustomizationTask{ orderId, wordcloudJobId, status },开轮询
6. 轮询 GET /api/jobs/{id}/result → 产物(PNG/SVG)转存 COS
→ 更新 CustomizationTask.status / resultUrl
```
小程序前端永不接触 wordcloudWCD 完全由 wxmp_backend 构造与投递。
### 3.2 WCD 包结构(wxmp_backend 构造,wordcloud 侧已能消费)
后端按 `designData` 构造以下 Zip
```
{orderNo}.wcd
├── manifest.json // format: "wordcloud-canvas", version: 1
│ // + canvas{width,height,background} + assets[ id/name/type/mimeType/sha256/size ]
├── document.json // CanvasDocumentwidth/height/background/layers/elements
└── assets/<assetId>.<ext> // 每个 sticker/底图的图片字节(从持久 URL 下载)
```
映射规则(以词云平台 `/api/design-templates/import` 现有实现为准):
- `document.canvas` ← 商品 `category.mask` 尺寸 + 背景色。
- `elements[]` ← 底图(`background.src`)作为一个 element;每个贴纸
`{ type:'sticker', assetId: asset-N, x, y, width, height, rotation?, opacity? }`
`assetId` 用包内临时 ID。
- `assets/` ← 每个贴纸图片 + 底图的字节,文件名以包内 `assetId` 开头
wordcloud 解包根据 `assets/<assetId>*` 匹配)。
- 名单快照(`designData.wordcloud.names`)写入 `manifest` 的自定义 `meta` 字段,
仅作记录,不影响导入还原。
### 3.3 触发与幂等
- 推荐:订单进入 `PROCESSING` 时(支付回调确认后)入队 `customization` 任务,
由处理器调用派单服务。
- 支付未配置期:提供 `POST /api/orders/:id/dispatch`(幂等)作为联调/运营触发手段,
同一订单只投递一次(以 `CustomizationTask.orderId` 唯一或状态机约束)。
### 3.4 环境变量(wxmp_backend
```dotenv
# ── 词云平台(WCD 生产任务)────────────────────────────
# 词云服务(FastAPI)地址;未配置时下单后的 WCD 派单返回“未配置”,不做假成功
WORDCLOUD_API_URL=
# 请求 wordcloud 超时(毫秒);可选
WORDCLOUD_TIMEOUT_MS=30000
```
`config/configuration.ts` 增加 `wordcloud: { apiUrl, timeoutMs }`
`config/validation.schema.ts` 增加 `WORDCLOUD_API_URL: Joi.string().allow('').default('')`
(允许为空,与"支付密钥未配置是合法状态"一致)。
### 3.5 词云平台需新增的能力
- `POST /api/jobs` 支持可选 `wcd_file`multipart `.wcd`):存在时跳过 `.xlsx` 名单
模式,进入"还原设计 → 生成生产任务"模式;状态机与产物管线复用现有 jobs。
- wordcloud 平台改动点(供其 R4 实现参考,契约先行冻结):
1. `name_list``wcd_file` 二选一(`wcd_file` 对已有调用向后兼容,纯新增)。
2. 解包 `.wcd` 校验 `format/version``assets` 按 SHA-256 去重(复用现有导入逻辑)。
3. `document.json` 归一化后落 `design_documents``sticker.assetId` 重映射为真实素材 ID。
4. 创建生产 job,产物沿用 `GET /api/jobs/{id}/result` + `/files/{kind}`
### 3.6 设计注意事项
#### DO
- DO 由 wxmp_backend 构造并投递 WCDwordcloud 地址、密钥、内部参数对小程序完全不可见。
- DO `wordcloudJobId → orderId/userId` 关联入库,轮询与结果查询带归属校验。
- DO `WORDCLOUD_API_URL` 未配置时返回结构化“未配置”错误(状态字面量 `not_configured`
`api-contract-v1.md` §8),前端给出可理解提示。
- DO 派单幂等:同一订单重复触发只产生一次投递。
- DO 产物由 wxmp_backend 下载转存 COS 后返回公网 URL,不向小程序暴露 wordcloud 内部地址。
- DO 名单/布局等设计快照写入 `designData`(冻结契约),保证下单后可重建 WCD。
- DO WCD 内部的贴纸图片必须来自持久 URL(COS),打包前先下载字节。
- DO 包大小与单账号投递频率限制(wordcloud 为 CPU 密集任务)。
#### DON'T
- DON'T 在小程序前端硬编码 wordcloud 基础地址或直接调用它。
- DON'T 让用户通过猜 `jobId` 读取他人任务结果,所有查询都带 userId 条件。
- DON'T 在密钥/`WORDCLOUD_API_URL` 未配置时静默跳过或假装成功。
- DON'T 用假的 `setInterval` 进度伪装下单后的任务结果;同样不模拟生产派单成功。
- DON'T 把 `.wcd` 文件当最终产物直接返回小程序,它只是 wordcloud 的交换容器。
- DON'T 接受本地临时路径(`wxfile://`/`tmp`)作为 `designData` 中的贴纸图,R4 打包拿不到字节。
### 3.7 验收标准
- 订单进入生产后,wordcloud 平台出现对应 job`queued → running → success`
产物 PNG 可下载并被 wxmp_backend 转存 COS。
- 同一订单重复触发只投递一次。
- `WORDCLOUD_API_URL` 未配置(留空)时,派单返回结构化“未配置”,不落假成功记录。
-`designData` 构造的 `.wcd` 能在 wordcloud `/api/design-templates/import`
同套逻辑下原样还原(素材去重、布局一致)。
- 后端 `npm run build`、前端 `npm run build:weapp` 通过;Swagger 与契约文档一致。
### 3.8 风险
- wordcloud 侧 `POST /api/jobs` 现强制 `name_list`;支持 `wcd_file` 二选一需要
wordcloud 排期改动。契约先冻结,wordcloud 实现可后置,wxmp_backend 按契约先写适配层。
- `designData` 中贴纸图片若仍为本地/临时 URL,WCD 打包会失败——**依赖 R2 冻结持久 URL 规则**。
- 支付未接入期没有自然触发点,需要一个幂等 `dispatch` 接口用于联调。
- `CustomizationTask``orderId`/`wordcloudJobId` 字段,需一次 migration。
- 贴纸 `edits`(亮度/色相/对比度/线稿)暂无法由 WCD `document.json` 表达,本期先不随包携带,
`manifest.meta` 记录并在契约中标注边界。
## 4. 对 R2 的依赖(接口先冻结)
R4 的 WCD 打包依赖 R2 设计清单里保存的 `designData` 结构。**接口需在本分支开工前冻结**,
相关契约见新文档 `docs/design-data-contract-v1.md`(跨路线共享,R2 为数据生产者,R4 为消费者)。
需 R2 配合冻结的要点:
| 项 | 要求 |
|---|---|
| `designData` 结构 | 按 `docs/design-data-contract-v1.md``version/background/wordcloud` 字段;`category.mask` 必须保存 |
| 贴纸图片持久化 | **R4 负责**:下单/派单前把贴纸本地路径上传为 COS 持久 URL 并回写 `src`R2 允许保存时 `src` 暂为本地路径 |
| 名单快照 | `designData.wordcloud.names` **为 optional,小程序可不带**;R4 生成词云后按实际写入,R2 保存/更新清单时保留该组字段,后端白名单放行 |
| 布局完整性 | 贴纸 `rotation`/`zIndex` 本期持久化(现有 `StickerItem` 缺 rotation,需补) |
| `design-list` 白名单 | 后端 items JSON 校验放行上述新字段,单条 ≤1MB 上限按新结构复核 |
决策记录与细则:见 `docs/design-data-contract-v1.md` §6。
## 5. 补充内容
### 合并与依赖
- 分支名:前端与后端均为 `feat/r4-upload-wordcloud`
- 与 R1-R3 并行,不依赖商品/订单链路;后端上传基础设施可为后续 R2 的贴纸图片持久化提供能力。
- 下单后 WCD 派单依赖 R2 冻结的 designData 结构与 R3 的订单状态机。
- wordcloud 契约若未冻结,R4 先完成“契约文档 + 后端适配层”,联调阶段再补真实 job。
### 测试要求
- 后端:上传凭证、大小/类型校验、wordcloud 适配器正常/失败/超时、**WCD 打包与派单、任务归属权限**、sketch 接口。
- 前端:词云上传、轮询进度、fail 重试、结果保存相册;线稿接口失败降级;**下单后派单状态展示**;无 COS/无 WORDCLOUD 配置提示。
- 集成:用固定 wordcloud 部署版本跑一次真实生成端到端 smoke + 一次下单派单 smoke。
+190
View File
@@ -0,0 +1,190 @@
# 智绘微刻小程序团队协作总文档
本文档是四人团队的协作单一入口,定义仓库、分工、Git 规范、契约管理和推荐开发节奏。
四个路线的具体开发内容见 `docs/routes/`,后端接口契约见
`wxmp_backend/docs/api-contract-v1.md``wxmp_backend/docs/wordcloud-contract.md`
## 1. 仓库与主干
| 仓库 | 角色 | 主干分支 | 说明 |
|---|---|---|---|
| `wechat_wc` | 小程序前端(Taro | `master` | 已连接 Gitee,页面与前端协作文档都在这里 |
| `wxmp_backend` | 小程序后端(NestJS | `main` | 已连接 GitHub,接口契约与迁移在这边 |
| `wordcloud` | 词云生成外部服务(FastAPI) | 独立演进 | 只通过冻结契约复用,不参与前后端分支同步 |
> 注意:早期文档里写“合并到 main”时,一律按本表执行:前端合 `master`,后端合 `main`。
## 2. 分工模型
团队由 4 人组成,每人独立拥有一条端到端路线,前端和后端改动都归本人:
| 成员 | 路线 | 分支 | 仓库 |
|---|---|---|---|
| 成员 1 | R1 商品目录 | `feat/r1-catalog` | `wechat_wc` + `wxmp_backend` |
| 成员 2 | R2 地址 + 设计清单 | `feat/r2-address-design` | `wechat_wc` + `wxmp_backend` |
| 成员 3 | R3 订单 + 支付占位 | `feat/r3-order-pay` | `wechat_wc` + `wxmp_backend` |
| 成员 4 | R4 上传 + 词云 + 线稿 | `feat/r4-upload-wordcloud` | `wechat_wc` + `wxmp_backend` |
四人互不等待:各自在两条仓库做自己的切片,不拆前端/后端小组,也不跨路线改别人的文件。
路线之间只通过契约、主干和合并顺序衔接。
### 2.1 四条路线
| 路线 | 分支 | 目标 | wordcloud |
|---|---|---|---|
| R1 商品目录 | `feat/r1-catalog` | 商品/分类 API + 前端四个页面切 API | 不用 |
| R2 地址 + 设计清单 | `feat/r2-address-design` | 地址 CRUD、清单 CRUD、状态映射 | 不用 |
| R3 订单 + 支付占位 | `feat/r3-order-pay` | 服务端下单、订单状态机、支付未配置占位 | 不用 |
| R4 上传 + 词云 + 线稿 | `feat/r4-upload-wordcloud` | COS 上传、词云任务、sketch、队列 | 唯一使用 |
### 2.2 文件所有权
前端公共文件只由 R0 维护:
| 文件 | 归属 |
|---|---|
| `src/utils/store/index.ts``src/utils/api/index.ts` | R0 聚合入口,只加 re-export,不堆业务 |
| `src/utils/store/design.ts``address.ts` | R2 |
| `src/utils/store/order.ts` | R3 |
| `src/utils/api/product.ts` | R1 |
| `src/utils/api/address.ts``design.ts` | R2 |
| `src/utils/api/order.ts` | R3 |
| `src/utils/api/upload.ts` | R4 |
页面所有权:
| 页面 | 归属 |
|---|---|
| `index``shop``shop/detail``product` | R1 |
| `address``designList` | R2 |
| `checkout``orders``orderDetail` | R3 |
| `wordcloud``diy/stickerEdit` | R4 |
| `profile` | 只读统计消费者,R2/R3 合入后统一收尾 |
后端按 NestJS 模块自然划分,不跨路线修改其他模块。
## 3. Git 规范
### 3.1 分支
- 每条路线在前端与后端仓库使用同名分支:`feat/r1-catalog``feat/r2-address-design``feat/r3-order-pay``feat/r4-upload-wordcloud`
- 主干只允许合入,不允许直接开发;面向发布的小改动可以走 `fix/...` 短分支。
- 分支从各自仓库主干创建:前端 `git checkout master`,后端 `git checkout main`
```bash
# 前端示例
git checkout master && git pull
git checkout -b feat/r1-catalog
# 后端示例
git checkout main && git pull
git checkout -b feat/r1-catalog
```
### 3.2 提交
- 使用 Conventional Commits`feat(scope): message``fix(scope): message``docs(scope): message``refactor(scope): message`
- scope 建议:`catalog``address``design``order``pay``upload``wordcloud``store``api``contract`
- 一个提交只做一件事;文件级原子提交,不把无关改动混进同一个 commit。
- 不提交 `.env`、密钥、证书、临时日志;已跟踪的 `dist/` 产物按各自仓库现有约定处理。
- 遇到他人未提交的工作区改动,不重置、不覆盖,需要时先沟通。
### 3.3 评审与合并
- 禁止直接推主干,统一走 Gitee/GitHub Pull Request。
- 同一路线的前端 PR 与后端 PR 由同一成员维护,其他成员评审;后端先定契约,前端按契约实现。
- 合并前先 rebase 主干,保持提交历史线性:
```bash
git fetch origin
git rebase origin/master # 前端
git rebase origin/main # 后端
```
- 合入使用 `--no-ff`,保留路线合并痕迹。
- 合入顺序:R1 最先(R3 依赖商品表),R2/R4 随后,R3 最后。
- 合入动作由路线负责人执行,其余成员只看评审,不代推主干。
### 3.4 冲突处理
- R0 聚合入口冲突由维护者协调,其他人只提交自己域内文件。
- 共享页面(如 `checkout`)按所有权表归属,冲突时由该页负责人解决。
- 出现跨路线耦合需求时,先在契约文档升版本,再开新任务,不临时改别人文件。
## 4. 契约管理
| 文档 | 位置 | 作用 |
|---|---|---|
| API 契约 v1 | `wxmp_backend/docs/api-contract-v1.md` | 前后端接口的唯一依据 |
| wordcloud 契约 v1 | `wxmp_backend/docs/wordcloud-contract.md` | 隔离 wordcloud 大量变更 |
| 路线文档 | `wechat_wc/docs/routes/` | 每条路线要开发什么、DO/DON'T、验收 |
规则:
- 接口先冻结,前端再开发;同一版本内禁止改字段名/类型/语义。
- 字段只能新增 optional 字段,否则升版本并同步更新前端 `types`
- wordcloud 契约变更由 R4 单独升级适配器,不阻塞其他路线。
## 5. 推荐开发节奏
### 5.1 单个路线的生命周期
1. 契约对齐:后端写 DTO/Swagger + 更新契约文档,前端同步类型。
2. 独立实现:本人同时处理前端页面/数据层与后端 service/事务/权限。
3. 本地联调:用 Swagger + 微信开发者工具走通主流程。
4. 验收:构建、接口 smoke、手测清单逐项打勾。
5. 合并:rebase 主干,前端与后端 PR 一起提交,通知其他成员 `git pull`
### 5.2 建议的两周迭代
| 阶段 | 建议安排 |
|---|---|
| 第 1-3 天 | R0 收尾;R1-R4 四轨独立开工;R3/R4 先冻结各自契约 |
| 第 4-8 天 | R1 合入;R2 持续开发;R3 基于 R1 契约开工;R4 开发上传与适配层 |
| 第 9-12 天 | R2 合入;R3 联调;R4 与 wordcloud 联调 |
| 第 13-14 天 | R3/R4 验收与合入;整理遗留问题和下一迭代计划 |
路线的粗略工时(一人独立完成):
| 路线 | 预计 |
|---|---|
| R1 | 3-5 天 |
| R2 | 3-4 天 |
| R3 | 5-7 天 |
| R4 | 5-7 天(含外部联调) |
### 5.3 每日节奏
- 早晨 15 分钟同步:昨天的进度、blocker、今天的契约/联调事项。
- 每人每天开工前 `git pull` + rebase 主干;长期分支不隔夜不管。
- 小步提交,一天至少推到远端一次,避免本地大爆炸式改动。
- PR 当天评审,评审人 24h 内反馈;遇到阻塞先降级方案再继续。
## 6. 验收红线(Definition of Done
每路线合入前必须满足:
- 前端 `npm run build:weapp` 通过;后端 `npm run build` 通过。
- Swagger 与 `api-contract-v1.md` 一致,前端类型与契约一致。
- 涉及金额:金额只能服务端重算,客户端金额永不生效。
- 涉及私有数据:所有按 id 查询都校验本人,越权返回 403。
- 涉及支付:密钥未配置时返回结构化“未配置”,不做假成功。
- 涉及词云:小程序不直连 wordcloud,任务结果有用户归属。
- 路线文档中的 DO/DON'T 与验收清单逐项确认。
## 7. 高风险警示
- 不要在仓库里提交微信支付密钥、COS 密钥、JWT_SECRET。
- 不要把 `wxfile://` 临时路径当作持久化图片地址入库。
- 不要模拟词云进度或模拟支付成功。
- 不要随意扩大路线边界;超出范围的需求先更新契约或新建任务。
## 8. 文档索引
- [并行开发指南](parallel-development-guide.md)
- [R1 商品目录动态化](routes/route-r1-product-catalog.md)
- [R2 收货地址 + 设计清单](routes/route-r2-address-design.md)
- [R3 订单闭环 + 支付占位](routes/route-r3-order-pay.md)
- [R4 上传 + 词云 + 线稿](routes/route-r4-upload-wordcloud.md)
- `wxmp_backend/docs/api-contract-v1.md`
- `wxmp_backend/docs/wordcloud-contract.md`
+5 -7
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -241,18 +242,15 @@ export default function DIYPage() {
return base
}
const handleComplete = () => {
const handleComplete = async () => {
if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
updateDesign(designId, {
designData: {
stickers,
category
}
})
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
updateDesign(designId, { designData: data })
}
setPreviewMode(false)
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
+17 -27
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -263,36 +264,25 @@ export default function StickerEditPage() {
})
}
/** 线稿:预留后端AI接口 */
const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址
const handleSketch = () => {
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
Taro.uploadFile({
url: `${API_BASE}/api/sketch`,
filePath: sticker.src,
name: 'image',
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.url) {
const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } }
setSticker(newSticker)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no url')
}
} catch {
// 如果接口不可用,降级为前端灰度+边缘检测
applyFrontendSketch()
}
},
fail: () => {
applyFrontendSketch()
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
setSticker(newSticker)
setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
})
} catch {
// 如果接口不可用/失败,降级为前端灰度+边缘检测
applyFrontendSketch()
}
}
/** 前端线稿降级方案:灰度+反相高对比 */
+56 -14
View File
@@ -8,6 +8,7 @@ import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import { assetUrl } from '../../utils/asset'
import { createWordCloudJob, getWordCloudJob } from '../../utils/api'
export default function WordCloudPage() {
const { theme, resolvedTheme } = useThemeContext()
@@ -19,6 +20,7 @@ export default function WordCloudPage() {
const [generatedImage, setGeneratedImage] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState('')
const steps = [
{ num: 1, label: '上传底图' },
@@ -33,24 +35,53 @@ export default function WordCloudPage() {
})
}
const handleGenerate = () => {
const handleGenerate = async () => {
if (!namesText.trim()) {
Taro.showToast({ title: '请先输入名字', icon: 'none' })
return
}
setStep(3); setIsGenerating(true); setProgress(0)
const timer = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
clearInterval(timer); setIsGenerating(false)
setGeneratedImage(baseImage)
return 100
}
return prev + Math.random() * 15
})
}, 1000)
if (!baseImage) {
Taro.showToast({ title: '请先上传底图', icon: 'none' })
return
}
setStep(3); setIsGenerating(true); setProgress(0); setError('')
try {
// 底图以 multipart 直接交给后端创建词云任务(不再本地模拟)
const { jobId } = await createWordCloudJob(baseImage, namesText.trim())
await pollWordCloudJob(jobId)
} catch (e) {
setIsGenerating(false)
setError(e instanceof Error ? e.message : '生成失败,请稍后重试')
}
}
/** 轮询词云任务:success 取结果图,failed 展示错误;单次网络抖动自动重试 */
const pollWordCloudJob = (jobId: string) => new Promise<void>((resolve, reject) => {
let retries = 0
const timer = setInterval(async () => {
try {
const job = await getWordCloudJob(jobId)
setProgress(Math.min(job.progress, 100))
if (job.status === 'success') {
clearInterval(timer); setIsGenerating(false)
setGeneratedImage(job.imageUrl || baseImage)
resolve()
} else if (job.status === 'failed') {
clearInterval(timer); setIsGenerating(false)
setError(job.error || '词云生成失败')
reject(new Error(job.error || '词云生成失败'))
}
} catch {
// 连续 5 次请求失败才终止,避免瞬时网络问题打断任务
retries += 1
if (retries > 5) {
clearInterval(timer); setIsGenerating(false)
reject(new Error('任务状态获取失败,请稍后重试'))
}
}
}, 1500)
})
const handleExportImage = () => {
if (!generatedImage) return
Taro.saveImageToPhotosAlbum({
@@ -148,10 +179,21 @@ export default function WordCloudPage() {
</View>
)}
{/* 步骤3: 生成中/预览 */}
{/* 步骤3: 生成中/结果/失败 */}
{step === 3 && (
<View className='step-content'>
{isGenerating ? (
{error ? (
<View className='generating-panel'>
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
<Text className='generating-title'></Text>
<Text className='generating-subtitle'>{error}</Text>
<View className='action-btns'>
<View className='btn-primary' onTap={() => { setError(''); setStep(2) }}>
<Text></Text>
</View>
</View>
</View>
) : isGenerating ? (
<View className='generating-panel'>
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
<Text className='generating-title'>...</Text>
+77 -8
View File
@@ -56,6 +56,10 @@ export interface StickerItem {
width: number
height: number
isOverlapping: boolean
/** 旋转角(度);本期 DIY 持久化,WCD 打包会携带(见 design-data-contract-v1.md 决策#2 */
rotation?: number
/** 图层顺序,WCD 按 zIndex 排列元素 */
zIndex?: number
/** 编辑状态持久化 */
edits?: {
brightness?: number // -100 ~ 100
@@ -66,6 +70,78 @@ export interface StickerItem {
}
}
/**
* 设计数据 v1:支撑 R4 下单后 WCD 生产任务打包。
* 契约见 docs/design-data-contract-v1.md;贴纸 src 持久化由 R4 负责。
*/
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string
color?: string
pos?: { x: number; y: number; scale: number }
}
/** 词云信息(R4 词云生成后写入,R2 保存/更新清单时原样保留) */
wordcloud?: {
jobId?: string
imageUrl: string
names: string[]
}
/** 贴纸(src 需为 COS 持久 URL,见 design-data-contract-v1.md 约束#1 */
stickers?: StickerItem[]
/** 兼容旧版字段 */
imageSrc?: string
imagePos?: { x: number; y: number; scale: number }
}
/** 词云任务状态(与 wordcloud 契约、后端透传一致) */
export type WordCloudStatus = 'queued' | 'running' | 'success' | 'failed'
/** 词云任务(前端轮询模型:route-r4 §2 / api-contract §8 */
export interface WordCloudJob {
id: string
status: WordCloudStatus
progress: number
imageUrl?: string
error?: string
}
/** COS 直传凭证(STS 临时凭证或预签名 URL;小程序不得持有永久密钥) */
export interface CosCredentials {
key: string
bucket?: string
region?: string
/** STS 临时密钥(服务端下发,非主账户密钥) */
credentials?: { secretId?: string; secretKey?: string; token?: string } | null
/** 预签名直传 URL(与 credentials 二选一) */
presignedUrl?: string
}
/** 线稿处理结果(POST /api/sketch */
export interface SketchResult {
imageUrl: string
}
/** 下单后 WCD 派单状态:not_configured = WORDCLOUD_API_URL 未配置(见 api-contract §8 */
export type WordCloudDispatchStatus =
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'not_configured'
/** 下单后 WCD 派单结果(POST /api/orders/:id/dispatch */
export interface WordCloudDispatchResult {
orderId: string
status: WordCloudDispatchStatus
wordcloudJobId?: string
message?: string
}
/** 设计清单条目 */
export interface DesignItem {
id: string
@@ -75,14 +151,7 @@ export interface DesignItem {
unitPrice: number
count: number
status: 'undesigned' | 'designing' | 'ordered'
designData?: {
/** 兼容旧版字段 */
imageSrc?: string
imagePos?: { x: number; y: number; scale: number }
/** 新版贴纸列表 */
stickers?: StickerItem[]
category?: ProductCategory
}
designData?: DesignDataV1
orderId?: string
createdAt: string
}
-90
View File
@@ -1,90 +0,0 @@
import http, { setToken, clearToken, getToken } from './request'
export { getToken }
/**
* 后端 API 方法集合
* 对应 wxmp_backend 的路由(见后端 Swagger /docs
* 说明:登录会调用后端 /api/auth/login,成功后把 accessToken 存入本地,
* 供后续请求自动携带。购物车/订单等仍可用本地 store.ts 作为离线兜底。
*/
export interface LoginResult {
accessToken: string
isNewUser: boolean // 首次登录(未设资料)为 true,前端需引导补填头像/姓名
nickname: string | null
avatar: string | null
}
/**
* 登录:把 wx.login() 的 code 交给后端,后端用 code2Session 换 openid
* 自动注册/续登并签发 accessToken(个人主体无需手机号)。
* 返回 isNewUser 供前端判断是否需补全资料。
*/
export async function login(code: string): Promise<LoginResult> {
const data = await http.post<LoginResult>('/api/auth/login', { code }, { auth: false })
if (data?.accessToken) {
setToken(data.accessToken)
}
return data
}
/** 更新当前用户资料(昵称/头像),新用户在补填后提交到后端持久化 */
export async function updateProfile(profile: { nickname?: string; avatar?: string }): Promise<UserProfile> {
return http.patch<UserProfile>('/api/users/me', profile)
}
/**
* 首次注册:用 registerTicket + wx.getPhoneNumber 的 code 验证手机号,完成后自动登录
*/
export async function registerWithPhone(
registerTicket: string,
phoneCode: string,
profile?: { nickname?: string; avatar?: string },
): Promise<{ accessToken: string }> {
const data = await http.post<{ accessToken: string }>(
'/api/auth/register',
{ registerTicket, phoneCode, ...profile },
{ auth: false },
)
setToken(data.accessToken)
return data
}
/** 登出:仅清除本地 token(后端 JWT 无状态,无需撤销) */
export function logout(): void {
clearToken()
}
/** 后端用户信息(对应 User 模型;openpid 为主标识,openid 可空) */
export interface UserProfile {
id: string
openpid?: string | null
openid?: string | null
unionid?: string | null
nickname?: string | null
avatar?: string | null
phone?: string | null
createdAt?: string
updatedAt?: string
}
/** 获取当前登录用户信息 */
export async function getMe(): Promise<UserProfile> {
return http.get<UserProfile>('/api/users/me')
}
/** 商品列表 */
export async function fetchProducts() {
return http.get('/api/products', { auth: false })
}
/** 商品详情 */
export async function fetchProduct(id: string) {
return http.get(`/api/products/${id}`, { auth: false })
}
/** 分类列表 */
export async function fetchCategories() {
return http.get('/api/categories', { auth: false })
}
+7
View File
@@ -0,0 +1,7 @@
/**
* R2 收货地址接口归属文件。
* 后端 /api/addresses CRUD 就绪后,在这里补充:
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约。
*/
export {}
+45
View File
@@ -0,0 +1,45 @@
import http, { clearToken, getToken, setToken } from '../request'
export { getToken }
export interface LoginResult {
accessToken: string
isNewUser: boolean // 首次登录(未设资料)为 true,前端需引导补填头像/姓名
nickname: string | null
avatar: string | null
}
/**
* 登录:把 wx.login() 的 code 交给后端,后端用 code2Session 换 openid
* 自动注册/续登并签发 accessToken(个人主体无需手机号)。
* 返回 isNewUser 供前端判断是否需补全资料。
*/
export async function login(code: string): Promise<LoginResult> {
const data = await http.post<LoginResult>('/api/auth/login', { code }, { auth: false })
if (data?.accessToken) {
setToken(data.accessToken)
}
return data
}
/**
* 首次注册:用 registerTicket + wx.getPhoneNumber 的 code 验证手机号,完成后自动登录
*/
export async function registerWithPhone(
registerTicket: string,
phoneCode: string,
profile?: { nickname?: string; avatar?: string },
): Promise<{ accessToken: string }> {
const data = await http.post<{ accessToken: string }>(
'/api/auth/register',
{ registerTicket, phoneCode, ...profile },
{ auth: false },
)
setToken(data.accessToken)
return data
}
/** 登出:仅清除本地 token(后端 JWT 无状态,无需撤销) */
export function logout(): void {
clearToken()
}
+7
View File
@@ -0,0 +1,7 @@
/**
* R2 设计清单接口归属文件。
* 后端 /api/design-list CRUD 就绪后,在这里补充:
* fetchDesignList / createDesign / updateDesign / deleteDesign
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
*/
export {}
+11
View File
@@ -0,0 +1,11 @@
/**
* API 层聚合入口:页面统一从 '../../utils/api' 导入。
* 新接口写在 api/<domain>.ts,本文件只负责 re-export,不要往单文件堆代码。
*/
export * from './auth'
export * from './user'
export * from './product'
export * from './address'
export * from './design'
export * from './order'
export * from './upload'
+7
View File
@@ -0,0 +1,7 @@
/**
* R3 订单接口归属文件。
* 后端 /api/orders 就绪后,在这里补充:
* createOrder / fetchOrders / fetchOrderDetail / payOrder
* 金额一律以服务端重算结果为准,前端只提交商品/设计数据与地址快照。
*/
export {}
+16
View File
@@ -0,0 +1,16 @@
import http from '../request'
/** 商品列表 */
export async function fetchProducts() {
return http.get('/api/products', { auth: false })
}
/** 商品详情 */
export async function fetchProduct(id: string) {
return http.get(`/api/products/${id}`, { auth: false })
}
/** 分类列表 */
export async function fetchCategories() {
return http.get('/api/categories', { auth: false })
}
+152
View File
@@ -0,0 +1,152 @@
import Taro from '@tarojs/taro'
import http, { BASE_URL, getToken } from '../request'
import type {
CosCredentials,
DesignDataV1,
SketchResult,
StickerItem,
WordCloudDispatchResult,
WordCloudJob,
WordCloudStatus,
} from '../../types'
/**
* R4 上传 / 词云 / 线稿接口。
* 契约:前端与后端见 docs 里 api-contract-v1.md §8;后端与 wordcloud 见 wordcloud-contract.md。
* multipart 上传经 Taro.uploadFile 统一带 Bearer token、解析后端 { code, message, data } 信封;
* 与 request.ts 的 JSON 封装保持一致的成功/失败语义。
*/
interface Envelope<T> {
code: number
message: string
data?: T
}
/** multipart 上传封装:带 token、解析统一信封;业务失败抛 Error(message) */
async function uploadMultipart<T>(
path: string,
filePath: string,
name: string,
formData?: Record<string, string>,
): Promise<T> {
const res = await Taro.uploadFile({
url: `${BASE_URL}${path}`,
filePath,
name,
formData,
header: { Authorization: `Bearer ${getToken()}` },
})
let body: Envelope<T>
try {
body = JSON.parse(res.data)
} catch {
throw new Error('网络异常,请稍后重试')
}
if (body && typeof body.code === 'number' && body.code !== 0) {
throw new Error(body.message || '请求失败')
}
return (body && typeof body.code === 'number' ? body.data : body) as T
}
/** 获取 COS 直传凭证(STS 临时凭证或预签名 URL;小程序不持有永久密钥) */
export function getUploadCredentials(key: string): Promise<CosCredentials> {
return http.get<CosCredentials>(`/api/upload/credentials?key=${encodeURIComponent(key)}`)
}
/**
* 直传 COS:服务端下发预签名 URL 时用 PUT 提交文件字节;否则返回凭证由调用方决定。
* 注:COS 直传的最终语义以联调阶段服务端 upload 模块实现为准(当前后端为占位)。
*/
export async function uploadToCos(localPath: string, credentials: CosCredentials): Promise<string> {
if (!credentials.presignedUrl) {
throw new Error('获取 COS 直传地址失败')
}
const fileSystem = Taro.getFileSystemManager()
const data = fileSystem.readFileSync(localPath)
await Taro.request({
url: credentials.presignedUrl,
method: 'PUT',
data,
header: { 'Content-Type': 'application/octet-stream' },
})
// 直传成功后返回去掉签名参数的对象地址
return credentials.presignedUrl.split('?')[0]
}
/** 线稿:图片 → 处理后图片 URL(失败由调用方降级本地灰度) */
export function sketchImage(localPath: string): Promise<SketchResult> {
return uploadMultipart<SketchResult>('/api/sketch', localPath, 'image')
}
/** 创建词云任务:底图 + 名单 → jobId(异步模型,随后轮询) */
export function createWordCloudJob(
imagePath: string,
names: string,
params?: Record<string, unknown>,
): Promise<{ jobId: string }> {
return uploadMultipart<{ jobId: string }>(
'/api/wordcloud/generate',
imagePath,
'image',
{ names, ...(params ? { params: JSON.stringify(params) } : {}) },
)
}
/** 轮询词云任务状态;success 后 WordCloudJob.imageUrl 为结果图 COS 链接 */
export function getWordCloudJob(jobId: string): Promise<WordCloudJob> {
return http.get<WordCloudJob>(`/api/wordcloud/jobs/${jobId}`)
}
/** 取词云任务结果(后端将 wordcloud 产物转存 COS 后返回) */
export function getWordCloudResult(jobId: string): Promise<{ imageUrl: string; svgUrl?: string }> {
return http.get<{ imageUrl: string; svgUrl?: string }>(`/api/wordcloud/jobs/${jobId}/result`)
}
/** 是否为设备本地临时路径(后端/wordcloud 无法消费,需先转 COS 持久 URL) */
function isLocalPath(url?: string): boolean {
return !!url && /^(wxfile:|http:\/\/tmp\/|tmp\/)/i.test(url)
}
/** 单张本地图 → COS 持久 URL;COS 未配置等失败时保留原路径,不阻断保存 */
async function persistLocalImage(localPath: string): Promise<string> {
try {
const key = `uploads/sticker/${Date.now()}_${Math.floor(Math.random() * 1e6)}.jpg`
const cred = await getUploadCredentials(key)
return await uploadToCos(localPath, cred)
} catch {
return localPath
}
}
/**
* 设计媒体持久化(契约 design-data-contract-v1.md 决策#4:贴纸持久化由 R4 完成)。
* 保存/下单前调用,把 designData 里的本地贴纸/底图上传为 COS 持久 URL 写回 src
* 保证 WCD 打包时后端能下载到素材字节。
*/
export async function persistDesignMedia(designData: DesignDataV1): Promise<DesignDataV1> {
if (!designData) return designData
const next: DesignDataV1 = { ...designData }
if (designData.background && isLocalPath(designData.background.src)) {
next.background = {
...designData.background,
src: await persistLocalImage(designData.background.src),
}
}
if (designData.stickers && designData.stickers.length) {
const list: StickerItem[] = []
for (const s of designData.stickers) {
list.push(isLocalPath(s.src) ? { ...s, src: await persistLocalImage(s.src) } : s)
}
next.stickers = list
}
return next
}
/** 下单后 WCD 派单(幂等触发;WORDCLOUD 未配置返回 not_configured,见 api-contract §8 */
export function dispatchOrder(orderId: string): Promise<WordCloudDispatchResult> {
return http.post<WordCloudDispatchResult>(`/api/orders/${orderId}/dispatch`)
}
export type { WordCloudStatus }
+24
View File
@@ -0,0 +1,24 @@
import http from '../request'
/** 后端用户信息(对应 User 模型;openpid 为主标识,openid 可空) */
export interface UserProfile {
id: string
openpid?: string | null
openid?: string | null
unionid?: string | null
nickname?: string | null
avatar?: string | null
phone?: string | null
createdAt?: string
updatedAt?: string
}
/** 更新当前用户资料(昵称/头像),新用户在补填后提交到后端持久化 */
export async function updateProfile(profile: { nickname?: string; avatar?: string }): Promise<UserProfile> {
return http.patch<UserProfile>('/api/users/me', profile)
}
/** 获取当前登录用户信息 */
export async function getMe(): Promise<UserProfile> {
return http.get<UserProfile>('/api/users/me')
}
-328
View File
@@ -1,328 +0,0 @@
import Taro from '@tarojs/taro'
import type { DesignItem, OrderItem, AddressItem, StickerItem } from '../types'
import { PRODUCT_ICON_MAP } from './productConfig'
import { assetUrl } from './asset'
import { invalidateAuthCache } from './authState'
// 重新导出类型,供各页面从 store 直接引用(修正原「声明但不导出」的编译错误)
export type { DesignItem, OrderItem, AddressItem, StickerItem }
const THEME_KEY = 'smart_theme'
const USER_KEY = 'smart_user_info'
const ACTIVE_USER_KEY = 'smart_active_openid'
/** 判断 openid 是否为历史版 mock 残留(形如 mock_xxx */
export function isMockOpenid(openid?: string | null): boolean {
return !!openid && (openid.startsWith('mock_') || openid.startsWith('mock-'))
}
// ---------- 当前用户 openid 管理 ----------
function getActiveOpenid(): string {
const user = getUserInfoRaw()
const openid = user?.openid || Taro.getStorageSync(ACTIVE_USER_KEY) || '_guest_'
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
return openid
}
function key(scope: string): string {
const prefix = getActiveOpenid()
return `${scope}_${prefix}`
}
// ---------- 用户数据 ----------
const USER_REGISTRY_KEY = 'smart_user_registry'
const ADMIN_PASSWORD = 'zhihui2024'
// ---------- 用户注册表 ----------
function getUserRegistry(): string[] {
try { return Taro.getStorageSync(USER_REGISTRY_KEY) || [] } catch { return [] }
}
function saveToRegistry(openid: string) {
const list = getUserRegistry()
if (!list.includes(openid)) {
list.push(openid)
Taro.setStorageSync(USER_REGISTRY_KEY, list)
}
}
function removeFromRegistry(openid: string) {
const list = getUserRegistry().filter(id => id !== openid)
Taro.setStorageSync(USER_REGISTRY_KEY, list)
}
// ---------- 用户数据 ----------
export function getUserInfoRaw(): any {
try { return Taro.getStorageSync(USER_KEY) } catch { return null }
}
export function setUserInfoRaw(info: any) {
Taro.setStorageSync(USER_KEY, info)
if (info?.openid) {
Taro.setStorageSync(ACTIVE_USER_KEY, info.openid)
// 为每个用户备份独立副本,方便切换账号时读取
Taro.setStorageSync(`user_info_${info.openid}`, info)
saveToRegistry(info.openid)
}
invalidateAuthCache()
Taro.eventCenter?.trigger('authStateChanged', { openid: info?.openid || '' })
}
export function getUserInfoByOpenid(openid: string): any | null {
try {
const backup = Taro.getStorageSync(`user_info_${openid}`)
if (backup) return backup
} catch {}
const current = getUserInfoRaw()
if (current?.openid === openid) return current
return null
}
export function clearUserInfo() {
// 仅退出登录,不删除用户数据
Taro.removeStorageSync(USER_KEY)
Taro.removeStorageSync(ACTIVE_USER_KEY)
invalidateAuthCache()
Taro.eventCenter?.trigger('authStateChanged', { openid: '' })
}
export function setUserInfo(info: any) {
setUserInfoRaw(info)
}
export function getUserInfo() {
return getUserInfoRaw()
}
// ---------- 用户数据库管理 ----------
export function listAllUsers() {
const registry = getUserRegistry()
return registry.map(openid => {
const info = getUserInfoByOpenid(openid)
const dList = getDesignListFor(openid)
const oList = getOrderListFor(openid)
return {
openid,
nickName: info?.nickName || '未知用户',
avatarUrl: info?.avatarUrl || '',
loginAt: info?.loginAt || 0,
designCount: dList.length,
orderCount: oList.length
}
})
}
export function createUser(nickName: string, avatarUrl?: string) {
const openid = 'mock_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
const info = { openid, nickName: nickName || '微信用户', avatarUrl: avatarUrl || '', loginAt: Date.now() }
setUserInfoRaw(info)
return info
}
export function switchUser(openid: string) {
const info = getUserInfoByOpenid(openid)
if (!info) return false
Taro.setStorageSync(USER_KEY, info)
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
return true
}
export function deleteUser(openid: string) {
// 删除该用户的所有数据
Taro.removeStorageSync(`user_info_${openid}`)
Taro.removeStorageSync(`design_list_${openid}`)
Taro.removeStorageSync(`order_list_${openid}`)
Taro.removeStorageSync(`address_list_${openid}`)
removeFromRegistry(openid)
// 如果删的是当前登录用户,清掉登录态
const current = getUserInfoRaw()
if (current?.openid === openid) {
clearUserInfo()
}
}
export function verifyAdminPassword(password: string): boolean {
return password === ADMIN_PASSWORD
}
// ---------- 跨用户读取辅助函数 ----------
function keyFor(scope: string, openid: string) {
return `${scope}_${openid}`
}
function getDesignListFor(openid: string): DesignItem[] {
try { return Taro.getStorageSync(keyFor('design_list', openid)) || [] } catch { return [] }
}
function getOrderListFor(openid: string): OrderItem[] {
try { return Taro.getStorageSync(keyFor('order_list', openid)) || [] } catch { return [] }
}
// ---------- 设计清单 ----------
export function getDesignList(): DesignItem[] {
try { return Taro.getStorageSync(key('design_list')) || [] } catch { return [] }
}
export function setDesignList(list: DesignItem[]) {
Taro.setStorageSync(key('design_list'), list)
}
export function addDesign(product: any, count: number): DesignItem {
const list = getDesignList()
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
const item: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: iconImg,
unitPrice: product.price,
count,
status: 'undesigned',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...list, item])
return item
}
export function updateDesign(id: string, patch: Partial<DesignItem>) {
const list = getDesignList()
const idx = list.findIndex(d => d.id === id)
if (idx === -1) return
list[idx] = { ...list[idx], ...patch }
setDesignList(list)
}
/** 批量删除设计条目 */
export function removeDesigns(ids: string[]) {
const list = getDesignList().filter(d => !ids.includes(d.id))
setDesignList(list)
}
/** 根据ID删除单条设计 */
export function removeDesign(id: string) {
const list = getDesignList().filter(d => d.id !== id)
setDesignList(list)
}
// ---------- 订单 ----------
export function getOrderList(): OrderItem[] {
try { return Taro.getStorageSync(key('order_list')) || [] } catch { return [] }
}
export function setOrderList(list: OrderItem[]) {
Taro.setStorageSync(key('order_list'), list)
}
export function designToOrder(designId: string): OrderItem | null {
const dList = getDesignList()
const oList = getOrderList()
const design = dList.find(d => d.id === designId)
if (!design) return null
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
? design.productIcon
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
const iconImg = assetUrl(rawIcon)
const order: OrderItem = {
id: 'ORD' + Date.now().toString().slice(-9),
productName: design.productName,
productIcon: iconImg,
statusCode: 'pending',
date: new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-'),
price: '¥' + (design.unitPrice * design.count).toFixed(2),
count: design.count,
sku: `${design.productName} × ${design.count}`
}
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx !== -1) {
dList[dIdx].status = 'ordered'
dList[dIdx].orderId = order.id
}
setDesignList(dList)
setOrderList([order, ...oList])
return order
}
// ---------- 主题 ----------
export type ThemeMode = 'light' | 'dark' | 'auto'
export function getTheme(): ThemeMode {
try { return Taro.getStorageSync(THEME_KEY) || 'auto' } catch { return 'auto' }
}
export function setTheme(theme: ThemeMode) {
Taro.setStorageSync(THEME_KEY, theme)
}
// 跟随系统模式下,系统主题由 onThemeChange/getAppBaseInfo 写入缓存,供 resolveTheme 使用
let detectedSystemTheme: 'light' | 'dark' | null = null
export function setDetectedSystemTheme(t: 'light' | 'dark'): void {
detectedSystemTheme = t
}
/** 根据自动模式解析实际生效主题 */
export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'auto') {
// darkmode 开启后 getAppBaseInfo().theme 能拿到系统主题;缓存仅用于启动时加速
if (detectedSystemTheme) return detectedSystemTheme
try {
const info = Taro.getAppBaseInfo()
if (info.theme === 'dark' || info.theme === 'light') return info.theme
} catch { /* ignore */ }
return 'light'
}
return mode
}
// ---------- 收货地址 ----------
export function getAddressList(): AddressItem[] {
try { return Taro.getStorageSync(key('address_list')) || [] } catch { return [] }
}
export function setAddressList(list: AddressItem[]) {
Taro.setStorageSync(key('address_list'), list)
}
export function addAddress(addr: Omit<AddressItem, 'id'>): AddressItem {
const list = getAddressList()
const item: AddressItem = { ...addr, id: 'ADR' + Date.now() }
// 若设为默认,取消其他默认
if (item.isDefault) {
list.forEach(a => { a.isDefault = false })
}
setAddressList([item, ...list])
return item
}
export function updateAddress(id: string, patch: Partial<AddressItem>) {
const list = getAddressList()
const idx = list.findIndex(a => a.id === id)
if (idx === -1) return
if (patch.isDefault) list.forEach(a => { a.isDefault = false })
list[idx] = { ...list[idx], ...patch }
setAddressList(list)
}
export function deleteAddress(id: string) {
const list = getAddressList().filter(a => a.id !== id)
setAddressList(list)
}
export function getDefaultAddress(): AddressItem | undefined {
return getAddressList().find(a => a.isDefault)
}
+42
View File
@@ -0,0 +1,42 @@
import Taro from '@tarojs/taro'
import type { AddressItem } from '../../types'
import { key } from './keys'
// ---------- 收货地址 ----------
export function getAddressList(): AddressItem[] {
try { return Taro.getStorageSync(key('address_list')) || [] } catch { return [] }
}
export function setAddressList(list: AddressItem[]) {
Taro.setStorageSync(key('address_list'), list)
}
export function addAddress(addr: Omit<AddressItem, 'id'>): AddressItem {
const list = getAddressList()
const item: AddressItem = { ...addr, id: 'ADR' + Date.now() }
// 若设为默认,取消其他默认
if (item.isDefault) {
list.forEach(a => { a.isDefault = false })
}
setAddressList([item, ...list])
return item
}
export function updateAddress(id: string, patch: Partial<AddressItem>) {
const list = getAddressList()
const idx = list.findIndex(a => a.id === id)
if (idx === -1) return
if (patch.isDefault) list.forEach(a => { a.isDefault = false })
list[idx] = { ...list[idx], ...patch }
setAddressList(list)
}
export function deleteAddress(id: string) {
const list = getAddressList().filter(a => a.id !== id)
setAddressList(list)
}
export function getDefaultAddress(): AddressItem | undefined {
return getAddressList().find(a => a.isDefault)
}
+57
View File
@@ -0,0 +1,57 @@
import Taro from '@tarojs/taro'
import type { DesignItem } from '../../types'
import { assetUrl } from '../asset'
import { PRODUCT_ICON_MAP } from '../productConfig'
import { key, keyFor } from './keys'
/** 跨用户读取指定 openid 的设计清单(账号管理用) */
export function getDesignListFor(openid: string): DesignItem[] {
try { return Taro.getStorageSync(keyFor('design_list', openid)) || [] } catch { return [] }
}
// ---------- 设计清单 ----------
export function getDesignList(): DesignItem[] {
try { return Taro.getStorageSync(key('design_list')) || [] } catch { return [] }
}
export function setDesignList(list: DesignItem[]) {
Taro.setStorageSync(key('design_list'), list)
}
export function addDesign(product: any, count: number): DesignItem {
const list = getDesignList()
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
const item: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: iconImg,
unitPrice: product.price,
count,
status: 'undesigned',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...list, item])
return item
}
export function updateDesign(id: string, patch: Partial<DesignItem>) {
const list = getDesignList()
const idx = list.findIndex(d => d.id === id)
if (idx === -1) return
list[idx] = { ...list[idx], ...patch }
setDesignList(list)
}
/** 批量删除设计条目 */
export function removeDesigns(ids: string[]) {
const list = getDesignList().filter(d => !ids.includes(d.id))
setDesignList(list)
}
/** 根据ID删除单条设计 */
export function removeDesign(id: string) {
const list = getDesignList().filter(d => d.id !== id)
setDesignList(list)
}
+47
View File
@@ -0,0 +1,47 @@
/**
* DA 层聚合入口:页面统一从 '../../utils/store' 导入。
* 新增领域逻辑请写在 store/<domain>.ts,本文件只负责 re-export。
*/
export { isMockOpenid } from './keys'
export {
getUserInfoRaw,
setUserInfoRaw,
getUserInfoByOpenid,
clearUserInfo,
setUserInfo,
getUserInfo,
listAllUsers,
createUser,
switchUser,
deleteUser,
verifyAdminPassword
} from './user'
export {
getDesignList,
setDesignList,
addDesign,
updateDesign,
removeDesigns,
removeDesign
} from './design'
export {
getOrderList,
setOrderList,
designToOrder
} from './order'
export {
getAddressList,
setAddressList,
addAddress,
updateAddress,
deleteAddress,
getDefaultAddress
} from './address'
export {
getTheme,
setTheme,
setDetectedSystemTheme,
resolveTheme
} from './theme'
export type { ThemeMode } from './theme'
export type { DesignItem, OrderItem, AddressItem, StickerItem } from '../../types'
+35
View File
@@ -0,0 +1,35 @@
import Taro from '@tarojs/taro'
/** 当前登录用户信息在 Storage 中的 key */
export const USER_KEY = 'smart_user_info'
/** 当前激活 openid 在 Storage 中的 key */
export const ACTIVE_USER_KEY = 'smart_active_openid'
/** 判断 openid 是否为历史版 mock 残留(形如 mock_xxx */
export function isMockOpenid(openid?: string | null): boolean {
return !!openid && (openid.startsWith('mock_') || openid.startsWith('mock-'))
}
/** 取当前用户 openid;没有登录态时回退 _guest_ 并缓存 */
function getActiveOpenid(): string {
let user: any = null
try {
user = Taro.getStorageSync(USER_KEY)
} catch {
user = null
}
const openid = user?.openid || Taro.getStorageSync(ACTIVE_USER_KEY) || '_guest_'
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
return openid
}
/** 按当前用户生成作用域 storage key */
export function key(scope: string): string {
const prefix = getActiveOpenid()
return `${scope}_${prefix}`
}
/** 按指定 openid 生成作用域 storage key */
export function keyFor(scope: string, openid: string): string {
return `${scope}_${openid}`
}
+55
View File
@@ -0,0 +1,55 @@
import Taro from '@tarojs/taro'
import type { OrderItem } from '../../types'
import { assetUrl } from '../asset'
import { PRODUCT_ICON_MAP } from '../productConfig'
import { getDesignList, setDesignList } from './design'
import { key, keyFor } from './keys'
/** 跨用户读取指定 openid 的订单列表(账号管理用) */
export function getOrderListFor(openid: string): OrderItem[] {
try { return Taro.getStorageSync(keyFor('order_list', openid)) || [] } catch { return [] }
}
// ---------- 订单 ----------
export function getOrderList(): OrderItem[] {
try { return Taro.getStorageSync(key('order_list')) || [] } catch { return [] }
}
export function setOrderList(list: OrderItem[]) {
Taro.setStorageSync(key('order_list'), list)
}
export function designToOrder(designId: string): OrderItem | null {
const dList = getDesignList()
const oList = getOrderList()
const design = dList.find(d => d.id === designId)
if (!design) return null
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
? design.productIcon
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
const iconImg = assetUrl(rawIcon)
const order: OrderItem = {
id: 'ORD' + Date.now().toString().slice(-9),
productName: design.productName,
productIcon: iconImg,
statusCode: 'pending',
date: new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-'),
price: '¥' + (design.unitPrice * design.count).toFixed(2),
count: design.count,
sku: `${design.productName} × ${design.count}`
}
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx !== -1) {
dList[dIdx].status = 'ordered'
dList[dIdx].orderId = order.id
}
setDesignList(dList)
setOrderList([order, ...oList])
return order
}
+33
View File
@@ -0,0 +1,33 @@
import Taro from '@tarojs/taro'
const THEME_KEY = 'smart_theme'
export type ThemeMode = 'light' | 'dark' | 'auto'
export function getTheme(): ThemeMode {
try { return Taro.getStorageSync(THEME_KEY) || 'auto' } catch { return 'auto' }
}
export function setTheme(theme: ThemeMode) {
Taro.setStorageSync(THEME_KEY, theme)
}
// 跟随系统模式下,系统主题由 onThemeChange/getAppBaseInfo 写入缓存,供 resolveTheme 使用
let detectedSystemTheme: 'light' | 'dark' | null = null
export function setDetectedSystemTheme(t: 'light' | 'dark'): void {
detectedSystemTheme = t
}
/** 根据自动模式解析实际生效主题 */
export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'auto') {
if (detectedSystemTheme) return detectedSystemTheme
try {
const info = Taro.getAppBaseInfo()
if (info.theme === 'dark' || info.theme === 'light') return info.theme
} catch { /* ignore */ }
return 'light'
}
return mode
}
+123
View File
@@ -0,0 +1,123 @@
import Taro from '@tarojs/taro'
import { invalidateAuthCache } from '../authState'
import { getDesignListFor } from './design'
import { ACTIVE_USER_KEY, USER_KEY } from './keys'
import { getOrderListFor } from './order'
const USER_REGISTRY_KEY = 'smart_user_registry'
const ADMIN_PASSWORD = 'zhihui2024'
// ---------- 用户注册表 ----------
function getUserRegistry(): string[] {
try { return Taro.getStorageSync(USER_REGISTRY_KEY) || [] } catch { return [] }
}
function saveToRegistry(openid: string) {
const list = getUserRegistry()
if (!list.includes(openid)) {
list.push(openid)
Taro.setStorageSync(USER_REGISTRY_KEY, list)
}
}
function removeFromRegistry(openid: string) {
const list = getUserRegistry().filter(id => id !== openid)
Taro.setStorageSync(USER_REGISTRY_KEY, list)
}
// ---------- 用户数据 ----------
export function getUserInfoRaw(): any {
try { return Taro.getStorageSync(USER_KEY) } catch { return null }
}
export function setUserInfoRaw(info: any) {
Taro.setStorageSync(USER_KEY, info)
if (info?.openid) {
Taro.setStorageSync(ACTIVE_USER_KEY, info.openid)
// 为每个用户备份独立副本,方便切换账号时读取
Taro.setStorageSync(`user_info_${info.openid}`, info)
saveToRegistry(info.openid)
}
invalidateAuthCache()
Taro.eventCenter?.trigger('authStateChanged', { openid: info?.openid || '' })
}
export function getUserInfoByOpenid(openid: string): any | null {
try {
const backup = Taro.getStorageSync(`user_info_${openid}`)
if (backup) return backup
} catch {}
const current = getUserInfoRaw()
if (current?.openid === openid) return current
return null
}
export function clearUserInfo() {
// 仅退出登录,不删除用户数据
Taro.removeStorageSync(USER_KEY)
Taro.removeStorageSync(ACTIVE_USER_KEY)
invalidateAuthCache()
Taro.eventCenter?.trigger('authStateChanged', { openid: '' })
}
export function setUserInfo(info: any) {
setUserInfoRaw(info)
}
export function getUserInfo() {
return getUserInfoRaw()
}
// ---------- 用户数据库管理 ----------
export function listAllUsers() {
const registry = getUserRegistry()
return registry.map(openid => {
const info = getUserInfoByOpenid(openid)
const dList = getDesignListFor(openid)
const oList = getOrderListFor(openid)
return {
openid,
nickName: info?.nickName || '未知用户',
avatarUrl: info?.avatarUrl || '',
loginAt: info?.loginAt || 0,
designCount: dList.length,
orderCount: oList.length
}
})
}
export function createUser(nickName: string, avatarUrl?: string) {
const openid = 'mock_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
const info = { openid, nickName: nickName || '微信用户', avatarUrl: avatarUrl || '', loginAt: Date.now() }
setUserInfoRaw(info)
return info
}
export function switchUser(openid: string) {
const info = getUserInfoByOpenid(openid)
if (!info) return false
Taro.setStorageSync(USER_KEY, info)
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
return true
}
export function deleteUser(openid: string) {
// 删除该用户的所有数据
Taro.removeStorageSync(`user_info_${openid}`)
Taro.removeStorageSync(`design_list_${openid}`)
Taro.removeStorageSync(`order_list_${openid}`)
Taro.removeStorageSync(`address_list_${openid}`)
removeFromRegistry(openid)
// 如果删的是当前登录用户,清掉登录态
const current = getUserInfoRaw()
if (current?.openid === openid) {
clearUserInfo()
}
}
export function verifyAdminPassword(password: string): boolean {
return password === ADMIN_PASSWORD
}