diff --git a/deliverables/261026-shuangcang5/video-link-mobile-v2.txt b/deliverables/261026-shuangcang5/video-link-mobile-v2.txt new file mode 100644 index 0000000..2648c1c --- /dev/null +++ b/deliverables/261026-shuangcang5/video-link-mobile-v2.txt @@ -0,0 +1 @@ +https://1me.xajuzi.com/media/videos/261026-shuangcang5-mobile-v2.html diff --git a/deliverables/261026-shuangcang5/video-link.txt b/deliverables/261026-shuangcang5/video-link.txt new file mode 100644 index 0000000..f8c7859 --- /dev/null +++ b/deliverables/261026-shuangcang5/video-link.txt @@ -0,0 +1 @@ +https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html diff --git a/deliverables/261026-shuangcang5/video-qr-mobile-v2.png b/deliverables/261026-shuangcang5/video-qr-mobile-v2.png new file mode 100644 index 0000000..505ea7a Binary files /dev/null and b/deliverables/261026-shuangcang5/video-qr-mobile-v2.png differ diff --git a/deliverables/261026-shuangcang5/video-qr.png b/deliverables/261026-shuangcang5/video-qr.png new file mode 100644 index 0000000..706efb2 Binary files /dev/null and b/deliverables/261026-shuangcang5/video-qr.png differ diff --git a/deliverables/centered-header-20261104/README.md b/deliverables/centered-header-20261104/README.md new file mode 100644 index 0000000..521ad1b --- /dev/null +++ b/deliverables/centered-header-20261104/README.md @@ -0,0 +1 @@ +实际淘宝 IDE 模拟器运行截图(非真机截图)。login-centered.png 从完整 IDE 窗口截图中裁取模拟器屏幕,未修改页面内容。 diff --git a/deliverables/centered-header-20261104/ide-login.png b/deliverables/centered-header-20261104/ide-login.png new file mode 100644 index 0000000..b6c0ebb Binary files /dev/null and b/deliverables/centered-header-20261104/ide-login.png differ diff --git a/deliverables/centered-header-20261104/login-centered.png b/deliverables/centered-header-20261104/login-centered.png new file mode 100644 index 0000000..abc1b8b Binary files /dev/null and b/deliverables/centered-header-20261104/login-centered.png differ diff --git a/deliverables/report-side-zoom-20261103/README.md b/deliverables/report-side-zoom-20261103/README.md new file mode 100644 index 0000000..60b6229 --- /dev/null +++ b/deliverables/report-side-zoom-20261103/README.md @@ -0,0 +1,11 @@ +# 报告侧脸大图对照 + +- mini-contour.png:当前小程序轮廓大图的浏览器投影。 +- mini-wrinkles.png:当前小程序皱纹大图的浏览器投影。 +- h5-contour.png:同一项目示例照片经 H5 reportZoomSource 实际函数生成的大图。 + +这些是使用项目示例照片及实际页面代码生成的浏览器预览,并非淘宝真机截图,也不能作为提审运行截图。原生图片组件最终显示效果仍需手机验证。 + +当前实现将 H5 裁切规则通过报告专用 canvas 生成独立位图,放大层只对位图 aspectFit,不再使用原生图片组件模拟裁切。预览脚本执行实际 report-side-zoom 模块的绘制命令。导出失败时等比显示完整原照片。 + +最新调整:按用户要求,mini-contour.png 和 mini-wrinkles.png 图片填充宽高均缩小至前一版的 80%,居中后向下移动缩放后图片高度的 5%;h5-contour.png 保留 H5 原规则作为历史对照,因此不再代表两者应当完全相同。 diff --git a/deliverables/report-side-zoom-20261103/README.txt b/deliverables/report-side-zoom-20261103/README.txt new file mode 100644 index 0000000..afccf46 --- /dev/null +++ b/deliverables/report-side-zoom-20261103/README.txt @@ -0,0 +1,5 @@ +同一张项目示例照片的浏览器对照预览,不是淘宝真机运行截图,不用于冒充提审截图。 +mini-contour.png:真实小程序报告放大图 AXML/ACSS 与控制器数据的浏览器投影。 +mini-wrinkles.png:皱纹视图同一放大规则的浏览器投影(示例数据)。 +h5-contour.png:使用线上 H5 reportZoomSource 原始函数及本地对应 CSS 生成的对照。 +修复:不再在尺寸获取失败时按 3:4 拉伸;复用已读取的自然尺寸,未知尺寸使用 aspectFit,图片 onLoad 后补齐真实几何。 diff --git a/deliverables/report-side-zoom-20261103/h5-contour.png b/deliverables/report-side-zoom-20261103/h5-contour.png new file mode 100644 index 0000000..b9ad43c Binary files /dev/null and b/deliverables/report-side-zoom-20261103/h5-contour.png differ diff --git a/deliverables/report-side-zoom-20261103/mini-contour.png b/deliverables/report-side-zoom-20261103/mini-contour.png new file mode 100644 index 0000000..174ad39 Binary files /dev/null and b/deliverables/report-side-zoom-20261103/mini-contour.png differ diff --git a/deliverables/report-side-zoom-20261103/mini-wrinkles.png b/deliverables/report-side-zoom-20261103/mini-wrinkles.png new file mode 100644 index 0000000..ea8d0a7 Binary files /dev/null and b/deliverables/report-side-zoom-20261103/mini-wrinkles.png differ diff --git a/deliverables/taobao-review-screenshots-20261103/01-login.png b/deliverables/taobao-review-screenshots-20261103/01-login.png new file mode 100644 index 0000000..976c3f9 Binary files /dev/null and b/deliverables/taobao-review-screenshots-20261103/01-login.png differ diff --git a/deliverables/taobao-review-screenshots-20261103/02-consent-validation.png b/deliverables/taobao-review-screenshots-20261103/02-consent-validation.png new file mode 100644 index 0000000..c3119bf Binary files /dev/null and b/deliverables/taobao-review-screenshots-20261103/02-consent-validation.png differ diff --git a/deliverables/taobao-review-screenshots-20261103/README.txt b/deliverables/taobao-review-screenshots-20261103/README.txt new file mode 100644 index 0000000..2907197 --- /dev/null +++ b/deliverables/taobao-review-screenshots-20261103/README.txt @@ -0,0 +1,4 @@ +2 张淘宝 IDE 模拟器实际运行截图,未使用 H5 投影、Mock 报告或重绘界面。仅裁去 IDE 外框,未修改界面内容。 +01:登录页。02:未勾选协议点击游客体验后,真实显示“请先阅读并同意用户协议和隐私政策”。 +格式 PNG,单张 239×423,均小于 4MB,满足用户提供的最大尺寸 2160×3840。 +后续主页与档案运行截图需要先手动完成模拟器中的淘宝登录验证。 diff --git a/deliverables/taobao-review-screenshots/01-login.png b/deliverables/taobao-review-screenshots/01-login.png new file mode 100644 index 0000000..f7bcd06 Binary files /dev/null and b/deliverables/taobao-review-screenshots/01-login.png differ diff --git a/deploy/README.md b/deploy/README.md index 85ba750..b627d10 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,8 +3,29 @@ 这份目录存放**不在 git 里自动生效**的服务器侧配置:systemd unit、nginx 片段、安装脚本。 换机器/换人接手时,按本文重建即可。 +## 淘宝拍照语音静态资源 + +`taobao-voice-media.py` 用于修复 `/state/assets/voice/` 的媒体响应。当前已部署:清单内 14 个公开 MP3 和 manifest.json 从 `/home/ubuntu/juzi/h5/assets/voice/` 复制到 `/var/www/juzi-voice-20261102/`,Nginx 在原 HTTPS server 增加该路径的 alias,返回 `audio/mpeg` 并支持 Range/206。此步骤只 reload Nginx,不重启三个业务应用;原 `/state/` 页面、后台认证、数据目录禁用及 `/tb/` 白名单保留。 + +在服务器使用 `sudo python3 /tmp/juzi-voice-media-20261102.py` 执行上传后的脚本;脚本验证清单和路径、拒绝源/目标符号链接、备份配置并执行 `nginx -t`,验证或 reload 失败会回滚。原站点配置备份为 `/etc/nginx/sites-available/juzi.voice-20261102.bak`。若日后更新语音文件,重新执行脚本刷新公开副本。14 条线上音频完整内容已与本地文件逐一比对一致。 + +客户端声音状态见 `[voice] VOICE-20261102-02` 真机 Console 日志;事件包括 loading/canplay/play_requested/playing/error。日志不包含原生错误对象、账号、照片或完整 URL。 + ## 拓扑 +### 公开视频(2026-11-02) + +用户指定的 `261026改双仓5.mp4` 已上传,源文件 SHA256 为 `46ae3089e4da884f03313b71809814813b88f1abc92d374dc8210f01db10b026`。视频为 2160×3840 H.264/AAC,约 32 秒;仅用 FFmpeg `-c copy -movflags +faststart` 调整封装,不重编码。 + +- 服务器文件:`/var/www/juzi-media/videos/261026-shuangcang5-46ae3089.mp4`,SHA256 `64790107cce2f78e68668474fb06d2e11acf3d76d9e05b7ccec4f588e89eb02e`。 +- 播放页:`https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html`。 +- 二维码:项目内 `deliverables/261026-shuangcang5/video-qr.png`,已解码确认指向上述播放页。 +- 发布脚本:`deploy/publish-video.py`;Nginx 为 `/media/videos/` 单独 alias,禁止目录索引及写入方法,支持 MP4 MIME、Range/206。 +- 本次配置备份:`/etc/nginx/sites-available/juzi.public-video-20261102T113656914226Z.bak`。仅 reload Nginx,三个业务服务未重启。 +- 验证:上传文件哈希一致、公网 HTML 200、MP4 206/32 字节范围请求正常;移动视口 Chromium 已实际开始播放,视频无解码错误。手机淘宝/微信内的具体播放体验仍以扫码为准。 + +微信反馈(能开页、播放卡住)后,另发布 `261026-shuangcang5-mobile-v2.mp4/.html`:720×1280、H.264 Main Level 3.1、AAC、faststart,5,329,575 字节,SHA256 `9f34e5a568230e87fb206f101467db9eb22d31c5951cedb26d02eacad709e33c`。保留原 4K 文件;旧 HTML 也改为播放兼容版,备份在 `/tmp/juzi-public-video-20261102-46ae3089/legacy-player-before-v2.html`。新二维码 `deliverables/261026-shuangcang5/video-qr-mobile-v2.png` 指向新的 HTML 路径以避开旧页面缓存。新增用户点击播放按钮及内联属性、等待/失败提示和直接视频链接;移动视口浏览器通过真实按钮点击已开始播放 720×1280 视频,微信真机仍待用户扫码验证。 + ``` 淘宝小程序(taobao/,AppKey 35486228) → 淘宝云网关(注入 app_key / open_id / sign,单请求 10s / 128KB 上限) diff --git a/deploy/publish-video.py b/deploy/publish-video.py new file mode 100644 index 0000000..75b3455 --- /dev/null +++ b/deploy/publish-video.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Publish an explicitly selected public video and player; reload nginx only.""" +import argparse +import hashlib +from pathlib import Path +import re +import shutil +import subprocess +from datetime import datetime, timezone + +PUBLIC = Path('/var/www/juzi-media/videos') +CONFIG = Path('/etc/nginx/sites-available/juzi') +MARKER = '# BEGIN juzi-public-videos' +BLOCK = ''' # BEGIN juzi-public-videos + location ^~ /media/videos/ { + alias /var/www/juzi-media/videos/; + autoindex off; + types { video/mp4 mp4; text/html html; } + default_type application/octet-stream; + charset utf-8; + add_header X-Content-Type-Options nosniff; + add_header Cache-Control "public, max-age=3600"; + limit_except GET HEAD { deny all; } + } + # END juzi-public-videos + +''' + + +def checked(args): + subprocess.run(args, check=True, capture_output=True, text=True) + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('staging') + parser.add_argument('stem') + parser.add_argument('sha256') + args = parser.parse_args() + assert re.fullmatch(r'[a-z0-9-]{1,80}', args.stem) + assert re.fullmatch(r'[a-f0-9]{64}', args.sha256) + staging = Path(args.staging) + assert staging.resolve() == staging and staging.parent == Path('/tmp') + assert staging.name.startswith('juzi-public-video-') and not staging.is_symlink() + sources = [staging / (args.stem + suffix) for suffix in ('.mp4', '.html')] + assert all(p.is_file() and not p.is_symlink() for p in sources) + assert digest(sources[0]) == args.sha256, 'uploaded video checksum mismatch' + assert not CONFIG.is_symlink() + original = CONFIG.read_text() + if MARKER not in original: + assert 'location ^~ /media/videos/' not in original + assert original.count(' location /state/ {') == 1 + for directory in [PUBLIC.parent, PUBLIC]: + assert not directory.is_symlink() + directory.mkdir(mode=0o755, exist_ok=True) + assert directory.resolve() == directory + for source in sources: + target = PUBLIC / source.name + assert not target.is_symlink() + if target.exists(): + assert digest(target) == digest(source), 'refusing to overwrite a different published file' + else: + shutil.copyfile(source, target) + target.chmod(0o644) + if MARKER not in original: + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + backup = CONFIG.with_name('juzi.public-video-' + stamp + '.bak') + assert not backup.exists() + shutil.copy2(CONFIG, backup) + CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1)) + try: + checked(['nginx', '-t']) + checked(['systemctl', 'reload', 'nginx']) + except Exception: + shutil.copy2(backup, CONFIG) + checked(['nginx', '-t']) + checked(['systemctl', 'reload', 'nginx']) + raise + print('nginx backup: ' + str(backup)) + else: + assert BLOCK in original, 'existing video location differs; inspect before changing' + checked(['nginx', '-t']) + print('video sha256: ' + digest(PUBLIC / sources[0].name)) + print('player: https://1me.xajuzi.com/media/videos/' + args.stem + '.html') + + +if __name__ == '__main__': + main() diff --git a/deploy/systemd/juzi-taobao.service b/deploy/systemd/juzi-taobao.service index cf825ab..cba6109 100644 --- a/deploy/systemd/juzi-taobao.service +++ b/deploy/systemd/juzi-taobao.service @@ -1,5 +1,5 @@ [Unit] -Description=1me taobao mini program backend (Node, /tb/ debug instance) +Description=1'me taobao mini program backend (Node, /tb/ debug instance) After=network.target [Service] diff --git a/deploy/taobao-voice-media.py b/deploy/taobao-voice-media.py new file mode 100644 index 0000000..16bde2a --- /dev/null +++ b/deploy/taobao-voice-media.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Serve public voice assets with MP3 MIME/range support; keep app services running. + +Run on the existing Ubuntu host using sudo. Only manifest-listed public files +are copied. Nginx config is backed up and rolled back if validation/reload fails. +""" +import json +from pathlib import Path +import re +import shutil +import subprocess + +SOURCE = Path('/home/ubuntu/juzi/h5/assets/voice') +PUBLIC = Path('/var/www/juzi-voice-20261102') +CONFIG = Path('/etc/nginx/sites-available/juzi') +BACKUP = CONFIG.with_name('juzi.voice-20261102.bak') +MARKER = '# BEGIN juzi-voice-media 20261102' +BLOCK = ''' # BEGIN juzi-voice-media 20261102 + location ^~ /state/assets/voice/ { + alias /var/www/juzi-voice-20261102/; + types { audio/mpeg mp3; application/json json; } + default_type application/octet-stream; + add_header Cache-Control "public, max-age=3600"; + limit_except GET HEAD { deny all; } + } + # END juzi-voice-media 20261102 + +''' + +def checked(command): + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode: + raise RuntimeError('command failed: ' + ' '.join(command) + '\n' + result.stderr) + +def main(): + manifest = json.loads((SOURCE / 'manifest.json').read_text()) + names = manifest['files'] + assert names and all(isinstance(name, str) and re.fullmatch(r'[a-z0-9-]+', name) for name in names) + files = [SOURCE / (name + '.mp3') for name in names] + [SOURCE / 'manifest.json'] + assert all(file.is_file() and not file.is_symlink() and file.resolve().parent == SOURCE.resolve() for file in files) + original = CONFIG.read_text() + if MARKER not in original: + assert not BACKUP.exists(), 'existing backup must be preserved' + assert original.count(' location /state/ {') == 1 + PUBLIC.mkdir(mode=0o755, parents=True, exist_ok=True) + assert PUBLIC.resolve() == Path('/var/www/juzi-voice-20261102') + for file in files: + target = PUBLIC / file.name + assert not target.is_symlink() + shutil.copyfile(file, target) + target.chmod(0o644) + if MARKER not in original: + shutil.copy2(CONFIG, BACKUP) + CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1)) + try: + checked(['nginx', '-t']) + checked(['systemctl', 'reload', 'nginx']) + except Exception: + shutil.copy2(BACKUP, CONFIG) + checked(['nginx', '-t']) + checked(['systemctl', 'reload', 'nginx']) + raise + else: + checked(['nginx', '-t']) + print(json.dumps({'publicFiles': len(files), 'nginxConfigValid': True, + 'backup': str(BACKUP), 'businessServicesRestarted': False})) + +if __name__ == '__main__': + main() diff --git a/docs/HANDOFF-2026-11-01.md b/docs/HANDOFF-2026-11-01.md index 60a902d..669a8f2 100644 --- a/docs/HANDOFF-2026-11-01.md +++ b/docs/HANDOFF-2026-11-01.md @@ -50,8 +50,8 @@ SAE `nnn` 上跑的是 `0.0.2` JAR,SAE 环境变量已清空,JAR 内置 `ver | 资源 | 值 | |---|---| -| 开放平台应用(当前正确) | `1me 肌肤状态检测`,AppKey **35486228** | -| 旧应用(不再使用) | `1me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 | +| 开放平台应用(当前正确) | `1'me 肌肤状态检测`,AppKey **35486228** | +| 旧应用(不再使用) | `1'me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 | | 聚石塔云应用 | `1me-net`,ID **62829**,默认应用;生产/测试 SLB 都是 `192.168.65.185:80` | | 聚石塔云应用(未用) | `app-new`,ID 62861;没接 SAE/CLB,别用 | | SAE 应用 | `nnn`,ID `7c9f2d2a-95d0-4879-afad-323d30f85d30` | @@ -148,7 +148,7 @@ Set-ExecutionPolicy -Scope Process Bypass ### 5.1 IDE 真机预览(第一优先级) -1. 确认 IDE 当前小程序是 **35486228**(`1me 肌肤状态检测`),不是 35479221。 +1. 确认 IDE 当前小程序是 **35486228**(`1'me 肌肤状态检测`),不是 35479221。 2. 重新编译。 3. 点顶部「预览」,手机淘宝扫码(**不要用模拟器**;`cloud.application.httpRequest` 模拟器不支持)。 4. 启动页右上角点「SAE MVP 连通性」。 diff --git a/docs/HANDOFF-2026-11-02.md b/docs/HANDOFF-2026-11-02.md index d2483cd..b8755aa 100644 --- a/docs/HANDOFF-2026-11-02.md +++ b/docs/HANDOFF-2026-11-02.md @@ -188,7 +188,7 @@ curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:80 ### 4.3 小程序 1. 淘宝 IDE 导入仓库里的 `taobao/`(`mini.project.json` 的 `appid` 留空,以 IDE 账号为准) -2. **确认 IDE 当前小程序是 35486228(`1me 肌肤状态检测`)** +2. **确认 IDE 当前小程序是 35486228(`1'me 肌肤状态检测`)** 3. `config.js` 保持 `transport: 'cloud'` / `cloudType: 'tower'` / `cloudEnv: 'test'` 4. 顶部「预览」→ **手机淘宝扫码**(模拟器不支持 `cloud.application.httpRequest`) 5. 启动页右上角有三个**开发版入口**(`environment !== 'production'` 时才显示): @@ -205,8 +205,8 @@ curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:80 | 资源 | 值 | |---|---| -| 开放平台应用 | `1me 肌肤状态检测`,AppKey **35486228** | -| 旧应用(废弃) | `1me肌肤检测` 35479221 —— **不要再用它的 Secret** | +| 开放平台应用 | `1'me 肌肤状态检测`,AppKey **35486228** | +| 旧应用(废弃) | `1'me肌肤检测` 35479221 —— **不要再用它的 Secret** | | 聚石塔云应用 | `1me-net`,ID **62829**,默认应用 | | 私网 CLB | `lb-k2j324dwux1svaf2akwqx` / `192.168.65.185:80` | | SAE 应用 | `nnn`,`cn-zhangjiakou:my-namespace` | diff --git a/docs/TAOBAO-ADAPTATION-2026-11-02.md b/docs/TAOBAO-ADAPTATION-2026-11-02.md new file mode 100644 index 0000000..abf7e2a --- /dev/null +++ b/docs/TAOBAO-ADAPTATION-2026-11-02.md @@ -0,0 +1,447 @@ +## 2026-11-04 未登录首页重复标题的动态清理 + +用户反馈未登录登录页出现两个标题。此前仅 JSON defaultTitle=零宽空格,尚未在生命周期调用 setNavigationBar,不能证明首页平台标题已在真机隐藏。本轮在公共页眉包装器的 onLoad/onShow/onReady 调用 my.setNavigationBar({title:零宽空格}),不修改导航背景及返回/胶囊;保留原页面生命周期、Promise 返回值与业务登录流程。回调用独立序号及可见性隔离,离页及旧回调不能更改现页标题状态。 + +登录页额外启用 nativeTitleFallback:若平台明确拒绝清理(fail、抛错或 Promise reject),隐藏自定义标题文字,保留平台原生标题,避免叠加;该降级情况下不能承诺原生标题居中。非登录页沿用现有居中标题。保留背景品牌图片和登录按钮。新增页面进入/返回时的原生清理、旧回调隔离、拒绝/异常不阻断业务测试,相关回归通过。真机是否实际接受标题清理仍待新预览验证,未将模拟器结果等同真机。 + +## 2026-11-04 全部业务页白色页眉标题水平居中 + +用户要求所有页面顶部标题左右居中。官方 app.json window 与 setNavigationBar 文档未提供标题对齐参数,未使用猜测的 titleAlign 等字段。改为保留原生透明导航及其返回/分享/关闭控件,将原生 defaultTitle 设置为零宽空格,使用公共 components/brand-header.axml + app.acss 显示 1'me肌肤状态检测;标题容器左右对称预留 76px,按全屏中心对齐,白底黑字。 + +utils/page-header.js 包装业务页注册,保留并调用原生命周期,onLoad/onResize 根据 statusBarHeight(或 safeArea.top)及 titleBarHeight 计算页眉安全区。六页正文/取景根节点避让页眉;档案表单高度收缩使用扣除页眉后的高度,相机初始蒙版尺寸同样扣除页眉,最终仍由已挂载 .cam 真实尺寸校正。分享文字和页眉标题共用同一常量,避免原生空标题影响分享。 + +完整回归及共享 H5 校验通过,新增实际模板/样式的 320/375/414 三屏宽、20/59px 状态栏检查,覆盖全屏中心、安全区及生命周期保留。淘宝 IDE 编译完成,模拟器实际登录页确认白色页眉标题居中、系统时间和原生右侧胶囊保留且不重叠。截图 deliverables/centered-header-20261104/login-centered.png 为真实 IDE 模拟器运行截图,明确非真机;手机刘海/动态岛安全区效果仍需实测。 + +官方参考:https://developer.alibaba.com/docs/doc.htm?articleId=117457&docType=1&treeId=785 + +## 2026-11-04 分享卡片大标题显式统一 + +用户确认不带撇号出现在分享卡片大标题。原 app.js 全局 onShareAppMessage 已返回 1'me肌肤状态检测,不能将问题简单归因为源码漏字或断言全局钩子不受支持。官方文档 articleId=118909 明确支持 Page 分享及 App 全局回退。为明确各页输出,六个业务页面均新增页面级 onShareAppMessage,共用 utils/share.js,title 和 desc 均为 1'me肌肤状态检测,path 固定 pages/login/login,不携带报告/照片/档案/会话信息;App 全局钩子也共用此函数。 + +138 项前端、会话商品、页面交互、拍照测试通过,包括逐页检查分享配置与登录页标题一致、分享对象无私人参数及每次返回独立对象。此次修改不涉及开放平台登记的小程序名称。真机分享卡片最终标题仍需从新预览版本重新分享验证,不能根据代码测试直接声称客户端已修好。 + +## 2026-11-04 倒计时锁定,不再因识别变化取消 + +用户反馈稍动仍取消倒计时,要求移除这项判断。此前 countdownReady 仍检查单脸、源帧边界、分数与新鲜度,本轮移除倒计时阶段的全部姿态/位置/清晰度/人脸数复查。正式 native-face 启动倒计时前保持原检测和稳定门槛;开始后暂停新推理,在途结果不能改变倒计时,到零补光结束直接 takePhoto,不再进入 nativeCaptureWait。离页、切换镜头、身份状态变化、相机停止/故障仍通过会话与序号取消拍摄。移除已不用的 countdownReady 元数据。 + +专项回归覆盖无新识别结果仍拍摄、角度/位置/置信度/人脸消失不打断倒计时、在途结果不取消,以及离页/切换/故障取消。完整测试及共享 H5 校验通过。H5/备用 AR 判定未改动;手机效果仍需扫码实测。 + +## 2026-11-04 推荐商品切换直播间链接 + +用户提供新链接 https://e.tb.cn/h.jhEroZP98ha4Res,短链页面中的目标 item.taobao.com/item.htm 明确返回 id=1090510453077。更新 config.productItemId 及 productItemIds 三档统一目标,商品为用户提供的“【李佳琦直播间】1'me以我自在有势挺括双仓精华抗皱”。保留推荐评分、文案与 tb.openDetail 交互。106 项会话/商品/拍照适配测试通过,覆盖三档相同商品、重复点击、返回报告及游客直接进入商品。实际手机商品详情打开效果尚未实测。 + +## 2026-11-03 两处侧脸缩略图填充缩小 10% + +用户要求轮廓/皱纹下面的两张小图脸部缩小。sideThumbnailStyle 的图片缩放由 cover*2.8 调整为 cover*2.52(原宽高的 90%),未读取尺寸时的 .side-img 兜底 transform 同步为 2.52;画框尺寸、纵向锚点及左右顺序不变。两处共用同一份 sides 数据及样式,原照片与皱纹标注同步缩放。大图仍为 cover*1.12、居中下移图片高度 5%,不受小图调整影响。64 项相关测试通过,缩略图覆盖横/竖/方照片与三个屏幕宽度,并验证相对原填充为 90%。 + +## 2026-11-03 侧脸大图缩小 20%,下移调整为 5% + +用户要求减小主体:轮廓/皱纹侧脸大图原填充尺寸宽高均乘 0.8(原 cover*1.4 改为 cover*1.12),保持居中,再下移缩放后图片高度的 5%。大图窗口大小不变,原照片和标注同步使用该矩形;缩略图及拍照流程不变。64 项相关测试通过,包含尺寸为原 80%、5% 下移、跨图片比例及屏幕宽度检查。 + +## 2026-11-03 两处侧脸大图统一居中后下移 10% + +用户要求皱纹页卡也采用居中裁切,并统一将大图填充区域向下移动图片高度的 10%。轮廓/皱纹侧脸大图共用 rectangle:left=(画框宽-缩放图片宽)/2,top=(画框高-缩放图片高)/2+缩放图片高*0.1。10% 取图片显示高度,不取画框高度;原照片和对应标注共用位置。保留倍率、缩略图和拍照逻辑。64 项相关测试通过,覆盖三种图片比例及三个屏幕宽度、实际 canvas 位移、标注对齐与回调隔离。 + +## 2026-11-03 轮廓侧脸放大改为图片居中 + +用户最新明确要求轮廓小图放大直接截图片中间,替代此前 H5 纵向偏移规则。轮廓大图使用 centerCrop,横纵位置均为(画框尺寸-缩放后图片尺寸)/2,取消向上部取样的偏移;保留原缩放倍率和等比展示。仅轮廓侧脸大图生效,缩略图、皱纹大图及拍照流程不变。新增横/竖/方图居中断言与实际轮廓 canvas 绘制中心验证,64 项报告相关回归通过。手机效果仍待重新编译后确认。 + +## 2026-11-03 侧脸放大改用独立位图 + +用户真机仍反馈轮廓侧脸大图只剩脑门和鼻子;此前浏览器几何一致不能证明淘宝原生组件显示一致,不能据此声称已修复真机问题。本轮按 H5 reportZoomSource 的绘制路径生成独立 JPEG:新增 utils/report-side-zoom.js 和报告专用离屏 canvas(与拍照 canvas 隔离),源照片/该侧标注使用相同矩形绘制;放大层只对生成图片 aspectFit,不再执行原生 overflow 图片裁切。读取尺寸优先 getImageInfo,回退原照片已知尺寸,导出图片的 onLoad 尺寸不能覆盖原图缓存。 + +生成期间或 API 不支持/导出失败,显示完整原照片 aspectFit,不再回退旧的激进原生裁切。导出有互斥、generation/账号/可见性守卫;旧结果不能重新打开大图。63 项相关测试通过,包括 H5 九种尺寸矩形对照、实际绘制命令与照片/标注对齐、关闭后的回调隔离。截图脚本改为执行实际 bake 模块绘制命令,输出仍明确为浏览器预览,不是真机截图。相机启动和识别本轮未修改;原生显示仍需用户重新编译后实测。 + +## 2026-11-03 倒计时后允许正常移动 + +用户要求取消倒计时后轻微移动导致无法拍摄的规则。正式 native-face 流程开始倒计时前仍按原角度/居中/距离与稳定帧判定;倒计时、补光、快门等待期间改用 countdownReady,只检查清晰的单人面部及其源画面边界,不再因角度、居中或距离偏离原阈值重新计时。新鲜帧 1.5 秒、性能故障停止、离页/切换镜头取消、账号隔离及原生拍照互斥均保留。 + +新增回归:启动后转头仍完成一次原生拍照、人脸消失取消;保留旧帧与在途识别等待测试。相机/质量/前端相关 97 项测试通过,共享 H5 文件校验通过。修改仅限小程序原生识别链路,未改共享 H5 判定或备用 AR 流程;手机实际效果仍待预览确认。 + +## 2026-11-03 扫描品牌黄与侧脸大图原生布局 + +用户确认相机目前恢复;本轮未修改识别、启动、角度或抓拍逻辑。实时扫描带 RGB 改为品牌黄 245/240/120,动画时序不变。 + +用户继续反馈侧脸大图裁切靠上。轮廓/皱纹大图改为最终屏幕尺寸的裁切窗口,图片和标注使用相同百分比定位,不再依赖小尺寸 overflow 容器整体 transform 放大。保留 H5 reportZoomSource 的 .5 fillScale 与 .25 contentDown 规则。新增浏览器几何对照覆盖 320/375/414 三宽及竖/横/方形照片九种组合,与 H5 drawImage 矩形误差小于 .15 CSS px;60 项相关测试及共享校验通过。浏览器截图已更新,淘宝真机裁切效果仍需核验,尚不能断言原生 transform 是唯一根因。 + +## 2026-11-03 侧脸小图点开后大图比例修正 + +用户反馈轮廓/皱纹侧脸大图扁平。检查发现原大图在 getImageInfo 不可用或失败时默认 3:4,而 image 使用 scaleToFill;当实际照片比例不同,会产生拉伸。实际手机是否命中此失败分支尚未采到日志,不据此断言全部真机根因。 + +现小图缓存自然宽高,放大优先复用;尺寸未知时显示 aspectFit,不再猜测 3:4,真实图片 onLoad 后补齐 H5 1.4 倍居中裁切及 .25 高度下移几何。大图基础照片始终等比;原照片与其标注层、左右顺序保持关联。尺寸回调带源图片及 zoom generation/账号校验,关闭或离页后不能重开。59 项相关测试及 H5 共享校验通过。 + +输出 deliverables/report-side-zoom-20261103/,以项目 contour-demo/left.jpg 和真实 AXML/ACSS/控制器数据生成浏览器投影,并用线上 reportZoomSource 函数生成 H5 对照;明确标记非淘宝真机截图。相机用户补充为恢复按钮常驻且点击也无法拍摄,需继续依据其错误提示定位,不能只归因于测试入口。 + +## 轮廓/皱纹侧脸小图裁切还原 + +已重新读取线上 `/state/css/ui-20261012.css` 和 `/state/js/app.js`。H5 两张侧脸缩略图使用居中 cover、translateY(-25%) scale(2.8),图框 104.35u×92.25u。此前小程序虽使用相同变换字符串,仍依赖原生 image 的 aspectFill/transform 执行效果,用户反馈实际不一致;尚不能确定具体原生差异机制。 + +现按照片自然尺寸显式计算 cover 后 2.8 倍的宽高,left=(208.7-imageWidth)/2,top=184.5/4-imageHeight/2(rpx),原生 image 使用 scaleToFill 和 transform:none。照片与侧脸皱纹图层共享同一 photoStyle;图片加载和 getImageInfo 均能更新自然尺寸,异步皱纹刷新保留已知几何,离页/旧照片/账号切换的回调不能写入。左右反向顺序、主图、放大图和拍照逻辑保持当前实现。 + +57 项相关测试通过,包含以真实 H5 CSS 在 Chromium 中计算 cover/transform 作为对照,320/375/414px 屏宽与竖图/横图/方图共 9 组显示几何误差均小于 0.15 CSS px(布局取整差异)。共享 H5 校验通过;淘宝真机实际效果仍待新版普通预览确认。 + +## 平台首屏性能检测(任务 20406447) + +用户提供版本 0.0.1、2026-11-02 19:53:33 的平台报告:华为 P30/Android,得分 85,明确不通过项为首屏渲染 4565ms(标准 <2000ms);包体约 1099KiB,RSS 67MB、vmSize 242MB、FPS/CPU 指标均在报告阈值内。仅凭总耗时无法确定唯一瓶颈,不能将相机识别或语音确定为根因。 + +本轮只优化首屏:登录背景、品牌和游客图标三张 H5 原图以原始字节复制到 taobao/assets/startup(总 131632 字节),登录改用本地相对路径;App.onLaunch 移除无首屏用途的语音播放器创建,voice.play 的现有按需创建逻辑保留。没有修改相机代码、页面样式、登录校验或云环境。公网图片本机抽测约 0.29–0.37 秒,不代表平台 P30 网络性能。 + +新增回归验证首屏素材与 H5 字节一致、启动不加载语音模块/创建播放器;完整 node test/*.test.cjs 及共享 H5 校验通过。需要重新上传新的开发版本并重跑平台性能检测,尚无优化后真实首屏成绩,不能声称已达到 2000ms。 + +## 语音真机定位续查(16:46) + +用户再次确认正式拍照仍无语音,拍照正常。IDE Console 仅存 VOICE-20261102-02 的 UNAVAILABLE/category=domain;不能据此断言域名白名单拒绝。VOICE-20261102-04 增加固定 operation 和受限 errorType,将 create、listen_ended/error/play/canplay、set_source、play 分开定位,不记录原始错误或 URL。补充 SDK appxDomain TypeError 回归测试;语音与拍照适配共 78 项测试通过。未修改相机、UI、交互;未声称声音已恢复。 + +IDE 已生成真机调试码版本 **0.1.2611021646.10**,待手机重新扫码进入正式拍照页,读取新版本错误日志后继续修复。服务器 MP3 MIME/Range 先前已修复,不再次修改云端设置。 + +# H5 → 淘宝小程序适配实施记录(2026-11-02) + +## 报告返回手势、顶部安全区及列表核对 + +按用户要求,报告详情页配置 `gestureBack:"NO"`,保留原生左上角返回;同时开启报告/列表二级页原生导航栏 `navigationBarForceEnable:true`、`transparentTitle:"none"`。报告内容顶部原 56rpx 增至 72rpx,并以系统安全区为下限;历史列表保留 H5 的 56rpx 顶距并增加安全区下限。页卡横向手势及页面纵向滚动保持,不用全页 touchmove 拦截来禁用返回。需真机确认宿主执行手势设置及标题位置。 + +列表页对照 H5 的 history-content、account-status-history、history-count、identity-note、history-item、history-detail、history-retry 源码,主要布局和尺寸一致;淘宝身份和游客仅本次查看提示为既定渠道差异。当前不以“听到咔嚓”认定引导语音通过。用户另反馈调试版相机停在“开启实时相机”,已询问点击后的结果,尚不能判为相机功能回归。 + +真机 Console 已出现 VOICE-20261102-02 的 error/category=domain。此分类来自筛选后的错误文字,不能单独断言平台域名白名单问题;版本 VOICE-20261102-03 进一步区分 APPX_DOMAIN_CONTEXT、DOMAIN_REJECTED、JS_CONTEXT_ERROR 等安全错误指纹,需后续日志确定。播放器发声仍待验证。 + +## 真机仍无声:音频原生就绪事件与资源服务修正 + +用户已确认正式流程仍完全无声,手机媒体音量和静音模式已排除;此前的代码单测不能代替真机发声确认。核对安装的淘宝 SDK,音频上下文具有 `onCanPlay/onCanplay` 与 `onPlay` 事件。按[淘宝购物小程序音频规范](https://developer.alibaba.com/docs/doc.htm?articleId=121184&docType=1&treeId=776),移除淘宝不支持的 `obeyMuteSwitch` 属性设置;监听可播放事件后再调用 play,两个大小写版本均兼容,重复/迟到的就绪事件不能重播旧音频。继续使用每条新实例和单通道队列。 + +新增 `VOICE-20261102-02` 开发日志,区分 loading、canplay、play_requested、playing、error,记录公开语音 ID 和经筛选的错误代码/类别;不打印错误原对象、URL、照片或账号信息。不增加业务 UI 控件。播放器诊断可通过 `voice.diagnostics()` 获取安全快照。 + +已确认原 HTTPS MP3 返回 `application/octet-stream`,Range 请求也返回整文件 200。部署 `deploy/taobao-voice-media.py` 修正仅 `/state/assets/voice/` 静态路径,公开目录 `/var/www/juzi-voice-20261102/` 仅包含清单内 14 个 MP3 和 manifest.json,Nginx 负责 audio/mpeg、206 与 Range。配置备份 `/etc/nginx/sites-available/juzi.voice-20261102.bak`;已通过 nginx -t 并 reload,H5/小红书/淘宝业务服务进程均未重启。14 条音频分段请求均返回 206 和正确类型,原主页 200、后台 401、数据路径 404,保护规则不变。源 server.js 同步补充 MP3/AAC MIME,未部署整份 H5 业务代码。 + +完整 244 项回归通过,共享规则校验通过;同时补齐了 WebGL 离线测试包中新增亮度取样模块的显式允许列表。IDE 正在生成本次代码的真机调试码,待手机扫码后核对原生播放事件与实际声音,不把这些改动表述成真机已修复。 + +## 主流程人脸亮度检测 + +检查发现 H5 的光线规则已复用,但此前普通相机识别结果不含亮度,正式调用也未传入 luma,因此只有固定“光线充足”引导,实时明暗提示未生效。现复用本地人脸识别已复制、转换好的小尺寸 RGB 帧,在首个人脸框内以 16×16 网格取样,使用 H5 的 .299R + .587G + .114B 均值;不新增 Canvas、Tensor、整帧复制或网络上传。 + +识别结果携带仅一个聚合 luma 值,质量适配器接入 H5 既有规则,侧脸放宽判定也保留此值:低于 110 提示“建议到光线明亮处拍摄”,高于 220 提示“光线过强,建议避开强光”,成功姿态时按 H5 附加“保持不动即可拍摄”。明暗均为建议,不作为拍摄否决条件;单人、完整取景、角度和新鲜度限制不变。缺失/无脸/无效输入保持未知,不伪装为亮度 0。只评估人脸画面明暗,不代表物理照度、逆光或光照均匀度检测。 + +96 项相关回归通过,覆盖不同背景、人脸 ROI、RGB 权重、通道格式归一、真实模型亮度输出、前/侧脸提示,以及过暗/过亮时仍可自动取图;需重编译预览后确认手机实际提示。此次不调整界面布局或已有语音文件。 + +## 正式拍照语音排查与恢复 + +用户反馈拍照没有语音。线上 step1-front、step2-rightface、countdown-321 三个 MP3 均可通过 HTTPS 读取,返回 200;手机实际无声原因仍需新版真机确认。代码检查发现普通相机及 AR 姿态结果未接入 `VOICE.hint`,现按 H5 映射补齐正对、左右转头、距离及保持不动提示;同提示不逐帧重复播放,换步/倒计时清理过期提示。正式三步引导、拍摄完成、分析与失败语音保留。倒计时先等步骤引导收尾,最长等待 3 秒后继续,保留三秒视觉计时与取图逻辑;等待期间继续识别真实帧,姿态失效取消等待,旧回调不重启拍摄,语音不可用时不阻断流程。 + +播放器不再每次先 stop 同一个实例再复用。改为单通道、每条播放结束或打断后释放实例,下一条重新创建;旧实例迟到的 ended/error 不可清除新语音队列,同步回调错误不再导致空对象异常。可写属性设置播放音量,支持时关闭遵循静音开关,平台不支持的属性失败不会阻断拍照。测试页继续遵守此前的静音、无上传规则,不把专项测试页当作正式语音验收。 + +新增播放器和主流程语音回归,验证无 stop-before-first-play、停止后不复用、迟到回调隔离、排队/去重、异常资源释放及正式转头提示触发;未调整 UI 或语音文件内容。 + +本轮完整 236 项回归及共享 H5 校验通过,IDE 自动编译完成;手机实际听音仍待重新编译预览后的正式流程确认。 + +## H5 排版与交互还原 + +已通过只读 HTTPS 获取线上 `/state/` 的 index.html、style.css、ui-20261012.css、swipe-track.js;统一 CRLF/LF 后与本地内容一致。以这些源码为基准修正此前遗漏,不改变评分、报告文案、照片处理或推荐规则。 + +- 报告三组详情卡原先仅渲染当前面板、只能点击标签。改为全部面板挂载的横向轨道,检测图区域支持跟手拖动、短滑回弹、快划切换、边界回弹及标签联动,竖向手势仍用于阅读页面;文字区不绑定横滑。滑动中保持卡片高度,结束后按当前面板实测高度调整;侧脸标注异步完成后重新测量,避免裁切文案。 +- 检测图和侧脸图新增与 H5 一样的点按放大/再次点击关闭。放大保留当前裁切、标记和图层;滑动释放不误触放大。离页、切换账号或缩放尺寸时取消未完成的手势/图片回调,避免私有图片重新弹出。 +- 档案肤质轮播从“松手才跳页”补为 H5 跟手轨道,箭头、圆点同步,取消/纵向/多指手势不误选。 +- 正常主页、登录和拍照页不再展示开发工具入口;需要专项验收时,开发环境显式启用 `cameraDebug:true` 可显示入口。主页游客状态恢复“游客模式”,去掉额外的游客操作按钮,保留 H5 的开始检测/我的报告;游客可从历史页进入淘宝登录,关闭小程序后不保存本次会话。报告页去掉 H5 没有的附加历史/结束体验按钮,历史入口统一从主页进入。 + +完整 227 项测试通过,新增 8 项针对手势、页卡高度、图层状态、放大与身份切换的行为回归。只读浏览器投影预览覆盖 320/375/414px 的主页、档案、报告布局,未见页面横向溢出,页卡宽高与当前面板吻合;预览脚本与截图保存在 `temp/check-h5-ui-layout.cjs`、`temp/h5-ui-restoration-20261102/`,使用项目测试样本,不能替代淘宝真机渲染/手势验收。共享 H5 校验通过。需重编译预览后核对真机横滑及放大体验。 + +## 正式主流程接入与回归 + +主流程沿用登录 → 启动页授权 → 档案 → 普通前置相机自动三连拍 → 压缩/上传 → 报告,入口为不带 debug 参数的 `/pages/camera/camera`。原页面与操作保持不变。首次进入、相机授权状态尚未确定时,自动衔接平台相机授权再开始取景;已明确拒绝时不反复请求,仍使用原权限提示与相册入口。 + +补充游客和淘宝账号两条多页面集成回归:运行实际登录、档案、拍照、上传客户端及报告页面代码,仅相机帧/JPEG、传输服务响应与平台 API 使用固定测试替身。验证游客不请求账号授权、不持久化档案/会话,正式账号保存自己的档案,普通业务三连拍完成后按 front/left/right 上传,只提交一个 request_id,渲染三组七项报告并打开 `1088839442533`;退出游客清除本次报告。完整 219 项回归通过,共享 H5 与本地模型产物校验通过;没有发起真实付费检测。云网关到真实服务和手机报告结果仍待验收。 + +## 当前状态:用户确认自动三连拍通过 + +用户已反馈“自动三连拍已经好了”,将最近交付的前置相机三步自动抓拍标记为当前手机真机通过;不推广为所有机型、后置或 AR 路线已通过。正式业务配置从临时上传优先恢复为 `photoInputMode:'camera'`,沿用同一普通相机识别与拍照实现;相册入口保留,页面布局和交互不变。正式业务离线回归使用实际配置验证三张照片只触发一次报告请求。 + +本轮再次确认远端 `juzi-taobao` 为 active,18081 `/api/health` 返回正常;这不代表小程序云网关已通。下一项仍是游客云服务访问、三图上传和手机真实报告,尚未完成端到端验收。 + +## 前置相机第二、第三张方向校准(LOCAL-FACE-20261102-09) + +用户真机确认第二、第三张的角度方向相反:提示向左转头时,实际接受的是向右转头。普通前置相机的本地姿态判定现传入 `mirrored:true`,翻转 yaw 符号,等价于对调两个侧脸步骤接受的原始角度方向。步骤仍是正脸/右脸/左脸,原 UI、转头提示、遮罩、照片标签及提交顺序不变;后置相机仍用原映射,AR 对照及共享 H5 文件不变。保留 `08` 的放宽阈值,不以取消方向检查来放行。 + +相关 71 项测试通过,覆盖前置左右方向互换、错误方向提示、后置方向保留、小角度自动三连拍及照片输出顺序。随后用户已确认自动三连拍可用。 + +## 性别选择控件对齐 H5 + +档案页原先使用系统 `picker mode=selector`,现改为页面内底部弹层的单列 `picker-view`,复用已有出生年月/地区弹层容器。对照 `h5/js/app.js` 的 `genderPicker` 与 `h5/css/style.css` 的 mobile-select 样式:标题“性别”,选项“女/男”,左右“取消/确定”,50px 标题栏、220px 滚轮、44px 淡黄色选中框、90% 内容宽度及安全区留白。触发框沿用原样,滚动仅更新草稿,确定后更新性别,取消不更改字段;再次打开定位已保存选项。出生年月与地区的原实现保持不变。需重新编译预览后确认手机实际观感。 + +## 最新真机反馈与角度调整(LOCAL-FACE-20261102-08) + +用户提供 `07` 真机诊断:WebGL 自测及预热通过,使用 `quad_strip`,本次识别约 70ms,已自动拍下一张;第二步仍为 `quality.reason=angle`。因此初始化及性能已在该设备本次测试通过,左右三连拍尚未完成。 + +按用户要求先放宽转头条件:仅在淘宝普通相机本地识别中,将侧脸 yaw 范围由 24–84° 放宽到 12–100°,侧脸 pitch 上限 29° → 40°、roll 上限 22° → 30°。只接受原单人、清晰度、完整取景和居中检查都通过后的侧脸角度失败;方向、正脸条件、450ms 稳定保持、倒计时及 1.5 秒新鲜度限制保留。共享 H5 文件、正式上传优先配置、页面布局与按钮交互不变。`08` 仍需真机确认能否完成三连拍。 + +## 最新约束:保持原有 UI 和交互,优先完成上传与报告 + +用户要求 UI 和交互与原来保持一致。本次撤回此前新增的上传面板、登录协议样式、动态按钮文案、入口版本标签和额外提示弹窗;恢复原相册/快门/切换镜头控制条、取景引导和步骤提示。保留接口修复及同步异常后解除按钮锁定的逻辑,不因排障新增业务页面或改变页面布局。 + +游客入口真机错误已确认为 `FAIL BIZ TARGET SERVICE_ERROR:.访问目标服务异常`。淘宝专用 Node 健康,最近日志未见对应新请求,仍需核对云应用、私网 CLB 和 SAE Java 转发服务;尚未确认云端根因,也未完成手机端报告验收。用户可通过此前提供的阿里云 SAE 控制台链接登录;代开浏览器的系统命令被自动权限检查拒绝,未绕过。 + +用户已调整优先级:实时抓拍继续保留,先验收三图上传闭环。`photoInputMode:'upload'` 在正式业务页跳过尚未通过真机验收的 GPU 初始化,沿用原相册图标选图、快门调用系统相机和三步引导,不显示独立上传面板。复用压缩、分片上传、同一 request_id 重试及报告跳转;游客身份与本次会话规则保持不变。开发版实时识别入口独立保留。 + +本轮联合测试 209 项通过;后续文案微调的相机回归 60 项通过,共享 H5 规则校验通过。已将匹配的 server.js、lib/*.js、js/*.js 更新到淘宝专用 `/home/ubuntu/juzi-tb/h5`,服务 `juzi-taobao`、端口 18081。备份为 `/home/ubuntu/juzi-tb-backup-upload-20261102.tar.gz`,数据目录 `/home/ubuntu/juzi-tb-data` 保留。发布检查曾因幂等标记查错文件触发自动回滚,修正后重新部署成功。健康检查、游客路由的同意门槛及 challenge 已在服务器通过;未调用真实付费检测,手机三图生成报告仍待验收。未部署 H5/小红书服务或统一后台。 + +手机验收:重新编译并预览 → 扫新码 → 正常游客体验或淘宝登录 → 开始检测并填写档案 → 依次选择三张照片 → 查看报告和商品跳转。此轮无需进入“相机测试”。以下相机版本记录为此前专项历史。 + +## 目标与基准 + +- 以 `https://1me.xajuzi.com/state/` 的页面、评分、七项报告、三视角拍摄和等待动画为基准。 +- 源码起点 `381da60`,工作分支 `codex/taobao-h5-adaptation`。已重新 fetch;远端当前与此起点相同。 +- 线上 HTML、评分及报告呈现文件已核对。本地 `app.js` 多了小红书渠道兼容,拍摄规则来自同一份 H5 源码。 +- 淘宝账号替代微信;保留游客体验,游客报告仅本次可见。三档推荐文案保留,全部跳商品 `1088839442533`。 +- 游客入口不需要淘宝账号登录/账号授权,不读取账号资料;仅在同意隐私告知后建立临时检测会话,相机权限在实际拍摄时申请。账号恢复不能阻塞游客按钮,迟到的旧账号结果不能覆盖游客流程。 +- 最新用户选择:颜色校验仅保留为可选开发工具,不作为拍照/导出/报告门槛。缺少格式且严格匹配四通道的帧使用应用默认 RGBA;平台明确格式优先。 +- **实时识别抓拍是首版验收要求。当前主线为普通前置相机;手机已识别人脸且姿态合格,CPU 优化版单帧从约 10.7 秒降至约 4.25 秒,仍未满足实时要求,首版尚未验收。正在验证原生 WebGL 加速候选。** + +## 平台测肤接入:新增前置核对(2026-11-02) + +已核对淘宝公开的[《肌肤测试》接入文档](https://developer.alibaba.com/docs/doc.htm?treeId=635&articleId=118965&docType=1)。页面更新于 2023-01-29,因此具体可用能力、申请入口和运营联系人仍需以目标应用后台及当前运营确认为准。 + +文档分别列出平台算法与自带算法两条路线。项目实际使用 Face++ 测肤服务及自有评分、报告逻辑,按“自带算法”路线准备;该路线仍有商家资质、ISV 调用资质、方案确认和测肤白名单流程。用户已反馈“尚未申请/目前不清楚”,当前按测肤白名单未申请、ISV 资质待确认处理;不能以已有 AppKey、可预览或普通相机可调用代替开通确认。 + +### 修正后的推进顺序 + +1. 核实天猫品牌/美容护肤主营类目、对应应用及 ISV 资质,确认自带算法测肤白名单进度。申请材料说明现有算法供应商、三视角采集、用途、数据流和游客仅本次查看规则;提交申请、联系运营由商家确认后执行。 +2. 获得该应用实际开放的拍照组件及接入契约,确认能否实时取景、获得人脸点位或识别结果、自动触发抓拍、连续采集三视角并返回可送检照片。**首版 H5 式实时识别抓拍目标不变**;若组件限制影响目标,先确认平台支持的实现方式,再确定正式相机方案。 +3. 增加拍摄前的品牌方人脸采集告知和明确授权,覆盖正式账号、游客及相机测试入口。当前登录页展示的是 Face++ 政策,不等于品牌方采脸弹窗。需要补齐品牌主体名称、完整面部图像用途、14 周岁承诺、肖像使用范围、实际第三方处理情况、品牌隐私链接及退出/删除联系方式;拒绝后不启动采集。官方模板的营销共享条款需按实际业务与当前审核口径确认,不能直接扩张实际共享用途。 +4. 在平台要求的测肤结果边界适配一级 `skinType`:0 干性、1 中性、2 混合偏干、3 混合偏油、4 油性。先确认回传接口和现有算法结果的语义,再加适配;不直接改 H5 历史结构,也不用问卷答案冒充算法检测结果。品牌七项报告继续沿用。 +5. 平台接入、采集授权与相机能力一起验收,再进行真实三连拍、报告、商品回跳及部署验收。 + +文档没有写明“所有普通相机调用一律禁止”,也没有证明原生 `ar-camera` 就是白名单开放的测肤拍照组件。目前的 AR 实现和测试页保留为技术验证;即使真机有帧、能导出 JPEG,也不能据此认定测肤业务已获平台准入。正式拍摄组件方案仍待上述信息确认。 + +### 对接说明草稿(尚未提交) + +业务为 1'me 以我品牌的淘宝小程序肌肤检测及产品推荐,拟采用自带算法接入:第三方 Face++ 提供检测能力,品牌系统负责评分、七项报告及推荐,算法服务不是品牌完全自研。参考 H5 为 `https://1me.xajuzi.com/state/`,首版要求页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,随后生成报告并跳转指定天猫商品。 + +拟使用平台对该应用开放的测肤拍照组件,保留 H5 的引导与报告规则。图像经品牌后端送算法服务;淘宝与 H5 的身份、报告存储隔离,管理后台可只读汇总。游客按当前实现仅在本次会话处理,品牌后端不将其照片和报告写入持久目录;这不代表已确认第三方算法服务的保留规则。正式账号的照片/报告保留及删除规则仍需确定,再同步到隐私告知。 + +商家需补充:应用名称及 AppID、品牌运营主体、店铺主体及主营类目、开发主体/ISV 信息、品牌隐私政策链接与用户联系渠道。申请时不附密钥或用户照片。 + +需向当前平台运营确认的问题: + +- Face++ 加品牌自有评分的接入分类、所需资质及当前申请流程是什么? +- 开通后提供哪个插件/组件、版本和技术文档?是否支持三视角实时识别抓拍、自定义引导、合适的图像导出和自带算法送检?若由组件内置识别抓拍,如何取得结果和处理取消/失败? +- 现有原生 `ar-camera` 能否用于该业务,还是必须使用另行开放的测肤组件?不能把两者视为同一权限。 +- 采脸授权的现行文案、品牌及第三方披露要求是什么?游客是否有额外接入规则? +- `skinType` 通过哪个接口/事件返回,是否要求同时回传其他结果字段? + +### 白名单申请的执行路径 + +优先由店铺负责人联系已有的天猫美容护肤行业运营,说明要申请“自带算法测肤拍照组件白名单”,请其确认当前测肤产品运营及接入要求。没有对接人时,可登录[淘宝开放平台支持中心](https://open.taobao.com/supportCenter)创建接入咨询工单,请求转接对应业务负责人;[官方支持中心说明](https://developer.alibaba.com/docs/doc.htm?articleId=849&docType=1&treeId=456)确认开发者可在线提交问题/需求。工单用于咨询和转接,正式白名单仍按业务运营给出的流程申请。 + +建议材料包括店铺链接与类目、品牌和开发主体、ISV 资质状态、目标应用名称及 AppID/AppKey、Face++ 与品牌评分的数据流、三视角实时抓拍流程、采集授权和隐私规则。先取得方案确认,再向当前运营指定的渠道提交白名单申请;通过后核对获批应用、组件/接口范围及技术文档。 + +公开测肤文档中的运营花名来自 2023 年,未公布邮箱,不推测联系地址。文档“申请插件”链接指向[能力中心](https://open.taobao.com/ability),本次未能验证登录后的申请按钮及能力 ID 63 的开放状态。也可从[文档关联提问入口](https://developer.alibaba.com/support/create_or_edit_problem.htm?docId=118965&docType=1)登录后咨询。 + +可发给运营/工单的说明: + +> 我们计划为 1'me 以我品牌接入淘宝小程序肌肤检测,使用第三方 Face++ 检测及品牌自有评分、报告,申请自带算法路线的测肤拍照组件白名单。首版需要页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,并支持默认前置和前后置切换。请协助确认商家及 ISV 资质要求、当前申请入口/材料和业务对接人,并提供对应组件文档。应用名称/AppID/AppKey、品牌主体及开发主体待补充。 + +## 已实施 + +1. 独立登录首屏、淘宝/游客两入口、协议、失败重试、正式账号签名恢复和宿主换号隔离。 +2. 游客临时 Bearer 仅内存保存;账号库、照片、原始结果、报告和侧脸缓存不落盘。商品页往返保留本次报告,主动结束或冷启动清理。 +3. 通过网关完整签名与单用 challenge 建立游客,不要求 open_id,不接受客户端自报身份。正式 `/me` 对照当次签名身份与旧 Bearer,避免宿主换号后继续显示旧报告。 +4. 上传和临时任务按 Bearer 会话隔离;稳定 request_id/photo_refs 复用原任务。队列满不消费照片,提交响应丢失重试不会重新收费;过期任务返回明确错误。 +5. 拍照蒙版从 H5 原始 SVG 轮廓生成本地 PNG,包含三方向定位线;图标、完成卡、扫描等待画面与 H5 对齐。`scripts/build-camera-assets.cjs` 可重建。 +6. 补齐原来缺失的 `compress-canvas`;补压缩超时、迟到回调隔离、上传/恢复阶段提示及错误重试。切后台后继续查询原任务。 +7. 推荐跳转统一固定商品 ID,防重复点击,提供缺 API/失败反馈;新报告、游客报告、历史报告的内存状态保持。 +8. 后台可按显式配置只读汇总 H5 与淘宝的数据源,支持渠道筛选、分页、统计、详情、照片、原始 JSON 和 CSV。外源重试禁止写入。详见 [后台部署说明](TAOBAO-ADMIN-CHANNELS.md)。 + +游客入口补充修复:登录页恢复旧账号期间允许直接选择游客;选择后立即失效旧账号恢复、清除旧 Bearer 和本地账号缓存。游客 challenge/guest-session 不携带旧账号 Bearer、不调用 `scope.userInfo` 授权;旧 `/me` 成功或 401、以及其迟到页面回调均不能重定向/覆盖游客流程,游客请求失败也不会自动恢复旧账号。用户未同意告知时仍保留原会话并提示同意,不提前切换身份。相关 API 时序与页面回归共 32 项通过。服务器游客接口仍需部署配套版本后才能手机验收。 + +## AR 实时相机 + +普通 `camera` 的实时帧监听在旧官方文档中标为暂不支持。本轮改用淘宝原生 `ar-camera`,模式 `faceDetection2D`,事件 `onARFrame` 返回人脸框及 106 点坐标。 + +### 默认前置与镜头切换(初始排查,最新映射见后续更新) + +用户反馈页内实时相机实际打开后置。原先的 `devicePosition: 'front'` 只绑定普通 `camera`,没有传给正在使用的 `ar-camera`;AR 分支的切换按钮也只弹出提示。现已向 AR 组件显式绑定驼峰属性 `devicePosition`,默认请求前置;正式拍照页右下角的翻转图标增加“切换镜头”文字,测试页也提供同名按钮。 + +点击切换会卸载旧组件,再按新方向挂载;取消旧倒计时、清除旧帧,并按相机会话标记丢弃旧组件的帧/停止/错误事件。已拍好的照片和当前拍摄步骤保留。人脸原始坐标仍沿用现有约定,不仅凭镜头方向推断坐标是否镜像。 + +本次镜头改动的相机、测试页及前端相关回归共 **28 项通过**,覆盖切换取消倒计时、旧组件事件隔离、已拍照片保留,以及测试页切镜头/重开时共享画布导出串行。这些是逻辑验证,不代表真实镜头已切换。 + +证据边界:淘宝缓存文档只描述按 `mode` 自动选择相机,没有列出方向参数。`devicePosition` 的明确字段及默认后置来自 [Taro 官方 Alipay 适配源码](https://github.com/NervJS/taro/blob/main/packages/taro-platform-alipay/src/components.ts#L155-L164),因此本轮是基于共享组件线索的兼容修正,**尚未证实淘宝宿主接受该参数**。没有调用未证实的 AR 切镜头方法,也没有直接用另一套 `` 替换识别与图像导出链。必须在手机确认实际镜头改变,不能以页面状态变化或本地测试通过代替验收。 + +用户随后重新编译预览反馈“默认仍是后置”,所以当前前置请求补丁未达到目标,镜头控制问题仍未解决;不把请求方向状态当作实际镜头状态,也不认定未开白名单就是该故障的原因。下一步需要目标应用实际可用组件的方向控制契约及真机能力确认。 + +### 继续排查:已复现并修复的本地问题 + +1. 正式拍摄页原先在切后台时解除拍摄忙状态,但旧原生画布导出还可能继续。已用持久 Promise 队列串行化 AR 导出和普通三图压缩;队列跨后台/恢复保留,旧照片不能恢复到新会话。测试实际延迟 `canvasPutImageData` 回调,验证不会并发写同一画布。 +2. 已复现补光后的 300ms 内相机报错/停止,定时器仍能采用旧帧进入下一步。现在会使未完成抓拍的序号失效、清除倒计时/补光/旧帧;已提交分析任务继续运行。 +3. 三张照片拍完后,相机原先仍在等待动画背后运行。现在进入报告处理时卸载实时组件并清空帧引用;分析失败时保持关闭,主动“重新拍摄”才恢复。 +4. 云 SDK 的 `init` 或请求 Promise 若不结束,原有轮询截止时间无法中断当前等待。传输层已新增独立超时:初始化 15 秒,每次云/本机请求 12 秒;迟到结果被忽略,初始化失败可重新建立。重试仍沿用原任务/request_id,不在超时处理里自动发起新检测。 + +上述是已复现逻辑缺陷的本地修复,不等于用户手机上的报告卡住根因全部确认,也未解决默认后置问题。 + +### 新的两组真机诊断 + +- 原 AR 测试页版本为 `AR-DIAG-20261102-01`,展示可读取的设备/淘宝/SDK 版本、实际 context 方法名、原生事件类型和白名单元信息。收到不符合标准结构的回调也会计数,避免将“有回调但解析失败”误认为没有帧。帧计数保存在 JS 中,页面与元信息扫描最多每 400ms 更新一次;点击“复制诊断”才写剪贴板,文字不含图像、landmarks 或身份。登录首屏入口已从左上角小字改为醒目的黄色“相机测试(开发版)”按钮,仍仅在开发环境显示。 +- 从该页新增“普通相机对照测试”,对应 `pages/camnative/camnative`。默认请求普通 `camera` 的前置,支持前后置重挂,按本机淘宝 SDK 实现调用 `onCameraFrame(callback)` 返回的 listener 的 `start/stop`。区分“方法存在”“start 成功”和“实际收到尺寸、字节非空的原始帧”;3/10 秒无帧给出明确状态。手动拍照仅作本页临时预览,无账号/上传/业务存储依赖。 +- 普通相机对照页只验证取景、原始帧与照片能力,**不作为实时人脸识别三连拍的替代方案**。有原始帧也不代表已有可用人脸关键点或已取得测肤业务准入。 + +### 淘宝专用后端只读核对 + +本次通过现有 SSH 授权只读连接 `ubuntu@1me.xajuzi.com`,确认 `juzi-taobao` 为 active,回环 `18081/api/health` 的 `ok=true`。读取 `/home/ubuntu/juzi-tb/h5` 四个业务文件的哈希/功能标记后,确认与本地版本不同:已部署路由没有 `guest-session`,会话模块没有本轮 `request_id` 幂等实现。没有读取凭据、用户照片或报告,也没有重启/部署/修改远端。 + +因此新版游客和完整幂等链路仍需匹配的 Node 版本才能进行手机验收。相机诊断页无需后端,可先独立验证。 + +本次代码完成后运行 **87 项自动测试全部通过**,共享 H5 规则校验通过;淘宝 IDE 再次本地编译完成,界面显示 0 错误、0 警告。没有调用真实 Face++ 检测。默认前置、真实切换和完整三连拍仍等待用户手机的两组诊断结果。 + +### 用户真机反馈与颜色校验(后续更新) + +用户已确认测试页为 AR 相机真机验收,计数持续增加,默认后置但按钮可以切换。其提供的旧测试页文字显示:720×1280、一张人脸、几何姿态判断通过,`capturedImage` 为 3,686,400 字节的 ArrayBuffer,未带像素格式;JPEG 导出报“相机原始图像格式尚未确认”。这个长度等于宽×高×4,可确认四通道长度匹配,但不能据此决定 R/B 或 alpha 的位置。默认方向和导出问题仍单独处理,不直接反转枚举值。 + +已修正两个有本机淘宝 SDK 源码依据的画布兼容问题:JPEG 结果兼容直接字符串及 `dataURL`/`dataUrl`/`base64` 外壳,验证 JPEG MIME、标准 Base64 与 JPEG 标记;像素写入优先使用淘宝 `ctx.putImageData`,保留探测到的 `my.canvasPutImageData` 兼容路径。四通道原始像素恰巧以 JPEG/PNG 标记字节开头时,不能误按压缩图片解码。 + +AR 测试页现为 **AR-DIAG-20261102-04**:顶部固定“复制诊断信息”和“导出照片”,复制失败仍展示可长按选择的脱敏文字。按用户后续要求,默认直接导出,不再要求做颜色校验。`config.arPixelFormat='RGBA'` 只补缺少格式且严格匹配四通道的图像;平台明确格式、开发页显式预览格式及当前运行内可选确认的格式优先,显式未知格式和不匹配的字节长度仍报错。应用默认值不是已确认的平台通道规范,诊断分别记录 default 与 confirmed。 + +颜色校验移至正文高级可选区域:明确点击才冻结一次原图,不复制 anchors,并串行生成最长边 480 的 RGBA/BGRA/ARGB/ABGR 预览。可选确认仅留在本次运行、仅用于同一图像字段路径,不写 Storage、不上传;不做校验或冷启动后也可使用应用默认值。正式导出保留最长边 1280,跨机型颜色与方向仍需后续验收。 + +用户进一步确认,切到实际自拍画面时页面显示“请求后置镜头”。据此将当前 AR 的 `config.arFrontPosition` 设为 `back`,测试页默认请求该值;正式页分开逻辑镜头 `devicePosition` 与 AR 请求 `arDevicePosition`,默认逻辑前置对应此请求值,切换后使用另一请求值。轮廓和扫描线的镜像继续按逻辑镜头计算;普通 `camera` 仍按其标准 front/back 属性控制。该配置来自当前真机反馈,新版本首次启动是否直接自拍及其他机型映射仍需验证,不能视为淘宝所有设备统一反向。 + +普通相机对照页同步补充固定复制入口和可选择文字后备,版本为 **CAM-NATIVE-20261102-02**。本次相关修改后 **126 项自动测试全部通过**,共享规则校验通过。仍需真机验证候选颜色、方向和 JPEG;未部署 Node 或调用真实 Face++。 + +默认导出与 AR 请求映射修改后,再次运行 **131 项自动测试全部通过**,共享 H5 规则校验通过。新增覆盖未校验直接导出、默认值不覆盖平台格式或可选确认、正式三连拍采用默认格式,以及反向 AR 请求不改变逻辑蒙版/普通相机。此次仍未部署 Node 或进行真实检测。 + +手机更新预览时,需在 IDE“预览”弹窗生成新的预览二维码,再扫码核对上述版本号;只重新编译本地模拟器不能当作手机已更新。用户此前提供的页面尚没有新版本号/复制按钮,不以该旧页面验收新入口。 + +点位转换使用官方图示的语义对应:轮廓取 0、2…32,眉鼻眼取 33…63,嘴取 84…103。H5 的头部姿态、距离、居中及稳定性判断原样同步到 `vendor/`,由哈希校验,未将未注明单位的原生 Euler 值当作角度。 + +`useCapturedImage` 只在官方文档中承诺返回图像 ArrayBuffer,未完整注明字段和像素格式。实现读取实际帧结构,支持可识别 JPEG/PNG 和明确标注的 RGBA/BGRA/ARGB/ABGR 四通道图像;按用户最新选择,缺少格式且严格匹配四通道的图像可使用应用默认值直接导出。显式未知格式或尺寸/字节不匹配仍明确报错。没有调用未记载的 AR `takePhoto` 方法。 + +### 真机验收步骤 + +后续手机反馈已确认:AR 反向请求映射仍未实现默认自拍;普通 `camera` 独立诊断则默认前置、可切换、可拍照出图,并且原始帧计数持续增加。因此继续验证“普通前置相机 + H5 本地人脸模型 + 原生 JPEG 自动三连拍”路线,不能把原 AR 请求映射标为修复成功。 + +用户反馈复制按钮与系统时间重叠后,两个诊断页的固定工具栏均新增顶部安全间距:读取可用状态栏/安全区/标题栏高度,最少留出 112px,正文同步下移。普通相机版号更新为 **CAM-NATIVE-20261102-03**,AR 页为 **AR-DIAG-20261102-05**。同时修正普通诊断页版号在 `Page()` 后才赋值导致初始页面可能不显示版号的问题。两页相关 39 项本地回归通过;真实手机点击位置仍待新预览验证。用户已要求跳过颜色校验,不再把此操作作为本轮测试步骤。 + +### 普通相机本地识别接线(最新实现) + +`config.captureMode` 已切为 `native-face` 开发候选:普通 `camera` 默认请求 front,单次实时帧推理后复用 H5 的姿态、居中、距离和稳定判断,再倒计时并用同一相机 `takePhoto` 获取 JPEG。正/右/左三张接入既有顺序压缩及单次任务提交;手机已提供普通帧宽高/字节数,实际本地识别、方向和耗时仍须手机验证。显式未知格式、额外行填充及旋转标记会报错,不静默改成手动拍照。 + +`utils/native-face.js` 使用从 H5 原有 face-api 和量化权重构建的本地 CPU 副本:无需 DOM、网络、Node 模块或 Face++。无 DOM 环境下以仓库本地参考图真实跑过检测及 68 点,专项 6 项通过;模型占 68 个 tensor、2,200,644 字节,最后一个引擎释放后回到 0。CPU 耗时受环境影响,本机直接 Node 连续参考帧约 369–552ms,隔离 VM 约 1s;均不代表手机表现。构建脚本去掉了库内会被 IDE 静态解析的 Node-only require 分支,保存原版权声明、许可与来源 SHA。 + +帧缓冲在返回宿主前同步缩采样为独立 RGB,避免原生复用缓冲改变待处理图像;并发推理前先跳过忙帧。倒计时末若检测未完成,最多等 1.5 秒复核新结果,仍从原始帧采集时刻判断新鲜度;连续 3 次识别超过 1.5 秒会明确停止。切后台、换镜头、相机异常及报告处理清理监听和倒计时,旧推理/旧快门回调不能推进新会话。 + +本轮最终 **163 项自动测试全部通过**,H5 共享规则逐字节校验及本地模型构建校验通过。没有调用真实检测或部署服务。手机仍需验收帧格式、速度、方向、自动三连拍及报告闭环;当前服务器缺少新版游客接口的部署事实不变。 + +后续用户提供 `AR-DIAG-20261102-05` 的 iPhone15,2/iOS 27.0.1/淘宝 10.68.10/SDK 2.9.4 诊断,`frameEvents=0`、context 未观测。该记录属于 AR 页,并非普通相机元信息;无帧不能推断能力不支持。为减少误入,登录与首页开发测试入口现直接进入普通相机,AR 页普通相机按钮移至固定工具栏。两测试模式用 `redirectTo` 切换,避免反复堆叠页面;原生拍照或 AR 导出进行中不允许切换。用户当前手机版本仍可使用正文旧“普通相机对照测试”入口,无需为取得元信息反复生成二维码。 + +### 普通相机真机帧已取得 + +用户随后复制 `CAM-NATIVE-20261102-03`:同一设备上 `requestedPosition=front`,`takePhoto/onCameraFrame/listener.start/stop` 均存在,监听启动成功,145 个回调均有有效原始字节。图像为 **540×960、2,073,600 字节**,等于宽×高×4,未报告 pixelFormat/format。这证明有紧密四通道长度的连续图像,不能仅凭长度认定通道排列;按用户要求沿用应用 RGBA 默认值,不要求颜色校验。`nativeReady=false` 只表示该事件未观测,正式流程不依赖此标记;当前这份诊断未发起手动拍照,照片能力仍来自此前用户真机反馈。 + +真实模型新增同尺寸竖屏回归:参考图等比居中放入 540×960,缺格式采用默认值,并在 detect 调用后立即复用清空原生缓冲。实际检测出 1 脸和 68 点,输出坐标合法,tensor 无增长;模型专项现 **7 项通过**。本机隔离环境约 937ms,仍不代表手机性能。 + +普通诊断页更新为 **CAM-NATIVE-20261102-04**,顶部增加“开始自动三连拍测试”。该入口使用独立 `debug=local-face` 开发模式复用正式相机的识别、稳定判定、倒计时、正/右/左自动抓拍和 JPEG 压缩,完成后仅本页临时预览,不要求账号/档案、不上传照片、不创建检测任务、不播放远程语音。已有手动 `debug=camera` 保持原来的本机直连开关。测试入口不等于正式报告验证;已有账号的应用全局前台恢复仍可能独立进行 `/me` 校验,因此不宣称整个应用全程零网络。 + +自动验收版本为 **LOCAL-FACE-20261102-01**,复制入口避开状态栏,包含安全的 stage/failureStage、模型耗时、人脸计数、拍照计数及独立 canIUse 信号。正式相机属性与已验证普通诊断页对齐为 `mode=normal`、`frame-size=medium`。新增隔离回归验证不调用账号 API/getApp/discardAnalysis,不改变其他正在进行的检测;旧模式开关和生产环境登录门槛不放宽。最终 **169 项自动测试全部通过**,共享 H5 规则和模型构建校验通过;没有真实检测或部署操作。 + +### 手机性能阻塞与后续优化 + +用户在 `LOCAL-FACE-20261102-01` 实测:`canIUse.camera/createCameraContext=yes`,6 次处理后在正脸阶段因 performance 停止;最后一帧 **10,734ms、1 脸、quality=ok**,源图仍为 540×960/2,073,600 字节。连续 3 个结果超过 1.5 秒后正确停止,照片计数 0。问题是当前识别链路耗时,不能以姿态合格认定抓拍可用,也不能把结果完成时间改作源帧时间或直接放宽门限。 + +新版诊断 `LOCAL-FACE-20261102-02` 分开记录原始回调、忙时跳过、实际检测次数及 snapshot/model/tensor/detector/landmark/total 耗时,区分配置请求值和引擎实际值,补运行时版本。正常推理期间的额外 100 个回调只跳过、不复制图像、不刷新 UI;桥接的大帧交付和 GC 仍需实测排查。 + +连续预览改用官方 [camera 的 small 帧尺寸](https://developer.alibaba.com/docs/doc.htm?articleId=120279&docType=1&treeId=776),降低原生到 JS 的图像传输量,原生正式拍照仍保留 high JPEG。轻量 68 点模型来自 [face-api 官方权重](https://github.com/justadudewhohacks/face-api.js/tree/master/weights),单独存于 `reference/native-face-models` 并记录来源/SHA,H5 原始模型不改。定位输入为 160、缩采样最长边保留 320;本地等比双脸图在 128 输入只检出 1 脸,故未用该更低尺寸作为默认值。 + +CPU 优化仅将旧 TFJS padding 的逐标量坐标换算改为连续 typed-array 块复制,数值运算顺序不变;1×1 卷积实验未取得稳定收益,未纳入发布候选。原 540×960 参考竖屏的同图隔离基准约 full256 934ms → tiny160+pad-copy 302ms(约 3.1 倍),模型张量内存约 2.20MB → 1.08MB。更低 maxSide 192 仅省约 24ms,未采用。上述是本地基准,手机的输入帧大小、计算时间及自动抓拍仍待 `LOCAL-FACE-20261102-02` 验收。 + +上一轮还完成 `temp/native-face-webgl-prototype.cjs` 离线原型:显式 WebGL1 注入可在屏蔽 DOM 的模块中通过矩阵乘法、卷积、读回及真实 68 点模型,数值自测确实执行 shader;没有网络请求。但这不证明淘宝手机原生 GL 已兼容。原型阶段尚未启用 enableSkia 或 GPU;在本轮 CPU 真机复验后,已继续实现下述 WebGL 候选。MNN 插件未接入。 + +本轮最终 **174 项自动测试全部通过**,共享 H5 规则与模型生成校验通过。独立复核额外对照 250 组 padding,覆盖 rank 1–5、空维、TypedArray 偏移和特殊常量;多模型失败恢复与最终释放均通过。源帧 1.5 秒新鲜度、连续 3 次慢结果停止、多人拒绝和调试页无上传规则保持不变。 + +### 原生 WebGL 候选(后续开发) + +用户在 `LOCAL-FACE-20261102-02` 复验:small 帧已实际降为 **360×640/921,600 字节**,tiny/160/pad-copy 均生效;处理 3 帧后仍停在 performance,最后一帧总耗时 **4,253ms**,其中 snapshot 8、model 29、tensor 0、detector 2,373、landmark 1,843ms。人脸及姿态合格,照片计数仍为 0。这证明减少传输量已生效,而本轮主要瓶颈在模型 CPU 计算,不能继续用同一 CPU 方案宣称实时通过。 + +下一候选 `LOCAL-FACE-20261102-03` 使用官方 [同层/离屏 Canvas](https://developer.alibaba.com/docs/doc.htm?articleId=118999&docType=1&treeId=776) 的原生 WebGL1 入口。按文档启用 `app.json.window.enableSkia:"true"`,配置明确请求 WebGL;不能仅以 API 存在或后端名称验收。初始化先做真实矩阵、卷积和 typed-array 读回数值自测,然后预热模型;数值错误、缺能力或上下文丢失时明确停止,不自动回到已证实过慢的 CPU。手机兼容性及速度仍待真机验证。 + +本机 SDK 的 legacy `createCanvasContext` 有原生 Canvas 兼容路由,保留现有 JPEG 压缩方式,不引入未证实的 2D 导出接口;文件读取结果补 JPEG 标记和标准 Base64 验证,防止把 PNG/坏数据标成 JPEG。真实手机仍需验证开启 Skia 后三张 JPEG 压缩、方向与覆盖层。 + +图形引擎的离线真实 WebGL 回归已验证:数值自测确实执行 shader 并读回;tiny/full 可共享同一后端;GPU 存活时拒绝切换 CPU;真实参考图输出 1 脸和 68 点;初始化缺浮点能力、读回错误及上下文失效均明确失败。挂起真实 Tensor 读回期间销毁 owner 不会提前删除资源,操作结束后旧结果被拒绝,模型及 Buffer/Texture/Framebuffer/Shader/Program/Renderbuffer 均释放;新会话可以重建。以上使用隔离 Chromium 软件 GL,网络请求为 0,不能当作淘宝手机支持或速度的证据。 + +相机在初始化时显示“正在校验图形加速并预热模型…”,模型就绪后才监听真实帧,初始化 30 秒未结束会停止并保留可复制诊断。GPU 错误会使原引擎退役,待其初始化/推理结束后才能重建,避免复用失效的缓存或在旧回调仍运行时删除资源。JPEG 的回调与 Promise 双通道都受同一完成标记保护,原生 binding 的 Promise 拒绝会被处理,不引发未处理拒绝。 + +本轮联合回归 **190 项全部通过**,其中 4 项使用真实隔离 WebGL,覆盖数值自测、模型、失败恢复、共享后端、挂起销毁、GPU 资源释放及重建。H5 共享规则与模型生成校验通过。没有上传手机照片、进行真实 Face++ 调用或部署后端;手机 GPU 自测、实时速度和三张 JPEG 闭环尚待版本 `03` 验收。 + +### WebGL 真机数值兼容(后续开发) + +用户在 `LOCAL-FACE-20261102-03` 返回:原生 Canvas/WebGL 上下文创建及后端注册已走通,矩阵自测执行了 4 次绘制,但严格数值校验失败,`MATMUL_FAILED`,没有开启真实帧监听或拍照。旧诊断把错误长度、非有限值和误差统一归此码;`pixelReads=0` 当时只在卷积成功后更新,**不能解读为完全没有像素读回**。当前不能认定手机支持可用 GPU 推理,也不能绕过自测继续抓拍。 + +离线兼容实验故意破坏 `readPixels(FLOAT)` 返回后,默认路径确实矩阵失败;关闭 `WEBGL_DOWNLOAD_FLOAT_ENABLED` 时,TFJS 1.7 的真实备用路径将浮点结果编码为 RGBA 字节并用 `UNSIGNED_BYTE` 读回,矩阵/卷积及完整 68 点模型可通过。同样可关闭 `WEBGL_PACK` 使用非打包 shader。依据为 [TFJS 1.7 官方后端实现](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/backend_webgl.ts) 与 [开关定义](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/flags_webgl.ts),未强造 HAS_WEBGL、WebGL 版本或浮点能力。 + +版本 `LOCAL-FACE-20261102-04` 按 `default` → `packed_byte`(仅禁用浮点下载)→ `unpacked_byte`(再禁用打包 shader)逐档执行原有严格合成矩阵/卷积/读回自测,每次失败先释放后端和图形对象,通过才加载模型及预热;三种均不通过时明确停止,不回退 CPU,不放宽 1.5 秒源帧新鲜度。新增诊断包括 profile、attemptCount、合成输入的长度、非有限值数、误差及校验和,以及 floatReads/byteReads。合成指标和兼容配置仅在初始化阶段缓存,后续模型检测返回不能覆盖;不复制照片或人脸坐标。 + +版本 `04` 的联合回归 **194 项全部通过**,含 6 项隔离真实 WebGL 测试:浮点读回损坏后进入字节读回、前两档失败后进入非打包字节读回、三档失败停止、两条兼容路径与 CPU 参考图的人脸框/置信度/68 点几何对照,以及每次换档前图形对象释放。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。这些本地结果不代表手机 GPU、速度或自动三连拍已通过。 + +### 原生 WebGL 读回检查(版本 05) + +用户在 `LOCAL-FACE-20261102-04` 真机复验:三档均 `MATMUL_FAILED`,最后 `unpacked_byte` 记录 5 次绘制、1 次 `UNSIGNED_BYTE` 读回,输出长度 256、非有限值 0、244 项不匹配、最大绝对误差 22、预期校验和 2786/实际 0。帧监听和照片计数均为 0。日志呈全零特征;聚合数据不能单独证明每个元素都为零,更不能据此确定是绘制、上传还是读回出错。此前代码在数值不匹配后即抛错,尚未检查该次 GL 错误,不能将“未报告 GL 错误”解读为“没有 GL 错误”。 + +版本 **LOCAL-FACE-20261102-05** 根据[淘宝官方 Canvas 注意事项](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)使用 `getContext('webgl',{enable_flip_y_after_read_pixels:false})`,关闭宿主默认附加的 flipY。隔离实验中,按较大 canvas 高度转换较小 framebuffer 的读取坐标可以产生全零且 `getError=0`;这说明该路径值得验证,**尚未证实手机内部存在这种坐标转换**。 + +TF 初始化前增加 2×2 RGBA8 清屏/读回、非对称 shader、字节纹理及浮点纹理上传采样检查,使用固定非零合成输入和预填目标哨兵值,区分未写回 TypedArray、错误像素、shader 编译/链接、纹理上传及 framebuffer 问题。基础检查失败即停止并释放资源;通过后仍需通过原有严格矩阵/卷积数值自测、兼容档选择及模型预热。没有降低数值阈值、放宽 1.5 秒新鲜度或自动回到 CPU。 + +新增诊断保留 `nativeProbe` 阶段与规范化错误、各基础检查结果、哨兵改写数、不匹配数、预期/实际校验和、读回返回值类型枚举、GL 错误和 framebuffer 状态。TF 自测在数值判定前采集 GL 状态;这些固定合成数据只在初始化时缓存,实际人脸检测不能覆盖,不复制照片、像素或人脸坐标。手机兼容性、根因、速度及自动三连拍仍待版本 `05` 验收。 + +本轮 `05` 联合回归 **199 项全部通过**,含 8 项隔离真实 WebGL 测试。新增测试验证默认坐标转换模拟的全零现象、关闭翻转后的非对称小 FBO 及完整人脸模型,并覆盖未修改读回出参、仅返回 ArrayBuffer/TypedArray/Promise、编译/链接返回 0、顶点上传、字节/浮点纹理故障共 9 类原生预检失败。失败时未注册 TF 后端、未加载模型,各类 GPU 对象释放。共享 H5 规则及模型构建校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。实际手机结果仍待确认。 + +### 原生绘制与兼容选路(版本 06) + +用户在 `LOCAL-FACE-20261102-05` 返回:原生清屏读回通过,非对称 shader 检查失败,`changedBytes=16`、3 项不匹配、预期校验和 2244/实际 2040,`getError=0`、FBO 状态 36053(完整)。实际校验和与原清屏颜色的 2040 相同。离线真实 GL 中,停止索引绘制、剔除所有三角形或把顶点上传清零均可复现这一组指标,因此它不能唯一确定绘制未覆盖、坐标、缓冲区上传或 GL 状态中的哪一项是手机根因。帧数及自动照片仍为 0。 + +版本 **LOCAL-FACE-20261102-06** 显式规范化 GL 绘制状态及颜色写入,在每次绘制前清黑以区分旧背景和实际绘制。先以不依赖 `gl_FragCoord` 的恒色 shader 分别验证 `drawArrays` 与索引绘制,再执行原非对称 shader、字节/浮点纹理检查。新增 `constantArrays`/`constantIndexed` 结果、可选顶点/索引缓冲区字节数及固定合成的四个 RGBA uint32 像素值;缓冲区元数据查询不额外作为硬门槛。 + +原生兼容策略按 `standard` → `buffer_copy`(将上传视图的字节紧密拷为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅为已绑定 FBO 补偿 viewport/scissor Y)→ `combined` 自测选择。仅原生 `VALUE_MISMATCH` 允许尝试下一路,其余错误明确停止;不能按手机型号认定某路可用。四路在同一个上下文上进行,每次检查结束清理本路图形对象;后续 TF 的 `default`/`packed_byte`/`unpacked_byte` 仍最多创建三个上下文。任何被选中的原生策略都须通过全部原生检查及后续原有严格矩阵/卷积数值校验和真实模型预热,保留 1.5 秒源帧新鲜度,不回退 CPU。 + +诊断同时保留 `nativeInitialStage`/`nativeInitialCode` 与最终 `nativeStrategy`,避免兼容通过后丢失最初失败信息。四像素、缓冲区及分层结果均来自固定合成输入,只在初始化时读取缓存,实际人脸检测不能覆盖。实际手机的选路、兼容性、速度及完整自动三连拍仍待版本 `06` 验收。 + +本轮 `06` 联合回归 **204 项全部通过**,其中 9 项为隔离真实 WebGL 测试。四种原生策略分别在第 1/2/3/4 次选中;模拟继承绘制状态、typed view 上传失效、viewport/scissor 坐标转换及组合情况后,实际模型和 CPU 人脸框/置信度/68 点几何对照通过。紧密 ArrayBuffer 拷贝保留子视图偏移和长度;索引绘制不执行时明确停在 `constant_indexed`,不会因 `drawArrays` 通过而准入。10 类原生故障保持注册后端/加载模型前停止,所有张量和跟踪 GPU 对象最终为 0。共享 H5 规则、模型生成及差异格式校验通过;淘宝 IDE 本地重新编译完成(DevServer: Done)。上述不能替代版本 `06` 的手机验收。 + +### 固定四边形的索引兼容(当前候选 07) + +用户在 `06` 返回 `nativeInitialStage=constant_indexed`、`VALUE_MISMATCH`,由执行顺序可知标准路径清屏和普通恒色绘制已通过。索引缓冲为 12 字节,但长度不能证明其内容和原生索引执行正确。最后 `buffer_copy_framebuffer_y` 的 `constant_arrays` 黑色失败属于后续候选,不能覆盖初始故障。真实帧数、照片仍为 0。 + +`LOCAL-FACE-20261102-07` 在每次原生索引绘制前显式重新绑定索引缓冲。若标准普通绘制通过、索引数值失败,则在相同 native 策略下验证四顶点 `TRIANGLE_STRIP`,保持原非对称像素、字节/浮点纹理和矩阵/卷积严格自测及真实模型预热。依据固定四点与 `[0,1,2,2,1,3]` 的[上游 TFJS 数据](https://raw.githubusercontent.com/tensorflow/tfjs/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/gpgpu_util.ts),第二个 strip 三角形与原第二三角形同为 `(2,1,3)`;这只适用于自有固定 quad。 + +实际转换同时要求精确的 `TRIANGLES/6/UNSIGNED_SHORT/offset0` 参数,以及已追踪上传的 12 字节固定索引内容。视图只读取其字节范围;未知缓冲、不同索引、数字分配、部分更新或删除后不再准入,错误为 `DRAW_UNSUPPORTED`。诊断明确 `drawStrategy=indexed/quad_strip` 和实际 strip 绘制计数;替代路径的 `constantIndexed` 保持 `not_run`,另以 `constantQuad` 表示替代输出,不宣称原生索引接口通过。原生自测最多 8 次且共用一个上下文,逐路释放检查对象;1500ms 新鲜度和不回退 CPU 的规则不变。手机是否能完成初始化、速度及三连拍仍待 `07` 验收。 + +本轮 `07` 联合回归 **207 项全部通过**,其中 11 项为隔离真实 WebGL 测试。模拟原生索引绘制完全无效后,标准原生策略在第 2 次原生检查选中 `quad_strip`,原矩阵/卷积、模型预热、实际人脸及 68 点与 CPU 几何对照通过。另验证 10 类错误调用/索引/缓冲状态均不会发出替代绘制,正确的 typed-view 字节边界仍能识别;资源和张量最终为 0。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。手机版本 `07` 尚未验收。 + +1. 在电脑淘宝 IDE 顶部点“预览”,等待预览弹窗打开。 +2. 在弹窗内点橙色“重新编译并预览”,等待生成新二维码;仅本地重新编译不会更新旧的手机预览。 +3. 手机打开淘宝,使用“扫一扫”扫描这个新二维码,进入本次小程序预览。 +4. 在手机登录首屏点黄色“相机测试(开发版)”;已进入首页时在开发工具区点同名入口,进入普通相机诊断。 +5. 在“普通 camera · 独立诊断”确认版号 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”。 +6. 进入后确认版号为 `LOCAL-FACE-20261102-07`。若出现“开启实时相机”,点一次,并由用户处理系统相机权限。先等待基础图形检查、数值校验和模型预热,约 30 秒内无需重复点击。 +7. 将正脸居中并保持不动,等待自动倒计时及第一张拍摄;随后按页面提示分别露出右脸、左脸,完成三张自动拍摄和压缩。 +8. 卡住、报错或完成后,点页面上方的“复制识别诊断”,回到聊天粘贴文字;无需发送照片或校验颜色。后续再验证镜头切换、方向、切后台恢复及拒绝授权。 +9. 相机路线通过后,在配套服务版本下验收稳定识别 → 3 秒倒计时 → 三步完成 → 报告,分别测试游客和淘宝账号。再次核对左右脸语义与图层坐标。 + +## 已运行的验证 + +- 淘宝前后端、游客无落盘、幂等、账号隔离、AR 几何、相机生命周期及后台汇总共 **61 项本地自动测试全部通过**;共享 H5 评分/报告/拍摄规则哈希一致。 +- H5 四组浏览器回归:游客不留存、HTTPS 微信登录/退出/跨设备、报告/后台/CSV、异步侧脸图层。 +- Java Maven `clean package`,11 项测试通过;转发层原样支持新增路径,无需新增 Java 业务接口。 +- IDE 曾发生桌面进程崩溃;已发现 Crashpad 转储,JavaScript 日志未记录对应原因。重新打开后再次编译成功(0 错误、0 警告),未新增转储;尚未确定首次闪退根因,不直接归为 AR/GPU。 + +## 部署顺序与边界 + +淘宝专用 Node 已按上方“最新目标”记录备份并更新游客、签名恢复和幂等代码;H5 及小红书服务尚未更新。本轮未迁移或删除业务数据,手机完整报告及真实网关仍待验收。 + +1. 先完成平台测肤资质/白名单及开放拍照组件核对,补齐采集授权;在平台支持的组件方案上通过实时识别、图像导出和三视角抓拍验收。现有 AR 验证只是其中一项技术验证。 +2. 部署淘宝专用 Node,保留独立数据目录;验证新登录/游客/三图报告,不向 H5 生产数据目录写入。 +3. 发布含本轮后台改动的 H5 管理服务,并明确配置只读 `JUZI_ADMIN_TAOBAO_DATA_ROOT`,保留原 nginx 管理员认证及 `/tb/` 网关入口限制。 +4. 运行大报告/历史详情的真实网关与低内存手机测试。前台最近 5 份只是索引规则,后台照片和快照尚无自动清理;保留周期、备份、删除另行确定,不能直接合并两个文件目录。 +5. 验收正式/测试环境隔离、资源域名、商品详情能力、新老报告及游客回跳;关闭开发入口。按现有交接要求处理测试凭据后再正式发布。 + +### 明确未完成 + +- 平台自带算法路线的商家/ISV 资质和白名单状态确认、正式拍照组件契约、品牌采脸授权及平台结果字段适配。 +- 普通相机在手机上的本地模型耗时、转头方向、覆盖层和完整自动三连拍。AR 保留为可选对照,其默认自拍和图像导出仍未验收。 +- 手机上的新游客接口和原系统相机报告故障最终复验(本地回归已覆盖,线上尚未更新)。 +- 固定商品真机跳转、真实网关大响应及后台跨源上线配置。 +- 照片保留/清理周期、数据库/对象存储迁移;本轮没有自动删除旧数据。 diff --git a/docs/TAOBAO-ADMIN-CHANNELS.md b/docs/TAOBAO-ADMIN-CHANNELS.md new file mode 100644 index 0000000..243fe22 --- /dev/null +++ b/docs/TAOBAO-ADMIN-CHANNELS.md @@ -0,0 +1,40 @@ +# H5 / 淘宝统一后台:数据源与部署 + +统一后台增加“全部渠道 / H5 / 淘宝小程序”筛选,覆盖记录、统计、失败记录及 CSV 导出。用户账号、照片、报告仍由各自业务服务保存,不做账号合并,也不改变 H5 用户历史。 + +## 默认行为 + +未配置额外数据源时,后台继续读取当前服务的 `JUZI_DATA_ROOT`,现有 CSV 列顺序和末尾“渠道”保持不变。新接口返回数据来源标识,不公开服务器目录。 + +## 接入淘宝只读数据源 + +在承载管理后台的 **H5 服务**环境中配置绝对路径,例如: + +```ini +JUZI_ADMIN_TAOBAO_DATA_ROOT=/home/ubuntu/juzi-tb-data +``` + +需要部署本次修改的 `h5/server.js`、`h5/lib/admin-sources.js`、`h5/admin.html` 和 `h5/js/admin.js`,然后按原部署流程重启该 H5 服务。淘宝业务服务继续使用原数据目录。管理后台进程需要拥有该目录的只读权限;两个服务不得共用同一可写 JSON 目录。 + +该配置只增加管理读取,不修改淘宝账号库或检测文件,不迁移、不删除历史数据。配置的额外目录必须存在,与当前数据目录独立,并位于 H5 静态应用目录之外;配置错误时服务启动会明确失败。 + +后台仍经原有 nginx 管理员认证入口访问,保持业务服务监听回环地址;不要为汇总功能放开 `/tb/` 网关白名单或取消后台认证。小红书隔离实例不接入此额外数据源。 + +## 路由与数据口径 + +- 每条汇总记录包含 `source`、`admin_key` 和只读状态。两个数据源即使有相同记录 ID,也能分别打开正确详情、照片和原始 JSON。 +- 详情和媒体请求通过后台已有路径的 `source=local` 或 `source=taobao` 选取已配置的数据源;HTTP 请求不能指定任意文件目录。 +- 全局列表先合并再筛选、稳定排序和分页。CSV 沿用所有记录筛选条件,导出日期可另选;接入额外数据源后,仅在原有列末尾追加“数据来源、记录唯一键”。 +- 按渠道统计检测记录及拍照流程;游客不留存的检测不会伪装成已保存报告。拍照漏斗是匿名客户端事件,不能视为人数或计费次数。淘宝尚未上报漏斗事件时显示零事件,不据此推断淘宝无检测。 +- 外部数据源为只读:详情和失败页关闭重试按钮,服务端也拒绝外源重试。重试必须在原服务后台进行,避免写入错误渠道。 +- “用户最近 5 份报告”是用户历史展示范围,不是后台磁盘保留期限。本次不会删除旧照片、原始结果、报告快照或检测记录;清理与备份周期需另行约定。 + +## 验证 + +```powershell +node --test taobao/test/admin-channels.test.cjs +$env:JUZI_ADMIN_BROWSER_QA = '1' +node --test taobao/test/admin-channels.test.cjs +``` + +测试使用独立临时数据目录和伪造图片,不请求 Face++,也不访问或修改线上数据。浏览器验证截图位于 `temp/taobao-adaptation/admin-channels.png`。 diff --git a/feedback/1meAI测肤旷视UI问题反馈/1meAI测肤旷视UI问题反馈.md b/feedback/1meAI测肤旷视UI问题反馈/1meAI测肤旷视UI问题反馈.md index 877c71e..0f722a6 100644 --- a/feedback/1meAI测肤旷视UI问题反馈/1meAI测肤旷视UI问题反馈.md +++ b/feedback/1meAI测肤旷视UI问题反馈/1meAI测肤旷视UI问题反馈.md @@ -1,4 +1,4 @@ -# 1meAI测肤旷视UI问题反馈 +# 1'meAI测肤旷视UI问题反馈 ## UI交互界面调整 diff --git a/h5/admin.html b/h5/admin.html index 99a9a7f..94c9b7a 100644 --- a/h5/admin.html +++ b/h5/admin.html @@ -17,6 +17,12 @@
+
+ + 正在读取渠道数据… +
@@ -71,7 +77,7 @@ - + @@ -92,7 +98,7 @@
照片记录ID年龄/性别/地区/肤质拍摄状态上传状态照片记录ID / 来源年龄/性别/地区/肤质拍摄状态上传状态 检测状态综合得分肌龄(原始 / 展示)三维度评分 / 档位推荐精华耗时创建时间
- + @@ -103,6 +109,7 @@
+

