feat: 支持退出后补告衣物处理结果
This commit is contained in:
@@ -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<ClothesItem>({ url })
|
||||
.then((detail) => {
|
||||
logger.debug(LOG, '详情加载成功, name=', detail && detail.name)
|
||||
this.setData({ detail, loading: false })
|
||||
const patch: Record<string, any> = { 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)
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ module.exports = { hex: hex, tags: tags }
|
||||
<view class="head">
|
||||
<view class="eyebrow">Processing</view>
|
||||
<view class="display">AI 正在识别你的衣物…</view>
|
||||
<view class="cd-note">通常需要 10 秒左右,请稍候</view>
|
||||
<view class="cd-note">通常需要 10 秒左右;处理较慢时可先返回,稍后再来查看</view>
|
||||
</view>
|
||||
<view class="up-preview">
|
||||
<view class="ph icon icon-hanger"></view>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
<view class="head">
|
||||
<view class="eyebrow">Processing</view>
|
||||
<view class="display">AI 正在识别你的衣物…</view>
|
||||
<view class="up-note-left">通常需要 10 秒左右,请稍候</view>
|
||||
<view wx:if="{{slowHint}}" class="up-note-left">处理时间较长,可先返回衣橱,结果会自动更新</view>
|
||||
<view wx:else class="up-note-left">通常需要 10 秒左右,请稍候</view>
|
||||
</view>
|
||||
|
||||
<view class="up-preview">
|
||||
@@ -52,7 +53,7 @@
|
||||
<view class="up-bang">!</view>
|
||||
<view class="head">
|
||||
<view class="display">识别失败</view>
|
||||
<view class="up-note-left">{{failReason || '未知原因'}} · 本次上传额度已自动退回</view>
|
||||
<view class="up-note-left">{{failReason || '未知原因'}}{{quotaRefunded ? ' · 本次上传额度已自动退回' : ''}}</view>
|
||||
</view>
|
||||
<view class="up-duo">
|
||||
<view class="btn btn--ghost" bindtap="onRetry">重新上传</view>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ClothesItem>(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
|
||||
|
||||
@@ -70,4 +70,15 @@
|
||||
<view class="fab" bindtap="onUpload">
|
||||
<view class="icon icon-plus"></view>
|
||||
</view>
|
||||
|
||||
<!-- 处理失败提示:「上传后退出小程序」的失败结果回归告知 -->
|
||||
<v2-overlay
|
||||
visible="{{failVisible}}"
|
||||
type="modal"
|
||||
title="AI 识别失败"
|
||||
content="{{failText}}"
|
||||
buttons="{{failButtons}}"
|
||||
bind:confirm="onFailConfirm"
|
||||
bind:cancel="onFailCancel"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user