feat(report): 美学诊断照片必选——本地直传链路 + 报告创建照片卡 + 授权文案改属实

- 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 零流水)
This commit is contained in:
xsl
2026-09-12 00:24:29 +08:00
parent 9161a2013c
commit fcc506920f
5 changed files with 305 additions and 38 deletions
@@ -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)
@@ -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: [] }
+97
View File
@@ -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<string> {
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<string, string> = {
'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()
}
}
}
+201 -27
View File
@@ -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<void> {
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<string, Object> = {
'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()