全部渠道检测概览

此表展示全部渠道对照,上方指标按当前渠道筛选。按检测记录统计,不是跨渠道去重人数;游客临时报告不进入已保存检测记录统计。

拍照 → 报告页转化(上线后新流程)

@@ -128,7 +135,7 @@ - 日期及导出时间均为北京时间;UTF-8 BOM,Excel 直接打开;不含接口原始 JSON + 沿用当前渠道及记录筛选条件;导出日期可另选。北京时间,UTF-8 BOM,不含接口原始 JSON。
diff --git a/h5/js/admin.js b/h5/js/admin.js index db99681..8f58275 100644 --- a/h5/js/admin.js +++ b/h5/js/admin.js @@ -49,6 +49,27 @@ function api(url, opts) { }); }); } +function channelName(channel) { return ({ h5: 'H5', taobao: '淘宝小程序', rednote: '小红书' })[channel] || channel || 'H5'; } +function selectedChannelQuery(url) { + var channel = $('#f-channel').value; + return channel ? url + (url.indexOf('?') < 0 ? '?' : '&') + 'channel=' + encodeURIComponent(channel) : url; +} +function sourceQuery(source) { return '?source=' + encodeURIComponent(source || 'local'); } +function renderSources(data) { + var sources = data.sources || []; + $('#channel-summary').textContent = sources.length > 1 + ? '已汇总 H5 与淘宝数据;外部渠道记录只读,重试请进入原服务后台。' + : '当前读取本服务的数据;尚未接入其他服务的数据源。'; +} +function renderChannelStats(data) { + renderSources(data); + var rows = data.by_channel || {}, html = '
记录ID失败环节失败码失败信息年龄拍摄状态创建时间操作记录ID / 来源失败环节失败码失败信息年龄拍摄状态创建时间操作
'; + Object.keys(rows).forEach(function (key) { + var row = rows[key]; + html += ''; + }); + $('#channel-stats').innerHTML = html + '
渠道累计检测今日检测成功失败
' + esc(channelName(key)) + '' + row.total + '' + row.today_total + '' + row.success_total + '' + row.fail_total + '
'; +} /* ─── 页签切换 ─── */ var TABS = { records: 1, fails: 1, stats: 1 }; @@ -116,6 +137,7 @@ function collectFilters() { add('from', $('#f-from').value); add('to', $('#f-to').value); add('detect_status', $('#f-status').value); + add('channel', $('#f-channel').value); add('fail_code', $('#f-failcode').value); add('age_min', $('#f-agemin').value); add('age_max', $('#f-agemax').value); @@ -130,6 +152,7 @@ function collectFilters() { function loadRecords() { api('/api/admin/records?' + collectFilters()).then(function (data) { + renderSources(data); pageState.total = data.total; var tbody = $('#tbl-records tbody'); tbody.innerHTML = ''; @@ -145,7 +168,7 @@ function loadRecords() { : '无'; tr.innerHTML = '' + thumb + '' + - '' + esc(it.record_id) + (it.retry_of ? '
重试自 ' + esc(it.retry_of) + '
' : '') + '' + + '' + esc(it.record_id) + '
' + esc(channelName(it.channel)) + (it.readonly ? ' · 只读' : '') + '
' + (it.retry_of ? '
重试自 ' + esc(it.retry_of) + '
' : '') + '' + '' + profileHtml(it) + '' + '' + esc(it.shoot_status) + captureQualityHtml(it) + '' + '' + esc(it.upload_status) + '' + @@ -159,7 +182,7 @@ function loadRecords() { '' + fmtTime(it.created_at) + ''; tr.addEventListener('click', function (e) { if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; } - openDetail(it.record_id); + openDetail(it.record_id, it.source); }); tbody.appendChild(tr); })(data.items[i]); @@ -175,6 +198,7 @@ function loadRecords() { function renderCaptureFunnel(st) { var funnel = st.capture_funnel; var panel = $('#capture-funnel'); + if (panel) panel.parentNode.hidden = !funnel; if (funnel && panel) { var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']]; var html = ''; @@ -190,7 +214,8 @@ function renderCaptureFunnel(st) { } } function loadFails() { - api('/api/admin/stats').then(function (st) { + api(selectedChannelQuery('/api/admin/stats')).then(function (st) { + renderSources(st); var box = $('#fail-dist'); box.innerHTML = ''; if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录')); @@ -205,7 +230,7 @@ function loadFails() { } }).catch(function (e) { toast('加载失败:' + e.message); }); - api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) { + api(selectedChannelQuery('/api/admin/records?detect_status=失败&page_size=100')).then(function (data) { var tbody = $('#tbl-fails tbody'); tbody.innerHTML = ''; if (!data.items.length) { @@ -215,17 +240,17 @@ function loadFails() { (function (it) { var tr = el('tr'); tr.innerHTML = - '' + + '' + '' + '' + '' + '' + '' + '' + - ''; + ''; tr.addEventListener('click', function (e) { - if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; } - openDetail(it.record_id); + if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id, it.source); return; } + openDetail(it.record_id, it.source); }); tbody.appendChild(tr); })(data.items[i]); @@ -233,10 +258,10 @@ function loadFails() { }).catch(function (e) { toast('加载失败:' + e.message); }); } -function doRetry(id) { +function doRetry(id, source) { if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return; toast('重试中,请稍候(最长约 1 分钟)…'); - api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) { + api('/api/admin/records/' + encodeURIComponent(id) + '/retry' + sourceQuery(source), { method: 'POST' }).then(function (data) { toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')'); var active = $('.panel.on').id; if (active === 'tab-records') loadRecords(); @@ -247,7 +272,8 @@ function doRetry(id) { /* ═══════════ 统计概览 ═══════════ */ var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026']; function loadStats() { - api('/api/admin/stats').then(function (st) { + api(selectedChannelQuery('/api/admin/stats')).then(function (st) { + renderChannelStats(st); renderCaptureFunnel(st); var cards = [ ['今日检测', st.today_total], @@ -332,18 +358,23 @@ function loadStats() { /* ═══════════ 记录详情(抽屉) ═══════════ */ var currentDetailId = null; +var currentDetailSource = 'local'; +var detailSeq = 0; -function openDetail(id) { +function openDetail(id, source) { + var seq = ++detailSeq; currentDetailId = id; + currentDetailSource = source || 'local'; $('#d-title').textContent = '记录详情 ' + id; $('#btn-retry').disabled = true; $('#d-body').innerHTML = '
加载中…
'; $('#drawer').classList.remove('hidden'); $('#drawer-mask').classList.remove('hidden'); - api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail) - .catch(function (e) { $('#d-body').innerHTML = '
加载失败:' + esc(e.message) + '
'; }); + api('/api/admin/records/' + encodeURIComponent(id) + sourceQuery(currentDetailSource)).then(function (data) { if (seq === detailSeq) renderDetail(data); }) + .catch(function (e) { if (seq === detailSeq) $('#d-body').innerHTML = '
加载失败:' + esc(e.message) + '
'; }); } function closeDetail() { + detailSeq++; $('#drawer').classList.add('hidden'); $('#drawer-mask').classList.add('hidden'); currentDetailId = null; @@ -355,7 +386,8 @@ function dSec(title, innerHtml) { function renderDetail(data) { var r = data.record; - $('#btn-retry').disabled = !(data.photos && data.photos.front); + $('#btn-retry').disabled = data.readonly || !(data.photos && data.photos.front); + $('#btn-retry').title = data.readonly ? '只读汇总记录,请在原服务后台重试' : ''; var h = ''; /* 三张照片 */ @@ -375,6 +407,7 @@ function renderDetail(data) { /* 状态行 */ h += dSec('状态', '
' + + '渠道 ' + esc(channelName(r.channel)) + (data.readonly ? '(只读汇总)' : '') + '' + '检测状态 ' + esc(r.detect_status) + '' + '拍摄状态 ' + esc(r.shoot_status) + '' + '上传状态 ' + esc(r.upload_status) + '' + @@ -553,7 +586,7 @@ function renderDetail(data) { if (data.raw_json) { var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null); var rawDl = rawFile - ? ' 下载完整原始 JSON' + ? ' 下载完整原始 JSON' : ''; h += dSec('接口原始 JSON', '
展开/收起(' + (rawFile || '') + ')' + rawDl + '
' +
       esc(JSON.stringify(data.raw_json, null, 1)) + '
'); @@ -572,7 +605,7 @@ function renderDetail(data) { /* 版本链跳转 */ var links = body.querySelectorAll('.d-versions a'); for (var v = 0; v < links.length; v++) { - links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); }); + links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id'), data.source); }); } } @@ -584,6 +617,14 @@ function showLightbox(src) { /* ═══════════ 事件绑定与初始化 ═══════════ */ function bind() { + if (window.JuziEntry && window.JuziEntry.mode === 'rednote') { + $('#f-channel').innerHTML = ''; + } + $('#f-channel').addEventListener('change', function () { + pageState.page = 1; + var active = $('.panel.on').id.replace('tab-', ''); + switchTab(active); + }); var tabs = document.querySelectorAll('.tab'); for (var i = 0; i < tabs.length; i++) { tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); }); @@ -599,13 +640,14 @@ function bind() { $('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); }); $('#btn-close').addEventListener('click', closeDetail); $('#drawer-mask').addEventListener('click', closeDetail); - $('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); }); + $('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId, currentDetailSource); }); $('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); }); $('#btn-export').addEventListener('click', function () { - var p = []; - if ($('#exp-from').value) p.push('from=' + $('#exp-from').value); - if ($('#exp-to').value) p.push('to=' + $('#exp-to').value); - var url = adminURL('/api/admin/export' + (p.length ? '?' + p.join('&') : '')); + var p = new URLSearchParams(collectFilters()); + p.delete('page'); p.delete('page_size'); + if ($('#exp-from').value) p.set('from', $('#exp-from').value); + if ($('#exp-to').value) p.set('to', $('#exp-to').value); + var url = adminURL('/api/admin/export?' + p.toString()); var a = el('a'); a.href = url; a.download = ''; diff --git a/h5/lib/accounts.js b/h5/lib/accounts.js index fc3bdbe..d45c40d 100644 --- a/h5/lib/accounts.js +++ b/h5/lib/accounts.js @@ -33,6 +33,7 @@ module.exports = function createAccounts(root, env = process.env) { if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; } const secure = !!(base && base.protocol === 'https:'); const guestSessions = new Map(); // No guest identities/sessions are written to disk. + const taobaoGuests = new Map(); // Separate, temporary Bearer sessions; never written to db. const appId = env.WECHAT_APP_ID || ''; const secret = env.WECHAT_APP_SECRET || ''; const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname)); @@ -226,13 +227,27 @@ module.exports = function createAccounts(root, env = process.env) { save(); return { token, expires_in: 30 * 86400, user: publicUser(user) }; } + function loginTaobaoGuest() { + for (const [key, value] of taobaoGuests) if (value.expires <= Date.now()) taobaoGuests.delete(key); + if (taobaoGuests.size >= 1000) return null; + const token = random(), user = { id: crypto.randomUUID(), kind: 'taobao_guest' }; + taobaoGuests.set(hash(token), { user, expires: Date.now() + 8 * 3600000 }); + return { token, expires_in: 8 * 3600, user: publicUser(user) }; + } function taobaoSession(req) { const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || ''); + const key = match && hash(match[1]), guest = key && taobaoGuests.get(key); + if (guest) { + if (guest.expires > Date.now()) return guest.user; + taobaoGuests.delete(key); + return null; + } const row = match && db.sessions[hash(match[1])]; return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null; } function taobaoLogout(req) { const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || ''); + if (match && taobaoGuests.delete(hash(match[1]))) return; if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); } } function reports(owner) { @@ -247,5 +262,5 @@ module.exports = function createAccounts(root, env = process.env) { return snapshot; } return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available, - loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport }; + loginTaobao, loginTaobaoGuest, taobaoSession, taobaoLogout, reports, ownedReport }; }; diff --git a/h5/lib/admin-sources.js b/h5/lib/admin-sources.js new file mode 100644 index 0000000..16be90f --- /dev/null +++ b/h5/lib/admin-sources.js @@ -0,0 +1,72 @@ +'use strict'; +// Administrative reads only. Detection/authentication continue to write solely +// to the process's own DATA_ROOT. Source paths never come from an HTTP request. +const fs = require('node:fs'); +const path = require('node:path'); + +module.exports = function ({ localRoot, localChannel = 'h5', taobaoRoot, publicRoot }) { + const sources = new Map([['local', { id: 'local', root: path.resolve(localRoot), channel: localChannel, readonly: false }]]); + const inside = (file, root) => file === root || file.startsWith(root + path.sep); + if (taobaoRoot) { + if (!path.isAbsolute(taobaoRoot)) throw Error('JUZI_ADMIN_TAOBAO_DATA_ROOT must be absolute'); + const root = fs.realpathSync(taobaoRoot); + const primary = fs.realpathSync(localRoot); + if (inside(root, primary) || inside(primary, root)) throw Error('Administrative data sources must be separate directories'); + if (publicRoot && inside(root, fs.realpathSync(publicRoot))) throw Error('Administrative external data must be outside the public application directory'); + sources.set('taobao', { id: 'taobao', root, channel: 'taobao', readonly: true }); + } + function source(id = 'local') { return sources.get(id) || null; } + function records(id) { + const selected = id ? [source(id)].filter(Boolean) : [...sources.values()]; + return selected.flatMap(s => { + let text; + const target = file(s.id, 'records.jsonl'); + if (!target) return []; + try { text = fs.readFileSync(target, 'utf8'); } + catch (e) { if (e.code === 'ENOENT') return []; throw e; } + return text.split('\n').flatMap(line => { + let rec; try { rec = JSON.parse(line); } catch (_) { return []; } + if (!rec || typeof rec.record_id !== 'string' || !/^[\w-]+$/.test(rec.record_id)) return []; + return [{ ...rec, channel: rec.channel || s.channel, admin_source: s.id, + admin_key: s.id + ':' + rec.record_id, admin_readonly: s.readonly }]; + }); + }); + } + function find(id, sourceId = 'local') { return records(sourceId).find(r => r.record_id === id) || null; } + function file(sourceId, ...parts) { + const s = source(sourceId); + if (!s || parts.some(p => typeof p !== 'string' || !p || p !== path.basename(p) || p === '.' || p === '..')) return null; + const target = path.resolve(s.root, ...parts); + if (!inside(target, s.root)) return null; + try { return inside(fs.realpathSync(target), fs.realpathSync(s.root)) ? target : null; } + catch (e) { if (e.code === 'ENOENT') return null; throw e; } + } + function presentation(rec) { + const target = file(rec.admin_source, 'accounts', 'reports', rec.owner_id, rec.record_id + '.json'); + if (!target) return null; + try { return JSON.parse(fs.readFileSync(target, 'utf8')).presentation || null; } + catch (_) { return null; } + } + function funnel(now, channel, localFunnel) { + const stats = [...sources.values()].filter(s => !channel || s.channel === channel).map(s => { + if (s.id === 'local') return localFunnel.stats(now); + return require('./capture-funnel')(path.join(s.root, 'capture-funnel.jsonl')).stats(now); + }); + function sum(scope) { + const rows = stats.map(st => st[scope]); + const counts = {}; + for (const st of rows) for (const [stage, n] of Object.entries(st.counts)) counts[stage] = (counts[stage] || 0) + n; + const reports = counts.report || 0; + return { counts, rate: counts.started ? Math.round(reports / counts.started * 1000) / 10 : 0, + response_without_report: rows.reduce((n, r) => n + r.response_without_report, 0), + unfinished: rows.reduce((n, r) => n + r.unfinished, 0), + avg_ms: reports ? Math.round(rows.reduce((n, r) => n + (r.avg_ms || 0) * r.counts.report, 0) / reports) : null }; + } + // Keep the event schema even when a selected channel has no telemetry yet. + if (!stats.length) return null; + const starts = stats.map(st => st.since).filter(Number.isFinite); + return { since: starts.length ? Math.min(...starts) : null, total: sum('total'), today: sum('today') }; + } + return { source, records, find, file, presentation, funnel, + descriptors: () => [...sources.values()].map(({ id, channel, readonly }) => ({ id, channel, readonly })) }; +}; diff --git a/h5/lib/taobao-auth.js b/h5/lib/taobao-auth.js index aab3989..7376ce1 100644 --- a/h5/lib/taobao-auth.js +++ b/h5/lib/taobao-auth.js @@ -18,19 +18,26 @@ function signature(params, body, secret) { } return crypto.createHash('md5').update(secret + content + secret).digest('hex').toUpperCase(); } -function verify(url, body, env = process.env) { +// Guest requests still require the real signed app gateway, without userInfo +// authorization or an open_id. Body fields never establish an identity. +function verifyGateway(url, body, env = process.env) { if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null; const params = Object.create(null); for (const [key, value] of new URL(url, 'http://localhost').searchParams) { if (key in params) return null; // Never ambiguously verify duplicate parameters. params[key] = value; } - if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') || + if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null; const expected = signature(params, body, env.TAOBAO_APP_SECRET); if (!expected || expected.length !== params.sign.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null; - // Login challenge is single-use and signed inside the body, preventing replay. - return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') }; + return { appKey: params.app_key, openId: params.open_id }; } -module.exports = { signature, verify }; +function verify(url, body, env = process.env) { + const gateway = verifyGateway(url, body, env); + if (!gateway || !/^[A-Za-z0-9_-]{4,256}$/.test(gateway.openId || '')) return null; + // Login challenge is single-use and signed inside the body, preventing replay. + return { providerKey: crypto.createHash('sha256').update(gateway.appKey + ':' + gateway.openId).digest('hex') }; +} +module.exports = { signature, verify, verifyGateway }; diff --git a/h5/lib/taobao-routes.js b/h5/lib/taobao-routes.js index ce8d51b..6db52ba 100644 --- a/h5/lib/taobao-routes.js +++ b/h5/lib/taobao-routes.js @@ -11,6 +11,7 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn try { const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8'); const body = JSON.parse(raw); + if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { ok: false, message: '请求数据异常' }); if (pathname === '/api/taobao/challenge') { const challenge = taobaoSessions.challenge(req.socket.remoteAddress); return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' }); @@ -21,29 +22,45 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' }); return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) }); } + if (pathname === '/api/taobao/guest-session') { + if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' }); + if (!TaobaoAuth.verifyGateway(req.url, raw) || !taobaoSessions.consume(body.challenge)) { + return reply(401, { ok: false, message: '游客会话校验失败,请重新开始' }); + } + const guest = accounts.loginTaobaoGuest(); + return reply(guest ? 200 : 429, guest ? { ok: true, ...guest } : { ok: false, message: '操作频繁,请稍后再试' }); + } const owner = accounts.taobaoSession(req); - if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' }); - if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } }); + if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新开始' }); + if (pathname === '/api/taobao/me') { + // A cached Bearer must not restore the previous Taobao account after + // the host app switches accounts. Guests require a signed app gateway + // but intentionally have no dependency on userInfo/open_id authorization. + const identity = owner.kind === 'taobao_guest' ? null : TaobaoAuth.verify(req.url, raw); + const matches = owner.kind === 'taobao_guest' ? !!TaobaoAuth.verifyGateway(req.url, raw) + : !!identity && identity.providerKey === owner.provider_key; + return reply(matches ? 200 : 401, matches ? { ok: true, user: { id: owner.id, kind: owner.kind } } + : { ok: false, message: '淘宝账号状态已变化,请重新进入' }); + } if (pathname === '/api/taobao/photo') { const { status, ...data } = taobaoSessions.upload(req, body); return reply(status, { ok: status === 200, ...data }); } if (pathname === '/api/taobao/skin-analyze') { - const photos = taobaoSessions.takePhotos(req, body.photo_refs); - if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' }); // Start once and poll: cloud gateway requests have a maximum 10s timeout. - const job = taobaoSessions.start(req, () => new Promise(resolve => { + const attempt = taobaoSessions.beginAnalysis(req, body, photos => new Promise(resolve => { const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } }; handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' })); - }), true); - if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); } - return reply(202, { ok: true, job }); + })); + return reply(attempt.status, { ok: attempt.status === 202, + ...(attempt.job ? { job: attempt.job } : { message: attempt.message }) }); } if (pathname === '/api/taobao/analysis') { const result = taobaoSessions.result(req, body.job); - return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' }); + return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: owner.kind === 'taobao_guest' ? '本次检测任务已失效,请重新开始' : '检测任务已失效,请查看历史报告或重新检测' }); } if (pathname === '/api/taobao/reports') { + if (owner.kind === 'taobao_guest') return reply(body.id ? 404 : 200, body.id ? { ok: false, message: '游客模式不保存历史报告' } : { ok: true, total: 0, items: [] }); if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) }); const report = accounts.ownedReport(owner.id, body.id); return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' }); diff --git a/h5/lib/taobao-session.js b/h5/lib/taobao-session.js index 9035160..c339b39 100644 --- a/h5/lib/taobao-session.js +++ b/h5/lib/taobao-session.js @@ -3,8 +3,11 @@ const crypto = require('node:crypto'); // Short-lived transfers/jobs/challenges. Identity/history live in accounts.js. module.exports = function ({ session, now = Date.now } = {}) { - const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(); + const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(), submissions = new Map(); const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024; + // Keep only small idempotency tombstones after result expiry. Never silently + // charge again when a client retries an old request_id in the same session. + const submissionLifetime = 30 * 86400000; let bytes = 0; function clearPhotos(row) { for (const photo of Object.values(row.photos)) bytes -= photo.bytes; @@ -15,6 +18,12 @@ module.exports = function ({ session, now = Date.now } = {}) { for (const [id, until] of challenges) if (until <= now()) challenges.delete(id); for (const [id, row] of rates) if (row.until <= now()) rates.delete(id); for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id); + for (const [id, value] of submissions) if (value.expires <= now()) submissions.delete(id); + } + function scope(req, owner) { + const authorization = req.headers && req.headers.authorization; + return typeof authorization === 'string' && authorization + ? crypto.createHash('sha256').update(authorization).digest('hex') : owner.id; } function challenge(address) { sweep(); @@ -35,8 +44,9 @@ module.exports = function ({ session, now = Date.now } = {}) { sweep(); const owner = session(req); if (!owner) return null; - if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime }); - return transfers.get(owner.id) || null; + const key = scope(req, owner); + if (!transfers.has(key) && create && transfers.size < 1000) transfers.set(key, { photos: {}, active: false, expires: now() + lifetime }); + return transfers.get(key) || null; } function upload(req, body) { const current = row(req, true); @@ -64,35 +74,85 @@ module.exports = function ({ session, now = Date.now } = {}) { current.expires = now() + lifetime; return { status: 200, complete: photo.chunks.length === total, photo_ref: id }; } - function takePhotos(req, refs) { - const current = row(req); + function availablePhotos(current, refs) { if (!current || current.active) return null; if (!refs || !['front', 'left', 'right'].every(view => { const photo = current.photos[view]; return photo && photo.id === refs[view] && photo.chunks.length === photo.total; })) return null; - const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')])); + return Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')])); + } + function takePhotos(req, refs) { + const current = row(req), photos = availablePhotos(current, refs); + if (!photos) return null; clearPhotos(current); current.active = true; return photos; } - function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } } - function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } } - function start(req, run, releasePhotos = false) { + function finish(req, key) { const current = key ? transfers.get(key) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } } + function revoke(req) { + const owner = session(req); + if (!owner) return; + const key = scope(req, owner), current = transfers.get(key); + if (current) { clearPhotos(current); transfers.delete(key); } + for (const [id, job] of jobs) if (job.scope === key) { job.result = null; jobs.delete(id); } + for (const [id, value] of submissions) if (value.scope === key) submissions.delete(id); + } + function stable(value) { + if (Array.isArray(value)) return value.map(stable); + return value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value; + } + function submission(req, requestId, body) { + sweep(); + const owner = session(req); + if (!owner) return { status: 401, message: '检测会话已失效,请重新开始' }; + if (requestId === undefined) return { status: 200 }; // Older clients remain supported. + if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(requestId)) { + return { status: 400, message: '检测请求标识异常,请重新开始' }; + } + const key = scope(req, owner) + ':' + requestId; + const fingerprint = crypto.createHash('sha256').update(JSON.stringify(stable({ photo_refs: body.photo_refs, profile: body.profile }))).digest('hex'); + const previous = submissions.get(key); + if (previous) { + if (previous.fingerprint !== fingerprint) return { status: 409, message: '检测请求已用于其他照片或档案,请重新开始' }; + if (!jobs.has(previous.job)) return { status: 410, message: '本次检测任务已失效,请重新开始新的检测' }; + return { status: 202, job: previous.job }; + } + if (submissions.size >= 10000) return { status: 429, message: '服务繁忙,请稍后再试' }; + return { status: 200, key, fingerprint }; + } + function start(req, run, releasePhotos = false, attempt) { sweep(); const owner = session(req); if (!owner || jobs.size >= 1000) return null; const id = crypto.randomBytes(24).toString('hex'); - const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null }; + const key = scope(req, owner); + const job = { owner: owner.id, scope: key, status: 'running', expires: now() + lifetime, result: null }; jobs.set(id, job); + if (attempt && attempt.key) submissions.set(attempt.key, { job: id, scope: key, fingerprint: attempt.fingerprint, expires: now() + submissionLifetime }); Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; }) - .finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); }); + .finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, key); }); return id; } + function beginAnalysis(req, body, run) { + const attempt = submission(req, body.request_id, body); + if (attempt.status !== 200) return attempt; + const current = row(req), photos = availablePhotos(current, body.photo_refs); + if (!photos) return { status: 409, message: '照片上传未完成或检测进行中,请重试' }; + // Reserve the job and its idempotency key BEFORE consuming uploads. There + // are no awaits here; the paid callback is queued for the next microtask. + // A full job queue leaves the complete uploads available for a later retry. + const job = start(req, () => run(photos), true, attempt); + if (!job) return { status: 429, message: '服务繁忙,请稍后再试' }; + clearPhotos(current); current.active = true; + return { status: 202, job }; + } function result(req, id) { sweep(); const job = jobs.get(id), owner = session(req); - return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null; + return job && owner && job.owner === owner.id && job.scope === scope(req, owner) + ? { status: job.status, payload: job.result } : null; } const timer = setInterval(sweep, 60000); timer.unref(); - return { challenge, consume, upload, takePhotos, finish, revoke, start, result }; + return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result }; }; diff --git a/h5/server.js b/h5/server.js index e4de71c..6fae365 100644 --- a/h5/server.js +++ b/h5/server.js @@ -33,6 +33,8 @@ const CaptureDiagnostics = require('./lib/capture-diagnostics.js'); const WrinkleViews = require('./lib/wrinkle-views.js'); const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl')); const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts')); +const adminSources = require('./lib/admin-sources')({ localRoot: DATA_ROOT, localChannel: entry.channel, + taobaoRoot: entry.rednote ? null : process.env.JUZI_ADMIN_TAOBAO_DATA_ROOT, publicRoot: ROOT }); const wrinkleJobs = require('./lib/wrinkle-jobs.js')({ dir:path.join(DATA_ROOT,'wrinkle-jobs'), collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{ @@ -93,7 +95,8 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8' + '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8', + '.mp3': 'audio/mpeg', '.aac': 'audio/aac' }; function serveStatic(req, res, pathname) { let p; @@ -578,9 +581,10 @@ function sendJson(res, status, obj) { function photoUrls(rec) { const urls = {}; const prefix = entry.rednote ? '/rednote' : ''; - if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`; - if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`; - if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`; + const source = rec.admin_source ? '?source=' + encodeURIComponent(rec.admin_source) : ''; + if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front${source}`; + if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left${source}`; + if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right${source}`; return urls; } @@ -603,6 +607,8 @@ function listItem(rec) { created_at: rec.created_at, real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type, channel: rec.channel || 'h5', + source: rec.admin_source || 'local', admin_key: rec.admin_key || 'local:' + rec.record_id, + readonly: !!rec.admin_readonly, shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status, capture_quality: rec.capture_quality || null, capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null, @@ -648,7 +654,7 @@ function matchQuery(rec, q) { return true; } -function handleRecordsQuery(req, res, url) { +function adminQuery(url) { const sp = url.searchParams; const q = { from: sp.get('from') || null, @@ -665,21 +671,32 @@ function handleRecordsQuery(req, res, url) { age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null }; if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) { - return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' }); + throw Error('age_min/age_max 必须是数字'); } - try { q.timeRange = BeijingTime.range(q.from, q.to); } + if (q.channel && !['h5', 'taobao', 'rednote'].includes(q.channel)) throw Error('渠道参数无效'); + q.timeRange = BeijingTime.range(q.from, q.to); + return q; +} +function adminRecordOrder(a, b) { + const delta = BeijingTime.milliseconds(b.created_at) - BeijingTime.milliseconds(a.created_at); + return (Number.isFinite(delta) ? delta : 0) || String(a.admin_key).localeCompare(String(b.admin_key)); +} +function handleRecordsQuery(req, res, url) { + let q; + try { q = adminQuery(url); } catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); } + const sp = url.searchParams; const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20)); - const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) - .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序 + const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) + .sort(adminRecordOrder); // 跨数据源统一排序;同时间用来源+ID稳定分页 const total = filtered.length; const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem); - sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items }); + sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items, sources: adminSources.descriptors() }); } -function handleRecordDetail(req, res, id) { - const rec = displayRecommendationRecord(findRecord(id)); +function handleRecordDetail(req, res, id, source = 'local') { + const rec = displayRecommendationRecord(adminSources.find(id, source)); if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' }); // 原始 JSON 全文:读文件失败给 null。 // face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器: @@ -687,7 +704,7 @@ function handleRecordDetail(req, res, id) { let rawJson = null; if (rec.raw_file) { const fname = String(rec.raw_file).replace(/[^\w.,-]/g, ''); - try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); } + try { rawJson = JSON.parse(fs.readFileSync(adminSources.file(source, 'results', fname), 'utf8')); } catch (e) { rawJson = null; } } if (rawJson && rawJson.result && rawJson.result.face_maps) { @@ -699,11 +716,11 @@ function handleRecordDetail(req, res, id) { } } // 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子) - const all = readAllRecords(); + const all = adminSources.records(source); const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null; const children = all.filter(r => r.retry_of === rec.record_id) - .map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at })); - let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id); + .map(r => ({ record_id: r.record_id, source, detect_status: r.detect_status, created_at: r.created_at })); + let presentation = rec.presentation || (source === 'local' ? accounts.reportPresentation(rec.owner_id, rec.record_id) : adminSources.presentation(rec)); if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) { // Recover text variables from raw data, but retain the saved scores, // recommendation and care order. No database/snapshot mutation. @@ -719,23 +736,26 @@ function handleRecordDetail(req, res, id) { if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null}); sendJson(res, 200, { ok: true, + source, readonly: !!rec.admin_readonly, + raw_file: rec.raw_file || null, record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec), dims: displayScoreLevels(rec.dims,true), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }), raw_json: rawJson, photos: photoUrls(rec), versions: { - parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null, + parent: parent ? { record_id: parent.record_id, source, detect_status: parent.detect_status, created_at: parent.created_at } : null, children } }); } /* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */ -function handleRawDownload(req, res, id) { - const rec = findRecord(id); +function handleRawDownload(req, res, id, source = 'local') { + const rec = adminSources.find(id, source); if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' }); const fname = String(rec.raw_file).replace(/[^\w.,-]/g, ''); - const file = path.join(DATA_DIR, fname); + const file = adminSources.file(source, 'results', fname); + if (!file) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' }); fs.readFile(file, (err, data) => { if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' }); res.writeHead(200, { @@ -746,14 +766,15 @@ function handleRawDownload(req, res, id) { }); } -function handlePhoto(req, res, id, which) { - const safe = String(id || '').replace(/[^\w-]/g, ''); - const file = path.join(PHOTOS_DIR, safe, which + '.jpg'); +function handlePhoto(req, res, id, which, source = 'local') { + const rec = adminSources.find(id, source); + const file = rec && adminSources.file(source, 'photos', id, which + '.jpg'); + if (!file) { res.writeHead(404); return res.end('Not Found'); } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); } res.writeHead(200, { 'Content-Type': 'image/jpeg', - 'Content-Disposition': `inline; filename="${safe}-${which}.jpg"` + 'Content-Disposition': `inline; filename="${id}-${which}.jpg"` }); res.end(data); }); @@ -761,8 +782,9 @@ function handlePhoto(req, res, id, which) { function handleStats(req, res, url) { const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道 + if (channel && !['h5', 'taobao', 'rednote'].includes(channel)) return sendJson(res, 400, { ok: false, message: '渠道参数无效' }); const now = Date.now(), today = BeijingTime.day(now); - const allRaw = readAllRecords().map(displayRecommendationRecord); + const allRaw = adminSources.records().map(displayRecommendationRecord); // 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计) const by_channel = {}; for (const r of allRaw) { @@ -822,7 +844,8 @@ function handleStats(req, res, url) { fail_total: failed.length, success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0, avg_cost_ms: avgCost, - capture_funnel: captureFunnel.stats(now), + capture_funnel: adminSources.funnel(now, channel, captureFunnel), + sources: adminSources.descriptors(), by_channel, fail_dist, dim_dist, @@ -883,15 +906,11 @@ function csvCell(v) { } function handleExport(req, res, url) { - const q = { - from: url.searchParams.get('from') || null, - to: url.searchParams.get('to') || null, - channel: url.searchParams.get('channel') || null - }; - try { q.timeRange = BeijingTime.range(q.from, q.to); } + let q; + try { q = adminQuery(url); } catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); } - const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) - .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); + const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) + .sort(adminRecordOrder); const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null; const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null; const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null; @@ -899,6 +918,8 @@ function handleExport(req, res, url) { '失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)', '紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档', '均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道']; + const includeSources = adminSources.descriptors().length > 1; + if (includeSources) header.push('数据来源', '记录唯一键'); const lines = [header.join(',')]; for (const r of filtered) { lines.push([ @@ -915,7 +936,7 @@ function handleExport(req, res, url) { r.capture_quality ? r.capture_quality.status : null, r.capture_diagnostics ? r.capture_diagnostics.version : null, r.channel || 'h5' - ].map(csvCell).join(',')); + ].concat(includeSources ? [r.admin_source, r.admin_key] : []).map(csvCell).join(',')); } const fromTag = (q.from || 'all').replace(/[^\d-]/g, ''); const toTag = (q.to || 'all').replace(/[^\d-]/g, ''); @@ -929,14 +950,17 @@ function handleExport(req, res, url) { async function handleAdmin(req, res, pathname, url) { try { + const source = url.searchParams.get('source') || 'local'; + if (!adminSources.source(source)) return sendJson(res, 404, { ok: false, message: '数据来源不存在' }); const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/); + if (req.method === 'POST' && retryM && source !== 'local') return sendJson(res, 409, { ok: false, message: '该渠道为只读汇总,请在原服务后台重试' }); if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]); const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/); - if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]); + if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1], source); const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/); - if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]); + if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1], source); const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/); - if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]); + if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2], source); if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url); if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res, url); if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url); diff --git a/rednote/deploy/juzi-rednote.service b/rednote/deploy/juzi-rednote.service index e5c0250..6f07c0c 100644 --- a/rednote/deploy/juzi-rednote.service +++ b/rednote/deploy/juzi-rednote.service @@ -1,5 +1,5 @@ [Unit] -Description=1me rednote H5 - isolated browser identities +Description=1'me rednote H5 - isolated browser identities After=network.target [Service] diff --git a/reference/native-face-models/face_landmark_68_tiny_model-shard1 b/reference/native-face-models/face_landmark_68_tiny_model-shard1 new file mode 100644 index 0000000..f04a9d5 Binary files /dev/null and b/reference/native-face-models/face_landmark_68_tiny_model-shard1 differ diff --git a/reference/native-face-models/face_landmark_68_tiny_model-weights_manifest.json b/reference/native-face-models/face_landmark_68_tiny_model-weights_manifest.json new file mode 100644 index 0000000..5dc790e --- /dev/null +++ b/reference/native-face-models/face_landmark_68_tiny_model-weights_manifest.json @@ -0,0 +1 @@ +[{"weights":[{"name":"dense0/conv0/filters","shape":[3,3,3,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008194216092427571,"min":-0.9423348506291708}},{"name":"dense0/conv0/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006839508168837603,"min":-0.8412595047670252}},{"name":"dense0/conv1/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009194007106855804,"min":-1.2779669878529567}},{"name":"dense0/conv1/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0036026100317637128,"min":-0.3170296827952067}},{"name":"dense0/conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.000740380117706224,"min":-0.06367269012273527}},{"name":"dense0/conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0037702228508743585,"min":-0.6220867703942692}},{"name":"dense1/conv0/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0033707996209462483,"min":-0.421349952618281}},{"name":"dense1/conv0/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014611541991140328,"min":-1.8556658328748217}},{"name":"dense1/conv0/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002832523046755323,"min":-0.30307996600281956}},{"name":"dense1/conv1/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006593170586754294,"min":-0.6329443763284123}},{"name":"dense1/conv1/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.012215249211180444,"min":-1.6001976466646382}},{"name":"dense1/conv1/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002384825547536214,"min":-0.3028728445370992}},{"name":"dense1/conv2/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005859645441466687,"min":-0.7617539073906693}},{"name":"dense1/conv2/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013121426806730382,"min":-1.7845140457153321}},{"name":"dense1/conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032247188044529336,"min":-0.46435950784122243}},{"name":"dense2/conv0/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002659512618008782,"min":-0.32977956463308894}},{"name":"dense2/conv0/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015499923743453681,"min":-1.9839902391620712}},{"name":"dense2/conv0/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032450980999890497,"min":-0.522460794098237}},{"name":"dense2/conv1/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005911862382701799,"min":-0.792189559282041}},{"name":"dense2/conv1/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021025861478319356,"min":-2.2077154552235325}},{"name":"dense2/conv1/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.00349616945958605,"min":-0.46149436866535865}},{"name":"dense2/conv2/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008104994250278847,"min":-1.013124281284856}},{"name":"dense2/conv2/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.029337059282789044,"min":-3.5791212325002633}},{"name":"dense2/conv2/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0038808938334969913,"min":-0.4230174278511721}},{"name":"fc/weights","shape":[128,136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014016061670639936,"min":-1.8921683255363912}},{"name":"fc/bias","shape":[136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0029505149698724935,"min":0.088760145008564}}],"paths":["face_landmark_68_tiny_model-shard1"]}] \ No newline at end of file diff --git a/reference/native-face-models/sources.json b/reference/native-face-models/sources.json new file mode 100644 index 0000000..4c3f992 --- /dev/null +++ b/reference/native-face-models/sources.json @@ -0,0 +1,14 @@ +[ + { + "file": "face_landmark_68_tiny_model-weights_manifest.json", + "source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-weights_manifest.json", + "bytes": 4441, + "sha256": "3c63b8984302c187b218d9ef5aa149ed8c2c7fa3fe54db078614692bc48d153c" + }, + { + "file": "face_landmark_68_tiny_model-shard1", + "source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-shard1", + "bytes": 77224, + "sha256": "b98e9f2f7da76f8a6dda9741a36ed485b224b889d552de2b2c1bb16217f67bfc" + } +] diff --git a/taobao/README.md b/taobao/README.md index 02bc977..6d6f0f3 100644 --- a/taobao/README.md +++ b/taobao/README.md @@ -1,6 +1,34 @@ +## 2026-11-03 正式包开发入口清理 + +正式路由仅保留登录、主页、档案、拍照、报告和历史六页。`pages/mvp`、`pages/camtest`、`pages/camnative` 已从 app.json 移除,并同时加入编译和上传排除项;开发测试源码保留供离线回归,不随正式包注册或上传。登录和主页的开发入口、诊断弹层、对应处理方法与样式已清理。主拍照页 `?debug=local-face` 还必须显式启用 cameraDebug,当前配置为 false。 + +“重新开启相机”是实际失败后的恢复入口,不是测试按钮:只在真实错误/权限提示下显示,登录校验、初次启动、预览、拍摄完成过渡和报告处理中隐藏;点击重试时先清除旧错误,失败再恢复提示。没有增加自动重试,也没有改变相机方向、角度、识别后端、照片顺序、亮度门限或 1.5 秒新鲜度限制。 + # 淘宝小程序开发说明 -当前完成本地开发闭环:授权登录 → 基础档案 → 正/右/左三步手动拍摄或相册选图 → 服务端检测 → 七项报告 → 最近5份历史报告。真实淘宝授权、云网关、相机和图片组件仍需在关联 AppKey 的淘宝 IDE 与真机中验收。 +用户已确认自动三连拍在当前手机通过,正式业务配置已恢复 `photoInputMode: 'camera'`,使用与测试页相同的识别和自动拍照逻辑;原相册入口保留。UI 和交互保持原样,不新增上传面板。若需独立排查选图上传,仍可临时设为 `'upload'` 跳过实时识别。淘宝专用 Node 已备份并更新新版游客及幂等接口,云服务访问异常及实际手机三图报告仍需验收。 + +正式入口:登录/游客 → 开始检测 → 原隐私弹窗 → 档案 → 拍照引导的“开始” → 自动三连拍 → 上传及报告。首次使用时衔接平台相机授权,无需先进入开发测试页。游客和正式账号的多页面集成回归均已覆盖,测试服务响应不代表真实云网关已通过。 + +页面按 H5 还原:报告检测图区域支持左右跟手滑动切换页卡,标签和高度同步;点检测图可放大,再点关闭;档案肤质卡支持跟手横滑、箭头和圆点联动。正常页面隐藏开发诊断入口,主页保留“开始检测/我的报告”,报告页不附加额外按钮。专项诊断时可在开发环境临时启用 `cameraDebug:true` 显示测试入口。 + +当前适配:独立登录首屏(淘宝账号/游客)→ 基础档案 → 正/右/左三步拍摄或相册选图 → 检测等待 → 七项报告 → 对应商品。正式淘宝账号保留最近 5 份历史;游客会话、档案和报告仅本次内存使用。商品 I/II/III 统一打开 `1090510453077`,推荐规则沿用 H5。 + +**首版要求实时识别抓拍。** 当前默认采用普通 `camera` 前置取景及连续帧,在本机运行人脸定位和轻量 68 点模型,复用 H5 姿态及稳定性判断,倒计时后用原生 `takePhoto` 获取 JPEG。用户已验证普通相机默认前置、帧数增加、可以拍照出图;手机已识别人脸并通过正脸姿态,但优化后的 CPU 单帧仍约 4.25 秒。当前候选改用原生 WebGL 加速,需通过数值自测、预热后再处理真实帧;手机兼容性、速度和自动三连拍仍待验收。AR 路线保留为显式开发对照,其默认自拍修正尚未通过真机验证。诊断页面保留在开发源码中,已移出正式路由及上传包。诊断不登录、不上传、不产生检测费用。 + +本轮实施状态、验收和部署顺序见 [适配交接](../docs/TAOBAO-ADAPTATION-2026-11-02.md)。 + +开发版“相机测试(开发版)”现直接进入 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”可进入 `LOCAL-FACE-20261102-09`。该页复用正式取景、实时识别、正/右/左自动拍摄及 JPEG 压缩,最后只展示本页临时照片,不要求账号或档案、不上传照片、不创建检测任务。卡住时点“复制识别诊断”,只复制模型阶段、耗时、帧尺寸、计数、安全错误代码及固定合成输入的图形自测指标,不复制图像或人脸坐标。测试页不播放语音;应用自身已有的静态资源加载及账号恢复不受这个页内模式控制。 + +手机在旧版 `LOCAL-FACE-20261102-01` 已识别 1 脸并通过姿态,CPU 优化版 `02` 从约 10.7 秒降至 4.25 秒,仍因结果过期停止,尚未完成自动抓拍。版本 `03` 明确请求原生 WebGL1,并按淘宝官方文档开启 `window.enableSkia:"true"`;手机矩阵自测执行 4 次绘制后报 `MATMUL_FAILED`。版本 `04` 依次验证 `default`、`packed_byte`(禁用浮点下载)和 `unpacked_byte`(再禁用打包 shader),真机三档均失败。最后一档记录 5 次绘制、1 次字节读回,矩阵校验和 0/预期 2786、244 项不匹配、最大误差 22,呈现全零特征;这些聚合指标尚不能单独证明全部元素为零或确定根因,仍未处理真实相机帧。 + +版本 `05` 按[淘宝官方说明](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)关闭 `readPixels` 的宿主额外翻转,并增加 2×2 原生图形检查。手机清屏读回通过,非对称 shader 有 3 项不匹配,预期校验和 2244/实际 2040;实际值与此前清屏校验和相同。这与绘制未覆盖的情况一致,也可能来自坐标、缓冲区上传或绘制状态,尚不能唯一确定根因,仍未处理真实相机帧。 + +版本 `06` 显式规范化 GL 绘制状态,每次绘制前清黑,先分别验证恒色 `drawArrays`/索引绘制,再验证原非对称 shader 和字节/浮点纹理。原生兼容候选按 `standard` → `buffer_copy`(将视图字节紧密拷贝为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅补偿已绑定 FBO 的 viewport/scissor Y 坐标)→ `combined` 自测;仅 `VALUE_MISMATCH` 允许换路,其他错误停止。四路使用同一上下文,每路结束清理自己的检查对象;TF 三档选择最多创建 3 个上下文。每条可用路径都须通过全部原生检查及后续严格矩阵/卷积自测、真实模型预热,不能按机型硬开,也不放宽 1.5 秒源帧新鲜度或回退 CPU。诊断保留原始失败阶段/代码、最终 `nativeStrategy`、固定合成的四像素及缓冲区字节数,仅初始化时缓存;真实人脸结果不能覆盖。手机实际选路、识别速度和三连拍仍待验收。 + +当前候选 `07` 针对真机 `06` 的 `nativeInitialStage=constant_indexed`:标准路径普通恒色绘制已通过,索引绘制失败;最后的坐标兼容黑屏不代表原始根因。原生索引调用前先重新绑定索引缓冲。若仍然是普通绘制通过而索引数值失败,则验证等价的四顶点 `TRIANGLE_STRIP` 路径;通过非对称图案、纹理、原矩阵/卷积和模型预热后才使用。仅把自有固定索引 `[0,1,2,2,1,3]`、`TRIANGLES/6/UNSIGNED_SHORT/offset0` 转换,未知签名、缓冲未确认或部分修改时明确拒绝。诊断新增 `drawStrategy=quad_strip`、实际绘制计数和 `constantQuad`;此时 `constantIndexed=not_run`,不宣称原生索引已通过。原生候选最多 8 次、仍使用同一上下文,手机效果与速度待验收。 + +此入口仅在 `environment: 'development'`、`captureMode: 'native-face'`、`nativeCamera: true` 时开放,允许当前 cloud 配置;`debug=local-face` 参数不能在生产环境绕过登录。原来的 `debug=camera` 手动验收仍要求显式 `cameraDebug` 开关及本机直连,不变更其范围。 ## 本地启动 @@ -20,7 +48,7 @@ npm run dev:backend 代理使用固定的本地测试身份并模拟淘宝网关签名;Face++ 返回仓库内的固定样本。模拟逻辑仅在开发启动脚本中,正式服务器不会接受客户端自报 `open_id`,也没有免验签的开发登录后门。本地 H5 的微信入口不启用;微信 `/state/` 授权由独立浏览器回归验证。 -`config.js` 当前是本地配置:`transport: 'direct'`、端口18180。直连传输只允许 localhost/127.0.0.1;不要把它当成淘宝正式网络方案。IDE 中导入本目录并关联自己的 AppKey。桌面模拟器是否允许访问回环地址需以实际 IDE 为准;手机上的127.0.0.1指向手机自身。 +`config.js` 默认是 `cloud`/`tower`/`test`,使用聚石塔云网关。仅做本机样本联调时手动切换到 `transport: 'direct'`、`baseUrl: 'http://127.0.0.1:18180'`;结束后恢复云配置。代理为正式和游客会话签名,不绕过后端校验。手机上的 127.0.0.1 指向手机自身。 ## 文件职责 @@ -30,14 +58,15 @@ npm run dev:backend 依次检查:首次授权、拒绝后相册仍可使用、授权设置恢复、前后镜头、三张方向、切后台恢复、压缩最长边≤1280。真实系统权限和相机画面仍需淘宝IDE/手机验收,自动测试仅验证代码流程。 -- `pages/splash`:协议同意、淘宝授权登录、退出和历史入口。 +- `pages/login`:淘宝/游客登录、协议、登录恢复,以及仅开发版显示的 AR 测试入口。 +- `pages/splash`:品牌首页、开始检测、历史、退出/结束游客体验。 - `pages/profile`:账号隔离的基础信息缓存、出生年月和省市滚轮、肤质选择。 -- `pages/camera`:手动三步拍摄/预览确认;离页取消、重复点击保护;单画布顺序压缩成最长边1280的 JPEG。 +- `pages/camera`:普通前置相机本地实时识别、正/右/左三步引导、H5 轮廓及定位线、自动倒计时和原生 JPEG 拍摄;保留 AR 开发对照、完成卡与扫描等待页。相册路径拍后直接推进;单画布顺序压缩最长边 1280 的 JPEG;暂停后复用原分析任务,不重新收费。 - `pages/report`:服务端返回的当前评分/话术、七项分析、护理选三、精华、真实图层和坐标标注;侧脸图层异步加载。 - `pages/history`:本人最近5份完整报告。 - `utils/transport.js`:本机代理或淘宝云 SDK 传输,保留反代子路径配置。 - `utils/api.js`:账号凭据、64KiB照片分片、检测任务提交与轮询、历史报告。 -- `vendor/`:从 H5 同步的评分、报告呈现、文案及哈希清单,不手工修改。 +- `vendor/`:从 H5 同步的评分、报告呈现、拍照规则及哈希清单;`native-face/` 为从 H5 原始模型生成的小程序 CPU 副本,含来源记录。分别使用共享同步脚本和 `node scripts/build-native-face.cjs` 重建,不手工改生成文件。 - `scripts/`、`test/`、`docs/`:开发与回归文件,IDE 配置排除上传。 H5 规则变更后运行 `npm run sync:shared`;`npm run check:shared` 会检查逐字节一致。在线报告优先使用服务端返回的 `vm/presentation`,原始检测分数和展示肌龄分别保存。 @@ -93,7 +122,7 @@ npm run test:h5 - 真实 AppKey/cloudAppId、签名参数和授权权限包联调。 - 真实网关传输报告大图时的返回体限制与内存占用;已减少小程序 `setData` 中重复照片和非当前Tab图层。 - Android/iOS相机权限、前置相片方向、cover-view覆盖、系统相机降级、后台恢复、原生canvas及大图读取。 -- UI真机对齐。当前相机使用椭圆框和左右转头提示,尚未迁移H5精确取景轮廓与实时姿态自动拍摄。 -- 商品跳转尚需真实商品ID与平台能力验收;当前报告展示推荐产品,没有伪造商品链接。 +- UI真机对齐。H5 取景轮廓及实时姿态自动拍摄已接入本地代码,仍需在手机验证定位、左右转头提示、识别耗时和完整三连拍。 +- 商品目标已确认:`1090510453077`(1'me以我自在有势挺括双仓精华液)。仍需验证平台能力、游客/新报告/历史报告跳转及返回状态。 以上是待真实平台验收的范围,不影响已通过的本地接口与H5回归结论。 diff --git a/taobao/app.acss b/taobao/app.acss index 0081627..ea333e2 100644 --- a/taobao/app.acss +++ b/taobao/app.acss @@ -10,6 +10,11 @@ page { -webkit-font-smoothing: antialiased; } +/* Center against the full screen, independently of native left/right buttons. */ +.brand-header { position:fixed; left:0; top:0; width:100%; box-sizing:border-box; background:#FFFFFF; color:#282828; z-index:70; pointer-events:none; } +.brand-header-line { position:relative; width:100%; display:flex; align-items:center; justify-content:center; } +.brand-header-title { display:block; width:calc(100% - 152px); text-align:center; font-size:15px; font-weight:500; line-height:22px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + /* ─── 主按钮:长方形 + 小倒角(H5 设计稿 181×37,圆角 5) ─── */ .btn-primary { display: flex; diff --git a/taobao/app.js b/taobao/app.js index a4f9291..83e88c7 100644 --- a/taobao/app.js +++ b/taobao/app.js @@ -10,11 +10,21 @@ App({ payload: null }, + onShareAppMessage: function () { + return require('./utils/share').message(); + }, + onLaunch: function () { - // 预取语音清单(失败静默,不影响主流程) - require('./utils/voice.js').init(); + require('./utils/api.js').resetForLaunch(); + // The login screen needs no native audio player. voice.play() creates its + // context lazily when spoken guidance is actually needed. + }, + onShow: function () { + var api = require('./utils/api.js'); + if (api.hasSession() && !api.isGuest()) api.restore().catch(function () {}); }, onHide: function () { + require('./utils/api.js').suspendSession(); require('./utils/voice.js').stop(true); }, // 真机上看不到 console:开发版里把未捕获异常弹出到屏幕上,方便截图定位。 diff --git a/taobao/app.json b/taobao/app.json index 9fc70bd..1d25ff3 100644 --- a/taobao/app.json +++ b/taobao/app.json @@ -1,19 +1,23 @@ { "pages": [ + "pages/login/login", "pages/splash/splash", "pages/profile/profile", "pages/camera/camera", "pages/report/report", - "pages/history/history", - "pages/mvp/mvp", - "pages/camtest/camtest" + "pages/history/history" ], "window": { - "defaultTitle": "1'me在线肌肤测试", + "defaultTitle": "​", + "enableSkia": "true", "titleBarColor": "#FFFFFF", "backgroundColor": "#101823", "pullRefresh": false, - "allowsBounceVertical": "NO" + "allowsBounceVertical": "NO", + "navigationBarForceEnable": true, + "transparentTitle": "always", + "navigationBarTextStyle": "black", + "gestureBack": "NO" }, "permission": { "scope.camera": { diff --git a/taobao/assets/README.md b/taobao/assets/README.md index b483bdb..81f5420 100644 --- a/taobao/assets/README.md +++ b/taobao/assets/README.md @@ -1,5 +1,5 @@ # 小程序内置资源 -这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。 +这里只放确实需要随包内置的资源。`startup/` 内置登录首屏的三张 H5 原图(约 129 KiB),避免首屏等待公网图片;图片内容、尺寸与 H5 一致。拍照遮罩等资源也随包内置。其余 UI 图片、推荐产品图与语音通过 `config.js` 的 `assetBase` 加载,复用共享后端素材;没有修改 H5 资源。 正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。 diff --git a/taobao/assets/camera/hole-front.png b/taobao/assets/camera/hole-front.png new file mode 100644 index 0000000..aa6f744 Binary files /dev/null and b/taobao/assets/camera/hole-front.png differ diff --git a/taobao/assets/camera/hole-left.png b/taobao/assets/camera/hole-left.png new file mode 100644 index 0000000..f903d0d Binary files /dev/null and b/taobao/assets/camera/hole-left.png differ diff --git a/taobao/assets/camera/hole-right.png b/taobao/assets/camera/hole-right.png new file mode 100644 index 0000000..a68420a Binary files /dev/null and b/taobao/assets/camera/hole-right.png differ diff --git a/taobao/assets/camera/icon-album.png b/taobao/assets/camera/icon-album.png new file mode 100644 index 0000000..bcafacd Binary files /dev/null and b/taobao/assets/camera/icon-album.png differ diff --git a/taobao/assets/camera/icon-shutter.png b/taobao/assets/camera/icon-shutter.png new file mode 100644 index 0000000..f2ed6c5 Binary files /dev/null and b/taobao/assets/camera/icon-shutter.png differ diff --git a/taobao/assets/camera/icon-switch-camera.png b/taobao/assets/camera/icon-switch-camera.png new file mode 100644 index 0000000..c9eaf2b Binary files /dev/null and b/taobao/assets/camera/icon-switch-camera.png differ diff --git a/taobao/assets/camera/mask-front-live.png b/taobao/assets/camera/mask-front-live.png new file mode 100644 index 0000000..2bf6af0 Binary files /dev/null and b/taobao/assets/camera/mask-front-live.png differ diff --git a/taobao/assets/camera/mask-front.png b/taobao/assets/camera/mask-front.png new file mode 100644 index 0000000..454812e Binary files /dev/null and b/taobao/assets/camera/mask-front.png differ diff --git a/taobao/assets/camera/mask-left-live.png b/taobao/assets/camera/mask-left-live.png new file mode 100644 index 0000000..f7fd0ae Binary files /dev/null and b/taobao/assets/camera/mask-left-live.png differ diff --git a/taobao/assets/camera/mask-left.png b/taobao/assets/camera/mask-left.png new file mode 100644 index 0000000..77d26a1 Binary files /dev/null and b/taobao/assets/camera/mask-left.png differ diff --git a/taobao/assets/camera/mask-right-live.png b/taobao/assets/camera/mask-right-live.png new file mode 100644 index 0000000..e0429f5 Binary files /dev/null and b/taobao/assets/camera/mask-right-live.png differ diff --git a/taobao/assets/camera/mask-right.png b/taobao/assets/camera/mask-right.png new file mode 100644 index 0000000..c94212e Binary files /dev/null and b/taobao/assets/camera/mask-right.png differ diff --git a/taobao/assets/camera/scan-base.png b/taobao/assets/camera/scan-base.png new file mode 100644 index 0000000..672462e Binary files /dev/null and b/taobao/assets/camera/scan-base.png differ diff --git a/taobao/assets/camera/scan-face-mask.png b/taobao/assets/camera/scan-face-mask.png new file mode 100644 index 0000000..4759c5c Binary files /dev/null and b/taobao/assets/camera/scan-face-mask.png differ diff --git a/taobao/assets/startup/bg-login-h5.webp b/taobao/assets/startup/bg-login-h5.webp new file mode 100644 index 0000000..8b9ef2f Binary files /dev/null and b/taobao/assets/startup/bg-login-h5.webp differ diff --git a/taobao/assets/startup/brand-login.png b/taobao/assets/startup/brand-login.png new file mode 100644 index 0000000..890554b Binary files /dev/null and b/taobao/assets/startup/brand-login.png differ diff --git a/taobao/assets/startup/icon-guest.png b/taobao/assets/startup/icon-guest.png new file mode 100644 index 0000000..58f81cb Binary files /dev/null and b/taobao/assets/startup/icon-guest.png differ diff --git a/taobao/components/brand-header.axml b/taobao/components/brand-header.axml new file mode 100644 index 0000000..4d43df0 --- /dev/null +++ b/taobao/components/brand-header.axml @@ -0,0 +1,8 @@ + diff --git a/taobao/config.js b/taobao/config.js index e0f097e..25dc4af 100644 --- a/taobao/config.js +++ b/taobao/config.js @@ -17,12 +17,33 @@ module.exports = { cloudAppId: '', // 聚石塔默认云应用可留空;多个云应用时填关联页的 cloudAppId cloudEnv: 'test', basePath: '', - // 淘宝 组件在真机(SDK 2.9.4)上会整页崩溃,且 canIUse('camera') 是假阳性。 - // 关掉后拍照页改为「系统相机 / 相册」两条 my.chooseImage 通道(权限 scope.camera 已授予)。 - // 等确认组件可用后把这里改回 true 即可恢复页内取景。 - nativeCamera: false, + // 当前手机已验证普通 camera 默认前置、连续帧和原生 JPEG;在本地跑 + // H5 人脸模型并自动拍摄。当前用户手机已确认自动三连拍可完成。 + captureMode: 'native-face', + // 正式入口使用已验证的自动三连拍;原相册入口保留。 + // 如需单独排查选图上传,可临时设为 'upload' 跳过实时识别。 + photoInputMode: 'camera', + // 连续识别帧使用平台支持的小尺寸,减少跨桥数据量;正式拍照仍为 high JPEG。 + nativeFrameSize: 'small', + // 轻量 68 点模型;160 输入保留本地等比双脸用例的多人识别能力。 + nativeFaceInputSize: 160, + nativeFaceMaxSide: 320, + nativeFaceLandmarkModel: 'tiny', + // CPU 路线真机仍约4.25s;使用经数值自测的原生WebGL,失败明确停止。 + nativeFaceBackend: 'webgl', + // 仅作为没有格式标记的紧密四通道帧的默认值;显式未知格式仍报错。 + nativePixelFormat: 'RGBA', + // AR 仅保留作开发对照。此值来自单次切换反馈,首次默认自拍未验收 + // 通过;不把该请求值视为已确认的实际镜头方向。 + arFrontPosition: 'back', + // 未标注格式且长度精确匹配四通道时使用的应用默认值;平台格式优先。 + // 用户已选择直接采用默认照片颜色,开发页颜色校验不作为使用门槛。 + arPixelFormat: 'RGBA', + nativeCamera: true, mvpLocal: false, mvpBaseUrl: 'http://127.0.0.1:8080', mock: false, - productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3 + // 当前三个推荐档位均进入用户确认的同一款天猫商品;评分及推荐文案仍按 H5。 + productItemId: '1090510453077', + productItemIds: { 1: '1090510453077', 2: '1090510453077', 3: '1090510453077' } }; diff --git a/taobao/mini.project.json b/taobao/mini.project.json index a95477a..17d1c0b 100644 --- a/taobao/mini.project.json +++ b/taobao/mini.project.json @@ -4,18 +4,26 @@ "miniprogramRoot": "./", "appid": "", "exclude": [ + "graphify-out/**", "scripts/**", "test/**", "docs/**", "README.md", - "package-lock.json" + "package-lock.json", + "pages/mvp/**", + "pages/camtest/**", + "pages/camnative/**" ], "uploadExclude": [ + "graphify-out/**", "scripts/**", "test/**", "docs/**", "README.md", - "package-lock.json" + "package-lock.json", + "pages/mvp/**", + "pages/camtest/**", + "pages/camnative/**" ], "enableAppxNg": true, "component2": true, diff --git a/taobao/pages/camera/camera.acss b/taobao/pages/camera/camera.acss index da8c111..7b3e8e9 100644 --- a/taobao/pages/camera/camera.acss +++ b/taobao/pages/camera/camera.acss @@ -1,6 +1,6 @@ /* ═════ 05-07 拍照页 ═════ 对齐 H5 h5/css/style.css 的 #s-cam 静态态: - .cam-step top 7.1%,21.5px/500,letter-spacing 1u,text-shadow + .cam-step top 7.1%,21.5px/700,letter-spacing 1u,text-shadow .cam-guide bottom 22.4%,14.5px,分隔线 15u 高 .cam-controls bottom 5.2%,高 62u,内边距 56u,快门 62u 单位换算:calc(N * var(--u)) → 2N rpx */ @@ -14,7 +14,7 @@ .cam-bg { position: absolute; left: 0; top: 0; right: 0; bottom: 0; - background: #262626; + background: #fff; } /* ─── 顶部步骤文案 ─── */ @@ -25,7 +25,7 @@ text-align: center; color: #fff; font-size: 21.5px; - font-weight: 500; + font-weight: 700; line-height: 1.5; letter-spacing: 2rpx; text-shadow: 0 1px 10px rgba(0, 0, 0, .4); @@ -43,26 +43,13 @@ z-index: 0; } -/* ─── 取景蒙版:H5 用 SVG 镂空出人脸轮廓;这里用「椭圆开窗 + 巨大 box-shadow」实现同样的暗框 + 中间透亮 ─── */ +/* H5 原始轮廓生成的透明 PNG;额外边缘遮罩保持不同比例视口的暗框。 */ .cam-mask { position: absolute; left: 0; top: 0; right: 0; bottom: 0; z-index: 1; pointer-events: none; } -.cam-hole { - position: absolute; - left: 50%; - top: 50%; - width: 440rpx; - height: 580rpx; - margin-left: -220rpx; - margin-top: -290rpx; - border-radius: 50%; - box-shadow: 0 0 0 3000rpx rgba(0, 0, 0, .82); -} -.mask-1 { transform: translateX(-26rpx) rotate(-4deg); } -.mask-2 { transform: translateX(26rpx) rotate(4deg); } /* ─── 底部引导条 ─── */ .cam-guide { @@ -101,7 +88,7 @@ z-index: 2; } .cam-side { - width: 120rpx; + width: 60rpx; height: 124rpx; display: flex; align-items: center; @@ -113,7 +100,7 @@ width: 124rpx; height: 124rpx; border-radius: 50%; - border: 4rpx solid #fff; + border: none; display: flex; align-items: center; justify-content: center; @@ -126,6 +113,36 @@ background: #fff; } +.cam-side image { width:60rpx; height:60rpx; } +.cam-side-switch { position:relative; } +.cam-switch-label { position:absolute; top:calc(50% + 34rpx); left:50%; transform:translateX(-50%); white-space:nowrap; font-size:10px; line-height:1.3; } +.cam-verifying { position:absolute; top:0; right:0; bottom:0; left:0; z-index:100; display:flex; flex-direction:column; align-items:center; justify-content:center; background:#101823; color:#fff; font-size:14px; gap:32rpx; } +.cam-shutter-auto { visibility:hidden; } +.cam-live-hint { position:absolute; bottom:27%; left:24rpx; right:24rpx; text-align:center; color:#fff; font-size:17px; font-weight:500; z-index:3; } +.cam-live-hint.good { color:#34C759; } +.cam-shutter image { width:124rpx; height:124rpx; } +.cam-mask-image { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); } +.cam-mask-image.mirror { transform:translate(-50%,-50%) scaleX(-1); } +.cam-live-scan { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); overflow:hidden; -webkit-mask-size:100% 100%; mask-size:100% 100%; } +.cam-live-scan.mirror { transform:translate(-50%,-50%) scaleX(-1); } +.scan-front { -webkit-mask-image:url('../../assets/camera/hole-front.png'); mask-image:url('../../assets/camera/hole-front.png'); } +.scan-right { -webkit-mask-image:url('../../assets/camera/hole-right.png'); mask-image:url('../../assets/camera/hole-right.png'); } +.scan-left { -webkit-mask-image:url('../../assets/camera/hole-left.png'); mask-image:url('../../assets/camera/hole-left.png'); } +.cam-live-band { position:absolute; top:10%; left:0; right:0; height:12%; background:linear-gradient(to bottom,rgba(245,240,120,0),rgba(245,240,120,.32),rgba(245,240,120,.95)); animation:camera-scan 2.2s ease-in-out infinite; } +@keyframes camera-scan { from {transform:translateY(0);} to {transform:translateY(440%);} } +.cam-mask-edge { position:absolute; background:rgba(0,0,0,.85); } +.edge-top { top:0; left:0; right:0; } .edge-bottom { bottom:0; left:0; right:0; } +.edge-left { left:0; top:0; bottom:0; } .edge-right { right:0; top:0; bottom:0; } +.cam-next { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:460rpx; box-sizing:content-box; padding:44rpx 32rpx 40rpx; border-radius:24rpx; background:rgba(0,0,0,.72); color:#fff; text-align:center; z-index:5; } +.cam-next-ok { margin:0 auto 24rpx; border-radius:50%; width:80rpx; height:80rpx; line-height:80rpx; background:#34C759; font-size:26px; } +.cam-next-done { font-size:17px; font-weight:500; } +.cam-next-tip { margin-top:16rpx; white-space:pre-line; font-size:14px; line-height:1.5; } +.cam-countdown { position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); color:#fff; font:600 88px/1 Arial,sans-serif; z-index:8; } +.cam-fill { position:absolute; top:0; left:0; right:0; bottom:0; background:#fff; z-index:9; } +.cam-enable { position:absolute; bottom:16%; left:50%; transform:translateX(-50%); padding:16rpx 32rpx; border:1px solid #fff; border-radius:8rpx; color:#fff; z-index:4; } +.cam-settings { position:absolute; top:24rpx; left:24rpx; color:#fff; font-size:12px; z-index:4; } +.compress-canvas { position:fixed; left:200vw; top:0; pointer-events:none; } + /* ─── 提示/错误 ─── */ .cam-hint { position: absolute; @@ -179,7 +196,7 @@ .cam-analyzing { position: absolute; left: 0; top: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, .78); + background: #000; display: flex; flex-direction: column; align-items: center; @@ -188,15 +205,27 @@ } .cam-analyzing-text { color: #fff; - font-size: 15px; + font-size: 17px; letter-spacing: 1rpx; + padding:0 40rpx; + text-align:center; + z-index:2; } .cam-cancel { - margin-top: 32rpx; + position:absolute; bottom:20rpx; left:0; right:0; text-align:center; color: rgba(255, 255, 255, .7); font-size: 13px; text-decoration: underline; } +.scan-stage { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); } +.scan-artwork { width:100%; height:100%; } +.scan-face { position:absolute; left:0; top:0; right:0; bottom:0; -webkit-mask-image:url('../../assets/camera/scan-face-mask.png'); mask-image:url('../../assets/camera/scan-face-mask.png'); -webkit-mask-size:100% 100%; mask-size:100% 100%; overflow:hidden; } +.scan-beam { position:absolute; left:0; right:0; top:20%; height:18%; background:linear-gradient(to bottom,#F5F078 0%,#F5F078 35%,rgba(245,240,120,0) 100%); animation:report-scan 1s ease-in-out infinite; } +@keyframes report-scan { 0%,100% {transform:translateY(0);} 50% {transform:translateY(166%);} } +.scan-flip-icon { position:absolute; left:77.314815%; top:87.900526%; width:8.888889%; height:4.591105%; background:#000; display:flex; align-items:center; justify-content:center; } +.scan-flip-icon image { width:83.333333%; height:83.333333%; } +.analysis-stage { position:absolute; bottom:100rpx; left:32rpx; right:32rpx; text-align:center; color:#bbb; font-size:12px; } +.analysis-retry,.analysis-retake { color:#fff; font-size:14px; padding:20rpx 36rpx; margin-top:24rpx; border:1px solid #777; border-radius:10rpx; z-index:2; } /* ─── 权限诊断(开发版) ─── */ .cam-diag-entry { @@ -239,6 +268,10 @@ padding: 14rpx 0; font-size: 13px; } +.cam-probe-toolbar { position:fixed; z-index:50; left:24rpx; right:24rpx; padding:14rpx 18rpx; border-radius:12rpx; background:rgba(12,39,56,.94); color:#fff; } +.cam-probe-version { margin-bottom:10rpx; font-size:22rpx; line-height:30rpx; } +.cam-probe-actions { display:flex; gap:14rpx; } +.cam-probe-actions button { flex:1; margin:0; padding:0 8rpx; font-size:25rpx; line-height:64rpx; background:#F5F078; color:#142632; } /* ─── 调试模式结果 ─── */ .cam-debug { diff --git a/taobao/pages/camera/camera.axml b/taobao/pages/camera/camera.axml index dd2f13a..6ee0492 100644 --- a/taobao/pages/camera/camera.axml +++ b/taobao/pages/camera/camera.axml @@ -1,23 +1,41 @@ - - + +
阶段累计流程今日流程
' + esc(it.record_id) + '' + esc(it.record_id) + '
' + esc(channelName(it.channel)) + '
' + esc(it.fail_stage || '-') + '' + esc(it.fail_code || '-') + '' + esc(it.fail_msg || '-') + '' + (it.real_age != null ? it.real_age + '岁' : '-') + '' + esc(it.shoot_status) + '' + fmtTime(it.created_at) + '' + (it.readonly ? '只读,请到原服务后台重试' : '') + '