Compare commits

..
85 Commits
Author SHA1 Message Date
xsl 0e34eeb1df fix: prevent duplicate titles on Taobao login page
Keep a single native title on the entry page even when the host ignores title clearing. Preserve centered headers elsewhere and normalize manifest line endings during shared-code checks.
2026-11-05 10:43:01 +08:00
xsl 873b45d3a5 fix: preserve legacy admin URLs across H5 channels
Keep local record and download paths compatible while retaining explicit external source selection. Record Taobao, H5, WeChat and rednote compatibility verification and baseline test failures.
2026-11-04 15:17:56 +08:00
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00
xsl 381da60ae4 chore: commit test-phase secrets so a new dev machine needs no manual copy
测试验证阶段,换开发机时不再需要手工拷贝两个机密文件。已与用户确认安全
要求不高,代价是密钥进入 git 历史,上线前必须清理。

入库(.gitignore 用 ! 例外放行):
- backend-java/src/main/resources/application-local.yml
  淘宝 AppKey/AppSecret + verify-sign + mode=forward + forward-base
- reference/facepp-skinanalyze/credentials.env
  旷视 FACEPP_API_KEY / FACEPP_API_SECRET / FACEPP_SKINANALYZE_URL
  (从服务器同名文件裁剪而来,只保留旷视相关行,去掉了其它项目条目)

其它密钥防护保持不变:apikey.*、.env、*.env 等仍被忽略。

上线前必做(已写入条待办第 6 项与 .gitignore 注释):
  1) 重置 AppSecret 与旷视 Key
  2) git filter-repo 从历史清除这两个文件
  3) 改回环境变量 / 保密字典注入

HANDOFF 1.5 节、待办第 6 项、交接检查清单已同步为「clone 即有」。
2026-11-01 23:53:11 +08:00
xsl 8bc6251899 docs(handoff): clarify where the two secret files come from
application-local.yml 在老开发机;credentials.env 只存在服务器
(老开发机上没有这个文件),需要 scp 取。补充了目标路径、验证命令
和「只做小程序端开发可以先不拷 credentials.env」的说明。
2026-11-01 23:51:04 +08:00
xsl 2eb0d8e807 docs(proposal): add Taobao product proposal decks and their generators
产品方案 PPT 是交付物,之前只存在于本机,换开发机后会丢,所以入库。

- 3 份 pptx:智能测肤方案(正版 1.3MB / 深色版 1.3MB)+ 购物小程序方案模版(0.1MB);
  模版是 build_taobao_proposal_from_template.py 的输入,必须一起入库
- 3 个生成脚本:把硬编码的 D:\juzi\... 改为相对仓库根(Path(__file__).parents[1]),
  渲染用的截图素材 temp/ppt_assets 属临时产物,加进 .gitignore
- build_taobao_proposal_pptx.py 是更早的一版(产物 AI智能测肤...pptx 当前不在仓库),
  保留用于追溯版式
- 新增 .gitignore:/sdk-java-35479221/(淘宝开放平台自动生成的 Java SDK,
  对应已废弃的 AppKey 35479221,当前架构走 HTTP 透传,不引 SDK)
2026-11-01 23:37:38 +08:00
xsl 43a5373233 docs: add development-machine handoff and server deploy configs
换开发机继续开发。新机器只需要拉仓库 + 自备工具链 + 单独拷贝两个机密文件,
服务器/聚石塔/SAE 全部沿用,不需要重建。

- docs/HANDOFF-2026-11-02.md:开发机交接(环境、仓库地图、三套测试、
  本地怎么跑、云端资源清单、上线方式、11 条关键结论、9 项待办、故障对照表)
- deploy/:服务器侧配置入库(原先只存在于机器上)
  - systemd/juzi-taobao.service(18081,独立数据目录与凭据文件)
  - nginx/tb-location.conf(/tb/ 反代 + 只放行聚石塔 NAT EIP)
  - add-tb-nginx.sh(幂等插入,先备份,nginx -t 通过才 reload)
  - README.md(重建与回滚步骤)
- tools/env-java.ps1:优先用仓库内便携工具链,缺失时回退 JAVA_HOME/PATH,
  换机器不用再下便携 JDK/Maven
- README.md:交接文档入口更新
2026-11-01 23:34:26 +08:00
xsl fed52930cc feat(miniapp): align UI with H5 and harden device debugging
五个页面按 h5/css 的 style.css → ui-20261009 → ui-20261012 覆盖顺序逐条重排,
换算规则固定为 calc(N * var(--u)) → 2N rpx,固定 px 的字号保持 px。

- app.acss:换回 H5 设计 token(方角小倒角按钮 10rpx、卡片 14rpx、.chev 折线箭头)
- splash/profile/history:底图与按钮文字图改用 H5 资源,隐私/政策弹窗按 H5 重排
- report:环形分用 conic-gradient、人脸裁切窗口叠加 face_maps、标记点百分比盒、
  侧脸皱纹 pending/unavailable 文案;用 Playwright 抓 H5 94 个选择器几何比对,
  整页高 2438.7 vs 2438.67
- camera:淘宝 <camera> 组件在真机整页崩溃(嵌了微信专有的 <cover-view>),
  改为默认系统相机 + `config.nativeCamera` 开关 + pages/camtest 冒烟页验证组件
- permissions:淘宝用带前缀的 scope.camera(裸名返回 5022 Scope:camera信息不存在),
  先试带前缀再退回裸名,拒绝后不再重复弹窗
