diff --git a/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets b/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets index 50b7e23..4e19173 100644 --- a/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets +++ b/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets @@ -1,21 +1,22 @@ /** - * TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04)。 + * TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04,T2b 闭环)。 * * 供弹层/区块复用:ProfileContent 入口卡、ChatPage 耗尽面板、试穿/发型软限制 * 耗尽面板都以 bindSheet 承载本组件。 * * 纪律: - * - 任务表本地静态(icon/名称/说明),奖励数值一律不硬编码——展示文案统一 - * 「领额度」,实际到账以 claim 响应 amount 为准; + * - 任务表本地静态(产品方案 §3.2 定稿 4 项,invite_friend/profile_complete 已 + * 下架);desc 里的奖励数值是定稿展示文案,实际到账仍以 claim 响应 amount 为准; * - 文案纪律(FR-BILL-07):不出现价格/购买/会员/升级字样; * - 已领判定走 core/TaskBonusState(preferences meida_task_bonus 的共享读写, - * 与报告页反馈自动 claim 互通),跨重启保留;claim 返回 40910(本周期已领) - * 时同步标记——本地状态永远以服务端口径为准; - * - 本迭代只接 sign_in 的真实 claim 链路;其余 4 项为「去完成」轻引导 toast, - * 完成判定属后续迭代。 + * 与报告页反馈/分享/首测的自动 claim 互通),跨重启保留;claim 返回 40910 + * (本周期已领)时同步标记——本地状态永远以服务端口径为准; + * - 「去完成」真实跳转:first_report → 速测落地页;report_feedback / share_report + * → 报告链路页(无报告时给引导 toast,不报错);sign_in 点击即领。 + * first_report 在服务端已有完成报告且未领过时直接变为「领额度」走 claim。 */ -import { promptAction } from '@kit.ArkUI' +import { promptAction, router } from '@kit.ArkUI' import { ApiClient } from '../../core/ApiClient' import { ApiError } from '../../core/ApiTypes' import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../../core/TaskBonusState' @@ -37,7 +38,17 @@ interface ClaimWire { remaining: number } -/** 任务定义。direct=true 表示点击即调 claim;false 为「去完成」轻引导。 */ +/** GET /api/v1/reports 列表元素(本面板只消费这两个字段)。 */ +interface ReportListItemWire { + report_id: string + status: string +} + +/** + * 任务定义。direct=true 表示点击即调 claim(签到); + * false 为「去完成」跳转(first_report 在服务端已有完成报告时升格为可领)。 + * successToast 是领取成功的 toast 前缀,amount 取响应值拼接。 + */ interface TaskDef { key: string icon: string @@ -45,36 +56,37 @@ interface TaskDef { desc: string period: string // day / lifetime direct: boolean - guide: string // direct=false 时的引导 toast 文案 + successToast: string } -/** 五项任务(task_key 与后端 TASK_BONUS_RULES_JSON 对齐)。 */ -const TASKS: TaskDef[] = [ +/** 每日任务(task_key 与后端 TASK_BONUS_RULES_JSON 定稿目录对齐)。 */ +const DAILY_TASKS: TaskDef[] = [ { - key: 'sign_in', icon: '✓', name: '每日签到', desc: '每天来一次,领 AI 对话额度', - period: PERIOD_DAY, direct: true, guide: '' + key: 'sign_in', icon: '✓', name: '每日签到', desc: '每日 · 领 10 轮 AI 对话额度', + period: PERIOD_DAY, direct: true, successToast: '签到成功' }, { - key: 'profile_complete', icon: '☺', name: '完善画像资料', desc: '补全资料,建议更合身', - period: PERIOD_LIFETIME, direct: false, guide: '完善资料功能即将上线' + key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '每日 · 领 2 次发型试戴额度', + period: PERIOD_DAY, direct: false, successToast: '感谢反馈' }, { - key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '告诉我们要不要调整', - period: PERIOD_DAY, direct: false, guide: '在报告页给结论打反馈可领额度' - }, - { - key: 'share_report', icon: '↗', name: '分享我的报告', desc: '把形象报告分享给朋友', - period: PERIOD_DAY, direct: false, guide: '生成报告后可分享领额度' - }, - { - key: 'invite_friend', icon: '♡', name: '邀请好友', desc: '邀请朋友一起用美搭', - period: PERIOD_DAY, direct: false, guide: '邀请功能即将上线' + key: 'share_report', icon: '↗', name: '分享我的报告', desc: '每日 · 领 3 次换装额度', + period: PERIOD_DAY, direct: false, successToast: '感谢分享' } ] -/** 本周期已领 / 奖励封顶 / 未知任务(与后端 errors.py 对齐)。 */ +/** 新手任务(一次性)。 */ +const NEWBIE_TASKS: TaskDef[] = [ + { + key: 'first_report', icon: '★', name: '完成首次形象速测', desc: '一次性 · 领 1 次报告重生成额度', + period: PERIOD_LIFETIME, direct: false, successToast: '形象速测完成' + } +] + +/** 本周期已领 / 奖励封顶 / 任务未完成 / 未知任务(与后端 errors.py 对齐)。 */ const CODE_ALREADY_CLAIMED: number = 40910 const CODE_BONUS_CAPPED: number = 40310 +const CODE_NOT_ELIGIBLE: number = 40311 @Component export struct TaskBonusPanel { @@ -83,15 +95,51 @@ export struct TaskBonusPanel { @State claimingKey: string = '' // 领取中的 task_key(防空点) @State claimsTick: number = 0 // claims 变更计数:驱动已领态重渲染 + @State firstReportReady: boolean = false // 服务端已有完成报告:first_report 可直接领 + @State latestReportId: string = '' // 报告链路跳转用(优先最新一份已完成报告) aboutToAppear(): void { this.hydrate() } - /** 已领态由 TaskBonusState 共享(报告页反馈自动 claim 也要互通),这里只 hydrate + 触发重渲染。 */ + /** 已领态由 TaskBonusState 共享(报告页自动 claim 也要互通):hydrate + 曝光埋点 + 报告态探测。 */ private async hydrate(): Promise { await TaskBonusState.init() this.claimsTick += 1 + Tracker.track('task_bonus.panel_view') + this.refreshReportState() + } + + /** 拉我的报告列表,定 first_report 可领态与报告链路跳转目标;失败静默(点击时再实时拉)。 */ + private async refreshReportState(): Promise { + try { + const list = await ApiClient.get('/api/v1/reports') + const succeeded: ReportListItemWire | undefined = + list.find((r: ReportListItemWire) => r.status === 'succeeded') + this.firstReportReady = succeeded !== undefined + this.latestReportId = succeeded !== undefined + ? succeeded.report_id + : (list.length > 0 ? list[0].report_id : '') + } catch (_e) { + // 网络失败不打扰:面板其余功能照常,「去完成」点击时再拉一次 + } + } + + /** 实时取最新报告 id(跳转前兜底再拉一次,hydrate 失败也能补救);无报告返回 ''。 */ + private async fetchLatestReportId(): Promise { + try { + const list = await ApiClient.get('/api/v1/reports') + const succeeded: ReportListItemWire | undefined = + list.find((r: ReportListItemWire) => r.status === 'succeeded') + const rid: string = succeeded !== undefined + ? succeeded.report_id + : (list.length > 0 ? list[0].report_id : '') + this.latestReportId = rid + this.firstReportReady = succeeded !== undefined + return rid + } catch (_e) { + return '' + } } private isClaimed(task: TaskDef): boolean { @@ -115,12 +163,36 @@ export struct TaskBonusPanel { if (this.isClaimed(task) || this.claimingKey !== '') { return } - if (!task.direct) { - // 「去完成」轻引导:本迭代不接真实完成判定 - this.toast(task.guide) + Tracker.track('task_bonus.task_tap', { 'task_key': task.key }) + if (task.direct) { + this.claim(task) return } - this.claim(task) + if (task.key === 'first_report') { + if (this.firstReportReady) { + // 服务端已有完成报告且未领过:直接领(服务端还会再校验一次 rp_report) + this.claim(task) + } else { + router.pushUrl({ url: 'pages/DiagnosisLandingPage' }) + } + return + } + // report_feedback / share_report:跳报告链路页(反馈在报告结果页,分享经 + // 报告结果页导出 PDF 后到预览页;ReportPdfPage 需要本地 pdfPath 无法直达) + this.goReportChain() + } + + private async goReportChain(): Promise { + let rid: string = this.latestReportId + if (rid === '') { + rid = await this.fetchLatestReportId() + } + if (rid === '') { + this.toast('先生成你的形象速测,再来反馈') + return + } + const params: Record = { 'reportId': rid } + router.pushUrl({ url: 'pages/ReportResultPage', params: params }) } private async claim(task: TaskDef): Promise { @@ -129,7 +201,7 @@ export struct TaskBonusPanel { const req: Record = {} const d = await ApiClient.post(`/api/v1/quota/tasks/${task.key}/claim`, req) await this.markClaimed(task) - this.toast(`签到成功,额度 +${d.amount}`) + this.toast(`${task.successToast},额度 +${d.amount}`) Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': true }) this.onClaimed(d.quota_key) } catch (e) { @@ -139,7 +211,9 @@ export struct TaskBonusPanel { await this.markClaimed(task) this.toast('今天已经领过啦') } else if (err.code === CODE_BONUS_CAPPED) { - this.toast('今日任务奖励已达上限') + this.toast('今日任务奖励已达上限,明天再来') + } else if (err.code === CODE_NOT_ELIGIBLE) { + this.toast('先完成任务,再来领奖励') } else { this.toast(err.message) } @@ -157,7 +231,10 @@ export struct TaskBonusPanel { if (this.claimingKey === task.key) { return '领取中…' } - return task.direct ? '领额度' : '去完成' + if (task.direct || (task.key === 'first_report' && this.firstReportReady)) { + return '领额度' + } + return '去完成' } @Builder @@ -197,6 +274,15 @@ export struct TaskBonusPanel { .alignItems(VerticalAlign.Center) } + @Builder + GroupHeader(title: string) { + Text(title) + .fontSize(13) + .fontColor(COLOR_TEXT_SECONDARY) + .width('100%') + .margin({ top: 8, bottom: 2 }) + } + build() { Column({ space: 4 }) { Text('做任务换额度') @@ -205,7 +291,12 @@ export struct TaskBonusPanel { .fontColor(COLOR_TEXT_PRIMARY) .width('100%') .margin({ bottom: 4 }) - ForEach(TASKS, (task: TaskDef) => { + this.GroupHeader('每日任务') + ForEach(DAILY_TASKS, (task: TaskDef) => { + this.TaskRow(task) + }, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`) + this.GroupHeader('新手任务') + ForEach(NEWBIE_TASKS, (task: TaskDef) => { this.TaskRow(task) }, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`) Text('奖励以实际到账为准,额度每日重置') diff --git a/app/entry/src/main/ets/pages/ChatPage.ets b/app/entry/src/main/ets/pages/ChatPage.ets index fb52907..2b398e7 100644 --- a/app/entry/src/main/ets/pages/ChatPage.ets +++ b/app/entry/src/main/ets/pages/ChatPage.ets @@ -33,7 +33,7 @@ interface QuotaWireResp { quota_key: string used: number limit_value: number - bonus_granted: boolean + bonus_granted: number remaining: number reset_at: string } diff --git a/app/entry/src/main/ets/pages/ReportPdfPage.ets b/app/entry/src/main/ets/pages/ReportPdfPage.ets index e02464c..684b62b 100644 --- a/app/entry/src/main/ets/pages/ReportPdfPage.ets +++ b/app/entry/src/main/ets/pages/ReportPdfPage.ets @@ -20,11 +20,27 @@ import { common } from '@kit.AbilityKit' import { picker, fileUri } from '@kit.CoreFileKit' import fs from '@ohos.file.fs' import { ContextHolder } from '../core/ContextHolder' +import { ApiClient } from '../core/ApiClient' +import { ApiError } from '../core/ApiTypes' +import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState' +import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' const PAGE_BG: string = '#F5F6F8' const TEXT_PRIMARY: string = '#222222' +/** POST /quota/tasks/share_report/claim 响应(share_report 任务闭环,T2b)。 */ +interface ClaimWire { + task_key: string + quota_key: string + amount: number + bonus_granted: number + remaining: number +} + +/** 本周期已领(与后端 errors.py 对齐)。 */ +const CODE_ALREADY_CLAIMED: number = 40910 + interface PdfRouteParams { pdfPath?: string title?: string @@ -100,13 +116,35 @@ struct ReportPdfPage { previewMode: systemShare.SharePreviewMode.DEFAULT } await controller.show(ContextHolder.get() as common.UIAbilityContext, opts) - // 面板正常收起(含用户主动取消):不打扰,不提示 + // 面板正常收起(含用户主动取消):不打扰,不提示。 + // share_report 任务闭环(T2b):调起成功即视为完成(端侧自报,内测水位, + // 产品方案 §3.2 决策理由 4);埋点 + 静默 claim 互不阻断。 + Tracker.track('report.share_invoke') + this.claimShareBonus() } catch (e) { console.error(`PDF share failed: ${(e as Error).message}`) this.setActionText('分享失败,请重试') } } + /** + * share_report 任务真实闭环:奖励到账提示;已领(40910)静默但同步已领态, + * 其他失败静默——分享动作本身已完成,不拿奖励失败打扰用户。 + */ + private async claimShareBonus(): Promise { + try { + const req: Record = {} + const d = await ApiClient.post('/api/v1/quota/tasks/share_report/claim', req) + await TaskBonusState.markClaimed('share_report', PERIOD_DAY) + this.setActionText(`感谢分享,额度 +${d.amount}`) + } catch (e) { + if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) { + // 今日已领:静默不打扰,但同步已领态(任务面板显示一致) + await TaskBonusState.markClaimed('share_report', PERIOD_DAY) + } + } + } + private async saveAs(): Promise { try { const docPicker = new picker.DocumentViewPicker(ContextHolder.get()) diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index 1e9284c..1314a7c 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -23,7 +23,7 @@ import { ApiClient } from '../core/ApiClient' import { ApiError, isAuthError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' import { ContextHolder } from '../core/ContextHolder' -import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState' +import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../core/TaskBonusState' import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import request from '@ohos.request' @@ -52,7 +52,7 @@ interface QuotaWireResp { quota_key: string used: number limit_value: number - bonus_granted: boolean + bonus_granted: number remaining: number reset_at: string } @@ -189,6 +189,9 @@ struct ReportResultPage { if (this.statusRaw === 'SUCCEEDED') { // 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21) this.loadFeedback() + // first_report 任务闭环(T2b):报告成功展示的入口路径上静默 claim + // (lifetime 周期,后端幂等;领过则 40910 静默同步已领态) + this.claimFirstReport() // 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次 if (!this.cardViewTracked) { this.cardViewTracked = true @@ -447,6 +450,25 @@ struct ReportResultPage { } } + /** + * first_report 任务真实闭环(T2b):报告成功展示即完成首次速测,静默 claim; + * 已领(40910,含 lifetime 重复进页)静默同步已领态,其他失败静默—— + * 报告展示是主流程,奖励失败不打扰。 + */ + private async claimFirstReport(): Promise { + try { + const req: Record = {} + const d = await ApiClient.post('/api/v1/quota/tasks/first_report/claim', req) + await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME) + this.toast(`形象速测完成,额度 +${d.amount}`) + } catch (e) { + if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) { + await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME) + } + // 其他失败静默 + } + } + // ==================== 全面报告预约留资卡(T-P3-22) ==================== /** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */ diff --git a/docs/开发任务包/T2b-任务换额度-端侧闭环.md b/docs/开发任务包/T2b-任务换额度-端侧闭环.md new file mode 100644 index 0000000..86000b7 --- /dev/null +++ b/docs/开发任务包/T2b-任务换额度-端侧闭环.md @@ -0,0 +1,193 @@ +# T2b · 任务换额度:端侧闭环与完成判定 + +> 对应任务:`T-P1-26` 收尾件之二(T2 的后续迭代)| 需求:`FR-BILL-03`、`FR-BILL-04`、`FR-BILL-07` | 跨端任务:后端(meida_backend)+ 端侧(app/) +> 先读《README.md(协作规程)》与《README-FE批次.md》,遵守全部纪律。 +> 产品定稿依据:产品方案 §3.2「任务奖励额度的获取方式」(2026-09-11 定稿表与决策理由)。 + +## 1. 目标 + +把「做任务换额度」从「只有签到能真领」补齐为完整闭环: + +1. 任务目录按产品定稿落地(4 项上线 + `invite_friend` 下架),奖励数值由占位值 1 改为定稿值; +2. 有服务端可信信号的任务(`report_feedback` / `first_report`)在 claim 时做完成校验,无凭据拒绝; +3. 端侧面板从「toast 占位」升级为真实「去完成」跳转 + 完成点自动 claim; +4. 全程遵守 `FR-BILL-07`:文案不出现价格/购买/会员/升级字样。 + +## 2. 现状事实(2026-09-11 读码核实) + +### 后端(meida_backend) + +- ✅ `POST /quota/tasks/{task_key}/claim` 已实现(T2):幂等(`bl_ledger.idempotency_key` 快捷路径 + 唯一约束并发兜底)、封顶(`grant_bonus` 对 `task_bonus_max`)、配置化(`TASK_BONUS_RULES_JSON`)、错误码 40410/40910/40310 文案合规。见 `apps/api/routers/quota.py:65`、`packages/billing/service.py:306`、`packages/domain/billing/quota.py:146`。 +- ✅ 配额种子与产品方案 §3.2 对齐(`migrations/versions/0003_align_quota_with_product_ssot.py`):`agent_chat` 10/10/40、`tryon` 3/3/8、`hairstyle` 3/2/6、`hd_export` 1/0/3、`report_basic` 终身 1、`report_regen` 月 1/1/2。 +- ❌ **claim 不做任何完成凭据校验**:任何登录用户对任何 task_key 直接 POST 即领。`report_feedback`(`rp_feedback` 表)与 `first_report`(`rp_report` 表)明明有可信信号却没用上。 +- ❌ `task_bonus_rules` 默认值(`packages/shared/config.py:136`)amount 全是占位值 1,且映射与 §3.2 总量对不上:换装最多 +2/日(表定 +3)、发型 0(表定 +2)、对话 +2(表定 +10)。 +- ❌ 无任务目录下发接口——V1 决定端侧静态表,不做。 +- 事实备注:`rp_share` 表已建结构但无路由(models.py 注释「V1 只建结构」);`POST /events/batch` 无事件白名单(事件字典待 TD-23)。 + +### 端侧(app/entry/src/main/ets/) + +- ✅ `components/f1/TaskBonusPanel.ets`:五项静态任务、已领态走 `core/TaskBonusState.ets`(preferences `meida_task_bonus`,跨重启)、40910 同步已领态、文案合规;已挂载 5 处(`ProfileContent` / `ChatPage` / `FittingRoomPage` / `HairDesignPage` / `HairColorPage`)。 +- ✅ 真实闭环已有两条:`sign_in` 点击即 claim(面板内);`report_feedback` 在报告页反馈提交成功后静默自动 claim(`ReportResultPage.ets:435`)。 +- ✅ `DailyLimiter.setBonus` 已把 `bonus_granted` 计入试穿/试戴当日上限;`ChatPage` 对话额度服务端口径(remaining 含 bonus)。 +- ❌ 其余 3 项任务是 toast 占位(「即将上线」),无「去完成」跳转。 +- ❌ `share_report` 无联动:`ReportPdfPage.ets:90` 已有 Share Kit `systemShare` 分享 PDF,但分享动作与 claim 之间没有凭据、没有联动。 +- ❌ 报告生成成功处无 `first_report` claim 联动。 +- ❌ 面板不展示每项的奖励数值与周期(任务表纪律是"数值不硬编码",定稿后可写入展示文案,到账仍以 claim 响应为准)。 +- 🐞 `ChatPage.ets:36` `QuotaWireResp.bonus_granted` 类型误写为 `boolean`(后端返回 number;该字段暂未消费,本迭代顺手修正)。 + +### 判定与埋点 + +- ✅ 埋点链路通:`Tracker` → `POST /events/batch` → `ob_event`(整批 fail-close 校验)。 +- ❌ 任务完成事件目前只有 `task_bonus.claim_result`;无面板曝光、任务点击、分享调起事件。 +- 产品决策(已写进 §3.2):**埋点只作观测,不作发放凭据**——埋点本身可伪造且有 20 条/30s 节流延迟,拿它做校验是自欺。 + +## 3. V1 任务目录(产品定稿,2026-09-11) + +| task_key | 名称 | quota_key | amount | claim_period | 完成判定 | 状态 | +|----------|------|-----------|--------|--------------|---------|------| +| `sign_in` | 每日签到 | `agent_chat` | **10** | `day` | 点击签到即完成,无额外校验 | 上 | +| `report_feedback` | 给报告结论打反馈 | `hairstyle` | **2** | `day` | 服务端校验当日 `rp_feedback` 存在 | 上 | +| `share_report` | 分享我的报告 | `tryon` | **3** | `day` | 端侧自报,无服务端校验(内测水位) | 上 | +| `first_report` | 完成首次形象速测 | `report_regen` | **1** | `lifetime` | 服务端校验 `rp_report` 存在已完成报告 | 上 | +| `invite_friend` | 邀请好友 | — | — | — | 无可信信号 | **下架**(env 不下发该键) | + +变更点:删除 `profile_complete`(资料维护 `FR-ACC-06` V1 未实现,无可信信号),新增 `first_report`;`report_feedback` 由 `report_regen` 改挂 `hairstyle`(理由见产品方案 §3.2 决策理由第 3 条)。 + +## 4. 实现要点 + +### 后端(worktree 纪律见《README.md》) + +允许改动:`packages/billing/service.py`、`packages/billing/repo.py`(如需)、`packages/billing/schemas.py`(如需)、测试新文件。 + +1. **B1 · 定稿数值走 env**:部署/本地 `.env` 的 `TASK_BONUS_RULES_JSON` 改为: + ```json + { + "sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"}, + "report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"}, + "share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"}, + "first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"} + } + ``` + 不含 `invite_friend`——不下发即下架(claim 返回 40410「任务不存在或暂未开放」),这是配置化设计的红利,不要为它写分支代码。 + ⚠️ `config.py` 的默认值是占位且**禁改**;在提交信息里写明「默认值与定稿不一致,建议协调提交同步默认值与 `.env.example`」,由协调者统一处理。 +2. **B2 · claim 完成校验钩子**:`BillingService.claim_task_bonus` 在解析规则后、幂等快捷路径**之前**,按 task_key 执行完成校验(映射写死在 service 内即可,不做配置化): + - `report_feedback`:`rp_feedback` 存在 `user_id` 匹配且 `created_at` 落在当日(UTC+8,与 `period_key` 同时区口径)的行; + - `first_report`:`rp_report` 存在 `user_id` 匹配且状态为已完成的行; + - `sign_in` / `share_report`:**不校验**,写注释说明产品决策(签到行为本身即任务;分享内测期自报,`hard_cap` 兜底,见 §3.2 决策理由 4)。 + - 校验不过 → `AppError(ErrorCode.TASK_BONUS_NOT_ELIGIBLE)`。**该错误码尚不存在**(建议 40311,meta「先完成任务,再来领奖励」)——`errors.py` 禁改,在提交信息中列出需求,由协调提交统一加;本地开发可先在 service 里 `raise AppError(...)` 引用占位并在提交信息标注。 +3. **B3 · 幂等与封顶语义不变**:40910/40310 路径、`delta=0` 流水口径不动;校验逻辑不得影响既有测试。 + +### 端侧(纯 Windows,构建门禁见《README-FE批次.md》§1) + +允许改动:`components/f1/TaskBonusPanel.ets`、`pages/ReportResultPage.ets`、`pages/ReportPdfPage.ets`、`pages/ChatPage.ets`(仅 B 端 wire 类型修正)。其余页面如需挂回调点,在提交信息里说明。 + +1. **E1 · 面板任务表更新为定稿 4 项**:删 `invite_friend`;`profile_complete` 替换为 `first_report`(名称「完成首次形象速测」);每项 desc 写清奖励内容(如「领 10 轮 AI 对话额度」「领 2 次发型试戴额度」)并标注周期(「每日」/「一次性」)。到账数值仍以 claim 响应 `amount` 为准,desc 只是展示。 +2. **E2 · 「去完成」真实跳转**(`router.pushUrl`,禁止 import 页面): + - `first_report` → `pages/DiagnosisLandingPage`;进入面板时若已可领(服务端已有报告且未领过)则按钮直接为「领额度」走 claim; + - `report_feedback` → 报告结果页(带 `reportId` 参数);无报告时 toast「先生成你的形象速测,再来反馈」; + - `share_report` → `pages/ReportPdfPage`(分享入口所在页);无报告时同上引导; + - `sign_in` 保持点击即领。 +3. **E3 · 完成点自动 claim**(与既有 `claimReportFeedback` 同款静默闭环:成功 toast 到账、40910 静默同步已领态、其他失败静默): + - `ReportPdfPage.share()` 调起系统分享面板成功后:静默 claim `share_report` + `TaskBonusState.markClaimed('share_report', PERIOD_DAY)` + `Tracker.track('report.share_invoke', ...)`; + - 报告生成任务成功(`ReportResultPage` 展示 SUCCEEDED 结果的入口路径):静默 claim `first_report` + `markClaimed`。 +4. **E4 · 状态与文案**:见 §5,逐字遵守。 +5. **E5 · 埋点补齐**(事件名字面量、props 只放低维枚举/数值):`task_bonus.panel_view`、`task_bonus.task_tap`(props: `task_key`)、`report.share_invoke`、`task_bonus.claim_result`(已有,保留)。 +6. **E6 · 修正** `ChatPage.ets:36` `bonus_granted: boolean` → `number`。 + +## 5. UX 与文案规格(FR-BILL-07,逐字遵守) + +**面板信息架构**:标题「做任务换额度」;分组一「每日任务」(签到 / 打反馈 / 分享报告);分组二「新手任务」(完成首次形象速测);底部说明保留「奖励以实际到账为准,额度每日重置」。 + +**按钮五态**: + +| 状态 | 文案 | +|------|------| +| 可领取(签到 / 已满足条件) | 「领额度」 | +| 需跳转完成 | 「去完成」 | +| 领取中 | 「领取中…」 | +| 已领(日周期) | 「今日已领」 | +| 已领(终身) | 「已领取」 | + +**toast**: + +| 场景 | 文案 | +|------|------| +| 领取成功 | 「签到成功,额度 +{amount}」/「感谢反馈,额度 +{amount}」(amount 取响应值,不硬编码) | +| 40910 本周期已领 | 「今天已经领过啦」 | +| 40310 奖励封顶 | 「今日任务奖励已达上限,明天再来」 | +| 40311 未完成 | 「先完成任务,再来领奖励」 | +| 无报告时去做反馈/分享 | 「先生成你的形象速测,再来反馈」 | + +**红线**:全部新增文案不得出现价格、购买、会员、升级、充值等字样(`FR-BILL-07`);额度耗尽入口文案维持「今日额度已用完,明天再来,或做个任务多换几次」口径。 + +## 6. 测试要求 + +### 后端 + +- 单测:校验钩子纯逻辑(若抽纯函数)覆盖有/无凭据两分支。 +- 集成测试新文件 `tests/integration/test_task_bonus_eligibility.py`(允许跑,`MEIDA_RUN_INTEGRATION=1`): + 1. 无报告用户 claim `first_report` → 40311;创建报告完成后 → 200 且 `bonus_granted`=1; + 2. 无反馈 claim `report_feedback` → 40311;提交一条反馈后 → 200 且发型 `bonus_granted`=2; + 3. `sign_in` claim → 200,`agent_chat` `bonus_granted`=10;当日再 claim → 40910; + 4. `invite_friend`(不在 env)→ 40410; + 5. 40311 与 40310 路径均**不写流水**(查 `bl_ledger` 无新行)。 +- 提交前全绿:`ruff check` / `ruff format --check` / `mypy`(范围同 T2)/ `lint-imports` / `pytest -q`。 + +### 端侧 + +- 构建门禁(唯一验收命令,见《README-FE批次.md》§1)`BUILD SUCCESSFUL`; +- ArkTS 严格模式铁律(README-FE批次 §3)逐条遵守:显式泛型、具名 wire interface、throw 只抛 ApiError。 + +## 7. 禁改清单(通用禁改之外) + +- 后端:`packages/shared/config.py`、`packages/shared/errors.py`、`packages/shared/models.py`、`migrations/**`、`apps/api/routers/events.py`、`packages/analytics/**`。新错误码与默认值同步**只写进提交信息**,由协调者处理。 +- 端侧:`core/**`(含 `TaskBonusState.ets`、`Tracker.ets`、`DailyLimiter.ets`——本迭代需求已够,不要顺手扩展)、`module.json5`、`main_pages.json`、resources、`oh-package*.json5`。 +- 不改配额种子数值、不动 `grant_bonus` 语义、不改既有测试断言语义。 + +## 8. 验收标准(按组,每条可验收) + +### 后端 + +1. 四个 task_key 按 §3 定稿数值 claim 成功,响应 `amount` 与定稿一致;`invite_friend` 返回 40410。 +2. 无凭据 claim `report_feedback` / `first_report` 返回 40311 且无流水;有凭据后 claim 成功。 +3. 重复 claim 40910、封顶 40310 行为与 T2 验收一致,无回归(既有 `test_task_bonus.py` 全绿)。 +4. 后端检查全绿(§6)。 + +### 端侧 + +5. 面板只展示 4 项定稿任务,奖励文案、周期标注、五态按钮与 §5 一致。 +6. 三项「去完成」跳转可达目标页;无报告场景给引导 toast 而非报错。 +7. 分享调起成功、报告生成成功两处自动 claim 生效,面板已领态同步(含杀进程重启后)。 +8. `ChatPage` wire 类型修正后构建绿;埋点新增事件在 `ob_event` 可见。 + +### 联调 + +9. 全链路走查(真机或模拟器连本地后端):签到 → 对话额度卡 +10;报告页打反馈 → 发型当日上限 +2(`DailyLimiter` 生效);PDF 页分享 → 换装当日上限 +3;完成首次速测 → 重生成额度 +1(个人中心额度卡可见)。 +10. 异常路径走查:同日重复领取 →「今天已经领过啦」;未打反馈直接在面板点领取(若 UI 可达)→「先完成任务,再来领奖励」。 +11. 文案红线检查:任务面板、toast、额度耗尽提示全文检索无「价格/购买/会员/升级/充值/¥」字样。 + +## 9. 提交信息 + +后端: + +``` +feat(billing): task bonus final rules + eligibility check (T2b) + +TASK_BONUS_RULES_JSON 定稿(签到+10对话/反馈+2发型/分享+3换装/首测+1重生成, +invite_friend 下架);report_feedback/first_report claim 前完成校验(rp_feedback/ +rp_report 可信信号),sign_in/share_report 不校验(产品决策见产品方案 §3.2)。 + +偏差说明:TASK_BONUS_NOT_ELIGIBLE(40311) 错误码与 config.py 默认值同步需协调提交; +<其余偏差写这里,无则写"无"> +``` + +端侧: + +``` +feat(f1): task bonus panel final catalog + auto-claim hooks (T2b) + +面板 4 项定稿任务与「去完成」真实跳转;分享调起/报告生成成功自动 claim; +新增 panel_view/task_tap/share_invoke 埋点;修正 ChatPage bonus_granted 类型。 + +偏差说明:<如有写在这里,无则写"无"> +``` diff --git a/docs/鸿蒙美搭-智能穿搭美学助手-产品方案.md b/docs/鸿蒙美搭-智能穿搭美学助手-产品方案.md index 1f42084..4efc296 100644 --- a/docs/鸿蒙美搭-智能穿搭美学助手-产品方案.md +++ b/docs/鸿蒙美搭-智能穿搭美学助手-产品方案.md @@ -464,7 +464,25 @@ V1 无付费、无会员、无点数购买,**所有注册用户使用同一套 2. **按设备维度计量**(设备指纹 + 匿名 ID),因此**必然可以被清数据绕过**。这是可接受的:游客额度极小,绕过的收益低于用户重装 App 的成本,不值得为它上强风控。 3. **额度用尽必须是"引导登录"而非"静默失败"** —— 这是游客模式唯一的转化时机,做成一个报错就白费了。 -**任务奖励额度的获取方式**(全部不涉及金钱):每日签到、完善画像资料、给报告结论打反馈、邀请好友、分享报告长图。 +**任务奖励额度的获取方式**(全部不涉及金钱)。V1 任务目录已于 **2026-09-11 定稿**(原为占位;研发任务书见《[开发任务包/T2b-任务换额度-端侧闭环](./开发任务包/T2b-任务换额度-端侧闭环.md)》): + +| 任务 | task_key | 奖励 | 领取周期 | 完成判定 | 内测期状态 | +|------|----------|------|---------|---------|-----------| +| 每日签到 | `sign_in` | AI 对话 **+10 轮** | 每日 | 点击签到即完成(登录态 + 服务端日幂等),行为本身即任务 | ✅ 上 | +| 给报告结论打反馈 | `report_feedback` | 发型/发色 **+2 次** | 每日 | **服务端可信**:当日 `rp_feedback` 存在本人记录 | ✅ 上 | +| 分享我的报告 | `share_report` | 换装 **+3 次** | 每日 | **端侧自报**:调起系统分享面板即算完成,埋点观测 | ✅ 上(自报水位) | +| 完成首次形象速测 | `first_report` | 报告重生成 **+1 次** | 终身一次 | **服务端可信**:`rp_report` 存在本人已完成报告 | ✅ 上(替代原「完善画像资料」) | +| ~~邀请好友~~ | `invite_friend` | — | — | 无可信信号(无邀请码/回流链路) | ❌ **内测期下架**,V2 有邀请链路后重评 | + +**数值决策理由(2026-09-11)**: + +1. **与配额种子精确对齐**:四个任务的奖励合计恰好等于 `bl_quota_rule.task_bonus_max`(对话 10 / 换装 3 / 发型 2 / 重生成 1),只需改 `TASK_BONUS_RULES_JSON` 配置即生效,**不动配额种子、不发版**(`FR-BILL-04`)。 +2. **签到独得对话 +10**:Agent 单轮成本最低(≤¥0.05),签到让对话额度翻倍(10→20 轮/日)是最直接的日活钩子;物理上限 `hard_cap` 40 不变,§5.2.1 成本模型不受影响。 +3. **反馈奖发型 +2/日**(改掉原配置「反馈奖重生成 +1」):反馈是每日可重复行为,而重生成月封顶 1 次——原配置下用户当月第 2 天起再反馈必撞 40310,等于白做。且反馈数据是结论认同率/修正率指标(`FR-RPT-19`)的唯一数据来源,值得用高频日奖励驱动。 +4. **分享只能端侧自报,内测期接受**:Share Kit 系统分享无「实际发送成功」回调,内测期不做服务端事件校验(埋点本身可伪造且批上报有节流,做校验是自欺)。刷满收益被 `hard_cap` 8 锁死,已含在 §5.2.1 物理上限成本模型内,**零增量成本风险**;埋点观测滥用率,内测后复评。 +5. **邀请好友直接砍**:无邀请码与回流链路,任何形态都只能纯自报且无观测锚点,上了等于无条件每日 +1 换装。 +6. **「完善画像资料」改造为「完成首次形象速测」**:资料维护功能(`FR-ACC-06`)V1 未实现,无可信信号;完成速测是产品核心漏斗(报告是画像生产者),服务端有 `rp_report` 记录可校验,语义闭环「觉得不准,本月可重来一次」。 +7. 主表「报告重新生成 +1 次/月」在内测期由 `first_report` **一次性**兑现;月度循环获取途径等 V2 任务体系扩展时再补。 **三条不可违反的设计纪律**: