Compare commits
17
Commits
f4075f6827
...
98a134c7a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a134c7a2 | ||
|
|
c24924fa43 | ||
|
|
dff8084bbb | ||
|
|
e409ff08cc | ||
|
|
995c18e7d5 | ||
|
|
ef25fa70c9 | ||
|
|
336b638a1f | ||
|
|
45e4ba52b8 | ||
|
|
1de70f31d6 | ||
|
|
772283220a | ||
|
|
198761677c | ||
|
|
0c3e07e660 | ||
|
|
34d94ba8dd | ||
|
|
9503c6e259 | ||
|
|
26357756e2 | ||
|
|
e511b6a66f | ||
|
|
2934954eb2 |
+14
@@ -0,0 +1,14 @@
|
||||
# 密钥与本地环境。文档仓库没有 detect-secrets,只能靠忽略规则兜底。
|
||||
# `git add -A` 不得把 reference 下的 txt / key 推进远端。
|
||||
reference/*.txt
|
||||
reference/*.key
|
||||
reference/*.pem
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 本地草稿与工具缓存
|
||||
.scratch/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
@@ -0,0 +1,12 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/local.properties
|
||||
/.idea
|
||||
**/build
|
||||
/.hvigor
|
||||
.cxx
|
||||
/.clangd
|
||||
/.clang-format
|
||||
/.clang-tidy
|
||||
**/.test
|
||||
/.appanalyzer
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.xiangsilian.app",
|
||||
"vendor": "example",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"buildVersion": "1",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "app"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# 美搭 HarmonyOS App(ArkTS)
|
||||
|
||||
- 模板:DevEco Studio 6.1 空 Ability 工程;API `6.1.1(24)`,`runtimeOS: HarmonyOS`,`bundleName: com.xiangsilian.app`
|
||||
- 后端:`meida_backend`(WSL)。本地开发默认 `http://127.0.0.1:8000`(模拟器可用;**真机连开发机要在登录页改成局域网 IP**)
|
||||
- 登录通道(V1 骨架):dev-token(后端 `DEV_AUTH_ENABLED=true` 本地可用);华为账号/短信为后续任务
|
||||
|
||||
## 命令行构建(Agent/CI 用的门禁)
|
||||
|
||||
```bash
|
||||
cd G:/meida/app
|
||||
export DEVECO_SDK_HOME="C:/Program Files/Huawei/DevEco Studio/sdk"
|
||||
"C:/Program Files/Huawei/DevEco Studio/tools/node/node.exe" \
|
||||
"C:/Program Files/Huawei/DevEco Studio/tools/hvigor/bin/hvigorw.js" \
|
||||
--mode module -p module=entry@default assembleHap --analyze=false --daemon=false
|
||||
```
|
||||
|
||||
`BUILD SUCCESSFUL` 即门禁通过(未签名 HAP 正常——签名等 AGC 证书)。运行/调试用 DevEco Studio 打开本工程点 Run(模拟器或真机,真机需先配签名)。
|
||||
|
||||
## 代码约定
|
||||
|
||||
- `ets/core/`:网络与会话地基(ApiClient/AuthStore/ContextHolder/ApiTypes)——**共享层,改动需全量构建验证**
|
||||
- `ets/pages/`:页面。**页面间跳转一律 `router.pushUrl({url: 'pages/Xxx'})` 路径方式,禁止 import 其它页面**(并行开发的冲突隔离约定)
|
||||
- 组件级复用放 `ets/components/`(各自任务目录内自建子目录)
|
||||
- 文案:骨架期中文硬编码即可(i18n 后置);错误提示只 catch `ApiError`,用其 `message/hint`(后端文案已保证无价格字样)
|
||||
- 状态管理:页面内 `@State`;跨页少量状态走 `AppStorage`
|
||||
- 不引第三方 ohpm 包(保持零依赖,后续按需评估)
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"signingConfig": "default",
|
||||
"targetSdkVersion": "6.1.1(24)",
|
||||
"compatibleSdkVersion": "6.1.1(24)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug",
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "entry",
|
||||
"srcPath": "./entry",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"files": [
|
||||
"**/*.ets"
|
||||
],
|
||||
"ignore": [
|
||||
"**/src/ohosTest/**/*",
|
||||
"**/src/test/**/*",
|
||||
"**/src/mock/**/*",
|
||||
"**/node_modules/**/*",
|
||||
"**/oh_modules/**/*",
|
||||
"**/build/**/*",
|
||||
"**/.preview/**/*"
|
||||
],
|
||||
"ruleSet": [
|
||||
"plugin:@performance/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@security/no-unsafe-aes": "error",
|
||||
"@security/no-unsafe-hash": "error",
|
||||
"@security/no-unsafe-mac": "warn",
|
||||
"@security/no-unsafe-dh": "error",
|
||||
"@security/no-unsafe-dsa": "error",
|
||||
"@security/no-unsafe-ecdsa": "error",
|
||||
"@security/no-unsafe-rsa-encrypt": "error",
|
||||
"@security/no-unsafe-rsa-sign": "error",
|
||||
"@security/no-unsafe-rsa-key": "error",
|
||||
"@security/no-unsafe-dsa-key": "error",
|
||||
"@security/no-unsafe-dh-key": "error",
|
||||
"@security/no-unsafe-3des": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/.preview
|
||||
/build
|
||||
/.cxx
|
||||
/.test
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
"resOptions": {
|
||||
"copyCodeResource": {
|
||||
"enable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": false,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
},
|
||||
{
|
||||
"name": "ohosTest",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Define project specific obfuscation rules here.
|
||||
# You can include the obfuscation configuration files in the current module's build-profile.json5.
|
||||
#
|
||||
# For more details, see
|
||||
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation
|
||||
|
||||
# Obfuscation options:
|
||||
# -disable-obfuscation: disable all obfuscations
|
||||
# -enable-property-obfuscation: obfuscate the property names
|
||||
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
|
||||
# -compact: remove unnecessary blank spaces and all line feeds
|
||||
# -remove-log: remove all console.* statements
|
||||
# -print-namecache: print the name cache that contains the mapping from the old names to new names
|
||||
# -apply-namecache: reuse the given cache file
|
||||
|
||||
# Keep options:
|
||||
# -keep-property-name: specifies property names that you want to keep
|
||||
# -keep-global-name: specifies names that you want to keep in the global scope
|
||||
|
||||
-enable-property-obfuscation
|
||||
-enable-toplevel-obfuscation
|
||||
-enable-filename-obfuscation
|
||||
-enable-export-obfuscation
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "1.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* ErrorBanner(F1)—— 统一错误横幅:展示 ApiError 的 message + hint。
|
||||
*
|
||||
* 两处使用:LoginPage(登录失败)、ProfilePage(数据加载失败)。
|
||||
* ArkTS 约束:回调用普通成员属性(非装饰器);@Prop 全部给本地默认值。
|
||||
*/
|
||||
|
||||
@Component
|
||||
export struct ErrorBanner {
|
||||
@Prop message: string = ''
|
||||
@Prop hint: string = ''
|
||||
onClose: () => void = () => {}
|
||||
|
||||
build() {
|
||||
if (this.message !== '') {
|
||||
Row({ space: 8 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text(this.message)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#C0392B')
|
||||
if (this.hint !== '') {
|
||||
Text(this.hint)
|
||||
.fontSize(12)
|
||||
.fontColor('#D98880')
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Text('×')
|
||||
.fontSize(20)
|
||||
.fontColor('#C0392B')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.onClick(() => this.onClose())
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FBEAEA')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* LedgerItemView(F1)—— 配额流水行:类型 + 状态徽标 + 配额/时间 + 变动值/余额。
|
||||
*
|
||||
* tx_type / status 的中文映射与配色由父层算好,本组件只负责展示(纯 @Prop)。
|
||||
*/
|
||||
|
||||
@Component
|
||||
export struct LedgerItemView {
|
||||
@Prop typeText: string = ''
|
||||
@Prop statusText: string = ''
|
||||
@Prop statusColor: string = '#999999'
|
||||
@Prop statusBg: string = '#F4F4F4'
|
||||
@Prop quotaText: string = ''
|
||||
@Prop timeText: string = ''
|
||||
@Prop deltaText: string = ''
|
||||
@Prop deltaColor: string = '#666666'
|
||||
@Prop balanceText: string = ''
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.typeText)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Text(this.statusText)
|
||||
.fontSize(10)
|
||||
.fontColor(this.statusColor)
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor(this.statusBg)
|
||||
}
|
||||
Text(`${this.quotaText} · ${this.timeText}`)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(this.deltaText)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.deltaColor)
|
||||
Text(this.balanceText)
|
||||
.fontSize(10)
|
||||
.fontColor('#BBBBBB')
|
||||
}
|
||||
.alignItems(HorizontalAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* QuotaCardView(F1)—— 配额卡片:剩余数大字 + 进度条 + 周期文案。
|
||||
*
|
||||
* 数据契约(对齐后端 GET /api/v1/quota):
|
||||
* used / (limit_value + bonus_granted) 为进度;remaining 为剩余额度。
|
||||
* 周期文案由父层根据 quota_key + reset_at 算好传入(日=明日恢复/终身=一次性)。
|
||||
*/
|
||||
|
||||
@Component
|
||||
export struct QuotaCardView {
|
||||
@Prop title: string = ''
|
||||
@Prop remaining: number = 0
|
||||
@Prop used: number = 0
|
||||
@Prop total: number = 0
|
||||
@Prop resetText: string = ''
|
||||
@Prop loaded: boolean = false
|
||||
@Prop errorText: string = ''
|
||||
@Prop accent: string = '#C2185B'
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Text(this.title)
|
||||
.fontSize(13)
|
||||
.fontColor('#8A8A8A')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
if (!this.loaded) {
|
||||
LoadingProgress()
|
||||
.width(28)
|
||||
.height(28)
|
||||
.color('#CCCCCC')
|
||||
.margin({ top: 10, bottom: 14 })
|
||||
} else if (this.errorText !== '') {
|
||||
Text(this.errorText)
|
||||
.fontSize(13)
|
||||
.fontColor('#C0392B')
|
||||
.maxLines(2)
|
||||
.margin({ top: 6, bottom: 8 })
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
Text(`${this.remaining}`)
|
||||
.fontSize(34)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.accent)
|
||||
Text('剩余次数')
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
Progress({ value: this.used, total: Math.max(this.total, 1), type: ProgressType.Linear })
|
||||
.width('100%')
|
||||
.height(6)
|
||||
.borderRadius(3)
|
||||
.color(this.accent)
|
||||
.backgroundColor('#F1E7EB')
|
||||
.margin({ top: 4 })
|
||||
Text(`已用 ${this.used}/${this.total}`)
|
||||
.fontSize(11)
|
||||
.fontColor('#999999')
|
||||
Text(this.resetText)
|
||||
.fontSize(10)
|
||||
.fontColor('#B3B3B3')
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ErrorView —— 通用失败态组件(F2)。
|
||||
*
|
||||
* NFR-UX-02「失败必有原因 + 下一步指引」的落地组件:
|
||||
* - message:失败原因(对应 ApiError.message)
|
||||
* - hint:下一步指引(对应 ApiError.hint,可空则不渲染)
|
||||
* - onRetry:可选「重试」回调,不传则不渲染按钮
|
||||
* 其它 FE 任务可直接 import 本组件复用(目录带 f2 前缀,迁移由协调者做)。
|
||||
*/
|
||||
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
const TEXT_SECONDARY: string = '#8A9099'
|
||||
const WARN: string = '#FF9F0A'
|
||||
const WARN_BG: string = '#FFF4E0'
|
||||
const ACCENT: string = '#FF5A79'
|
||||
|
||||
@Component
|
||||
export struct ErrorView {
|
||||
@Prop message: string = '请求失败,请检查网络'
|
||||
@Prop hint: string = ''
|
||||
@Prop retryText: string = '重试'
|
||||
onRetry?: () => void
|
||||
|
||||
build() {
|
||||
Column({ space: 10 }) {
|
||||
Text('!')
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(WARN)
|
||||
.width(56)
|
||||
.height(56)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(28)
|
||||
.backgroundColor(WARN_BG)
|
||||
Text(this.message)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
if (this.hint !== '') {
|
||||
Text(this.hint)
|
||||
.fontSize(13)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
if (this.onRetry !== undefined) {
|
||||
Button(this.retryText)
|
||||
.fontSize(15)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(ACCENT)
|
||||
.borderRadius(20)
|
||||
.height(40)
|
||||
.padding({ left: 32, right: 32 })
|
||||
.margin({ top: 6 })
|
||||
.onClick(() => {
|
||||
if (this.onRetry !== undefined) {
|
||||
this.onRetry()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 32, right: 32 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* LoadingView —— 通用加载态组件(F2)。
|
||||
*
|
||||
* 居中 LoadingProgress + 可选文案;text 传空串时只显示转圈。
|
||||
* props 一律 @Prop(父传子单向);颜色硬编码(resources 本批禁改,i18n 后置)。
|
||||
*/
|
||||
|
||||
const ACCENT: string = '#FF5A79'
|
||||
const TEXT_SECONDARY: string = '#8A9099'
|
||||
|
||||
@Component
|
||||
export struct LoadingView {
|
||||
@Prop text: string = '加载中…'
|
||||
|
||||
build() {
|
||||
Column({ space: 12 }) {
|
||||
LoadingProgress()
|
||||
.width(44)
|
||||
.height(44)
|
||||
.color(ACCENT)
|
||||
if (this.text !== '') {
|
||||
Text(this.text)
|
||||
.fontSize(14)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* DimensionCard —— 32 维诊断单项卡片(F3)。
|
||||
*
|
||||
* - ok 维度:name_cn + value(四种形态由 formatDimValue 统一处理:字符串直显 /
|
||||
* 数字转字符串 / 数组 join '、' / 空值显示 '—')+ 来源标签中文映射 + 置信度百分比
|
||||
* - confidence < 0.7 加“仅供参考”小标(NFR-UX-05)
|
||||
* - skipped 维度:整体灰显,evidence 作为未覆盖原因文案
|
||||
* - rule_hits 非空时折叠展示“依据规则 N 条”
|
||||
*/
|
||||
|
||||
import { EMPTY_DIMENSION, DimensionWire, formatDimValue, sourceColor, sourceLabel } from './ReportTypes'
|
||||
|
||||
const CARD_PRIMARY_COLOR: string = '#C2185B'
|
||||
|
||||
@Component
|
||||
export struct DimensionCard {
|
||||
dim: DimensionWire = EMPTY_DIMENSION
|
||||
@State ruleExpanded: boolean = false
|
||||
|
||||
private isSkipped(): boolean {
|
||||
return this.dim.status !== 'ok'
|
||||
}
|
||||
|
||||
private titleText(): string {
|
||||
const n = this.dim.name_cn
|
||||
if (n !== undefined && n !== '') {
|
||||
return n
|
||||
}
|
||||
const k = this.dim.key
|
||||
if (k !== undefined && k !== '') {
|
||||
return k
|
||||
}
|
||||
const num = this.dim.number
|
||||
return num !== undefined ? `维度 ${num}` : '未知维度'
|
||||
}
|
||||
|
||||
private hasConfidence(): boolean {
|
||||
const c = this.dim.confidence
|
||||
return c !== undefined && c !== null
|
||||
}
|
||||
|
||||
private confidencePercent(): string {
|
||||
const c = this.dim.confidence
|
||||
return `${Math.round((c ?? 0) * 100)}%`
|
||||
}
|
||||
|
||||
private isLowConfidence(): boolean {
|
||||
const c = this.dim.confidence
|
||||
return c !== undefined && c !== null && c < 0.7
|
||||
}
|
||||
|
||||
private ruleHits(): string[] {
|
||||
const r = this.dim.rule_hits
|
||||
return r ?? []
|
||||
}
|
||||
|
||||
private reasonText(): string {
|
||||
const ev = this.dim.evidence
|
||||
if (ev !== undefined && ev !== null && ev !== '') {
|
||||
return ev
|
||||
}
|
||||
return '本期未采集到足够信息'
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.titleText())
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.isSkipped() ? '#9AA0A6' : '#333333')
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (!this.isSkipped()) {
|
||||
Text(sourceLabel(this.dim.source))
|
||||
.fontSize(10)
|
||||
.fontColor(sourceColor(this.dim.source))
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor('#F5F5F7')
|
||||
.borderRadius(4)
|
||||
if (this.hasConfidence()) {
|
||||
Text(this.confidencePercent())
|
||||
.fontSize(12)
|
||||
.fontColor('#666666')
|
||||
}
|
||||
if (this.isLowConfidence()) {
|
||||
Text('仅供参考')
|
||||
.fontSize(10)
|
||||
.fontColor('#D46A0B')
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.backgroundColor('#FDF3E3')
|
||||
.borderRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.isSkipped()) {
|
||||
Text(`未覆盖:${this.reasonText()}`)
|
||||
.fontSize(13)
|
||||
.fontColor('#9AA0A6')
|
||||
.width('100%')
|
||||
} else {
|
||||
Text(formatDimValue(this.dim.value))
|
||||
.fontSize(14)
|
||||
.fontColor('#333333')
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (this.ruleHits().length > 0) {
|
||||
Text(this.ruleExpanded ? `收起依据(规则 ${this.ruleHits().length} 条)` : `依据规则 ${this.ruleHits().length} 条`)
|
||||
.fontSize(12)
|
||||
.fontColor(CARD_PRIMARY_COLOR)
|
||||
.onClick(() => {
|
||||
this.ruleExpanded = !this.ruleExpanded
|
||||
})
|
||||
if (this.ruleExpanded) {
|
||||
Column({ space: 4 }) {
|
||||
ForEach(this.ruleHits(), (r: string) => {
|
||||
Text(`· ${r}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#888888')
|
||||
.width('100%')
|
||||
}, (r: string) => r)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(this.isSkipped() ? '#F7F7F8' : '#FFFFFF')
|
||||
.opacity(this.isSkipped() ? 0.85 : 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* F3 报告流共享:wire 接口(字段名对齐后端 JSON)+ 纯函数工具。
|
||||
*
|
||||
* ArkTS 约束(对齐 core 风格):
|
||||
* - wire 类型全部是文件内具名 interface,字段可选以防御后端缺省
|
||||
* - 维度 value 是动态形态(string | number | string[]),wire 侧收 Object、
|
||||
* 渲染前用 formatDimValue 做运行时分派,四种形态统一转展示文本
|
||||
*/
|
||||
|
||||
/** POST /api/v1/reports 与 POST /api/v1/reports/{id}/regenerate 的响应 */
|
||||
export interface ReportCreateResp {
|
||||
report_id?: string
|
||||
task_id?: string
|
||||
status?: string
|
||||
version?: number
|
||||
}
|
||||
|
||||
/** GET /api/v1/tasks/{id} 的响应(status: PENDING/RUNNING/SUCCEEDED/FAILED) */
|
||||
export interface TaskWire {
|
||||
task_id?: string
|
||||
status?: string
|
||||
progress?: number
|
||||
stage_text?: string
|
||||
}
|
||||
|
||||
/** 单个诊断维度(diagnosis.dimensions[] 恒 32 项;status: 'ok' | 'skipped') */
|
||||
export interface DimensionWire {
|
||||
number?: number
|
||||
key?: string
|
||||
name_cn?: string
|
||||
value?: Object // string | number | string[],渲染前用 formatDimValue 分派
|
||||
confidence?: number // 0~1;< 0.7 需标“仅供参考”(NFR-UX-05)
|
||||
source?: string // questionnaire|vision|rule|derived|kb
|
||||
evidence?: string // skipped 时的原因文案
|
||||
rule_hits?: string[]
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface CoverageWire {
|
||||
basic_full?: boolean
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface DiagnosisWire {
|
||||
dimensions?: DimensionWire[]
|
||||
coverage?: CoverageWire
|
||||
}
|
||||
|
||||
export interface CopySectionWire {
|
||||
title?: string
|
||||
paragraphs?: string[]
|
||||
}
|
||||
|
||||
export interface CopyWire {
|
||||
summary?: string
|
||||
sections?: CopySectionWire[]
|
||||
}
|
||||
|
||||
export interface ValidationWire {
|
||||
passed?: boolean
|
||||
degraded?: boolean // true = 规则引擎降级生成,UI 需黄条透明提示
|
||||
}
|
||||
|
||||
export interface ReportResultWire {
|
||||
diagnosis?: DiagnosisWire
|
||||
copy?: CopyWire
|
||||
validation?: ValidationWire
|
||||
}
|
||||
|
||||
/** GET /api/v1/reports/{id} 的响应 */
|
||||
export interface ReportDetailWire {
|
||||
report_id?: string
|
||||
status?: string
|
||||
version?: number
|
||||
result?: ReportResultWire
|
||||
created_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
/** router.getParams() 的参数形态(Create/Result 页之间只传 reportId) */
|
||||
export interface ReportRouteParams {
|
||||
reportId?: string
|
||||
}
|
||||
|
||||
export interface DimensionGroups {
|
||||
analyzed: DimensionWire[]
|
||||
skipped: DimensionWire[]
|
||||
}
|
||||
|
||||
export const EMPTY_DIMENSION: DimensionWire = {}
|
||||
|
||||
/**
|
||||
* 四种 value 形态统一转展示文本:
|
||||
* 字符串直显 / 数字转字符串 / 数组 join '、' / 空值(undefined、null、'')显示 '—'
|
||||
*/
|
||||
export function formatDimValue(v: Object | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return '—'
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return v === '' ? '—' : v
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return v.toString()
|
||||
}
|
||||
if (v instanceof Array) {
|
||||
const arr: Object[] = v as Object[]
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const s = formatDimValue(arr[i])
|
||||
if (s !== '—') {
|
||||
parts.push(s)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('、') : '—'
|
||||
}
|
||||
return v.toString()
|
||||
}
|
||||
|
||||
/** 来源标签中文映射 */
|
||||
export function sourceLabel(src: string | undefined): string {
|
||||
if (src === undefined || src === '') {
|
||||
return '综合'
|
||||
}
|
||||
if (src === 'questionnaire') {
|
||||
return '你填写'
|
||||
}
|
||||
if (src === 'vision') {
|
||||
return '照片分析'
|
||||
}
|
||||
if (src === 'rule') {
|
||||
return '规则推导'
|
||||
}
|
||||
if (src === 'derived') {
|
||||
return '推导'
|
||||
}
|
||||
if (src === 'kb') {
|
||||
return '知识库'
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
export function sourceColor(src: string | undefined): string {
|
||||
if (src === 'questionnaire') {
|
||||
return '#3B82C4'
|
||||
}
|
||||
if (src === 'vision') {
|
||||
return '#8B5CF6'
|
||||
}
|
||||
if (src === 'rule') {
|
||||
return '#0891B2'
|
||||
}
|
||||
if (src === 'derived') {
|
||||
return '#D46A0B'
|
||||
}
|
||||
if (src === 'kb') {
|
||||
return '#0E9F6E'
|
||||
}
|
||||
return '#6B7280'
|
||||
}
|
||||
|
||||
/** 任务/报告状态中文映射 */
|
||||
export function statusLabelOf(s: string | undefined): string {
|
||||
if (s === 'SUCCEEDED') {
|
||||
return '已完成'
|
||||
}
|
||||
if (s === 'RUNNING' || s === 'GENERATING') {
|
||||
return '生成中'
|
||||
}
|
||||
if (s === 'PENDING') {
|
||||
return '排队中'
|
||||
}
|
||||
if (s === 'FAILED') {
|
||||
return '生成失败'
|
||||
}
|
||||
return s === undefined || s === '' ? '—' : s
|
||||
}
|
||||
|
||||
export function statusColorOf(s: string | undefined): string {
|
||||
if (s === 'SUCCEEDED') {
|
||||
return '#0E9F6E'
|
||||
}
|
||||
if (s === 'FAILED') {
|
||||
return '#E5484D'
|
||||
}
|
||||
if (s === 'PENDING' || s === 'RUNNING' || s === 'GENERATING') {
|
||||
return '#D46A0B'
|
||||
}
|
||||
return '#999999'
|
||||
}
|
||||
|
||||
/** ISO 时间串截到分钟展示('2026-08-15T10:30:00+00:00' → '2026-08-15 10:30') */
|
||||
export function formatIsoTime(s: string | undefined): string {
|
||||
if (s === undefined || s === null || s === '') {
|
||||
return '—'
|
||||
}
|
||||
const t = s.indexOf('T')
|
||||
if (t < 0) {
|
||||
return s
|
||||
}
|
||||
const date = s.substring(0, t)
|
||||
const time = s.substring(t + 1, t + 6)
|
||||
return `${date} ${time}`
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`
|
||||
}
|
||||
|
||||
/** Date → 'yyyy-MM-dd'(本地时区,问卷 birthday 用) */
|
||||
export function formatDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 随机 hex 串(request_key 用;重试沿用同一 key 即幂等重放) */
|
||||
export function randomHex(len: number): string {
|
||||
const chars = '0123456789abcdef'
|
||||
let out = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += chars.charAt(Math.floor(Math.random() * 16))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 模拟照片对象键:report_photo/{userId}/{随机}.jpg(V1 骨架,COS 直传未接入) */
|
||||
export function buildMockPhotoKey(userId: string): string {
|
||||
const uid = userId === '' ? 'anonymous' : userId
|
||||
return `report_photo/${uid}/${randomHex(12)}.jpg`
|
||||
}
|
||||
|
||||
/** 32 维按 status 分两组:已分析(ok)与未覆盖(skipped,含 status 缺失的兜底) */
|
||||
export function splitDimensions(dims: DimensionWire[]): DimensionGroups {
|
||||
const groups: DimensionGroups = { analyzed: [], skipped: [] }
|
||||
for (let i = 0; i < dims.length; i++) {
|
||||
const d = dims[i]
|
||||
if (d.status === 'ok') {
|
||||
groups.analyzed.push(d)
|
||||
} else {
|
||||
groups.skipped.push(d)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* TaskPollingPanel —— 任务轮询通用面板(F3,Create 提交后与 Result 重新生成共用)。
|
||||
*
|
||||
* - 每 3 秒 GET /api/v1/tasks/{id},展示 progress 进度条 + stage_text
|
||||
* - SUCCEEDED / FAILED 通过回调上抛,由宿主页面决定跳转或失败态
|
||||
* - 连续 3 次网络异常视为失败(避免瞬时抖动误杀长任务)
|
||||
* - 定时器随组件销毁自动清理(页面返回/跳转即停);taskId 切换时
|
||||
* 通过 generation 计数丢弃旧任务的在途响应
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../../core/ApiClient'
|
||||
import { ApiError } from '../../core/ApiTypes'
|
||||
import { TaskWire } from './ReportTypes'
|
||||
|
||||
const POLL_INTERVAL_MS: number = 3000
|
||||
const MAX_CONSECUTIVE_ERRORS: number = 3
|
||||
const POLL_PRIMARY_COLOR: string = '#C2185B'
|
||||
|
||||
@Component
|
||||
export struct TaskPollingPanel {
|
||||
@Prop @Watch('onTaskIdChanged') taskId: string = ''
|
||||
onSucceeded: () => void = () => {}
|
||||
onFailed: (reason: string) => void = () => {}
|
||||
|
||||
@State progress: number = 0
|
||||
@State stageText: string = '任务已提交,等待调度…'
|
||||
|
||||
private timerId: number = -1
|
||||
private generation: number = 0
|
||||
private errCount: number = 0
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.taskId !== '') {
|
||||
this.startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
// 页面销毁/返回清理定时器:离开页面不会残留轮询
|
||||
this.stopTimer()
|
||||
}
|
||||
|
||||
private onTaskIdChanged(): void {
|
||||
this.startPolling()
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopTimer()
|
||||
this.generation += 1
|
||||
this.errCount = 0
|
||||
this.progress = 0
|
||||
this.stageText = '任务已提交,等待调度…'
|
||||
this.pollOnce(this.generation)
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.timerId !== -1) {
|
||||
clearTimeout(this.timerId)
|
||||
this.timerId = -1
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOnce(gen: number): Promise<void> {
|
||||
const tid = this.taskId
|
||||
if (tid === '') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const t = await ApiClient.get<TaskWire>(`/api/v1/tasks/${tid}`)
|
||||
if (gen !== this.generation) {
|
||||
return // 已切换到新任务,丢弃旧任务的在途响应
|
||||
}
|
||||
this.errCount = 0
|
||||
const p = t.progress
|
||||
if (p !== undefined && p !== null) {
|
||||
this.progress = Math.min(100, Math.max(0, p))
|
||||
}
|
||||
const st = t.stage_text
|
||||
if (st !== undefined && st !== null && st !== '') {
|
||||
this.stageText = st
|
||||
}
|
||||
if (t.status === 'SUCCEEDED') {
|
||||
this.stopTimer()
|
||||
this.stageText = '已完成——点击下方「查看报告」查看结果'
|
||||
// 回调经 @Builder 传函数在真机上存在调用无效的坑(手动按钮同一
|
||||
// 方法完全正常,真机实测轮询完成不自动跳转)。保留回调尝试,
|
||||
// 失败静默并由宿主的「查看报告」兜底按钮承担导航。
|
||||
try {
|
||||
this.onSucceeded()
|
||||
} catch (e) {
|
||||
console.error(`poll onSucceeded failed: ${JSON.stringify(e)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (t.status === 'FAILED') {
|
||||
this.stopTimer()
|
||||
this.onFailed(this.stageText)
|
||||
return
|
||||
}
|
||||
this.scheduleNext(gen)
|
||||
} catch (e) {
|
||||
if (gen !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.errCount += 1
|
||||
if (this.errCount >= MAX_CONSECUTIVE_ERRORS) {
|
||||
this.stopTimer()
|
||||
let reason = '网络异常,连续查询任务状态失败'
|
||||
if (e instanceof ApiError) {
|
||||
reason = `${reason}(${e.message})`
|
||||
}
|
||||
this.onFailed(reason)
|
||||
return
|
||||
}
|
||||
this.scheduleNext(gen)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(gen: number): void {
|
||||
this.stopTimer()
|
||||
this.timerId = setTimeout((): void => {
|
||||
this.pollOnce(gen)
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 10 }) {
|
||||
Progress({ value: this.progress, total: 100, type: ProgressType.Linear })
|
||||
.width('100%')
|
||||
.height(8)
|
||||
.color(POLL_PRIMARY_COLOR)
|
||||
.backgroundColor('#F3D9E3')
|
||||
.borderRadius(4)
|
||||
Row({ space: 8 }) {
|
||||
Text(this.stageText)
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.layoutWeight(1)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(`${Math.round(this.progress)}%`)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(POLL_PRIMARY_COLOR)
|
||||
}
|
||||
.width('100%')
|
||||
Text(`任务 ID:${this.taskId}|每 3 秒自动刷新,返回不会中断生成`)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 极简 HTTP 客户端:统一信封解包 + 401 自动刷新重试一次。
|
||||
*
|
||||
* ArkTS 严格模式约束(本文件全部遵守,FE 任务照此风格):
|
||||
* - 请求体一律 Record<string, Object>(具名字面量),不用内联对象类型
|
||||
* - throw 只抛 ApiError(Error 子类),未知异常先包装
|
||||
* - code!=0 一律抛 ApiError;UI 层只 catch ApiError
|
||||
*/
|
||||
|
||||
import http from '@ohos.net.http'
|
||||
import { ApiEnvelope, ApiError } from './ApiTypes'
|
||||
import { AuthStore } from './AuthStore'
|
||||
|
||||
const AUTH_EXPIRED_CODES: number[] = [40100, 40101, 40102]
|
||||
|
||||
export class ApiClient {
|
||||
static async get<T>(path: string): Promise<T> {
|
||||
return ApiClient.request<T>(http.RequestMethod.GET, path, undefined)
|
||||
}
|
||||
|
||||
static async post<T>(path: string, body?: Record<string, Object>): Promise<T> {
|
||||
return ApiClient.request<T>(http.RequestMethod.POST, path, body)
|
||||
}
|
||||
|
||||
/** 不带 token、不触发自动刷新(AuthStore.refreshOnce 内部使用,防递归)。 */
|
||||
static async postWithoutAuth<T>(path: string, body: Record<string, Object>): Promise<T> {
|
||||
return ApiClient.doRequest<T>(path, http.RequestMethod.POST, body, undefined, false)
|
||||
}
|
||||
|
||||
private static async request<T>(
|
||||
method: http.RequestMethod,
|
||||
path: string,
|
||||
body: Record<string, Object> | undefined
|
||||
): Promise<T> {
|
||||
const token = AuthStore.getAccessToken()
|
||||
if (token === undefined) {
|
||||
// 未登录直接短路,省一次必然 401 的往返
|
||||
throw new ApiError(40100, '请先登录', '登录后即可继续')
|
||||
}
|
||||
try {
|
||||
return await ApiClient.doRequest<T>(path, method, body, token, true)
|
||||
} catch (e) {
|
||||
const wrapped = ApiClient.wrapUnknown(e)
|
||||
if (wrapped instanceof ApiError && AUTH_EXPIRED_CODES.includes(wrapped.code)) {
|
||||
const refreshed = await AuthStore.refreshOnce()
|
||||
if (refreshed) {
|
||||
return await ApiClient.doRequest<T>(path, method, body, AuthStore.getAccessToken(), true)
|
||||
}
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
private static async doRequest<T>(
|
||||
path: string,
|
||||
method: http.RequestMethod,
|
||||
body: Record<string, Object> | undefined,
|
||||
token: string | undefined,
|
||||
withAuth: boolean
|
||||
): Promise<T> {
|
||||
const base = await AuthStore.getBaseUrl()
|
||||
const url = `${base}${path}`
|
||||
const client = http.createHttp()
|
||||
try {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (withAuth && token !== undefined) {
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
const resp = await client.request(url, {
|
||||
method: method,
|
||||
header: header,
|
||||
extraData: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
expectDataType: http.HttpDataType.OBJECT,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 30000
|
||||
})
|
||||
// 后端业务错误(40920/40301/42910…)走非 200 的 HTTP 状态码,
|
||||
// 但 body 仍是统一信封——先验信封形状再判成败;只有 body 不是
|
||||
// 信封(网关 502 的 HTML、字符串等)才按网关错误兜底。
|
||||
// 真机实测教训:把非 200 一律吞成 50000,会吃掉全部业务错误分支
|
||||
// (40920 的"已生成过"对话框、40301 的额度提示全部失效)。
|
||||
const env = resp.result as ApiEnvelope<T>
|
||||
if (typeof env === 'object' && env !== null && typeof env.code === 'number') {
|
||||
if (env.code !== 0) {
|
||||
throw new ApiError(env.code, env.message, env.hint, resp.responseCode)
|
||||
}
|
||||
return env.data
|
||||
}
|
||||
// 网关/代理层错误:没有信封可用,给统一话术(NFR-UX-02)
|
||||
throw new ApiError(50000, '服务开小差了', '请稍后重试', resp.responseCode)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/** ArkTS 禁止 throw 任意类型:未知异常统一包装成 ApiError。 */
|
||||
private static wrapUnknown(e: Object): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
return e
|
||||
}
|
||||
return new ApiError(50000, '请求失败,请检查网络', '请稍后重试')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 后端统一响应信封与错误类型(对齐 packages/shared/response.py)。
|
||||
*
|
||||
* 信封形状:{code, message, data, hint?, request_id?}
|
||||
* - code=0 成功;4xxxx 客户端错;5xxxx 服务端错;6xxxx 外部依赖错
|
||||
* - 所有非 0 code 都必须走 ApiError 抛出,UI 层只 catch ApiError
|
||||
*/
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
hint?: string
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: number
|
||||
readonly hint: string | undefined
|
||||
readonly httpStatus: number
|
||||
|
||||
constructor(code: number, message: string, hint?: string, httpStatus = 200) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.hint = hint
|
||||
this.httpStatus = httpStatus
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 会话状态与令牌持久化(对齐后端 FR-ACC-05 双令牌)。
|
||||
*
|
||||
* - access 短令牌放内存 + preferences(local 明文可接受:V1 骨架,无支付;
|
||||
* 正式版应迁 @ohos.security.asset,注释留迁移点)
|
||||
* - refresh 一次性轮换:每次 refresh 换新对
|
||||
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用;
|
||||
* 华为账号/短信登录是后续通道,接口按多通道预留)
|
||||
* - ArkTS 约束:所有请求/响应都是具名 interface;字面量先声明类型再传
|
||||
*/
|
||||
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { ApiClient } from './ApiClient'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
refreshExpiresIn: number
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface AuthWireResp {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
refresh_expires_in: number
|
||||
user_id: string
|
||||
}
|
||||
|
||||
const PREF_NAME = 'meida_auth'
|
||||
const KEY_TOKENS = 'tokens'
|
||||
const KEY_BASE_URL = 'base_url'
|
||||
export const DEFAULT_BASE_URL = 'http://127.0.0.1:8000'
|
||||
|
||||
export class AuthStore {
|
||||
private static tokens: TokenPair | undefined
|
||||
private static prefs: preferences.Preferences | undefined
|
||||
private static refreshing: Promise<boolean> | undefined
|
||||
|
||||
static async init(): Promise<void> {
|
||||
AuthStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
const raw = await AuthStore.prefs.get(KEY_TOKENS, '') as string
|
||||
if (raw !== '') {
|
||||
try {
|
||||
AuthStore.tokens = JSON.parse(raw) as TokenPair
|
||||
} catch (_e) {
|
||||
AuthStore.tokens = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static isLoggedIn(): boolean {
|
||||
return AuthStore.tokens !== undefined && AuthStore.tokens.accessToken !== ''
|
||||
}
|
||||
|
||||
static getAccessToken(): string | undefined {
|
||||
return AuthStore.tokens?.accessToken
|
||||
}
|
||||
|
||||
static getUserId(): string | undefined {
|
||||
return AuthStore.tokens?.userId
|
||||
}
|
||||
|
||||
static async setBaseUrl(url: string): Promise<void> {
|
||||
await AuthStore.ensurePrefs()
|
||||
await AuthStore.prefs!.put(KEY_BASE_URL, url)
|
||||
await AuthStore.prefs!.flush()
|
||||
}
|
||||
|
||||
static async getBaseUrl(): Promise<string> {
|
||||
await AuthStore.ensurePrefs()
|
||||
return await AuthStore.prefs!.get(KEY_BASE_URL, DEFAULT_BASE_URL) as string
|
||||
}
|
||||
|
||||
/** dev-token 登录(本地后端开发通道)。失败由 ApiClient 抛 ApiError。 */
|
||||
static async loginByDevToken(deviceKey: string): Promise<void> {
|
||||
const req: Record<string, Object> = { 'device_key': deviceKey }
|
||||
// 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
|
||||
// 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
|
||||
const data = await ApiClient.postWithoutAuth<AuthWireResp>('/api/v1/auth/dev-token', req)
|
||||
await AuthStore.saveTokens(AuthStore.fromWire(data))
|
||||
}
|
||||
|
||||
/** 用 refresh 换新对。返回 false 表示会话已失效(应回登录页)。 */
|
||||
static async refreshOnce(): Promise<boolean> {
|
||||
if (AuthStore.refreshing !== undefined) {
|
||||
return AuthStore.refreshing
|
||||
}
|
||||
const t = AuthStore.tokens
|
||||
if (!t || t.refreshToken === '') {
|
||||
return false
|
||||
}
|
||||
AuthStore.refreshing = AuthStore.doRefresh(t)
|
||||
return AuthStore.refreshing
|
||||
}
|
||||
|
||||
static async logout(): Promise<void> {
|
||||
const t = AuthStore.tokens
|
||||
if (t && t.refreshToken !== '') {
|
||||
// 尽力通知服务端撤销;失败不阻塞本地登出
|
||||
try {
|
||||
const req: Record<string, Object> = { 'refresh_token': t.refreshToken }
|
||||
await ApiClient.post<void>('/api/v1/auth/logout', req)
|
||||
} catch (_e) {
|
||||
// 忽略:服务端不可达也必须能本地登出
|
||||
}
|
||||
}
|
||||
await AuthStore.clear()
|
||||
}
|
||||
|
||||
static async clear(): Promise<void> {
|
||||
AuthStore.tokens = undefined
|
||||
await AuthStore.ensurePrefs()
|
||||
await AuthStore.prefs!.delete(KEY_TOKENS)
|
||||
await AuthStore.prefs!.flush()
|
||||
}
|
||||
|
||||
private static async doRefresh(t: TokenPair): Promise<boolean> {
|
||||
try {
|
||||
// 刻意走 postWithoutAuth:绕过 ApiClient 的自动刷新(否则递归)
|
||||
const req: Record<string, Object> = { 'refresh_token': t.refreshToken }
|
||||
const data = await ApiClient.postWithoutAuth<AuthWireResp>('/api/v1/auth/refresh', req)
|
||||
await AuthStore.saveTokens(AuthStore.fromWire(data))
|
||||
return true
|
||||
} catch (_e) {
|
||||
await AuthStore.clear()
|
||||
return false
|
||||
} finally {
|
||||
AuthStore.refreshing = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private static fromWire(w: AuthWireResp): TokenPair {
|
||||
return {
|
||||
accessToken: w.access_token,
|
||||
refreshToken: w.refresh_token,
|
||||
expiresIn: w.expires_in,
|
||||
refreshExpiresIn: w.refresh_expires_in,
|
||||
userId: w.user_id
|
||||
}
|
||||
}
|
||||
|
||||
private static async saveTokens(t: TokenPair): Promise<void> {
|
||||
AuthStore.tokens = t
|
||||
await AuthStore.ensurePrefs()
|
||||
await AuthStore.prefs!.put(KEY_TOKENS, JSON.stringify(t))
|
||||
await AuthStore.prefs!.flush()
|
||||
}
|
||||
|
||||
private static async ensurePrefs(): Promise<void> {
|
||||
if (AuthStore.prefs === undefined) {
|
||||
AuthStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* ContextHolder:UI 上下文的全局挂点。
|
||||
*
|
||||
* preferences / http 等系统能力需要 Context;ArkUI 里合法取法是
|
||||
* getUIContext().getHostContext(),但网络层与页面解耦后统一从 EntryAbility
|
||||
* 注入一次最简单(EntryAbility.onCreate 里 set)。
|
||||
*/
|
||||
|
||||
export class ContextHolder {
|
||||
private static context: Context | undefined
|
||||
|
||||
static set(context: Context): void {
|
||||
ContextHolder.context = context
|
||||
}
|
||||
|
||||
static get(): Context {
|
||||
if (!ContextHolder.context) {
|
||||
throw new Error('ContextHolder 未初始化:EntryAbility.onCreate 应先调用 set()')
|
||||
}
|
||||
return ContextHolder.context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { ContextHolder } from '../core/ContextHolder';
|
||||
import { AuthStore } from '../core/AuthStore';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
// 全局 Context 挂点:core 层(preferences/http)从这里取,UI 不传参
|
||||
ContextHolder.set(this.context)
|
||||
AuthStore.init()
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
// Main window is created, set main page for this ability
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||
|
||||
windowStage.loadContent('pages/Index', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
});
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
// Main window is destroyed, release UI related resources
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
// Ability has brought to foreground
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
// Ability has back to background
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryBackupAbility extends BackupExtensionAbility {
|
||||
async onBackup() {
|
||||
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onRestore(bundleVersion: BundleVersion) {
|
||||
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* ChatPage —— AI 穿搭对话页(F4)。
|
||||
*
|
||||
* 任务契约:docs/开发任务包/F4-AI对话页.md
|
||||
* - 消息列表 @State ChatMsg[]:本地内存态,退出即失(V1 不做会话持久化,
|
||||
* 后端 ag_conversation 会话落库属后续任务)
|
||||
* - 发送流程:push 用户消息 → AI 侧"正在思考…"占位 → POST /api/v1/agent/chat
|
||||
* → 成功替换占位(meta = "模型 · 耗时")并更新顶部配额薄条 quota_remaining
|
||||
* - 失败态:只 catch ApiError(40301 额度用尽 / 60003 今日服务上限 / 50000 网络错
|
||||
* 与未知异常,均已在 ApiClient 内包成 ApiError),统一在 AI 气泡位展示
|
||||
* message + hint 次行小字,"正在思考…"占位必撤
|
||||
* - 发送中禁用输入防连点;空消息不发送;首次进入插入本地静态欢迎语(不调接口)
|
||||
*/
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
|
||||
/** wire 响应:POST /api/v1/agent/chat 的 data.usage(字段名对齐后端 JSON) */
|
||||
interface ChatUsage {
|
||||
tokens_in: number
|
||||
tokens_out: number
|
||||
tokens_cached: number
|
||||
latency_ms: number
|
||||
cost_cents: number
|
||||
}
|
||||
|
||||
/** wire 响应:POST /api/v1/agent/chat 的 data(字段名对齐后端 JSON) */
|
||||
interface ChatResp {
|
||||
reply: string
|
||||
provider: string
|
||||
model: string
|
||||
prompt_id: string
|
||||
prompt_version: string
|
||||
call_id: string
|
||||
usage: ChatUsage
|
||||
quota_remaining: number
|
||||
}
|
||||
|
||||
/** 列表项:meta 存 AI 消息底部小字(成功为"模型 · 耗时",失败为 e.hint) */
|
||||
interface ChatMsg {
|
||||
role: 'user' | 'ai'
|
||||
text: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const ROLE_USER = 'user'
|
||||
const ROLE_AI = 'ai'
|
||||
const THINKING_TEXT = '正在思考…'
|
||||
const WELCOME_TEXT = '你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~'
|
||||
|
||||
/** 单条消息气泡:用户右对齐主色底,AI 左对齐白底;meta 为底部小字 */
|
||||
@Component
|
||||
struct MessageBubble {
|
||||
@Prop role: string = ROLE_AI
|
||||
@Prop text: string = ''
|
||||
@Prop meta: string = ''
|
||||
|
||||
private isUser(): boolean {
|
||||
return this.role === ROLE_USER
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Column() {
|
||||
Text(this.text)
|
||||
.fontSize(15)
|
||||
.lineHeight(22)
|
||||
.fontColor(this.isUser() ? '#FFFFFF' : '#333333')
|
||||
if (this.meta.length > 0) {
|
||||
Text(this.meta)
|
||||
.fontSize(11)
|
||||
.margin({ top: 4 })
|
||||
.fontColor(this.isUser() ? '#FFDCD8' : '#999999')
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(this.isUser() ? '#D9605A' : '#FFFFFF')
|
||||
.constraintSize({ maxWidth: '82%' })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(this.isUser() ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
}
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ChatPage {
|
||||
/** 本地内存态:不做会话持久化(后端 ag_conversation 属后续任务) */
|
||||
@State messages: ChatMsg[] = []
|
||||
@State inputText: string = ''
|
||||
@State sending: boolean = false
|
||||
/** -1 = 尚未从任何响应拿到配额 → 薄条不显示 */
|
||||
@State quotaRemaining: number = -1
|
||||
private scroller: Scroller = new Scroller()
|
||||
|
||||
aboutToAppear(): void {
|
||||
const welcome: ChatMsg = { role: ROLE_AI, text: WELCOME_TEXT }
|
||||
this.messages.push(welcome)
|
||||
}
|
||||
|
||||
/** 发送入口(回车/按钮共用):空消息不发,发送中防连点 */
|
||||
private send(): void {
|
||||
const text: string = this.inputText.trim()
|
||||
if (text.length === 0 || this.sending) {
|
||||
return
|
||||
}
|
||||
this.doSend(text)
|
||||
}
|
||||
|
||||
private async doSend(text: string): Promise<void> {
|
||||
this.inputText = ''
|
||||
this.sending = true
|
||||
const userMsg: ChatMsg = { role: ROLE_USER, text: text }
|
||||
this.messages.push(userMsg)
|
||||
const thinkingMsg: ChatMsg = { role: ROLE_AI, text: THINKING_TEXT }
|
||||
this.messages.push(thinkingMsg)
|
||||
this.scrollToEnd()
|
||||
try {
|
||||
const req: Record<string, Object> = { 'message': text }
|
||||
const data: ChatResp = await ApiClient.post<ChatResp>('/api/v1/agent/chat', req)
|
||||
const meta: string = `${data.model} · ${data.usage.latency_ms}ms`
|
||||
this.replaceThinking(data.reply, meta)
|
||||
this.quotaRemaining = data.quota_remaining
|
||||
} catch (e) {
|
||||
// 只 catch ApiError:40301 / 60003 / 网络错(50000)均包成 ApiError,
|
||||
// 统一气泡位展示 message + hint;占位在 replaceThinking 里必撤
|
||||
if (e instanceof ApiError) {
|
||||
this.replaceThinking(e.message, e.hint ?? '')
|
||||
}
|
||||
} finally {
|
||||
this.sending = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把末尾"正在思考…"占位替换为结果;占位缺失时兜底补一条,确保占位一定被撤掉 */
|
||||
private replaceThinking(text: string, meta: string): void {
|
||||
const replaced: ChatMsg = { role: ROLE_AI, text: text, meta: meta }
|
||||
const idx: number = this.messages.length - 1
|
||||
if (idx >= 0 && this.messages[idx].text === THINKING_TEXT) {
|
||||
this.messages[idx] = replaced
|
||||
} else {
|
||||
this.messages.push(replaced)
|
||||
}
|
||||
this.scrollToEnd()
|
||||
}
|
||||
|
||||
private scrollToEnd(): void {
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 顶部栏:返回 + 标题
|
||||
Row() {
|
||||
Text('‹')
|
||||
.fontSize(26)
|
||||
.fontColor('#333333')
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('AI 穿搭顾问')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1F1F1F')
|
||||
.margin({ left: 4 })
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.height(56)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 配额薄条:拿到首个响应后展示"今日剩余 N 次"
|
||||
if (this.quotaRemaining >= 0) {
|
||||
Text(`今日剩余 ${this.quotaRemaining} 次`)
|
||||
.fontSize(12)
|
||||
.fontColor('#B3554D')
|
||||
.backgroundColor('#FBEAE8')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
}
|
||||
|
||||
// 消息列表(key 带内容:占位被替换时 key 变化触发该项重渲染)
|
||||
List({ scroller: this.scroller }) {
|
||||
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
|
||||
ListItem() {
|
||||
MessageBubble({
|
||||
role: msg.role,
|
||||
text: msg.text,
|
||||
meta: msg.meta ?? ''
|
||||
})
|
||||
}
|
||||
}, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.backgroundColor('#F5F5F7')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
// 底部输入栏:发送中禁用输入框与按钮防连点
|
||||
Row() {
|
||||
TextInput({
|
||||
text: this.inputText,
|
||||
placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F2F2F4')
|
||||
.borderRadius(8)
|
||||
.enabled(!this.sending)
|
||||
.enterKeyType(EnterKeyType.Send)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value
|
||||
})
|
||||
.onSubmit(() => {
|
||||
this.send()
|
||||
})
|
||||
Button(this.sending ? '回复中' : '发送')
|
||||
.fontSize(14)
|
||||
.height(36)
|
||||
.margin({ left: 8 })
|
||||
.backgroundColor(this.sending ? '#C9C9CD' : '#D9605A')
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* HomePage —— 应用主壳:底部四 Tab(首页/报告/AI/我的)+ 首页内容。
|
||||
*
|
||||
* 协作铁律(FE 批次 README §2/§3):
|
||||
* - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件;
|
||||
* Tab2/3/4 本批用「占位 + 跳页」方案(目标页面是独立路由页,由对应任务实现)
|
||||
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
||||
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWireResp {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: boolean
|
||||
remaining: number
|
||||
reset_at: string
|
||||
}
|
||||
|
||||
const PAGE_BG: string = '#F5F6F8'
|
||||
const ACCENT: string = '#FF5A79'
|
||||
const ACCENT_AI: string = '#6C5CE7'
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
const TEXT_SECONDARY: string = '#8A9099'
|
||||
const TEXT_DISABLED: string = '#C2C7CE'
|
||||
const QUOTA_PATH: string = '/api/v1/quota?quota_key=agent_chat'
|
||||
const WEEKDAYS: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct HomePage {
|
||||
@State currentTabIndex: number = 0
|
||||
@State dateLine: string = ''
|
||||
@State quotaRemaining: string = '…'
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.dateLine = this.buildDateLine()
|
||||
this.loadQuota()
|
||||
}
|
||||
|
||||
/** 返回键:不拦截(返回 false 走系统默认)。Tab 壳是应用首页,再按返回即退出应用——本批不加复杂逻辑。 */
|
||||
onBackPress(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** 问候区日期行,如「2026年8月15日 周六」。 */
|
||||
private buildDateLine(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEKDAYS[now.getDay()]}`
|
||||
}
|
||||
|
||||
/** 拉今日 AI 对话额度;失败静默降级为 '—'(首页额度只是辅助信息,不值得打断用户)。 */
|
||||
private async loadQuota(): Promise<void> {
|
||||
try {
|
||||
const data = await ApiClient.get<QuotaWireResp>(QUOTA_PATH)
|
||||
this.quotaRemaining = `${data.remaining}`
|
||||
} catch (_e) {
|
||||
this.quotaRemaining = '—'
|
||||
}
|
||||
}
|
||||
|
||||
/** 底部 Tab 栏项:简单 Text 字形 + 中文标签(本批不引资源)。 */
|
||||
@Builder
|
||||
TabBarItem(icon: string, label: string, index: number) {
|
||||
Column({ space: 3 }) {
|
||||
Text(icon)
|
||||
.fontSize(20)
|
||||
.fontColor(this.currentTabIndex === index ? ACCENT : TEXT_SECONDARY)
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.currentTabIndex === index ? ACCENT : TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl。 */
|
||||
@Builder
|
||||
EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(icon)
|
||||
.fontSize(20)
|
||||
.fontColor(Color.White)
|
||||
.width(44)
|
||||
.height(44)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(color)
|
||||
Column({ space: 4 }) {
|
||||
Text(title)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
Text(subtitle)
|
||||
.fontSize(12)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(22)
|
||||
.fontColor(TEXT_DISABLED)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
router.pushUrl({ url: targetUrl })
|
||||
})
|
||||
}
|
||||
|
||||
/** 今日额度小卡:显示「AI 对话剩余 N 次」,失败静默显示 '—'。 */
|
||||
@Builder
|
||||
QuotaCard() {
|
||||
Row() {
|
||||
Column({ space: 4 }) {
|
||||
Text('今日额度')
|
||||
.fontSize(12)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
Text(`AI 对话剩余 ${this.quotaRemaining} 次`)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Text('每日刷新')
|
||||
.fontSize(11)
|
||||
.fontColor(TEXT_DISABLED)
|
||||
.padding({ top: 4, bottom: 4, left: 10, right: 10 })
|
||||
.borderRadius(10)
|
||||
.backgroundColor(PAGE_BG)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */
|
||||
@Builder
|
||||
HomeTab() {
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
Column({ space: 6 }) {
|
||||
Text('你好,美搭')
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
Text(this.dateLine)
|
||||
.fontSize(13)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ top: 8 })
|
||||
|
||||
this.EntryCard('生成我的形象报告', '体型 · 体态 · 穿搭建议,一次看全', '▣', ACCENT, 'pages/ReportCreatePage')
|
||||
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage')
|
||||
this.QuotaCard()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.align(Alignment.Top)
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
|
||||
/** Tab2/3/4 占位页:说明文案 + 居中按钮跳转对应真页面(独立路由页)。 */
|
||||
@Builder
|
||||
PlaceholderTab(icon: string, title: string, desc: string, buttonText: string, targetUrl: string) {
|
||||
Column({ space: 10 }) {
|
||||
Text(icon)
|
||||
.fontSize(44)
|
||||
.fontColor(TEXT_DISABLED)
|
||||
Text(title)
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
Text(desc)
|
||||
.fontSize(13)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
Button(buttonText)
|
||||
.fontSize(15)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(ACCENT)
|
||||
.borderRadius(22)
|
||||
.height(44)
|
||||
.padding({ left: 28, right: 28 })
|
||||
.margin({ top: 10 })
|
||||
.onClick(() => {
|
||||
router.pushUrl({ url: targetUrl })
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 32, right: 32 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Tabs({ barPosition: BarPosition.End }) {
|
||||
TabContent() {
|
||||
this.HomeTab()
|
||||
}
|
||||
.tabBar(this.TabBarItem('⌂', '首页', 0))
|
||||
|
||||
TabContent() {
|
||||
this.PlaceholderTab('▤', '报告', '形象报告的查看与管理在这里(建设中)\n新建报告请前往报告创建页', '去生成报告', 'pages/ReportCreatePage')
|
||||
}
|
||||
.tabBar(this.TabBarItem('▤', '报告', 1))
|
||||
|
||||
TabContent() {
|
||||
this.PlaceholderTab('✦', 'AI 顾问', '与 AI 穿搭顾问的对话在独立页面进行(建设中)', '进入 AI 对话', 'pages/ChatPage')
|
||||
}
|
||||
.tabBar(this.TabBarItem('✦', 'AI', 2))
|
||||
|
||||
TabContent() {
|
||||
this.PlaceholderTab('☺', '我的', '个人资料与设置(建设中)', '去我的资料', 'pages/ProfilePage')
|
||||
}
|
||||
.tabBar(this.TabBarItem('☺', '我的', 3))
|
||||
}
|
||||
.scrollable(true)
|
||||
.barMode(BarMode.Fixed)
|
||||
.vertical(false)
|
||||
.divider({ strokeWidth: 0.5, color: '#E5E7EB' })
|
||||
.onChange((index: number) => {
|
||||
this.currentTabIndex = index
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(PAGE_BG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 应用入口:按登录态分流到登录页或主页。
|
||||
* 页面间一律 router 路径跳转(页面已全部注册 main_pages),跨任务零 import。
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
aboutToAppear(): void {
|
||||
const target = AuthStore.isLoggedIn() ? 'pages/HomePage' : 'pages/LoginPage'
|
||||
router.replaceUrl({ url: target })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
LoadingProgress().width(48).height(48)
|
||||
Text('美搭')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.margin({ top: 12 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* LoginPage(F1)—— dev-token 开发登录页。
|
||||
*
|
||||
* 闭环:设备标识(随机生成一次并持久化,可改)→ AuthStore.loginByDevToken
|
||||
* → 成功 router.replaceUrl HomePage;失败 catch ApiError 展示 message+hint。
|
||||
* 高级设置:折叠区编辑 baseUrl(AuthStore.setBaseUrl),真机连开发机靠它。
|
||||
* 已登录态进入本页:aboutToAppear 直接跳 HomePage。
|
||||
*
|
||||
* ArkTS 约束(对齐 core/AuthStore 风格):
|
||||
* - 泛型调用显式类型参数;未知异常统一包 ApiError(本页只展示不抛出)
|
||||
* - async 逻辑放具名方法,build 内只留 UI 与点击回调
|
||||
*/
|
||||
|
||||
import router from '@ohos.router'
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { ErrorBanner } from '../components/f1/ErrorBanner'
|
||||
|
||||
const PREF_NAME_LOGIN = 'meida_login'
|
||||
const KEY_DEVICE_KEY = 'device_key'
|
||||
const COLOR_PRIMARY = '#C2185B'
|
||||
|
||||
/** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */
|
||||
function generateDeviceKey(): string {
|
||||
const chars = '0123456789abcdef'
|
||||
let suffix = ''
|
||||
for (let i = 0; i < 16; i++) {
|
||||
suffix += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return `dev-${suffix}`
|
||||
}
|
||||
|
||||
/** 未知异常统一包 ApiError(与 ApiClient.wrapUnknown 同一话术)。 */
|
||||
function toApiError(e: Object): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
return e
|
||||
}
|
||||
return new ApiError(50000, '请求失败,请检查网络', '请稍后重试')
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct LoginPage {
|
||||
@State deviceKey: string = ''
|
||||
@State baseUrlInput: string = ''
|
||||
@State showAdvanced: boolean = false
|
||||
@State logging: boolean = false
|
||||
@State savingUrl: boolean = false
|
||||
@State errMessage: string = ''
|
||||
@State errHint: string = ''
|
||||
@State savedTip: string = ''
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.bootstrap()
|
||||
}
|
||||
|
||||
/** 具名 async 初始化:确保令牌已载入 → 已登录直跳 → 回填设备标识与地址。 */
|
||||
private async bootstrap(): Promise<void> {
|
||||
try {
|
||||
await AuthStore.init()
|
||||
} catch (_e) {
|
||||
// onCreate 里已 fire-and-forget 初始化过,这里重复 init 失败不影响
|
||||
}
|
||||
if (AuthStore.isLoggedIn()) {
|
||||
router.replaceUrl({ url: 'pages/HomePage' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME_LOGIN })
|
||||
const saved = await prefs.get(KEY_DEVICE_KEY, '') as string
|
||||
if (saved !== '') {
|
||||
this.deviceKey = saved
|
||||
} else {
|
||||
this.deviceKey = generateDeviceKey()
|
||||
await prefs.put(KEY_DEVICE_KEY, this.deviceKey)
|
||||
await prefs.flush()
|
||||
}
|
||||
} catch (_e) {
|
||||
this.deviceKey = generateDeviceKey()
|
||||
}
|
||||
this.baseUrlInput = await AuthStore.getBaseUrl()
|
||||
}
|
||||
|
||||
/** 登录主流程:校验 → loading → dev-token → 跳首页;失败横幅展示。 */
|
||||
private async doLogin(): Promise<void> {
|
||||
if (this.logging) {
|
||||
return
|
||||
}
|
||||
const key = this.deviceKey.trim()
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
this.savedTip = ''
|
||||
if (key === '') {
|
||||
this.errMessage = '请先填写设备标识'
|
||||
this.errHint = '可点「随机生成」重新生成一个'
|
||||
return
|
||||
}
|
||||
this.logging = true
|
||||
this.persistDeviceKey(key)
|
||||
try {
|
||||
await AuthStore.loginByDevToken(key)
|
||||
router.replaceUrl({ url: 'pages/HomePage' })
|
||||
} catch (e) {
|
||||
const err = toApiError(e)
|
||||
this.errMessage = err.message
|
||||
this.errHint = err.hint !== undefined ? err.hint : ''
|
||||
} finally {
|
||||
this.logging = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 设备标识持久化失败不阻塞登录。 */
|
||||
private async persistDeviceKey(key: string): Promise<void> {
|
||||
try {
|
||||
const prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME_LOGIN })
|
||||
await prefs.put(KEY_DEVICE_KEY, key)
|
||||
await prefs.flush()
|
||||
} catch (_e) {
|
||||
// 忽略:仅影响下次回填
|
||||
}
|
||||
}
|
||||
|
||||
private async saveBaseUrl(): Promise<void> {
|
||||
const url = this.baseUrlInput.trim()
|
||||
this.savedTip = ''
|
||||
if (url === '') {
|
||||
this.errMessage = '服务器地址不能为空'
|
||||
this.errHint = '示例:http://192.168.1.100:8000'
|
||||
return
|
||||
}
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
this.errMessage = '地址需以 http:// 或 https:// 开头'
|
||||
this.errHint = '示例:http://192.168.1.100:8000'
|
||||
return
|
||||
}
|
||||
this.savingUrl = true
|
||||
try {
|
||||
await AuthStore.setBaseUrl(url)
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
this.savedTip = '已保存'
|
||||
} catch (_e) {
|
||||
this.errMessage = '保存失败,请重试'
|
||||
this.errHint = ''
|
||||
} finally {
|
||||
this.savingUrl = false
|
||||
}
|
||||
}
|
||||
|
||||
private async resetBaseUrl(): Promise<void> {
|
||||
this.savingUrl = true
|
||||
try {
|
||||
await AuthStore.setBaseUrl(DEFAULT_BASE_URL)
|
||||
this.baseUrlInput = DEFAULT_BASE_URL
|
||||
this.savedTip = '已恢复默认'
|
||||
} catch (_e) {
|
||||
this.errMessage = '保存失败,请重试'
|
||||
this.errHint = ''
|
||||
} finally {
|
||||
this.savingUrl = false
|
||||
}
|
||||
}
|
||||
|
||||
private regenDeviceKey(): void {
|
||||
this.deviceKey = generateDeviceKey()
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column({ space: 22 }) {
|
||||
// 品牌区
|
||||
Column({ space: 10 }) {
|
||||
Text('美搭')
|
||||
.fontSize(48)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.letterSpacing(6)
|
||||
Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质')
|
||||
.fontSize(14)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.margin({ top: 72, bottom: 8 })
|
||||
|
||||
// 设备标识
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text('设备标识')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Blank()
|
||||
Text('随机生成')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#F7E3EA')
|
||||
.onClick(() => this.regenDeviceKey())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
|
||||
.height(48)
|
||||
.fontSize(15)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onChange((value: string) => {
|
||||
this.deviceKey = value
|
||||
})
|
||||
|
||||
Text('同一标识对应同一账号与配额,换标识即换新用户')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 高级设置(服务器地址,真机连开发机靠它)
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('高级设置 · 服务器地址')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Blank()
|
||||
Text(this.showAdvanced ? '收起' : '展开')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
this.savedTip = ''
|
||||
})
|
||||
|
||||
if (this.showAdvanced) {
|
||||
Column({ space: 8 }) {
|
||||
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
|
||||
.height(46)
|
||||
.fontSize(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onChange((value: string) => {
|
||||
this.baseUrlInput = value
|
||||
})
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Button(this.savingUrl ? '保存中…' : '保存地址')
|
||||
.fontSize(14)
|
||||
.height(38)
|
||||
.borderRadius(19)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.fontColor('#FFFFFF')
|
||||
.enabled(!this.savingUrl)
|
||||
.onClick(() => this.saveBaseUrl())
|
||||
|
||||
Button('恢复默认')
|
||||
.fontSize(14)
|
||||
.height(38)
|
||||
.borderRadius(19)
|
||||
.backgroundColor('#F0E0E6')
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.enabled(!this.savingUrl)
|
||||
.onClick(() => this.resetBaseUrl())
|
||||
|
||||
if (this.savedTip !== '') {
|
||||
Text(this.savedTip)
|
||||
.fontSize(13)
|
||||
.fontColor('#2E9E5B')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('模拟器直连本机可用默认地址;真机请填开发机局域网 IP(如 http://192.168.1.100:8000)')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 登录按钮
|
||||
Button(this.logging ? '正在进入…' : '进入美搭')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.borderRadius(25)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.fontColor('#FFFFFF')
|
||||
.enabled(!this.logging && this.deviceKey.trim() !== '')
|
||||
.onClick(() => this.doLogin())
|
||||
|
||||
// 错误横幅(登录/地址保存失败)
|
||||
ErrorBanner({
|
||||
message: this.errMessage,
|
||||
hint: this.errHint,
|
||||
onClose: () => {
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 24, right: 24, bottom: 32 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F7F2F4')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* ProfilePage(F1)—— 个人中心:用户脱敏 ID、登出、配额卡片、最近流水。
|
||||
*
|
||||
* 数据契约(全部走 ApiClient/AuthStore,不直接碰 http):
|
||||
* - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取)
|
||||
* - 流水:GET /api/v1/quota/ledger?limit=10
|
||||
* 周期文案按架构文档 §7.4 配额映射:agent_chat/tryon=日、report_basic=终身一次。
|
||||
*
|
||||
* ArkTS 约束:wire 用具名 interface、UI 状态用带默认值的 class + new、
|
||||
* 未知异常包 ApiError、async 全部放具名方法、显式泛型参数。
|
||||
*/
|
||||
|
||||
import router from '@ohos.router'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
import { ErrorBanner } from '../components/f1/ErrorBanner'
|
||||
import { QuotaCardView } from '../components/f1/QuotaCard'
|
||||
import { LedgerItemView } from '../components/f1/LedgerItem'
|
||||
|
||||
/** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
/** GET /api/v1/quota/ledger 数组元素。 */
|
||||
interface LedgerWire {
|
||||
tx_id: string
|
||||
delta: number
|
||||
tx_type: string
|
||||
status: string
|
||||
quota_key: string
|
||||
balance_after: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 配额卡片的 UI 状态模型。 */
|
||||
class QuotaCardData {
|
||||
title: string
|
||||
used: number = 0
|
||||
total: number = 0
|
||||
remaining: number = 0
|
||||
resetText: string = ''
|
||||
loaded: boolean = false
|
||||
errorText: string = ''
|
||||
|
||||
constructor(title: string) {
|
||||
this.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/** 流水行的 UI 状态模型(映射/配色在构造期完成)。 */
|
||||
class LedgerRowData {
|
||||
typeText: string = ''
|
||||
statusText: string = ''
|
||||
statusColor: string = '#999999'
|
||||
statusBg: string = '#F4F4F4'
|
||||
quotaText: string = ''
|
||||
timeText: string = ''
|
||||
deltaText: string = ''
|
||||
deltaColor: string = '#666666'
|
||||
balanceText: string = ''
|
||||
}
|
||||
|
||||
const COLOR_POSITIVE = '#2E9E5B'
|
||||
const COLOR_NEGATIVE = '#C0392B'
|
||||
const COLOR_MUTED = '#8A8A8A'
|
||||
|
||||
/** quota_key → 中文名(对齐产品方案 §3.2 / 架构文档 §7.4)。 */
|
||||
function quotaName(key: string): string {
|
||||
if (key === 'agent_chat') {
|
||||
return 'AI 对话'
|
||||
}
|
||||
if (key === 'tryon') {
|
||||
return '换装试穿'
|
||||
}
|
||||
if (key === 'hairstyle') {
|
||||
return '发型试戴'
|
||||
}
|
||||
if (key === 'hd_export') {
|
||||
return '高清导出'
|
||||
}
|
||||
if (key === 'report_basic') {
|
||||
return '美学报告'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '报告重新生成'
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/** 是否日配额(决定「明日恢复」文案)。 */
|
||||
function isDailyQuota(key: string): boolean {
|
||||
return key === 'agent_chat' || key === 'tryon' || key === 'hairstyle' || key === 'hd_export'
|
||||
}
|
||||
|
||||
/** 周期文案:日配额=明日恢复;月/终身有 reset_at 显示日期;null 按周期区分。 */
|
||||
function resetTextFor(key: string, resetAt: string | null): string {
|
||||
if (resetAt === null || resetAt === '') {
|
||||
if (key === 'report_basic') {
|
||||
return '一次性额度'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '每月重置'
|
||||
}
|
||||
return '每日重置'
|
||||
}
|
||||
if (isDailyQuota(key)) {
|
||||
return '明日恢复'
|
||||
}
|
||||
const short = resetAt.length >= 10 ? resetAt.substring(5, 10) : resetAt
|
||||
return `${short} 重置`
|
||||
}
|
||||
|
||||
/** tx_type → 中文(V1 三种,V2 预留 purchase/gift)。 */
|
||||
function txTypeText(t: string): string {
|
||||
if (t === 'consume') {
|
||||
return '消耗'
|
||||
}
|
||||
if (t === 'refund') {
|
||||
return '退还'
|
||||
}
|
||||
if (t === 'system_grant') {
|
||||
return '奖励'
|
||||
}
|
||||
if (t === 'purchase') {
|
||||
return '购买'
|
||||
}
|
||||
if (t === 'gift') {
|
||||
return '赠送'
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** status → 中文(预扣三段式:reserved → committed;退还写反向流水)。 */
|
||||
function statusTextOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '进行中'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '已入账'
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return '已退还'
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function statusColorOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#B9770E'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return COLOR_POSITIVE
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return COLOR_MUTED
|
||||
}
|
||||
return '#999999'
|
||||
}
|
||||
|
||||
function statusBgOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#FBF0DC'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '#E4F3EA'
|
||||
}
|
||||
return '#F4F4F4'
|
||||
}
|
||||
|
||||
/** ISO 时间 → 'MM-DD HH:mm'。 */
|
||||
function timeText(iso: string): string {
|
||||
if (iso === '') {
|
||||
return '—'
|
||||
}
|
||||
const t = iso.replace('T', ' ')
|
||||
return t.length >= 16 ? t.substring(5, 16) : t
|
||||
}
|
||||
|
||||
/** 用户 ID 脱敏:前 6 后 4。 */
|
||||
function maskUserId(id: string): string {
|
||||
if (id === '') {
|
||||
return '未登录'
|
||||
}
|
||||
if (id.length <= 10) {
|
||||
return id
|
||||
}
|
||||
return `${id.substring(0, 6)}…${id.substring(id.length - 4)}`
|
||||
}
|
||||
|
||||
/** wire → 行视图模型(对象字面量禁裸用,故用 class + 字段赋值)。 */
|
||||
function toLedgerRow(w: LedgerWire): LedgerRowData {
|
||||
const row = new LedgerRowData()
|
||||
row.typeText = txTypeText(w.tx_type)
|
||||
row.statusText = statusTextOf(w.status)
|
||||
row.statusColor = statusColorOf(w.status)
|
||||
row.statusBg = statusBgOf(w.status)
|
||||
row.quotaText = quotaName(w.quota_key)
|
||||
row.timeText = timeText(w.created_at)
|
||||
row.deltaText = w.delta > 0 ? `+${w.delta}` : `${w.delta}`
|
||||
row.deltaColor = w.delta > 0 ? COLOR_POSITIVE : (w.delta < 0 ? COLOR_NEGATIVE : COLOR_MUTED)
|
||||
row.balanceText = `余额 ${w.balance_after}`
|
||||
return row
|
||||
}
|
||||
|
||||
function toApiError(e: Object): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
return e
|
||||
}
|
||||
return new ApiError(50000, '请求失败,请检查网络', '请稍后重试')
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ProfilePage {
|
||||
@State userIdMasked: string = ''
|
||||
@State cardChat: QuotaCardData = new QuotaCardData('AI 对话')
|
||||
@State cardTryon: QuotaCardData = new QuotaCardData('换装试穿')
|
||||
@State cardReport: QuotaCardData = new QuotaCardData('美学报告')
|
||||
@State ledgerRows: LedgerRowData[] = []
|
||||
@State ledgerLoaded: boolean = false
|
||||
@State ledgerError: string = ''
|
||||
@State bannerMessage: string = ''
|
||||
@State bannerHint: string = ''
|
||||
@State refreshing: boolean = false
|
||||
@State loggingOut: boolean = false
|
||||
private pendingLoads: number = 0
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (!AuthStore.isLoggedIn()) {
|
||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||
return
|
||||
}
|
||||
const uid = AuthStore.getUserId()
|
||||
this.userIdMasked = maskUserId(uid !== undefined ? uid : '')
|
||||
this.startLoadAll()
|
||||
}
|
||||
|
||||
/** 并行拉三张配额卡 + 流水;计数归零时收起下拉刷新。 */
|
||||
private startLoadAll(): void {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
this.ledgerError = ''
|
||||
this.cardChat = new QuotaCardData('AI 对话')
|
||||
this.cardTryon = new QuotaCardData('换装试穿')
|
||||
this.cardReport = new QuotaCardData('美学报告')
|
||||
this.pendingLoads = 4
|
||||
this.loadQuota('agent_chat')
|
||||
this.loadQuota('tryon')
|
||||
this.loadQuota('report_basic')
|
||||
this.loadLedger()
|
||||
}
|
||||
|
||||
private finishOne(): void {
|
||||
if (this.pendingLoads > 0) {
|
||||
this.pendingLoads -= 1
|
||||
}
|
||||
if (this.pendingLoads === 0) {
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async loadQuota(key: string): Promise<void> {
|
||||
try {
|
||||
const q = await ApiClient.get<QuotaWire>(`/api/v1/quota?quota_key=${key}`)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.used = q.used
|
||||
data.total = q.limit_value + q.bonus_granted
|
||||
data.remaining = q.remaining
|
||||
data.resetText = resetTextFor(key, q.reset_at)
|
||||
data.loaded = true
|
||||
this.applyQuota(key, data)
|
||||
} catch (e) {
|
||||
// 单卡失败不拖垮整页:错误落在卡片内
|
||||
const err = toApiError(e)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.loaded = true
|
||||
data.errorText = err.message
|
||||
this.applyQuota(key, data)
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuota(key: string, data: QuotaCardData): void {
|
||||
if (key === 'agent_chat') {
|
||||
this.cardChat = data
|
||||
} else if (key === 'tryon') {
|
||||
this.cardTryon = data
|
||||
} else {
|
||||
this.cardReport = data
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLedger(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<LedgerWire[]>('/api/v1/quota/ledger?limit=10')
|
||||
const rows: LedgerRowData[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
rows.push(toLedgerRow(list[i]))
|
||||
}
|
||||
this.ledgerRows = rows
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = ''
|
||||
} catch (e) {
|
||||
const err = toApiError(e)
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = err.hint !== undefined && err.hint !== '' ? `${err.message}(${err.hint})` : err.message
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private confirmLogout(): void {
|
||||
AlertDialog.show({
|
||||
title: '确认登出',
|
||||
message: '登出后本地会话将清除,配额与报告数据仍保留在账号下。',
|
||||
primaryButton: {
|
||||
value: '取消',
|
||||
action: () => {
|
||||
// 仅关闭弹窗
|
||||
}
|
||||
},
|
||||
secondaryButton: {
|
||||
value: '登出',
|
||||
fontColor: '#C0392B',
|
||||
action: () => this.performLogout()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async performLogout(): Promise<void> {
|
||||
if (this.loggingOut) {
|
||||
return
|
||||
}
|
||||
this.loggingOut = true
|
||||
try {
|
||||
await AuthStore.logout()
|
||||
} finally {
|
||||
this.loggingOut = false
|
||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// 顶部:用户信息 + 登出
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text('美')
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.borderRadius(28)
|
||||
.backgroundColor('#C2185B')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text('美搭用户')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Text(`ID ${this.userIdMasked}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#9A9A9A')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Button(this.loggingOut ? '登出中…' : '登出')
|
||||
.fontSize(13)
|
||||
.height(32)
|
||||
.borderRadius(16)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor('#F7E3EA')
|
||||
.fontColor('#C2185B')
|
||||
.enabled(!this.loggingOut)
|
||||
.onClick(() => this.confirmLogout())
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
ErrorBanner({
|
||||
message: this.bannerMessage,
|
||||
hint: this.bannerHint,
|
||||
onClose: () => {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 配额三卡
|
||||
Row({ space: 10 }) {
|
||||
QuotaCardView({
|
||||
title: this.cardChat.title,
|
||||
remaining: this.cardChat.remaining,
|
||||
used: this.cardChat.used,
|
||||
total: this.cardChat.total,
|
||||
resetText: this.cardChat.resetText,
|
||||
loaded: this.cardChat.loaded,
|
||||
errorText: this.cardChat.errorText,
|
||||
accent: '#C2185B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardTryon.title,
|
||||
remaining: this.cardTryon.remaining,
|
||||
used: this.cardTryon.used,
|
||||
total: this.cardTryon.total,
|
||||
resetText: this.cardTryon.resetText,
|
||||
loaded: this.cardTryon.loaded,
|
||||
errorText: this.cardTryon.errorText,
|
||||
accent: '#7A5AF8'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardReport.title,
|
||||
remaining: this.cardReport.remaining,
|
||||
used: this.cardReport.used,
|
||||
total: this.cardReport.total,
|
||||
resetText: this.cardReport.resetText,
|
||||
loaded: this.cardReport.loaded,
|
||||
errorText: this.cardReport.errorText,
|
||||
accent: '#2E9E5B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// 最近流水
|
||||
Column({ space: 4 }) {
|
||||
Text('最近流水')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
.width('100%')
|
||||
|
||||
if (!this.ledgerLoaded) {
|
||||
Text('加载中…')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerError !== '') {
|
||||
Text(this.ledgerError)
|
||||
.fontSize(13)
|
||||
.fontColor('#C0392B')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerRows.length === 0) {
|
||||
Text('暂无流水记录')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else {
|
||||
ForEach(this.ledgerRows, (row: LedgerRowData) => {
|
||||
LedgerItemView({
|
||||
typeText: row.typeText,
|
||||
statusText: row.statusText,
|
||||
statusColor: row.statusColor,
|
||||
statusBg: row.statusBg,
|
||||
quotaText: row.quotaText,
|
||||
timeText: row.timeText,
|
||||
deltaText: row.deltaText,
|
||||
deltaColor: row.deltaColor,
|
||||
balanceText: row.balanceText
|
||||
})
|
||||
}, (row: LedgerRowData) => `${row.typeText}-${row.quotaText}-${row.timeText}`)
|
||||
}
|
||||
|
||||
if (this.ledgerLoaded && this.ledgerRows.length > 0) {
|
||||
Text('仅展示最近 10 条,下拉可刷新')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 14, bottom: 32 })
|
||||
}
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
}
|
||||
.onRefreshing(() => {
|
||||
this.startLoadAll()
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F6F2F3')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
|
||||
*
|
||||
* 流程:
|
||||
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验
|
||||
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
|
||||
* 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放)
|
||||
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录),
|
||||
* 无记录则提示稍后在任务中心查看
|
||||
* 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回)
|
||||
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 幂等重试
|
||||
*/
|
||||
|
||||
import { router, componentUtils } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
import { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
|
||||
import { ReportCreateResp, buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes'
|
||||
|
||||
const COLOR_PRIMARY: string = '#C2185B'
|
||||
const GENDER_OPTIONS: string[] = ['女', '男', '不便告知']
|
||||
const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈']
|
||||
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
|
||||
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
|
||||
|
||||
interface FieldLabelParams {
|
||||
label: string
|
||||
err: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ReportCreatePage {
|
||||
@State phase: string = 'form' // form | polling | failed
|
||||
@State gender: string = ''
|
||||
@State heightInput: string = ''
|
||||
@State weightInput: string = ''
|
||||
@State birthday: Date = new Date(2000, 0, 15)
|
||||
@State coreGoals: string[] = []
|
||||
@State psychStyle: string = ''
|
||||
@State photoKeyInput: string = ''
|
||||
@State genderErr: string = ''
|
||||
@State heightErr: string = ''
|
||||
@State weightErr: string = ''
|
||||
@State goalsErr: string = ''
|
||||
@State styleErr: string = ''
|
||||
@State submitting: boolean = false
|
||||
@State showDatePicker: boolean = false
|
||||
@State formSummary: string = ''
|
||||
@State navError: string = ''
|
||||
@State submitError: string = ''
|
||||
@State failMsg: string = ''
|
||||
@State showDupDialog: boolean = false
|
||||
@State lastReportId: string = ''
|
||||
private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度
|
||||
private taskId: string = ''
|
||||
private reportId: string = ''
|
||||
private scroller: Scroller = new Scroller()
|
||||
|
||||
aboutToAppear(): void {
|
||||
const cached = AppStorage.get<string>(LAST_REPORT_STORAGE_KEY)
|
||||
if (cached !== undefined && cached !== '') {
|
||||
this.lastReportId = cached
|
||||
}
|
||||
}
|
||||
|
||||
private isNumeric(s: string): boolean {
|
||||
return /^\d+(\.\d+)?$/.test(s.trim())
|
||||
}
|
||||
|
||||
private validateForm(): boolean {
|
||||
let ok = true
|
||||
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
||||
if (this.genderErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
const h = Number(this.heightInput)
|
||||
this.heightErr = !this.isNumeric(this.heightInput) || h < 100 || h > 250 ? '需为 100~250' : ''
|
||||
if (this.heightErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
const w = Number(this.weightInput)
|
||||
this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : ''
|
||||
if (this.weightErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
this.goalsErr = this.coreGoals.length === 0 ? '至少选择 1 项' : ''
|
||||
if (this.goalsErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
this.styleErr = this.psychStyle === '' ? '请选择心理风格' : ''
|
||||
if (this.styleErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private toggleGoal(g: string): void {
|
||||
const idx = this.coreGoals.indexOf(g)
|
||||
if (idx >= 0) {
|
||||
this.coreGoals.splice(idx, 1)
|
||||
} else {
|
||||
this.coreGoals.push(g)
|
||||
}
|
||||
this.goalsErr = ''
|
||||
}
|
||||
|
||||
private isSelectedGoal(g: string): boolean {
|
||||
return this.coreGoals.indexOf(g) >= 0
|
||||
}
|
||||
|
||||
private onSubmitTap(): void {
|
||||
if (this.submitting) {
|
||||
return
|
||||
}
|
||||
this.formSummary = ''
|
||||
if (!this.validateForm()) {
|
||||
// 字段级红字可能滚在视野外(真机实测的"点了没反应"就是这种情况):
|
||||
// 汇总条点名缺哪几项,并自动滚到第一个缺失字段。
|
||||
const labels: string[] = this.missingLabels()
|
||||
this.formSummary = labels.length > 0 ? `请完成:${labels.join('、')}` : '请检查标红的字段'
|
||||
this.scrollToFirstError()
|
||||
return
|
||||
}
|
||||
this.doSubmit()
|
||||
}
|
||||
|
||||
private missingLabels(): string[] {
|
||||
const labels: string[] = []
|
||||
if (this.genderErr !== '') {
|
||||
labels.push('性别')
|
||||
}
|
||||
if (this.heightErr !== '') {
|
||||
labels.push('身高')
|
||||
}
|
||||
if (this.weightErr !== '') {
|
||||
labels.push('体重')
|
||||
}
|
||||
if (this.goalsErr !== '') {
|
||||
labels.push('核心诉求')
|
||||
}
|
||||
if (this.styleErr !== '') {
|
||||
labels.push('心理风格')
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
private scrollToFirstError(): void {
|
||||
let firstId = ''
|
||||
if (this.genderErr !== '') {
|
||||
firstId = 'field_gender'
|
||||
} else if (this.heightErr !== '' || this.weightErr !== '') {
|
||||
firstId = 'field_height'
|
||||
} else if (this.goalsErr !== '') {
|
||||
firstId = 'field_goals'
|
||||
} else if (this.styleErr !== '') {
|
||||
firstId = 'field_style'
|
||||
}
|
||||
if (firstId === '') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// windowOffset 随滚动同步移动,两处相减即为内容坐标差,
|
||||
// 与当前滚动位置无关
|
||||
const root = componentUtils.getRectangleById('form_root')
|
||||
const target = componentUtils.getRectangleById(firstId)
|
||||
const dy = target.windowOffset.y - root.windowOffset.y - 8
|
||||
this.scroller.scrollTo({ xOffset: 0, yOffset: Math.max(0, dy), animation: { duration: 250, curve: Curve.EaseOut } })
|
||||
} catch (e) {
|
||||
console.error(`scrollTo ${firstId} failed: ${String(e).substring(0, 80)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async doSubmit(): Promise<void> {
|
||||
this.submitting = true
|
||||
this.submitError = ''
|
||||
if (this.requestKey === '') {
|
||||
// 随机 hex;FAILED 后的重试复用同一 key(见 retrySubmit 注释)
|
||||
this.requestKey = randomHex(16)
|
||||
}
|
||||
const heightVal: number = Number(this.heightInput)
|
||||
const weightVal: number = Number(this.weightInput)
|
||||
const goalsObj: Object = this.coreGoals
|
||||
const questionnaire: Record<string, Object> = {
|
||||
'gender': this.gender,
|
||||
'height_cm': heightVal,
|
||||
'weight_kg': weightVal,
|
||||
'birthday': formatDate(this.birthday),
|
||||
'core_goals': goalsObj,
|
||||
'psych_style': this.psychStyle
|
||||
}
|
||||
const qObj: Object = questionnaire
|
||||
const photoInput = this.photoKeyInput.trim()
|
||||
const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous')
|
||||
const req: Record<string, Object> = {
|
||||
'questionnaire': qObj,
|
||||
'photo_object_key': photoKey,
|
||||
'request_key': this.requestKey
|
||||
}
|
||||
try {
|
||||
const data = await ApiClient.post<ReportCreateResp>('/api/v1/reports', req)
|
||||
this.reportId = data.report_id ?? ''
|
||||
this.taskId = data.task_id ?? ''
|
||||
if (this.reportId !== '') {
|
||||
this.lastReportId = this.reportId
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.reportId)
|
||||
}
|
||||
if (this.taskId === '') {
|
||||
this.submitError = '服务端未返回任务 ID,请稍后重试'
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
this.submitting = false
|
||||
this.phase = 'polling'
|
||||
} catch (e) {
|
||||
this.submitting = false
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 40920) {
|
||||
// 本期报告已生成:给“查看我的报告”入口(无记录则提示稍后在任务中心看)
|
||||
this.showDupDialog = true
|
||||
return
|
||||
}
|
||||
this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
return
|
||||
}
|
||||
this.submitError = '请求失败,请检查网络'
|
||||
}
|
||||
}
|
||||
|
||||
private retrySubmit(): void {
|
||||
// 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理,
|
||||
// 不会重复生成报告或重复扣减额度
|
||||
this.failMsg = ''
|
||||
this.doSubmit()
|
||||
}
|
||||
|
||||
private onTaskSucceeded(): void {
|
||||
const params: Record<string, Object> = { 'reportId': this.reportId }
|
||||
// 显式 catch:replaceUrl 的 Promise 拒绝在无 catch 时完全静默(真机实测
|
||||
// 卡在轮询页无任何提示),至少把失败暴露出来并由兜底按钮接管。
|
||||
router.replaceUrl({ url: 'pages/ReportResultPage', params: params })
|
||||
.catch((e: Object): void => {
|
||||
this.navError = `报告已生成,但打开结果页失败:${String(e).substring(0, 80)}`
|
||||
})
|
||||
}
|
||||
|
||||
private onTaskFailed(reason: string): void {
|
||||
this.phase = 'failed'
|
||||
this.failMsg = reason
|
||||
}
|
||||
|
||||
private goLastReport(): void {
|
||||
this.showDupDialog = false
|
||||
if (this.lastReportId === '') {
|
||||
return
|
||||
}
|
||||
const params: Record<string, Object> = { 'reportId': this.lastReportId }
|
||||
router.pushUrl({ url: 'pages/ReportResultPage', params: params })
|
||||
}
|
||||
|
||||
@Builder
|
||||
TopBar() {
|
||||
Row({ space: 8 }) {
|
||||
Text('←')
|
||||
.fontSize(20)
|
||||
.fontColor('#333333')
|
||||
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('生成报告')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 16, top: 10, bottom: 6 })
|
||||
}
|
||||
|
||||
// 按引用传递($$):@Builder 按值传参时 @State 变化不会触发刷新,
|
||||
// 字段级红字将永远停在初始空值(真机实测复现)
|
||||
@Builder
|
||||
FieldLabel($$: FieldLabelParams) {
|
||||
Row({ space: 6 }) {
|
||||
if ($$.required) {
|
||||
Text('*')
|
||||
.fontSize(14)
|
||||
.fontColor('#E5484D')
|
||||
}
|
||||
Text($$.label)
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
if ($$.err !== '') {
|
||||
Text($$.err)
|
||||
.fontSize(11)
|
||||
.fontColor('#E5484D')
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
FormBody() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text('生成你的形象诊断报告')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告')
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
|
||||
Column({ space: 14 }) {
|
||||
Text('基础信息')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '性别', err: this.genderErr, required: true })
|
||||
Row({ space: 8 }) {
|
||||
ForEach(GENDER_OPTIONS, (g: string) => {
|
||||
Text(g)
|
||||
.fontSize(14)
|
||||
.fontColor(this.gender === g ? '#FFFFFF' : '#444444')
|
||||
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
|
||||
.backgroundColor(this.gender === g ? COLOR_PRIMARY : '#F3F4F6')
|
||||
.borderRadius(14)
|
||||
.onClick(() => {
|
||||
this.gender = g
|
||||
this.genderErr = ''
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.id('field_gender')
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true })
|
||||
TextInput({ placeholder: '如 165', text: this.heightInput })
|
||||
.type(InputType.Number)
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
this.heightInput = v
|
||||
this.heightErr = ''
|
||||
})
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.id('field_height')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true })
|
||||
TextInput({ placeholder: '如 52', text: this.weightInput })
|
||||
.type(InputType.Number)
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
this.weightInput = v
|
||||
this.weightErr = ''
|
||||
})
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '生日', err: '', required: false })
|
||||
Row({ space: 12 }) {
|
||||
Text(`当前选择:${formatDate(this.birthday)}`)
|
||||
.fontSize(13)
|
||||
.fontColor('#333333')
|
||||
.layoutWeight(1)
|
||||
Text(this.showDatePicker ? '收起 ▲' : '修改 ▼')
|
||||
.fontSize(13)
|
||||
.fontColor('#C2185B')
|
||||
.onClick(() => {
|
||||
this.showDatePicker = !this.showDatePicker
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
// DatePicker 默认收起:展开态高约半屏,会把心理风格/提交按钮挤出
|
||||
// 视野并把滚动手势吃掉(真机实测:用户看不到必填项与错误提示,
|
||||
// 表现为"提交没反应")。需要改生日时才展开。
|
||||
if (this.showDatePicker) {
|
||||
DatePicker({
|
||||
start: new Date(1930, 0, 1),
|
||||
end: new Date(),
|
||||
selected: this.birthday
|
||||
})
|
||||
.onDateChange((d: Date) => {
|
||||
this.birthday = d
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
Column({ space: 14 }) {
|
||||
Text('风格偏好')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '核心诉求(多选)', err: this.goalsErr, required: true })
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(GOAL_OPTIONS, (g: string) => {
|
||||
Text(g)
|
||||
.fontSize(13)
|
||||
.fontColor(this.isSelectedGoal(g) ? '#FFFFFF' : '#444444')
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.isSelectedGoal(g) ? COLOR_PRIMARY : '#F3F4F6')
|
||||
.borderRadius(13)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.toggleGoal(g)
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.id('field_goals')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '心理风格(九型·单选)', err: this.styleErr, required: true })
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(STYLE_OPTIONS, (s: string) => {
|
||||
Text(s)
|
||||
.fontSize(13)
|
||||
.fontColor(this.psychStyle === s ? '#FFFFFF' : '#444444')
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.psychStyle === s ? COLOR_PRIMARY : '#F3F4F6')
|
||||
.borderRadius(13)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.psychStyle = s
|
||||
this.styleErr = ''
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.id('field_style')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Text('照片(模拟)')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Text('V1 骨架:COS 直传未接入,此处模拟对象键')
|
||||
.fontSize(11)
|
||||
.fontColor('#999999')
|
||||
TextInput({ placeholder: 'report_photo/xxx.jpg(选填)', text: this.photoKeyInput })
|
||||
.height(44)
|
||||
.fontSize(13)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
this.photoKeyInput = v
|
||||
})
|
||||
Text('留空时自动生成 report_photo/{userId}/{随机}.jpg')
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.formSummary !== '') {
|
||||
Text(this.formSummary)
|
||||
.fontSize(13)
|
||||
.fontColor('#E5484D')
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.backgroundColor('#FDECEC')
|
||||
.borderRadius(8)
|
||||
.textAlign(TextAlign.Start)
|
||||
.onClick(() => {
|
||||
this.scrollToFirstError()
|
||||
})
|
||||
}
|
||||
if (this.submitError !== '') {
|
||||
Text(this.submitError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E5484D')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
|
||||
Button(this.submitting ? '提交中…' : '提交并生成报告')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.enabled(!this.submitting)
|
||||
.margin({ bottom: 24 })
|
||||
.onClick(() => {
|
||||
this.onSubmitTap()
|
||||
})
|
||||
}
|
||||
.padding({ left: 16, right: 16 })
|
||||
.id('form_root')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
PollingBody() {
|
||||
Column({ space: 16 }) {
|
||||
Text('正在生成你的专属报告')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('32 维形象诊断通常需要 1~3 分钟,生成完成后自动进入结果页')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
.textAlign(TextAlign.Center)
|
||||
TaskPollingPanel({
|
||||
taskId: this.taskId,
|
||||
onSucceeded: (): void => {
|
||||
this.onTaskSucceeded()
|
||||
},
|
||||
onFailed: (reason: string): void => {
|
||||
this.onTaskFailed(reason)
|
||||
}
|
||||
})
|
||||
.width('86%')
|
||||
if (this.navError !== '') {
|
||||
Text(this.navError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E5484D')
|
||||
.width('86%')
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
// 兜底:自动跳转若失败(或用户中途离开又回来),手动进结果页。
|
||||
// reportId 在提交成功时即已保存,轮询页始终可进。
|
||||
if (this.reportId !== '') {
|
||||
Button('查看报告')
|
||||
.fontSize(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.fontColor('#C2185B')
|
||||
.borderRadius(20)
|
||||
.padding({ left: 24, right: 24 })
|
||||
.margin({ top: 8 })
|
||||
.onClick(() => {
|
||||
this.onTaskSucceeded()
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
FailedBody() {
|
||||
Column({ space: 12 }) {
|
||||
Text('生成失败')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text(this.failMsg)
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('重试将沿用同一 request_key 重新提交,服务端按幂等重放处理,不会重复消耗额度。')
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
.textAlign(TextAlign.Center)
|
||||
Button('重试提交')
|
||||
.width('62%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.margin({ top: 8 })
|
||||
.onClick(() => {
|
||||
this.retrySubmit()
|
||||
})
|
||||
Button('返回修改问卷')
|
||||
.width('62%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.backgroundColor('#F3F4F6')
|
||||
.fontColor('#666666')
|
||||
.onClick(() => {
|
||||
this.phase = 'form'
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 32, right: 32 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
DupDialog() {
|
||||
Column({ space: 14 }) {
|
||||
Text('本期报告已生成')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('每个周期仅可生成一次完整报告,无需重复提交。')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
if (this.lastReportId !== '') {
|
||||
Text('将为你打开最近一次生成的报告。')
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
} else {
|
||||
Text('本期报告稍后可在任务中心查看。')
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
Row({ space: 12 }) {
|
||||
Button('我知道了')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F3F4F6')
|
||||
.fontColor('#666666')
|
||||
.onClick(() => {
|
||||
this.showDupDialog = false
|
||||
})
|
||||
if (this.lastReportId !== '') {
|
||||
Button('查看我的报告')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.goLastReport()
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
this.TopBar()
|
||||
if (this.phase === 'form') {
|
||||
this.FormBody()
|
||||
} else if (this.phase === 'polling') {
|
||||
this.PollingBody()
|
||||
} else {
|
||||
this.FailedBody()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
if (this.showDupDialog) {
|
||||
Column() {
|
||||
this.DupDialog()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#66000000')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F5F6F8')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* ReportResultPage —— 报告结果展示(F3)。
|
||||
*
|
||||
* - reportId 从 router.getParams() 取,GET /api/v1/reports/{id}
|
||||
* - 顶部状态:version / status / completed_at(生成中给刷新入口)
|
||||
* - 降级透明化:result.validation.degraded === true 时顶部黄条
|
||||
* “本报告由规则引擎直接生成”
|
||||
* - 文案区:result.copy.summary + sections(title + paragraphs)
|
||||
* - 32 维诊断区:status=ok(已分析)与 skipped(未覆盖,灰显附原因)两组;
|
||||
* 卡片内含来源标签中文映射、置信度百分比、<0.7“仅供参考”(NFR-UX-05)、
|
||||
* rule_hits 折叠展开
|
||||
* - 重新生成:POST /api/v1/reports/{id}/regenerate → 拿新 task_id 在本页复用
|
||||
* TaskPollingPanel 轮询(比跳回 Create 页携带参数更简单),SUCCEEDED 后重拉报告;
|
||||
* 失败重试沿用同一 request_key(服务端幂等重放)
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
|
||||
import { DimensionCard } from '../components/f3/DimensionCard'
|
||||
import {
|
||||
CopySectionWire,
|
||||
DimensionWire,
|
||||
ReportCreateResp,
|
||||
ReportDetailWire,
|
||||
ReportRouteParams,
|
||||
formatIsoTime,
|
||||
randomHex,
|
||||
splitDimensions,
|
||||
statusColorOf,
|
||||
statusLabelOf
|
||||
} from '../components/f3/ReportTypes'
|
||||
|
||||
const COLOR_PRIMARY: string = '#C2185B'
|
||||
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ReportResultPage {
|
||||
@State loading: boolean = true
|
||||
@State loadError: string = ''
|
||||
@State reportId: string = ''
|
||||
@State versionLabel: string = 'v1'
|
||||
@State statusRaw: string = ''
|
||||
@State statusText: string = '—'
|
||||
@State completedText: string = '—'
|
||||
@State degraded: boolean = false
|
||||
@State summaryText: string = ''
|
||||
@State sections: CopySectionWire[] = []
|
||||
@State analyzed: DimensionWire[] = []
|
||||
@State skipped: DimensionWire[] = []
|
||||
@State totalLabel: string = '32'
|
||||
@State basicFullText: string = ''
|
||||
@State regenerating: boolean = false
|
||||
@State regenTaskId: string = ''
|
||||
@State regenError: string = ''
|
||||
private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放
|
||||
|
||||
aboutToAppear(): void {
|
||||
const raw = router.getParams()
|
||||
if (raw !== undefined && raw !== null) {
|
||||
const p = raw as ReportRouteParams
|
||||
const rid = p.reportId
|
||||
if (rid !== undefined && rid !== '') {
|
||||
this.reportId = rid
|
||||
}
|
||||
}
|
||||
if (this.reportId === '') {
|
||||
this.loading = false
|
||||
this.loadError = '缺少报告 ID,请从报告创建页进入'
|
||||
return
|
||||
}
|
||||
this.loadReport()
|
||||
}
|
||||
|
||||
private async loadReport(): Promise<void> {
|
||||
this.loading = true
|
||||
this.loadError = ''
|
||||
try {
|
||||
const d = await ApiClient.get<ReportDetailWire>(`/api/v1/reports/${this.reportId}`)
|
||||
this.versionLabel = d.version !== undefined && d.version !== null ? `v${d.version}` : 'v1'
|
||||
this.statusRaw = d.status ?? ''
|
||||
this.statusText = statusLabelOf(d.status)
|
||||
this.completedText = formatIsoTime(d.completed_at)
|
||||
const result = d.result
|
||||
this.degraded = result?.validation?.degraded === true
|
||||
this.summaryText = result?.copy?.summary ?? '(本报告暂无摘要)'
|
||||
this.sections = result?.copy?.sections ?? []
|
||||
const dims = result?.diagnosis?.dimensions ?? []
|
||||
const groups = splitDimensions(dims)
|
||||
this.analyzed = groups.analyzed
|
||||
this.skipped = groups.skipped
|
||||
const total = result?.diagnosis?.coverage?.total
|
||||
this.totalLabel = total !== undefined && total !== null ? `${total}` : `${dims.length}`
|
||||
const basicFull = result?.diagnosis?.coverage?.basic_full
|
||||
this.basicFullText = basicFull === true ? '基础信息完整' : (basicFull === false ? '基础信息不完整' : '')
|
||||
const rid = d.report_id
|
||||
if (rid !== undefined && rid !== '') {
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
|
||||
}
|
||||
this.loading = false
|
||||
this.regenRequestKey = '' // 报告已成功拉到,下次重新生成用新 key
|
||||
} catch (e) {
|
||||
this.loading = false
|
||||
if (e instanceof ApiError) {
|
||||
this.loadError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
} else {
|
||||
this.loadError = '请求失败,请检查网络'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async doRegenerate(): Promise<void> {
|
||||
if (this.regenerating || this.reportId === '') {
|
||||
return
|
||||
}
|
||||
this.regenError = ''
|
||||
if (this.regenRequestKey === '') {
|
||||
this.regenRequestKey = randomHex(16)
|
||||
}
|
||||
const req: Record<string, Object> = { 'request_key': this.regenRequestKey }
|
||||
try {
|
||||
const data = await ApiClient.post<ReportCreateResp>(`/api/v1/reports/${this.reportId}/regenerate`, req)
|
||||
const tid = data.task_id ?? ''
|
||||
if (tid === '') {
|
||||
this.regenError = '服务端未返回任务 ID,请稍后重试'
|
||||
return
|
||||
}
|
||||
this.regenTaskId = tid
|
||||
this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
this.regenError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
} else {
|
||||
this.regenError = '请求失败,请检查网络'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRegenSucceeded(): void {
|
||||
this.regenerating = false
|
||||
this.regenError = ''
|
||||
this.loadReport()
|
||||
}
|
||||
|
||||
private onRegenFailed(reason: string): void {
|
||||
this.regenerating = false
|
||||
this.regenError = `重新生成失败:${reason}`
|
||||
}
|
||||
|
||||
private dimKey(d: DimensionWire): string {
|
||||
const k = d.key
|
||||
if (k !== undefined && k !== '') {
|
||||
return k
|
||||
}
|
||||
const n = d.number
|
||||
return n !== undefined ? `dim-${n}` : `dim-${d.name_cn ?? ''}`
|
||||
}
|
||||
|
||||
@Builder
|
||||
TopBar() {
|
||||
Row({ space: 8 }) {
|
||||
Text('←')
|
||||
.fontSize(20)
|
||||
.fontColor('#333333')
|
||||
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('报告结果')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 16, top: 10, bottom: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
LoadingView() {
|
||||
Column({ space: 12 }) {
|
||||
LoadingProgress()
|
||||
.width(44)
|
||||
.height(44)
|
||||
.color(COLOR_PRIMARY)
|
||||
Text('报告加载中…')
|
||||
.fontSize(14)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ErrorView() {
|
||||
Column({ space: 12 }) {
|
||||
Text('报告加载失败')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text(this.loadError)
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
.textAlign(TextAlign.Center)
|
||||
Button('重试')
|
||||
.width('50%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.margin({ top: 8 })
|
||||
.onClick(() => {
|
||||
this.loadReport()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 32, right: 32 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
RegenView() {
|
||||
Column({ space: 16 }) {
|
||||
Text('正在重新生成报告')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('重新生成沿用同一报告 ID,完成后自动展示新版本')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
.textAlign(TextAlign.Center)
|
||||
TaskPollingPanel({
|
||||
taskId: this.regenTaskId,
|
||||
onSucceeded: (): void => {
|
||||
this.onRegenSucceeded()
|
||||
},
|
||||
onFailed: (reason: string): void => {
|
||||
this.onRegenFailed(reason)
|
||||
}
|
||||
})
|
||||
.width('86%')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
HeaderCard() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('形象诊断报告')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text(`${this.versionLabel}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Text(this.statusText)
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(statusColorOf(this.statusRaw))
|
||||
.borderRadius(10)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(`完成时间:${this.completedText}`)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
Text(`报告编号:${this.reportId}`)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
if (this.statusRaw !== 'SUCCEEDED') {
|
||||
Text('报告仍在生成中,可点击刷新查看最新状态。')
|
||||
.fontSize(12)
|
||||
.fontColor('#D46A0B')
|
||||
Button('刷新状态')
|
||||
.height(34)
|
||||
.fontSize(13)
|
||||
.backgroundColor('#FDF3E3')
|
||||
.fontColor('#D46A0B')
|
||||
.margin({ top: 4 })
|
||||
.onClick(() => {
|
||||
this.loadReport()
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
DegradedBanner() {
|
||||
Column({ space: 2 }) {
|
||||
Text('本报告由规则引擎直接生成')
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#8A6D3B')
|
||||
Text('(AI 文案通道暂不可用,已透明降级)')
|
||||
.fontSize(11)
|
||||
.fontColor('#A98F5F')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(10)
|
||||
.backgroundColor('#FFF7E0')
|
||||
.border({ width: 1, color: '#F0D48A' })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
CopyCard() {
|
||||
Column({ space: 10 }) {
|
||||
Text('报告文案')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Text(this.summaryText)
|
||||
.fontSize(14)
|
||||
.fontColor('#333333')
|
||||
.lineHeight(22)
|
||||
ForEach(this.sections, (s: CopySectionWire, idx: number) => {
|
||||
Column({ space: 6 }) {
|
||||
Text(s.title ?? '')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#444444')
|
||||
ForEach(s.paragraphs ?? [], (p: string) => {
|
||||
Text(p)
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.lineHeight(21)
|
||||
}, (p: string, i: number) => `${idx}-${i}`)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}, (s: CopySectionWire, idx: number) => `${idx}-${s.title ?? ''}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
DimGroupTitle(title: string, count: number, extra: string) {
|
||||
Row({ space: 8 }) {
|
||||
Text(title)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Text(`${count} 项`)
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
if (extra !== '') {
|
||||
Text(extra)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
DiagnosisArea() {
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('32 维形象诊断')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.layoutWeight(1)
|
||||
Text(`已分析 ${this.analyzed.length} / ${this.totalLabel}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.basicFullText !== '') {
|
||||
Text(this.basicFullText)
|
||||
.fontSize(11)
|
||||
.fontColor('#AAAAAA')
|
||||
}
|
||||
|
||||
this.DimGroupTitle('已分析', this.analyzed.length, '')
|
||||
if (this.analyzed.length === 0) {
|
||||
Text('暂无已分析维度')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
ForEach(this.analyzed, (d: DimensionWire) => {
|
||||
DimensionCard({ dim: d })
|
||||
}, (d: DimensionWire) => this.dimKey(d))
|
||||
|
||||
this.DimGroupTitle('未覆盖', this.skipped.length, '灰显·附原因')
|
||||
if (this.skipped.length === 0) {
|
||||
Text('全部维度已覆盖')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
ForEach(this.skipped, (d: DimensionWire) => {
|
||||
DimensionCard({ dim: d })
|
||||
}, (d: DimensionWire) => this.dimKey(d))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContentBody() {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
this.HeaderCard()
|
||||
if (this.degraded) {
|
||||
this.DegradedBanner()
|
||||
}
|
||||
this.CopyCard()
|
||||
this.DiagnosisArea()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
if (this.regenError !== '') {
|
||||
Text(this.regenError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E5484D')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
Button(this.regenError !== '' ? '重试重新生成' : '重新生成报告')
|
||||
.width('100%')
|
||||
.height(46)
|
||||
.fontSize(15)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.doRegenerate()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.TopBar()
|
||||
if (this.regenerating) {
|
||||
this.RegenView()
|
||||
} else if (this.loading) {
|
||||
this.LoadingView()
|
||||
} else if (this.loadError !== '') {
|
||||
this.ErrorView()
|
||||
} else {
|
||||
this.ContentBody()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F5F6F8')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"requestPermissions": [
|
||||
{ "name": "ohos.permission.INTERNET" }
|
||||
],
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "$string:EntryAbility_desc",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:EntryAbility_label",
|
||||
"startWindowIcon": "$media:startIcon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"ohos.want.action.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extensionAbilities": [
|
||||
{
|
||||
"name": "EntryBackupAbility",
|
||||
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
|
||||
"type": "backup",
|
||||
"exported": false,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.backup",
|
||||
"resource": "$profile:backup_config"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"float": [
|
||||
{
|
||||
"name": "page_text_font_size",
|
||||
"value": "50fp"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "module_desc",
|
||||
"value": "module description"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "description"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "label"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowToBackupRestore": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index",
|
||||
"pages/LoginPage",
|
||||
"pages/HomePage",
|
||||
"pages/ReportCreatePage",
|
||||
"pages/ReportResultPage",
|
||||
"pages/ChatPage",
|
||||
"pages/ProfilePage"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';
|
||||
|
||||
export default function abilityTest() {
|
||||
describe('ActsAbilityTest', () => {
|
||||
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
|
||||
beforeAll(() => {
|
||||
// Presets an action, which is performed only once before all test cases of the test suite start.
|
||||
// This API supports only one parameter: preset action function.
|
||||
})
|
||||
beforeEach(() => {
|
||||
// Presets an action, which is performed before each unit test case starts.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: preset action function.
|
||||
})
|
||||
afterEach(() => {
|
||||
// Presets a clear action, which is performed after each unit test case ends.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: clear action function.
|
||||
})
|
||||
afterAll(() => {
|
||||
// Presets a clear action, which is performed after all test cases of the test suite end.
|
||||
// This API supports only one parameter: clear action function.
|
||||
})
|
||||
it('assertContain', 0, () => {
|
||||
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
|
||||
hilog.info(0x0000, 'testTag', '%{public}s', 'it begin');
|
||||
let a = 'abc';
|
||||
let b = 'b';
|
||||
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
|
||||
expect(a).assertContain(b);
|
||||
expect(a).assertEqual(a);
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import abilityTest from './Ability.test';
|
||||
|
||||
export default function testsuite() {
|
||||
abilityTest();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry_test",
|
||||
"type": "feature",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import localUnitTest from './LocalUnit.test';
|
||||
|
||||
export default function testsuite() {
|
||||
localUnitTest();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';
|
||||
|
||||
export default function localUnitTest() {
|
||||
describe('localUnitTest', () => {
|
||||
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
|
||||
beforeAll(() => {
|
||||
// Presets an action, which is performed only once before all test cases of the test suite start.
|
||||
// This API supports only one parameter: preset action function.
|
||||
});
|
||||
beforeEach(() => {
|
||||
// Presets an action, which is performed before each unit test case starts.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: preset action function.
|
||||
});
|
||||
afterEach(() => {
|
||||
// Presets a clear action, which is performed after each unit test case ends.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: clear action function.
|
||||
});
|
||||
afterAll(() => {
|
||||
// Presets a clear action, which is performed after all test cases of the test suite end.
|
||||
// This API supports only one parameter: clear action function.
|
||||
});
|
||||
it('assertContain', 0, () => {
|
||||
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
|
||||
let a = 'abc';
|
||||
let b = 'b';
|
||||
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
|
||||
expect(a).assertContain(b);
|
||||
expect(a).assertEqual(a);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"modelVersion": "6.1.1",
|
||||
"dependencies": {
|
||||
},
|
||||
"execution": {
|
||||
// "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */
|
||||
// "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */
|
||||
// "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */
|
||||
// "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */
|
||||
// "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */
|
||||
// "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */
|
||||
},
|
||||
"logging": {
|
||||
// "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */
|
||||
},
|
||||
"debugging": {
|
||||
// "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */
|
||||
},
|
||||
"nodeOptions": {
|
||||
// "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/
|
||||
// "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0",
|
||||
"@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hamock@1.0.0": {
|
||||
"name": "@ohos/hamock",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ohos/hypium@1.0.25": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.25",
|
||||
"integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"modelVersion": "6.1.1",
|
||||
"description": "Please describe the basic information.",
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.25",
|
||||
"@ohos/hamock": "1.0.0"
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -1,7 +1,7 @@
|
||||
# 「美搭」文档索引
|
||||
|
||||
> 智能穿搭美学助手 · 文档体系导航
|
||||
> 最后更新:2026-08-13
|
||||
> 最后更新:2026-08-14
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
| 技术架构 | 待技术评审 |
|
||||
| 合规方案 | 待法务复核 |
|
||||
| 任务拆解 | 待评审后进入执行 |
|
||||
| **文档一致性评审** | ✅ 已完成一轮(见《文档评审报告-2026-08-13》),P0/P1/P2 全部修订完毕 |
|
||||
| **文档一致性评审** | ✅ 已完成一轮(2026-08-13,评审报告用毕已清理),P0/P1/P2 全部修订完毕 |
|
||||
|
||||
**最紧急的三件事见 §0.2**(备案完成后已重排:模型清单核实 → 顾问到岗 → 自采流水线)。
|
||||
|
||||
@@ -229,21 +229,23 @@
|
||||
|
||||
**第一批 —— 业务决策落地**:备案完成、V1 不收费、市场数据换官方信源、素材自采、顾问全职、命理不做。
|
||||
|
||||
**第二批 —— 文档一致性评审修订**(见《[文档评审报告](./文档评审报告-2026-08-13.md)》):11 项 P0 事实冲突、9 项 P1 覆盖缺口、9 项 P2 一致性问题全部修订。
|
||||
**第二批 —— 文档一致性评审修订**(2026-08-13,评审报告用毕已清理):11 项 P0 事实冲突、9 项 P1 覆盖缺口、9 项 P2 一致性问题全部修订。
|
||||
|
||||
**第三批 —— 后端脚手架落地后的回写**:`meida_backend` 仓库跑通真实纵向切片后,两项文档未覆盖的问题被实测暴露并回写为
|
||||
`T-P1-24a`(思考模式)与 `T-P1-25c`(峰谷计价);同时新增《Sprint 执行计划》承接排期与已交付部分的验收状态。
|
||||
|
||||
**第四批 —— 文档 ↔ 代码交叉评审**(2026-08-14,评审报告用毕已清理):以产品方案 §3.2 修正配额种子;`points_reserved` → `quota_reserved`、`bonus_value` → `bonus_granted`;用户系统表名与架构对齐;文档仓库补 `.gitignore` 挡住 `reference/*.txt`。
|
||||
|
||||
| 文档 | 版本 | 主要变更 |
|
||||
| --- | :---: | --- |
|
||||
| [产品方案](./鸿蒙美搭-智能穿搭美学助手-产品方案.md) | v6.1 | §5.0「V1 不收费只限量」;§3.2 配额表补入**游客列**并成为唯一事实源;§4.1 市场数据换官方信源并纠正鸿蒙可触达口径;§8.1.1 用户目标下调且**以季度回访率替代 DAU**;**§5.2.1/§5.3.1 成本口径重写为三档并推翻「配额封顶总成本」的错误论证**;§8.3.1 性能指标改为引用 `NFR-PERF-*`;§8.3.3 准确率拆为验证/上线两档;删除全部日历周期承诺 |
|
||||
| [需求规格说明](./需求规格说明.md) | v1.2 | BILL 域改为「配额与计量」,V1 移除全部支付;**§4.1 成为性能指标唯一事实源**(修正 Agent 整轮 8s→15s、可用性 99.9%→99.5%,换装改两段式);`NFR-COST-02` 升级为**自动熔断**;新增 `FR-ACC-14` 短信防刷、`FR-ACC-15` 清空照片、`FR-RPT-19` 结论修正入口、`FR-RPT-20` 预约留资、`FR-BILL-12` 会员页留资 |
|
||||
| [技术架构文档](./技术架构文档.md) | v1.3 | billing 域降级为配额与计量;**§7.4 新增四张表职责边界与 `task_bonus` 物化方案**(此前开发会卡在这里);**§7.2 token 预算改为嵌套表并修正合计 3700→4200**;**§13 成本模型重写为三口径 + §13.3 四档熔断降级**;`pf_memory`/`id_consent` 枚举与用户系统对齐;手机号存储位置明确;`points_reserved`→`quota_reserved`;v1.3 按脚手架实现回写五处:**§16.1 补全 11 个业务域包与三层判别标准**、**`ob_ai_call` 补 4 列**(Prompt 版本/思考 token/峰谷档位)、**§9.1 澄清 `/api/v1` 为规范写法**、**§13.2 新增峰谷计价约束**、**§13.3 补关键场景豁免** |
|
||||
| [技术架构文档](./技术架构文档.md) | v1.4 | billing 域降级为配额与计量;**§7.4 新增四张表职责边界与 `task_bonus` 物化方案**(此前开发会卡在这里);**§7.2 token 预算改为嵌套表并修正合计 3700→4200**;**§13 成本模型重写为三口径 + §13.3 四档熔断降级**;`pf_memory`/`id_consent` 枚举与用户系统对齐;手机号存储位置明确;`points_reserved`→`quota_reserved`;v1.3 按脚手架实现回写五处:**§16.1 补全 11 个业务域包与三层判别标准**、**`ob_ai_call` 补 4 列**(Prompt 版本/思考 token/峰谷档位)、**§9.1 澄清 `/api/v1` 为规范写法**、**§13.2 新增峰谷计价约束**、**§13.3 补关键场景豁免** |
|
||||
| [技术选型与调研报告](./技术选型与调研报告.md) | v1.2 | `TD-26`~`TD-31` 六项业务决策收口;`TD-19` 备案已办结;**补 `TD-00` 定义**(此前被引用却未定义);**新增 `TD-32` 短信通道**(此前 P0 需求无选型)、**`TD-33` 天气数据源**(V1 不接,给降级方案) |
|
||||
| [合规与安全方案](./合规与安全方案.md) | v1.2 | §2.2.0 备案后四项持续义务;公示要求升为开发任务;**人脸原图保留期统一为 2 小时**(此前与产品方案的 24h 冲突) |
|
||||
| [开发任务拆解](./开发任务拆解.md) | v1.3 | **关键路径转移**:内容与素材生产升为最优先;**M4 拆为 a/b 两块**消除依赖图歧义;新增 `T-P1-12b/12c` 短信、`T-P1-16b` 清空照片、`T-P1-25b` 熔断、`T-P3-21/22` 两个埋点、`T-P5-17/18/19`;v1.3 补 **`T-P1-24a` 思考模式显式配置**与 **`T-P1-25c` 峰谷计价**(均由后端实测暴露) |
|
||||
| [合规与安全方案](./合规与安全方案.md) | v1.3 | §2.2.0 备案后四项持续义务;公示要求升为开发任务;**人脸原图保留期统一为 2 小时**(此前与产品方案的 24h 冲突);v1.3 补 prod 无 `dev-token` |
|
||||
| [开发任务拆解](./开发任务拆解.md) | v1.4 | **关键路径转移**:内容与素材生产升为最优先;**M4 拆为 a/b 两块**消除依赖图歧义;新增 `T-P1-12b/12c` 短信、`T-P1-16b` 清空照片、`T-P1-25b` 熔断、`T-P3-21/22` 两个埋点、`T-P5-17/18/19`;v1.3 补 **`T-P1-24a` 思考模式显式配置**与 **`T-P1-25c` 峰谷计价**(均由后端实测暴露) |
|
||||
| [Sprint 执行计划](./Sprint执行计划.md) | v1.0 | 新增。按依赖就绪切分六个 Sprint,标注 `meida_backend` 已交付任务的实测证据,并把「内容产能」明确为倒排上线时间的唯一依据 |
|
||||
| [美学知识库-建设方案](./美学知识库-建设方案.md) | v1.2 | §4.2.1 素材自采流水线;§5.1.1 全职顾问工作流;**§9.1 成为规模唯一事实源**(V1 硬指标与长期目标分列);**§7 黄金集拆为四个子集共 330 例**;修正脸型 8→7、局部特征 12→13 |
|
||||
| [美学报告-需求文档](./美学报告-需求文档.md) | v1.2 | 五行模块移除(维度 34→32);**§9.4 成为准确率门槛唯一事实源**(验证/上线两档);**清除全部价格残留**(¥29.9、预计价格、点击价格档位);**维度覆盖改为准确口径**(初级 21/32,原「40+ 项」不存在) |
|
||||
| [用户系统与记忆系统-需求文档](./用户系统与记忆系统-需求文档.md) | v1.2 | §2.4 重构为 V1 配额 + V2 规划;一键登录降级;**§3.8 记忆注入分项被采纳为全局事实源**;配额数值改为引用产品方案 §3.2 |
|
||||
| [用户系统与记忆系统-需求文档](./用户系统与记忆系统-需求文档.md) | v1.3 | §2.4 重构为 V1 配额 + V2 规划;一键登录降级;**§3.8 记忆注入分项被采纳为全局事实源**;配额数值改为引用产品方案 §3.2;v1.3 **表名与架构对齐**(`id_user` / `id_user_identity` / `id_consent` / `pf_memory_event`) |
|
||||
| [穿搭 Agent-需求文档](./穿搭Agent-需求文档.md) | v1.2 | **语音输入由 P0 改为 P1/V2**(此前与 `FR-AGT-17` 冲突);§2.1 能力表补版本与编号列;token 预算与性能指标改为引用;**天气类问题明确不得编造数值**;意图识别准确率拆两档;删除无依据的「1000+ 并发」 |
|
||||
|
||||
+39
-16
@@ -38,13 +38,19 @@
|
||||
| `T-P1-01` 仓库结构与包边界 | ✅ | `apps / packages / services` 三层;**四条 import-linter 契约全绿**,`packages/domain` 禁 framework 与 IO 由机器强制 |
|
||||
| `T-P1-02` CI 流水线 | ✅ | ruff + mypy(domain/llm 为 strict) + pytest + lint-imports + detect-secrets + gitleaks + **迁移 upgrade→downgrade→upgrade 往返** 六道防线 |
|
||||
| `T-P1-03` 本地开发环境 | 🟡(缺 COS mock) | `make up` 起 pgvector + Redis,端口 55432/56379 避冲突;`make install` 一条命令装齐 |
|
||||
| `T-P1-06` Alembic 基线 | ✅ | `0001` 建 9 张表并创建 `vector` 扩展;正反向往返实测通过 |
|
||||
| `T-P1-06` Alembic 基线 | ✅ | `0001` 建 **8** 张表(`id_user` / `id_user_identity` / `id_consent` / `bl_quota_rule` / `bl_quota` / `bl_ledger` / `tk_task` / `ob_ai_call`)并创建 `vector` 扩展;正反向往返实测通过 |
|
||||
| `T-P1-07` 统一响应与错误码 | ✅ | `code/message/data/request_id` 信封;每个错误码有文案,且**配额文案经测试断言不含价格字样** |
|
||||
| `T-P1-08` 可观测性 | 🟡(缺 CLS 与看板) | structlog JSON + request_id 贯穿;**日志脱敏有专项测试**(手机号/令牌/密钥/诊断结论);Prometheus 指标就绪,OTel 留开关 |
|
||||
| `T-P1-19` 任务中心 | 🟡(仅骨架任务) | `tk_task` + 状态机(非法跃迁有测试)+ 幂等键;`POST /tasks/ping`、`GET /tasks/{id}`、列表可用 |
|
||||
| `T-P1-24` **LLM 网关** | ✅ | 场景路由、主备故障转移、熔断器、结构化输出重试、Prompt 版本、成本归因,共 28 个单测 |
|
||||
| `T-P1-25` 成本归因表 | ✅ | `ob_ai_call` 每次调用(含失败)落一行;实测行:`tokens_in=240 / cached=128 / cost_cents=0.10744` |
|
||||
| `T-P1-26` 配额账本 | 🟡(缺周期重置与后台) | 预扣三段式 + `bl_ledger` 幂等;实测 DeepSeek 超时时**自动退还**、成功时 `committed` |
|
||||
| `T-P1-26` 配额账本 | ✅ | 预扣三段式 + `bl_ledger` 幂等;DeepSeek 超时**自动退还**;**任务奖励 claim 已并入**(`POST /quota/tasks/{task_key}/claim`,幂等 40910 + 封顶 40310);周期重置靠 `period_key` 自然滚动(无后台任务=设计如此,非缺口) |
|
||||
| `T-P1-25b` 四档熔断 | ✅(阈值待重算) | 60/80/90/100% 四档降级 + critical 豁免 + Redis 手动 override + Prometheus 指标;**"被拒请求配额净扣减 = 0"有集成断言**;⚠️ `LLM_DAILY_BUDGET_CENTS=100000` 仍是旧价占位,8-17 后按实测重算 |
|
||||
| `T-P1-13` 双令牌会话 | ✅ | refresh 一次性轮换(FOR UPDATE 行锁)+ 服务端撤销 + 重放检测告警;dev-token 向后兼容 |
|
||||
| `T-P1-10` 埋点通道(服务端) | ✅ | `POST /events/batch` fail-close 整批校验 + PII 剔除;事件字典校验待 `TD-23` 定稿后收紧 |
|
||||
| `T-P1-27` 直传预签名 | ✅(联调待 COS) | 场景/类型/大小三重校验 + 可审计 key 布局;SDK 隔离在 Protocol 后,16 单测零网络;真实桶联调归《前置开通清单》§2 |
|
||||
| `T-P1-12` 华为账号登录(代码) | ✅(联调待 AGC) | OIDC JWKS 验签(kid 轮换、alg 锁定、audience 空配置拒绝)+ UnionID 映射;自造密钥全测,19 单测 |
|
||||
| `T-P1-12b/12c` 短信登录(代码) | ✅(发送待签名审核) | 验证码哈希存储/一次性/5 次作废 + 三重频控(脚本化刷量 11 次断言拦截)+ 掩码纪律;fake/tencent 双通道可切 |
|
||||
|
||||
### 1.2 本 Sprint 待补(BE)
|
||||
|
||||
@@ -85,12 +91,12 @@
|
||||
|
||||
| 任务 | 轨道 | 验证门槛(`Phase 0` 档,非上线档) | 不达标的处置 |
|
||||
| --- | --- | --- | --- |
|
||||
| `T-P0-10` 🔴 | ALG | 体型测算与人工标注一致率 **≥ 80%** | 换方案(引入关键点比例法或第三方) |
|
||||
| `T-P0-10` 🔴 | ALG | 体型测算与人工标注一致率 **≥ 80%** | 换方案(引入关键点比例法或第三方)。**2026-08-14 SPIKE(`scripts/spikes/REPORT.md`):原型通过**——合成图 6/6 体型变体(H/A/V/O/X+中性)分类正确,测宽与真值偏差 ±1px;正式 ≥80% 一致率需顾问轨 ≥100 体标注。⚠️ **RTMPose 在合成剪影上置信度极低(0.003,域差异)——真实照片重验是本项的前置** |
|
||||
| `T-P0-11` 🔴 | ALG | 色彩季型一致率 **≥ 75%** | 降级为「色彩倾向」而非「季型结论」 |
|
||||
| `T-P0-12` | ALG | BiRefNet 抠图质检通过 **≥ 90%** | 调参或改用商业抠图 API |
|
||||
| `T-P0-12` | ALG | BiRefNet 抠图质检通过 **≥ 90%** | 调参或改用商业抠图 API。**SPIKE 初步:有条件通过**——管线与代理指标全绿(前景占比 0.244 / 连通域 1.0 / 羽化 ~1px);⚠️ CPU 单张 **3.1s**,同步接口不可接受,需异步化(走任务中心);≥90% 质检率必须真实素材验证 |
|
||||
| `T-P0-13` | BE | 换装单次 `gpu_seconds` 与成功率基线 | 数据直接填入成本模型,**并按 §1.3 的新单价重算** |
|
||||
| `T-P0-14` | BE | 发型接口可用性与耗时基线 | — |
|
||||
| `T-P0-16` | ALG | RTMPose ONNX CPU 单张耗时可接受 | 上 GPU 或降采样 |
|
||||
| `T-P0-16` | ALG | RTMPose ONNX CPU 单张耗时可接受 | ~~上 GPU 或降采样~~ **✅ 2026-08-14 SPIKE 通过:rtmpose-s 全链路 2.8ms/张(P95 3.3ms),rtmpose-m 5.9ms——CPU 完全够用** |
|
||||
| `T-P0-17` 🔴 | FE | 鸿蒙骨架打通登录/拍照/直传/轮询/展示 | **结论决定 FE 能否铺量**,不通则先解决工程链路 |
|
||||
| `T-P0-18` | BE | AI 标识元数据经水印/COS/CDN/保存后仍存活 | 改用不可剥离的显式水印 |
|
||||
| `T-P0-19` | BE | LLM 缓存命中率与成本降幅 | 已有初步实测:固定前缀命中 128/240 输入 token |
|
||||
@@ -113,17 +119,17 @@
|
||||
|
||||
| 任务 | 轨道 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| `T-P2-01` | BE | 12 类实体可存取;`tryon_ready` 标志可用 |
|
||||
| `T-P2-02` | BE | 规则求值 p95 < 50ms;**硬约束可拦截** |
|
||||
| `T-P2-03` | BE | BGE-M3 向量化可重跑,模型版本可切换 |
|
||||
| `T-P2-04` 🔴 | BE | 检索 p95 < 200ms;**硬过滤零漏** |
|
||||
| `T-P2-05` | BE | 1~3 跳返回可解释路径 |
|
||||
| `T-P2-07` | ALG | 抠图批处理 + 白底规格校验拦截不合格图 |
|
||||
| `T-P2-01` | BE | ✅ 2026-08-14:12 类实体 CRUD + 状态机(16 组跃迁全覆盖)+ `kb_asset`(`tryon_ready` 标志 + partial 索引) |
|
||||
| `T-P2-02` | BE | ✅ 2026-08-14:规则引擎 p95 **0.412ms**(400 条规则,预算 50ms 的 1/120);硬约束 `hard_blocks` + 用户禁忌压过专业推荐,黄金集 11 组固化 |
|
||||
| `T-P2-03` | BE | ✅(接口位):`EmbeddingProvider` 可插拔 + `embedding_model/version` 随行写入(可重跑判据);当前为确定性假实现,**真实 BGE-M3 ONNX 待接入** |
|
||||
| `T-P2-04` 🔴 | BE | ✅ 2026-08-14:硬过滤→向量→关键词→RRF 重排;**硬过滤零漏有红线用例**(禁忌实体向量距离 0 仍零出现 + 对照组);顺带修复 jsonb NULL 语义真 bug |
|
||||
| `T-P2-05` | BE | ✅ 2026-08-14:递归 CTE 1~3 跳可解释路径(`path` 数组防环) |
|
||||
| `T-P2-07` | ALG | 抠图批处理 + 白底规格校验拦截不合格图(SPIKE 代理指标已就绪,批处理待素材) |
|
||||
| `T-P2-08` | ALG | 属性预标注准确率 ≥ 70%,人工可修正 |
|
||||
| `T-P2-09` 🔴 | BE | **顾问可独立完成内容维护、审核、发布与回滚,全程无工程介入** |
|
||||
| `T-P2-10` | BE | 发布后检索缓存自然失效 |
|
||||
| `T-P2-11` 🔴 | BE | 黄金集框架接入 CI,**指标退化即阻断合并** |
|
||||
| `T-P2-06` | BE | 无结果 query 全量记录并可排序 |
|
||||
| `T-P2-09` 🔴 | BE | **顾问可独立完成内容维护、审核、发布与回滚,全程无工程介入**(极简后台,第三批开发中) |
|
||||
| `T-P2-10` | BE | 发布后检索缓存自然失效(第三批开发中) |
|
||||
| `T-P2-11` 🔴 | BE | 黄金集框架接入 CI,**指标退化即阻断合并**(第三批开发中) |
|
||||
| `T-P2-06` | BE | ✅ 2026-08-14:无结果 query 落 `kb_gap`(同 query+filter 聚合 `hit_count`),列表/排序接口随后台交付 |
|
||||
|
||||
**内容轨(CONT,本 Sprint 的真实瓶颈)**:`T-P2-12` 色板、`T-P2-13` 规则库冷启动(**V1 硬指标 400 条**)、
|
||||
`T-P2-14` 实体录入、`T-P2-15` 单品 300 件 `tryon_ready`、`T-P2-16` 黄金集标注(**四个子集共 330 例**)。
|
||||
@@ -227,8 +233,25 @@
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
## 9. 多 Agent 并行开发交付记录(2026-08-14)
|
||||
|
||||
两批共 11 个任务(T1~T11)以「协调提交消除共享文件冲突面 + 每 task 独立 worktree/分支 + 逐分支合并跑全量门禁」的方式并行交付。**零合并冲突**;任务契约与协作规程见 `开发任务包/`。
|
||||
|
||||
| 批次 | 任务 → WBS 映射 | 单测增量 | 集成增量 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第一批(5 任务) | T1 四档熔断→`T-P1-25b`;T2 任务奖励→`T-P1-26` 收尾;T3 埋点→`T-P1-10`;T4 刷新令牌→`T-P1-13`;T5 直传预签名→`T-P1-27` | +69 | +12 |
|
||||
| 第二批(6 任务) | T6 实体图谱→`T-P2-01/05`;T7 规则引擎→`T-P2-02`;T8 混合检索→`T-P2-03/04/06`;T9 视觉 SPIKE→`T-P0-10/12/16`(结论已回写 §2);T10 华为登录→`T-P1-12`;T11 短信登录→`T-P1-12b/12c` | +235 | +22 |
|
||||
| **累计** | 单测 169→**473**,集成 3→**38** | | |
|
||||
|
||||
**待联调清单**(代码就绪、等外部凭证):华为登录(AGC 配置)、短信真实发送(签名审核)、COS 直传(四桶创建)——对应《前置开通清单》。
|
||||
|
||||
**遗留提醒**:8-17 峰谷新价生效后重算 `LLM_DAILY_BUDGET_CENTS` 与四档阈值;RTMPose 真实照片重验;BiRefNet CPU 3.1s/张需异步化接入任务中心。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.1
|
||||
**创建日期**:2026-08-13
|
||||
**最后更新**:2026-08-14
|
||||
**状态**:待评审
|
||||
**关联文档**:《开发任务拆解》(任务定义与依赖)、《需求规格说明》(验收指标唯一事实源)、
|
||||
《技术架构文档》(token 预算与成本模型)、《技术选型与调研报告》(`TD-00`~`TD-33`)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 前置开通清单:云资源与资质(本机开发期间并行办理)
|
||||
|
||||
> 创建日期:2026-08-14 | 性质:**行动清单**(照着办,办完打勾)
|
||||
>
|
||||
> **为什么有这份清单**:开发策略是"本机开发完成、腾讯云部署放最后"。但以下三项**有审核周期或属于边开发边对接的配置**,必须现在启动,否则会在 Sprint 0 出口(双登录通道)和 Sprint 4(真机联调)反向卡住开发进度。
|
||||
> 它们只是"开通 + 拿到凭证",**不涉及任何应用部署**,成本接近零。
|
||||
>
|
||||
> **不在本清单内的**(避免混淆范围):
|
||||
> - CVM/TKE、CLB、域名 + HTTPS、CDN 全量、CLS、压测 —— 全部归 Phase 5 部署
|
||||
> - 真实环境红线验证(临时桶 2 小时实测、AI 标识元数据经 CDN 存活、天御误杀率)—— 归 Sprint 1/5 的专项验证任务
|
||||
> - 本机开发的一切(PG/Redis/GPU/LLM 调用)—— 不需要任何云资源
|
||||
|
||||
---
|
||||
|
||||
## 1. 腾讯云账号 + 实名 + 短信签名/模板审核 🔴 最先办(有审核周期)
|
||||
|
||||
**对应**:`T-P1-12b/12c`、`TD-32`(短信是 V1 登录兜底通道,且是被刷时唯一直接现金损失项)
|
||||
|
||||
| # | 事项 | 要点 | 完成 |
|
||||
| --- | --- | --- | :---: |
|
||||
| 1.1 | 注册腾讯云账号 + 实名认证 | 主体资质决定短信签名审核难度,个人主体限制多;按实际情况选,若可走企业主体优先 | ⬜ |
|
||||
| 1.2 | 购买国内短信套餐包 | 最小档即可(验证码短信一条几分钱),目的是拿到发送资格 | ⬜ |
|
||||
| 1.3 | **申请短信签名** | 签名内容与主体资质材料一致(App 名称/公司名等,以腾讯云审核要求为准);**审核有周期,被驳回按驳回原因补材料重提** | ⬜ |
|
||||
| 1.4 | 申请验证码模板 | 登录验证码类型;模板变量只留 `{1}`(验证码),不含营销语 | ⬜ |
|
||||
| 1.5 | 创建子账号 + CAM 最小授权 | 只授 SMS 与 COS 相关权限;**不用主账号密钥** | ⬜ |
|
||||
| 1.6 | SecretId/SecretKey 落位 | **只写后端仓库 `.env`**(已 gitignore + CI 双密钥扫描);文档仓库 `reference/` 禁止出现密钥 | ⬜ |
|
||||
|
||||
**验收**:用测试手机号在腾讯云控制台试发一条模板短信,能收到。
|
||||
|
||||
**开发对接参数**(实现 `T-P1-12c` 防刷时按此配置,源:`FR-ACC-14`):同号 60s 内 1 条、1 小时 ≤5、24h ≤10;同 IP 24h ≤20;同设备 24h ≤10。
|
||||
|
||||
---
|
||||
|
||||
## 2. COS 四桶创建 🔴 一并办(空桶成本 ≈ 0)
|
||||
|
||||
**对应**:`T-P1-04`(开通部分)、`TD-03`、`T-P1-27`(直传联调时再对细节)
|
||||
|
||||
| # | 事项 | 要点 | 完成 |
|
||||
| --- | --- | --- | :---: |
|
||||
| 2.1 | 确定地域 | **四桶 + 未来的 CVM/CDN 同地域**(内网直连、免流量费);定了就不要换 | ⬜ |
|
||||
| 2.2 | 创建 `meida-temp` | 临时桶:**生命周期规则 = 2 小时清理**(`CR-02` 人脸原图不落盘的兜底,上线红线);私有读写 | ⬜ |
|
||||
| 2.3 | 创建 `meida-user` | 用户内容桶:私有读写(结果图走预签名 URL,`NFR-SEC-02`) | ⬜ |
|
||||
| 2.4 | 创建 `meida-kb` | 知识素材桶(白底图等) | ⬜ |
|
||||
| 2.5 | 创建 `meida-report` | 报告产物桶(长图等) | ⬜ |
|
||||
| 2.6 | 顺手做一次 2 小时实测 | 往 `meida-temp` 传一个文件,2 小时后确认被自动清理 —— **这正是 Sprint 0 出口条件之一**,今天办今天验,别留到上线前 | ⬜ |
|
||||
| 2.7 | 记录四桶名称与地域 | 归档到团队可见处;后端 `.env` 后续按此填 | ⬜ |
|
||||
|
||||
**暂不需要做**:CORS 跨域、直传预签名策略 —— 等 `T-P1-27` 开发时边联调边配。
|
||||
|
||||
---
|
||||
|
||||
## 3. 华为开发者后台(应用 + 证书指纹双套配置)🟡 本周内
|
||||
|
||||
**对应**:`T-P1-12/13`(华为账号登录)、用户系统 §2.1;`T-P0-17`(端侧骨架)的前置
|
||||
|
||||
| # | 事项 | 要点 | 完成 |
|
||||
| --- | --- | --- | :---: |
|
||||
| 3.1 | AGC 创建应用 | 包名定为最终包名(上架后难改);拿到 App ID | ⬜ |
|
||||
| 3.2 | 申请/确认调试证书与发布证书 | **两套都要** | ⬜ |
|
||||
| 3.3 | **证书指纹双套录入** | 调试证书与发布证书的 SHA256 指纹**都要**配到 AGC;**漏配发布指纹的表现是"调试包登录正常、发布包登录失败"**,这种错最晚才暴露 | ⬜ |
|
||||
| 3.4 | 开通 Account Kit | 华为账号授权登录(UnionID/OpenID 映射 `id_user_identity`) | ⬜ |
|
||||
| 3.5 | Push Kit 可后置 | 通知授权默认关闭(`TD-17`),Sprint 0 不阻塞,联调期再开 | ⬜ |
|
||||
|
||||
**验收**:用调试证书签名的 App 能拉起华为授权登录页(发布通道的验证归 Phase 5)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 办理顺序与完成标志
|
||||
|
||||
```
|
||||
今天 :1.1 → 1.2 → 1.3(短信签名审核周期最长,最先提交)
|
||||
:2.1 → 2.2~2.5(半小时的事)→ 2.6 挂着等 2 小时
|
||||
本周内 :3.1 ~ 3.4(AGC 配置)
|
||||
签名审核通过后 :1.4 模板 → 1.5/1.6 密钥落位 → 控制台试发验收
|
||||
```
|
||||
|
||||
**整体完成标志**(三条全绿即本清单关闭):
|
||||
- [ ] 测试手机号收到验证码短信
|
||||
- [ ] 四桶就位且 `meida-temp` 实测 2 小时清理生效
|
||||
- [ ] 调试证书 App 拉起华为授权页
|
||||
|
||||
> 办理过程中的凭证(App ID、SdkId、签名 ID、桶名/地域等非机密信息)建议登记到本文末尾的"办理记录"表;**密钥类一律只进后端 `.env`**。
|
||||
|
||||
---
|
||||
|
||||
## 附:办理记录(办理时填写)
|
||||
|
||||
| 日期 | 事项 | 结果 / 备注 |
|
||||
| --- | --- | --- |
|
||||
| | | |
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# 「美搭」合规与安全方案
|
||||
|
||||
> 版本:**v1.2** | 更新日期:2026-08-13 | 状态:待法务/合规复核
|
||||
> 版本:**v1.3** | 更新日期:2026-08-14 | 状态:待法务/合规复核
|
||||
>
|
||||
> **为什么这份文档单独存在**:本产品同时踩在三条监管线上——处理**人脸信息**(敏感个人信息)、提供**生成式 AI 服务**、产出**AI 生成合成内容**。这三条线各自有独立的法定义务,把它们混在技术文档里会被当成技术细节忽略,所以单独成文。
|
||||
>
|
||||
@@ -363,6 +363,7 @@ AI 生成内容仅供参考,不构成专业建议
|
||||
- [ ] 注销流程 7 日内彻底删除,且有执行记录
|
||||
- [ ] 越权访问测试全部拦截
|
||||
- [ ] 日志中无敏感信息(人工抽查 + 自动扫描)
|
||||
- [ ] **生产环境确认无 `dev-token` 路由**(`DEV_AUTH_ENABLED` 与 `is_local` 双开关均须为关;发布包探测 `/api/v1/auth/dev-token` 应 404)
|
||||
- [ ] 越界问题(医疗/命理/他人评价)拒答测试通过 —— **命理模块已决定不做(`TD-31`),但对话侧仍须拒答**
|
||||
- [ ] 贬损性表述检测生效(用负面样本集验证)
|
||||
- [ ] 备案编号公示位在发布包中可见且内容正确
|
||||
@@ -424,6 +425,7 @@ AI 生成内容仅供参考,不构成专业建议
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-14 | v1.3 | §5.3 补「生产环境确认无 `dev-token` 路由」 |
|
||||
| 2026-08-13 | v1.2 | **文档一致性评审修订**。**本文的「人脸原图保留期 ≤ 2 小时」被确认为全局唯一事实源** —— 产品方案 §7.1.4 与美学报告 §9.1 原写「≤ 24h 仅用于任务重试」,已按本文更正(2 小时已足够覆盖重试窗口:最长任务 320s、重试上限 3 次;24h 只是白担合规风险,保留期越长越可能被认定为"存储了人脸信息"从而触发 10 万人备案义务)。§5.3 检查清单补入 `FR-ACC-15`「清空我的照片」(涉及人脸数据删除,此前无 FR 也未纳入清单);防刷项补入配额硬限制与短信通道防刷(`FR-ACC-14`);措辞「退点」→「退还配额」 |
|
||||
| 2026-08-13 | v1.1 | **主线二状态更新为「备案已完成」**。新增 §2.2.0「备案完成后的四项持续义务」(编号公示、备案范围与实际调用一致、变更备案 30 工作日、材料维护),并标出唯一开放风险 —— 备案覆盖的模型清单是否含 DeepSeek + GLM 两家,若只含一家则双路由属超范围。§2.2.3 时间线改为已完成说明,指出关键路径已转移到素材与规则库生产。§2.2.4 公示要求从"待办材料"升为"必须落地的开发任务",补充三条实现要求(入口硬编码、编号可下发、文案与网关路由同源)。§5.1 检查清单标记已完成项,移除华为企业开发者认证(V1 不需要);§5.3 移除支付类合规项 |
|
||||
| 2026-08-13 | v1.0 | 首次输出。确立三条主线:① 人脸原图不落盘以规避 10 万人备案阈值;② 大模型登记 + 算法备案须在 Phase 0 启动(周期 2~4 个月);③ AI 生成内容显式 + 隐式双重标识,经统一合规出口收口 |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# F1 · 登录页与个人页
|
||||
|
||||
> 分支:`task/f1-fe` | worktree:`G:\meida-app-f1` | 只改:`app/entry/src/main/ets/pages/LoginPage.ets`、`pages/ProfilePage.ets`(填桩)、`ets/components/f1/`(可自建)
|
||||
> 先读《README-FE批次.md》(协作规程 + ArkTS 铁律 + API 速查),严格遵守。
|
||||
|
||||
## 目标
|
||||
|
||||
登录闭环与个人中心:dev-token 登录页(含 baseUrl 设置)+ 个人页(用户信息、配额卡片、登出)。
|
||||
|
||||
## LoginPage 要求
|
||||
|
||||
1. 品牌区("美搭"标题 + 一句 slogan)+ 设备标识输入框(默认值随机生成一次并 @State 记住,可改)+「进入美搭」按钮
|
||||
2. **服务器地址设置**:折叠区("高级设置")显示当前 `AuthStore.getBaseUrl()`,可编辑保存(`setBaseUrl`)——真机连开发机靠它
|
||||
3. 登录流程:按钮 loading 态 → `AuthStore.loginByDevToken(deviceKey)` → 成功 `router.replaceUrl({url:'pages/HomePage'})`;失败 catch ApiError 展示 message+hint(错误横幅/对话框)
|
||||
4. 已登录态进入本页时 aboutToAppear 直接跳 HomePage
|
||||
|
||||
## ProfilePage 要求
|
||||
|
||||
1. 顶部:用户 ID(`AuthStore.getUserId()`,脱敏显示前 6 后 4)、登出按钮(确认弹窗 → `AuthStore.logout()` → 回 LoginPage)
|
||||
2. **配额卡片**:并行拉 `agent_chat`、`tryon`、`report_basic` 三个 `GET /api/v1/quota`(具名 interface 对齐速查结构;`reset_at` 显示"明日恢复"或日期,null=终身/月度文案区分)
|
||||
3. 进度条展示 `used/(limit_value+bonus_granted)`,剩余数大字
|
||||
4. 「最近流水」:`GET /api/v1/quota/ledger?limit=10` 列表(tx_type 中文映射:consume=消耗/refund=退还/system_grant=奖励;status 颜色区分)
|
||||
5. 下拉刷新(Refresh 组件)可加分但非必须
|
||||
|
||||
## 验收
|
||||
|
||||
1. 构建门禁 `BUILD SUCCESSFUL`(见规程 §1 命令)
|
||||
2. 无 ArkTS 铁律违规(规程 §3 逐条自查)
|
||||
3. 请求全部走 `ApiClient`/`AuthStore`(不直接碰 http)
|
||||
4. 单次提交,信息:`feat(app): login page with dev-token flow and profile with quota cards(F1)` + 正文中文说明 + 偏差说明
|
||||
@@ -0,0 +1,32 @@
|
||||
# F2 · 应用壳(Tabs)与首页
|
||||
|
||||
> 分支:`task/f2-fe` | worktree:`G:\meida-app-f2` | 只改:`app/entry/src/main/ets/pages/HomePage.ets`(填桩)、`ets/components/f2/`(自建)
|
||||
> 先读《README-FE批次.md》,严格遵守。**禁止 import 其它页面文件**(规程铁律 2)。
|
||||
|
||||
## 目标
|
||||
|
||||
应用主壳:底部 Tabs(首页/报告/AI/我的)+ 首页内容 + 两个通用组件。
|
||||
|
||||
## HomePage 要求
|
||||
|
||||
1. `@Entry @Component struct HomePage` + 底部 `Tabs`(TabContent × 4,barMode 固定四项、图标用 SymbolGlyph 或简单 Text、中文标签)
|
||||
2. **Tab1 首页**(本页实现):
|
||||
- 问候区("你好,美搭" + 日期)
|
||||
- 功能入口卡 ×2:「生成我的形象报告」「AI 穿搭顾问」——点击 `router.pushUrl({url:'pages/ReportCreatePage'})` / `'pages/ChatPage'`(路径跳转,不 import)
|
||||
- 「今日额度」小卡:`GET /api/v1/quota?quota_key=agent_chat` 显示"AI 对话剩余 N 次"(失败静默显示占位"—",不打扰首页)
|
||||
3. **Tab2 报告 / Tab4 我的**:占位内容 + 居中按钮跳 `pages/ReportCreatePage` / `pages/ProfilePage`(真页面是独立路由页;后续迭代再决定嵌组件还是跳页,本批先跳页)
|
||||
4. **Tab3 AI**:占位说明 + 跳 `pages/ChatPage` 按钮
|
||||
- 备选方案:若你能在**不 import ChatPage 文件**的前提下用 `builder` 占位则更好;做不到就跳页,别破铁律
|
||||
5. 返回键处理:Tab 页内再按返回应退出应用(onBackPress 返回 false 走默认)——不加复杂逻辑,注释说明即可
|
||||
|
||||
## 通用组件(`ets/components/f2/`)
|
||||
|
||||
1. `LoadingView`:居中 LoadingProgress + 可选文案;props 用 `@Prop`
|
||||
2. `ErrorView`:错误图标/文案 + `message` + `hint` + 「重试」按钮回调(`onRetry?: () => void`)——**这是 NFR-UX-02「失败态有原因+下一步指引」的落地组件**,其它任务后续会移用(目录带 f2 前缀,移动由协调者做)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 构建门禁 `BUILD SUCCESSFUL`
|
||||
2. 四 Tab 可切换、三个跳转按钮路径正确(跳转目标页面是桩也没关系)
|
||||
3. ArkTS 铁律自查零违规;请求只走 ApiClient
|
||||
4. 单次提交:`feat(app): app shell tabs with home entries and common loading/error views(F2)`
|
||||
@@ -0,0 +1,31 @@
|
||||
# F3 · 报告流(提交 → 轮询 → 32 维结果展示)
|
||||
|
||||
> 分支:`task/f3-fe` | worktree:`G:\meida-app-f3` | 只改:`pages/ReportCreatePage.ets`、`pages/ReportResultPage.ets`(填桩)、`ets/components/f3/`(自建)
|
||||
> 先读《README-FE批次.md》,严格遵守(diagnosis 结构见其 §4,**恒 32 项、skipped 显式**)。
|
||||
|
||||
## ReportCreatePage(问卷 + 模拟照片 + 提交轮询)
|
||||
|
||||
1. **问卷表单**(必填项做前端校验):性别(单选 女/男/不便告知)、身高 cm(TextInput 数字)、体重 kg、生日(DatePicker)、核心诉求(多选:显高/显瘦/显白/显气质/遮肉/修饰肩颈,至少 1 项)、心理风格(9 型单选:戏剧/睿智/自然/古典/优雅/浪漫/前卫/少女/少年)
|
||||
2. **照片(模拟)**:TextInput 填 `photo_object_key`,输入框上方显式灰字说明"V1 骨架:COS 直传未接入,此处模拟对象键"(键格式提示 `report_photo/...jpg`;留空时自动生成 `report_photo/{userId}/{随机}.jpg`)
|
||||
3. 提交:组装 `questionnaire` Record(键名对齐:`gender,height_cm,weight_kg,birthday,core_goals[],psych_style`)+ `photo_object_key` + `request_key`(随机 hex)→ `POST /api/v1/reports`
|
||||
- 40920(本期已生成)→ 对话框提示并给「查看我的报告」按钮跳结果页(携带最近 reportId——无则提示稍后在任务中心看)
|
||||
- 成功 → 进入**轮询态**:`GET /api/v1/tasks/{task_id}` 每 3s 一次(`setInterval` 或递归 setTimeout,页面销毁清理),进度条 + stage_text 展示;SUCCEEDED → `router.replaceUrl` 结果页(params 带 reportId);FAILED → ErrorView + 重试(重新提交同 request_key 即幂等重放,注释说明)
|
||||
4. 轮询期间允许返回(清定时器)
|
||||
|
||||
## ReportResultPage(结果展示)
|
||||
|
||||
1. `GET /api/v1/reports/{id}`(reportId 从 `router.getParams()` 取)
|
||||
2. **文案区**:`result.copy.summary` + sections 渲染(title + paragraphs);`result.validation.degraded === true` 时顶部黄条提示"本报告由规则引擎直接生成"(透明化降级)
|
||||
3. **32 维诊断区**(核心):`result.diagnosis.dimensions`
|
||||
- 按 `basic_coverage` 分组思路简化为:**已分析**(status=ok)与**未覆盖**(skipped,灰色 + evidence 作为原因文案)两组
|
||||
- 每维卡片:name_cn + value(数组 join '、')+ 来源标签(中文映射:questionnaire=你填写/vision=照片分析/rule=规则推导/derived=推导/kb=知识库)+ 置信度(百分比);confidence < 0.7 加"仅供参考"小标(NFR-UX-05)
|
||||
- rule_hits 非空时折叠显示"依据规则 N 条"
|
||||
4. 顶部状态:version、status、completed_at;「重新生成」按钮 → `POST /api/v1/reports/{id}/regenerate` → 成功后拿新 task_id 回到轮询态(同页复用轮询逻辑或跳回 Create 页携带参数——选实现简单者,注释说明)
|
||||
5. 加载态 LoadingView / 失败态 ErrorView(含重试)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 构建门禁 `BUILD SUCCESSFUL`
|
||||
2. 32 维渲染对空值/skipped/数组 value/低置信度四种形态都有处理(本地构造假数据自查 UI 分支,不依赖后端也能看——用临时默认 @State 假数据再删,提交里不留假数据)
|
||||
3. 轮询有清理、40920/FAILED/降级三条异常路径有 UI
|
||||
4. 单次提交:`feat(app): report flow with polling and 32-dimension result view(F3)`
|
||||
@@ -0,0 +1,23 @@
|
||||
# F4 · AI 穿搭对话页
|
||||
|
||||
> 分支:`task/f4-fe` | worktree:`G:\meida-app-f4` | 只改:`pages/ChatPage.ets`(填桩)、`ets/components/f4/`(自建)
|
||||
> 先读《README-FE批次.md》,严格遵守(chat 响应结构见其 §4)。
|
||||
|
||||
## ChatPage 要求
|
||||
|
||||
1. **消息列表**:`@State messages: ChatMsg[]`(本地内存态,V1 不做会话持久化——后端 ag_conversation 属后续任务,注释说明);List + ForEach 渲染,气泡样式区分用户/AI(左右对齐 + 配色)
|
||||
2. `ChatMsg` 具名 interface:`{role: 'user'|'ai', text: string, meta?: string}`(meta 存"模型 · 耗时"小字)
|
||||
3. **发送流程**:底部输入框(TextInput,回车/按钮发送)→ push 用户消息 → AI 侧显示"正在思考…"占位气泡 → `POST /api/v1/agent/chat` `{message}` → 成功替换为 reply,meta 拼接 `{model} · {latency_ms}ms`,并在顶部薄条更新 `quota_remaining`("今日剩余 N 次")
|
||||
4. **失败态**(只 catch ApiError):
|
||||
- 40301(额度用尽):AI 气泡位显示 e.message(后端文案无价格字样)+ e.hint 次行小字;输入框保留可重试
|
||||
- 60003(今日服务上限)/ 其它:同样气泡位展示 message+hint;"正在思考…"占位必须撤掉
|
||||
5. 发送中禁用输入框防连点;空消息不发送
|
||||
6. 顶部栏:标题"AI 穿搭顾问" + 返回(router.back)
|
||||
7. 首次进入自动插入一条 AI 欢迎语(本地静态文案,不调接口):"你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~"
|
||||
|
||||
## 验收
|
||||
|
||||
1. 构建门禁 `BUILD SUCCESSFUL`
|
||||
2. 发送/思考/成功/四种失败展示(40301、60003、网络错)路径齐全;思考占位在失败时一定被撤掉
|
||||
3. ArkTS 铁律零违规;只走 ApiClient;无第三方依赖
|
||||
4. 单次提交:`feat(app): AI chat page with quota hint and error states(F4)`
|
||||
@@ -0,0 +1,52 @@
|
||||
# FE 批次·多 Agent 协作规程(第五批 F1~F4)
|
||||
|
||||
> 版本:v1.3 | 2026-08-15 | 适用:**鸿蒙 FE 批次(F1~F4)**——仓库是 Windows 侧 `G:\meida`(本仓库),工程在 `app/`。后端仓库的规程(v1.2)仍适用其后端任务,不适用本批。
|
||||
> **每个 agent 开工前必须先读本文件,再读自己的任务文档。**
|
||||
|
||||
## 1. 环境操作手册(纯 Windows,无 WSL)
|
||||
|
||||
| 任务 | worktree | 分支 |
|
||||
| --- | --- | --- |
|
||||
| F1 | `G:\meida-app-f1` | `task/f1-fe` |
|
||||
| F2 | `G:\meida-app-f2` | `task/f2-fe` |
|
||||
| F3 | `G:\meida-app-f3` | `task/f3-fe` |
|
||||
| F4 | `G:\meida-app-f4` | `task/f4-fe` |
|
||||
|
||||
- 读写文件:Read/Write/Edit 工具直接用 Windows 路径(如 `G:\meida-app-f1\app\entry\src\main\ets\pages\...`)
|
||||
- **构建门禁(唯一验收命令)**:
|
||||
```
|
||||
cd G:/meida-app-f1/app && export DEVECO_SDK_HOME="C:/Program Files/Huawei/DevEco Studio/sdk" && "C:/Program Files/Huawei/DevEco Studio/tools/node/node.exe" "C:/Program Files/Huawei/DevEco Studio/tools/hvigor/bin/hvigorw.js" --mode module -p module=entry@default assembleHap --analyze=false --daemon=false
|
||||
```
|
||||
(把 f1 换成你的编号;`BUILD SUCCESSFUL` = 通过。未签名 WARN 正常忽略。)
|
||||
|
||||
## 2. 冲突隔离铁律
|
||||
|
||||
1. **只改自己任务文档允许的文件**。`app/entry/src/main/ets/core/**`、`module.json5`、`main_pages.json`、`app/README.md`、`oh-package*.json5`、`hvigor*` 全部禁改(core 已就绪,路由已全注册)。
|
||||
2. **页面间跳转一律 `router.pushUrl({ url: 'pages/XxxPage' })`**,**禁止 import 其它页面文件**——七个页面都已注册,按路径跳不需要 import,这是本批零冲突的根本。
|
||||
3. 组件复用:在自己页面文件内声明 `@Component`;确实通用的放 `ets/components/<你的任务名>/`(如 `components/f2/`),**目录名带任务前缀**。
|
||||
4. 不引任何第三方 ohpm 包;不动 resources(字符串/颜色硬编码,i18n 后置)。
|
||||
5. 提交:构建绿后**单次提交**(信息格式见任务文档),禁止 push、禁止切分支。
|
||||
|
||||
## 3. ArkTS 严格模式铁律(协调者实测踩坑,违反必炸编译)
|
||||
|
||||
1. **泛型调用必须显式类型参数**:`ApiClient.post<void>(...)`、`ApiClient.get<MyResp>(...)`——裸 `post(...)` 直接报 `arkts-no-inferred-generic-params`
|
||||
2. **请求体用具型 Record 字面量**:`const req: Record<string, Object> = { 'k': v }`——不允许 interface→Record 强转(无索引签名)、不允许内联对象类型 `{a:string}(...)` 做泛型参数
|
||||
3. **wire 响应类型用具名 interface**(文件内声明,字段名对齐后端 JSON),`resp.result as ApiEnvelope<T>` 之后再 `data` 层转具体类型
|
||||
4. **throw 只抛 ApiError**:catch 里 `if (e instanceof ApiError) throw e`,未知异常包 `new ApiError(50000, '请求失败,请检查网络', '请稍后重试')`
|
||||
5. **对象字面量必须有具名声明的对应类型**(Record/接口/class 皆可,裸字面量赋 var 会报错);async 逻辑放**具名方法**而不是复杂箭头链(泛型推断限制)
|
||||
6. UI 惯例:`@Entry @Component struct XxxPage`、`@State` 状态、`aboutToAppear` 里 async 初始化(用 `.then` 或 fire-and-forget + `@State` 驱动刷新)、异常只 catch `ApiError` 展示 `e.message + e.hint`
|
||||
|
||||
## 4. 后端 API 速查(baseUrl 默认 `http://127.0.0.1:8000`,信封 `{code,message,data,hint}`)
|
||||
|
||||
- 登录:`POST /api/v1/auth/dev-token` `{device_key}` → `{access_token,refresh_token,expires_in,refresh_expires_in,user_id}`(AuthStore 已封装)
|
||||
- 配额:`GET /api/v1/quota?quota_key=agent_chat|tryon|...` → `{quota_key,used,limit_value,bonus_granted,remaining,reset_at}`
|
||||
- 流水:`GET /api/v1/quota/ledger?limit=20` → `[{tx_id,delta,tx_type,status,quota_key,balance_after,created_at}]`
|
||||
- 对话:`POST /api/v1/agent/chat` `{message}` → `{reply,provider,model,prompt_id,prompt_version,call_id,usage{tokens_in,tokens_out,tokens_cached,latency_ms,cost_cents},quota_remaining}`
|
||||
- 任务:`GET /api/v1/tasks/{id}` → `{task_id,status,progress,stage_text,...}`(status: PENDING/RUNNING/SUCCEEDED/FAILED)
|
||||
- 报告:`POST /api/v1/reports` `{questionnaire:{...},photo_object_key,request_key?}` → `{report_id,task_id,status,version}`;`GET /api/v1/reports/{id}` → `{report_id,status,version,result{vision,diagnosis,copy,validation},created_at,completed_at}`;`POST /api/v1/reports/{id}/regenerate` `{request_key?}`
|
||||
- diagnosis 结构:`{dimensions:[{number,key,name_cn,value,confidence,source,evidence,rule_hits,status}](恒 32 项), coverage:{basic_full,total}}`;copy:`{summary,sections:[{title,paragraphs[]}],model,...}`;validation:`{passed,degraded?,checks{pejorative,absolute,consistency}}`
|
||||
- 常见错误码:40100 未登录 / 40301 额度用尽(文案已无价格字样)/ 40920 本期报告已生成 / 60003 AI 服务今日上限 / 50003 配置异常
|
||||
|
||||
## 5. 运行验证说明(agent 不做)
|
||||
|
||||
构建绿即可提交;真机/模拟器运行由用户在 DevEco Studio 里做(连本机后端:模拟器 127.0.0.1 直连,真机改登录页 baseUrl 为开发机局域网 IP)。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 多 Agent 并行开发·协作规程
|
||||
|
||||
> 版本:v1.2 | 2026-08-14 | 前两批(T1~T5、T6~T11)已全部合并;本版适用第三批 **T12~T14**
|
||||
> **每个 agent 开工前必须先读本文件,再读自己的任务文档。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 本轮架构决策(为什么这样切)
|
||||
|
||||
六个任务按**文件所有权**切分:每个文件只属于一个任务。协调者(主会话)已提前在 main 分支做了一个「协调提交」,把**所有共享文件的改动一次性做完**(kb 七表迁移、ORM 模型、配置项、错误码、路由注册、依赖、gitignore)。因此:
|
||||
|
||||
- **你不需要碰任何共享文件**。如果你的任务文档说某配置/错误码/表已就绪,它就真的已就绪——直接用。
|
||||
- 禁改清单见你的任务文档。**改了禁改文件 = 合并时被整体丢弃**。
|
||||
|
||||
## 2. 环境操作手册(Windows 宿主 + WSL 仓库)
|
||||
|
||||
你的工作目录是 WSL 里的一个 **git worktree**(不是主仓库):
|
||||
|
||||
| 任务 | worktree 路径(WSL) | 分支 |
|
||||
| --- | --- | --- |
|
||||
| T12 | `/home/xsl/meida_backend-t12` | `task/t12-admin-console` |
|
||||
| T13 | `/home/xsl/meida_backend-t13` | `task/t13-golden-ci` |
|
||||
| T14 | `/home/xsl/meida_backend-t14` | `task/t14-retrieval-cache` |
|
||||
|
||||
- **读写文件**:用 Read / Write / Edit 工具,路径写成 UNC 形式:
|
||||
`\\wsl.localhost\Ubuntu-22.04\home\xsl\meida_backend-t6\packages\...`
|
||||
(Edit 前必须先 Read 目标文件。)
|
||||
- **跑命令**(测试/lint/git):
|
||||
```
|
||||
MSYS_NO_PATHCONV=1 wsl.exe -d Ubuntu-22.04 -e bash -c "cd /home/xsl/meida_backend-t6 && .venv/bin/pytest -q"
|
||||
```
|
||||
注意:venv 在**你自己的 worktree 里**(`.venv/bin/...`),不要用主仓库的(本批已含全部所需依赖,用 `--extra dev` 同步)。
|
||||
- PG(55432)/Redis(56379) 容器已在跑,`.env` 已复制到你的 worktree。
|
||||
|
||||
## 3. 提交前必须全绿(在你的 worktree 里)
|
||||
|
||||
```bash
|
||||
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
||||
.venv/bin/mypy <你的任务文档里指定的包列表>
|
||||
.venv/bin/lint-imports
|
||||
.venv/bin/pytest -q # 单测(不连库)
|
||||
# 若你的任务允许集成测试:
|
||||
MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m integration -q --no-cov
|
||||
```
|
||||
|
||||
全绿后提交(**只提交一次,不要多次碎提交**):
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "<你的任务文档里给定的提交信息>"
|
||||
```
|
||||
|
||||
**禁止 `git push`。禁止改动 `.git/`、禁止切分支、禁止 rebase/merge。**
|
||||
|
||||
## 4. 硬性纪律(违反任何一条 = 返工)
|
||||
|
||||
1. **禁改文件**以任务文档为准。通用禁改:`pyproject.toml`、`uv.lock`、`migrations/**`、`packages/shared/models.py`、`packages/shared/config.py`、`packages/shared/errors.py`、`.env.example`、`apps/api/main.py`、`apps/api/deps.py`、`.github/**`、`Makefile`、`scripts/smoke.sh`、`deploy/**`、`packages/domain/**`、`packages/llm/**`、`packages/billing/**`、`packages/agent/**`、`packages/task/**`、`packages/analytics/**`、`packages/storage/**`(除非任务文档明确允许某文件)。
|
||||
2. **不加任何新依赖**(不 `uv add`,不手写 import 第三方新包)。需要的库协调者已装(含 `spike` extra)。
|
||||
3. **不连外网**:单测禁止真实 HTTP。mock 方式参考 `tests/unit/test_deepseek_provider.py`(respx)与 `tests/integration/test_vertical_slice.py`(FakeProvider)。**唯一例外:T9 的模型下载脚本**(见其任务文档)。
|
||||
4. **代码风格对齐现状**:中文 docstring/注释(解释"为什么")、`from __future__ import annotations`、frozen+slots dataclass、结构化日志 `get_logger(__name__)`、错误一律走 `AppError(ErrorCode.X)`、响应一律 `ok(...)` 信封。
|
||||
5. **mypy strict** 适用于 `packages/domain`、`packages/llm`(如果你改这两处,标注必须完整)。其余包按现有宽松档,但新代码仍应写满类型标注。
|
||||
6. **ruff line-length 100**;格式化用 `ruff format`。
|
||||
7. 测试文件放 `tests/unit/test_<主题>.py`;集成测试放 `tests/integration/test_<主题>.py` **新文件**。**本批任何人不得编辑已有测试文件**(上批例外已用尽)。
|
||||
8. **不修改现有测试的断言语义**。
|
||||
9. 遇到与任务文档冲突的现实情况(比如文档说的符号不存在):**不要自行扩大范围**,在提交信息里写明偏差与原因。
|
||||
|
||||
## 5. 完成定义(DoD)
|
||||
|
||||
1. 任务文档「验收标准」全部满足;
|
||||
2. §3 的检查全绿;
|
||||
3. 新增代码有对应测试(纯逻辑必须有单测;涉及 DB 的路径按任务文档要求补集成测试);
|
||||
4. 提交信息按任务文档给定格式,正文用中文说明实现了什么、测了什么、有何偏差。
|
||||
|
||||
## 6. 冲突隔离原理(知其所以然)
|
||||
|
||||
- 本批 3 个分支最终由协调者**顺序合并**,每合一个跑一轮全量门禁(含 `make golden`——CI 防线 5c);
|
||||
- 你分支里的每一个文件改动,理论上不应与其它任务重叠——这是"并行不踩坑"的根本保证;
|
||||
- 如果你觉得"必须"改一个禁改文件才能完成任务:停下来,在提交信息里说明需求,由协调者在合并阶段统一处理。
|
||||
- 前两批(11 任务)用同样的方案跑完:零合并冲突,单测 169→473。照纪律执行即可。
|
||||
@@ -0,0 +1,106 @@
|
||||
# T1 · 全局日成本四档熔断降级
|
||||
|
||||
> 对应任务:`T-P1-25b` | 需求:`NFR-COST-02`、架构 §13.3 | 分支:`task/t1-tiered-budget` | worktree:`/home/xsl/meida_backend-t1`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把现有"单一日预算阈值"(`DailyBudgetGuard`:到 100% 拒绝非 critical 场景)升级为架构 §13.3 的**四档分级降级**,并补上 T-P1-25b 的验收要求:**被熔断拒绝的请求配额净扣减 = 0(须有集成断言)**。
|
||||
|
||||
四档(阈值可配置,百分比相对 `LLM_DAILY_BUDGET_CENTS`):
|
||||
|
||||
| 档位 | 阈值 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| tier 1 `warn` | ≥60% | 只告警(日志 + 指标),不影响任何请求 |
|
||||
| tier 2 `degrade_secondary` | ≥80% | 拒绝 blocked 列表中的场景(默认 `hd_export`、`report_regen`) |
|
||||
| tier 3 `degrade_generation` | ≥90% | 追加拒绝(默认 `hairstyle`、`tryon`) |
|
||||
| tier 4 `block_all` | ≥100% | 拒绝所有非 critical 场景,**但 allow 名单内的场景放行**(默认 `agent_chat`——架构原文"仅保留在途任务与纯文本对话") |
|
||||
|
||||
规则:
|
||||
- **critical 场景永远豁免**(现有 `SceneConfig.critical`,如 `INTENT_CLASSIFY`),但照常计入成本;
|
||||
- **手动干预**:Redis 键可强制指定当前档位(运营解除/加严),优先级高于成本水位;
|
||||
- 在途任务放行 = 熔断只拦网关**新请求**(现有行为已满足,不要在 worker 侧加检查)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,直接用,禁止改动)
|
||||
|
||||
### 配置(`packages/shared/config.py` 已加字段 + `.env.example` 已示例)
|
||||
|
||||
```python
|
||||
llm_budget_tiers: list[dict[str, Any]] # env: LLM_BUDGET_TIERS_JSON,**JSON 是一个列表**(无外层 key)
|
||||
```
|
||||
默认 JSON(元素结构:`{"threshold": 0.6, "name": "warn", "blocked": []}`;`blocked` 为字符串列表,`"*"` 表示全部;`allow` 仅 `block_all` 档有意义):
|
||||
```json
|
||||
{"tiers":[
|
||||
{"threshold":0.6,"name":"warn","blocked":[]},
|
||||
{"threshold":0.8,"name":"degrade_secondary","blocked":["hd_export","report_regen"]},
|
||||
{"threshold":0.9,"name":"degrade_generation","blocked":["hairstyle","tryon"]},
|
||||
{"threshold":1.0,"name":"block_all","blocked":["*"],"allow":["agent_chat"]}
|
||||
]}
|
||||
```
|
||||
|
||||
### 错误码(`packages/shared/errors.py` 已加)
|
||||
|
||||
- `LLM_SCENE_DEGRADED = 60006`,meta:http 503,message「该功能今日暂时关闭」,hint「AI 服务今日已达服务上限,明天恢复」。
|
||||
|
||||
### 现有代码事实(先读这些文件再动手)
|
||||
|
||||
- `packages/llm/budget.py`:`DailyBudgetGuard(counter, *, daily_budget_cents, enabled, key_prefix, tz_offset_hours, clock)`;方法 `current_key()`(`llm:cost:daily:{UTC+8日期}`)、`spent_today()`、`ensure_within_budget(scene, critical)`、`add_cost(cost_cents)`;`CostCounter` Protocol(`InMemoryCostCounter`/`RedisCostCounter`)。
|
||||
- `packages/llm/gateway.py:110`:`await self._budget.ensure_within_budget(scene=scene.value, critical=config.critical)` —— 你要替换的就是这一处调用点。
|
||||
- `packages/llm/errors.py`:已有 `DailyBudgetExceededError`。
|
||||
- `apps/api/routers/agent.py:38`:已 catch `DailyBudgetExceededError → LLM_DAILY_BUDGET_EXCEEDED`。
|
||||
- `packages/agent/service.py`:reserve → commit → gateway 异常时**先 refund 再 raise**(配额净扣减=0 的机制已存在,你只需用集成测试证明它)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
全部改动限定在:`packages/llm/budget.py`、`packages/llm/errors.py`、`packages/llm/gateway.py`(仅调用点与构造参数)、`apps/api/routers/agent.py`(异常映射)、`apps/api/deps.py` **不许改**——预算档配置从 `Settings.llm_budget_tiers` 读取的地方在哪?`build_llm_gateway()` 已在 deps.py(禁改)。**因此**:`DailyBudgetGuard.__init__` 增加关键字参数 `tiers: list[dict[str, Any]] | None = None`(默认 None = 内置默认四档),deps.py 已在协调提交里改为传入 `tiers=list(settings.llm_budget_tiers)`。你只管实现。
|
||||
|
||||
1. **budget.py**:
|
||||
- 新增 pydantic `BaseModel`:`BudgetTier(threshold: float, name: str, blocked: list[str], allow: list[str] = [])`(llm 层允许 pydantic);
|
||||
- `DailyBudgetGuard` 扩展:`current_tier() -> int`(0=未触发,1~4 如上)、`tier_name()`、`blocked_scenes() -> frozenset[str] | "*"`;
|
||||
- 新方法 `ensure_scene_allowed(*, scene: str, critical: bool = False) -> None`:
|
||||
- `critical` → 直接返回;
|
||||
- 手动 override(Redis `llm:budget:override`,值为 "1"~"4",**读不到/值非法则忽略**)优先于水位;
|
||||
- 按档位判定:tier2/3 且 scene ∈ blocked → 抛 `SceneDegradedError(scene, tier_name)`(新异常,加到 `errors.py`);tier4 且 `blocked=="*"` 且 scene ∉ allow → 抛 `DailyBudgetExceededError`(**保持 100% 档的既有语义与 60003 错误码不变**——现有测试依赖它);
|
||||
- 保留 `ensure_within_budget()` 兼容(内部转调 `ensure_scene_allowed`),或直接替换调用点后删除——二选一,保持 `gateway.py` 只调一个方法;
|
||||
- 新增 `set_override(tier: int)` / `clear_override()`(写 Redis,TTL 到当日 UTC+8 24 点),供运营/演练用;
|
||||
- **指标**:`prometheus_client` 的 `Gauge("llm_budget_tier", ...)` 与 `Counter("llm_budget_blocked_total", ..., ["scene"])`(参考 `packages/llm/recording.py` 里 Prometheus 的用法);档位上升时 `logger.warning("budget_tier_raised", tier=..., spent=..., budget=...)`(仅上升沿打一次,避免每请求刷日志——实例内记 last_tier 即可)。
|
||||
2. **gateway.py**:把 `ensure_within_budget` 调用换成 `ensure_scene_allowed(scene=scene.value, critical=config.critical)`;构造函数若需传 tiers,加默认参数(deps 已传)。
|
||||
3. **agent.py**:新增 `except SceneDegradedError as exc: raise AppError(ErrorCode.LLM_SCENE_DEGRADED) from exc`。
|
||||
4. **mypy strict**:budget.py 在 `packages/llm` 内,全量标注。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
单测(`tests/unit/test_tiered_budget.py` 新文件;现有 `test_daily_budget.py` 保持通过,允许因 API 扩展做**增量**修改):
|
||||
- 四档边界:59.9%/60%/79.9%/80%/89.9%/90%/99.9%/100% 时 `current_tier()` 正确;
|
||||
- tier2 拒绝 `hd_export` 放行 `agent_chat`;tier4 拒绝非 allow 非 critical;critical 全档豁免;
|
||||
- override 强制 tier4 / 清除后回落;
|
||||
- `allow=["agent_chat"]` 在 tier4 生效;
|
||||
- 计数用 `InMemoryCostCounter` + 注入 clock(参考现有测试)。
|
||||
|
||||
集成测试(**编辑 `tests/integration/test_vertical_slice.py`**,你是唯一允许编辑它的任务):
|
||||
- 用 `monkeypatch.setenv` 把 `LLM_DAILY_BUDGET_CENTS` 设为极小值(如 "1")、`LLM_BUDGET_TIERS_JSON` 把 `block_all.blocked` 设为 `["agent_chat"]`(即在本测试里连纯文本对话也拦),`get_settings.cache_clear()` 后 `create_app`;
|
||||
- 先用 Redis 客户端把当日成本键 `INCRBYFLOAT` 到预算以上(键格式照 `DailyBudgetGuard.current_key()` 计算);
|
||||
- `POST /v1/agent/chat`(FakeProvider,参考现有 fixture)→ 断言:响应 code 为 60003(或 60006,取决于你把 agent_chat 归到哪个异常——按上面设计应为 60003)、**且 `bl_ledger` 中该用户当日 `consume` 流水净和为 0(reserved 后 refund)**——这正是 T-P1-25b 的验收"被熔断拒绝的请求配额净扣减 = 0,须有集成断言覆盖 reserve 之后才触发熔断这条路径";
|
||||
- 测试结束 `get_settings.cache_clear()` 恢复,避免污染其它用例。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/agent/**`、`packages/billing/**`、`packages/identity/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{auth,quota,tasks,events,uploads}.py`、`apps/api/schemas.py`。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 四档降级可由配置驱动,`warn` 档无用户可感知变化;
|
||||
2. 手动 override 可加严/解除(redis-cli 可操作,写清键名与取值到 budget.py docstring);
|
||||
3. 集成断言证明"reserve 之后熔断 → refund → 净扣减 0";
|
||||
4. §3 检查全绿(mypy 范围:`packages/llm` strict + `apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(llm): four-tier daily budget degradation with manual override
|
||||
|
||||
四档熔断(warn/secondary/generation/block_all)+ critical 豁免 + Redis 手动 override
|
||||
+ Prometheus 指标 + 集成断言:被拒请求配额净扣减为 0(T-P1-25b)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# T10 · 登录·华为账号(代码先行,联调后置)
|
||||
|
||||
> 对应任务:`T-P1-12` | 需求:`FR-ACC-01`(登录成功率 ≥99%、UnionID 映射唯一、首次自动建用户)| 分支:`task/t10-auth-huawei` | worktree:`/home/xsl/meida_backend-t10`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /auth/huawei`:客户端传华为 Account Kit 签发的 **id_token**,服务端按 OIDC 标准做 **JWKS 验签 + claims 校验**,映射到内部用户并签发双令牌(access + refresh,复用 T4 已交付的 `issue_session`)。
|
||||
|
||||
**本任务性质是"代码先行"**:华为 AGC 后台尚未配置(见《前置开通清单》§3),因此真实联调后置;全部逻辑用**自造 RSA 密钥对 + 本地 JWKS** 做单测/集成,凭证到位后只改 `.env` 即可上线。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(config.py 已加)
|
||||
|
||||
```python
|
||||
huawei_jwks_url: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/certs" # HUAWEI_JWKS_URL
|
||||
huawei_audience: str = "" # HUAWEI_AUDIENCE,= AGC App ID,联调时填
|
||||
huawei_issuer: str = "https://accounts.huawei.com" # HUAWEI_ISSUER(以 Account Kit 文档为准,可配)
|
||||
huawei_jwks_cache_seconds: int = 86400
|
||||
```
|
||||
|
||||
### 可复用的现有设施(先读再写)
|
||||
|
||||
- `packages/identity/service.py`:`get_or_create_by_external_identity(provider, union_id, open_id, register_source)`(T4 已有);`issue_session(user_id) -> SessionIssued`(T4 交付,含 refresh 轮换结构)。
|
||||
- `packages/shared/security.py`:`create_access_token(...)`(HS256 access)。
|
||||
- `packages/identity/schemas.py`:`TokenPairResponse`(T4 交付)——**直接复用,不要在本任务重复定义**。
|
||||
- `pyjwt[crypto]` 已装(含 cryptography,RS256 验签可用)。
|
||||
|
||||
### 端点归属
|
||||
|
||||
`apps/api/routers/auth.py` 归你(T4 已合并完,本批无他人碰它)。`apps/api/routers/auth_sms.py` 归 T11,**禁碰**。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/huawei.py`(新建,核心逻辑全在此)、`apps/api/routers/auth.py`(追加端点)、测试文件。**不改 identity/service.py 与 identity/schemas.py**(避免与 T11 冲突——你的模型定义在 huawei.py 内)。
|
||||
|
||||
1. **`packages/identity/huawei.py`**:
|
||||
- `HuaweiIdTokenVerifier`:构造参数 `(jwks_url, audience, issuer, cache_seconds, *, http_fetch=None, now=None)`——`http_fetch` 是 `Callable[[str], Awaitable[str]]`(单测注入本地 JWKS,**不连外网**);JWKS 内存缓存(TTL 按配置,kid 变化时强制刷新一次并重试验签);
|
||||
- `verify(id_token) -> HuaweiClaims`(frozen dataclass:`union_id/open_id/exp/iat`):RS256 验签(`jwt.decode` + `PyJWK`/手工 kid 匹配)、`aud`(等于配置 audience,空配置时**拒绝**而不是跳过——联调前后都必须严格)、`iss`、`exp/iat`、`union_id` 缺失拒绝(华为子账号场景无 union_id 时降级用 open_id 的策略**不做**,注释说明留给联调期决定);
|
||||
- 错误映射:验签失败/claims 不符 → `AppError(TOKEN_INVALID)`(40102,已有);JWKS 拉取失败 → `AppError(UPSTREAM_ERROR)`(60000,已有)——**不需要新错误码**。
|
||||
2. **路由**(auth.py 追加):
|
||||
```python
|
||||
@router.post("/huawei", summary="华为账号授权登录(FR-ACC-01)")
|
||||
async def huawei_login(payload: HuaweiLoginRequest, ...) -> ...:
|
||||
claims = await verifier.verify(payload.id_token)
|
||||
user = await identity.get_or_create_by_external_identity(
|
||||
provider="huawei", union_id=claims.union_id, open_id=claims.open_id,
|
||||
register_source="huawei")
|
||||
issued = await identity.issue_session(user.user_id)
|
||||
access = create_access_token(...)
|
||||
return ok(TokenPairResponse(...)) # 复用 T4 模型
|
||||
```
|
||||
- verifier 用依赖注入(路由模块内建一个 `get_huawei_verifier` 的 FastAPI Depends,从 settings 构造并缓存到 `app.state` 或模块级 lru——**不改 deps.py**,在 auth.py 内实现);
|
||||
- `HuaweiLoginRequest(id_token: str)` 定义在 huawei.py。
|
||||
3. **模块 docstring** 写明:真实联调前置 = AGC 应用创建 + 证书指纹双套 + `HUAWEI_AUDIENCE` 填 App ID(引用《前置开通清单》§3)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_huawei_verifier.py`:测试内用 `cryptography` 生成 RSA 密钥对、自签 id_token + 本地 JWKS dict:
|
||||
1. 合法 token → claims 解析正确;
|
||||
2. 错误签名(换密钥)→ 40102;过期 → 40101(复用 TOKEN_EXPIRED);aud 不符 → 40102;audience 未配置 → 40102;
|
||||
3. JWKS 拉取失败 → 60000;kid 轮换 → 缓存刷新后验签成功。
|
||||
- 集成 `tests/integration/test_auth_huawei.py`(新文件):monkeypatch 注入 FakeFetcher(本地 JWKS)→ `POST /v1/auth/huawei` → 200 双令牌;再次登录同 union_id → 同一 user_id(映射唯一);新 access 可调 `GET /v1/quota`。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/identity/{service,repo,schemas,tokens}.py`、`apps/api/routers/auth_sms.py`、`packages/{kb,rules,retrieval}/**`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-01)
|
||||
|
||||
1. 验签逻辑完整(签名/aud/iss/exp/iat 全查),audience 未配置时拒绝启动校验路径;
|
||||
2. UnionID→内部 user_id 映射唯一,首次登录自动建用户(集成断言同 ID 复登);
|
||||
3. 全链路不连外网(FakeFetcher 注入);
|
||||
4. 全部检查绿(mypy 范围同 T6 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): Huawei Account Kit login via OIDC JWKS verification
|
||||
|
||||
POST /auth/huawei:JWKS 验签(kid 缓存/轮换)+ claims 严格校验 +
|
||||
UnionID 映射 + T4 双令牌签发(FR-ACC-01;真实联调待 AGC 配置)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# T11 · 登录·手机号验证码(代码先行,联调后置)
|
||||
|
||||
> 对应任务:`T-P1-12b/12c` | 需求:`FR-ACC-04`(6 位、5 分钟、一次性、失败 5 次作废)、`FR-ACC-14`(三重频控防刷)、`TD-32` | 分支:`task/t11-auth-sms` | worktree:`/home/xsl/meida_backend-t11`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
手机号验证码登录全链路(发送 → 校验 → 建户 → 双令牌)+ **短信轰炸防护**。腾讯云短信签名/模板还在审核(《前置开通清单》§1),因此真实发送后置:`SmsSender` 走 Protocol 隔离,默认 `fake` 实现(验证码打日志,dev 可用),腾讯实现写好待凭证。
|
||||
|
||||
**FR-ACC-04 纪律:与华为通道不得互相依赖**——本任务不 import 华为相关任何东西,登录成功路径只依赖 identity 域已有设施。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(config.py 已加,.env.example 已示例)
|
||||
|
||||
```python
|
||||
sms_provider: str = "fake" # SMS_PROVIDER: fake | tencent
|
||||
tencent_sms_secret_id/key/sdk_app_id/sign_name/template_id: str = ""
|
||||
sms_code_ttl_seconds: int = 300 # 5 分钟(FR-ACC-04)
|
||||
sms_code_length: int = 6
|
||||
sms_verify_max_attempts: int = 5 # 校验失败 5 次作废
|
||||
sms_resend_cooldown_seconds: int = 60 # 同号 60s 内 1 条
|
||||
sms_hourly_limit: int = 5 # 同号 1h ≤5
|
||||
sms_daily_limit: int = 10 # 同号 24h ≤10
|
||||
sms_ip_daily_limit: int = 20 # 同 IP 24h ≤20
|
||||
sms_device_daily_limit: int = 10 # 同设备 24h ≤10
|
||||
```
|
||||
|
||||
### 错误码(errors.py 已加,含 meta)
|
||||
|
||||
- `SMS_CODE_INVALID = 40110`(401「验证码错误」hint「请检查后重试」)
|
||||
- `SMS_CODE_EXPIRED = 40111`(401「验证码已过期」hint「请重新获取」)
|
||||
- `SMS_SEND_TOO_FREQUENT = 42910`(429「发送太频繁了」hint「请稍后再试」)
|
||||
- `SMS_DAILY_LIMIT = 42911`(429「今日发送次数已达上限」hint「明天再试」)
|
||||
- `SMS_PROVIDER_FAILED = 61000`(502「短信服务暂时不可用」)
|
||||
|
||||
### 依赖与设施
|
||||
|
||||
- `tencentcloud-sdk-python-sms` 已装(协调提交)。
|
||||
- 可复用(先读再写):`packages/identity/service.py` 的 `get_or_create_by_external_identity`(provider 用 `"phone"`)与 `issue_session`(T4);`packages/identity/schemas.py` 的 `TokenPairResponse`——**复用,不重复定义、不修改该文件**。
|
||||
- 路由骨架:`apps/api/routers/auth_sms.py` 空路由(prefix `/auth`)已注册 main.py——归你填充。`apps/api/routers/auth.py` 归 T10,**禁碰**。
|
||||
|
||||
### 号码存储决策(写进代码注释)
|
||||
|
||||
V1 简化:`id_user_identity` 现有列无 `phone_enc`,用 `union_id = "phone:" + sha256(规范化号码)[:16]`(E.164 规范化:去 +86/前导零/空白后哈希)。**Redis 与日志中一律不存明文号码**——键用 `sha256(phone)` 前 16 位,日志打码 `138****1234`(FR-ACC-14 掩码要求)。`phone_enc` 列的补齐属后续迁移任务,不在本批。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/sms_login.py`(新建,全部核心逻辑)、`apps/api/routers/auth_sms.py`、测试文件。**不改 identity/{service,repo,schemas,tokens,huawei}.py。**
|
||||
|
||||
1. **`packages/identity/sms_login.py`**:
|
||||
- `SmsSender` Protocol:`async def send_code(phone_masked_key: str, code: str) -> None`(收掩码键不收明文——防实现层记日志泄漏);
|
||||
- `FakeSmsSender`:`logger.info("sms_code_issued", masked=..., code=...)`(dev 专用,注释写明生产必须切 tencent);
|
||||
- `TencentSmsSender`:包官方 SDK(`SmsClient` + `SendSmsRequest`,参数从 settings 读);构造失败/响应非 ok → `AppError(SMS_PROVIDER_FAILED)`;**本批不要求真实联调,但代码路径要完整可读**;
|
||||
- `SmsAuthService(redis, sender, *, settings)`:
|
||||
- `request_code(phone, ip, device_id)`:号码格式校验(大陆手机号正则 `^1[3-9]\d{9}$`,写死并注释 V1 只做大陆)→ **先频控后发送**:同号 cooldown/hourly/daily、IP daily、设备 daily 全过才生成 6 位随机码(`secrets.randbelow`,非 random)→ 存 Redis `sms:code:{phone_hash}`(值 = sha256(code),**不存明文**,TTL 300s)+ 计数器自增 → sender 发送;
|
||||
- 频控计数键按自然窗口(cooldown 用 TTL;hourly/daily 用 UTC+8 小时/日期桶,对齐 billing 的时区纪律);
|
||||
- `verify(phone, code, ip, device_id)`:取码哈希比对(**恒定时间比较** `hmac.compare_digest`)→ 错误则 attempts+1(`sms:attempts:{phone_hash}`,达 5 删码并返回 40110)→ 成功删码(一次性)→ 由调用方建户;
|
||||
- 全部日志打码:`masked_phone(phone) -> "138****1234"`。
|
||||
2. **路由**(auth_sms.py):
|
||||
- `POST /auth/sms/send` body `{phone, device_id}`(ip 从 `Request.client.host` 取)→ 限流错误码透传;
|
||||
- `POST /auth/sms/verify` body `{phone, code, device_id}` → 成功后 `get_or_create_by_external_identity(provider="phone", union_id=..., register_source="phone")` + `issue_session` → `ok(TokenPairResponse(...))`;
|
||||
- 请求/响应模型定义在 sms_login.py 或新建 `packages/identity/sms_schemas.py`(二选一,不碰共享 schemas)。
|
||||
3. **刷量测试**(FR-ACC-14 验收原文"脚本化刷量测试证明拦截有效"):集成测试用循环模拟。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_sms_login.py`(fakeredis 或自写内存 dict 实现 Protocol 化的 redis 接口——参考 `InMemoryCostCounter` 的做法,不引新依赖):
|
||||
1. 号码正则(非法格式 400 类错误);
|
||||
2. 频控矩阵:cooldown 内二次 → 42910;1h 第 6 条 → 42910;24h 第 11 条 → 42911;IP 第 21 条 / 设备第 11 条 → 42911;
|
||||
3. 验证码:错误 4 次可再试、第 5 次后码作废(40110 且码已删)、过期(TTL 模拟)→ 40111、成功后码即失效(二次 verify 40111);
|
||||
4. 掩码函数;恒定时间比较(不 assert 时长,只 assert 行为)。
|
||||
- 集成 `tests/integration/test_auth_sms.py`(新文件,SMS_PROVIDER=fake,从响应前的日志拿不到码——**测试钩子**:fake sender 把码写进 Redis 测试键或直接用 fixture 替换 sender 为捕获型 fake,不要为测试开后门到生产代码):
|
||||
1. send → verify(fixture 捕获的码)→ 200 双令牌,新 access 可用;
|
||||
2. 同号再登录同 user_id(映射唯一);
|
||||
3. **刷量脚本化**:连发 11 次,第 11 次 42911;换 IP 维度再验一轮;
|
||||
4. 错误码 5 次作废路径。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/identity/{service,repo,schemas,tokens,huawei}.py`、`apps/api/routers/auth.py`、`packages/{kb,rules,retrieval}/**`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-04 / FR-ACC-14)
|
||||
|
||||
1. 验证码 6 位、5 分钟、一次性、失败 5 次作废——四点各有断言;
|
||||
2. 三重频控全部生效且有脚本化刷量测试证明;
|
||||
3. 号码与验证码在日志/Redis 均无明文(掩码 + 哈希);
|
||||
4. 与华为通道零耦合(不 import huawei 模块);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): phone SMS login with triple rate limiting and anti-abuse
|
||||
|
||||
SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/
|
||||
5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32;
|
||||
真实发送待签名审核)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# T12 · 极简运营后台
|
||||
|
||||
> 对应任务:`T-P2-09` 🔴 | 需求:`FR-ADM-01~05`、Sprint 2 出口「**顾问可独立完成内容维护、审核、发布与回滚,全程无工程介入**」| 分支:`task/t12-admin-console` | worktree:`/home/xsl/meida_backend-t12`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
`apps/admin/` 独立 FastAPI 应用(与 apps/api 平级,部署上分离):知识实体与规则的维护/审核/发布/回滚入口、素材登记与审核、知识缺口看板。**这是 M2 出口的成败标志**——顾问从此不找工程。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
### 配置与鉴权
|
||||
|
||||
- `settings.admin_token`(env `ADMIN_TOKEN`):**空字符串 = 全部 admin 端点 404**(等价未部署)。鉴权方式:请求头 `X-Admin-Token` 精确匹配(`hmac.compare_digest`);不匹配 → 40100(UNAUTHENTICATED)。
|
||||
- **apps 之间互不依赖**(import-linter):admin 不许 import `apps.api` 的任何东西——session 依赖、响应信封在 admin 包内自建(`ok`/`AppError` 来自 `packages.shared`,允许)。
|
||||
|
||||
### 可复用的服务层(先读再用,不改它们)
|
||||
|
||||
- `packages/kb/service.py`:`KbService`(实体 CRUD/状态机/图谱/色板)+ **`KbAssetService`(新)**:`register_asset(payload) -> KbAsset`、`review_asset(asset_id, payload)`(`tryon_ready` 只能随 `approved` 打开,服务端已强制)、`list_assets(...)`;schemas:`AssetCreate/AssetReview/AssetOut` 已加。
|
||||
- `packages/rules/service.py`:规则 CRUD + 状态机 + `PublishedRules` 缓存 `invalidate()`。
|
||||
- `packages/retrieval/service.py`:模块级 `list_gaps(session, ...)`(hit_count 降序)+ **`invalidate_cache()`**(发布编排后调用——T14 会在背后实现真缓存,你只管调)。
|
||||
- 实体状态机:draft→reviewing→published→archived(`KbService` 内已有 transition 语义,读源码确认方法名;若服务层没有显式 `transition` 方法,通过 `update_entity` 的 status 字段走 `assert_transition` 校验——以代码为准)。
|
||||
|
||||
### 现状
|
||||
|
||||
`apps/admin/__init__.py`(占位注释 FR-ADM-01~05)——你填充;无其它骨架。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`apps/admin/**`、测试文件。**不改任何 packages/**(服务层已齐;发现缺口写进偏差说明)。**
|
||||
|
||||
1. **`apps/admin/app.py`**:`create_admin_app(settings) -> FastAPI`;`lifespan` 里建 DB engine 复用 `packages.shared.db`;挂 `RequestContextMiddleware`、统一异常处理(`packages.shared.handlers`);路由前缀 `/admin/api/v1`(与客户端 API 区分);docs 仅 local。
|
||||
2. **`apps/admin/deps.py`**:`admin_auth` 依赖(读 `X-Admin-Token`,空配置 → 全部路由在装配时不注册或请求时 404——选**装配时不注册**,404 语义更干净);session 依赖(仿 `apps/api/deps.session_dep`,不 import 它)。
|
||||
3. **路由**(每域一个 router 文件,全部经 `admin_auth`):
|
||||
- **实体**:`GET/POST /kb/items`、`GET/PATCH /kb/items/{id}`、`POST /kb/items/{id}/transition`(body `{target: "reviewing"|"published"|"archived"}`,走服务层状态机;**发布成功后调用 `rules service invalidate()` + `retrieval invalidate_cache()`**——发布编排是后台的职责,这就是 T-P2-10 的"自然失效"入口)、`GET /kb/items/{id}/graph`;
|
||||
- **规则**:`GET/POST /rules`、`PATCH /rules/{id}`、`POST /rules/{id}/transition`(发布同样触发双 invalidate);
|
||||
- **素材**:`POST /assets`(登记)、`GET /assets`(entity/tryon_ready 过滤)、`POST /assets/{id}/review`(审核);
|
||||
- **缺口看板**:`GET /kb/gaps`(limit/offset,直接返回 `list_gaps` 结果)。
|
||||
4. **模块 docstring**:V1 极简鉴权的边界(共享令牌、无审计日志字段、无角色)与升级路径(运营账号体系属 V2;操作留证当前靠 structlog request_id)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_admin_app.py`:`ADMIN_TOKEN=""` 时所有路由 404(用 TestClient 打几个代表路径);令牌错误 → 40100;正确令牌 → 200(服务层用假 session 或只测装配层,不连库——若 router 直接依赖 DB session,把鉴权/404 逻辑测在装配层即可)。
|
||||
- 集成 `tests/integration/test_admin_console.py`(新文件,允许跑):
|
||||
1. 顾问全流程**不碰客户端 API**:建实体 → reviewing → published → 建规则 → 发布 → 断言规则可被 `PublishedRules` 缓存载入、检索新内容可见(简化断言:发布后 `invalidate` 被调用的副作用可观察——直接再查库即可);
|
||||
2. 非法跃迁 published→draft → 40900;
|
||||
3. 素材:登记(tryon_ready 恒 False)→ 审核 approved+tryon_ready=true → 列表过滤 `tryon_ready=true` 命中;
|
||||
4. 审核不通过时 tryon_ready=true → 400(服务层约束透传);
|
||||
5. 缺口:预插两行 kb_gap → `GET /kb/gaps` 按 hit_count 降序;
|
||||
6. 无令牌/错令牌 → 401。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:**全部 `packages/**`**、`apps/api/**`、所有已有测试文件。
|
||||
|
||||
## 6. 验收标准(T-P2-09)
|
||||
|
||||
1. 实体/规则/素材/缺口四类操作全部有 HTTP 入口,鉴权生效(空 token 404、错 token 401);
|
||||
2. 发布编排(publish → rules invalidate + retrieval invalidate_cache)落地;
|
||||
3. 集成测试覆盖顾问全流程(无工程介入语义);
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared ... packages/retrieval` 全列——照 T6 文档同口径,含你的 `apps/admin`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(admin): minimal console for KB content operations
|
||||
|
||||
apps/admin 独立应用:X-Admin-Token 鉴权(空=404)、实体/规则维护与状态机、
|
||||
发布编排(双 invalidate)、素材登记与审核、缺口看板(T-P2-09,FR-ADM-01~05)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# T13 · 黄金集回归框架接入 CI
|
||||
|
||||
> 对应任务:`T-P2-11` 🔴 | 需求:`FR-KB-11`、`NFR-MAINT-04`(指标退化阻断合并)、架构 §17「黄金集是最重要的一道防线」| 分支:`task/t13-golden-ci` | worktree:`/home/xsl/meida_backend-t13`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把黄金集从"散在单测里的固定样本"升级为**数据驱动的回归框架**:用例存 JSON、阈值存配置、CI 强制跑、**指标退化直接 fail**。V1 先落两个子集(规则黄金集、检索黄金集),Agent 问答与报告结构两个子集留占位(等 Sprint 3/4)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
- **pytest marker `golden`** 已注册(`--strict-markers` 下可直接用);默认 addopts 排除 `golden`(与 integration 同待遇),`make golden` / CI 防线 5c 强制跑(env `MEIDA_RUN_INTEGRATION=1`)。
|
||||
- **规则黄金集现有资产**:`tests/unit/test_rules_golden.py` 有 8 条规则 + 11 组样本(硬禁忌压制、多规则叠加等)——**不许改它**;你的 JSON 用例集是独立交付(可参考其场景设计新写,不是搬运代码)。
|
||||
- **检索侧可复用**:`packages/retrieval/embedding.py` 的 `DeterministicEmbedder`(同文本同向量→黄金集可断言确定性结果)+ `packages/rules` 的 DSL/引擎。
|
||||
|
||||
### 黄金集规模口径(知识库文档 §7,唯一事实源)
|
||||
|
||||
四子集共 **330 例**(检索 100 / Agent 问答 100 / 诊断 100 / 报告结构 30)。**本任务只搭框架 + 规则/检索各 10~15 例种子**,规模达标是内容轨 `T-P2-16` 的事——框架必须让"加用例=加 JSON 行"。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`tests/golden/**`(新目录,归你全部)、**仅此一处**;不改 packages/apps/已有测试。
|
||||
|
||||
1. **目录结构**:
|
||||
```
|
||||
tests/golden/
|
||||
README.md # 用例格式说明 + 如何加用例 + 阈值怎么调
|
||||
thresholds.json # {"rules": {"pass_rate_min": 1.0}, "retrieval": {"pass_rate_min": 0.8, "first_hit_in_top3_min": 0.8}}
|
||||
cases/
|
||||
rules_cases.json # 规则黄金集种子
|
||||
retrieval_cases.json # 检索黄金集种子
|
||||
runner.py # 用例加载 + 执行 + 指标计算 + 明细报告
|
||||
test_golden.py # pytest 入口(marker=golden,skipif 无 MEIDA_RUN_INTEGRATION)
|
||||
```
|
||||
2. **规则用例格式**(rules_cases.json):`{"name", "rules": [完整 Rule DSL 数组], "context": {...RuleContext}, "expect": {"must_recommend": [{"slot": "top", "attrs_include": {...}}], "must_avoid": [...], "hard_blocks_nonempty": bool, "forbidden_recommend": [...]}}`。判定:`evaluate()` 结果逐一核对 expect;一条不符 = 该用例 fail。
|
||||
3. **检索用例格式**(retrieval_cases.json):`{"name", "seed_entities": [{entity_type, name, attrs, status, chunks: [text]}], "query", "user_ctx": {...}, "expect": {"must_hit_names": [...], "must_miss_names": [...], "first_hit_in": "top1|top3"}`。执行:每个用例**先清自己 seed 的实体**(按 name 前缀 `golden:` 命名,跑前 DELETE 这些行,保证幂等重跑)→ DeterministicEmbedder 写 chunk → `hybrid_search` → 判定。`must_miss` 语义 = 硬过滤红线(禁忌/draft 实体绝不出现)。
|
||||
4. **指标与阻断**(runner.py):
|
||||
- 每子集算 `pass_rate`(通过用例/总数);检索加算 `first_hit_in_top3_rate`;
|
||||
- **规则子集阈值 = 1.0**(硬结论子集,一条不许退);检索 = 0.8 起步(阈值在 thresholds.json,顾问扩充用例后可调);
|
||||
- fail 时输出**逐用例明细**(name + 期望 vs 实际的差异摘要)——CI 日志里要能直接看出哪条退化了;
|
||||
- `runner.py` 是普通模块(不依赖 pytest),`test_golden.py` 只是薄壳调用——将来想在本地脚本或 admin 后台跑同一套,不用改框架。
|
||||
5. **README.md**:写明四子集规划(330 例口径)、当前覆盖(两子集 N 例)、加用例流程、"为什么规则阈值必须 1.0"(核心结论由规则产出,规则退化=产品根基退化)。
|
||||
|
||||
## 4. 测试要求(即框架本身)
|
||||
|
||||
- `make golden`(或 `MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m golden --no-cov`)全绿:两个种子子集通过率 100%(种子用例是手工校准过的,必须全对,否则框架自身有 bug);
|
||||
- **阻断语义自证**:在本地临时改坏一条用例的 expect 验证会 fail 且输出明细(验证完改回来——提交里只有正确版本;这个自证过程写进提交信息,不写代码);
|
||||
- 无 PG/Redis 时 `pytest -m golden` 全部 skip(不拖累本地 `make test`)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/**`、`apps/**`、`tests/unit/**`、`tests/integration/**`、`pyproject.toml`(golden marker 已注册)、`Makefile`/CI(防线 5c 已挂)。**你的全部产出在 `tests/golden/**`。**
|
||||
|
||||
## 6. 验收标准(T-P2-11)
|
||||
|
||||
1. CI 防线 5c 能跑且通过(本地等价命令全绿);
|
||||
2. 指标退化即 fail:人为退化会被拦截(自证过程留提交信息);
|
||||
3. 加用例 = 加 JSON 行,零代码改动(README 有流程);
|
||||
4. 规则子集含硬禁忌场景、检索子集含 must_miss(红线)场景;
|
||||
5. 全部检查绿(tests 不在 mypy 范围;ruff 对 tests/golden 全绿)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
test(golden): data-driven golden set framework wired into CI
|
||||
|
||||
tests/golden:JSON 用例 + 阈值配置 + runner(规则 pass_rate=1.0、检索 0.8
|
||||
起步)+ pytest 壳;规则/检索两子集种子,Agent/报告子集占位(T-P2-11)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
# T14 · 检索结果缓存与发布失效
|
||||
|
||||
> 对应任务:`T-P2-10` | 需求:发布后检索缓存自然失效(Sprint 2 验收行)| 分支:`task/t14-retrieval-cache` | worktree:`/home/xsl/meida_backend-t14`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
给 `RetrievalService.search` 加**进程内结果缓存**(TTL + 容量上限 + 代数失效),把协调提交里的 `invalidate_cache()` 桩变成真实现。缓存解决的是"同一 query 高频重复检索"(Agent 多轮、黄金集回归);失效保证知识发布后**不吐旧结果**。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
- **配置**(config.py 已加,.env.example 已示例):`retrieval_cache_enabled=True`、`retrieval_cache_ttl_seconds=60`、`retrieval_cache_max_entries=512`。
|
||||
- **钩子已存在**:`packages/retrieval/service.py` 模块级 `invalidate_cache()`(当前只是 `repo.INVALIDATION_VERSION += 1`)与 `packages/retrieval/repo.py` 的 `INVALIDATION_VERSION = 1`——运营后台(T12)已经在调它。**你实现真缓存,不改钩子签名**。
|
||||
- `RetrievalService.__init__(session, *, embedding, weights, top_k)` 与 `from_settings(session, settings)`——缓存参数经 `from_settings` 读 Settings 注入(**不改 `__init__` 既有签名**,加带默认值的 keyword-only 参数)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/retrieval/cache.py`(新建)、`packages/retrieval/service.py`(缓存接线)、测试文件。**不改 repo.py 的既有函数与 INVALIDATION_VERSION 语义**。
|
||||
|
||||
1. **`packages/retrieval/cache.py`**(纯逻辑,重点单测):
|
||||
- `CacheKey`:`(version, query_text, body_shape, occasion, entity_types, rule_boost_hash, top_k)`——**全部影响结果的输入都必须进键**,缺一个就会串结果(rule_boost 是 dict[int,float],排序后序列化再哈希);
|
||||
- `TtlCache`:容量上限用**简单 LRU**(`OrderedDict` 移动末尾 + 超限 popitem(last=False));TTL 用惰性过期(读时比对时间戳,过期视作 miss);**线程安全不需要**(asyncio 单循环,无 await 竞态窗口——注释写明这个前提,别加锁);
|
||||
- 指标:`hits`/`misses`/`evictions` 计数器(Prometheus 可后续接,先暴露属性)+ `stats()` 方法。
|
||||
2. **service.py 接线**:
|
||||
- `RetrievalService` 增加 `cache: TtlCache | None`(None = 禁用,行为与现在完全一致);
|
||||
- `search()`:`cache` 非 None 且非 `degraded` 路径——命中直接返回缓存 `SearchOutcome`;未命中照常检索后**只在有结果时**写缓存(空结果不缓存:`record_gap` 每次都要跑,否则缺口计数被缓存吞掉);
|
||||
- `from_settings` 按 Settings 装配缓存;**模块级 `invalidate_cache()`**:把 service 模块持有的共享缓存实例(或 repo.INVALIDATION_VERSION,二选一,选实现更干净的那个)作废——调用方(admin 发布编排)不需要知道细节。
|
||||
3. **正确性红线**(写进 cache.py docstring):缓存的是 `SearchOutcome`(frozen dataclass,不可变)——**绝不缓存可变对象引用**;命中路径不得复用 session(缓存命中时 DB 零交互)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_retrieval_cache.py`(fake repo/embedding,不连库):
|
||||
1. 同参数二连查:第二次零 DB 调用(fake 计数断言)、结果相等;
|
||||
2. 任一键分量变化(query/occasion/body_shape/rule_boost/top_k)→ miss;
|
||||
3. TTL 过期 → miss(注入 clock);
|
||||
4. LRU 容量超限 → 最旧被逐、计数正确;
|
||||
5. 空结果不缓存(degraded 路径每次都触发 gap 记录 fake 调用);
|
||||
6. `invalidate_cache()` 后同参数 → miss(代数失效);
|
||||
7. cache=None → 行为与现在一致。
|
||||
- 集成 `tests/integration/test_retrieval_cache.py`(新文件):
|
||||
1. seed 实体 + chunk → search 命中 → **发布新实体(直接调 kb service 或 SQL 置 published)+ `invalidate_cache()`** → 再 search 能看到新实体(**这是 T-P2-10 的验收语义**);
|
||||
2. 不失效等 TTL 过期(TTL 配 1s,sleep 1.1s)→ 新实体同样可见(双保险)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/retrieval/repo.py` 的既有函数、`packages/{kb,rules}/**`、`apps/**`、所有已有测试文件。T12 会调 `invalidate_cache()`——**签名不变**是你与它的契约。
|
||||
|
||||
## 6. 验收标准(T-P2-10)
|
||||
|
||||
1. 同参重复检索零 DB 交互(单测断言);
|
||||
2. 发布后新内容立即可见(invalidate 路径)且 TTL 到期自然可见(双路径集成测试);
|
||||
3. 影响结果的输入全量进缓存键(rule_boost 哈希);
|
||||
4. 空结果不缓存(gap 埋点不被吞);
|
||||
5. 全部检查绿(mypy 范围照 T6 文档同口径 + `packages/retrieval`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(retrieval): TTL+LRU result cache with generation-based invalidation
|
||||
|
||||
TtlCache(全输入进键/惰性过期/容量逐出/命中零 DB)+ search 接线
|
||||
(空结果不缓存,保住 gap 埋点)+ invalidate_cache 真实现(T-P2-10)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# T15 · 报告域骨架——触发/配额/异步链路/中间态恢复
|
||||
|
||||
> 对应任务:`T-P3-07`(触发与频控、配额生效)、`T-P3-09` 🔴(失败不需重新上传照片——中间态落库+分步恢复)、架构 §7.1 步骤 1~7 与 22~24 | 分支:`task/t15-report-skeleton` | worktree:`/home/xsl/meida_backend-t15`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。**第四批三个任务(T15/T16/T17)拼成 M3 报告流水线:你是骨架与编排,T16 供视觉/诊断阶段,T17 供文案/校验阶段——你们互不改对方文件,靠协调提交定好的阶段注册机制衔接。**
|
||||
|
||||
## 1. 目标
|
||||
|
||||
报告域的编排骨架:`POST /reports`(资格校验→终身配额预扣→建 tk_task→入 cpu 队列)→ worker 执行**可注入的流水线**(阶段动态装配、中间态逐段落库、视觉结果可复用不重跑)→ 失败自动退款;`GET /reports`、`GET /reports/{id}`、`POST /reports/{id}/regenerate`(走 `report_regen` 月配额)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations/config/errors)
|
||||
|
||||
### 表(迁移 `0006`,ORM `RpReport`/`RpInput`/`RpShare`,先读 models.py 核对)
|
||||
|
||||
- `rp_report`:`report_id` PK、`user_id`、`tier`('basic'/'full')、`status`('pending'/'running'/'succeeded'/'failed')、`version`、`result` JSONB(**流水线中间态与最终产物都写这里**,按 stage 键增量更新)、`model_info` JSONB、`cost_cents` Numeric、`created_at/completed_at`;Index(user_id,created_at)
|
||||
- `rp_input`:`input_id` PK、`report_id`、`questionnaire` JSONB、`photo_meta` JSONB、**`derived_features` JSONB**(视觉中间态——T16 的 vision 阶段写,**已存在则跳过视觉**=T-P3-09 的核心);UNIQUE(report_id)
|
||||
- `rp_share`:`share_id` PK、`report_id`、`share_token` UNIQUE、`expire_at`、`view_count`、`revoked`(V1 只建结构,路由不做)
|
||||
|
||||
### 错误码:`REPORT_NOT_FOUND = 40420`(404)、`REPORT_ALREADY_EXISTS = 40920`(409「本期报告已生成过」,hint「每 30 天可重新生成一次」——**文案不含价格字样**)
|
||||
|
||||
### 配置:`report_pipeline_stages: list[str]`(**dotted path 列表**,默认 `["packages.report.vision_stage:VisionStage","packages.report.diagnosis_stage:DiagnosisStage","packages.report.copy_stage:CopyStage","packages.report.validation_stage:ValidationStage"]`)、`report_vision_provider: str = "deterministic"`、`report_tier: str = "basic"`
|
||||
|
||||
### 阶段注册机制(协调提交的 `packages/report/pipeline.py` 骨架里有接口约定,你实现)
|
||||
|
||||
- `Stage` Protocol:`name: str` + `async def run(ctx: ReportContext) -> None`(阶段把产物写进 `ctx.report.result[self.name]`,异常即流水线失败);
|
||||
- `ReportContext`(frozen dataclass):`report: RpReport`、`rp_input: RpInput`、`session`、`settings`、`gateway: LLMGateway | None`(worker 装配时注入——**T17 的阶段要用**);
|
||||
- `assemble_stages(settings) -> list[Stage]`:用 **importlib 按 dotted path 动态导入**(这是刻意的插件式装配:T16/T17 各自建阶段模块,不碰你的文件)。**模块/类不存在时抛 `AppError(CONFIG_ERROR)` 并列出缺失项**——绝不静默跳过(静默=流水线空跑还扣配额)。动态导入绕过 import-linter 静态检查,注释写明这一点与理由。
|
||||
|
||||
### 复用设施(先读再用)
|
||||
|
||||
- `BillingService.reserve/commit/refund`(quota_key:首次 `report_basic`(lifetime)、重新生成 `report_regen`(month));幂等键沿用 `reserve_idempotency_key` 风格;
|
||||
- `TaskService.submit(...)`(幂等键=`request_key`)+ `tk_task` 状态机;celery `task_routes` 已预设 `meida.report.*`→cpu;
|
||||
- 参考现成范式:`apps/api/routers/tasks.py` 的 ping 投递 + `apps/worker/tasks.py` 的 `_run_ping`(asyncio.run 包装)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/report/**`(pipeline.py、service.py、repo.py、schemas.py、`__init__.py`、README.md)、`apps/api/routers/reports.py`(填充协调提交的空桩)、`apps/worker/report_tasks.py`(新建)、测试文件。**vision_stage.py / diagnosis_stage.py / copy_stage.py / validation_stage.py 四个文件归 T16/T17,禁建禁改**——你的流水线只认配置里的 dotted path。
|
||||
|
||||
1. **pipeline.py**:Stage Protocol、ReportContext、assemble_stages(动态导入+缺失即 CONFIG_ERROR)、`run_pipeline(ctx, stages)`——逐阶段执行,每阶段完成后 `ctx.report.result[stage.name] = {...}` 并 **flush 落库**(中间态持久化);阶段异常:tk_task→failed + refund + `rp_report.status='failed'` + error 写 result,**异常上抛给 celery 重试逻辑前先保证退款事务提交**(参考 agent service 的事务纪律)。
|
||||
2. **service.py** `ReportService`:
|
||||
- `create_report(user_id, questionnaire, photo_object_key, request_key)`:查该用户已 succeeded 的 basic 报告 → 有则 40920(**首次终身 1 次的资格语义**,FR 定义见需求文档 §2.1);reserve(report_basic, amount=1) → 建 rp_report(pending)+rp_input → tk_task(idempotency_key=request_key) → 入队 → 返回 task_id+report_id。**reserve 与 tk_task 同事务,入队在事务提交后**(队列消息不能早于数据落库);
|
||||
- `regenerate(report_id, request_key)`:目标报告须 succeeded;reserve(report_regen) → 复用 rp_input(**derived_features 保留——这就是"失败/重生成不需重新上传"**)→ version+1 新报告行;
|
||||
- `get_report / list_reports`(归属校验,result 透出但 failed 只透错误摘要);
|
||||
- **幂等**:同 request_key 二次提交返回同一 task_id,不重复预扣(tk_task 幂等键 + reserve 幂等键双重保证)。
|
||||
3. **worker/report_tasks.py**:`@celery_app.task(name="meida.report.generate", bind=True, max_retries=1)`——开 session、装配 gateway(worker 侧 `build_llm_gateway(settings)`——deps.py 是 apps.api 的,worker 里自建装配函数,不 import apps.api)、`assemble_stages`、执行 run_pipeline、成功后 commit 配额 + status=succeeded + completed_at + 成本聚合写 `model_info`。**重试语义**:重试时若 `rp_input.derived_features` 已存在,视觉阶段自然跳过(T16 实现),网络类异常才重试。
|
||||
4. **路由 reports.py**(prefix `/reports`):`POST /`(body:questionnaire dict + photo_object_key + 可选 request_key)、`GET /`、`GET /{id}`、`POST /{id}/regenerate`。响应模型放 `packages/report/schemas.py`。
|
||||
5. **测试钩子**:集成测试不启真 celery——直接调 `run_pipeline`(注入 Fake 阶段)+ 独立测 celery 任务函数(`task.run()` 直调)。**先看 tests/integration/test_task_bonus.py 里已有的 dev-token 与 app fixture 模式。**
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_report_pipeline.py`:assemble_stages 缺失模块 → CONFIG_ERROR 且列出缺失项;run_pipeline 两阶段顺序执行、产物按键写入、第二阶段抛错 → 状态 failed + 已完成阶段产物仍在。
|
||||
- 集成 `tests/integration/test_report_flow.py`(新文件,Fake 阶段注入——把配置里的 stages 用 monkeypatch 换成文件内定义的两个 Fake 阶段):
|
||||
1. dev-token → POST /reports → 200,task/report id;tk_task PENDING、bl_ledger reserved 一行(report_basic lifetime);
|
||||
2. 直调 worker 函数(Fake 阶段成功)→ rp_report succeeded、result 含两个阶段键、ledger committed;
|
||||
3. **重复同 request_key → 同 task_id、无第二笔 reserve**;
|
||||
4. **首次资格**:succeeded 后再 POST /reports → 40920;
|
||||
5. **失败退款**:Fake 阶段二抛错 → status failed、ledger 出现 refund、**rp_input.derived_features 保留**(Fake 阶段一写的);
|
||||
6. regenerate:走 report_regen 月配额、version+1、复用 derived_features。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/report/{vision,vision_stage,diagnosis_stage,copy,copy_stage,validation,validation_stage,dimensions}.py`(T16/T17 所有)、全部已有测试、`apps/worker/tasks.py`。
|
||||
|
||||
## 6. 验收标准(T-P3-07/09)
|
||||
|
||||
1. 首次终身资格校验与 40920;regenerate 走月配额;
|
||||
2. 幂等提交不重复扣减;
|
||||
3. 阶段产物逐段落库;失败自动退款且视觉中间态保留(重试/重生成不重跑视觉的结构保证);
|
||||
4. 全部检查绿(mypy 范围:T6 同口径 + `packages/report`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(report): orchestration skeleton with staged pipeline and refund-on-failure
|
||||
|
||||
POST /reports 骨架:资格校验→终身配额预扣→tk_task→cpu 队列;动态装配
|
||||
流水线(dotted-path 注册、缺阶段即 CONFIG_ERROR);中间态逐段落库、失败
|
||||
退款、derived_features 跨重试/重生成复用(T-P3-07/09)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# T16 · 报告流水线·视觉分析与 32 维诊断装配(mock vision)
|
||||
|
||||
> 对应任务:`T-P3-01`(各项返回置信度与质量标记)、`T-P3-06` 🔴(**32 维度**每维四要素完整,缺失显式标注)| 分支:`task/t16-report-diagnosis` | worktree:`/home/xsl/meida_backend-t16`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。**第四批拼流水线:T15 定 Stage 接口(协调提交的 pipeline 骨架),你供视觉与诊断两个阶段,文件名与类名已由 T15 的任务契约定死——按下面 §2 的 dotted path 精确落位。**
|
||||
|
||||
## 2. 你要创建的文件(与流水线的挂点,禁差分毫)
|
||||
|
||||
| 文件 | 导出 | 挂进流水线的名字 |
|
||||
| --- | --- | --- |
|
||||
| `packages/report/dimensions.py` | `DIMENSIONS`(32 项冻结结构)等 | —(纯字典) |
|
||||
| `packages/report/vision.py` | `VisionProvider` Protocol、`DeterministicVisionProvider`、`build_vision_provider(settings)` | —(被 vision_stage 用) |
|
||||
| `packages/report/vision_stage.py` | `class VisionStage` | `packages.report.vision_stage:VisionStage` |
|
||||
| `packages/report/diagnosis_stage.py` | `class DiagnosisStage` | `packages.report.diagnosis_stage:DiagnosisStage` |
|
||||
|
||||
Stage 接口以协调提交的 `packages/report/pipeline.py` 骨架 docstring 为准(`name` + `async run(ctx)`;`ctx.report.result[stage.name]` 写产物;`ctx.rp_input.derived_features` 是你的中间态位)。
|
||||
|
||||
## 3. 维度字典(dimensions.py——本任务的事实核心)
|
||||
|
||||
**必读**:`G:\meida\docs\美学报告-需求文档.md` 的 **§4 诊断维度字典**(32 行表)。逐行编码成:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DimensionSpec:
|
||||
number: int # 1~32(33/34 已永久废弃,不复用)
|
||||
key: str # 稳定英文键,如 body_shape / season_code / face_shape
|
||||
name_cn: str
|
||||
source: str # questionnaire | vision | color | kb | rule | llm(按表「数据来源」列归类)
|
||||
basic_coverage: str # "full" | "label" | "partial" | "none"(按「初级」列的 ✅/标签/部分/❌)
|
||||
```
|
||||
|
||||
- `DIMENSIONS: tuple[DimensionSpec, ...]` 必须恰好 32 项、number 1~32 连续;模块常量 `BASIC_FULL_KEYS`(初级 full 覆盖集,验收「初级 21/32」口径的机器可读形态——**以你逐行统计为准并写注释**)。
|
||||
- key 命名参考 kb/记忆系统已有词汇(body_shape、face_shape、season_code…),全库一致。
|
||||
|
||||
## 4. 视觉分析(vision.py + vision_stage.py)
|
||||
|
||||
1. **`VisionProvider` Protocol**:`async def analyze(photo_meta: dict, questionnaire: dict) -> dict`(返回派生特征 dict:体型、脸型、肤色 Lab 采样、量感/直曲粗判……**形状由你定,写进 docstring**——这是 T9 SPIKE 之后的正式接口位,真实 RTMPose/BiRefNet 实现后续替换)。
|
||||
2. **`DeterministicVisionProvider`**:不读照片字节(V1 骨架根本没有照片下载步骤),从 `questionnaire` + `photo_meta["object_key"]` 的 sha256 派生**确定性**特征(同输入同输出——黄金集与集成测试才能断言)。特征生成规则写注释:哪个问题驱动哪个维度、哈希驱动哪些枚举选择。
|
||||
3. `build_vision_provider(settings)`:按 `settings.report_vision_provider` 选择(当前只认 `"deterministic"`,其它 → `AppError(CONFIG_ERROR)`)。
|
||||
4. **`VisionStage`**(name=`"vision"`):`ctx.rp_input.derived_features` **已存在且非空 → 跳过**(写 `ctx.report.result["vision"] = {"skipped": true, "reused": true}`——这就是 T-P3-09 的实现点);否则 provider.analyze → 写 `derived_features` 并 flush → `result["vision"] = {"status": "ok", "dimensions_touched": [...]}`。质量标记:photo_meta 里可选 `quality_flags`(模糊/背光等,mock 下默认空列表)透传进产物(T-P3-01 的形状)。
|
||||
|
||||
## 5. 诊断装配(diagnosis_stage.py)
|
||||
|
||||
`DiagnosisStage`(name=`"diagnosis"`):
|
||||
1. 输入 = questionnaire(`ctx.rp_input.questionnaire`)+ derived_features;**逐个维度**产四要素(T-P3-06 验收原文):
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DimensionResult:
|
||||
number: int
|
||||
key: str
|
||||
value: Any # 结论值(枚举/数值/列表)
|
||||
confidence: float # 0~1:问卷自评 0.6、确定性视觉 0.85、规则推导 0.9(口径写注释,对齐记忆系统 §3.5)
|
||||
source: str # questionnaire | vision | rule | derived
|
||||
evidence: str # 一句人话依据(将出现在报告里)
|
||||
rule_hits: list[str] # 命中的 rule_key
|
||||
status: str # "ok" | "skipped"——**缺失/输入不足的维度必须显式 skipped + 原因,不允许缺键**(验收红线)
|
||||
```
|
||||
|
||||
2. 数据流:问卷字段直接映射(性别/身高体重/职业/生日/核心诉求/场景权重/心理风格/偏好禁忌…);视觉字段映射(体型/脸型/肤色档位/季型/量感/直曲/基因风格…);**规则引擎联动**:构造 `RuleContext`(body_shape/face_shape/features/goals…)→ `RulesService` 载入 published 规则 → `evaluate()` → verdict 的 recommend/avoid 聚合进「核心诉求/偏好与禁忌」相关维度的 rule_hits 与 evidence。规则服务接口以 `packages/rules/service.py` 实际代码为准。
|
||||
3. 产物:`ctx.report.result["diagnosis"] = {"dimensions": [32 个 DimensionResult 的 dict], "coverage": {"basic_full": N, "total": 32}}`;序列化用 dataclass `asdict`。
|
||||
4. **色板维度(#19/21)**:V1 骨架从 `kb_color` 读该季型 base/accent/avoid 各取若干(库空则该维度 skipped+「色板未录入」——**显式缺失优于编造**,这正是 skipped 语义的用例)。
|
||||
|
||||
## 6. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_report_dimensions.py`:32 项、number 连续、33/34 不存在、basic_coverage 枚举合法、`BASIC_FULL_KEYS` 与逐行统计一致。
|
||||
- 单测 `tests/unit/test_report_vision.py`:确定性(同输入同输出)、不同问卷驱动不同体型、工厂未知名拒绝。
|
||||
- 单测 `tests/unit/test_report_diagnosis.py`:四要素齐全性——**构造故意缺问卷+缺视觉的场景,断言缺失维度 status=skipped 且 32 键全在**;规则命中进 rule_hits(用内置最小规则集,不连库——RulesService 若需 DB,把规则求值抽成可注入纯函数 `evaluate_dimensions(ctx_data, verdict)`)。
|
||||
- 集成 `tests/integration/test_report_stages.py`(新文件,**只测你的两个阶段,不测整条流水线**——T15 的集成已覆盖编排):建 rp_report/rp_input + 种 2 条 published 规则 → VisionStage → DiagnosisStage 断言 derived_features 落库、32 维全键、规则 rule_hits 出现、kb_color 为空时色板维度 skipped。
|
||||
|
||||
## 7. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/report/` 除你四个文件外全部(pipeline/service/repo/schemas 归 T15,copy*/validation* 归 T17)、`apps/**`、全部已有测试。
|
||||
|
||||
## 8. 验收标准(T-P3-01/06)
|
||||
|
||||
1. `DIMENSIONS` 与需求文档 §4 逐行一致(32 项、编号连续、初级覆盖口径机器可读);
|
||||
2. 32 维四要素全量输出,缺失显式 skipped——**任何输入组合下 result["diagnosis"]["dimensions"] 恒 32 键**(单测证明);
|
||||
3. 视觉中间态复用:derived_features 存在时 vision 阶段 skipped/reused;
|
||||
4. 规则引擎硬结论进入 rule_hits/evidence;
|
||||
5. 全部检查绿(mypy 范围同 T15 + 你的文件;`packages/report` 整包进列表)。
|
||||
|
||||
## 9. 提交信息
|
||||
|
||||
```
|
||||
feat(report): deterministic vision provider and 32-dimension diagnosis assembly
|
||||
|
||||
dimensions.py 逐行编码需求文档 §4(32 项四要素、缺失显式 skipped)+
|
||||
VisionProvider 接口位与确定性实现 + 规则引擎联动 rule_hits(T-P3-01/06)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# T17 · 报告流水线·LLM 文案与生成后校验
|
||||
|
||||
> 对应任务:`T-P3-10`(文案结论与诊断值一致,无自相矛盾)、`T-P3-11` 🔴(**硬性禁忌违反数 = 0**;贬损词零通过)、架构 §7.1 步骤 17~19(「生成后校验不可跳过:校验失败最多重试 2 次,仍失败降级为规则直出模板文案,绝不放行违规内容」)| 分支:`task/t17-report-copy` | worktree:`/home/xsl/meida_backend-t17`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。**第四批拼流水线:T15 定 Stage 接口,你供文案与校验两个阶段,文件名与类名已定死。**
|
||||
|
||||
## 2. 你要创建的文件(挂点,禁差分毫)
|
||||
|
||||
| 文件 | 导出 | 流水线 dotted path |
|
||||
| --- | --- | --- |
|
||||
| `packages/report/copy.py` | 纯逻辑:结论→文案素材、模板降级渲染 | — |
|
||||
| `packages/report/copy_stage.py` | `class CopyStage` | `packages.report.copy_stage:CopyStage` |
|
||||
| `packages/report/validation.py` | 纯逻辑:贬损词表、一致性校验器 | — |
|
||||
| `packages/report/validation_stage.py` | `class ValidationStage` | `packages.report.validation_stage:ValidationStage` |
|
||||
|
||||
Stage 接口以协调提交 `packages/report/pipeline.py` 骨架为准;`ctx.gateway` 是 T15 注入的 `LLMGateway`(集成测试可换 FakeProvider——参考 `tests/integration/test_vertical_slice.py` 的做法)。
|
||||
|
||||
## 3. LLM 文案(copy.py + copy_stage.py)
|
||||
|
||||
1. **Prompt**:`prompts/report_copy/v1.txt`(你新建,版本化目录规则见 `prompts/README.md`——`[system]` 段**不许含任何变量**(缓存前缀纪律,`validate_cache_prefix` 会拦);`[user]` 段放 `{dimensions_json}`)。system 定角色:形象顾问报告文案;红线写进 system:描述而非评判、不用绝对化用语、不出现贬损词、结论只能复述给定诊断值**不得新增维度结论**、简体中文。
|
||||
2. **结构化输出**:`gateway.complete(scene=Scene.REPORT_COPY, prompt_vars={...}, schema=ReportCopy, user_id=..., ref_type="report", ref_id=str(report_id))`——先读 `packages/llm/gateway.py` 的 `complete` 签名与 `Scene` 枚举、`packages/llm/routing.py` 里 REPORT_COPY 场景现有配置(temperature/prompt_id 指向你的 v1)。`ReportCopy(BaseModel)`:`summary: str`、`sections: list[Section]`(Section:`title/paragraphs: list[str]`)——**schema 宽进严出的取舍写注释**(字段越少结构化成功率越高,文案主体自由度放 sections 内)。
|
||||
3. **素材组装(纯函数 `build_prompt_material(result_diagnosis) -> str`)**:从 T16 的 `result["diagnosis"]["dimensions"]` 取 status=ok 的维度(skipped 不进 prompt——**LLM 看不到缺失维度就不会编造它**,这是防幻觉的第一道闸);序列化成紧凑 JSON(key+value+evidence,舍 confidence 小数位)。**总长控制**:拼好后若超 ~6000 字符,按 basic_coverage 优先级裁剪维度并记录 `dropped_keys`(对齐架构 §7.2 的裁剪思想:低重要度先丢,硬结论永不丢)。
|
||||
4. **`CopyStage`(name="copy")**:无 diagnosis 产物 → 抛错(流水线顺序错);调 gateway → `result["copy"] = {"summary": ..., "sections": [...], "model": completion.model, "prompt_version": ..., "dropped_keys": [...]}`。**这里不做校验**——校验是下一阶段的独立职责(T-P3-10 的"生成后校验"是流水线步骤 18,不是文案函数的内嵌断言)。
|
||||
|
||||
## 4. 生成后校验(validation.py + validation_stage.py)
|
||||
|
||||
1. **贬损词表初版**(`validation.py` 模块常量 `PEJORATIVE_TERMS`,来源:`G:\meida\docs\合规与安全方案.md` §3「内容伦理红线」——**开工先读该节**,把"描述而非评判/不使用缺陷框架"翻译成具体词表:胖/丑/矮小/缺陷/土气/难看/显老/失败 等约 20~40 词 + 简单变形("有点胖"命中"胖"即可,不做词干)。词表带注释:这是初版内嵌版,正式版归合规词库任务(T-P0-07)。
|
||||
2. **一致性校验(纯函数)**:
|
||||
- `check_pejorative(text) -> list[str]`(命中词列表);
|
||||
- `check_conclusion_consistency(copy: ReportCopy, dimensions: list[dict]) -> list[str]`(不一致清单):初版三条规则——① 文案中出现的**体型/脸型/季型枚举词**必须是诊断值同款(诊断说 H 型、文案写 A 型 → 不一致);② 文案提到具体色值 `#RRGGBB` 必须在色板维度 value 集合内;③ 出现"建议"句式却无对应 rule_hits 支撑的维度结论 → 标记(宽松口径,注释说明误报代价 vs 漏报代价的取舍)。
|
||||
- `check_absolute_claims(text)`:绝对化用语(最/第一/彻底/百分百——prompt 禁了,校验兜底)。
|
||||
3. **`ValidationStage`(name="validation")**:取 copy + diagnosis → 全部校验 →
|
||||
- **全过**:`result["validation"] = {"passed": true, "checks": {...计数}}`;
|
||||
- **有违规**:**重试一次**——把违规清单作为修正指令追加(messages 上下文,参考 gateway 的结构化重试机制但不能用它内部的重试——那是解析层,这是语义层,两层独立)再生成 → 再校验 → 仍违规 → **降级模板**:`render_fallback_copy(dimensions, rule_hits)`(纯函数:按维度 evidence + rule explanation 拼模板文案,零 LLM)→ `result["validation"] = {"passed": true, "degraded": true, "violations_first_pass": [...]}`。**任何出口 passed 必须为 true 或阶段抛错——绝不放行未过校验的文案**(T-P3-11 红线)。
|
||||
4. 校验产物同段落库(`result["validation"]`),报告查询侧能看到是否降级。
|
||||
|
||||
## 5. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_report_copy.py`:素材组装(skipped 不进、超长裁剪记录 dropped、硬结论不裁)、模板降级渲染(有 rule_hits 时含 explanation)。
|
||||
- 单测 `tests/unit/test_report_validation.py`:词表命中(含"有点胖"变形)、一致性三条规则各一正一反、绝对化用语、重试后仍违规 → 降级路径纯函数级验证。
|
||||
- 集成 `tests/integration/test_report_copy_stage.py`(新文件,FakeProvider 替换 gateway——参考 test_vertical_slice):
|
||||
1. 干净 LLM(Fake 返回合规文案)→ copy + validation 两阶段 passed=true;
|
||||
2. **脏 LLM**(Fake 第一次返回含"微胖/最合适",修正后干净)→ 第一轮 violations 非空 → 重试通过;
|
||||
3. **持续脏 LLM**(恒返贬损词)→ 降级模板 passed=true degraded=true,**最终文案零贬损词**(T-P3-11 的黄金断言)。
|
||||
- 不测整条流水线(T15 集成已覆盖编排);只直接构造 ctx 调你的阶段。
|
||||
|
||||
## 6. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/report/` 除你四个文件 + `prompts/report_copy/**` 外全部、`apps/**`、全部已有测试。**`prompts/report_copy/v1.txt` 归你**(新目录不与他人冲突)。
|
||||
|
||||
## 7. 验收标准(T-P3-10/11)
|
||||
|
||||
1. 校验不可跳过:任何出口要么 passed 要么抛错;
|
||||
2. 贬损词零通过(含持续脏 LLM 的降级路径断言);
|
||||
3. 文案-结论一致性三条规则生效;
|
||||
4. LLM 看不到 skipped 维度(防幻觉得到结构保证);
|
||||
5. 全部检查绿(mypy 范围同 T15 + 你的文件)。
|
||||
|
||||
## 8. 提交信息
|
||||
|
||||
```
|
||||
feat(report): LLM copy generation with mandatory post-validation and fallback
|
||||
|
||||
report_copy/v1 prompt(系统段零变量)+ 结构化 ReportCopy + 素材组装
|
||||
(skipped 不进 prompt/超长裁剪)+ 贬损词表/一致性/绝对化三重校验 +
|
||||
重试一次后降级规则直出模板(T-P3-10/11)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# T2 · 任务奖励额度领取(claim)
|
||||
|
||||
> 对应任务:`T-P1-26` 收尾件 | 需求:`FR-BILL-03`、架构 §9.3 | 分支:`task/t2-task-bonus` | worktree:`/home/xsl/meida_backend-t2`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现产品方案 §3.2 的「做任务换额度」闭环的服务端:`POST /quota/tasks/{task_key}/claim`。
|
||||
发放即到账、**发放幂等**(同一任务同一周期不重复发放,FR-BILL-03 验收原文)、受 `bl_quota_rule.task_bonus_max` 封顶。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(`config.py` 已加字段,`.env.example` 已示例)
|
||||
|
||||
```python
|
||||
task_bonus_rules: dict[str, dict[str, Any]] # env: TASK_BONUS_RULES_JSON
|
||||
```
|
||||
默认值(协调提交写死在 config.py,含义:task_key → {quota_key, amount, claim_period}):
|
||||
```json
|
||||
{
|
||||
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"},
|
||||
"profile_complete":{"quota_key": "agent_chat", "amount": 1, "claim_period": "lifetime"},
|
||||
"report_feedback": {"quota_key": "report_regen", "amount": 1, "claim_period": "day"},
|
||||
"share_report": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
|
||||
"invite_friend": {"quota_key": "tryon", "amount": 1, "claim_period": "day"}
|
||||
}
|
||||
```
|
||||
⚠️ `amount` 全部是**占位值 1**——具体数值待产品在产品方案 §3.2 补充后改 env,不改代码(FR-BILL-04 配置化)。代码注释里必须写明这一点。
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `TASK_BONUS_UNKNOWN = 40410`:404「任务不存在或暂未开放」;
|
||||
- `TASK_BONUS_ALREADY_CLAIMED = 40910`:409「这个周期的任务奖励已领取」,hint「下个周期再来」;
|
||||
- `TASK_BONUS_CAP_REACHED = 40310`:403「任务奖励已达上限」,hint「明天再来,或等额度自然恢复」。
|
||||
(注意:这些文案已按 FR-BILL-07 写好,**不得出现价格/购买/会员/升级字样**,你不需要也不要改 meta。)
|
||||
|
||||
### 现有代码事实(先读再写)
|
||||
|
||||
- `packages/domain/billing/quota.py`:`QuotaState(used, base_limit, bonus, hard_cap)`、`period_key(now, period_type, tz_offset_hours)`、`period_reset_at(...)`、`reserve_idempotency_key`/`refund_idempotency_key`;`PeriodType = Literal["day","month","lifetime"]`。
|
||||
- `packages/billing/service.py`:`BillingService(session, tz_offset_hours)`;`QuotaSnapshot`;`reserve/commit/refund/get_quota/list_ledger`;内部 `_load_rule(quota_key)`、`lock_or_create_quota(...)`(返回已行锁的 `Quota` 行,`limit_value` 是 base_limit 快照,`bonus_granted` 是本周期已获奖励)。
|
||||
- `packages/billing/repo.py`:`add_ledger`/`find_ledger_by_idempotency_key` 等(照现有模式补你需要的)。
|
||||
- `packages/shared/models.py`:`Ledger`(`bl_ledger`:`tx_id` PK、`delta`、`tx_type`、`status`、`idempotency_key` UNIQUE、`balance_after`、`quota_key`、`period_key`、`reason`、`ref_type`、`ref_id`)。
|
||||
- `apps/api/routers/quota.py`:现有 `GET /quota`、`GET /quota/ledger`,响应模式照抄。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/billing/service.py`、`packages/billing/repo.py`、`packages/billing/schemas.py`(新建)、`packages/domain/billing/quota.py`、`apps/api/routers/quota.py`、测试文件。
|
||||
|
||||
1. **纯域函数**(`packages/domain/billing/quota.py`,mypy strict):
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BonusDecision: ...
|
||||
def grant_bonus(state: QuotaState, amount: int, task_bonus_max: int) -> QuotaDecision-like
|
||||
```
|
||||
语义:`bonus + amount > task_bonus_max` → 不允许(封顶);否则返回 `replace(state, bonus=state.bonus + amount)`。`task_bonus_max` 来自 `bl_quota_rule`。
|
||||
2. **服务方法** `BillingService.claim_task_bonus(user_id, task_key, rules)`:
|
||||
- task_key 不在 rules → `AppError(TASK_BONUS_UNKNOWN)`;
|
||||
- 解析 `{quota_key, amount, claim_period}`;`rule = await self._load_rule(quota_key)`;
|
||||
- `pk = period_key(now, claim_period, tz)`;幂等键 `f"grant:{user_id}:{task_key}:{pk}"`;
|
||||
- 先查 `bl_ledger.idempotency_key` 已存在 → `TASK_BONUS_ALREADY_CLAIMED`(幂等快捷路径);
|
||||
- `lock_or_create_quota`(同周期)→ `grant_bonus(...)` 封顶判定 → 不允许则 `TASK_BONUS_CAP_REACHED`(**不写任何流水**);
|
||||
- 同一事务内:更新 `bl_quota.bonus_granted`,插入 `Ledger(tx_type="system_grant", delta=0, status="committed", balance_after=state.remaining, reason=task_key, ref_type="task_bonus", ref_id=task_key)`,提交;
|
||||
- 并发兜底:`IntegrityError`(唯一键冲突)→ 回滚 → `TASK_BONUS_ALREADY_CLAIMED`。**`delta=0` 的理由写进注释**:system_grant 不改变 `used`,只提高有效额度,`balance_after` 仍按 remaining 语义记录(流水可重算的是 used,不是余额本身——与现有 reserve/commit 的记账口径一致)。
|
||||
3. **路由**(`apps/api/routers/quota.py` 追加):`POST /tasks/{task_key}/claim`(注意 router prefix 是 `/quota`,最终路径即架构 §9.3 的 `POST /quota/tasks/{task_key}/claim`)。请求无需 body;响应 `ok(TaskBonusClaimResponse(...))`,字段:`task_key/quota_key/amount/bonus_granted/remaining`。Pydantic 模型放 **`packages/billing/schemas.py`**(新建;apps/api/schemas.py 禁改)。
|
||||
- rules 从 settings 读:路由函数签名加 `settings: SettingsDep`,传 `dict(settings.task_bonus_rules)`。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_bonus_domain.py`:`grant_bonus` 的封顶边界(bonus+amount == max 允许、== max+1 拒绝)、amount≤0 抛 ValueError、不改变 used。
|
||||
- 单测 `tests/unit/test_task_bonus_rules.py`:settings 解析 `TASK_BONUS_RULES_JSON`(字符串 JSON / dict / 空值容错)——参考 config.py 的 `_parse_json_object` 模式,如果协调提交已把 `task_bonus_rules` 加进该 validator 的字段列表就不用测解析,直接测默认值形状(每个 entry 必有 quota_key/amount/claim_period,claim_period ∈ PeriodType)。
|
||||
- 集成测试 `tests/integration/test_task_bonus.py`(新文件,允许跑):
|
||||
1. dev-token 登录 → claim `sign_in` → 200,`GET /quota?quota_key=agent_chat` 的 `bonus_granted` = 1;
|
||||
2. 再 claim 同一 task → 40910;
|
||||
3. 把 Redis 无关、纯 DB 断言:`bl_ledger` 出现 `system_grant` 行且 `balance_after` = base+bonus;
|
||||
4. 封顶路径可用 `TASK_BONUS_RULES_JSON` 覆盖 amount 为 11(> task_bonus_max=10)→ claim → 40310,且无新流水。
|
||||
(settings 覆盖用 `monkeypatch.setenv + get_settings.cache_clear()`,结束恢复,参考 T1 文档的写法。)
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/agent/**`、`packages/identity/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{agent,auth,tasks,events,uploads}.py`、`apps/api/deps.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准(FR-BILL-03 原文)
|
||||
|
||||
1. 「签到、完善资料、反馈、邀请、分享完成即到账」——五类 task_key 全部走通 claim;
|
||||
2. **发放幂等**:同一任务同一周期重复 claim 返回 40910,账本无重复行;
|
||||
3. 封顶:`bonus_granted` 永不超过 `bl_quota_rule.task_bonus_max`;
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage` + `packages/domain` strict)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(billing): task bonus claim endpoint with idempotency and cap
|
||||
|
||||
POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
|
||||
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# T3 · 客户端埋点批量上报
|
||||
|
||||
> 对应任务:`T-P1-10`(埋点事件字典/上报通道的服务端部分)| 需求:`FR-SYS-06`、架构 §9.3 | 分支:`task/t3-events` | worktree:`/home/xsl/meida_backend-t3`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /events/batch`:客户端批量上报埋点事件,落 `ob_event` 表。这是 V1 全链路埋点(FR-SYS-06)与后续三张看板(TD-24)的数据入口。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 表(迁移 `0004` 已建,ORM 已加,**你不需要碰 models.py / migrations**)
|
||||
|
||||
`ob_event`(架构 §6.2):
|
||||
- `event_id` bigint PK(雪花)、`user_id` bigint NOT NULL、`event_name` varchar(64)、`props` JSONB、`client_ts` timestamptz NULL、`server_ts` timestamptz NOT NULL default now()、
|
||||
- 索引:`(event_name, server_ts)`、`(user_id, server_ts)`。
|
||||
- 架构原文"按月分区"——**V1 先普通表**(与 ob_ai_call 同样处理),代码注释写明"分区在数据量上来后再做,先不预支复杂度"。
|
||||
|
||||
### 配置(`config.py` 已加字段)
|
||||
|
||||
```python
|
||||
events_max_batch: int = 50 # env: EVENTS_MAX_BATCH
|
||||
events_props_max_bytes: int = 8192 # env: EVENTS_PROPS_MAX_BYTES
|
||||
```
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `EVENTS_BATCH_REJECTED = 40020`:400「埋点数据格式有误」,hint=null(对客户端静默降级即可,不打扰用户)。
|
||||
|
||||
### 骨架(协调提交已建,直接填充)
|
||||
|
||||
- `packages/analytics/__init__.py`、`packages/analytics/README.md`、`packages/analytics/service.py`(空骨架)
|
||||
- `apps/api/routers/events.py`(空骨架 router,**已注册进 main.py**,prefix `/events`)
|
||||
- `apps/api/deps.py` 已有 `AnalyticsServiceDep`(构造 `AnalyticsService(session)`)——**deps.py 禁改,签名如不满足需要,用路由函数内自建服务对象的方式,不改 deps**。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages.analytics` 被协调提交放进了 import-linter 的"共享能力层"(与 identity/billing/task 同层)——它只能 import `packages.shared`/`packages.domain`,**不得** import 其它业务包或 llm。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/analytics/**`、`apps/api/routers/events.py`、测试文件。
|
||||
|
||||
1. **`packages/analytics/schemas.py`**(新建):
|
||||
- `EventIn`:`event_name: str`(`pattern=r"^[a-z][a-z0-9_.]{1,63}$"`)、`props: dict[str, Any] = {}`、`client_ts: datetime | None = None`;
|
||||
- `EventBatchRequest`:`events: list[EventIn]`(`min_length=1`)、
|
||||
- `EventBatchResponse`:`accepted: int`。
|
||||
- **事件字典(event dictionary)**:V1 不做服务端枚举校验(客户端事件名集合由 TD-23 埋点字典维护,尚未定稿)——用正则约束形状即可,代码注释写明"字典校验等 TD-23 定稿后再收紧"。
|
||||
2. **`packages/analytics/service.py`**:
|
||||
- `AnalyticsService(session)`:`async def record(user_id: int, batch: list[EventIn]) -> int`;
|
||||
- 校验(fail-close,任一不过 → 整批拒绝 `AppError(EVENTS_BATCH_REJECTED)`,**不部分落库**):批量 ≤ `events_max_batch`;每条 props 序列化后 ≤ `events_props_max_bytes`(`len(json.dumps(props, ensure_ascii=False).encode())`);`client_ts` 若给出:不晚于 server_now+5min、不早于 7 天前;
|
||||
- 组装 `ObEvent(event_id=next_id(), user_id=..., server_ts=datetime.now(UTC))` 逐条 add,`flush` 一次,返回条数;(`from packages.domain.ids import next_id`,参考 identity/service.py 用法。)
|
||||
- **不含 PII 清洗**:props 里若有 `phone`/`token`/`authorization` 键 → 直接删除该键并 `logger.warning("events_pii_dropped", ...)`(NFR-SEC-03 日志脱敏的同族要求)。
|
||||
3. **`apps/api/routers/events.py`**:
|
||||
- `POST /batch`,`user_id: CurrentUserId`,`analytics: AnalyticsServiceDep`(若 Dep 不可用则函数内 `AnalyticsService(session)` with `SessionDep`);
|
||||
- `ok(EventBatchResponse(...))`;服务层抛的 `AppError` 自然走统一异常处理,不需要额外 catch。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_events_validation.py`(纯校验逻辑,不连库——把校验拆成模块级纯函数 `validate_batch(events, *, max_batch, max_bytes, now) -> list[ObEvent-like]` 便于无 DB 测试):
|
||||
- 合法批量通过;超批量拒;props 超字节拒;event_name 正则(大写/数字开头/超长拒);client_ts 未来>5min 拒、7 天前拒;PII 键被剔除。
|
||||
- 集成测试 `tests/integration/test_events.py`(新文件,允许跑):dev-token → POST /v1/events/batch 两条事件 → 200 accepted=2;查库两行 `ob_event`;再 POST 非法批(event_name 大写)→ 40020 且**库里没有半条**。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/billing/**`、`packages/identity/**`、`packages/storage/**`、`packages/agent/**`、`apps/api/routers/{agent,auth,quota,tasks,uploads}.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. `POST /api/v1/events/batch`(及 `/v1` 别名)可用,认证必需(无 token → 40100);
|
||||
2. 整批原子:要么全落、要么全拒;
|
||||
3. 校验规则全部有测试覆盖;
|
||||
4. 全部检查绿(mypy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(analytics): POST /events/batch with fail-close validation
|
||||
|
||||
ob_event 落库通道:形状校验 + props 字节上限 + client_ts 合理性 +
|
||||
PII 键剔除,整批原子(FR-SYS-06 服务端部分)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
# T4 · 刷新令牌与服务端可撤销会话
|
||||
|
||||
> 对应任务:`T-P1-13`(双令牌会话)| 需求:`FR-ACC-05`、架构 §6.2/§9.1 | 分支:`task/t4-refresh-session` | worktree:`/home/xsl/meida_backend-t4`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在现有 15 分钟 access JWT 之外,补齐 **refresh token**:opaque 令牌 + `id_session` 服务端可撤销 + 轮换(rotation)。端点:`POST /auth/refresh`、`POST /auth/logout`;dev-token 扩展为同时签发 refresh,使整条链路可测。
|
||||
|
||||
**明确不做**(写进代码注释,防止后人误判):华为账号登录(T-P1-12,等资质)、手机验证码(T-P1-12b,等短信通道)、游客/合并/注销、access token 的黑名单式即时吊销(JWT 无状态,V1 接受"access 最长 15 分钟残留";「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话,见下)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 表(迁移 `0004` 已建,ORM 已加,禁改 models.py/migrations)
|
||||
|
||||
`id_session`(架构 §6.2 原文列:`session_id, user_id, refresh_token_hash, expire_at, revoked`;协调提交落地为):
|
||||
- **ORM 类名为 `UserSession`**(刻意不用 `Session`,避免与 SQLAlchemy 的 AsyncSession 混淆):
|
||||
- `session_id` bigint PK(雪花)、`user_id` bigint NOT NULL、`refresh_token_hash` varchar(64) UNIQUE(sha256 hex)、`expire_at` timestamptz NOT NULL、`revoked` boolean NOT NULL default false、`revoked_at` timestamptz NULL、`created_at` timestamptz NOT NULL default now();
|
||||
- 索引:`(user_id)`。
|
||||
|
||||
### 配置(`config.py` 已加字段)
|
||||
|
||||
```python
|
||||
refresh_token_ttl_seconds: int = 2592000 # env: REFRESH_TOKEN_TTL_SECONDS,30 天
|
||||
```
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `REFRESH_TOKEN_INVALID = 40103`:401「登录状态无效」,hint「请重新登录」;
|
||||
- `REFRESH_TOKEN_EXPIRED = 40104`:401「登录状态已过期」,hint「请重新登录」;
|
||||
- `SESSION_REVOKED = 40105`:401「该会话已登出」,hint「请重新登录」。
|
||||
|
||||
### 现有代码事实(先读再写)
|
||||
|
||||
- `packages/shared/security.py`:`create_access_token`/`decode_access_token`(JWT HS256,claims `sub/iat/exp/iss/typ`)——**不改此文件**,refresh 不是 JWT。
|
||||
- `packages/identity/service.py`:`IdentityService(session)`、`get_or_create_by_external_identity`、`get_user`(注销/禁用会抛 UNAUTHENTICATED)、`record_consent`;`DEV_PROVIDER = "dev"`。
|
||||
- `packages/identity/repo.py`:`get_user/find_identity/add_user/add_identity/add_consent/get_consent` 模式(照抄风格补 session 函数)。
|
||||
- `apps/api/routers/auth.py`:dev-token 端点(`DEV_AUTH_ENABLED && is_local` 双开关)。
|
||||
- `apps/api/deps.py`:`IdentityServiceDep`、`SettingsDep` 已有(deps.py 禁改,够用)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/tokens.py`(新建)、`packages/identity/repo.py`、`packages/identity/service.py`、`packages/identity/schemas.py`(新建)、`apps/api/routers/auth.py`、测试文件。
|
||||
|
||||
1. **`packages/identity/tokens.py`**(纯逻辑,无 IO,重点单测):
|
||||
```python
|
||||
def generate_refresh_token() -> str # secrets.token_urlsafe(48)
|
||||
def hash_refresh_token(token: str) -> str # hashlib.sha256(token.encode()).hexdigest()
|
||||
```
|
||||
决策注释:**为什么 opaque 而不是 JWT**——refresh 的核心价值就是"服务端可单条撤销"(FR-ACC-05),无状态 JWT 做不到撤销;哈希落库而非明文,泄漏库不等于泄漏令牌。
|
||||
2. **repo**:`find_session_by_hash(session, token_hash)`、`add_session(session, Session(...))`、`revoke_session(session, session_id)`、`revoke_all_sessions(session, user_id)`(最后一个 V1 先备着,logout-all 属 FR 后续)。
|
||||
3. **service**(`IdentityService` 上追加方法):
|
||||
- `async def issue_session(user_id) -> SessionIssued`(dataclass:`refresh_token: str`、`expires_at: datetime`、`session_id: int`):先 `await self.get_user(user_id)`(防已注销用户续签),生成/哈希/落库/flush;
|
||||
- `async def rotate(refresh_token: str) -> SessionIssued`:查哈希 → 无 → `REFRESH_TOKEN_INVALID`;`revoked` → `SESSION_REVOKED`;`expire_at < now` → `REFRESH_TOKEN_EXPIRED`(并把该行 `revoked=true` 顺手落库);通过 → **旧行 revoked=true + 新行创建,同一事务**(轮换 = 旧 refresh 一次性);返回新 token;
|
||||
- `async def revoke(refresh_token: str) -> bool`:查哈希 → 无/已撤销返回 False;否则 revoked=true。**重复使用已撤销的 refresh(重放检测)**:V1 只报 `SESSION_REVOKED` 并 `logger.warning("refresh_reuse_detected", user_id=...)`,不做家族连坐(注释写明这是 V2 硬化项)。
|
||||
4. **`packages/identity/schemas.py`**(新建;apps/api/schemas.py 禁改):
|
||||
- `RefreshRequest(refresh_token: str)`、`LogoutRequest(refresh_token: str)`;
|
||||
- `TokenPairResponse(access_token, refresh_token, expires_in, refresh_expires_in, user_id: str)`。
|
||||
5. **`apps/api/routers/auth.py`**:
|
||||
- `POST /refresh`:`identity.rotate(...)` → `create_access_token(...)` → `ok(TokenPairResponse(...))`;
|
||||
- `POST /logout`:`user_id: CurrentUserId` + `identity.revoke(...)` → `ok({"revoked": True/False})`(False 不报错——幂等登出);
|
||||
- dev-token 端点扩展:同时 `issue_session`,响应改用 `TokenPairResponse`(**保留原字段名 `access_token/expires_in/user_id` 不变**,新增 `refresh_token/refresh_expires_in`——不破坏 smoke.sh 与现有测试对旧字段的读取;若现有测试断言整个 dict 形状,允许增量更新断言但不得删除旧字段)。
|
||||
- 注意:登出语义 = 撤销会话(refresh 失效)。access JWT 在 TTL 内仍有效的残留窗口,写进 auth.py 模块 docstring。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_refresh_tokens.py`:token 长度/字符域、哈希稳定性、哈希不可逆(≠原文)。
|
||||
- 集成测试 `tests/integration/test_auth_refresh.py`(新文件,允许跑,**自建 app fixture 参考 test_vertical_slice.py 的 FakeProvider 段,但 auth 用例不需要 LLM**):
|
||||
1. dev-token → 拿 `refresh_token`;`POST /v1/auth/refresh` → 200,新 access 可调 `GET /v1/quota`;
|
||||
2. **旧 refresh 二次使用** → 40105;
|
||||
3. `POST /v1/auth/logout` → 再 refresh → 40105;
|
||||
4. 伪造 refresh → 40103;
|
||||
5. 过期路径:构造 `REFRESH_TOKEN_TTL_SECONDS=0`(monkeypatch.setenv + `get_settings.cache_clear()`)→ refresh → 40104;结束恢复 cache。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/shared/security.py`、`packages/llm/**`、`packages/billing/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{agent,quota,tasks,events,uploads}.py`、`apps/api/deps.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-05)
|
||||
|
||||
1. access/refresh 双令牌齐备:access 15 分钟 JWT(现状)+ refresh 30 天 opaque 可撤销;
|
||||
2. **refresh 服务端可单条撤销**:logout 后该 refresh 立即失效;
|
||||
3. 轮换生效:每次 refresh 换新 token,旧 token 一次性;
|
||||
4. dev-token 链路向后兼容(旧响应字段不变);
|
||||
5. 全部检查绿(mopy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): refresh token rotation with server-side revocable sessions
|
||||
|
||||
id_session 落地:opaque refresh + sha256 哈希存储 + 一次性轮换 +
|
||||
logout 撤销 + 重放检测告警(FR-ACC-05);dev-token 兼容性扩展。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
# T5 · 图片直传预签名 URL
|
||||
|
||||
> 对应任务:`T-P1-27` | 需求:`FR-SYS-03`、架构 §9.3 | 分支:`task/t5-upload-presign` | worktree:`/home/xsl/meida_backend-t5`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /uploads/presign`:为客户端签发 COS 临时桶的 PUT 预签名 URL,**图片不经应用服务器**(FR-SYS-03 验收原文)。本任务只做「签发」,不做上传回调/校验图片内容(那是报告链路 T-P3 的事)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 依赖(协调提交已 `uv add`,禁改 pyproject/uv.lock)
|
||||
|
||||
`cos-python-sdk-v5`(腾讯云 COS 官方 SDK,模块名 `qcloud_cos`)。mypy 对它的缺失 stub 已在协调提交里配好 override。真实调用只需要 `CosS3Config` 与 `CosS3Client.get_presigned_url` / `get_presigned_download_url` 所在的 `CosS3Client`。
|
||||
|
||||
### 配置(`config.py` 已加字段,`.env.example` 已示例)
|
||||
|
||||
```python
|
||||
cos_region: str = "ap-shanghai" # env: COS_REGION
|
||||
cos_secret_id: str = "" # env: COS_SECRET_ID
|
||||
cos_secret_key: str = "" # env: COS_SECRET_KEY
|
||||
cos_bucket_temp: str = "meida-temp" # env: COS_BUCKET_TEMP(临时桶,2h 生命周期 CR-02)
|
||||
cos_bucket_user: str = "meida-user" # env: COS_BUCKET_USER
|
||||
cos_bucket_kb: str = "meida-kb" # env: COS_BUCKET_KB
|
||||
cos_bucket_report: str = "meida-report" # env: COS_BUCKET_REPORT
|
||||
cos_presign_expire_seconds: int = 600 # env: COS_PRESIGN_EXPIRE_SECONDS
|
||||
upload_max_bytes: int = 20_971_520 # env: UPLOAD_MAX_BYTES(20MB)
|
||||
upload_allowed_types: str = "image/jpeg,image/png,image/webp" # env: UPLOAD_ALLOWED_TYPES
|
||||
upload_scenes: dict[str, dict[str, Any]] = Field(..., alias="UPLOAD_SCENES_JSON")
|
||||
# 默认:{"report_photo": {"bucket": "temp"}, "avatar": {"bucket": "temp"}}
|
||||
```
|
||||
(字段名/别名以 config.py 协调提交的实际代码为准——**开工先读 config.py 核对**,文档若有出入以代码为准。)
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `UPLOAD_REJECTED = 40010`:400「不支持的图片类型或超出大小限制」,hint「请上传 20MB 以内的 JPG/PNG/WebP 图片」。
|
||||
|
||||
### 骨架(协调提交已建)
|
||||
|
||||
- `packages/storage/__init__.py`、`README.md`、`service.py`(空骨架);
|
||||
- `apps/api/routers/uploads.py`(空骨架 router,prefix `/uploads`,已注册 main.py);
|
||||
- `apps/api/deps.py` 已有 `StorageServiceDep`(**deps.py 禁改**;若构造参数不满足,在路由函数内用 `SettingsDep` 自建 service)。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages.storage` 在 import-linter"共享能力层"。只准 import `packages.shared`/`packages.domain` 与 `qcloud_cos`/stdlib。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/storage/**`、`apps/api/routers/uploads.py`、测试文件。
|
||||
|
||||
1. **`packages/storage/schemas.py`**:
|
||||
- `PresignRequest(scene: str, content_type: str, size_bytes: int)`(size ≥1);
|
||||
- `PresignResponse(upload_url: str, object_key: str, expires_in: int, max_bytes: int, content_type: str)`。
|
||||
2. **`packages/storage/service.py`**:
|
||||
- 定义 `Protocol CosSigner`:`def presign_put(bucket: str, key: str, expires: int, content_type: str) -> str`(**把 SDK 隔离在 Protocol 后面**,单测用 FakeSigner,绝不连网);
|
||||
- `QcloudCosSigner`:包 `CosS3Client`(构造参数 region/secret_id/secret_key),`presign_put` 调 `client.get_presigned_url(Method.PUT, bucket, key, Expire=expires, Headers={"Content-Type": content_type})`(`from qcloud_cos import CosConfig, CosS3Client`;`Method` 从 `qcloud_cos.method` import——以实际 SDK 签名为准,若 Headers 参数名不同按 SDK 文档调整,**以能签出带 content-type 约束的 PUT URL 为准**);
|
||||
- `StorageService(settings, signer: CosSigner | None = None)`:signer 为 None 时用 `QcloudCosSigner` 惰性构造(**只有真实请求才建 client**;测试必传 FakeSigner);
|
||||
- `def build_presign(user_id: int, request: PresignRequest) -> PresignResponse`:
|
||||
- scene 不在 `settings.upload_scenes` → `AppError(UPLOAD_REJECTED)`(message 写"不支持的上传场景");
|
||||
- content_type ∉ `settings.upload_allowed_types` 解析集合 → `UPLOAD_REJECTED`;
|
||||
- size_bytes > `upload_max_bytes` → `UPLOAD_REJECTED`;
|
||||
- 扩展名映射 `{image/jpeg: jpg, image/png: png, image/webp: webp}`(其它类型已被上一条拦下);
|
||||
- object_key = `f"{scene}/{user_id}/{datetime:%Y%m%d}/{uuid4().hex}.{ext}"`(**这个 key 布局写死**:按场景分目录、按用户隔离、按日分组、uuid 防猜——注释说明这是 COS 侧做前缀审计与生命周期的基础);
|
||||
- bucket = scenes 配置里的 `bucket`("temp"/"user"/"kb"/"report" → 对应 `settings.cos_bucket_*` 四字段映射);
|
||||
- 调 signer 得 URL,返回 `PresignResponse(expires_in=settings.cos_presign_expire_seconds, max_bytes=settings.upload_max_bytes, ...)`。
|
||||
3. **`apps/api/routers/uploads.py`**:
|
||||
- `POST /presign`,`user_id: CurrentUserId` + settings;`ok(PresignResponse(...))`;
|
||||
- 模块 docstring 写明:真实联调需在 `.env` 配 COS 四件套;本地单测不依赖任何真实 COS。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
单测 `tests/unit/test_upload_presign.py`(全部用 FakeSigner,断言 URL/key/参数,**零网络**):
|
||||
- 合法请求 → key 布局正确(scene 前缀、user_id 段、日期段、扩展名)、expires_in/max_bytes 回显;
|
||||
- 非法 content_type(如 `application/pdf`)→ 40010;
|
||||
- 超大小 → 40010;
|
||||
- 未知 scene → 40010;
|
||||
- bucket 映射:`{"bucket":"temp"}` → 使用 `cos_bucket_temp` 配置值(FakeSigner 捕获 bucket 参数断言);
|
||||
- `upload_allowed_types` 字符串解析(空串/带空格容错)。
|
||||
|
||||
不需要集成测试(无 DB 依赖)。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/billing/**`、`packages/identity/**`、`packages/analytics/**`、`apps/api/routers/{agent,auth,quota,tasks,events}.py`、`apps/api/deps.py`、`tests/integration/**`。
|
||||
|
||||
## 6. 验收标准(FR-SYS-03)
|
||||
|
||||
1. `POST /api/v1/uploads/presign`(含 `/v1` 别名)可用,认证必需;
|
||||
2. 类型/大小/场景三重服务端校验,错误码统一 40010;
|
||||
3. key 布局可审计(scene/user/日期/uuid);
|
||||
4. 单测全绿且无任何真实网络调用(FakeSigner 注入);
|
||||
5. 全部检查绿(mypy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(storage): COS presigned PUT URL endpoint for direct upload
|
||||
|
||||
POST /uploads/presign:场景/类型/大小三重校验 + 可审计 object_key 布局 +
|
||||
SDK 隔离在 CosSigner Protocol 后(FR-SYS-03,T-P1-27)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# T6 · 知识库·实体与图谱
|
||||
|
||||
> 对应任务:`T-P2-01`(12 类实体可存取)、`T-P2-05`(1~3 跳可解释路径)| 需求:`FR-KB-*`、架构 §6.2 kb 域、`TD-09` | 分支:`task/t6-kb-entity` | worktree:`/home/xsl/meida_backend-t6`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
知识库的实体底座:12 类实体统一表的 CRUD 与状态机、`kb_relation` 邻接表 + 递归 CTE 的 1~3 跳图谱查询(可解释路径)、`kb_color` 自建色板存取。这是知识库可运营(M2 出口)的第一块。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表(迁移 `0005` 已建,ORM 已加:`KbEntity`/`KbRelation`/`KbColor`,先读 `packages/shared/models.py` 核对列名)
|
||||
|
||||
- `kb_entity`:`entity_id` PK 雪花、`entity_type` varchar(32)、`name` varchar(128)、`attrs` JSONB、`status` draft/reviewing/published/archived、`version` int、`source`、`license`、`updated_by`、`created_at/updated_at`;UNIQUE(entity_type,name)、Index(entity_type,status)
|
||||
- `kb_relation`:`relation_id` PK、`from_id`/`to_id` bigint、`rel_type` varchar(64)、`weight` Numeric(4,3)、`confidence` Numeric(4,3)、`evidence` varchar(255);UNIQUE(from_id,rel_type,to_id)、Index(to_id)
|
||||
- `kb_color`:`color_id` PK、`season_code` varchar(16)、`role` base/accent/avoid、`lab_l/lab_a/lab_b` Numeric(6,2)、`hex` varchar(7)、`name_cn` varchar(64);Index(season_code)
|
||||
|
||||
### 12 类 entity_type(知识库文档 §3.1,写进 schemas 的 Literal 校验)
|
||||
|
||||
`item / color / style / body_shape / face_hair / occasion / material / accessory / makeup / rule / outfit / trend`
|
||||
|
||||
### 状态机(写进 service,非法跃迁抛 `AppError(STATE_CONFLICT)`)
|
||||
|
||||
`draft → reviewing → published → archived`;`archived` 终态;`draft/reviewing → archived` 允许(撤稿);其余跃迁非法。每次**内容修改** version+1 且**不允许直接改 published 实体**——published 实体要改必须先回 draft(新版本走评审,这是知识库文档"发布回滚"的语义)。
|
||||
|
||||
### 路由骨架
|
||||
|
||||
`apps/api/routers/kb.py` 空路由(prefix `/kb`)已注册 main.py——你填充。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/kb/**`(service.py / repo.py / schemas.py / README.md)、`apps/api/routers/kb.py`、测试文件。**`packages/kb` 现只有 README + 空 `__init__`;`packages/rules`、`packages/retrieval` 归 T7/T8,禁碰。**
|
||||
|
||||
1. **schemas.py**(Pydantic):`EntityCreate`(entity_type/name/attrs)、`EntityUpdate`(仅 draft/reviewing 可用)、`EntityOut`(含 status/version/时间戳)、`RelationCreate`、`GraphPath`(路径节点序列 + 每跳 rel_type)、`ColorOut`。**跨域响应模型放本包**,apps/api/schemas.py 禁改。
|
||||
2. **repo.py**:CRUD + `search_entities(entity_type, status, name_ilike, limit, offset)` + `traverse(from_id, max_hops)` —— 递归 CTE 用 `sqlalchemy.text()` 写原生 SQL(返回 hop 序列),1~3 跳,**防环**(CTE 里用 path 数组 + `NOT path @> ARRAY[to_id]` 判重)。参考架构 TD-09 的理由:浅层有界遍历,不需要图数据库。
|
||||
3. **service.py**:状态机校验、版本递增、relation 写入(自环/重复抛 `AppError(BAD_REQUEST)`)、色板 CRUD(role/hex 格式校验,`#RRGGBB`)。实体创建时 `entity_id=next_id()`(`packages.domain.ids`)。
|
||||
4. **路由**:`GET /kb/items`(分页 + entity_type/status 过滤)、`GET /kb/items/{id}`、`POST /kb/items`、`PATCH /kb/items/{id}`、`GET /kb/items/{id}/graph?max_hops=3`(1~3 跳,超界 422/400)。写接口**暂不挂鉴权以外的前置**(后台运营角色属 T-P2-09,先 CurrentUserId)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_kb_entity.py`:状态机全跃迁矩阵(合法/非法)、12 类 Literal 校验、非法 entity_type 拒绝、色板 hex 校验。(纯逻辑部分;repo/service 层用集成覆盖。)
|
||||
- 集成 `tests/integration/test_kb_entity.py`(新文件):建实体→reviewing→published;改 published 被拒;draft 改→version+1;建 relation→graph 2 跳返回可解释路径(含每跳 rel_type);自环拒绝;`GET /v1/kb/items` 过滤分页。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/rules/**`、`packages/retrieval/**`、`packages/identity/**`、`apps/api/routers/{auth,auth_sms}.py`、全部已有测试文件。
|
||||
|
||||
## 6. 验收标准(T-P2-01/05)
|
||||
|
||||
1. 12 类实体可存取,`attrs` JSONB 差异化承载;
|
||||
2. 状态机非法跃迁 100% 被拦(有测试矩阵证明);
|
||||
3. 图谱 1~3 跳返回可解释路径且防环;
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage packages/kb`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(kb): entity store with lifecycle, graph traversal and color palette
|
||||
|
||||
12 类实体统一表 + 状态机 + version 递增 + kb_relation 递归 CTE 1~3 跳
|
||||
可解释路径(防环)+ kb_color 色板(T-P2-01/05,TD-09/18)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# T7 · 知识库·规则引擎
|
||||
|
||||
> 对应任务:`T-P2-02`(规则求值 p95 < 50ms;硬约束可拦截)| 需求:`FR-KB-02`、架构 §8.2 rules-engine、`TD-10` | 分支:`task/t7-rules-engine` | worktree:`/home/xsl/meida_backend-t7`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
自研数据驱动决策表(TD-10):`kb_rule` 的 JSON DSL 解析 + 纯域求值引擎。**核心结论由规则引擎产出,LLM 只负责表达**(防幻觉红线)——这是整个产品"专业可信"的根基,代码质量按"钱与正确性"标准写。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表 `kb_rule`(ORM `KbRule` 已加)
|
||||
|
||||
`rule_id` PK 雪花、`rule_key` varchar(64) UNIQUE(如 `R-BODY-A-002`)、`category` varchar(32)、`version` int、`status` draft/published/archived、`priority` int、`conditions` JSONB、`effects` JSONB、`is_hard` bool、`confidence` Numeric(4,3)、`evidence` varchar(255)、`author`/`reviewer` varchar(64)、`published_at`;Index(status, priority)。
|
||||
(注:架构 §6.2 写的列名 `conclusions`,协调提交统一为 **`effects`**——与"决策表"术语一致,任务文档以 models.py 实际代码为准。)
|
||||
|
||||
### DSL 规格(知识库文档 §3.2⑩ 原文结构)
|
||||
|
||||
```json
|
||||
{
|
||||
"rule_key": "R-BODY-A-002",
|
||||
"category": "body_shape",
|
||||
"priority": 90,
|
||||
"conditions": {
|
||||
"body_shape": "A型",
|
||||
"features": { "include": ["溜肩"] },
|
||||
"goals": { "include": ["修饰溜肩"] }
|
||||
},
|
||||
"effects": {
|
||||
"recommend": [ { "slot": "top", "attrs": { "shoulder": ["垫肩","自然肩"], "sleeve": ["泡泡袖"] } } ],
|
||||
"avoid": [ { "slot": "top", "attrs": { "shoulder": ["落肩"], "silhouette": ["修身"] } } ],
|
||||
"explanation": "溜肩需要在肩部建立结构感……"
|
||||
},
|
||||
"is_hard": false,
|
||||
"confidence": 0.95
|
||||
}
|
||||
```
|
||||
|
||||
### 优先级分组(知识库文档 §3.2⑩,语义:**数字越大越优先**)
|
||||
|
||||
合规 100 / 用户禁忌 95 / 体型 90 / 色彩 85 / 场景 80 / 温度 75 / 风格 70 / 偏好 60 / 趋势 50。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages/rules` 已在 import-linter 第 4 层(kb|retrieval|rules|memory|profile),现只有 README + 空 `__init__`。只准 import `packages.shared`/`packages.domain`。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/rules/**`、测试文件。
|
||||
|
||||
1. **`packages/rules/dsl.py`**——纯逻辑(无 IO,重点单测):
|
||||
- `RuleCondition` / `RuleEffect` / `Rule` pydantic 模型(解析校验 DSL;`conditions` 支持的字段:等值匹配(str)、`include`/`exclude` 列表、`min`/`max` 数值区间;未识别的键**拒绝**而非忽略——静默忽略会让规则悄悄失效);
|
||||
- `match(condition: RuleCondition, ctx: RuleContext) -> bool`:`RuleContext` 是 frozen dataclass(`body_shape/season/face_shape/temperature/features/goals/occasion/style/user_taboos…`,缺字段 = 不匹配该子句);
|
||||
- 所有子句 AND 语义。
|
||||
2. **`packages/rules/engine.py`**——纯域求值:
|
||||
- `evaluate(rules, ctx) -> RuleVerdict`:返回 `recommend/avoid/explanation 汇总 + rule_hits`(每条含 rule_key/priority/category/is_hard/confidence/explanation);
|
||||
- 排序:priority 降序,同 priority 按 confidence 降序;
|
||||
- **硬约束语义**:`is_hard=true` 且 avoid 命中 → `RuleVerdict.hard_blocks` 非空,调用方必须用它做硬过滤(禁忌违反 = 0 是发布红线);**用户禁忌(priority ≥95 的 avoid)永远压过专业推荐**——同 slot 冲突时用户禁忌胜出并从 recommend 中剔除冲突项;
|
||||
- p95 < 50ms:求值是纯内存操作,规则量 400 条时远低于此;写一个 400 条规则 × 1000 次求值的基准单测(`time.perf_counter`,断言均值 < 5ms/千次的量级即可,CI 机器差异留裕量)。
|
||||
3. **`packages/rules/service.py` + `repo.py`**——DB 侧:按 status='published' 载入规则(内存缓存 + `invalidate()`;量级 400,直接整表载入),CRUD(draft→published 状态机,同 T6 语义),DSL 写入前用 pydantic 校验。
|
||||
4. **黄金集雏形**(架构 §1081 明确要求):`tests/unit/test_rules_golden.py` 固定 8~10 组「输入画像 → 期望 recommend/avoid/hard_blocks」样本,其中**必须包含**:一条硬禁忌场景(用户禁忌压过专业推荐的用例)、一条多规则叠加场景。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测:DSL 解析(合法/非法结构/未识别键拒绝)、match 矩阵(等值/include/exclude/min-max/缺字段)、排序、硬约束剔除、用户禁忌优先、基准测试、黄金集。
|
||||
- 集成 `tests/integration/test_rules_engine.py`(新文件):插 3 条规则(含 1 条 is_hard)→ service 载入 → 求值命中与拦截正确;draft 规则不被载入。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/kb/**`(表共享但包不共享——**repo 只碰 kb_rule 表**,通过 ORM 模型访问,不改 T6 的文件)、`packages/retrieval/**`、`packages/identity/**`。
|
||||
|
||||
## 6. 验收标准(T-P2-02)
|
||||
|
||||
1. 求值 p95 满足 400 条规则量级远低于 50ms(基准测试证明);
|
||||
2. 硬约束可拦截(hard_blocks 语义 + 黄金集用例);
|
||||
3. 用户禁忌永远压过专业推荐(黄金集用例);
|
||||
4. DSL 非法结构 100% 拒绝(不会静默失效);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表 + `packages/rules`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(rules): data-driven decision table engine with hard constraints
|
||||
|
||||
kb_rule DSL 解析 + 纯域求值(优先级分组/硬约束/用户禁忌压过专业推荐)
|
||||
+ 400 条基准 + 黄金集雏形(T-P2-02,TD-10)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# T8 · 知识库·向量与混合检索
|
||||
|
||||
> 对应任务:`T-P2-03`(向量化可重跑/模型可切换)、`T-P2-04`(检索、硬过滤零漏)、`T-P2-06`(无结果 query 记录)| 需求:`FR-KB-03/04`、架构 §8.3、`TD-08` | 分支:`task/t8-retrieval` | worktree:`/home/xsl/meida_backend-t8`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
pgvector 上的混合检索管线骨架:**硬过滤 → 向量召回 → 关键词召回 → RRF 融合重排**,加 `kb_chunk` 写入通道(向量 + tsvector 双索引)与 `kb_gap` 知识缺口埋点。检索是 Agent(M4-b)和报告(M3)的共同前置。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表(迁移 `0005` 已建,ORM `KbChunk`/`KbAsset`/`KbGap` 已加,先读 models.py 核对)
|
||||
|
||||
- `kb_chunk`:`chunk_id` PK、`entity_id`、`chunk_type` varchar(32)、`text` text、`embedding` **vector(1024)**、`embedding_model` varchar(64)、`embedding_version` varchar(16)、`tsv` tsvector(GENERATED ALWAYS AS to_tsvector('simple', text) STORED——**写入时不要手工填 tsv**);HNSW 索引(cosine)+ GIN(tsv)
|
||||
- `kb_asset`:`asset_id` PK、`entity_id`、`asset_type`、`cos_key` varchar(255)、`spec_check` JSONB、`tryon_ready` bool、`review_status`、`license`;Index(entity_id)、partial Index(tryon_ready)
|
||||
- `kb_gap`:`gap_id` PK、`query_text`、`filter_ctx` JSONB、`hit_count` int、`first_seen_at`;Index(query_text)
|
||||
|
||||
### 配置(config.py 已加,.env.example 已示例)
|
||||
|
||||
```python
|
||||
kb_vector_dimensions: int = 1024 # KB_VECTOR_DIMENSIONS
|
||||
kb_embedding_model: str = "deterministic-v1" # KB_EMBEDDING_MODEL(当前默认=确定性假实现)
|
||||
kb_embedding_device: str = "cpu" # KB_EMBEDDING_DEVICE
|
||||
retrieval_top_k: int = 50 # RETRIEVAL_TOP_K
|
||||
retrieval_rerank_weights: dict = {"vector":0.45,"keyword":0.2,"rule":0.25,"freshness":0.1} # RETRIEVAL_RERANK_WEIGHTS_JSON
|
||||
```
|
||||
⚠️ 上批经验:带 alias 的 Settings 字段在**测试里按字段名构造会被静默忽略**——测试用 `monkeypatch.setenv` + `get_settings.cache_clear()`。
|
||||
|
||||
### 重排公式(架构 §8.3 唯一口径)
|
||||
|
||||
`最终分 = 0.45×向量 + 0.2×关键词 + 0.25×规则权重 + 0.1×新鲜度`(权重可配,就是上面的 rerank_weights)。规则权重项本批的输入是 `rule_boost: dict[chunk_id, float]`(由调用方传入,规则引擎联动属后续任务,管线只认接口)。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages/retrieval` 在 import-linter 第 4 层(与 rules 同层)。只准 import `packages.shared`/`packages.domain` 与已装依赖(pgvector 的 sqlalchemy 类型、numpy)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/retrieval/**`、测试文件。**不建 HTTP 路由**(检索是内部能力,被 agent/report 的 service 调用)。
|
||||
|
||||
1. **`packages/retrieval/embedding.py`**:
|
||||
- `EmbeddingProvider` Protocol:`async def embed(texts: list[str]) -> list[list[float]]`、属性 `model_name/version/dimensions`;
|
||||
- `DeterministicEmbedder`:把文本 sha256 种子映射到**归一化**的 1024 维向量(同文本同向量、不同文本近似正交——用 seed 生成伪随机向量后 L2 归一化)。注释写明:这是测试/dev 的假实现,**真实 BGE-M3 ONNX 加载是后续任务**(配置里的 `kb_embedding_device` 是为它预留的);
|
||||
- `build_embedding_provider(settings)` 工厂:按 `kb_embedding_model` 选择实现(当前只认 `deterministic-v1`,其它值抛 `AppError(CONFIG_ERROR)`)。
|
||||
2. **`packages/retrieval/repo.py`**:
|
||||
- `upsert_chunks(session, entity_id, chunks, embedding)`:写 kb_chunk(embedding 用 `pgvector.sqlalchemy.Vector`;`embedding_model/version` 必填——**没有版本号的向量不可重跑**,这是 T-P2-03 的验收);
|
||||
- `hybrid_search(session, query_vec, query_text, *, entity_types, occasion, body_shape, rule_boost, top_k)`:按架构 §8.3 的 CTE 结构实现(可用 `sqlalchemy.text()` 原生 SQL + 绑定参数,或 SQLAlchemy core;`embedding <=> :query_vec` 余弦距离、`ts_rank`、RRF:`1/(60+rank_vec) + 1/(60+rank_kw)`,再叠加权重与新鲜度 `(now-updated_at)` 归一化衰减);
|
||||
- **硬过滤是第一公民**:`e.status='published'` + entity_types + occasion(`attrs->'occasions' ? :occasion`)+ 禁忌排除(`NOT (attrs->'avoid_body_shapes' ? :body_shape)`)——**硬过滤零漏**是 T-P2-04 红线,集成测试必须证明"禁忌实体即使向量最近也绝不出现";
|
||||
- `record_gap(session, query_text, filter_ctx)`:无结果时 upsert kb_gap(同 query_text + filter_ctx 则 `hit_count += 1`)。
|
||||
3. **`packages/retrieval/service.py`**:`RetrievalService.search(query_text, *, user_ctx) -> SearchOutcome`(编排 embed → hybrid_search →(结果为空时)record_gap);`SearchOutcome` 含 `results: list[ScoredChunk]`(chunk_id/entity_id/text/vec_score/kw_score/final_score)与 `degraded: bool`(无结果走了 gap 记录)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_embedding.py`:确定性(同文本同向量)、维度、L2 归一化、工厂的未知模型名拒绝。
|
||||
- 单测 `tests/unit/test_rerank.py`:RRF 融合 + 权重 + 新鲜度的数学正确性(构造已知分数断言最终序)。
|
||||
- 集成 `tests/integration/test_hybrid_retrieval.py`(新文件,用 DeterministicEmbedder,**不需要真实模型**):
|
||||
1. 建 3 个实体(1 个 published 带禁忌属性、1 个 draft、1 个 published 正常)+ 各挂 chunk → 检索命中正常实体、**draft 不出现、禁忌实体零出现**(T-P2-04 硬过滤零漏);
|
||||
2. 关键词命中路径(text 里放英文关键词,tsv 'simple' 分词);
|
||||
3. 无结果 query → kb_gap 出现一行、再查同 query → hit_count=2(T-P2-06);
|
||||
4. `tryon_ready=true` 的 kb_asset 可按 entity 查询(顺手验证 partial 索引可用)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/kb/**`、`packages/rules/**`、`packages/identity/**`、所有路由文件。
|
||||
|
||||
## 6. 验收标准(T-P2-03/04/06)
|
||||
|
||||
1. 向量写入带 `embedding_model/version`,换模型名即视为不同版本(可重跑判据);
|
||||
2. 硬过滤零漏(集成断言禁忌/draft 实体绝不出现在结果);
|
||||
3. RRF + 权重重排数学正确(单测);
|
||||
4. 无结果 query 全量落 kb_gap 且可聚合排序(hit_count);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表 + `packages/retrieval`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(retrieval): hybrid search over pgvector with hard filters and gap tracking
|
||||
|
||||
DeterministicEmbedder(可换 BGE-M3 的接口位)+ kb_chunk 双索引写入
|
||||
+ 硬过滤→向量→关键词→RRF 重排管线 + kb_gap 缺口埋点(T-P2-03/04/06,TD-08)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# T9 · 算法验证 SPIKE·RTMPose 体型原型 + BiRefNet 抠图
|
||||
|
||||
> 对应任务:`T-P0-16`(RTMPose ONNX 单张耗时)、`T-P0-12`(BiRefNet 抠图质检 ≥90%)、`T-P0-10` 前置(体型测算原型)| 分支:`task/t9-vision-spike` | worktree:`/home/xsl/meida_backend-t9`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。**本任务是 SPIKE:产出是脚本 + 基准报告 + 结论,不是生产代码。**
|
||||
|
||||
## 1. 目标
|
||||
|
||||
用本地可跑的最小代价回答三个 Sprint 1 硬门槛的**可行性问题**,结论写进报告(不达标要换方案,不要硬做):
|
||||
|
||||
1. **RTMPose ONNX 在 CPU 上的单张推理耗时**(T-P0-16 验收:可接受即通过;开发机另有 RTX 5090,若 WSL 能用 GPU 也测一档,不行就只报 CPU——先探测 `nvidia-smi` 与 `onnxruntime` 的 CUDA 可用性,探测失败不折腾);
|
||||
2. **BiRefNet ONNX 抠图的质量代理指标**(T-P0-12 的 ≥90% 质检通过率需要真实素材与人工标注,本 SPIKE 只建管线 + 代理指标 + 少量样图的主观核验,正式验收留给素材流水线);
|
||||
3. **体型测算「掩膜宽度剖面法 + 关键点定高」原型**(TD-06):在合成人形图上跑通"关键点 → 定高 → 剖面宽度 → 肩/腰/臀比例 → H/A/V/O/X 初判",证明管线成立;**一致率 ≥80% 的正式验证需要人工标注数据(顾问轨),本任务不承诺**。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
- **依赖**:`spike` extra 已加入 pyproject(`onnxruntime`、`opencv-python-headless`、`numpy`),你的 worktree venv 已 `uv sync --extra spike` 装好。**GPU 版 onnxruntime 不装**(CUDA 匹配成本高,CPU 基准已满足 T-P0-16 验收口径)。
|
||||
- **目录**:`scripts/spikes/`(新建,归你)+ 模型缓存目录 `models/`(已在 `.gitignore`,**模型文件绝不入库**)。
|
||||
- **样图**:用脚本合成(OpenCV 画人形剪影/几何人体,见 §3),不使用任何真实人脸/人体数据(合规红线:测试不碰真实个人数据)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`scripts/spikes/**`、测试(可选,本任务以报告为验收)。其余全禁改。
|
||||
|
||||
### 3.1 模型获取(本批唯一允许联网的环节)
|
||||
|
||||
- 优先 `HF_ENDPOINT=https://hf-mirror.com`(国内镜像)用 `huggingface_hub.snapshot_download` / `hf_hub_download` 下载 ONNX:
|
||||
- RTMPose:`PINTO0309/PINTO_model_zoo` 里的 rtmpose(搜 `rtmpose` + onnx,选 **body 26/17 keypoint 的小模型**)或 `dhkim0115/rtmpose-onnx` 等社区导出;取不到就换 OpenMMLab 官方导出说明,**再不行降级:用 `opencv` 自带的 HOGDetector 作关键点占位并在报告声明**;
|
||||
- BiRefNet:`zhengpeng7/birefnet` 的 ONNX 导出(或 `onnx-community/BiRefNet`);文件大(~1GB)下载慢的话选 **lite/general-lite** 变体,报告注明用的哪个;
|
||||
- 下载脚本 `scripts/spikes/download_models.py`:带本地存在性检查 + 进度打印 + 失败重试一次;模型路径默认 `models/<name>.onnx`,支持 CLI 参数覆盖;
|
||||
- **超时纪律**:单个模型下载超过 ~10 分钟即放弃并走降级路线,把时间花在报告上。
|
||||
|
||||
### 3.2 `scripts/spikes/rtmpose_benchmark.py`
|
||||
|
||||
- 加载 ONNX → 预处理(resize/归一化,按模型 meta)→ 推理 → 解码关键点(17 点 COCO 或 26 点,按模型);
|
||||
- 输入:合成图 `scripts/spikes/assets/synthetic_person.png`(用 OpenCV 画:头圆 + 躯干梯形 + 四肢线段,肩/腰/臀位置可控——这也是 3.4 的输入);
|
||||
- 耗时基准:预热 3 次 + 正式 20 次,报 **均值 / P95 / 单次最大**(`time.perf_counter`);
|
||||
- 输出关键点坐标 JSON + 控制台表格。
|
||||
|
||||
### 3.3 `scripts/spikes/birefnet_matting.py`
|
||||
|
||||
- 加载 ONNX → 推理 → 后处理(sigmoid + resize 回原尺寸 + 二值化阈值 0.5)→ 保存掩膜图与抠图预览到 `scripts/spikes/out/`(out/ 也 gitignore,报告里只留指标);
|
||||
- 代理质检指标(T-P2-07 质检的雏形口径,写清楚这是代理不是正式验收):
|
||||
- 前景像素占比(应在 15%~60% 合理区间);
|
||||
- 最大连通域占前景比(>0.9 视为"主体单一完整");
|
||||
- 边缘羽化度(掩膜梯度带宽度均值);
|
||||
- 抠图耗时(同 3.2 口径);
|
||||
- 输入:合成人形图(与 3.2 同一张)+ 一张带渐变背景的变体。
|
||||
|
||||
### 3.4 `scripts/spikes/body_measure_prototype.py`(TD-06 原型)
|
||||
|
||||
- 用 3.2 的关键点(或合成图直接已知的真值框)定肩线/腰线/臀线高度 → 在 3.3 的掩膜上取对应高度的**宽度剖面** → 肩宽/腰宽/臀宽比值;
|
||||
- 规则初判:肩/臀比 >1.05 → V 型;<0.95 → A 型;肩≈腰≈臀 → H;腰远小于肩臀 → X;中段最大 → O(阈值写成常量并注明"待顾问校准");
|
||||
- 对合成图断言:**已知真值形状的分类正确**(合成图生成时就知道自己画的是 A 型还是 H 型——这就是最小可行的一致率验证)。
|
||||
|
||||
### 3.5 `scripts/spikes/REPORT.md`(**核心交付物**)
|
||||
|
||||
结构:结论先行(三问三答,每问给"通过 / 有条件通过 / 不达标需换方案")→ 各基准数字表 → 用的模型与版本 → 降级与偏差 → 对 Sprint 1 正式验证的建议(需要什么数据、多少标注量)。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. `download_models.py` + 三个 spike 脚本可重复执行(模型已缓存时离线可跑);
|
||||
2. RTMPose CPU 耗时基准有数字(或降级路径有声明);
|
||||
3. 体型原型在合成图上分类正确;
|
||||
4. `REPORT.md` 三问三答齐全,结论可决策;
|
||||
5. `ruff check` / `ruff format --check` 对 `scripts/spikes/**` 全绿;无 pytest 强制要求(脚本里的断言跑一遍即可,在报告里贴输出)。
|
||||
|
||||
## 5. 提交信息
|
||||
|
||||
```
|
||||
spike(vision): rtmpose latency, birefnet matting proxies, body-shape prototype
|
||||
|
||||
T-P0-16/12/10 可行性验证:CPU 基准 + 代理质检指标 + 合成图体型剖面法
|
||||
原型 + REPORT.md 三问三答(Sprint 1 门槛的决策依据)。
|
||||
|
||||
偏差说明:<模型是否齐全、走了什么降级、写在这里>
|
||||
```
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# 「美搭」开发任务拆解(WBS)
|
||||
|
||||
> 版本:**v1.3** | 更新日期:2026-08-13 | 状态:待评审
|
||||
> 版本:**v1.4** | 更新日期:2026-08-14 | 状态:待评审
|
||||
>
|
||||
> **本文的定位**:把需求与架构翻译成可指派、可验收的任务清单,并明确任务之间的依赖与关键路径。
|
||||
>
|
||||
@@ -187,9 +187,9 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| T-P1-24 | DEV | **LLM 网关**(路由/降级/熔断/结构化输出/Prompt 版本/成本记录) | 模块 | 主备切换 < 3s;成本 100% 记录 | FR-SYS-04 |
|
||||
| **T-P1-24a** | DEV | **思考模式按场景显式配置** | 场景配置 + provider 参数 | 每个场景**显式**给出 `thinking` 值,不依赖供应商默认;对话与结构化抽取场景一律关闭;单独记录 `tokens_reasoning` | `NFR-PERF-02b`、`NFR-COST-01`、TD-04 |
|
||||
| T-P1-25 | DEV | 成本归因表与日成本预警 | 表 + 告警 | 可按功能/模型/用户聚合;突增告警生效。**不允许后置到上线后补**(V2 定价的唯一数据来源) | FR-BILL-08、NFR-COST-01、FR-SYS-07 |
|
||||
| T-P1-25b | DEV | **全局日成本熔断**(四档自动降级) | 模块 + 后台开关 | 注入成本可复现 60/80/90/100% 四档降级;**在途任务放行**;**被熔断拒绝的请求配额净扣减 = 0**;后台可查状态并手动解除 | NFR-COST-02、架构 §13.3 |
|
||||
| T-P1-25b | DEV | **全局日成本熔断**(四档自动降级) | 模块 + 后台开关 | 注入成本可复现 60/80/90/100% 四档降级;**在途任务放行**;**被熔断拒绝的请求配额净扣减 = 0**(须有集成断言覆盖「reserve 之后才触发熔断」这条路径,不能只测「熔断在 reserve 之前」);后台可查状态并手动解除;**日预算阈值须按 8-17 峰谷新价 + 实测 token 分布重算**,`.env` 里的 `100000` 分是占位不是验收值 | NFR-COST-02、架构 §13.3 |
|
||||
| **T-P1-25c** | DEV | **峰谷单价按调用时刻计价** | 两套价目表 + 时段判定 | 高峰(北京时间 09:00-12:00、14:00-18:00)与空闲各一套单价;成本在**调用当时**算好落库,事后不按当前价重算;跨时段有测试 | `NFR-COST-01`、TD-04 |
|
||||
| T-P1-26 | DEV | **配额账本**(余额、流水、预扣三段式、周期重置、规则配置化) | 模块 + 接口 | 余额可由流水重算;失败自动退还;**表结构含 `tx_type` 字段,V2 上支付无需迁移**;服务端硬校验不可绕过 | FR-BILL-01/02/04/05/06 |
|
||||
| T-P1-26 | DEV | **配额账本**(余额、流水、预扣三段式、周期重置、规则配置化) | 模块 + 接口 | 余额可由流水重算;失败自动退还;**表结构含 `tx_type` 字段,V2 上支付无需迁移**;服务端硬校验不可绕过;**`bl_quota_rule` 种子必须与产品方案 §3.2 一致**(含 `hairstyle` / `hd_export` / `report_basic` / `report_regen`),禁止开发期另写一套数字 | FR-BILL-01/02/04/05/06 |
|
||||
| T-P1-26b | DEV | 额度耗尽文案与提示 | 端侧组件 | **不出现任何价格、购买、会员、升级字样**;消耗前提示剩余量 | FR-BILL-07 |
|
||||
| T-P1-27 | DEV | 图片直传(预签名 URL)与临时桶生命周期 | 接口 + 桶策略 | 图片不经应用服务器;2 小时自动清理 | FR-SYS-03、CR-02 |
|
||||
| T-P1-28 | DEV | 推送接入(Push Kit + Token 管理) | 模块 | 到达率 ≥ 90%;文案无敏感结论 | FR-TASK-04 |
|
||||
@@ -429,6 +429,8 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-14 | v1.5 | **两批多 agent 并行开发交付回写**(任务包见 `开发任务包/`,实测详情见《Sprint 执行计划》§9):`T-P1-25b` 四档熔断(阈值待 8-17 重算)、`T-P1-26` 收尾 claim、`T-P1-10/13/27` 交付、`T-P1-12/12b/12c` 登录双通道代码先行(联调待凭证)、`T-P2-01~06` 知识库三件套交付(BGE-M3 真模型待接)、`T-P0-16` ✅(2.8ms)、`T-P0-10/12` SPIKE 初步结论。**新风险登记:RTMPose 合成图置信度 0.003(域差异),真实照片重验为 `T-P0-10` 前置;BiRefNet CPU 3.1s/张需异步化** |
|
||||
| 2026-08-14 | v1.4 | 交叉评审回写:`T-P1-25b` 验收补「reserve 之后熔断」集成断言与日预算按新价重算;`T-P1-26` 明确种子必须与产品方案 §3.2 一致 |
|
||||
| 2026-08-13 | v1.3 | **后端脚手架落地后的回写**。新增两项由真机实测暴露、原 WBS 完全未覆盖的任务:**`T-P1-24a` 思考模式按场景显式配置** —— `deepseek-v4-flash` **默认开启**思考模式,实测同一单句问答开启 13.5s、关闭 1.8s,思考 token 还按输出价计费,漏配会同时打破 `NFR-PERF-02b` 与成本模型;**`T-P1-25c` 峰谷单价按调用时刻计价** —— DeepSeek 自 2026-08-17 起高峰单价为空闲时段 2 倍,用单一均价记账会让 `ob_ai_call` 与真实账单差出近一倍,而它是 V2 定价的唯一依据。另:`T-P1-01`/`02`/`06`/`07`/`24`/`25` 已在 `meida_backend` 仓库交付并实测通过,各任务的完成状态见《Sprint 执行计划》§1.1 |
|
||||
| 2026-08-13 | v1.2 | **文档一致性评审修订,补齐 9 项"需求已定但无任务认领"的缺口**。新增任务:`T-P1-12b` 短信验证码登录、`T-P1-12c` 短信防刷、`T-P1-16b` 清空我的照片、`T-P1-25b` **全局日成本熔断**(四档降级,验收含「配额净扣减 = 0」)、`T-P3-21` **报告结论认同/修正入口**、`T-P3-22` **全面报告预约留资**、`T-P5-17` 会员页留资、`T-P5-18` CLS 日志留存 ≥6 个月(`CR-11` 此前无人认领)、`T-P5-19` 熔断四档演练。其中 `T-P3-21`/`T-P3-22` 尤其关键 —— 前者是「结论认同率 > 70%、修正率 < 15%」的唯一数据来源,后者是 V1 唯一的商业化验证数据来源,**缺了会让 V1 白跑一遍**。另:**依赖图中的 M4 拆为 `M4-a` 换装/发型(可与 M3 并行)与 `M4-b` Agent 对话(须等 M2+M3)**,原图两处都标 M4 易误读为同一批工作;`T-P3-17` 注入预算 500→1400、`T-P4-03` 总输入 3700→4200;`T-P4-14` 明确输入校验须在预扣配额之前 |
|
||||
| 2026-08-13 | v1.1 | **关键路径转移**。备案已完成,原 `T-P0-01`~`T-P0-08` 申报类任务关闭,替换为备案后的落地与核实任务(模型清单核实、编号公示位实现、公示与网关路由同源校验)。§2.3 内容与素材生产升为新的关键路径,`T-P0-20` 顾问到岗与 `T-P0-21` 自采流水线成为全项目最优先任务,并要求输出**每周产能数据**用于倒排。配额相关任务去除支付语义(`T-P1-26` 强调 `tx_type` 字段使 V2 免迁移,新增 `T-P1-26b` 文案约束、`T-P5-15` 对账、`T-P5-16` 价格字样扫描)。诊断维度改 32。Phase 6 新增企业开发者认证、一键登录、**小范围真实收费灰度**,并明确 `T-P6-16` 定价的四项前置输入 |
|
||||
|
||||
+24
-7
@@ -1,6 +1,6 @@
|
||||
# 「美搭」技术架构文档
|
||||
|
||||
> 版本:**v1.3** | 更新日期:2026-08-13 | 状态:待技术评审
|
||||
> 版本:**v1.4** | 更新日期:2026-08-14 | 状态:待技术评审
|
||||
>
|
||||
> **本文的定位**:把产品需求翻译成可直接拆解为开发任务的技术方案。读完这份文档,应该能回答三个问题——系统由哪些部分组成、数据长什么样、每条关键链路怎么跑通。
|
||||
>
|
||||
@@ -185,6 +185,8 @@
|
||||
| `notify` | 推送、应用内消息、Token 管理 | `push()` `notify_task_done()` | — |
|
||||
| `wardrobe` | 我的衣橱(V2) | — | — |
|
||||
|
||||
> **域与包不是一一对应。** 上表按产品职责切(`profile` 含记忆);§16.1 的 `packages/` 按发布与依赖切。记忆有独立表、独立生命周期,且会被 agent / report 同时写入,因此代码上是独立的 `packages/memory`,产品职责仍归 profile 域。跨域只许 import `memory` 的 `service`,不许直接读 `pf_memory`。
|
||||
|
||||
### 4.2 依赖规则
|
||||
|
||||
```
|
||||
@@ -344,11 +346,24 @@ kb ← agent / report(读取)
|
||||
|
||||
| 表 | 关键字段 | V1 | 说明 |
|
||||
| --- | --- | :--: | --- |
|
||||
| `bl_quota` | `user_id`, `quota_key`, `period_key`, `used`, **`bonus_granted`**, `updated_at`;UNIQUE(`user_id`,`quota_key`,`period_key`) | ✅ | **V1 扣减判定的唯一依据**。周期配额计量(日/月/终身),按 `period_key` 自然重置,**不要写定时清零任务**。`bonus_granted` 物化本周期已获任务奖励,避免每次判定都聚合流水 |
|
||||
| `bl_quota_rule` | `quota_key`, `period_type`, `base_limit`, `task_bonus_max`, `hard_cap` | ✅ | 配额规则配置化(`FR-BILL-04`),改配置不发版。数值来源:产品方案 §3.2 配额表(唯一事实源) |
|
||||
| `bl_quota` | `user_id`, `quota_key`, `period_key`, `used`, **`limit_value`**, **`bonus_granted`**, `updated_at`;UNIQUE(`user_id`,`quota_key`,`period_key`) | ✅ | **V1 扣减判定的唯一依据**。周期配额计量(日/月/终身),按 `period_key` 自然重置,**不要写定时清零任务**。`limit_value` 是本周期创建时从规则拷下的 `base_limit` 快照(规则中途被改,本周期仍按快照判定);`bonus_granted` 物化本周期已获任务奖励,避免每次判定都聚合流水 |
|
||||
| `bl_quota_rule` | `quota_key`, `period_type`, `base_limit`, `task_bonus_max`, `hard_cap` | ✅ | 配额规则配置化(`FR-BILL-04`),改配置不发版。数值与 `quota_key` 映射见下表,**一律引用产品方案 §3.2,禁止在种子里另写一套** |
|
||||
| `bl_ledger` | `tx_id`, `user_id`, `delta`, `tx_type`, `status`, `reason`, `ref_type`, `ref_id`, `balance_after`, `idempotency_key` UNIQUE, `created_at` | ✅ | **流水是真相**,但**不参与 V1 的判定**(判定看 `bl_quota`)。`tx_type` V1 用 `system_grant`/`consume`/`refund` **三种**,V2 增加 `purchase`/`gift` —— 这是 V1→V2 免迁移的关键 |
|
||||
| `bl_wallet` | `user_id` PK, `balance`, `version`(乐观锁) | ✅ | ⚠️ **V1 仅用于「跨周期不清零」的额度**(如首次终身报告);周期类额度一律走 `bl_quota`。V2 语义扩展为点数余额,**同一张表,不改结构** |
|
||||
|
||||
**`bl_quota_rule.quota_key` 与产品方案 §3.2 的对应**(种子必须与此表一致):
|
||||
|
||||
| `quota_key` | 对应 §3.2 行 | `period_type` | `base` / `task_bonus_max` / `hard_cap` |
|
||||
| --- | --- | --- | --- |
|
||||
| `agent_chat` | AI 对话 | day | 10 / 10 / 40 |
|
||||
| `tryon` | 换装试穿 | day | 3 / 3 / 8 |
|
||||
| `hairstyle` | 发型 / 发色试戴 | day | 3 / 2 / 6 |
|
||||
| `hd_export` | 高清导出 | day | 1 / 0 / 3 |
|
||||
| `report_basic` | 初级美学报告(首次) | lifetime | 1 / 0 / 1 |
|
||||
| `report_regen` | 报告重新生成 | month | 1 / 1 / 2 |
|
||||
|
||||
「每日 ≤ 2」在月额度上限为 2 时自然满足,不必再拆一条日配额。**记忆条数 200 是存储上限,不是消耗型配额,不进本表。** 游客额度按设备维度计量,依赖 `id_guest`;游客合并链路未建之前**不写入 `guest_*` key**,避免种子里出现没人读的第二套口径。
|
||||
|
||||
> 🔑 **四张表的职责边界见 §7.4 开头**。这是最容易实现成"两套并行扣减逻辑"的地方,务必先读那一段。
|
||||
| `bl_membership` | `user_id`, `level`, `valid_from`, `valid_to`, `source_order_id`, `auto_renew` | ❌ V2 | 会员 |
|
||||
| `bl_order` | `order_id`, `user_id`, `product_id`, `product_type`, `price_cents`, `platform`('huawei_iap'), `purchase_token` UNIQUE, `status`, `verified_at`, `raw_receipt` JSONB | ❌ V2 | `purchase_token` 唯一约束防重复发货(`TD-16`) |
|
||||
@@ -368,7 +383,7 @@ kb ← agent / report(读取)
|
||||
|
||||
| 表 | 关键字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ob_ai_call` | `call_id`, `user_id`, `scene`, `provider`, `model`, `prompt_id`, `prompt_version`, `tokens_in/out/cached`, `tokens_reasoning`, `latency_ms`, `cost_cents`, `rate_tier`, `success`, `error_code`, `ref_type`, `ref_id`, `created_at` | **成本归因的唯一数据源**,按月分区。`prompt_id`/`prompt_version` 使每次调用可复现(§8.1 要求 Prompt 版本化,缺这两列就无法回答「上周的报告是哪版 Prompt 生成的」);`tokens_reasoning` 单独记思考模式 token(按输出价计费,混进 `tokens_out` 会掩盖开关思考模式的成本差异);`rate_tier` 记录该次调用适用的峰谷档位,否则事后无法复核 `cost_cents` |
|
||||
| `ob_ai_call` | `call_id`, `user_id`, `scene`, `provider`, `model`, `prompt_id`, `prompt_version`, `tokens_in/out/cached`, `tokens_reasoning`, `latency_ms`, `cost_cents`, `rate_tier`, `success`, `error_code`, `ref_type`, `ref_id`, `created_at` | **成本归因的唯一数据源**,按月分区。`prompt_id`/`prompt_version` 使每次调用可复现(§8.1 要求 Prompt 版本化,缺这两列就无法回答「上周的报告是哪版 Prompt 生成的」);`tokens_reasoning` 单独记思考模式 token(按输出价计费,混进 `tokens_out` 会掩盖开关思考模式的成本差异);`rate_tier` 记录该次调用适用的峰谷档位,否则事后无法复核 `cost_cents`。**保留口径**:明细至少保留到 V2 定价完成(`T-P6-16`);满 12 个月的分区可迁冷存储,**不可按常规日志周期删除** —— 删了就失去定价依据 |
|
||||
| `ob_event` | `event_id`, `user_id`, `event_name`, `props` JSONB, `client_ts`, `server_ts` | 埋点事件,按月分区(`TD-23`) |
|
||||
|
||||
### 6.3 诊断维度的存储形态
|
||||
@@ -996,7 +1011,7 @@ get_memory_snapshot(user_id, budget_tokens)
|
||||
### 16.1 仓库结构
|
||||
|
||||
```
|
||||
meida/
|
||||
meida_backend/ # 后端仓库。文档在独立仓库 meida,鸿蒙端尚未建库
|
||||
├─ apps/
|
||||
│ ├─ api/ # FastAPI 应用(路由、依赖注入、中间件)
|
||||
│ ├─ worker/ # Celery 任务定义
|
||||
@@ -1031,10 +1046,11 @@ meida/
|
||||
│ ├─ integration/ # 带 DB/Redis(testcontainers)
|
||||
│ └─ golden/ # 黄金集回归(检索/规则/报告)
|
||||
├─ deploy/ # Dockerfile / compose / CI 配置
|
||||
├─ docs/ # 本文档集
|
||||
└─ client-harmony/ # 鸿蒙 ArkTS 工程
|
||||
└─ scripts/
|
||||
```
|
||||
|
||||
文档集在 `meida` 仓库(`docs/`),不进本仓库。鸿蒙客户端独立建库,不预置空目录。
|
||||
|
||||
**包内约定**:每个业务域包统一为 `service.py`(用例编排,对外唯一入口)+ `repo.py`(该域自己的表访问)
|
||||
+ 可选 `schemas.py`。§4.2 的「禁止直接查对方的表」因此落为一条可机检规则:
|
||||
**跨域只允许 import 对方的 `service`,不允许 import 对方的 `repo`。**
|
||||
@@ -1149,6 +1165,7 @@ meida/
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-14 | v1.4 | **文档 ↔ 代码交叉评审回写**。① §16.1 根目录 `meida/` → `meida_backend`,并去掉本仓库并不存在的 `docs/`、`client-harmony/`。② §4.1 补「域与包不是一一对应」:产品职责上记忆归 profile,代码上独立 `packages/memory`。③ `bl_quota` 列清单补 `limit_value`(本周期 base_limit 快照),并给出 `quota_key` 与产品方案 §3.2 的一一映射;游客 key 与记忆条数明确不进种子。④ `ob_ai_call` 补保留口径(定价完成前不可按日志周期删除) |
|
||||
| 2026-08-13 | v1.3 | **脚手架落地后按实现回写,五处修正**。① **§16.1 仓库结构补全 11 个业务域包** —— 原树只列了 7 个 `packages/`,§4.1 定义的 11 个业务域在树里无处安放,实现时只能猜;现补全并加「包内约定」(每域 `service.py` + `repo.py`,跨域只许 import 对方 `service`)与三层判别标准表。② **§6.2 `ob_ai_call` 补 `prompt_id`/`prompt_version`/`tokens_reasoning`/`rate_tier` 四列** —— §8.1 要求 Prompt 版本化可复现,但表里没有版本列,等于要求做不到的事;思考 token 混进 `tokens_out` 会掩盖开关思考模式的成本差异。③ **§9.1 澄清基础路径** —— 正文写 `/api/v1`、任务描述写 `/v1`,实现两者都要能用;现明确 `/api/v1` 为规范写法、`/v1/*` 仅作兼容别名。④ **§13.2 新增峰谷计价约束** —— DeepSeek 自 8-17 起高峰单价翻倍且新价目输出侧涨 2.25~4.5 倍,成本须在调用当时算好落库(不可事后回算),§13.1 的预算与熔断阈值需按新价重算;Agent 单轮成本注明思考模式须显式关闭。⑤ **§13.3 补「关键场景显式豁免」** —— 意图识别这类调用失败会让链路整体不可用,须标记 `critical` 不受熔断拒绝,但仍计入成本;豁免清单必须极短,否则熔断被绕空 |
|
||||
| 2026-08-13 | v1.2 | **文档一致性评审修订,其中三处是会直接卡住开发的实质缺陷**。① **§7.4 补齐 `billing` 域四张表的职责边界** —— `bl_quota` 与 `bl_wallet` 此前看起来像两套重复机制,`task_bonus` 的来源也没定义;现明确「V1 判定看 `bl_quota`、V2 判定看 `bl_wallet`、`bl_ledger` 两版都只是流水」,并把 `task_bonus` 物化为 `bl_quota.bonus_granted` 字段(避免每次判定聚合流水),补充三处最易写错的地方(熔断须在预扣之前、`used` 增减须与流水同事务、周期重置靠 `period_key` 而非定时清零)。② **§13 成本模型重写** —— 推翻「月度成本上限 = MAU × 单用户配额上限」这个错误论证(配额约束单用户,总成本 = MAU × 人均,MAU 无上限),改为「理论最坏 / 预期成本 / 真实封顶」三口径,并新增 **§13.3 四档熔断降级**(含在途任务放行、被熔断请求不得扣配额、可人工解除)。③ **§7.2 token 预算改为嵌套表**,合计 3700→**4200**(原表漏算会话近轮原文 400),并明确硬禁忌永不裁剪。另:`pf_memory.status`/`source`、`id_consent.consent_type` 枚举与用户系统对齐(`personalization` 不可省略,否则记忆写入无合规依据);手机号存储位置明确为 `id_user_identity`;诊断维度 34→**32**;`points_reserved`→**`quota_reserved`**;外部依赖表补入腾讯云短信并说明登录双通道不得互相依赖;§12.2 说明 1 万 DAU 基线与 MAU 15 万目标的口径关系 |
|
||||
| 2026-08-13 | v1.1 | 按业务决策收口调整。**billing 域降级为「配额与计量」**:V1 移除支付、订单、会员、IAP;`bl_point_tx` 改名 `bl_ledger` 并新增 `tx_type` 字段,使 V1 配额账本与 V2 点数账本共用同一张表(V2 免数据迁移);新增 `bl_quota_rule` 配额规则表。§7.4 拆为「V1 配额三段式」与「V2 IAP 支付」。§13 成本模型补充「V1 成本上限可事前算准」的公式,并强调 `ob_ai_call` 是 V2 定价的唯一数据来源、不允许后补。§19 风险登记:备案风险关闭,新增备案范围一致性、素材自采产能、顾问到岗三项风险 |
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# 「美搭」文档体系评审报告
|
||||
|
||||
> 评审日期:2026-08-13 | 评审范围:`docs/` 目录全部 11 份文档(README + 10 份内容文档)
|
||||
> 评审方法:逐份通读 + 跨文档事实交叉比对(数字、枚举、编号、状态)+ 按《开发任务拆解》§9.3 的要求做「需求 → 任务」反查
|
||||
> 本文性质:评审反馈,不修改任何被评文档;问题定位到文档与章节,供修订时对照
|
||||
|
||||
---
|
||||
|
||||
## 0. 总体结论
|
||||
|
||||
**这套文档体系的骨架是好的,可以进入评审流程;但有一批"数字口径"类的事实性冲突需要先修掉,否则评审会上会卡在事实核对而不是方案讨论上。**
|
||||
|
||||
值得肯定的地方(这些做得比多数项目文档好,应保持):
|
||||
|
||||
- **四层结构与编号体系清晰**:FR/NFR/CR/TD/ADR/T/R 七套编号 + 「同一事实只在一处定义、其余引用编号」的纪律(README §4、§6),是这套文档能长期维护的前提。
|
||||
- **决策留痕完整**:每份文档有变更记录,技术选型报告 §8 有「与既有文档的冲突修正清单」,产品方案 §11 有已决策/待输入清单。本轮"备案完成、V1 不收费、素材自采"三个大转向在各文档间基本同步到位。
|
||||
- **验收标准可判定**:需求规格 §1.3 专门给出了"不可验收 vs 可验收"的对照写法,DoD(§1.4)把合规出口、配额三段式、埋点纳入完成定义。
|
||||
- **风险是真实践过的**:腾讯云人体关键点下线、RMBG-2.0 非商用许可、AI 标识元数据被 CDN 剥离,都是调研中实际撞出来的坑,文档如实记录并改了方案。
|
||||
- **对代理指标的诚实**:明确写出"预约 ≠ 付费"、V1 不收费意味着付费意愿无法被证伪(产品方案 §7.2.3、TD-28),没有自欺欺人。
|
||||
|
||||
但交叉比对发现:**v6.0/v1.1/v1.2 这轮大改版在"定性结论"上同步得很好,在"定量数字"上留下了不少残留矛盾**。下面按严重程度分三级列出。
|
||||
|
||||
---
|
||||
|
||||
## 1. P0:事实性冲突与错误(评审前必须修正)
|
||||
|
||||
这类问题是"两个文档对同一事实给了不同答案",不修正则下游(开发、测试、财务)必有一方做错。
|
||||
|
||||
### P0-1 V1 成本封顶的数字算不平(最重要的一条)
|
||||
|
||||
**位置**:产品方案 §5.2.1、§5.3.1;技术架构 §13
|
||||
|
||||
- 产品方案 §5.3.1:「月度 AI 总成本(MAU 15 万口径)≤ **¥12 万/月**」,「单免费用户月度 AI 成本 ≤ **¥8**」,并声称「月度成本上限 = MAU × 单用户配额成本上限,**是可以事前算准的确定值**」。架构 §13 复述了同一口径。
|
||||
- 但 15 万 × ¥8 = **¥120 万**,与 ¥12 万相差 10 倍。两个数字不可能同时成立。
|
||||
- 再验算 ¥8 本身:按配额表基础额度(每日 3 次换装 ×¥0.5 + 3 次发型 ×¥0.2 + 10 轮对话 ×¥0.05 ≈ ¥2.6/日),若用户每日用满,月成本约 **¥78**;若按硬上限(8+6+40 次)算是 ¥246/月。¥8 只有在"月均活跃约 3 天"的使用率假设下才成立,但这个假设**没有写在任何文档里**。
|
||||
- 后果:这是 V1 唯一的财务锚点,且文档把它定性为"由配额硬性决定的确定上限"。按文中公式算出来的真硬上限(百万级/月)与文中给出的 ¥12 万相差两个数量级,**"成本可事前算准"这个论证目前不成立**。
|
||||
|
||||
**建议**:把两个口径分开写清楚——① 真·硬上限 = MAU × 配额硬上限 × 单次成本(数字会很大,但它才是"确定值");② 预期成本 = 硬上限 × 使用率假设(把活跃天数/功能渗透率的假设显式写出来,并标注为假设)。¥12 万和 ¥8 按口径②重新推导或修正。
|
||||
|
||||
### P0-2 诊断维度数:技术架构文档仍写 34 项
|
||||
|
||||
**位置**:技术架构 §6.2(`pf_diagnosis` 行注释「34 个诊断维度」)、§6.3(「34 个诊断维度用 JSONB 存储」)
|
||||
|
||||
TD-31 已决定维度由 34 减为 **32**(删除五行分布、幸运色),美学报告 §4、需求规格 FR-RPT-06、README §0.1/§5、任务拆解 T-P0-25/T-P3-06 均已同步为 32,**只有技术架构文档两处遗漏**。这是"本轮决策同步"的明确漏网之鱼。
|
||||
|
||||
### P0-3 性能指标至少四处互相矛盾
|
||||
|
||||
同一指标在不同文档给出不同数值,且需求规格 §4.1 并未被其他文档遵守为单一事实源:
|
||||
|
||||
| 指标 | 需求规格 §4.1 / 架构 §12.1 | 产品方案 §8.3.1 | 模块文档 | 冲突 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 初级报告 P95 | **< 90s**(NFR-PERF-03) | **< 60s** | 美学报告 §10.2:< 60s | 90 vs 60 |
|
||||
| 全面报告 P95 | **< 180s**(NFR-PERF-04) | **< 5min(300s)** | 美学报告 §10.2:≤ 5min | 180 vs 300 |
|
||||
| 换装出图 P95 | **< 40s**(NFR-PERF-05,不含排队) | **< 320s** | 产品方案 §2.1.1:前端超时 ≥ 320s | 40 vs 320 |
|
||||
| Agent 整轮 P95 | **< 8s**(NFR-PERF-02) | — | 穿搭 Agent §7.1/§8.3:**< 15s** | 8 vs 15 |
|
||||
| 系统可用性 | ≥ 99.5%(NFR-REL-01、架构 §12.1) | **> 99.9%** | — | 停机预算差 5 倍 |
|
||||
|
||||
换装一条特别需要说明:40s 与 320s 可能分别是"改造后目标"与"当前接口现实",但没有任何文档写明这层关系,FR-TRY-01 的验收标准(p95 < 40s)按现有接口实测很可能直接不达标。
|
||||
|
||||
**建议**:性能指标只保留需求规格 §4.1 一处定义,其余文档改为引用;换装/发型补充"现状基线(T-P0-13/14 实测)→ 目标值"的两段式表述。
|
||||
|
||||
### P0-4 记忆注入的 token 预算有两套互相矛盾的口径
|
||||
|
||||
**位置**:产品方案 §2.4.2、用户系统 §3.8、穿搭 Agent §3.5.2 vs 需求规格 FR-MEM-04、架构 §7.2、穿搭 Agent §10.2、任务拆解 T-P3-17
|
||||
|
||||
- 口径 A(≤1400):画像 ≤250 + 偏好 ≤150 + 事件召回 ≤200 + 会话 ≤800(产品方案、用户系统、Agent §3.5.2 一致)。
|
||||
- 口径 B:架构 §7.2 与 Agent §10.2 的预算表为「画像摘要 ≤500、会话摘要 ≤400」(总输入 ≤3700 的分解);FR-MEM-04 验收标准写「注入 token ≤ 500」,T-P3-17 照抄。
|
||||
- 直接冲突点:**会话摘要 800 vs 400**;**记忆注入合计 1400 vs 500**。验收标准(FR-MEM-04)与需求原文(用户系统 §3.8)不一致,测试不知道该按哪个写。
|
||||
|
||||
**建议**:在架构 §7.2 定义唯一的预算分解(建议明确"记忆注入预算"与"总输入预算 3700"的包含关系),FR-MEM-04 与其余文档全部引用该处。
|
||||
|
||||
### P0-5 人脸原图保留期:2 小时 vs 24 小时
|
||||
|
||||
**位置**:合规方案 §2.1.2/§4.1、CR-02、架构 §7.1/§11.2、任务拆解 T-P1-27 = **≤ 2 小时**;产品方案 §7.1.3、美学报告 §9.1 = **≤ 24h 仅用于任务重试**
|
||||
|
||||
这是合规红线参数,两套说法直接冲突。"24h 用于任务重试"与"处理后显式删除、桶级 2 小时兜底"是两种不同的设计(前者意味着原图要留存到任务结束)。按"下层优先"原则应以合规方案/CR-02 的 2 小时为准,修正产品方案与美学报告;若确实需要重试窗口,则合规方案要补充论证。
|
||||
|
||||
### P0-6 配额流水类型:两处写"两种",两处写"三种"
|
||||
|
||||
**位置**:需求规格 §2.9 架构约束、产品方案 §3.3 = V1 只用 `system_grant` + `consume` **两种**;用户系统 §2.4.2、架构 §6.2 = `system_grant` / `consume` / `refund` **三种**
|
||||
|
||||
`refund` 是预扣三段式(FR-BILL-05)的必需类型,需求规格与产品方案的表述属于漏写。按"表结构真相在架构"的原则,应以三种为准,修正需求规格 §2.9 与产品方案 §3.3。
|
||||
|
||||
### P0-7 美学报告文档内的价格残留(与同节注释自相矛盾)
|
||||
|
||||
**位置**:美学报告 §2.5、§2.1
|
||||
|
||||
- §2.5 正文 bullet 仍写「预览卡片(模糊化目录截图 + 内容清单 + **预计价格**)」、埋点记录含「**点击价格档位**」;紧随其后的 ⚠️ 注释明确说这两项"已移除"。同一节内自相矛盾。
|
||||
- §2.1 分级总览表「价格」行仍写全面报告「**单次 ¥29.9**」,与 §2.5「全部为占位草案,待定」及"V1 全线不出现价格"的纪律(FR-BILL-07、TD-28)冲突。
|
||||
|
||||
**建议**:删除 §2.5 正文中的「预计价格」「点击价格档位」,§2.1 的 ¥29.9 改为"待定(V2 按真实数据定价)"。
|
||||
|
||||
### P0-8 全面报告"40+ 项维度"与 32 项维度字典矛盾
|
||||
|
||||
**位置**:产品方案 §2.2.1(「8 项核心 / **40+ 项完整**」)、美学报告 §2.1(同)vs 美学报告 §4 维度字典(**共 32 项**)、README §5
|
||||
|
||||
字典总共 32 项,全面报告不可能覆盖 40+ 项。这是五行模块移除(34→32)之前就遗留的旧口径。另外"初级 8 项核心"与字典中初级打 ✅ 的维度数(约 15 项)也对不上——建议把分级表改为"初级覆盖 X 项 / 全面覆盖全部 32 项"的准确口径,或明确"8 项"指的是首屏核心展示数。
|
||||
|
||||
### P0-9 语音输入在穿搭 Agent 文档中被列为 P0
|
||||
|
||||
**位置**:穿搭 Agent §2.2(P0 能力范围含「文字对话(**语音输入转文字**)」)、§2.1(能力总览「文字/语音多模态输入」标 P0)
|
||||
|
||||
FR-AGT-17 与 TD-14 均明确语音输入为 **P1 / V2**(Core Speech Kit),本文 §9.1/§9.2 的阶段划分也把语音放在 Phase 2。§2.2 属于改版残留,与需求规格直接冲突。
|
||||
|
||||
### P0-10 画像/记忆/同意的数据结构与规则在架构与用户系统间不一致
|
||||
|
||||
**位置**:技术架构 §6.2、§8.4 vs 用户系统 §2.2、§3.3、§3.5
|
||||
|
||||
| 项 | 架构文档 | 用户系统文档 |
|
||||
| --- | --- | --- |
|
||||
| `pf_memory.status` 枚举 | active / pending_confirm / **archived / rejected** | active / pending_confirm / **decayed / overridden / deleted** |
|
||||
| `pf_memory.source` 枚举 | explicit / implicit / report / behavior | explicit_user_input / user_setting / report_vision / self_reported / behavior_inferred / llm_extracted |
|
||||
| 显式陈述初始置信度 | **0.95**(§8.4) | **0.90**(§3.5;产品方案 §2.4.2 同为 0.9) |
|
||||
| 同意类型枚举 | terms / privacy / face / **minor** | 隐私政策 / 人脸 / **个性化推荐 / 推送通知** |
|
||||
| 手机号存储 | `id_user` 字段清单中**无 phone**,`id_user_identity` 仅示例 huawei | 用户主表含 `phone`(加密存储) |
|
||||
|
||||
其中同意类型枚举的偏差有合规后果:用户系统 §3.10 要求记忆写入建立在「个性化推荐」授权之上,但架构的 `id_consent` 枚举里没有这一项。建议以用户系统文档的语义为准修订架构 §6.2,并补齐手机号的存储位置说明。
|
||||
|
||||
### P0-11 准确率门槛两套数值
|
||||
|
||||
**位置**:FR-RPT-05 与 T-P0-10/11(体型 ≥80%、季型 ≥75%)vs 美学报告 §9.4、产品方案 §8.3.3(体型 ≥85%、季型 ≥80%);FR-AGT-02(意图识别 ≥85%)vs 穿搭 Agent §8.2(>90%)
|
||||
|
||||
可能是"Phase 0 验证门槛"与"上线质量门槛"的区分,但没有任何文档写明这层关系。建议显式标注两档(如:验证门槛 80/75,上线门槛 85/80),否则 Phase 0 验收与上线验收会打架。
|
||||
|
||||
---
|
||||
|
||||
## 2. P1:覆盖缺口(需求 → 任务反查发现)
|
||||
|
||||
按任务拆解 §9.3 的要求做了 P0/CR 需求反查,以下缺口会直接影响执行:
|
||||
|
||||
### P1-1 手机号验证码登录:无开发任务、无短信通道选型(最实际的缺口)
|
||||
|
||||
- FR-ACC-04 是 P0/V1(登录兜底通道),但任务拆解 Phase 1 §3.2(T-P1-11~18)**只有华为账号登录任务,没有手机号验证码任务**。
|
||||
- 更大范围看:技术选型 31 项决策中**没有短信服务商选型**;架构 §2 外部依赖表与系统上下文图均无短信通道;成本模型 §13 未计短信成本;NFR-SEC-05 防刷未提短信轰炸防护。
|
||||
- 建议:补一项短信通道选型决策(TD-32)+ 一个开发任务(验证码下发、校验、限频、防刷),并在外部依赖表中登记降级策略。
|
||||
|
||||
### P1-2 全面报告「预约留资」入口无 WBS 任务
|
||||
|
||||
产品方案 §2.2.3 与 §6.1.2 均要求 V1 做「全面报告预约入口 + 埋点」,**这是 V1 唯一的商业化验证指标(预约率 ≥8% 决策门槛)的数据来源**;FR-RPT-15 验收标准也含"V1 仅做预约留资"。但任务拆解 Phase 3(T-P3-01~20)没有对应任务。漏掉它,V1 上线后连"要不要做付费报告"都回答不了。
|
||||
|
||||
### P1-3 会员页静态展示 + 「上线提醒我」留资:无 FR、无任务
|
||||
|
||||
产品方案 §2.8(🅿️ 区)与 §3.4 均要求 V1 交付,但需求规格中无对应 FR 编号,WBS 无任务。建议补 FR-BILL-xx(或 FR-ACC-07 扩展)与 Phase 5 任务。
|
||||
|
||||
### P1-4 报告结论的反馈与修正入口无 FR
|
||||
|
||||
产品方案 §8.1.2/§8.1.4 的核心质量指标——**报告结论认同率 >70%、结论修正率 <15%**——依赖"用户主动确认/修正"的采集入口(美学报告 §7.2「不准?修正一下」、§9.4 反馈入口)。需求规格中没有对应 FR(FR-RPT-10 的验收不含此项),WBS 中 T-P4-11 只覆盖 Agent 反馈。指标没有数据来源就等于没有指标。
|
||||
|
||||
### P1-5 游客配额未进入配额表,FR-ACC-02 验收不可判定
|
||||
|
||||
游客额度(1 次换装 / 1 次试戴 / 1 次报告 / 3 次对话)只出现在产品方案 §2.4.1 与用户系统 §2.1 的叙述中,**没有进入配额表**(产品方案 §3.2 / 用户系统 §2.4.1 均只有注册用户行),违反自定的"单一事实来源"纪律。同时 FR-ACC-02 的验收标准「体验流程中被允许的部分」不可判定,建议把游客额度补入配额表并在 FR-ACC-02 引用。
|
||||
|
||||
### P1-6 天气数据源完全未选型
|
||||
|
||||
Agent 的意图类别含「天气适配」(穿搭 Agent §3.2)、典型场景 §4.4 直接演示"明天 15 度有雨怎么穿"、知识库场景实体含温度带映射(§3.2 ⑥)、L0 城市字段的用途写明"天气定位"——但全部文档中**没有天气 API 的来源、选型与成本说明**。V1 的 Agent 要回答天气类问题就需要它。建议补一项选型决策(或明确 V1 天气问题降级为"让用户告知天气")。
|
||||
|
||||
### P1-7 「清空照片」入口无 FR
|
||||
|
||||
用户系统 §2.6 与产品方案 §2.4.1 均承诺"清空照片"作为账号生命周期能力,需求规格无对应 FR(FR-ACC-09 注销、FR-ACC-10 导出之外缺少"清空照片"),合规检查清单 §5.3 也未覆盖。涉及人脸数据删除,建议补 FR 并纳入合规清单。
|
||||
|
||||
### P1-8 配额模型中 `bl_quota` 与 `bl_wallet`/`bl_ledger` 的关系未说清
|
||||
|
||||
架构 §6.2 同时存在周期配额表(`bl_quota` + `bl_quota_rule`)与余额账本(`bl_wallet` + `bl_ledger`),§7.4 校验公式为 `bl_quota.used < limit_value + task_bonus`——但 `task_bonus` 从哪里来(是 `bl_ledger` 中 system_grant 的周期聚合?)、`bl_wallet.balance` 在 V1 纯配额模式下代表什么,没有定义。两套机制并存但职责边界模糊,开发时会卡住。建议在架构 §7.4 补一段"V1 配额判定数据流"的明确说明。
|
||||
|
||||
### P1-9 两个编号/归属问题
|
||||
|
||||
- **TD-00 被引用但从未定义**:需求规格 FR-TRY-01 依赖列写「TD-00 改造」、架构 §4.4 写「(TD-00 前提)」,但技术选型报告的决策总表从 TD-01 开始。§0「已确认的项目前提」中的"换装/发型服务自有可控"应编号化为 TD-00。
|
||||
- **CR-11 日志留存 ≥6 个月**无明确落地任务(T-P1-08 只含日志脱敏,CLS 留存周期配置无人认领),建议在 T-P1-04 或 T-P1-08 的验收标准中补上。
|
||||
|
||||
---
|
||||
|
||||
## 3. P2:一致性与维护性问题(不阻塞评审,但应一并修)
|
||||
|
||||
| # | 问题 | 位置 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| P2-1 | **五份文档头部版本号滞留 v1.0** | 需求规格、技术架构、技术选型、合规方案、任务拆解的页首 | 五份文档的变更记录均已升至 v1.1,README §8 也按 v1.1 引用,但页首仍写 v1.0。本轮新增的五份文档全部中招,建议统一刷新 |
|
||||
| P2-2 | README §8 遗漏一份文档 | README §8「九份文档全部更新」 | 内容文档实为 **10 份**,表格 9 行漏了《穿搭 Agent-需求文档》(当天同样更新至 v1.2) |
|
||||
| P2-3 | 文档状态口径不一 | README §7 vs 各文档页首/页尾 | README 写「产品需求已定稿」,但产品方案、美学报告、用户系统、知识库、Agent 五份文档自身状态均为「待评审」 |
|
||||
| P2-4 | "点数/扣点/计费"措辞残留 | 合规 §2.4.1("不消耗点数/退点")、架构 `tk_task.points_reserved` 字段名、架构 §7.1("不重复扣点")、FR-TASK-05、TD-11、产品方案 §2.7("不重复计费")、TD-23 埋点清单含"点数消耗/付费页曝光/下单/支付成功" | V1 已统一为"配额"语义,这些 V2 词汇残留会造成实现期误解;TD-23 的 V2 事件建议标注版本 |
|
||||
| P2-5 | 枚举计数错误 | 知识库 §3.1 ⑤「8 脸型映射」vs 详情与美学报告 §4 均为 **7 种脸型**;§3.2 ④「局部特征 12 项」实际列了 **13 项** | 小数字错误,但这类错误多了会损害文档可信度 |
|
||||
| P2-6 | 知识库规模口径需单一事实源 | 单品:知识库 §3.1「冷启动 800~1500」、§9「长期 800 / V1 硬指标 300」、产品方案 §2.5.2「800 条(≥400 可试穿)」、§2.8「300 件」;规则:知识库 §3.1「800~1200」、§9 表「第一版 800」、§9 文末分期「V1 400」、产品方案 §2.5.2「800」、§2.8「400」 | 产品方案 §2.5.2 的"第一版目标量级"列与 §2.8 直接矛盾;知识库 §9 表格与其自己的分期建议矛盾。建议统一为:V1 = 规则 400 / 单品 300 tryon_ready / 方案 100,长期目标另列 |
|
||||
| P2-7 | 黄金集规模口径需分列 | 知识库 §7「200 条」、美学报告 §9.4「100 例」、FR-KB-11「≥100」、FR-AGT-05「100 例」、FR-RPT-07「30 例」、产品方案 §6.1.2「200 条」、知识库 §5.1.1「100~200 例」 | 各数字可能对应不同子集(检索/规则/Agent/报告),但没有一处说清。建议在 FR-KB-11 或知识库 §7 定义各子集规模,其余引用 |
|
||||
| P2-8 | 若干净期承诺与"按产能倒排"原则冲突 | 产品方案 §4.3.3「1-2 个月上线 MVP」、§6.1 标题「Phase 1:MVP(1-2 个月)」、§9.3「1 个月内…正式上线」 | 本轮已把排期原则改为"不按人日估算、按实测产能倒排"(知识库 §5.1 还专门删了"3~4 周"估算),这几处时间盒是改版前残留,建议改为以里程碑出口条件表述 |
|
||||
| P2-9 | 其他小项 | ① 架构 §12.2 容量基线 1 万 DAU < 产品方案 §8.1.1 目标档 DAU 2 万,建议对齐或说明假设;② 穿搭 Agent §7.1「1000+ 并发对话」相对容量基线缺乏依据;③ FR-TASK-04「推送到达率 ≥90%」未定义分母(全体用户还是已授权用户,TD-17 指出通知授权默认关闭);④ 产品方案 §9.1.4 仍列「市场数据信源补齐」待办、§10.4.1 仍挂三条"待确认"行业报告,与 §4.1 已完成信源替换矛盾;⑤ 美学报告 §2.1「首次不限」与配额表「首次 1 次(终身)」措辞歧义;⑥ README §0.3「其余 20 项决策」与产品方案 §11.1 实际 21 行;⑦ 任务拆解 §1 依赖图中 M4 编号同时指"换装/发型"与"Agent 对话"两块,易误读;⑧ 需求规格 §1.3 示例(p95 < 90s)与产品方案 §2.2.1(≤60s)矛盾,同 P0-3 | — |
|
||||
|
||||
---
|
||||
|
||||
## 4. 修订建议与处理顺序
|
||||
|
||||
**第一优先(评审会前)**:P0-1(成本口径)与 P0-3(性能指标)。这两条会被任何认真读文档的人第一时间发现,且都涉及"哪个文档是单一事实源"的问题——正好借修订把纪律落实:
|
||||
|
||||
| 事实 | 建议的唯一定义位置 | 其余文档 |
|
||||
| --- | --- | --- |
|
||||
| 性能指标 | 需求规格 §4.1 | 产品方案 §8.3.1、各模块文档改为引用 |
|
||||
| 配额数值(含游客) | 产品方案 §3.2 | 用户系统 §2.4.1 引用 |
|
||||
| Token 预算 | 架构 §7.2 | FR-MEM-04、Agent §3.5.2/§10.2、用户系统 §3.8 引用 |
|
||||
| 诊断维度数与字典 | 美学报告 §4 | 架构 §6.2/§6.3、README §5 引用 |
|
||||
| 知识库规模目标 | 知识库 §9 | 产品方案 §2.5.2/§2.8 引用 |
|
||||
| 黄金集规模 | 知识库 §7 或 FR-KB-11 | 其余引用 |
|
||||
| 准确率门槛 | 美学报告 §9.4(区分验证档/上线档) | FR-RPT-05、产品方案 §8.3.3 引用 |
|
||||
| 人脸原图保留期 | 合规方案 §2.1.2 / CR-02 | 产品方案 §7.1.3、美学报告 §9.1 引用 |
|
||||
|
||||
**第二优先(进入开发前)**:P1-1(短信通道)、P1-2(预约留资任务)、P1-8(配额数据流)。这三项会直接卡住开发或让 V1 失去关键数据。
|
||||
|
||||
**第三优先(随下一轮文档更新)**:P0 其余项与全部 P2。建议按 README §6 的变更留痕规范逐份修订并登记。
|
||||
|
||||
**流程建议**:把"数字一致性走查"加入文档评审 checklist——本轮的问题高度集中在数字(维度数、预算数、成本数、性能数、规模数),人工通读很难避免,建议在每次大改版后专门做一次"同一数字全局搜索"(如搜 `34`、`500`、`1400`、`90s`、`300`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 附:评审覆盖清单
|
||||
|
||||
| 文档 | 版本(自称) | 实际应为 | 主要问题分布 |
|
||||
| --- | --- | --- | --- |
|
||||
| README | — | — | P2-2、P2-3、P2-8⑥ |
|
||||
| 产品方案 | v6.0 | v6.0 ✓ | P0-1、P0-3、P0-4、P0-5、P0-6、P0-8、P2-4、P2-6、P2-8、P2-9 |
|
||||
| 需求规格说明 | v1.0(页首滞留) | v1.1 | P0-3、P0-4、P0-6、P0-11、P1-1~P1-7、P2-1 |
|
||||
| 美学报告-需求文档 | v1.2 | v1.2 ✓ | P0-5、P0-7、P0-8、P0-11、P2-9⑤ |
|
||||
| 穿搭Agent-需求文档 | v1.2 | v1.2 ✓ | P0-3、P0-4、P0-9、P0-11、P2-9② |
|
||||
| 用户系统与记忆系统-需求文档 | v1.2 | v1.2 ✓ | P0-4、P0-6、P0-10(作为正确口径基准) |
|
||||
| 美学知识库-建设方案 | v1.2 | v1.2 ✓ | P2-5、P2-6、P2-7 |
|
||||
| 技术架构文档 | v1.0(页首滞留) | v1.1 | P0-1、P0-2、P0-3、P0-4、P0-10、P1-8、P1-9、P2-1、P2-4、P2-9① |
|
||||
| 技术选型与调研报告 | v1.0(页首滞留) | v1.1 | P1-1(缺短信选型)、P1-6(缺天气选型)、P1-9(TD-00)、P2-1、P2-4 |
|
||||
| 合规与安全方案 | v1.0(页首滞留) | v1.1 | P0-5(作为正确口径基准)、P2-1、P2-4 |
|
||||
| 开发任务拆解 | v1.0(页首滞留) | v1.1 | P1-1、P1-2、P1-3、P1-4、P1-9、P2-1、P2-9⑦ |
|
||||
|
||||
---
|
||||
|
||||
**评审人**:AI 辅助评审
|
||||
**说明**:本报告仅基于文档文本的交叉比对,未验证文档与真实世界的一致性(如备案材料内容、接口实际表现)。P0-1 涉及的成本推算过程已在 §1 中给出算式,修订时可直接复算。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 本地联调指南(App ↔ 后端)
|
||||
|
||||
> 2026-08-15 | 适用:FE 第一批(F1~F4)交付后的真机/模拟器联调
|
||||
|
||||
## ⚠️ 先看:端口冲突
|
||||
|
||||
App 的默认 baseUrl 是 `http://127.0.0.1:8000`,但**你这台机器的 8000 端口已被 AutoVideo(voxcpm-tts)长期占用**。因此本地联调请让美搭后端用 **8666** 端口,并在 App 登录页「高级设置」里把服务器地址改成对应值(F1 交付的设置入口)。
|
||||
|
||||
## 一、启动后端(WSL)
|
||||
|
||||
```bash
|
||||
wsl -d Ubuntu-22.04 -e bash -c "cd /home/xsl/meida_backend && \
|
||||
(nohup .venv/bin/uvicorn apps.api.main:app --host 0.0.0.0 --port 8666 > /tmp/meida-api.log 2>&1 &) && \
|
||||
(nohup .venv/bin/celery -A apps.worker.celery_app worker -Q cpu,io --loglevel WARNING > /tmp/meida-worker.log 2>&1 &) && sleep 3 && \
|
||||
curl -s http://127.0.0.1:8666/healthz"
|
||||
```
|
||||
|
||||
- `--host 0.0.0.0`:真机(USB)需要开发机局域网 IP 可达
|
||||
- worker 必须起:报告生成是异步任务,不起 worker 会一直 RUNNING
|
||||
- 依赖容器:PG(55432)/Redis(56379) 应已在跑(`docker ps` 确认)
|
||||
- 停止:`wsl -d Ubuntu-22.04 -e bash -c "pkill -f 'uvicorn apps.api'; pkill -f 'celery -A apps.worker'"`
|
||||
|
||||
## 二、App 侧(DevEco Studio)
|
||||
|
||||
1. 打开工程 `G:\meida\app`,点 Run(模拟器或真机)
|
||||
2. 登录页「高级设置」:
|
||||
- **模拟器**:`http://127.0.0.1:8666`(WSL2 端口自动转发到 Windows localhost)
|
||||
- **真机(USB)**:`http://<开发机局域网IP>:8666`(`ipconfig` 查,手机与电脑同 WiFi;防火墙首次会弹允许)
|
||||
3. 设备标识随便填(记住它——同标识=同账号,配额/报告都挂它下面)
|
||||
|
||||
## 三、可体验的闭环(对齐 FE 第一批)
|
||||
|
||||
1. 登录 → 四 Tab 主界面
|
||||
2. 首页「生成我的形象报告」→ 填问卷 → 提交 → 看进度轮询 → **32 维结果页**(约 10~30 秒,含一次真实 LLM 调用)
|
||||
3. 首页「AI 穿搭顾问」→ 对话(真实 DeepSeek)
|
||||
4. 「我的」→ 配额卡片(对话/试穿/报告)+ 流水(消耗/退还/奖励)
|
||||
5. 重复生成报告 → 40920 提示;报告重生成 → 视觉阶段复用(秒出诊断)
|
||||
|
||||
## 四、已知边界(设计使然,不是 bug)
|
||||
|
||||
- 照片是"模拟对象键"输入框——COS 直传等云端开通后替换
|
||||
- 空知识库时报告文案会**降级为规则直出**(结果页有黄条标识)——等规则库录入后消失
|
||||
- 华为账号/短信登录未接(等 AGC/签名审核);当前是 dev-token 通道,仅限本地
|
||||
- 未配置签名 → 只能模拟器/调试真机跑,发布包要等证书指纹双套配置
|
||||
|
||||
## 五、快速排障
|
||||
|
||||
| 症状 | 原因/处理 |
|
||||
|---|---|
|
||||
| 登录一直转圈/网络错误 | baseUrl 不对;或后端没起;模拟器记得用 127.0.0.1 |
|
||||
| 报告一直 RUNNING | worker 没起(第二步的两条 nohup 都要) |
|
||||
| 401 反复跳登录 | 旧 token 过期属正常自动刷新;若持续,`hdc shell` 清 App 数据重登 |
|
||||
| 真机连不上 | 防火墙放行 8666;确认手机电脑同网段;后端必须 `--host 0.0.0.0` |
|
||||
+38
-26
@@ -54,32 +54,43 @@
|
||||
|
||||
### 2.2 用户数据模型
|
||||
|
||||
#### 用户主表 `user`
|
||||
> 表名与列清单以《技术架构文档》§6.2 为唯一事实源。本节只写产品语义,**不再自造表名**。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `user_id` | string | 全局唯一 ID(雪花号) |
|
||||
| `phone` | string | 手机号(加密存储,脱敏展示) |
|
||||
| `huawei_open_id` | string | 华为账号标识 |
|
||||
| `nickname` / `avatar` | string | 昵称、头像 |
|
||||
| `gender` | enum | 女 / 男 / 不便告知 |
|
||||
| `birthday` | date | 生日。**用途限于生日权益与年龄段判断**,五行推算已取消(`TD-31`) |
|
||||
| `member_level` | enum | free / member / premium。**V1 恒为 `free`**,字段预留 |
|
||||
| `member_expire_at` | datetime | 会员到期时间。**V1 恒为空**,字段预留 |
|
||||
| ~~`points_balance`~~ | — | **不放在主表**。余额统一存 `bl_wallet.balance`,以 `bl_ledger` 流水为真相(架构 §6.2)。在主表冗余余额会引入两处数据不一致的风险,而余额是最不能出错的字段 |
|
||||
| `register_channel` | string | 注册渠道(归因) |
|
||||
| `status` | enum | active / frozen / deleting / deleted |
|
||||
| `created_at` / `last_active_at` | datetime | — |
|
||||
#### 账号主表 `id_user`
|
||||
|
||||
#### 授权记录表 `user_consent`(合规必需)
|
||||
内部主键与账号状态,与外部身份解耦。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `consent_type` | 隐私政策 / 人脸信息处理 / 个性化推荐 / 推送通知 |
|
||||
| `version` | 协议版本号 |
|
||||
| `user_id` | 雪花号,全局唯一。**不用手机号或 UnionID 做主键** |
|
||||
| `nickname` / `avatar_key` | 昵称、头像 |
|
||||
| `register_source` | 注册渠道(归因) |
|
||||
| `status` | `active` / `frozen` / `deleting` / `deleted` |
|
||||
| `created_at` / `last_active_at` | — |
|
||||
|
||||
**不在本表的字段(此前误画在主表上)**:
|
||||
|
||||
| 数据 | 实际位置 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 手机号 | `id_user_identity`(`provider='phone'`,`phone_enc` + `phone_hash`) | 登录凭证,不是用户属性;加密与检索要和华为身份走同一张映射表 |
|
||||
| 华为账号 | `id_user_identity`(`provider='huawei'`,`union_id` / `open_id`) | 同上 |
|
||||
| 性别 / 生日 | 画像与问卷(`pf_profile` / 报告问卷),不进账号主表 | 生日用途限于权益与年龄段,五行推算已取消(`TD-31`) |
|
||||
| 会员档位 / 到期 | V2 的 `bl_membership`;V1 不建 | V1 全员同一套配额,主表预留会员字段会造成「看起来能升级」的假接口 |
|
||||
| 余额 | `bl_wallet.balance`,以 `bl_ledger` 为真相 | 主表冗余余额会引入两处不一致 |
|
||||
|
||||
#### 登录身份表 `id_user_identity`
|
||||
|
||||
一个用户可有多条外部身份。`UNIQUE(provider, union_id)`。手机号检索用 `phone_hash`,展示用解密后掩码。
|
||||
|
||||
#### 授权记录表 `id_consent`(合规必需)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `consent_type` | `privacy` / `face` / `personalization` / `push` / `minor`。**人脸必须独立成行** |
|
||||
| `doc_version` | 协议版本号 |
|
||||
| `granted` | 是否同意 |
|
||||
| `granted_at` / `revoked_at` | 授权与撤回时间 |
|
||||
| `evidence` | 授权来源页面与操作记录 |
|
||||
| `client_ip` / `ua` | 授权来源留证 |
|
||||
|
||||
> 人脸信息属敏感个人信息,必须**单独授权**并可**单独撤回**;撤回后停止相关功能并删除派生数据。
|
||||
|
||||
@@ -340,15 +351,15 @@
|
||||
|
||||
### 3.9 存储与技术形态
|
||||
|
||||
> 选型结论见《[技术选型与调研报告](./技术选型与调研报告.md)》`TD-02`/`TD-08`;表结构见《[技术架构文档](./技术架构文档.md)》§5.2。
|
||||
> 选型结论见《[技术选型与调研报告](./技术选型与调研报告.md)》`TD-02`/`TD-08`;表结构见《[技术架构文档](./技术架构文档.md)》§6.2。
|
||||
|
||||
| 数据 | 存储 | 说明 |
|
||||
|------|------|------|
|
||||
| L0 / L1 / L2 结构化画像 | PostgreSQL | 强一致、可索引、可审计 |
|
||||
| L0 / L1 / L2 结构化画像 | PostgreSQL(`pf_profile` / `pf_diagnosis` / `pf_memory`) | 强一致、可索引、可审计 |
|
||||
| L3 事件与对话摘要 | PostgreSQL + pgvector | 同一库内完成硬过滤与语义召回,不引入专用向量库 |
|
||||
| L4 会话状态 | Redis(带 TTL) | 高频读写 |
|
||||
| L4 会话状态 | Redis(带 TTL)+ `ag_conversation.rolling_summary` | 高频读写 |
|
||||
| L5 衣橱单品 | PostgreSQL + COS(图片)+ pgvector(图像向量) | 支持图搜与属性筛选 |
|
||||
| 变更审计 | 追加写日志表 `memory_audit` | 记录每次记忆变更的 before/after 与操作者 |
|
||||
| 变更审计 | 追加写 `pf_memory_event` | 记录每次记忆变更的 before/after 与操作者 |
|
||||
|
||||
- 端侧仅缓存画像**摘要**,支持弱网/离线时的基础个性化展示;**不缓存记忆明文**(含体型尺寸等敏感项)
|
||||
- 所有记忆读写走统一 `MemoryService`,禁止各业务模块直连表,保证冲突消解与审计逻辑唯一
|
||||
@@ -436,7 +447,7 @@
|
||||
|
||||
| **V1 是否收费** | **完全不收费**(`TD-28`)。无会员、无点数购买、无 IAP。用配额硬限制控成本,额度用尽只有「等恢复」与「做任务换额度」两条路径,文案不出现价格字样 | `TD-28`、`FR-BILL-07` |
|
||||
| **登录方式** | V1 用「华为账号授权登录 + 手机号验证码」双通道;**一键登录降级为 P1 推至 V2**(需企业资质,属体验优化非能力缺口) | `TD-29`、`FR-ACC-13` |
|
||||
| **余额字段位置** | 余额存 `bl_wallet`,**不在 `user` 主表冗余** `points_balance`;以 `bl_ledger` 流水为真相 | 架构 §6.2 |
|
||||
| **余额字段位置** | 余额存 `bl_wallet`,**不在 `id_user` 冗余** `points_balance`;以 `bl_ledger` 流水为真相 | 架构 §6.2 |
|
||||
| **配额账本与点数账本的关系** | **同一张表**。V1 用 `system_grant`/`consume`/`refund` 三种 `tx_type`,V2 增加 `purchase`/`gift`,V2 上支付**无需数据迁移** | 架构 §6.2、§7.4 |
|
||||
|
||||
### 6.2 仍待业务输入
|
||||
@@ -461,12 +472,13 @@
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.2
|
||||
**文档版本**:v1.3
|
||||
**创建日期**:2026-08-11
|
||||
**更新日期**:2026-08-13
|
||||
**更新日期**:2026-08-14
|
||||
**作者**:产品团队
|
||||
**状态**:待评审
|
||||
**变更说明**:
|
||||
- v1.3(2026-08-14):§2.2 / §3.9 表名与架构 §6.2、实际代码对齐。原 `user` / `user_consent` / `memory_audit` 以及把手机号、华为账号画在主表上,是概念模型残留;现改为 `id_user` / `id_user_identity` / `id_consent` / `pf_memory_event`,并写明性别生日归画像、会员归 V2 `bl_membership`
|
||||
- v1.2 rev.2(2026-08-13):文档一致性评审修订
|
||||
- **§3.8 的记忆注入分项预算被采纳为全局唯一事实源**。评审发现架构 §7.2 与本节存在两套口径(会话 400 vs 800、合计 3700 vs 1400),**错在架构表** —— 本节的 800 是「最近 6 轮原文 + 更早轮次滚动摘要」,架构表只算了滚动摘要、漏掉近轮原文。故以本节分项为准,架构侧总输入合计由 3700 修正为 **4200**;`FR-MEM-04` 的「≤ 500」为误写,已改为 ≤ 1400(V1 实际 ≤ 1200,L3 为 V2 能力)
|
||||
- **§2.4.1 配额数值改为引用产品方案 §3.2**,不再复述。同时**游客额度已补入产品方案 §3.2 的配额表**(此前只写在本文 §2.1 的叙述里,导致 `FR-ACC-02` 的验收标准无法判定),并明确它是**设备维度的终身额度而非每日额度**
|
||||
|
||||
@@ -1538,7 +1538,7 @@ V1 不接入支付,因此**本节对 V1 不产生任何影响**。但 V2 定
|
||||
**作者**:产品团队
|
||||
**状态**:待评审
|
||||
**变更说明**:
|
||||
- v6.1(2026-08-13):文档一致性评审修订(见《文档评审报告-2026-08-13》)
|
||||
- v6.1(2026-08-13):文档一致性评审修订(2026-08-13)
|
||||
- **§5.2.1 / §5.3.1 成本口径彻底重写,并推翻一个错误论证**。原文称「月度成本上限 = MAU × 单用户配额成本上限,是可事前算准的确定值」,有两个漏洞:① 数字算不平(15 万 × ¥8 = ¥120 万 ≠ ¥12 万);② **更根本的是,按人头发配额封不住总成本** —— 配额约束单用户,而总成本 = MAU × 人均,MAU 无上限。现改为:单用户成本分**物理上限 ¥236 / 重度 ¥150 / 人均预期 ¥0.8** 三档;总成本 ¥7~12 万定性为**预期值(含使用率假设)而非硬上限**,并给出可复算的算式;**新增「全局日成本熔断」为唯一真正的封顶机制**(`NFR-COST-02`,四档自动降级,非仅告警)
|
||||
- **§8.3.1 性能指标改为引用 `NFR-PERF-*`**,不再自成一套。连带修正四处:初级报告 60s→90s、全面报告 5min→180s、换装改「现状基线 → 目标值」两段式、**可用性 99.9%→99.5%**(V1 单可用区无冗余,99.9% 在架构上无法兑现)
|
||||
- **§8.3.3 诊断准确率拆为「Phase 0 验证门槛 / 上线质量门槛」两档**,此前两套数值冲突的根源就是没区分这两档(两者的失败后果完全不同:前者触发换方案,后者触发维度降级)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# reference/
|
||||
|
||||
本目录只放**不含密钥**的参考资料(公开文档摘录、第三方接口说明的本地副本)。
|
||||
|
||||
- 密钥、Token、备案原件扫描件**不要放这里**。DeepSeek / 智谱等 API Key 只写在后端仓库的 `.env`(该仓库已 gitignore,且 CI 有 detect-secrets + gitleaks)。
|
||||
- `*.txt` / `*.key` / `*.pem` 已被仓库根目录 `.gitignore` 排除。
|
||||
- 需要给同事看的正式结论写进 `docs/`,不要在这里堆积会过期的草稿。
|
||||
Reference in New Issue
Block a user