- voice:非 HTTPS 资源导致的异常不再冒泡成整页崩溃;app.js 加开发版错误 toast
- 开发版入口:权限诊断(启动页/相机页)、相机组件测试
- 测试 20/20(新增权限与相机能力用例)
2026-11-01 23:30:38 +08:00
xsl f5c416dd9d feat(sae): add cloud-gateway adapters and forward mode (0.0.4)
SAE 上这一版只做云网关适配层:/api/taobao/* 字节级透传给 1me.xajuzi.com 上
现有的 H5 后端(旷视调用、评分、报告都留在那边),Java 不再重写测肤逻辑。

- TaobaoEnvelope:客户端要求的 data.ok + data.http_status 信封(HTTP 恒 200)
- TaobaoAuthController:challenge/session/me/logout(native 模式)
- TaobaoSessionService:一次性 challenge + 30 天 Bearer token,按 open_id 限流
  (网关后面所有请求同源 IP,按 IP 计数会变成全站共享 30 次/分钟)
- TaobaoForwardController:mode=forward 时透传,保留原始 query 与 body 字节
  (签名覆盖原始 body,重新序列化会让验签失败)
- TaobaoSignatureVerifier:缺省 sign_method 按 md5 校验
- 打包名固定为 taobao-mvp-backend-0.0.4-forward,SAE 不需要任何环境变量
- pom 版本 0.0.4;mvn test 11/11(新增 8 个用例)
- docs:更新 SAE 操作步骤,新增本次交接文档
2026-11-01 23:30:35 +08:00
xsl 65ba3cc7a5 fix(taobao): default cloud gateway signature to the md5 form
真实网关请求不带 sign_method(2026-11-01 用 nginx access.log 抓到的真实请求离线复算确认),
缺省签名形式是 MD5(secret + content + secret),也就是 sign_method=md5 的算法,
而不是 MD5(content + secret)。旧实现按「空方法」走了后者,必然 mismatch,
真机登录一直卡在 401「淘宝身份校验失败」。

- h5/lib/taobao-auth.js:缺省 method 归一为 md5
- h5/test/taobao-auth.js:把抓包参数固化成回归测试(含旧算法的反例与 verify 正反例)
2026-11-01 23:30:29 +08:00
xsl ac0b7ca42b feat(sae): add Java MVP backend and Taobao connectivity page
Spring Boot 3.5.6 / Java 17 JAR with cloud gateway envelope and ping/whoami/echo; Taobao gateway signature verification matching the Node implementation; /pages/mvp page, splash dev entry and local transport contract test; SAE + 聚石塔 runbook and portable JDK/Maven setup; Windows 11 wmic shim for Taobao DevTools.
2026-10-31 18:35:04 +08:00
xsl 459e21dad4 docs(rednote): record verified isolated production deployment 2026-10-19 23:04:28 +08:00
xsl 4af825a5e4 feat(rednote): add isolated persistent browser H5 entry 2026-10-19 22:59:10 +08:00
xsl 9eec4369da feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
2026-10-19 20:14:31 +08:00
xsl d07f8cc1bb save 2026-10-17 17:01:46 +08:00
xsl bb6da17f54 fix(recommendation): use displayed skin age across reports and admin 2026-10-16 19:03:25 +08:00
xsl 571d78a2ed ui(report): show wrinkle loading hints over side photos 2026-10-16 16:45:07 +08:00
xsl 9181069643 perf(report): load side wrinkle layers after report entry 2026-10-16 16:34:11 +08:00
xsl b19811f974 fix(camera): add 30-second scan waiting watchdog 2026-10-16 16:07:53 +08:00
xsl 5e07985b19 fix(camera): remove post-capture photo revalidation 2026-10-16 16:01:25 +08:00
xsl 6a628f837b fix(camera): make lighting checks advisory only 2026-10-16 15:51:07 +08:00
xsl 9ff7a582af feat(admin): track capture-to-report conversion funnel 2026-10-16 15:45:13 +08:00
xsl 03a8907b4e fix(camera): lower frontal and side confidence thresholds 2026-10-16 15:35:53 +08:00
xsl 30c90f8df5 fix(camera): relax initial frontal face recognition 2026-10-16 15:27:36 +08:00
xsl 8365ef459f fix(report): remove eye conditions from contour conclusions 2026-10-16 11:05:34 +08:00
xsl 98d6189f5c final save 2026-10-15 22:16:01 +08:00
xsl 49db1a6f5b fix(camera): relax pitch and roll acceptance limits 2026-10-15 21:16:46 +08:00
xsl c6d65493b6 fix(camera): set accepted face brightness to 110–220 2026-10-15 20:51:19 +08:00
xsl fbc556d1a6 fix(report): move side photo preview fill upward 2026-10-15 20:47:41 +08:00
xsl 8115db4ec2 fix(camera): constrain brightness and adjust side photo previews 2026-10-15 20:41:28 +08:00
xsl 480fed40cc fix(camera): raise minimum face brightness to 100 2026-10-15 20:23:45 +08:00
xsl 5632ac43d7 fix(report): cap dimension scores and match zoom crops to thumbnails 2026-10-15 20:17:07 +08:00
xsl 482fb4280d fix(h5): relax capture angles and annotate acne marks 2026-10-15 19:55:21 +08:00
xsl 757b96dfad feat(report): add annotated wrinkle views and restore capture countdown 2026-10-15 19:45:21 +08:00
xsl 2d80f6dfd0 feat(report): unify display grades and add contour side photos 2026-10-15 17:14:05 +08:00
xsl 60f0965c5a feat(scoring): use independent tone hydration minima and focus-first care 2026-10-15 14:40:59 +08:00
xsl 77493bdff5 feat(h5): align report care and single-product section with updated design 2026-10-15 01:30:45 +08:00
xsl 60ae6c97ef feat(h5): update skin scoring, age display and capture countdown 2026-10-15 00:20:48 +08:00
xsl d8c67762da feat(h5): calculate integer total from weighted dimensions across reports and admin 2026-10-14 14:25:17 +08:00
xsl 61a7f7c726 fix(h5): enlarge regular brand text for optical alignment 2026-10-13 22:40:04 +08:00
xsl fc711e3b1c fix(h5): add comma after brand name on splash and photo guide 2026-10-13 20:29:40 +08:00
xsl 6acc541cd8 fix(h5): unify regular slogan typography on splash and photo guide 2026-10-13 19:01:00 +08:00
xsl 84a92137aa fix(h5): use replacement flip icon on report scanning screen 2026-10-13 18:04:32 +08:00
xsl 7381306139 fix(h5): optimize camera flipping and angle haptics, refresh splash branding 2026-10-13 17:57:55 +08:00
xsl e71368db6d fix(h5): show raw skin age, support Harmony touch pickers and add WeChat logout 2026-10-13 16:12:54 +08:00
xsl 4e42e33994 fix(h5): rename guest entry button to 游客登陆 2026-10-13 15:28:07 +08:00
xsl 1fdbb575b0 feat(h5): allow guest access without login and retain history only for WeChat 2026-10-13 15:23:23 +08:00
xsl 98935dd168 fix(h5): prevent native scrolling from stealing diagonal image swipes 2026-10-13 00:42:01 +08:00
xsl 4c478b16e3 fix(h5): prioritize horizontal report image gestures 2026-10-13 00:32:44 +08:00
xsl 3efa28b311 fix(h5): reduce report swipe conflicts and limit edge overscroll 2026-10-13 00:24:52 +08:00
xsl f1e64565b9 fix(h5): center grouped score unit and tighten report summary spacing 2026-10-13 00:12:02 +08:00
xsl 6f83f58a09 fix(h5): center score stack, lock profile scroll and gate turn guidance 2026-10-12 23:48:11 +08:00
xsl 2a49ebdab2 test(deploy): verify report touch release on public server 2026-10-12 22:44:41 +08:00
xsl 63c9e065a9 feat(h5): refine report layout, add finger-following tabs and optimize images 2026-10-12 22:36:44 +08:00
xsl bcf198ca6f 添加反馈文档 2026-10-12 21:46:47 +08:00
xsl 69dc9962c0 fix(h5): show captured face behind report scan animation 2026-10-12 21:21:49 +08:00
xsl ce6dbc20d4 feat(h5): remember profile selections and add finger-following skin swipe 2026-10-12 21:10:00 +08:00
xsl c9352fe4ca fix(h5): ease camera capture requirements and rename page title 2026-10-12 20:47:58 +08:00
xsl 3f165bf9e5 test(deploy): verify fourth feedback capture and public release 2026-10-12 20:27:45 +08:00
xsl 92a3965b7d feat(h5): apply fourth feedback and tighten local capture validation 2026-10-12 20:16:41 +08:00
xsl e53c809522 docs(deploy): record third feedback production verification 2026-10-12 16:38:36 +08:00
xsl c7b085e795 feat(h5): align third feedback with latest PSD and scanning screen 2026-10-12 16:33:10 +08:00
xsl f3e7eac909 test(deploy): verify second feedback release on public site 2026-10-12 14:40:25 +08:00
xsl fe9623a1d7 feat(h5): apply second customer UI feedback and homepage refinements 2026-10-12 14:35:39 +08:00
xsl abb1c4c1de docs(deploy): record customer feedback release verification 2026-10-12 13:18:21 +08:00
xsl d7ae748e08 feat(h5): apply October 12 customer report feedback 2026-10-12 13:15:09 +08:00
xsl 3193a4b683 docs(auth): record deployed login performance verification 2026-10-12 11:03:47 +08:00
xsl d2931a41e4 feat(auth): clarify login identity and speed up mobile entry 2026-10-12 11:01:49 +08:00
xsl 85790ef985 docs(auth): record live preflight and portable log checks 2026-10-12 10:11:44 +08:00
xsl d68f841f0e fix(auth): harden WeChat login and explicit identity switching 2026-10-12 10:07:54 +08:00
xsl 0ec5e5dc32 fix(report): recommend by measured skin age and align admin results 2026-10-12 00:23:57 +08:00
xsl 7518dc2307 fix(h5): sync latest report copy and deployment verification 2026-10-11 22:59:21 +08:00
Ubuntu 3dda4ec7ff 话术有修改 2026-10-11 22:52:15 +08:00
xsl 55b3a45282 添加参考图 psd 2026-10-11 17:51:48 +08:00
xsl ce8d0ff1f7 feat(h5): align October UI and add WeChat auth with saved reports 2026-10-11 17:28:11 +08:00
xsl ee744b6b5f save 2026-10-10 14:24:32 +08:00
root 652c977cec 第二版修改 2026-10-10 09:46:17 +08:00
root 4b26288098 save 2026-10-09 23:41:04 +08:00
root 658b232a39 save 2026-10-09 23:22:28 +08:00
Ubuntu f7902b86c8 save 2026-10-09 22:29:02 +08:00
xsl 55f26d91af 修改整理反馈需求 2026-10-09 22:12:33 +08:00
xsl 499be22771 第一版反馈 2026-10-09 21:08:12 +08:00
Ubuntu c06d15a2d4 chore+perf: 密钥出库 + 后台详情接口裁剪 face_maps
安全:
- reference/facepp-skinanalyze/credentials.env 内含 Face++ 正式 key/secret
  且被 git 跟踪,移出 index(文件保留本地,.gitignore 已覆盖 *.env)。
  ⚠️ 密钥已存在于 git 历史,若 origin 曾被 push 即视为泄漏,
  请在旷视控制台重置 API secret,改用环境变量/apikey.bash 注入。

性能(后台详情页卡顿):
- 详情接口将 raw_json.result.face_maps 的 base64 图层(单条约 3MB)
  替换为占位说明,响应 3.2MB→163KB;新增
  GET /api/admin/records/:id/raw 供下载完整原始 JSON(走 Basic Auth)
- admin 列表/详情图片加 loading=lazy + decoding=async
2026-09-28 22:46:08 +08:00
Ubuntu 3de730a127 save 2026-09-28 15:27:31 +08:00
xsl 3d32434d6f 添加后端文档 2026-09-28 10:49:09 +08:00
549 changed files with 82371 additions and 3410 deletions
+30 -3
View File
@@ -1,19 +1,46 @@
# 密钥与敏感配置 —— 绝不入库
# 密钥与敏感配置
apikey.bash
apikey.*
aipkey.bash
.env
.env.*
*.env
credentials.env
# ⚠️ 2026-11-02 测试验证阶段的例外(刻意入库,方便切换开发机)
# reference/facepp-skinanalyze/credentials.env —— 旷视 Face++ 凭据
# backend-java/src/main/resources/application-local.yml —— 淘宝 AppKey/AppSecret
# 约束:
# 1) 仅限内网私有仓库 git.xiangsilian.com/xsl/juzi,不要 fork 到公开仓库;
# 2) 正式上线前必须重置密钥 → 从 git 历史清除 → 改回环境变量/保密字典注入;
# 3) 不要复制到聊天、文档、工单等任何外部渠道。
# 下面两条用 ! 覆盖上面的通配规则(必须放在被覆盖的规则之后)。
!reference/facepp-skinanalyze/credentials.env
!/backend-java/src/main/resources/application-local.yml
# 运行时产物
node_modules/
*.log
__pycache__/
aipkey.bash
h5/data/
temp/
# Java MVP 构建产物
/backend-java/target/
# 便携工具链(体积大,本机安装,不入库)
/tools/jdk17/
/tools/maven/
/tools/downloads/
# 大型设计源文件,不入库
new_feedback/第三轮反馈20261012/UI外发版10.09.psd
# 淘宝开放平台自动生成的 Java SDK(对应已废弃的 AppKey 35479221,当前架构不引 SDK)
/sdk-java-35479221/
# 提案 PPT 生成时用的截图素材(临时产物,可重新截取)
/temp/ppt_assets/
# graphify 本地缓存与日期快照(可重建,无需入库)
graphify-out/cache/
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
+1
View File
@@ -0,0 +1 @@
kpx234XimQGtFPo6
+28
View File
@@ -0,0 +1,28 @@
# 巨子测肤 · H5 与淘宝小程序
本仓库包含两个入口,共用 Node 后端、Face++ 检测、评分与报告话术。
- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。
- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。
- `backend-java/`:SAE + 淘宝云网关的 Java 最小连通性 MVP;操作步骤见 [docs/SAE-Java-MVP-操作步骤.md](docs/SAE-Java-MVP-操作步骤.md)。
- 当前交接(**换开发机先读这份**):[docs/HANDOFF-2026-11-02.md](docs/HANDOFF-2026-11-02.md)。
- 上一轮交接:[docs/HANDOFF-2026-11-01.md](docs/HANDOFF-2026-11-01.md)。
- 服务器侧配置(systemd / nginx `/tb/` 白名单 / 安装脚本):[deploy/README.md](deploy/README.md)。
- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
- `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。
- `backend/docs/`:后台需求资料;可运行的共享服务实际在 `h5/server.js`。
- `temp/`:本地测试数据、预览和截图,已忽略,不提交。
兼容边界:允许共享后端扩展,但保持 H5 的页面、微信公众号链接、微信登录、游客不留存和历史报告行为。淘宝使用独立账号来源,不把淘宝账号与微信账号自动合并。
本轮工作只在本地开发和样本验证,未部署或修改线上服务器。启动本地隔离环境:
```powershell
cd taobao
npm ci
npm run dev:backend
```
H5 本地入口为 http://127.0.0.1:18180;这个环境固定使用测试样本,不调用真实 Face++。生产 H5 的运行说明仍见 [h5/README.md](h5/README.md)。
+56
View File
@@ -0,0 +1,56 @@
# taobao-mvp-backend
淘宝小程序 + 淘宝云网关 + SAE 的最小 Java 后端验证工程。
## 快速启动
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn -DskipTests package
java -jar target\taobao-mvp-backend-0.0.2.jar
```
启动后:
- 健康检查:<http://127.0.0.1:8080/actuator/health>
- 云网关测试接口:`POST http://127.0.0.1:8080/api/taobao/ping`
## 接口
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/actuator/health` | SAE 健康检查 |
| GET/POST | `/api/taobao/ping` | 返回统一 envelope,验证连通性 |
| POST | `/api/taobao/whoami` | 返回网关注入的 query 参数(open_id/app_key/env/sign 等) |
| POST | `/api/taobao/echo` | 原样回显 JSON body,验证 POST/Content-Type |
统一响应:
```json
{"success":true,"data":{"ok":true}}
```
失败:
```json
{"success":false,"errorCode":"SIGN_INVALID","errorMessage":"..."}
```
## 环境变量(SAE)
| 变量 | 本地 | 云端 |
|---|---|---|
| `TAOBAO_APP_KEY` | 可空 | 淘宝开放平台 AppKey |
| `TAOBAO_APP_SECRET` | 可空 | AppSecret,只放服务端 |
| `TAOBAO_VERIFY_SIGN` | `false` | `true` |
云端必须用 `TAOBAO_VERIFY_SIGN=true`,否则任何知道地址的人都能直接调用接口。
## 说明
- 本工程只实现连通性 MVP:`ping/whoami/echo`。
- 真实的 `challenge/session/photo/skin-analyze/analysis/reports` 尚未迁移;现有 Node 实现见 `../h5/lib/taobao-routes.js`,可作为 Java 迁移的接口契约参考。
- SAE 部署时选 **JAR 包部署 + 标准 Java 应用运行环境 + Open JDK 17**,不要选 WAR/Tomcat/EDAS-Container。
- 端口 `8080`,健康检查 `/actuator/health`,至少 2 实例。
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
<relativePath/>
</parent>
<groupId>com.juzi</groupId>
<artifactId>taobao-mvp-backend</artifactId>
<version>0.0.4</version>
<name>taobao-mvp-backend</name>
<description>Taobao mini program + SAE + cloud gateway minimal MVP backend</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<!-- 文件名带 forward 标识,避免和 0.0.2(native 模式)混淆 -->
<finalName>taobao-mvp-backend-0.0.4-forward</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.juzi.mvp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MvpApplication {
public static void main(String[] args) {
SpringApplication.run(MvpApplication.class, args);
}
}
@@ -0,0 +1,20 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 淘宝云网关要求的统一响应结构。
* 成功:{"success":true,"data":{...}}
* 失败:{"success":false,"errorCode":"...","errorMessage":"..."}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(boolean success, T data, String errorCode, String errorMessage) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, null);
}
public static <T> ApiResponse<T> error(String errorCode, String errorMessage) {
return new ApiResponse<>(false, null, errorCode, errorMessage);
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
@RestController
@RequestMapping("/api/taobao")
public class MvpController {
private final TaobaoSignatureVerifier verifier;
private final ObjectMapper objectMapper;
public MvpController(TaobaoSignatureVerifier verifier, ObjectMapper objectMapper) {
this.verifier = verifier;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/ping", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> ping(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("pong", true);
data.put("server", "java");
data.put("time", OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
data.put("transport", isGateway(request) ? "gateway" : "direct");
data.put("appKey", param(request, "app_key"));
data.put("openId", param(request, "open_id"));
data.put("env", param(request, "env"));
data.put("signVerified", check.verified());
data.put("signMessage", check.message());
return ApiResponse.ok(data);
}
@GetMapping("/ping")
public ApiResponse<Map<String, Object>> pingGet(HttpServletRequest request) {
return ping(null, request);
}
@PostMapping(value = "/whoami", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> whoami(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("transport", isGateway(request) ? "gateway" : "direct");
data.put("signVerified", check.verified());
data.put("signMessage", check.message());
data.put("params", sortedParams(request));
return ApiResponse.ok(data);
}
@PostMapping(value = "/echo", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> echo(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("bytes", rawBody == null ? 0 : rawBody.getBytes(StandardCharsets.UTF_8).length);
data.put("body", parseBody(rawBody));
data.put("signVerified", check.verified());
return ApiResponse.ok(data);
}
private Object parseBody(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<Object>() {
});
} catch (Exception e) {
Map<String, Object> fallback = new LinkedHashMap<>();
fallback.put("_raw", raw);
fallback.put("_parseError", e.getMessage());
return fallback;
}
}
private boolean isGateway(HttpServletRequest request) {
return request.getParameter("app_key") != null;
}
private String param(HttpServletRequest request, String name) {
String value = request.getParameter(name);
return value == null ? "" : value;
}
private Map<String, String> sortedParams(HttpServletRequest request) {
Map<String, String> out = new TreeMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0 && !"sign".equals(key)) {
out.put(key, values[0]);
}
});
return out;
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序登录相关的四个接口:challenge / session / me / logout。
*
* <p>契约来自参考实现 h5/lib/taobao-routes.js 与客户端 taobao/utils/api.js:
* <ul>
* <li>HTTP 永远 200,逻辑状态放 data.http_status(见 {@link TaobaoEnvelope})。</li>
* <li>客户端不发身份,身份来自云网关注入的 query 参数(app_key / open_id / sign)。</li>
* <li>session 必须带 consent=true 和一次性的 challenge。</li>
* <li>登录后所有接口用 Authorization: Bearer &lt;token&gt;。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/taobao")
@ConditionalOnProperty(name = "taobao.mode", havingValue = "native", matchIfMissing = true)
public class TaobaoAuthController {
private static final String OPEN_ID_PATTERN = "[A-Za-z0-9_-]{4,256}";
private static final String IDENTITY_FAILED = "淘宝身份校验失败,请重新授权登录";
private static final String SESSION_EXPIRED = "检测会话已失效,请重新登录";
private final TaobaoSignatureVerifier verifier;
private final TaobaoSessionService sessions;
private final ObjectMapper objectMapper;
public TaobaoAuthController(TaobaoSignatureVerifier verifier,
TaobaoSessionService sessions,
ObjectMapper objectMapper) {
this.verifier = verifier;
this.sessions = sessions;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/challenge", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> challenge(HttpServletRequest request) {
String id = sessions.challenge(sessions.rateKey(request));
if (id == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(429, "操作频繁,请稍后重试"));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("challenge", id);
return ApiResponse.ok(data);
}
@PostMapping(value = "/session", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> session(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
Map<String, Object> body = parseBody(rawBody);
if (!Boolean.TRUE.equals(body.get("consent"))) {
return ApiResponse.ok(TaobaoEnvelope.fail(400, "请先阅读并同意隐私协议"));
}
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
String openId = request.getParameter("open_id");
if (openId == null || !openId.matches(OPEN_ID_PATTERN)) {
// 只有经由云网关的请求才带 open_id;本地直连需要自己拼 ?open_id=...
return ApiResponse.ok(TaobaoEnvelope.fail(401, "未取得淘宝身份,请在手机淘宝内重新打开"));
}
Object challenge = body.get("challenge");
if (!(challenge instanceof String challengeId) || !sessions.consume(challengeId)) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
TaobaoSessionService.Token token = sessions.issue(openId);
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("token", token.value());
data.put("expires_in", token.expiresInSeconds());
data.put("user", user(token.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/me", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> me(HttpServletRequest request) {
TaobaoSessionService.Session session = sessions.find(TaobaoSessionService.bearerToken(request));
if (session == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, SESSION_EXPIRED));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("user", user(session.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/logout", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> logout(HttpServletRequest request) {
sessions.revoke(TaobaoSessionService.bearerToken(request));
return ApiResponse.ok(TaobaoEnvelope.ok(200));
}
private Map<String, Object> user(String userId) {
Map<String, Object> user = new LinkedHashMap<>();
user.put("id", userId);
user.put("kind", "taobao");
return user;
}
private Map<String, Object> parseBody(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
return Map.of();
}
}
}
@@ -0,0 +1,33 @@
package com.juzi.mvp.api;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序客户端读取的 data 结构约定。
*
* <p>客户端(taobao/utils/api.js:13-16)只认 data.ok 和 data.http_status:
* HTTP 状态码永远是 200,逻辑状态(202/400/401/404/409/413/429)放在 data.http_status 里。
* 失败时 message 就是 toast 文案,status 401 还会触发客户端自动登出。
* 这与参考实现 h5/lib/taobao-routes.js:8 保持一致。
*/
public final class TaobaoEnvelope {
private TaobaoEnvelope() {
}
public static Map<String, Object> ok(int httpStatus) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("http_status", httpStatus);
return data;
}
public static Map<String, Object> fail(int httpStatus, String message) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", false);
data.put("http_status", httpStatus);
data.put("message", message);
return data;
}
}
@@ -0,0 +1,117 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* taobao.mode=forward 时启用:把 /api/taobao/* 业务接口字节级转发给外层 H5 后端(Node)。
*
* <p>为什么必须字节级透传:对端用「原始 query string + 原始 body」验淘宝签名
* (h5/lib/taobao-auth.js:16-29)。Java 一旦重新序列化 JSON 或重排 query,
* 签名字节就变了,session 会直接 401。所以这里不读 body 语义、不动 JSON。
*
* <p>/ping、/whoami、/echo 仍然由 {@link MvpController} 自己处理(更具体的映射优先)。
*/
@RestController
@ConditionalOnProperty(name = "taobao.mode", havingValue = "forward")
public class TaobaoForwardController {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
/** 云网关单请求 10 秒上限,留出余量。 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(9);
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
private final String forwardBase;
public TaobaoForwardController(@Value("${taobao.forward-base}") String forwardBase) {
this.forwardBase = forwardBase.endsWith("/")
? forwardBase.substring(0, forwardBase.length() - 1)
: forwardBase;
}
@RequestMapping("/api/taobao/{*path}")
public ResponseEntity<byte[]> forward(HttpServletRequest request) {
byte[] body = readBody(request);
String target = forwardBase + request.getRequestURI()
+ (request.getQueryString() == null ? "" : "?" + request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
.timeout(REQUEST_TIMEOUT)
.method(request.getMethod(), body.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body));
copyHeader(request, builder, "Content-Type");
copyHeader(request, builder, "Authorization");
HttpResponse<byte[]> response;
try {
response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
return envelope(502, "测肤服务连接失败");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return envelope(502, "测肤服务被中断");
}
// 对端正常返回时已经是客户端要的信封(h5/lib/taobao-routes.js:8),原样回传。
if (looksLikeEnvelope(response.body())) {
return ResponseEntity.status(response.statusCode())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(response.body());
}
// 对端没部署 / 返回 404 页面 / 网关错误:包成统一信封,避免小程序 JSON.parse 失败。
return envelope(502, "测肤服务暂不可用(上游 " + response.statusCode() + ")");
}
private void copyHeader(HttpServletRequest request, HttpRequest.Builder builder, String name) {
String value = request.getHeader(name);
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
private static byte[] readBody(HttpServletRequest request) {
try {
return request.getInputStream().readAllBytes();
} catch (IOException e) {
return new byte[0];
}
}
private static boolean looksLikeEnvelope(byte[] body) {
if (body == null || body.length == 0) {
return false;
}
String text = new String(body, StandardCharsets.UTF_8).stripLeading();
return text.startsWith("{") && text.contains("\"success\"");
}
private static ResponseEntity<byte[]> envelope(int status, String message) {
String json = "{\"success\":true,\"data\":{\"ok\":false,\"http_status\":" + status
+ ",\"message\":\"" + message + "\"}}";
return ResponseEntity.status(200)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(json.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,154 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 淘宝小程序会话:一次性的 challenge + 30 天有效的 Bearer token。
*
* <p>参考实现 h5/lib/taobao-session.js 的取值:challenge 60 秒、单次消费、
* 每来源 30 次/分钟;token 32 字节随机十六进制、有效期 30 天。
*
* <p>注意:全部状态都在进程内存里,SAE 只能单副本运行;多副本/发布不丢会话
* 需要换 Redis 或 RDS。这一点和现有 Node 实现一致,不是新增的退化。
*/
@Component
public class TaobaoSessionService {
private static final long CHALLENGE_TTL_SECONDS = 60;
private static final long TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
private static final int MAX_OUTSTANDING_CHALLENGES = 1000;
private static final int RATE_LIMIT_PER_MINUTE = 30;
private final SecureRandom random = new SecureRandom();
private final Map<String, Instant> challenges = new ConcurrentHashMap<>();
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final Map<String, Rate> rates = new ConcurrentHashMap<>();
@Value("${taobao.app-key:}")
private String appKey;
private record Rate(Instant window, int count) {
}
public record Session(String userId, String openId, Instant expires) {
}
public record Token(String value, long expiresInSeconds, String userId) {
}
/**
* 限流计数用的来源标识。
*
* <p>云网关后面所有请求的来源 IP 都是同一个代理 IP,按 IP 计数会变成全站共享
* 30 次/分钟,所以在有 open_id 时优先按用户计数。
*/
public String rateKey(HttpServletRequest request) {
String openId = request.getParameter("open_id");
return openId != null && !openId.isBlank() ? "open:" + openId : "ip:" + request.getRemoteAddr();
}
/**
* 发放一次性 challenge;超出频率或存量上限时返回 null(调用方回 429)。
*/
public String challenge(String rateKey) {
sweep();
Instant now = Instant.now();
Rate rate = rates.compute(rateKey, (key, current) ->
current == null || !current.window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now)
? new Rate(now, 1)
: new Rate(current.window(), current.count() + 1));
if (rate.count() > RATE_LIMIT_PER_MINUTE || challenges.size() >= MAX_OUTSTANDING_CHALLENGES) {
return null;
}
String id = randomHex(24);
challenges.put(id, now.plusSeconds(CHALLENGE_TTL_SECONDS));
return id;
}
/**
* 消费 challenge;remove 返回非空即视为抢到,天然只成功一次。
*/
public boolean consume(String challenge) {
if (challenge == null || challenge.isBlank()) {
return false;
}
Instant expires = challenges.remove(challenge);
return expires != null && expires.isAfter(Instant.now());
}
public Token issue(String openId) {
String userId = userId(openId);
String value = randomHex(32);
sessions.put(value, new Session(userId, openId, Instant.now().plusSeconds(TOKEN_TTL_SECONDS)));
return new Token(value, TOKEN_TTL_SECONDS, userId);
}
public Session find(String token) {
if (token == null || token.isBlank()) {
return null;
}
Session session = sessions.get(token);
if (session == null) {
return null;
}
if (!session.expires().isAfter(Instant.now())) {
sessions.remove(token);
return null;
}
return session;
}
public void revoke(String token) {
if (token != null) {
sessions.remove(token);
}
}
/**
* 稳定的用户 id:sha256(app_key + ':' + open_id),与 Node 端 provider_key 口径一致。
*/
public String userId(String openId) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String seed = (appKey == null ? "" : appKey) + ":" + openId;
return HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/** 从 Authorization: Bearer xxx 里取 token。 */
public static String bearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null) {
return null;
}
String prefix = "Bearer ";
return header.regionMatches(true, 0, prefix, 0, prefix.length())
? header.substring(prefix.length()).trim()
: null;
}
private void sweep() {
Instant now = Instant.now();
challenges.entrySet().removeIf(entry -> !entry.getValue().isAfter(now));
sessions.entrySet().removeIf(entry -> !entry.getValue().expires().isAfter(now));
rates.entrySet().removeIf(entry -> !entry.getValue().window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now));
}
private String randomHex(int bytes) {
byte[] buffer = new byte[bytes];
random.nextBytes(buffer);
return HexFormat.of().formatHex(buffer);
}
}
@@ -0,0 +1,21 @@
package com.juzi.mvp.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 仅用于本地浏览器/curl 调试。小程序原生请求不受 CORS 限制。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
}
@@ -0,0 +1,126 @@
package com.juzi.mvp.gateway;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* 淘宝云网关签名校验的最小实现。
*
* 规则(与 h5/lib/taobao-auth.js 一致):
* 1. 取全部 query 参数,去掉 sign 和空值,按 key 升序,拼成 key1value1key2value2...
* 2. 末尾追加原始请求体(未经重新序列化的 JSON 字符串)
* 3. sign_method=md5 时:MD5(secret + content + secret)
* sign_method 为空时:MD5(content + secret)
* hmac/hmac_md5:HMAC-MD5(secret, content)
* hmac_sha256:HMAC-SHA256(secret, content)
* 4. 结果转大写十六进制,与 sign 比较
*
* 本地直连没有 app_key/sign,默认 verify-sign=false 直接放行;
* 云端部署时设置 TAOBAO_VERIFY_SIGN=true 和 TAOBAO_APP_KEY/TAOBAO_APP_SECRET。
*/
@Component
public class TaobaoSignatureVerifier {
@Value("${taobao.app-key:}")
private String appKey;
@Value("${taobao.app-secret:}")
private String appSecret;
@Value("${taobao.verify-sign:false}")
private boolean verifySign;
public record Result(boolean accepted, boolean verified, String message) {
}
public Result verify(HttpServletRequest request, String rawBody) {
if (!verifySign) {
return new Result(true, false, "verify-sign=false, skipped (local/direct mode)");
}
if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank()) {
return new Result(false, false, "TAOBAO_APP_KEY / TAOBAO_APP_SECRET is not configured");
}
Map<String, String> params = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
String[] values = entry.getValue();
if (values == null || values.length == 0) {
continue;
}
if (values.length > 1) {
return new Result(false, false, "duplicate parameter: " + entry.getKey());
}
params.put(entry.getKey(), values[0]);
}
if (!appKey.equals(params.get("app_key"))) {
return new Result(false, false, "app_key mismatch");
}
String openId = params.getOrDefault("open_id", "");
if (!openId.matches("[A-Za-z0-9_-]{4,256}")) {
return new Result(false, false, "open_id missing or invalid");
}
String sign = params.getOrDefault("sign", "");
if (!sign.matches("[A-Fa-f0-9]{32,64}")) {
return new Result(false, false, "sign missing or invalid");
}
try {
String expected = sign(params, rawBody == null ? "" : rawBody, appSecret);
if (expected == null || expected.length() != sign.length()) {
return new Result(false, false, "unsupported sign_method");
}
boolean equal = MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
sign.toUpperCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8));
return equal ? new Result(true, true, "ok") : new Result(false, false, "signature mismatch");
} catch (Exception e) {
return new Result(false, false, "signature error: " + e.getMessage());
}
}
public static String sign(Map<String, String> params, String body, String secret) throws Exception {
// 真实网关请求不带 sign_method,缺省等价于 md5:
// MD5(secret + content + secret)。2026-11-01 用抓包参数离线复算确认。
String rawMethod = params.getOrDefault("sign_method", "");
String method = rawMethod.isEmpty() ? "md5" : rawMethod;
if (!Set.of("md5", "hmac", "hmac_md5", "hmac_sha256").contains(method)) {
return null;
}
StringBuilder content = new StringBuilder();
params.entrySet().stream()
.filter(e -> !"sign".equals(e.getKey()))
.filter(e -> e.getValue() != null && !e.getValue().isEmpty())
.sorted(Map.Entry.comparingByKey())
.forEach(e -> content.append(e.getKey()).append(e.getValue()));
content.append(body == null ? "" : body);
if ("hmac".equals(method) || "hmac_md5".equals(method) || "hmac_sha256".equals(method)) {
String algorithm = "hmac_sha256".equals(method) ? "HmacSHA256" : "HmacMD5";
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm));
return hex(mac.doFinal(content.toString().getBytes(StandardCharsets.UTF_8)));
}
String plain = secret + content + secret;
return hex(MessageDigest.getInstance("MD5").digest(plain.getBytes(StandardCharsets.UTF_8)));
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b & 0xFF));
}
return sb.toString();
}
}
@@ -0,0 +1,16 @@
# Temporary validation only. This file is gitignored and must not be committed.
# It is baked into the JAR so SAE does not need TAOBAO_* environment variables.
# For the first end-to-end validation, signature verification is disabled because
# the current AppSecret is still suspect. Enable it only after resetting the
# AppSecret for AppKey 35486228 and updating the value below.
taobao:
app-key: "35486228"
app-secret: "b8bd05ebce2f8a34d28026b443ba3ed6"
verify-sign: false
# 淘宝小程序的真实测肤仍在 1me.xajuzi.com 那套 Node 服务上(旷视调用、评分、报告都在那边)。
# SAE 这边只做云网关适配层:把 /api/taobao/* 字节级透传过去。
# forward-base 对应 nginx 里的 location /tb/(只允许聚石塔 NAT 出口 IP 访问)。
mode: forward
forward-base: "https://1me.xajuzi.com/tb"
@@ -0,0 +1,35 @@
server:
port: 8080
spring:
application:
name: taobao-mvp-backend
profiles:
active: local
jackson:
time-zone: Asia/Shanghai
default-property-inclusion: non_null
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
taobao:
# forward: /api/taobao/* 业务接口字节级透传给外层 H5 后端(Node),Java 只做网关适配层
# native: Java 自己实现(challenge/session/me/logout);测肤接口尚未实现
mode: ${TAOBAO_MODE:native}
forward-base: ${TAOBAO_FORWARD_BASE:https://1me.xajuzi.com/state}
# 云端部署时通过 SAE 环境变量注入;本地留空即可。
app-key: ${TAOBAO_APP_KEY:}
app-secret: ${TAOBAO_APP_SECRET:}
# 本地直连没有网关签名,保持 false;云端改成 true。
verify-sign: ${TAOBAO_VERIFY_SIGN:false}
logging:
level:
com.juzi.mvp: INFO
@@ -0,0 +1,62 @@
package com.juzi.mvp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MvpApplicationTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
@Test
@SuppressWarnings("unchecked")
void pingReturnsCloudGatewayEnvelope() {
ResponseEntity<Map> response = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data).containsEntry("server", "java").containsEntry("pong", true);
}
@Test
@SuppressWarnings("unchecked")
void echoReturnsRequestBody() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> response = rest.postForEntity(
url("/api/taobao/echo"),
new HttpEntity<>("{\"hello\":\"world\"}", headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
Map<String, Object> body = (Map<String, Object>) data.get("body");
assertThat(body).containsEntry("hello", "world");
}
@Test
void actuatorHealthIsUp() {
ResponseEntity<String> response = rest.getForEntity(url("/actuator/health"), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("\"status\":\"UP\"");
}
}
@@ -0,0 +1,139 @@
package com.juzi.mvp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 登录链路(challenge → session → me → logout)的端到端契约测试。
*
* <p>本地/测试环境 verify-sign=false,所以用 ?open_id= 模拟云网关注入的身份;
* 打开 verify-sign 后,同样的请求需要带正确的 sign。
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
// 部署用的 application-local.yml 里写的是 mode: forward(生产走透传)。
// 这个用例测的是 Java 自己实现登录的那条路,所以显式钉住 native。
properties = "taobao.mode=native")
class TaobaoAuthFlowTests {
private static final String GATEWAY_QUERY =
"?app_key=35486228&open_id=AAG8-test-open-id&sign_method=hmac_sha256";
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
private HttpEntity<String> json(String body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(body, headers);
}
private HttpEntity<String> json(String body, String token) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (token != null) {
headers.set("Authorization", "Bearer " + token);
}
return new HttpEntity<>(body, headers);
}
@SuppressWarnings("unchecked")
private Map<String, Object> data(ResponseEntity<Map> response) {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
return (Map<String, Object>) response.getBody().get("data");
}
@SuppressWarnings("unchecked")
private String login() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
assertThat(challenge).containsEntry("ok", true).containsEntry("http_status", 200);
String challengeId = (String) challenge.get("challenge");
assertThat(challengeId).hasSize(48);
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":true,\"challenge\":\"" + challengeId + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", true).containsEntry("http_status", 200);
assertThat((String) session.get("token")).hasSize(64);
assertThat(((Number) session.get("expires_in")).longValue()).isEqualTo(2592000L);
Map<String, Object> user = (Map<String, Object>) session.get("user");
assertThat(user).containsEntry("kind", "taobao");
assertThat((String) user.get("id")).hasSize(64);
return (String) session.get("token");
}
@Test
@SuppressWarnings("unchecked")
void challengeSessionMeLogoutRoundTrip() {
String token = login();
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(me).containsEntry("ok", true);
assertThat(((Map<String, Object>) me.get("user"))).containsEntry("kind", "taobao");
assertThat(data(rest.postForEntity(
url("/api/taobao/logout"), json("{}", token), Map.class))).containsEntry("ok", true);
Map<String, Object> afterLogout = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(afterLogout).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void sessionRejectsMissingConsent() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":false,\"challenge\":\"" + challenge.get("challenge") + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", false).containsEntry("http_status", 400);
assertThat(session.get("message")).isEqualTo("请先阅读并同意隐私协议");
}
@Test
void challengeCannotBeReused() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
String body = "{\"consent\":true,\"challenge\":\"" + challenge.get("challenge") + "\"}";
assertThat(data(rest.postForEntity(url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class)))
.containsEntry("ok", true);
Map<String, Object> replay = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class));
assertThat(replay).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void meWithoutTokenIsUnauthorized() {
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}"), Map.class));
assertThat(me).containsEntry("ok", false).containsEntry("http_status", 401);
assertThat(me.get("message")).isEqualTo("检测会话已失效,请重新登录");
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* taobao.mode=forward 时,Java 必须把请求「字节级」转发给外层 H5 后端:
* 原始 query string、原始 body、Authorization 都要原样过去,回来的信封也要原样返回。
*
* <p>用一个本地 stub 冒充 Node 后端来验证这件事。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TaobaoForwardTests {
private static final HttpServer STUB;
private static volatile String lastQuery;
private static volatile String lastBody;
private static volatile String lastAuthorization;
private static volatile String lastPath;
static {
try {
STUB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
STUB.createContext("/api/taobao/", exchange -> {
lastPath = exchange.getRequestURI().getPath();
lastQuery = exchange.getRequestURI().getRawQuery();
lastAuthorization = exchange.getRequestHeaders().getFirst("Authorization");
try (InputStream in = exchange.getRequestBody()) {
lastBody = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
byte[] payload = ("{\"success\":true,\"data\":{\"ok\":true,\"http_status\":200,"
+ "\"echo_path\":\"" + lastPath + "\","
+ "\"echo_query\":\"" + lastQuery + "\","
+ "\"echo_body\":" + (lastBody.isEmpty() ? "\"\"" : lastBody) + "}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8");
exchange.sendResponseHeaders(200, payload.length);
exchange.getResponseBody().write(payload);
exchange.close();
});
STUB.start();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("taobao.mode", () -> "forward");
registry.add("taobao.forward-base", () -> "http://127.0.0.1:" + STUB.getAddress().getPort());
}
@AfterAll
static void stopStub() {
STUB.stop(0);
}
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
@Test
@SuppressWarnings("unchecked")
void forwardsBodyQueryAndAuthorizationVerbatim() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer abc123");
// 故意带空格和不常见字段顺序:任何重新序列化都会改变这里的字节
String body = "{\"consent\":true, \"challenge\":\"x-1\", \"note\":\"带空格\"}";
ResponseEntity<Map> response = rest.postForEntity(
url("/api/taobao/session?app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"),
new HttpEntity<>(body, headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
assertThat(lastPath).isEqualTo("/api/taobao/session");
assertThat(lastQuery).isEqualTo("app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC");
assertThat(lastBody).isEqualTo(body);
assertThat(lastAuthorization).isEqualTo("Bearer abc123");
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data).containsEntry("ok", true).containsEntry("echo_path", "/api/taobao/session");
}
@Test
void localDiagnosticsEndpointsStayLocal() {
ResponseEntity<Map> ping = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
assertThat(ping.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) ping.getBody().get("data");
assertThat(data).containsEntry("server", "java");
assertThat(lastPath).isNotEqualTo("/api/taobao/ping");
}
}
@@ -0,0 +1,40 @@
package com.juzi.mvp.gateway;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.TreeMap;
import static org.assertj.core.api.Assertions.assertThat;
class TaobaoSignatureVerifierTest {
@Test
void md5SignatureMatchesExistingNodeImplementation() throws Exception {
Map<String, String> params = new TreeMap<>();
params.put("app_key", "app");
params.put("open_id", "open-account");
params.put("sign_method", "md5");
params.put("env", "online");
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
// 由 taobao/test/runtime.test.cjs 的同一组参数计算得出。
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
.isEqualTo("E673310E672C9EB7E918857C6C64D247");
}
@Test
void missingSignMethodUsesTheMd5Form() throws Exception {
// 真实网关请求不带 sign_method(2026-11-01 抓包确认),缺省等价于 md5(secret+content+secret)。
Map<String, String> params = new TreeMap<>();
params.put("app_key", "app");
params.put("open_id", "open-account");
params.put("env", "online");
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
.isEqualTo("DD2B95886A5E8F61D6111A5659E4FBD5");
}
}
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-mobile-v2.html
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
实际淘宝 IDE 模拟器运行截图(非真机截图)。login-centered.png 从完整 IDE 窗口截图中裁取模拟器屏幕,未修改页面内容。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

@@ -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 原规则作为历史对照,因此不再代表两者应当完全相同。
@@ -0,0 +1,5 @@
同一张项目示例照片的浏览器对照预览,不是淘宝真机运行截图,不用于冒充提审截图。
mini-contour.png:真实小程序报告放大图 AXML/ACSS 与控制器数据的浏览器投影。
mini-wrinkles.png:皱纹视图同一放大规则的浏览器投影(示例数据)。
h5-contour.png:使用线上 H5 reportZoomSource 原始函数及本地对应 CSS 生成的对照。
修复:不再在尺寸获取失败时按 3:4 拉伸;复用已读取的自然尺寸,未知尺寸使用 aspectFit,图片 onLoad 后补齐真实几何。
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,4 @@
2 张淘宝 IDE 模拟器实际运行截图,未使用 H5 投影、Mock 报告或重绘界面。仅裁去 IDE 外框,未修改界面内容。
01:登录页。02:未勾选协议点击游客体验后,真实显示“请先阅读并同意用户协议和隐私政策”。
格式 PNG,单张 239×423,均小于 4MB,满足用户提供的最大尺寸 2160×3840。
后续主页与档案运行截图需要先手动完成模拟器中的淘宝登录验证。
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+148
View File
@@ -0,0 +1,148 @@
# deploy —— 1me.xajuzi.com 上的「淘宝专用」Node 实例
这份目录存放**不在 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 上限)
→ 聚石塔云应用 1me-net(62829) → 私网 CLB 192.168.65.185:80
→ SAE 应用 nnn(taobao-mvp-backend-0.0.4-forward.jar,mode=forward,纯透传)
→ 聚石塔 NAT 网关出网(EIP 47.92.113.176)
→ https://1me.xajuzi.com/tb/ (nginx 白名单只放行上面这个 EIP)
→ juzi-taobao.service(Node,127.0.0.1:18081,本文档这套配置)
→ 旷视 Face++ skinanalyze_pro
```
生产 H5(`/state/`)和红书渠道(`/rednote/`)跑在同机另外两个 systemd 服务上,**8080/8081 端口,和本实例完全隔离**。
## 三个服务的分工
| 服务 | 端口 | 代码目录 | 用途 |
|---|---|---|---|
| `juzi-h5.service` | 8080 | `/home/ubuntu/juzi/h5` | 生产 H5(微信登录、报告、后台) |
| `juzi-rednote.service` | 8081 | `/home/ubuntu/juzi-rednote/current/h5` | 小红书渠道隔离实例 |
| `juzi-taobao.service` | 18081 | `/home/ubuntu/juzi-tb/h5` | **本目录新增**:淘宝小程序专用 |
## 重建步骤
### 1. 代码
`/home/ubuntu/juzi-tb` 是**独立的一份 h5 代码**(不是生产目录的软链),来自本仓库的 `h5/`。
部署方式(本地执行,把仓库里的 h5 打包传上去):
```powershell
# 本地仓库根目录
tar -czf temp/juzi-h5.tgz --exclude=h5/data --exclude=h5/test --exclude=h5/scripts -C . h5
scp temp/juzi-h5.tgz ubuntu@1me.xajuzi.com:/tmp/
```
```bash
# 服务器上
sudo mkdir -p /home/ubuntu/juzi-tb /home/ubuntu/juzi-tb-data
sudo chown -R ubuntu:ubuntu /home/ubuntu/juzi-tb /home/ubuntu/juzi-tb-data
chmod 700 /home/ubuntu/juzi-tb-data
tar -xzf /tmp/juzi-h5.tgz -C /home/ubuntu/juzi-tb
# 旷视凭据复用生产那份(gitignored,不随仓库走)
mkdir -p /home/ubuntu/juzi-tb/reference/facepp-skinanalyze
cp /home/ubuntu/juzi/reference/facepp-skinanalyze/credentials.env \
/home/ubuntu/juzi-tb/reference/facepp-skinanalyze/credentials.env
chmod 600 /home/ubuntu/juzi-tb/reference/facepp-skinanalyze/credentials.env
```
> `h5/server.js` 启动时如果找不到旷视 key 会直接 `exit(1)`,所以这一步不能漏。
### 2. 淘宝凭据
```bash
sudo mkdir -p /etc/juzi
sudo tee /etc/juzi/taobao.env >/dev/null <<'EOF'
TAOBAO_APP_KEY=35486228
TAOBAO_APP_SECRET=<35486228 的 AppSecret,向项目负责人索取>
EOF
sudo chmod 600 /etc/juzi/taobao.env && sudo chown root:root /etc/juzi/taobao.env
```
这个文件被 `h5/lib/taobao-auth.js` 读取,用于校验云网关签名。**没有它,`/session` 一律 401。**
### 3. systemd
```bash
scp deploy/systemd/juzi-taobao.service ubuntu@1me.xajuzi.com:/tmp/
ssh ubuntu@1me.xajuzi.com "sudo install -m 644 -o root -g root /tmp/juzi-taobao.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload && sudo systemctl enable --now juzi-taobao && systemctl is-active juzi-taobao"
```
### 4. nginx
```bash
# 本地
scp deploy/add-tb-nginx.sh deploy/nginx/tb-location.conf ubuntu@1me.xajuzi.com:/tmp/
# 服务器
ssh ubuntu@1me.xajuzi.com "sed -i 's/\r\$//' /tmp/add-tb-nginx.sh && bash /tmp/add-tb-nginx.sh /tmp/tb-location.conf"
```
脚本是幂等的(检测到 `location /tb/` 就跳过),会先备份成 `juzi.bak-tb-<时间戳>`。
### 5. 验收
```bash
# 服务器本机(127.0.0.1 在白名单里)
curl -sk -H 'Host: 1me.xajuzi.com' https://127.0.0.1/tb/api/health
# 期望 {"ok":true}
# 公网(应被白名单挡掉)
curl -s -o /dev/null -w '%{http_code}\n' https://1me.xajuzi.com/tb/api/health
# 期望 403
# 生产 H5 不受影响
curl -s -o /dev/null -w '%{http_code}\n' https://1me.xajuzi.com/state/api/health
# 期望 200
```
最后在聚石塔 SAE 容器里再验一次(这是唯一走 NAT 的路径):
```bash
curl -s https://1me.xajuzi.com/tb/api/health # 期望 {"ok":true}
```
## 回滚
```bash
sudo systemctl disable --now juzi-taobao
sudo cp /etc/nginx/sites-available/juzi.bak-tb-<时间戳> /etc/nginx/sites-available/juzi
sudo nginx -t && sudo systemctl reload nginx
```
生产两个服务完全不受影响(端口、目录、数据都是独立的)。
## 数据
- 运行数据:`/home/ubuntu/juzi-tb-data`(700)—— 照片 `photos/`、原始返回 `results/`、记录 `records.jsonl`、账号 `accounts/`
- 与生产的 `/home/ubuntu/juzi/h5/data` 完全分开,两边用户/报告不互通
- **没有任何自动清理**:每次检测约 10–12MB 常驻磁盘,长期要换 OSS + RDS
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# 幂等地把 deploy/nginx/tb-location.conf 插入 /etc/nginx/sites-available/juzi。
# 只做「插入一段 location」,不改动任何已有 location;先备份,nginx -t 通过才 reload。
#
# 用法(在 1me.xajuzi.com 上):
# scp deploy/add-tb-nginx.sh deploy/nginx/tb-location.conf ubuntu@1me.xajuzi.com:/tmp/
# ssh ubuntu@1me.xajuzi.com "sed -i 's/\r$//' /tmp/add-tb-nginx.sh && bash /tmp/add-tb-nginx.sh"
set -euo pipefail
CONF=/etc/nginx/sites-available/juzi
SNIPPET=${1:-/tmp/tb-location.conf}
STAMP=$(date +%Y%m%d%H%M%S)
BACKUP="$CONF.bak-tb-$STAMP"
if grep -q 'location /tb/' "$CONF"; then
echo "SKIP: /tb/ 已存在,不重复插入"
exit 0
fi
if [ ! -f "$SNIPPET" ]; then
echo "缺少片段文件: $SNIPPET" >&2
exit 1
fi
sudo cp "$CONF" "$BACKUP"
echo "BACKUP: $BACKUP"
sudo python3 - "$CONF" "$SNIPPET" <<'PY'
import sys
conf_path, snippet_path = sys.argv[1], sys.argv[2]
text = open(conf_path, encoding='utf-8').read()
snippet = open(snippet_path, encoding='utf-8').read()
block = "# BEGIN taobao-debug 2026-11-01\n" + snippet + "# END taobao-debug 2026-11-01\n\n"
marker = " location / {\n"
if marker not in text:
sys.exit("marker 'location / {' not found")
text = text.replace(marker, block + marker, 1)
open(conf_path, 'w', encoding='utf-8').write(text)
print("INSERTED")
PY
echo "--- nginx -t ---"
sudo nginx -t
echo "--- reload ---"
sudo systemctl reload nginx
echo "RELOADED"
+25
View File
@@ -0,0 +1,25 @@
# 插入到 /etc/nginx/sites-available/juzi 的 HTTPS server { } 块内。
# 作用:给淘宝小程序云网关一个专用入口 /tb/,反代到 juzi-taobao.service(127.0.0.1:18081)。
#
# 为什么要 allow 白名单:/tb/ 暴露在公网,后面是真实旷视计费调用。
# 只允许聚石塔 NAT 的出口 EIP(以及本机自测)访问,其余一律 403。
#
# 注意:nginx 会剥掉 /tb/ 前缀,所以 /tb/api/taobao/session → Node 收到 /api/taobao/session,
# 与 h5/server.js 的路由完全一致。
location = /tb {
return 301 /tb/;
}
location /tb/ {
# 聚石塔 NAT 网关绑定的 EIP。换环境时改这一行。
allow 47.92.113.176;
# 本机自测(curl -H 'Host: 1me.xajuzi.com' https://127.0.0.1/tb/api/health)
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:18081/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 150s;
}
+96
View File
@@ -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()
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=1'me taobao mini program backend (Node, /tb/ debug instance)
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/juzi-tb/h5
# 独立于生产 juzi-h5(8080)/juzi-rednote(8081):独立端口、独立数据目录、独立凭据文件
Environment=JUZI_ENTRY_MODE=state
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=18081
Environment=JUZI_DATA_ROOT=/home/ubuntu/juzi-tb-data
Environment=TRUST_LOOPBACK_PROXY=1
# 内容格式:
# TAOBAO_APP_KEY=35486228
# TAOBAO_APP_SECRET=<35486228 的 AppSecret>
# 权限 600 root,绝不入库。
EnvironmentFile=/etc/juzi/taobao.env
ExecStart=/usr/bin/node /home/ubuntu/juzi-tb/h5/server.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
UMask=0077
[Install]
WantedBy=multi-user.target
+69
View File
@@ -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()
+42
View File
@@ -0,0 +1,42 @@
# UI 外发版 10.09 图片资源
来源:用户提供的 `UI外发版10.09.psd`。按 Photoshop 图层原样导出,未重新生成或重绘素材。
## 文件位置
- `pages/`:10 个画板的原尺寸 PNG,宽 1080px。包含登录、启动、授权弹窗、我的报告、档案填写、拍照弹窗、三视图拍照及长报告。
- `pages-overview.jpg`:全部页面缩略总览,按 PSD 原始画板顺序排列。
- `assets-overview.jpg`:独立切图总览。
- `manifest.json`:页面与切图的尺寸、文件名、图层索引、边界、可见状态及 SHA-256;同时保存完整图层树及文字,便于后续还原。
- `../../h5/assets/img/ui-20261009/`:新版页面使用的独立图片,与旧版素材分开存放。H5 使用去除原生顶栏的 `*-h5.jpg` 衍生背景。
## 前端素材对应
| 文件 | 用途 |
| --- | --- |
| `bg-splash.jpg` | 启动页原始照片,不含独立标题、Logo 与按钮 |
| `bg-profile.jpg` / `bg-login.jpg` | 档案页 / 登录页背景,按画板裁剪,保留设计里的叠色 |
| `bg-report.jpg` / `bg-history.jpg` | 长报告 / 我的报告页纯背景,按画板裁剪 |
| `skin-0-dry.png` … `skin-4-mixedoily.png` | 五档肤质插画;也导出了隐藏状态中的变体 |
| `product-25.png` / `product-35.png` / `product-50.png` | 三档产品图,分别取对应产品组中的主视觉图层 |
| `brand-*.png` / `product-badge.png` | 品牌标志与产品标志 |
| `icon-*.png` | 关闭、返回、下拉、相册、切换相机、快门、微信、游客图标 |
| `score-ring-*.png` | 设计稿中的评分环参考素材;实际评分仍应动态绘制 |
| `camera-mask-front.png` / `camera-mask-right.png` / `camera-mask-left.png` | 三步拍照轮廓蒙版,保留画板坐标与透明区域 |
| `camera-guide-front.png` / `camera-guide-right.png` / `camera-guide-left.png` | 对应三步的眼睛与鼻梁虚线定位叠层,与蒙版使用相同画板坐标 |
| `sample-*.png` | 设计稿内检测示意图,仅供视觉参考,不代替用户真实检测图 |
照片使用高质量渐进 JPG;其他素材使用 PNG 保留透明通道。切图按原图层尺寸输出,不强行放大,不将文字与按钮烘焙进背景。整页预览保留设计稿原有系统状态栏和重复/隐藏图层状态。
`sample-lines.png` 来自隐藏的备选示意图层,设计文件内各分类存在重复示意图,文件名不表示图片已经过该项目评分引擎的真实检测。产品图按主视觉独立导出,卡片圆角和裁剪需由前端容器实现。
注意:PSD 的画板名「左面」实际文案为第 2 步「右脸出镜」,「右面」实际文案为第 3 步「左脸出镜」。切图按文案中的用户脸部方向命名,清单保存了对应图层路径。
## 重新导出
```bash
python -m pip install psd-tools Pillow aggdraw
python tools/export_psd_assets.py '/absolute/path/UI外发版10.09.psd'
```
重新导出会更新本版本目录内的导出文件,不会覆盖 `h5/assets/img/` 下原有同名线上图片。
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

@@ -0,0 +1,780 @@
{
"viewport": {
"width": 375,
"height": 726
},
"pages": {
"01-splash": {
"screen": "s-splash",
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
"overlays": [],
"rects": [
{
"class": "splash-slogan",
"rect": {
"x": 0,
"y": 77.671875,
"width": 375,
"height": 63.46875,
"top": 77.671875,
"right": 375,
"bottom": 141.140625,
"left": 0
}
},
{
"class": "splash-bottom",
"rect": {
"x": 0,
"y": 551.984375,
"width": 375,
"height": 84,
"top": 551.984375,
"right": 375,
"bottom": 635.984375,
"left": 0
}
}
]
},
"02-privacy": {
"screen": "s-splash",
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
"overlays": [
{
"id": "p-privacy",
"text": "欢迎体验1'me\n肌肤状态评估服务!\n\n我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。\n\n我已阅读并同意相关隐私协议\n继续"
}
],
"rects": [
{
"class": "splash-slogan",
"rect": {
"x": 0,
"y": 77.671875,
"width": 375,
"height": 63.46875,
"top": 77.671875,
"right": 375,
"bottom": 141.140625,
"left": 0
}
},
{
"class": "splash-bottom",
"rect": {
"x": 0,
"y": 551.984375,
"width": 375,
"height": 84,
"top": 551.984375,
"right": 375,
"bottom": 635.984375,
"left": 0
}
}
]
},
"03-profile": {
"screen": "s-profile",
"text": "您的肌肤状态\n您的出生年月\n您的性别\n您的所在地区\n您的日常肤质\n偏干\n偏油\n水油平衡\n干性肌肤\n\n全脸干燥,容易紧绷、缺乏光泽\n\n混合偏干肌肤\n\n两颊偏干,T区油脂较少或偶尔出油\n\n中性肌肤\n\n水油平衡,肤感舒适,无明显干燥出油\n\n油性肌肤\n\n全脸易出油,容易泛油光、毛孔明显\n\n混合偏油肌肤\n\nT区易出油,两颊相对清爽或轻微干燥\n\n提交",
"overlays": [],
"rects": [
{
"class": "profile-body",
"rect": {
"x": 0,
"y": 0,
"width": 375,
"height": 726,
"top": 0,
"right": 375,
"bottom": 726,
"left": 0
}
},
{
"class": "page-title",
"rect": {
"x": 64,
"y": 34,
"width": 247,
"height": 31,
"top": 34,
"right": 311,
"bottom": 65,
"left": 64
}
}
]
},
"04-guide": {
"screen": "s-profile",
"text": "您的肌肤状态\n您的出生年月\n1996年1月\n您的性别\n女\n您的所在地区\n北京市\n您的日常肤质\n偏干\n偏油\n水油平衡\n干性肌肤\n\n全脸干燥,容易紧绷、缺乏光泽\n\n混合偏干肌肤\n\n两颊偏干,T区油脂较少或偶尔出油\n\n中性肌肤\n\n水油平衡,肤感舒适,无明显干燥出油\n\n油性肌肤\n\n全脸易出油,容易泛油光、毛孔明显\n\n混合偏油肌肤\n\nT区易出油,两颊相对清爽或轻微干燥\n\n提交",
"overlays": [
{
"id": "p-photoguide",
"text": "Life in Full\n为更好的生命表现\n\n请拍摄或上传\n正面及左右侧面照片\n开始"
}
],
"rects": [
{
"class": "profile-body",
"rect": {
"x": 0,
"y": 0,
"width": 375,
"height": 726,
"top": 0,
"right": 375,
"bottom": 726,
"left": 0
}
},
{
"class": "page-title",
"rect": {
"x": 64,
"y": 34,
"width": 247,
"height": 31,
"top": 34,
"right": 311,
"bottom": 65,
"left": 64
}
}
]
},
"05-camera-front": {
"screen": "s-cam",
"text": "1/3\n请正对屏幕\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n准备拍摄下一张\n正脸居中\n取下眼镜\n光线充足",
"overlays": [],
"rects": [
{
"class": "cam-step",
"rect": {
"x": 0,
"y": 51.53125,
"width": 375,
"height": 64.5,
"top": 51.53125,
"right": 375,
"bottom": 116.03125,
"left": 0
}
},
{
"class": "cam-guide",
"rect": {
"x": 0,
"y": 542.390625,
"width": 375,
"height": 21,
"top": 542.390625,
"right": 375,
"bottom": 563.390625,
"left": 0
}
},
{
"class": "cam-controls",
"rect": {
"x": 0,
"y": 626.25,
"width": 375,
"height": 62,
"top": 626.25,
"right": 375,
"bottom": 688.25,
"left": 0
}
}
]
},
"06-camera-right": {
"screen": "s-cam",
"text": "1/3\n请正对屏幕\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n准备拍摄下一张\n正脸居中\n取下眼镜\n光线充足",
"overlays": [],
"rects": [
{
"class": "cam-step",
"rect": {
"x": 0,
"y": 51.53125,
"width": 375,
"height": 64.5,
"top": 51.53125,
"right": 375,
"bottom": 116.03125,
"left": 0
}
},
{
"class": "cam-guide",
"rect": {
"x": 0,
"y": 542.390625,
"width": 375,
"height": 21,
"top": 542.390625,
"right": 375,
"bottom": 563.390625,
"left": 0
}
},
{
"class": "cam-controls",
"rect": {
"x": 0,
"y": 626.25,
"width": 375,
"height": 62,
"top": 626.25,
"right": 375,
"bottom": 688.25,
"left": 0
}
}
]
},
"07-camera-left": {
"screen": "s-cam",
"text": "2/3\n请右脸出镜\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n下一张:右脸出镜\n请向左转头\n右脸居中\n取下眼镜\n光线充足",
"overlays": [],
"rects": [
{
"class": "cam-step",
"rect": {
"x": 0,
"y": 51.53125,
"width": 375,
"height": 64.5,
"top": 51.53125,
"right": 375,
"bottom": 116.03125,
"left": 0
}
},
{
"class": "cam-guide",
"rect": {
"x": 0,
"y": 542.390625,
"width": 375,
"height": 21,
"top": 542.390625,
"right": 375,
"bottom": 563.390625,
"left": 0
}
},
{
"class": "cam-controls",
"rect": {
"x": 0,
"y": 626.25,
"width": 375,
"height": 62,
"top": 626.25,
"right": 375,
"bottom": 688.25,
"left": 0
}
}
]
},
"08-report-top": {
"screen": "s-report",
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n轮廓\n\n下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。\n\n皱纹\n\n全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
"overlays": [],
"rects": [
{
"class": "report-title",
"rect": {
"x": 18,
"y": 0,
"width": 339,
"height": 86,
"top": 0,
"right": 357,
"bottom": 86,
"left": 18
}
},
{
"class": "card card-summary",
"rect": {
"x": 18,
"y": 86,
"width": 339,
"height": 140,
"top": 86,
"right": 357,
"bottom": 226,
"left": 18
}
},
{
"class": "card card-dims",
"rect": {
"x": 18,
"y": 238,
"width": 339,
"height": 201.1875,
"top": 238,
"right": 357,
"bottom": 439.1875,
"left": 18
}
},
{
"class": "card card-detail",
"rect": {
"x": 18,
"y": 451.1875,
"width": 339,
"height": 334.96875,
"top": 451.1875,
"right": 357,
"bottom": 786.15625,
"left": 18
}
},
{
"class": "card card-care",
"rect": {
"x": 18,
"y": 852.15625,
"width": 339,
"height": 510,
"top": 852.15625,
"right": 357,
"bottom": 1362.15625,
"left": 18
}
},
{
"class": "card card-essence",
"rect": {
"x": 18,
"y": 1374.15625,
"width": 339,
"height": 119.5,
"top": 1374.15625,
"right": 357,
"bottom": 1493.65625,
"left": 18
}
},
{
"class": "card card-product",
"rect": {
"x": 18,
"y": 1608.34375,
"width": 339,
"height": 215.671875,
"top": 1608.34375,
"right": 357,
"bottom": 1824.015625,
"left": 18
}
}
]
},
"09-report-tab-0": {
"screen": "s-report",
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n轮廓\n\n下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。\n\n皱纹\n\n全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
"overlays": [],
"rects": [
{
"class": "report-title",
"rect": {
"x": 18,
"y": -60,
"width": 339,
"height": 86,
"top": -60,
"right": 357,
"bottom": 26,
"left": 18
}
},
{
"class": "card card-summary",
"rect": {
"x": 18,
"y": 26,
"width": 339,
"height": 140,
"top": 26,
"right": 357,
"bottom": 166,
"left": 18
}
},
{
"class": "card card-dims",
"rect": {
"x": 18,
"y": 178,
"width": 339,
"height": 201.1875,
"top": 178,
"right": 357,
"bottom": 379.1875,
"left": 18
}
},
{
"class": "card card-detail",
"rect": {
"x": 18,
"y": 391.1875,
"width": 339,
"height": 334.96875,
"top": 391.1875,
"right": 357,
"bottom": 726.15625,
"left": 18
}
},
{
"class": "card card-care",
"rect": {
"x": 18,
"y": 792.15625,
"width": 339,
"height": 510,
"top": 792.15625,
"right": 357,
"bottom": 1302.15625,
"left": 18
}
},
{
"class": "card card-essence",
"rect": {
"x": 18,
"y": 1314.15625,
"width": 339,
"height": 119.5,
"top": 1314.15625,
"right": 357,
"bottom": 1433.65625,
"left": 18
}
},
{
"class": "card card-product",
"rect": {
"x": 18,
"y": 1548.34375,
"width": 339,
"height": 215.671875,
"top": 1548.34375,
"right": 357,
"bottom": 1764.015625,
"left": 18
}
}
]
},
"09-report-tab-1": {
"screen": "s-report",
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n光滑\n\n皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。\n\n肤色水润\n\n肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
"overlays": [],
"rects": [
{
"class": "report-title",
"rect": {
"x": 18,
"y": -60,
"width": 339,
"height": 86,
"top": -60,
"right": 357,
"bottom": 26,
"left": 18
}
},
{
"class": "card card-summary",
"rect": {
"x": 18,
"y": 26,
"width": 339,
"height": 140,
"top": 26,
"right": 357,
"bottom": 166,
"left": 18
}
},
{
"class": "card card-dims",
"rect": {
"x": 18,
"y": 178,
"width": 339,
"height": 201.1875,
"top": 178,
"right": 357,
"bottom": 379.1875,
"left": 18
}
},
{
"class": "card card-detail",
"rect": {
"x": 18,
"y": 391.1875,
"width": 339,
"height": 306.53125,
"top": 391.1875,
"right": 357,
"bottom": 697.71875,
"left": 18
}
},
{
"class": "card card-care",
"rect": {
"x": 18,
"y": 763.71875,
"width": 339,
"height": 510,
"top": 763.71875,
"right": 357,
"bottom": 1273.71875,
"left": 18
}
},
{
"class": "card card-essence",
"rect": {
"x": 18,
"y": 1285.71875,
"width": 339,
"height": 119.5,
"top": 1285.71875,
"right": 357,
"bottom": 1405.21875,
"left": 18
}
},
{
"class": "card card-product",
"rect": {
"x": 18,
"y": 1519.90625,
"width": 339,
"height": 215.671875,
"top": 1519.90625,
"right": 357,
"bottom": 1735.578125,
"left": 18
}
}
]
},
"09-report-tab-2": {
"screen": "s-report",
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n敏感\n\n泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。\n\n痘痘\n\n检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
"overlays": [],
"rects": [
{
"class": "report-title",
"rect": {
"x": 18,
"y": -60,
"width": 339,
"height": 86,
"top": -60,
"right": 357,
"bottom": 26,
"left": 18
}
},
{
"class": "card card-summary",
"rect": {
"x": 18,
"y": 26,
"width": 339,
"height": 140,
"top": 26,
"right": 357,
"bottom": 166,
"left": 18
}
},
{
"class": "card card-dims",
"rect": {
"x": 18,
"y": 178,
"width": 339,
"height": 201.1875,
"top": 178,
"right": 357,
"bottom": 379.1875,
"left": 18
}
},
{
"class": "card card-detail",
"rect": {
"x": 18,
"y": 391.1875,
"width": 339,
"height": 303,
"top": 391.1875,
"right": 357,
"bottom": 694.1875,
"left": 18
}
},
{
"class": "card card-care",
"rect": {
"x": 18,
"y": 760.1875,
"width": 339,
"height": 510,
"top": 760.1875,
"right": 357,
"bottom": 1270.1875,
"left": 18
}
},
{
"class": "card card-essence",
"rect": {
"x": 18,
"y": 1282.1875,
"width": 339,
"height": 119.5,
"top": 1282.1875,
"right": 357,
"bottom": 1401.6875,
"left": 18
}
},
{
"class": "card card-product",
"rect": {
"x": 18,
"y": 1516.375,
"width": 339,
"height": 215.671875,
"top": 1516.375,
"right": 357,
"bottom": 1732.046875,
"left": 18
}
}
]
},
"splash-375x844": {
"screen": "s-splash",
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
"overlays": [],
"rects": [
{
"class": "splash-slogan",
"rect": {
"x": 0,
"y": 90.296875,
"width": 375,
"height": 63.46875,
"top": 90.296875,
"right": 375,
"bottom": 153.765625,
"left": 0
}
},
{
"class": "splash-bottom",
"rect": {
"x": 0,
"y": 655.359375,
"width": 375,
"height": 84,
"top": 655.359375,
"right": 375,
"bottom": 739.359375,
"left": 0
}
}
]
},
"splash-390x844": {
"screen": "s-splash",
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
"overlays": [],
"rects": [
{
"class": "splash-slogan",
"rect": {
"x": 0,
"y": 90.296875,
"width": 390,
"height": 66,
"top": 90.296875,
"right": 390,
"bottom": 156.296875,
"left": 0
}
},
{
"class": "splash-bottom",
"rect": {
"x": 0,
"y": 652.265625,
"width": 390,
"height": 87.09375,
"top": 652.265625,
"right": 390,
"bottom": 739.359375,
"left": 0
}
}
]
},
"splash-414x844": {
"screen": "s-splash",
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
"overlays": [],
"rects": [
{
"class": "splash-slogan",
"rect": {
"x": 0,
"y": 90.296875,
"width": 414,
"height": 70.0625,
"top": 90.296875,
"right": 414,
"bottom": 160.359375,
"left": 0
}
},
{
"class": "splash-bottom",
"rect": {
"x": 0,
"y": 645.859375,
"width": 414,
"height": 93.5,
"top": 645.859375,
"right": 414,
"bottom": 739.359375,
"left": 0
}
}
]
}
},
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,59 @@
{
"description": "Smaller product-card delivery images. Original PSD crops preserved.",
"retained": "Background/scan WebPs are smaller than JPEG; transparent icons/masks remain PNG.",
"assets": [
{
"source": "h5/assets/img/ui-20261012/product-25.webp",
"source_sha256": "c18827342d23bbff2de04c3a2ecbeeae9b16eb96f3a9f7e30f2a66cdc21beddc",
"source_bytes": 276778,
"file": "h5/assets/img/ui-20261012/product-25.jpg",
"sha256": "2c399cd6bd7bd783f698a91b9203a7ba42b92295d58bbeb0b1c2a884e862f00b",
"bytes": 63959,
"size": [
504,
503
],
"quality": 90,
"subsampling": 0,
"progressive": true,
"matte": "#FFFFFF",
"psnr_db": 40.98
},
{
"source": "h5/assets/img/ui-20261012/product-35.webp",
"source_sha256": "c1dc455c8072da17ac06cee386248b358947da65bd190b714f2ccb25360afea4",
"source_bytes": 267094,
"file": "h5/assets/img/ui-20261012/product-35.jpg",
"sha256": "ab4c5623235020b1129f564b733fe843c539088e7b62b2d38ed42a204e4af113",
"bytes": 66077,
"size": [
504,
503
],
"quality": 90,
"subsampling": 0,
"progressive": true,
"matte": "#FFFFFF",
"psnr_db": 41.47
},
{
"source": "h5/assets/img/ui-20261012/product-50.webp",
"source_sha256": "86647c8882caf62c43eafb1efe495bad3289126cab73a88c8963b7ad64ffa084",
"source_bytes": 331924,
"file": "h5/assets/img/ui-20261012/product-50.jpg",
"sha256": "78687985751afbf60c374f2605dee372ac64ffc5271aa54ddca7897443de3047",
"bytes": 153787,
"size": [
504,
503
],
"quality": 96,
"subsampling": 0,
"progressive": true,
"matte": "#FFFFFF",
"psnr_db": 41.94
}
],
"source_bytes": 875796,
"delivery_bytes": 283823
}
+501
View File
@@ -0,0 +1,501 @@
{
"source": "UI外发版10.09.psd",
"source_sha256": "c7698f5900adc7476286818450d928e8fa2ce2e58cda09375ff2b861629fd58f",
"document_size": [
7330,
16411
],
"native_header_px": 248,
"design_scale": 2.88,
"assets": [
{
"file": "h5/assets/img/ui-20261012/splash-slogan.png",
"source_layers": [
"0/5",
"0/4"
],
"bbox": [
1415,
391,
2133,
608
],
"size": [
718,
217
],
"bytes": 12781,
"sha256": "c9dee693eb3c1d566bb08d3770e7dd2f0c22e7d4e0b81bbe5e10ad8219b26044"
},
{
"file": "h5/assets/img/ui-20261012/brand-lockup.png",
"source_layers": [
"0/1"
],
"bbox": [
1515,
1861,
2032,
1920
],
"size": [
517,
59
],
"bytes": 7013,
"sha256": "3cb697d6c5542562a9cea20554b0772f2c0137b9c3977ff258908024d5a8e9ed"
},
{
"file": "h5/assets/img/ui-20261012/button-start-label.png",
"source_layers": [
"0/3/1"
],
"bbox": [
1696,
2005,
1857,
2045
],
"size": [
161,
40
],
"bytes": 2163,
"sha256": "fd88b174f120845d8045a78a36bc7665516d91011f7ea0ebc7fe200271e17738"
},
{
"file": "h5/assets/img/ui-20261012/button-history-label.png",
"source_layers": [
"0/2/2/2"
],
"bbox": [
1696,
2252,
1859,
2291
],
"size": [
163,
39
],
"bytes": 2024,
"sha256": "0253d761f63bda89301c1eec9df84352a2f5dd1aa97884e27ebf91092c0816d8"
},
{
"file": "h5/assets/img/ui-20261012/product-25.webp",
"source_layers": [
"8/1/0"
],
"bbox": [
1765,
14677,
2269,
15180
],
"size": [
504,
503
],
"bytes": 276778,
"sha256": "c18827342d23bbff2de04c3a2ecbeeae9b16eb96f3a9f7e30f2a66cdc21beddc"
},
{
"file": "h5/assets/img/ui-20261012/product-35.webp",
"source_layers": [
"8/1/1"
],
"bbox": [
1765,
15228,
2269,
15731
],
"size": [
504,
503
],
"bytes": 267094,
"sha256": "c1dc455c8072da17ac06cee386248b358947da65bd190b714f2ccb25360afea4"
},
{
"file": "h5/assets/img/ui-20261012/product-50.webp",
"source_layers": [
"8/1/2"
],
"bbox": [
1765,
15778,
2269,
16281
],
"size": [
504,
503
],
"bytes": 331924,
"sha256": "86647c8882caf62c43eafb1efe495bad3289126cab73a88c8963b7ad64ffa084"
},
{
"file": "h5/assets/img/ui-20261012/scan-waiting.webp",
"source_layers": [
"9"
],
"bbox": [
6250,
5500,
7330,
7591
],
"size": [
1080,
2091
],
"bytes": 11690,
"sha256": "6b661f3d454e9b696b70ca8846fed11243d3f53b95506c2d78b11b6b1a6d9f77"
}
],
"text_styles": [
{
"path": "0/4",
"text": "以我为始 自有表现",
"bbox": [
1502,
545,
2045,
608
],
"transform": [
0.7386429422796229,
0.0,
-1.0090361144648896e-16,
0.7386429422796231,
1772.6682649161658,
602.0113961671652
],
"runs": [
{
"font": "MiSans-Demibold",
"font_size": 90.25561,
"leading": 104.16666
},
{
"font": "MiSans-Demibold",
"font_size": 90.25561,
"leading": 104.16666
}
]
},
{
"path": "0/5",
"text": " ONE ME,\rFROM THE INSIDE OUT",
"bbox": [
1415,
391,
2133,
520
],
"transform": [
0.7386429422796229,
0.0,
-1.0090361144648896e-16,
0.7386429422796231,
1771.9296219738862,
442.37366417300353
],
"runs": [
{
"font": "MiSans-Regular",
"font_size": 90.25561,
"leading": 104.16666
},
{
"font": "MiSans-Regular",
"font_size": 90.25561,
"leading": 104.16666
}
]
},
{
"path": "0/1/1",
"text": "肌肤状态检测",
"bbox": [
1664,
1862,
2032,
1920
],
"transform": [
1.0,
0.0,
0.0,
0.9999999999999999,
1662.281376176642,
1868.3475610977566
],
"runs": [
{
"font": "MiSans-Demibold",
"font_size": 62.5,
"leading": null
},
{
"font": "MiSans-Demibold",
"font_size": 62.5,
"leading": null
}
]
},
{
"path": "1/3/0",
"text": "欢迎体验1'me\r肌肤状态评估服务!",
"bbox": [
2664,
832,
3218,
979
],
"transform": [
1.0,
0.0,
0.0,
0.9999999999999999,
2662.281376176642,
840.3475610977557
],
"runs": [
{
"font": "MiSans-Demibold",
"font_size": 66.66666,
"leading": 83.33334
},
{
"font": "MiSans-Demibold",
"font_size": 66.66666,
"leading": 83.33334
}
]
},
{
"path": "1/3/1",
"text": "我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。",
"bbox": [
2663,
1045,
3361,
1177
],
"transform": [
0.9999999999999999,
0.0,
0.0,
1.0,
2662.281376176642,
1049.3475610977548
],
"runs": [
{
"font": "MiSans-Regular",
"font_size": 33.33333,
"leading": 50.0
},
{
"font": "MiSans-Regular",
"font_size": 33.33333,
"leading": 50.0
}
]
},
{
"path": "4/7",
"text": "请拍摄或上传\r正面及左右侧面照片",
"bbox": [
1442,
6160,
2036,
6307
],
"transform": [
1.0,
0.0,
0.0,
0.9999999999999999,
1439.281376176642,
6167.347561097756
],
"runs": [
{
"font": "MiSans-Demibold",
"font_size": 66.66666,
"leading": 83.33334
},
{
"font": "MiSans-Demibold",
"font_size": 66.66666,
"leading": 83.33334
}
]
},
{
"path": "4/8",
"text": "为使测试结果更准确,请关闭美颜/滤镜、并确保光线充足均匀。",
"bbox": [
1443,
6372,
2119,
6454
],
"transform": [
0.9999999999999999,
0.0,
0.0,
1.0,
1442.281376176642,
6376.347561097755
],
"runs": [
{
"font": "MiSans-Regular",
"font_size": 33.33333,
"leading": 50.0
},
{
"font": "MiSans-Regular",
"font_size": 33.33333,
"leading": 50.0
}
]
},
{
"path": "8/1/0/5",
"text": "以我本然有光蓄活\r双仓精华液",
"bbox": [
1362,
14850,
1627,
14932
],
"transform": [
1.7691030436028,
0.0,
0.0,
1.769103043602818,
1361.2813761766417,
14854.052217587123
],
"runs": [
{
"font": "MiSans-Semibold",
"font_size": 18.84194,
"leading": 28.26291
},
{
"font": "MiSans-Semibold",
"font_size": 18.84194,
"leading": 28.26291
}
]
},
{
"path": "8/1/0/6",
"text": "1'me 主场精华 I",
"bbox": [
1363,
14779,
1653,
14820
],
"transform": [
1.5358216667040816,
0.0,
0.0,
1.5358216667040878,
1361.2813761766417,
14784.058678621632
],
"runs": [
{
"font": "MiSans-Semibold",
"font_size": 27.12989,
"leading": 28.26291
},
{
"font": "MiSans-Semibold",
"font_size": 27.12989,
"leading": 28.26291
}
]
}
],
"shape_metrics": [
{
"path": "0/3/0",
"bbox": [
1516,
1971,
2038,
2083
],
"corner_radius_psd_px": 20.000065
},
{
"path": "0/2/0",
"bbox": [
1516,
2104,
2038,
2216
],
"corner_radius_psd_px": 20.000065
},
{
"path": "1/2/0",
"bbox": [
2753,
1410,
3275,
1522
],
"corner_radius_psd_px": 20.000065
},
{
"path": "4/2/0",
"bbox": [
1517,
6604,
2039,
6716
],
"corner_radius_psd_px": 20.000065
},
{
"path": "8/1/0/0",
"bbox": [
1285,
14677,
2269,
15180
],
"corner_radius_psd_px": 15.000158
},
{
"path": "1/1",
"bbox": [
2571,
719,
3458,
1621
],
"corner_radius_psd_px": 24.999972
},
{
"path": "4/1",
"bbox": [
1334,
5971,
2221,
6873
],
"corner_radius_psd_px": 24.999972
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Some files were not shown because too many files have changed in this diff Show More