From fcc506920f0e0869f118c8b160335f61818557d5 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sat, 12 Sep 2026 00:24:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E7=BE=8E=E5=AD=A6=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E7=85=A7=E7=89=87=E5=BF=85=E9=80=89=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=9B=B4=E4=BC=A0=E9=93=BE=E8=B7=AF=20+=20?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=88=9B=E5=BB=BA=E7=85=A7=E7=89=87=E5=8D=A1?= =?UTF-8?q?=20+=20=E6=8E=88=E6=9D=83=E6=96=87=E6=A1=88=E6=94=B9=E5=B1=9E?= =?UTF-8?q?=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReportCreatePage:必选照片卡(人脸授权→相册/拍照→预览/重选),未选红字+汇总条拦截;提交先上传拿真 object_key 再建报告(上传失败不碰配额可重试);删除 buildMockPhotoKey 模拟链路;照片引导按需求文档 §3.4/§5.1(正面全身照) - core/UploadApi.ets:POST /uploads/direct 原始字节上传(绕开真机 multiFormDataList 0 字节 bug),带鉴权信封解包 - FacePhotoConsent:加可选 usageText(诊断页传报告口径文案,试穿/发型页零影响);处理方/保存两行按本地磁盘降级路线改属实(去「第三方」「生成后即删除」),CONSENT_VERSION v1→v2 全体重询一次 - 后端配套:meida_backend a1d0171(本地直传+存在性校验);联调 E2E 已验证(真 key 全链路 succeeded / 假 key 404 零流水) --- .../ets/components/f2/FacePhotoConsent.ets | 10 +- .../main/ets/components/f3/ReportTypes.ets | 6 - app/entry/src/main/ets/core/UploadApi.ets | 97 ++++++++ .../src/main/ets/pages/ReportCreatePage.ets | 228 +++++++++++++++--- meida_backend | 2 +- 5 files changed, 305 insertions(+), 38 deletions(-) create mode 100644 app/entry/src/main/ets/core/UploadApi.ets diff --git a/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets b/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets index 024ec36..a5adc40 100644 --- a/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets +++ b/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets @@ -22,7 +22,7 @@ import { COLOR_PRIMARY } from '../../core/Theme' const CONSENT_STORAGE_KEY: string = 'face_photo_consent_granted' const CONSENT_PREF_NAME: string = 'meida_face_consent' const CONSENT_RECORD_KEY: string = 'record' -const CONSENT_VERSION: string = 'v1' +const CONSENT_VERSION: string = 'v2' // v2:处理方/保存文案按本地磁盘降级路线改为属实表述(2026-09-12) const TEXT_PRIMARY: string = '#222222' const TEXT_SECONDARY: string = '#8A9099' const ACCENT: string = COLOR_PRIMARY @@ -103,6 +103,8 @@ export class FaceConsentStore { export struct FacePhotoConsent { onAgree: () => void = () => {} onRefuse: () => void = () => {} + /** 用途说明行:默认试穿/发型场景口径;诊断报告等场景传入自己的文案。 */ + usageText: string = '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。' @Builder ConsentLine(bullet: string, text: string) { @@ -128,9 +130,9 @@ export struct FacePhotoConsent { .fontWeight(FontWeight.Bold) .fontColor(TEXT_PRIMARY) Column({ space: 8 }) { - this.ConsentLine('·', '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。') - this.ConsentLine('·', '处理方:照片将上传至第三方 AI 图像服务完成本次生成,不会共享给其他方。') - this.ConsentLine('·', '保存:原图在生成完成后即删除;你保存到相册的效果图可随时自行删除。') + this.ConsentLine('·', this.usageText) + this.ConsentLine('·', '处理方:照片将上传至美搭的图像服务完成本次生成,不会共享给其他方。') + this.ConsentLine('·', '保存:上传的原图不会公开展示;你保存到相册的效果图可随时自行删除。') } .width('100%') .alignItems(HorizontalAlign.Start) diff --git a/app/entry/src/main/ets/components/f3/ReportTypes.ets b/app/entry/src/main/ets/components/f3/ReportTypes.ets index 21c6a9a..e507c9d 100644 --- a/app/entry/src/main/ets/components/f3/ReportTypes.ets +++ b/app/entry/src/main/ets/components/f3/ReportTypes.ets @@ -264,12 +264,6 @@ export function randomHex(len: number): string { 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: [] } diff --git a/app/entry/src/main/ets/core/UploadApi.ets b/app/entry/src/main/ets/core/UploadApi.ets new file mode 100644 index 0000000..ca0ea3c --- /dev/null +++ b/app/entry/src/main/ets/core/UploadApi.ets @@ -0,0 +1,97 @@ +/** + * UploadApi:诊断照片直传本机后端(无 COS 四件套的本地磁盘降级路线)。 + * + * 后端 POST /api/v1/uploads/direct 收原始字节(后端仓库未引入 + * python-multipart,不收 multipart 表单;这也正好绕开真机 + * http.multiFormDataList 的 0 字节 bug): + * - body 即图片字节(ImageCodec.toJpegBuffer 压缩后的 JPEG) + * - Content-Type 头申报类型;scene 走 query(默认 report_photo) + * - 响应走统一信封,data.object_key 回传给 POST /api/v1/reports + * + * 错误一律抛 ApiError(与 ApiClient 约定一致,UI 层只 catch ApiError)。 + * 鉴权头从 AuthStore 取;401 自动刷新不在此处做(与 TryonApi 同口径, + * 令牌过期由下一次 ApiClient 请求兜底刷新)。 + */ + +import http from '@ohos.net.http' +import { hilog } from '@kit.PerformanceAnalysisKit' +import { ApiError } from './ApiTypes' +import { AuthStore } from './AuthStore' + +const UPLOAD_DOMAIN: number = 0x0201 +const UPLOAD_TAG: string = 'MEIDA_UPLOAD' +const UPLOAD_READ_TIMEOUT_MS: number = 60000 // 压缩后单张 ~300KB,60s 覆盖弱网 + +/** POST /uploads/direct 响应的 data 段(字段名对齐后端 JSON)。 */ +export interface DirectUploadData { + object_key?: string + size_bytes?: number + content_type?: string +} + +/** 直传响应信封(同 ApiEnvelope 形状,data 可选以防御后端缺省)。 */ +interface DirectUploadWire { + code: number + message: string + data?: DirectUploadData + hint?: string +} + +export class UploadApi { + /** + * 上传诊断照片(已压缩 JPEG 字节),返回真实 object_key。 + * 失败抛 ApiError(message/hint 用服务端信封原文,可直接展示重试)。 + */ + static async uploadReportPhoto(imageBuf: ArrayBuffer): Promise { + const token = AuthStore.getAccessToken() + if (token === undefined) { + // 与 ApiClient 同一纪律:未登录直接短路,省一次必然 401 的往返 + throw new ApiError(40100, '请先登录', '登录后即可继续') + } + const base = await AuthStore.getBaseUrl() + const url = `${base}/api/v1/uploads/direct?scene=report_photo` + const client = http.createHttp() + try { + const header: Record = { + 'Content-Type': 'image/jpeg', + 'Authorization': `Bearer ${token}` + } + hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct post %{public}s, %{public}d bytes', url, imageBuf.byteLength) + const resp = await client.request(url, { + method: http.RequestMethod.POST, + header: header, + extraData: imageBuf, + expectDataType: http.HttpDataType.STRING, + connectTimeout: 10000, + readTimeout: UPLOAD_READ_TIMEOUT_MS + }) + const bodyText: string = (resp.result ?? '') as string + hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct resp code=%{public}d body=%{public}s', + resp.responseCode, bodyText.substring(0, 300)) + let wire: DirectUploadWire + try { + wire = JSON.parse(bodyText) as DirectUploadWire + } catch (_p) { + // 网关/代理层错误:body 不是信封 JSON,给统一话术(对齐 ApiClient) + throw new ApiError(50000, '服务开小差了', '请稍后重试', resp.responseCode) + } + const key = wire.data?.object_key ?? '' + if (wire.code === 0 && key !== '') { + return key + } + if (wire.code !== 0) { + throw new ApiError(wire.code, wire.message, wire.hint, resp.responseCode) + } + // code=0 但没有 object_key:契约破损,按服务端异常处理 + throw new ApiError(50000, '照片上传结果异常', '请稍后重试', resp.responseCode) + } catch (e) { + if (e instanceof ApiError) { + throw e + } + hilog.error(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct request failed: %{public}s', String(e)) + throw new ApiError(50000, '照片上传失败,请检查网络', '请稍后重试') + } finally { + client.destroy() + } + } +} diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index eee5587..717de85 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -1,10 +1,12 @@ /** - * ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。 + * ReportCreatePage —— 报告创建(F3):必选照片 + 问卷六字段 + 提交 + 任务轮询。 * * 流程: - * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验; + * 1) 个人照片(必选,正面全身照):先走 FacePhotoConsent 人脸单独授权(CR-01), + * 再相册/拍照;提交时先上传(POST /uploads/direct,本地磁盘降级路线) + * 拿真实 object_key,再建报告——模拟键已被服务端存在性校验封死 + * 2) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验; * 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入 - * 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg * 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key, * 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务) * 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录, @@ -17,14 +19,15 @@ import { router, componentUtils } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError } from '../core/ApiTypes' -import { AuthStore } from '../core/AuthStore' +import { ImageCodec } from '../core/ImageCodec' +import { UploadApi } from '../core/UploadApi' import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' +import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' import { ReportCreateResp, ReportListItemWire, - buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes' @@ -79,7 +82,10 @@ struct ReportCreatePage { @State birthday: Date = new Date(2000, 0, 15) @State coreGoals: string[] = [] @State psychStyle: string = '' - @State photoKeyInput: string = '' + @State photoUri: string = '' // 已选照片(媒体 URI);空 = 未选,提交必校验 + @State photoErr: string = '' + @State showFaceConsent: boolean = false + @State showPhotoSource: boolean = false @State genderErr: string = '' @State heightErr: string = '' @State weightErr: string = '' @@ -130,6 +136,10 @@ struct ReportCreatePage { private validateForm(): boolean { let ok = true + this.photoErr = this.photoUri === '' ? '请上传个人照片' : '' + if (this.photoErr !== '') { + ok = false + } this.genderErr = this.gender === '' ? '请选择性别' : '' if (this.genderErr !== '') { ok = false @@ -208,6 +218,44 @@ struct ReportCreatePage { }) } + /** + * 照片入口:人脸属敏感个人信息,上传前须单独同意(CR-01 / FR-ACC-08); + * 已授权则直接弹来源选择(相册/拍照)。 + */ + private onPhotoTap(): void { + this.photoErr = '' + if (!FaceConsentStore.isGranted()) { + this.showFaceConsent = true + return + } + this.showPhotoSource = true + } + + private onConsentAgree(): void { + FaceConsentStore.grant() + this.showFaceConsent = false + this.showPhotoSource = true + } + + private onConsentRefuse(): void { + this.showFaceConsent = false + this.photoErr = '需同意照片授权后才能上传' + } + + private async doPickPhoto(fromCamera: boolean): Promise { + this.showPhotoSource = false + try { + const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() + if (picked === undefined) { + return // 用户取消:保持原状 + } + this.photoUri = picked.uri + this.photoErr = '' + } catch (e) { + this.photoErr = e instanceof ApiError ? e.message : '读取照片失败,请重试' + } + } + private onSubmitTap(): void { if (this.submitting) { return @@ -247,6 +295,9 @@ struct ReportCreatePage { private missingLabels(): string[] { const labels: string[] = [] + if (this.photoErr !== '') { + labels.push('个人照片') + } if (this.genderErr !== '') { labels.push('性别') } @@ -267,7 +318,9 @@ struct ReportCreatePage { private scrollToFirstError(): void { let firstId = '' - if (this.genderErr !== '') { + if (this.photoErr !== '') { + firstId = 'field_photo' + } else if (this.genderErr !== '') { firstId = 'field_gender' } else if (this.heightErr !== '' || this.weightErr !== '') { firstId = 'field_height' @@ -310,8 +363,21 @@ struct ReportCreatePage { 'psych_style': this.psychStyle } const qObj: Object = questionnaire - const photoInput = this.photoKeyInput.trim() - const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous') + // 先上传照片拿真实 object_key(本机后端磁盘直传),成功后再建报告; + // 上传失败不扣报告额度(服务端存在性校验也挡在配额预扣之前)。 + let photoKey: string = '' + try { + const jpegBuf = await ImageCodec.toJpegBuffer(this.photoUri) + photoKey = await UploadApi.uploadReportPhoto(jpegBuf) + } catch (e) { + this.submitting = false + if (e instanceof ApiError) { + this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + } else { + this.submitError = '照片处理失败,请重试' + } + return + } const req: Record = { 'questionnaire': qObj, 'photo_object_key': photoKey, @@ -447,7 +513,7 @@ struct ReportCreatePage { .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#333333') - Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告') + Text('上传 1 张正面全身照 + 填写 6 项基础信息,约 1~3 分钟生成 32 维报告') .fontSize(12) .fontColor('#999999') } @@ -455,6 +521,65 @@ struct ReportCreatePage { .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.photoErr, required: true }) + if (this.photoUri === '') { + Column({ space: 6 }) { + Text('+') + .fontSize(28) + .fontColor('#999999') + Text('上传个人照片') + .fontSize(13) + .fontColor('#999999') + } + .width('100%') + .height(140) + .justifyContent(FlexAlign.Center) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .border({ width: 1, color: '#DDDDDD', style: BorderStyle.Dashed }) + .onClick(() => { + this.onPhotoTap() + }) + } else { + Stack() { + Image(this.photoUri) + .width('100%') + .height(220) + .objectFit(ImageFit.Cover) + .borderRadius(10) + Text('重选') + .fontSize(12) + .fontColor('#FFFFFF') + .padding({ left: 12, right: 12, top: 5, bottom: 5 }) + .backgroundColor('#80000000') + .borderRadius(12) + .margin(8) + .onClick(() => { + this.onPhotoTap() + }) + } + .alignContent(Alignment.BottomEnd) + } + Text('请用本人正面全身照:正面站立、双手自然下垂、纯色背景、自然光,头到脚完整入镜') + .fontSize(11) + .fontColor('#999999') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Start) + .id('field_photo') + Column({ space: 14 }) { Text('基础信息') .fontSize(15) @@ -623,23 +748,6 @@ struct ReportCreatePage { .borderRadius(14) .backgroundColor('#FFFFFF') - // 照片区(#6):上传能力未开放,只做预告;对象键输入框对用户隐藏 - // (后端本就自动生成 report_photo/{userId}/{随机}.jpg,输入无用户价值) - Column({ space: 8 }) { - Text('照片(选填)') - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor('#333333') - Text('上传照片可以让报告更准,即将开放') - .fontSize(11) - .fontColor('#999999') - } - .width('100%') - .padding(16) - .borderRadius(14) - .backgroundColor('#FFFFFF') - .alignItems(HorizontalAlign.Start) - if (this.formSummary !== '') { Text(this.formSummary) .fontSize(13) @@ -862,6 +970,50 @@ struct ReportCreatePage { .backgroundColor('#FFFFFF') } + /** 照片来源选择(相册/拍照):授权通过后弹出。 */ + @Builder + PhotoSourceDialog() { + Column({ space: 14 }) { + Text('上传个人照片') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('请上传本人正面全身照,照片仅用于本次形象诊断') + .fontSize(13) + .fontColor('#666666') + .textAlign(TextAlign.Center) + Button('相册选择') + .width('100%') + .height(42) + .fontSize(15) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.doPickPhoto(false) + }) + Button('拍照') + .width('100%') + .height(42) + .fontSize(15) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.doPickPhoto(true) + }) + Button('取消') + .width('100%') + .height(42) + .fontSize(14) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { + this.showPhotoSource = false + }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor('#FFFFFF') + } + build() { Stack() { Column() { @@ -877,6 +1029,28 @@ struct ReportCreatePage { .width('100%') .height('100%') + if (this.showFaceConsent) { + FacePhotoConsent({ + usageText: '用途:照片仅用于为你生成形象诊断报告,不用于识别你的身份,也不做其他用途的分析。', + onAgree: (): void => { + this.onConsentAgree() + }, + onRefuse: (): void => { + this.onConsentRefuse() + } + }) + } + + if (this.showPhotoSource) { + Column() { + this.PhotoSourceDialog() + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .justifyContent(FlexAlign.Center) + } + if (this.showDupDialog) { Column() { this.DupDialog() diff --git a/meida_backend b/meida_backend index 7bcbb8d..a1d0171 160000 --- a/meida_backend +++ b/meida_backend @@ -1 +1 @@ -Subproject commit 7bcbb8d40bc3b5dca4275b2847073a262558d413 +Subproject commit a1d0171fc5fb43e9077b0c0e582795172db925d7