feat: 支持退出后补告衣物处理结果

This commit is contained in:
2026-09-16 02:01:26 +08:00
parent a9f2659c42
commit 7be561500b
12 changed files with 539 additions and 22 deletions
+53
View File
@@ -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: '' }
}
+3
View File
@@ -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',
}
// 衣物图片处理状态
+138
View File
@@ -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<PendingResolveResult> {
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
})
}