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:
@@ -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: [] }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+1
-1
Submodule meida_backend updated: 7bcbb8d40b...a1d0171fc5
Reference in New Issue
Block a user