From 7be561500b4c40e8b945e00c754007e900964e53 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Wed, 16 Sep 2026 02:01:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=80=E5=87=BA?= =?UTF-8?q?=E5=90=8E=E8=A1=A5=E5=91=8A=E8=A1=A3=E7=89=A9=E5=A4=84=E7=90=86?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload-process-resilience_7d735905.md | 167 ++++++++++++++++++ .../pages/clothes-detail/clothes-detail.ts | 34 +++- .../pages/clothes-detail/clothes-detail.wxml | 2 +- miniprogram/pages/upload/upload.ts | 76 +++++++- miniprogram/pages/upload/upload.wxml | 5 +- miniprogram/pages/wardrobe/wardrobe.json | 3 +- miniprogram/pages/wardrobe/wardrobe.ts | 65 ++++++- miniprogram/pages/wardrobe/wardrobe.wxml | 11 ++ miniprogram/types/models.ts | 4 + miniprogram/utils/clothes-status.ts | 53 ++++++ miniprogram/utils/config.ts | 3 + miniprogram/utils/pending-clothes.ts | 138 +++++++++++++++ 12 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 .codebuddy/plans/upload-process-resilience_7d735905.md create mode 100644 miniprogram/utils/clothes-status.ts create mode 100644 miniprogram/utils/pending-clothes.ts diff --git a/.codebuddy/plans/upload-process-resilience_7d735905.md b/.codebuddy/plans/upload-process-resilience_7d735905.md new file mode 100644 index 0000000..13bb380 --- /dev/null +++ b/.codebuddy/plans/upload-process-resilience_7d735905.md @@ -0,0 +1,167 @@ +--- +name: upload-process-resilience +overview: 修复"用户退出小程序后衣物处理结果无法感知、失败态误报"的前后端一致性问题:修正上传页 90s 超时误报与"额度已自动退回"文案,增加待处理衣物持久化与重进恢复查询,衣橱列表正确区分"处理中/处理失败"(后端列表与详情接口补充 image_process_status / fail_reason)。 +todos: + - id: status-utils-storage + content: 新增 clothes-status 与 pending-clothes 工具,config 加 PENDING_CLOTHES,模型补可选状态字段 + status: completed + - id: upload-fix + content: 改造上传页:轮询预算对齐后端、超时不误报失败、失败与额度文案仅真实失败触发、onShow 续查 + status: completed + dependencies: + - status-utils-storage + - id: wardrobe-recover + content: 衣橱接入待处理解析:失败弹窗引导详情、列表区分处理中与失败、终结后静默刷新 + status: completed + dependencies: + - status-utils-storage + - id: detail-align + content: 详情页用接口状态初始化并跳过终态轮询,修正处理中与失败态展示时机 + status: completed + dependencies: + - status-utils-storage + - id: backend-fields + content: 后端列表与详情补处理状态字段、非属主隐藏失败原因、上传回退改用独立 context、更新接口文档 + status: completed + - id: verify-audit + content: Use [subagent:code-explorer] 审计字段消费方无回归,运行前端 tsc 与后端 go build 验证 + status: completed + dependencies: + - upload-fix + - wardrobe-recover + - detail-align + - backend-fields +--- + +## 用户需求 + +用户报告疑虑:"用户退出小程序之后,会导致上传的衣服处理失败?"。经排查确认:后端处理任务与客户端连接解耦,已成功上传的衣物不会因退出而处理失败;但由此暴露出多处真实的前端体验缺陷与前后端契约缺口,用户要求全面修复,包括 90s 超时误报与"额度已退回"文案不实的问题,并允许同步调整后端接口。 + +## 产品概述 + +修复「上传衣物后退出小程序」场景下的状态可感知性:处理结果在重新进入小程序后自动恢复与告知;处理中、处理失败状态如实展示;超时不再被误判为失败;额度退回文案只在真实失败时出现。 + +## 核心功能 + +- **退出后恢复处理结果**:衣物上传成功后,即使用户退出小程序或离开页面,重新进入衣橱时自动核对该衣物的最终处理结果;成功无感展示,失败给出明确提示并可直达衣物详情。 +- **处理状态如实展示**:衣橱列表正确区分「处理中(排队中/抠图中/分类中)」与「处理失败」,失败项显示醒目标识,不再将失败项永久显示为"AI 处理中"。 +- **超时不再误报失败**:上传页轮询时长与后端处理现实对齐;轮询结束但未收到最终结果时,展示中性提示(仍在处理中,可先返回衣橱稍后查看),不进入"识别失败"界面。 +- **额度文案准确**:仅当后端确认处理失败(此时额度已自动退回)才展示"识别失败 + 额度已自动退回";处理超时、网络中断等场景不出现该文案。 +- **失败原因可达**:衣物详情页进入时即可正确渲染处理中/失败状态(不再依赖轮询首次返回),失败原因与后续操作入口清晰可见。 + +## 技术栈 + +- 前端:微信小程序原生框架 + TypeScript(沿用现有工程与组件体系,无新增依赖) +- 后端:Go(Gin + GORM,仓库 `/root/zt-closet-backend`,仅做加法式接口扩展与回退加固) +- 复用既有设施:`utils/request.ts`(request/uploadFile、silentAuth/showError)、`utils/logger.ts`、`utils/outfit-status.ts`(状态语义收敛范式)、`STORAGE_KEYS` + `Ax_TASK` 式续接范式(ai-recommend 的 onShow 续轮询/onHide 停轮询/结果落库即清理) +- 组件:复用 `v2-overlay`(modal 形态)与 `clothes-card`(`statusText`/`statusType`,已有 `.tile-flag.is-ink` 醒目样式) + +## 实现方案 + +整体策略:**前后端契约补齐 + 前端状态语义收敛 + 轻量持久化续接**,分四层落地。 + +1. **状态语义单一来源(前端)**:新增 `utils/clothes-status.ts`,对齐 `utils/outfit-status.ts` 范式,统一输出「处理中 / 处理失败 / 无标识」的文案与样式判定;对旧后端响应(无状态字段)保留"字段为空即处理中"的降级推断,保证向后兼容。 +2. **续接机制(前端)**:新增 `utils/pending-clothes.ts`,上传成功即把 `{uuid, createdAt}` 写入本地存储(新增 `STORAGE_KEYS.PENDING_CLOTHES`);衣橱 `onShow` 时仅当有待处理记录才逐条查询 `/clothes/:uuid/status`(`showError:false` + `silentAuth:true`),终结态移出存储:成功静默、失败用 `v2-overlay` 提示并可跳详情;未完成保留待下次核对。存储上限 20 条、超 24 小时自动清理,防止膨胀。上传页自身 onHide 停轮询(对齐 ai-recommend)、onShow 恢复续查。 +3. **超时策略修正(前端)**:上传页轮询预算由 60×1.5s≈90s 调整为 2s×90≈180s(与后端单任务 3 分钟下限对齐);预算耗尽只停止轮询并展示中性提示,绝不置 `failed`;`failed` 仅在收到后端 `image_process_status=5` 时进入,此时"额度已自动退回"文案才成立(后端失败路径确实回退)。 +4. **后端字段补齐**:`ClothesGetByUUIDResponse` 增加 `image_process_status`、`fail_reason`(`buildClothesResponse` 填充),列表与详情共用;衣物详情页据此在 `onLoad` 即正确渲染状态并跳过终态轮询,消除"先显示正常详情/空白再回退处理中"的闪烁。非属主读取(穿搭分享 `share_uuid`、衣柜分享)屏蔽 `fail_reason`(可能含内部错误信息),保留状态字段。另加固:上传同步阶段失败回退额度改用不受请求取消影响的独立 context(沿用 `processImageAsync` 的 trace+Background 派生手法),消除"客户端中途断开导致额度已扣无法回退"窗口。 + +关键取舍: + +- 不做全局常驻轮询/不做后台任务队列:处理本身在服务端持续进行且有恢复调度器兜底,前端只承担"进入时核对一次"的最小职责,避免耗电与请求浪费。 +- 不新增接口:复用已有 status 接口(幂等只读),列表/详情字段为向后兼容的加法;旧后端可正常降级使用。 +- 明确不在范围:上传请求尚未返回时退出导致该次上传中断(属"上传失败",前端无法挽回)。 + +性能与可靠性:pending 为空时零额外请求;解析为并发 N 条 status 查询(N 受上限 20 约束且通常为 1-2);列表刷新与解析并行、由已有 reqSeq 序号防竞态;解析完成后若存在终结态记录再做一次静默刷新,保证列表状态与服务端一致。 + +## 架构设计 + +数据流(新增/变更部分): + +```mermaid +flowchart TB + subgraph 前端 + U[上传页 upload] -->|uploadFile 成功写入| S[(本地存储 pending 衣物)] + U -->|180s 有界轮询| ST[/clothes/:uuid/status/] + U -->|真实失败: 弹失败态| U + W[衣橱 onShow] -->|读取 pending 逐条核对| ST + W -->|失败提示弹层| M[衣物详情] + W -->|刷新| L[列表: 处理中/处理失败] + D[衣物详情页] -->|onLoad 用详情状态初始化, 仅处理中才轮询| ST + end + subgraph 后端 + ST --> P[异步处理流水线: 与连接解耦 + 定时恢复] + P --> DB[(clothes 表: 状态/失败原因)] + DB -->|列表/详情新增字段| L + end +``` + +沿用现有分层:页面层(upload/wardrobe/clothes-detail)→ 工具层(clothes-status、pending-clothes、request、logger)→ 存储层(wx storage);后端 controller(router) → service → dao 分层不变,仅在 DTO 与 service 映射层做加法。 + +## 实现说明(执行要点) + +- 存储写入需按 uuid 幂等去重;读取时先做过期清理(>24h)。 +- status 查询统一 `showError:false`、`silentAuth:true`:后台核对不弹错、不触发登录跳转;未登录时 request 内置直接 reject,pending 保留待下次。 +- 衣物不存在(50000,用户已删除)时静默移出 pending,避免残留。 +- 日志沿用 `logger` 模块名(upload/wardrobe/pending-clothes/clothes-detail),仅记录 uuid 与状态码,不打印敏感信息。 +- 列表渲染保持 `_statusText/_statusType` 字段形状不变,仅替换计算逻辑,wxml 主结构不动,降低改动面。 +- 后端字段为可选扩展:前端所有读取均做缺省降级,灰度期间新旧接口均可工作。 +- 失败弹窗文案聚合("有 N 件衣物 AI 识别失败"),主按钮跳转最近一条失败衣物详情。 +- 不改动 ai-recommend、outfit 等无关页面逻辑;`clothes-card` 组件无需改动。 + +## 目录结构 + +``` +zt-closet-wxmini-frontend/ +├── miniprogram/ +│ ├── utils/ +│ │ ├── config.ts # [MODIFY] STORAGE_KEYS 新增 PENDING_CLOTHES(待核对处理结果的衣物 uuid 列表) +│ │ ├── clothes-status.ts # [NEW] 衣物处理状态语义单一来源。导出 isProcessing/isTerminal/clothesStatusPill 等:1/2/3 归"AI 处理中"(plain),5 归"处理失败"(ink),4 为空;字段缺失时降级为原"category_main 与 processed_image 均为空则处理中"推断,保证旧后端兼容 +│ │ └── pending-clothes.ts # [NEW] 待处理衣物持久化与解析器。add/remove/get(幂等、上限 20、24h 过期清理);resolvePendingClothes() 并发查询各 uuid 的 /clothes/:uuid/status,返回 {finished, failed:[{uuid,reason}], processing},终结态移出存储;50000 静默移除;network 失败保留 +│ ├── types/ +│ │ └── models.ts # [MODIFY] ClothesItem 增加可选 image_process_status?: number、fail_reason?: string(对齐后端新字段) +│ └── pages/ +│ ├── upload/ +│ │ ├── upload.ts # [MODIFY] 上传成功即 addPendingClothes;轮询改 2s×90≈180s;预算耗尽只停轮询并置 slowHint 中性提示,不再置 failed;仅后端返回 FAILED 时进入失败态(同时 removePending);onHide 停轮询、onShow 续查恢复;SUCCESS 移除 pending +│ │ └── upload.wxml # [MODIFY] 处理中区块增加 slowHint 中性文案(可先返回衣橱,结果自动更新);失败区块的"额度已自动退回"改为受数据开关控制,仅真实失败展示 +│ ├── wardrobe/ +│ │ ├── wardrobe.ts # [MODIFY] onShow 在有待处理记录时调用 resolvePendingClothes:失败弹窗(v2-overlay)可跳详情、存在终结态则静默重刷列表;fetchList 映射改用 clothesStatusPill +│ │ ├── wardrobe.wxml # [MODIFY] 新增 v2-overlay 节点承接失败提示 +│ │ └── wardrobe.json # [MODIFY] 注册 v2-overlay 组件 +│ └── clothes-detail/ +│ ├── clothes-detail.ts # [MODIFY] fetchDetail 用响应状态初始化 imageStatus/step/failReason;终态(4/5)不启动轮询,仅处理中才轮询;轮询预算耗尽保持处理中视图不误报 +│ └── clothes-detail.wxml # [MODIFY] 处理中提示补"排队时可能更久"中性说明(可选微调) + +zt-closet-backend/ +├── internal/ +│ ├── dto/ +│ │ └── clothes.go # [MODIFY] ClothesGetByUUIDResponse 增加 image_process_status、fail_reason(列表/详情共用) +│ └── service/ +│ ├── clothes.go # [MODIFY] buildClothesResponse 填充新字段;GetByUUID 非属主(share_uuid)清空 fail_reason;上传失败回退 refundCosQuota 改用独立 context(trace+Background 派生,断开连接也能回退) +│ └── closet.go # [MODIFY] 衣柜分享响应路径同样屏蔽非属主 fail_reason,保留状态字段 +└── 接口文档.md # [MODIFY] 列表/详情响应补充新字段说明,并注明 fail_reason 仅属主可见 +``` + +## 关键代码结构 + +```ts +// utils/pending-clothes.ts —— 跨模块契约 +export interface PendingClothes { uuid: string; createdAt: number } // createdAt: 秒级时间戳 + +export function addPendingClothes(uuid: string): void +export function removePendingClothes(uuid: string): void +export function getPendingClothes(): PendingClothes[] // 读取时顺带清理过期项 + +export function resolvePendingClothes(): Promise<{ + finished: string[] // 本轮确认成功 + failed: Array<{ uuid: string; reason: string }> // 本轮确认失败(含原因) + processing: string[] // 仍在处理中,保留待下次 +}> +``` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose: 在改动完成后跨仓库审计 `ClothesGetByUUIDResponse`(后端 closet.go、clothes.go 等消费方)与前端 `ClothesItem` 全部使用页面(wardrobe、ai-recommend、outfit-edit、closet-share-create、outfit-detail 等)的受影响调用点,确认新增字段与非属主屏蔽逻辑无遗漏、无破坏。 +- Expected outcome: 输出受影响调用点清单与回归结论(是否存在遗漏消费方或破坏性变更),作为验证环节的依据。 \ No newline at end of file diff --git a/miniprogram/pages/clothes-detail/clothes-detail.ts b/miniprogram/pages/clothes-detail/clothes-detail.ts index de43232..f64184c 100644 --- a/miniprogram/pages/clothes-detail/clothes-detail.ts +++ b/miniprogram/pages/clothes-detail/clothes-detail.ts @@ -1,6 +1,8 @@ import { request } from '../../utils/request' import { ClothesItem } from '../../types/models' import { ProcessStatus } from '../../utils/config' +import { isProcessTerminal } from '../../utils/clothes-status' +import { removePendingClothes } from '../../utils/pending-clothes' import { navScroll } from '../../utils/nav' import { logger } from '../../utils/logger' @@ -152,9 +154,13 @@ Page({ this.setData({ loading: false, notFound: true }) return } - this.fetchDetail() - // 只读(来自穿搭分享)场景无需轮询 AI 处理状态 - if (!readonly) this.pollStatus() + this.fetchDetail().then(() => { + // 只读(来自穿搭分享)场景无需轮询 AI 处理状态 + if (readonly) return + // 详情响应已带处理状态:成功/失败等终态直接定态,仅处理中才轮询 + if (isProcessTerminal(this.data.detail)) return + this.pollStatus() + }) }, onBackWardrobe() { @@ -172,7 +178,19 @@ Page({ return request({ url }) .then((detail) => { logger.debug(LOG, '详情加载成功, name=', detail && detail.name) - this.setData({ detail, loading: false }) + const patch: Record = { detail, loading: false } + const status = Number(detail && detail.image_process_status) + if (!this.data.readonly && status) { + // 详情响应带处理状态:进入页面即渲染正确视图,避免先闪正常详情再切回处理中 + patch.imageStatus = status + patch.step = statusToStep(status) + if (status === ProcessStatus.FAILED) { + patch.failReason = (detail && detail.fail_reason) || '识别失败' + } + // 终态即可清掉待核对记录(幂等,未登记时为空操作) + if (isProcessTerminal(detail)) removePendingClothes(this.data.uuid) + } + this.setData(patch) }) .catch((e: any) => { logger.warn(LOG, '详情加载失败, code=', e && e.code) @@ -181,22 +199,27 @@ Page({ }, pollStatus() { + // 先清旧定时器,避免重复进入时叠加成多份轮询 + this.clearTimer() let count = 0 ;(this as any).pollTimer = setInterval(() => { count++ request<{ image_process_status: number; fail_reason?: string }>({ url: `/clothes/${this.data.uuid}/status`, showError: false, + silentAuth: true, }) .then((res) => { const status = res.image_process_status if (status === ProcessStatus.SUCCESS) { // 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁 this.clearTimer() + removePendingClothes(this.data.uuid) logger.info(LOG, 'AI 处理完成') this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 })) } else if (status === ProcessStatus.FAILED) { this.clearTimer() + removePendingClothes(this.data.uuid) logger.warn(LOG, 'AI 识别失败, 原因=', res.fail_reason) this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' }) } else { @@ -205,8 +228,9 @@ Page({ }) .catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code)) if (count >= 120) { + // 只停轮询,保持处理中视图(后端有恢复调度器兜底),不误报失败 this.clearTimer() - logger.warn(LOG, '状态轮询超时(240s),停止轮询') + logger.warn(LOG, '状态轮询超时(240s),保持处理中视图并停止轮询') } }, 2000) }, diff --git a/miniprogram/pages/clothes-detail/clothes-detail.wxml b/miniprogram/pages/clothes-detail/clothes-detail.wxml index c5ef45a..5509684 100644 --- a/miniprogram/pages/clothes-detail/clothes-detail.wxml +++ b/miniprogram/pages/clothes-detail/clothes-detail.wxml @@ -70,7 +70,7 @@ module.exports = { hex: hex, tags: tags } Processing AI 正在识别你的衣物… - 通常需要 10 秒左右,请稍候 + 通常需要 10 秒左右;处理较慢时可先返回,稍后再来查看 diff --git a/miniprogram/pages/upload/upload.ts b/miniprogram/pages/upload/upload.ts index 839fe55..207793c 100644 --- a/miniprogram/pages/upload/upload.ts +++ b/miniprogram/pages/upload/upload.ts @@ -1,11 +1,18 @@ import { request, uploadFile, NOT_LOGIN_CODE } from '../../utils/request' import { ProcessStatus } from '../../utils/config' +import { addPendingClothes, removePendingClothes } from '../../utils/pending-clothes' import { navPageScroll } from '../../utils/nav' import { logger } from '../../utils/logger' // 日志模块名 const LOG = 'upload' +// 状态轮询:2s 一次、最多 90 次 ≈ 3 分钟,与后端单任务 3 分钟上限对齐。 +// 预算耗尽只停轮询并转中性提示,绝不判定失败:任务可能仍在排队/执行中, +// 由后端恢复调度器兜底,最终结果由衣橱页核对补齐。 +const POLL_INTERVAL_MS = 2000 +const POLL_MAX_COUNT = 90 + Page({ data: { state: 'idle' as 'idle' | 'processing' | 'failed', @@ -13,6 +20,10 @@ Page({ clothesUuid: '', failReason: '', step: 0, + // 轮询预算耗尽:仍在处理中,只做中性提示,不算失败 + slowHint: false, + // 失败原因是否伴随额度退回(仅后端确认失败才为真) + quotaRefunded: false, // 滚动收缩:由 utils/nav.ts 的共享 handler 驱动 navScrolled: false, // v2 玻璃弹层 @@ -66,11 +77,20 @@ Page({ }, startUpload(filePath: string) { - this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '' }) + this.setData({ + state: 'processing', + imagePath: filePath, + step: 0, + failReason: '', + slowHint: false, + quotaRefunded: false, + }) uploadFile<{ clothes_uuid: string; image_process_status: number }>(filePath) .then((res) => { logger.info(LOG, '衣物上传成功, uuid=', res.clothes_uuid) + // 登记待核对:即使用户立刻退出小程序,回到衣橱时也会补齐最终处理结果 + addPendingClothes(res.clothes_uuid) this.setData({ clothesUuid: res.clothes_uuid }) this.askNextAction() }) @@ -116,19 +136,30 @@ Page({ this.clearTimer() if (e.detail.value === 'again') { logger.debug(LOG, '选择继续上传,重置状态') - this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' }) + this.setData({ + state: 'idle', + imagePath: '', + clothesUuid: '', + step: 0, + failReason: '', + slowHint: false, + quotaRefunded: false, + }) return } this.onDoneCancel() }, pollStatus(uuid: string) { + // 先清旧定时器:onShow 续接时避免叠加成多份轮询 + this.clearTimer() let count = 0 ;(this as any).pollTimer = setInterval(() => { count++ request<{ image_process_status: number; fail_reason: string }>({ url: `/clothes/${uuid}/status`, showError: false, + silentAuth: true, }) .then((res) => { const status = res.image_process_status @@ -139,20 +170,28 @@ Page({ } else if (status === ProcessStatus.SUCCESS) { // 处理成功不再自动跳转,由用户自行从衣橱进入详情 this.clearTimer() + removePendingClothes(uuid) } else if (status === ProcessStatus.FAILED) { + // 仅后端确认失败才进入失败态:该路径额度已由后端自动退回 this.clearTimer() + removePendingClothes(uuid) logger.warn(LOG, 'AI 识别失败, 原因=', res.fail_reason) - this.setData({ state: 'failed', failReason: res.fail_reason || '识别失败' }) + this.setData({ + state: 'failed', + failReason: res.fail_reason || '识别失败', + quotaRefunded: true, + }) } }) .catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code)) - if (count >= 60) { + if (count >= POLL_MAX_COUNT) { + // 只停轮询,不判失败:任务可能仍在队列/执行中,由后端兜底、衣橱页核对 this.clearTimer() - logger.warn(LOG, 'AI 处理超时(90s)') - this.setData({ state: 'failed', failReason: '处理超时,请重试' }) + logger.warn(LOG, '状态轮询预算耗尽,转中性提示, uuid=', uuid) + this.setData({ slowHint: true }) } - }, 1500) + }, POLL_INTERVAL_MS) }, clearTimer() { @@ -163,6 +202,19 @@ Page({ } }, + // 离开页面只停本地轮询:后端处理不中断,最终结果由衣橱页核对补齐 + onHide() { + this.clearTimer() + }, + + // 回到前台:仍在处理中则续接轮询,恢复进度展示 + onShow() { + if (this.data.state === 'processing' && this.data.clothesUuid) { + logger.debug(LOG, 'onShow 续接状态轮询, uuid=', this.data.clothesUuid) + this.pollStatus(this.data.clothesUuid) + } + }, + onUnload() { this.clearTimer() }, @@ -185,7 +237,15 @@ Page({ }, onRetry() { - this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '' }) + this.setData({ + state: 'idle', + imagePath: '', + clothesUuid: '', + failReason: '', + step: 0, + slowHint: false, + quotaRefunded: false, + }) }, onManualEdit() { diff --git a/miniprogram/pages/upload/upload.wxml b/miniprogram/pages/upload/upload.wxml index ccc0e6c..f2490ff 100644 --- a/miniprogram/pages/upload/upload.wxml +++ b/miniprogram/pages/upload/upload.wxml @@ -23,7 +23,8 @@ Processing AI 正在识别你的衣物… - 通常需要 10 秒左右,请稍候 + 处理时间较长,可先返回衣橱,结果会自动更新 + 通常需要 10 秒左右,请稍候 @@ -52,7 +53,7 @@ ! 识别失败 - {{failReason || '未知原因'}} · 本次上传额度已自动退回 + {{failReason || '未知原因'}}{{quotaRefunded ? ' · 本次上传额度已自动退回' : ''}} 重新上传 diff --git a/miniprogram/pages/wardrobe/wardrobe.json b/miniprogram/pages/wardrobe/wardrobe.json index f3a5e2b..8932aeb 100644 --- a/miniprogram/pages/wardrobe/wardrobe.json +++ b/miniprogram/pages/wardrobe/wardrobe.json @@ -2,6 +2,7 @@ "usingComponents": { "navigation-bar": "/components/navigation-bar/navigation-bar", "clothes-card": "/components/clothes-card/clothes-card", - "empty-state": "/components/empty-state/empty-state" + "empty-state": "/components/empty-state/empty-state", + "v2-overlay": "/components/v2-overlay/index" } } diff --git a/miniprogram/pages/wardrobe/wardrobe.ts b/miniprogram/pages/wardrobe/wardrobe.ts index 7094c36..746193d 100644 --- a/miniprogram/pages/wardrobe/wardrobe.ts +++ b/miniprogram/pages/wardrobe/wardrobe.ts @@ -3,6 +3,8 @@ import * as auth from '../../utils/auth' import { PagedResult, ClothesItem, QuotaItem } from '../../types/models' import { QuotaType } from '../../utils/config' import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged' +import { clothesStatusFlag } from '../../utils/clothes-status' +import { resolvePendingClothes } from '../../utils/pending-clothes' import { setTabBarSelected } from '../../utils/tabbar' import { navScroll } from '../../utils/nav' import { logger } from '../../utils/logger' @@ -35,6 +37,14 @@ Page({ hasMore: true, // 滚动收缩:由 utils/nav.ts 的共享 handler 驱动 navScrolled: false, + // 「上传后退出小程序」的失败结果回归告知(v2 玻璃弹窗) + failVisible: false, + failText: '', + failUuid: '', + failButtons: [ + { text: '知道了', value: '' }, + { text: '查看衣物', value: 'ok', type: 'main' }, + ], }, // scroll-view 滚动 → 导航收缩为玻璃小标题 @@ -47,6 +57,8 @@ Page({ redirectToLogin() return } + // 核对「上传后退出小程序」的衣物最终处理结果(pending 为空时零请求) + this.resolvePending() // 首帧渲染未结束就 setData 会触发「Expected updated data but get first rendering data」, // 整个 webview 渲染失败,用户视觉上仍看到上一个页面(从 login switchTab 过来时表现为 // "卡在登录页")。把首次 onShow 的刷新延迟到下一帧,规避首帧冲突。 @@ -96,11 +108,11 @@ Page({ if ((this as any).reqSeq !== seq) return const { list: raw, total } = normalizePaged(res) logger.debug(LOG, '列表加载成功, 分类=', cat || '全部', 'page=', page, '条数=', raw && raw.length) - const list = raw.map((it) => ({ - ...it, - _statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '', - _statusType: 'plain', - })) + // 状态标识收敛到 clothes-status:处理中(玻璃)/ 处理失败(实心)/ 无 + const list = raw.map((it) => { + const flag = clothesStatusFlag(it) + return { ...it, _statusText: flag.text, _statusType: flag.type } + }) const merged = mergePaged(this.data.list, list, page === 1) this.setData({ list: merged, @@ -128,6 +140,49 @@ Page({ .catch((e: any) => logger.warn(LOG, '额度加载失败(静默降级), code=', e && e.code)) }, + // 核对待处理衣物:成功静默、失败弹窗告知;有终结态时重刷列表对齐状态 + resolvePending() { + if ((this as any).resolvingPending) return + ;(this as any).resolvingPending = true + resolvePendingClothes() + .then((res) => { + if (!res.finished.length && !res.failed.length) return + logger.info(LOG, '待核对衣物已终结, 成功=', res.finished.length, '失败=', res.failed.length) + if (res.failed.length) this.showFailModal(res.failed) + this.refresh() + }) + .catch((e: any) => logger.warn(LOG, '待核对衣物解析异常, code=', e && e.code)) + .finally(() => { + ;(this as any).resolvingPending = false + }) + }, + + showFailModal(failed: Array<{ uuid: string; reason: string }>) { + const first = (failed[0] || ({} as { uuid: string; reason: string })) as { + uuid: string + reason: string + } + const content = + failed.length === 1 + ? `1 件衣物识别失败:${first.reason},额度已自动退回` + : `${failed.length} 件衣物识别失败,额度已自动退回,可进入详情查看原因` + this.setData({ failVisible: true, failText: content, failUuid: first.uuid || '' }) + }, + + onFailCancel() { + this.setData({ failVisible: false }) + }, + + onFailConfirm(e: WechatMiniprogram.CustomEvent) { + const uuid = this.data.failUuid + this.setData({ failVisible: false }) + if (e.detail.value !== 'ok' || !uuid) return + wx.navigateTo({ + url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}`, + fail: (err) => logger.warn(LOG, 'navigateTo 衣物详情失败, err=', err && err.errMsg), + }) + }, + onCategoryTap(e: WechatMiniprogram.TouchEvent) { const index = Number(e.currentTarget.dataset.index) if (index === this.data.activeCategory) return diff --git a/miniprogram/pages/wardrobe/wardrobe.wxml b/miniprogram/pages/wardrobe/wardrobe.wxml index bd37ad4..231dcba 100644 --- a/miniprogram/pages/wardrobe/wardrobe.wxml +++ b/miniprogram/pages/wardrobe/wardrobe.wxml @@ -70,4 +70,15 @@ + + + diff --git a/miniprogram/types/models.ts b/miniprogram/types/models.ts index 1e25ef4..1158c86 100644 --- a/miniprogram/types/models.ts +++ b/miniprogram/types/models.ts @@ -35,6 +35,10 @@ export interface ClothesItem { name: string remark: string processed_image: string + // 处理状态:1=抠图中 2=分类中 3=排队中 4=成功 5=失败(老响应可能无该字段,读取处需降级) + image_process_status?: number + // 处理失败原因:仅失败时由属主场景返回 + fail_reason?: string } export interface OutfitItem { diff --git a/miniprogram/utils/clothes-status.ts b/miniprogram/utils/clothes-status.ts new file mode 100644 index 0000000..00e66c0 --- /dev/null +++ b/miniprogram/utils/clothes-status.ts @@ -0,0 +1,53 @@ +// 衣物处理状态语义的单一事实来源。 +// +// 后端 image_process_status:1=抠图中 2=分类中 3=排队中 4=成功 5=失败, +// 列表与详情响应都会带出(老响应无该字段时做降级推断)。 +// 这里把「状态 → 标识文案与样式」的判定收敛到一处,避免同一语义在衣橱、 +// 衣物详情、上传页各写一遍 if-else 而逐渐分叉。 +// +// 零彩色纪律下的映射:需要用户关注的失败 = 实心黑标识(ink);中性处理中 = 玻璃灰标识(plain)。 +import { ProcessStatus } from './config' + +export interface ClothesStatusLike { + image_process_status?: number + category_main?: string + processed_image?: string +} + +export interface ClothesStatusFlag { + text: string + type: '' | 'plain' | 'ink' +} + +// 处理中:排队中 / 抠图中 / 分类中。 +// 老响应无状态字段时沿用旧推断:既无属性也无抠图结果,视为仍在处理。 +export function isProcessing(c: ClothesStatusLike): boolean { + const status = Number(c.image_process_status) + if ( + status === ProcessStatus.QUEUED || + status === ProcessStatus.MATTING || + status === ProcessStatus.CLASSIFYING + ) { + return true + } + if (!status && !c.category_main && !c.processed_image) return true + return false +} + +// 处理失败:后端已确认失败(该路径下额度已自动退回) +export function isProcessFailed(c: ClothesStatusLike): boolean { + return Number(c.image_process_status) === ProcessStatus.FAILED +} + +// 终态:成功或失败,无需继续轮询 +export function isProcessTerminal(c: ClothesStatusLike): boolean { + const status = Number(c.image_process_status) + return status === ProcessStatus.SUCCESS || status === ProcessStatus.FAILED +} + +// 列表标识:处理失败(实心,需用户关注)> 处理中(玻璃,中性)> 无 +export function clothesStatusFlag(c: ClothesStatusLike): ClothesStatusFlag { + if (isProcessFailed(c)) return { text: '处理失败', type: 'ink' } + if (isProcessing(c)) return { text: 'AI 处理中', type: 'plain' } + return { text: '', type: '' } +} diff --git a/miniprogram/utils/config.ts b/miniprogram/utils/config.ts index 10c7c3b..ddce1f3 100644 --- a/miniprogram/utils/config.ts +++ b/miniprogram/utils/config.ts @@ -17,6 +17,9 @@ export const STORAGE_KEYS = { OPEN_PROFILE_EDIT: 'zt_open_profile_edit', // AI 推荐为异步任务:存 task_id,退出页面再进来可继续轮询结果 AI_TASK: 'zt_ai_recommend_task', + // 衣物上传后 AI 处理为异步任务:存待核对的 clothes_uuid 列表, + // 退出小程序再进来时据此补齐「处理成功/失败」的结果告知 + PENDING_CLOTHES: 'zt_pending_clothes', } // 衣物图片处理状态 diff --git a/miniprogram/utils/pending-clothes.ts b/miniprogram/utils/pending-clothes.ts new file mode 100644 index 0000000..255964e --- /dev/null +++ b/miniprogram/utils/pending-clothes.ts @@ -0,0 +1,138 @@ +// 待核对处理结果的衣物(本地持久化 + 结果核对)。 +// +// 衣物上传成功后写入;即使用户退出小程序,下次进入衣橱时也会逐条查询 +// /clothes/:uuid/status,把「处理成功 / 处理失败」的最终结果补告给用户, +// 避免"退出后不知道衣服到底处理成功没有"。 +// +// 处理本身在后端与连接解耦(独立 ctx + 定时恢复调度器),前端只承担 +// 「进入时核对一次」的最小职责:pending 为空时零额外请求,不做常驻轮询。 +import { STORAGE_KEYS, ProcessStatus } from './config' +import { request } from './request' +import { logger } from './logger' + +// 日志模块名 +const LOG = 'pending-clothes' + +// 存储上限:防御性上限,在途任务正常只有 1-2 条 +const MAX_ITEMS = 20 +// 过期时长(秒):超时直接丢弃;后端恢复调度器最迟约 5 分钟接手,24h 足够冗余 +const EXPIRE_SEC = 24 * 60 * 60 +// 衣物不存在(已被用户删除):静默移出,避免残留 +const CLOTHES_NOT_FOUND_CODE = 50000 +// 无权操作该衣物(换账号后残留的 uuid):同样静默移出,避免每次进入都反复请求 +const CLOTHES_NO_PERMISSION_CODE = 50005 + +export interface PendingClothes { + uuid: string + createdAt: number // 秒级时间戳 +} + +export interface PendingResolveResult { + finished: string[] // 本轮核对为处理成功 + failed: Array<{ uuid: string; reason: string }> // 本轮核对为处理失败(含原因) + processing: string[] // 仍在处理中,保留待下次核对 +} + +function writeAll(list: PendingClothes[]): void { + try { + wx.setStorageSync(STORAGE_KEYS.PENDING_CLOTHES, list) + } catch (e) { + logger.warn(LOG, '待核对衣物写入失败(降级为仅本次会话生效)') + } +} + +// 读取并清洗存储:非法结构 / 过期记录一并剔除;读取即顺手落盘 +export function getPendingClothes(): PendingClothes[] { + let raw: any = null + try { + raw = wx.getStorageSync(STORAGE_KEYS.PENDING_CLOTHES) + } catch (e) { + raw = null + } + if (!Array.isArray(raw)) return [] + + const now = Math.floor(Date.now() / 1000) + const valid = (raw as any[]) + .filter((it) => it && typeof it.uuid === 'string' && !!it.uuid) + .filter((it) => Number(it.createdAt) > 0 && now - Number(it.createdAt) <= EXPIRE_SEC) + .slice(-MAX_ITEMS) as PendingClothes[] + + if (valid.length !== raw.length) writeAll(valid) + return valid +} + +// 登记待核对衣物(按 uuid 幂等去重) +export function addPendingClothes(uuid: string): void { + if (!uuid) return + const list = getPendingClothes().filter((it) => it.uuid !== uuid) + list.push({ uuid, createdAt: Math.floor(Date.now() / 1000) }) + const next = list.slice(-MAX_ITEMS) + writeAll(next) + logger.debug(LOG, '待核对衣物已登记, uuid=', uuid, '总数=', next.length) +} + +export function removePendingClothes(uuid: string): void { + if (!uuid) return + const list = getPendingClothes() + const next = list.filter((it) => it.uuid !== uuid) + if (next.length === list.length) return + writeAll(next) + logger.debug(LOG, '待核对衣物已移出, uuid=', uuid, '剩余=', next.length) +} + +/** + * 逐条核对处理状态: + * - 成功 / 失败:移出存储并计入结果(失败带原因); + * - 衣物已删除:静默移出; + * - 仍在处理中 / 网络失败 / 未登录:保留待下次核对。 + * 后台核对不弹错、不触发登录跳转(showError:false + silentAuth:true)。 + */ +export function resolvePendingClothes(): Promise { + const list = getPendingClothes() + const result: PendingResolveResult = { finished: [], failed: [], processing: [] } + if (!list.length) return Promise.resolve(result) + + const tasks = list.map((item) => + request<{ image_process_status: number; fail_reason?: string }>({ + url: `/clothes/${item.uuid}/status`, + showError: false, + silentAuth: true, + }) + .then((res) => { + const status = Number(res && res.image_process_status) + if (status === ProcessStatus.SUCCESS) { + removePendingClothes(item.uuid) + result.finished.push(item.uuid) + return + } + if (status === ProcessStatus.FAILED) { + removePendingClothes(item.uuid) + result.failed.push({ uuid: item.uuid, reason: (res && res.fail_reason) || '识别失败' }) + return + } + result.processing.push(item.uuid) + }) + .catch((e: any) => { + // 衣物已被删除 / 已不属于当前账号:静默移出,避免每次进入都反复请求 + if (e && (e.code === CLOTHES_NOT_FOUND_CODE || e.code === CLOTHES_NO_PERMISSION_CODE)) { + removePendingClothes(item.uuid) + return + } + logger.warn(LOG, '核对衣物处理状态失败, uuid=', item.uuid, 'code=', e && e.code) + result.processing.push(item.uuid) + }), + ) + + return Promise.all(tasks).then(() => { + logger.info( + LOG, + '待核对衣物解析完成, 成功=', + result.finished.length, + '失败=', + result.failed.length, + '处理中=', + result.processing.length, + ) + return result + }) +}