diff --git a/README.md b/README.md new file mode 100644 index 0000000..9301ea5 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# 巨子测肤 · H5 与淘宝小程序 + +本仓库包含两个入口,共用 Node 后端、Face++ 检测、评分与报告话术。 + +- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。 +- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/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)。 diff --git a/h5/lib/accounts.js b/h5/lib/accounts.js index 5a965bf..c3bcd1d 100644 --- a/h5/lib/accounts.js +++ b/h5/lib/accounts.js @@ -177,7 +177,7 @@ module.exports = function createAccounts(root, env = process.env) { } function storeReport(ownerId, snapshot) { const user = db.users.find(u => u.id === ownerId); - if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner'); + if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner'); const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); atomic(path.join(dir, snapshot.id + '.json'), snapshot); user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }] @@ -193,7 +193,7 @@ module.exports = function createAccounts(root, env = process.env) { catch (_) { return null; } } function reportSnapshot(ownerId,id) { - if (!db.users.some(u=>u.id===ownerId&&u.kind==='wechat') || !/^R[\w-]+$/.test(id)) return null; + if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null; try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;} } function updateWrinkleViews(ownerId,id,views) { @@ -203,5 +203,35 @@ module.exports = function createAccounts(root, env = process.env) { if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete'; atomic(path.join(snapshots,ownerId,id+'.json'),snapshot); } - return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available }; + function loginTaobao(providerKey) { + let user = db.users.find(u => u.kind === 'taobao' && u.provider_key === providerKey); + if (!user) { user = { id: crypto.randomUUID(), kind: 'taobao', provider_key: providerKey, reports: [] }; db.users.push(user); } + const token = random(), expires = Date.now() + 30 * 86400000; + for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key]; + db.sessions[hash(token)] = { user: user.id, expires }; + save(); + return { token, expires_in: 30 * 86400, user: publicUser(user) }; + } + function taobaoSession(req) { + const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || ''); + const row = match && db.sessions[hash(match[1])]; + return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null; + } + function taobaoLogout(req) { + const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || ''); + if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); } + } + function reports(owner) { + return db.users.find(u => u.id === owner && u.kind === 'taobao')?.reports || []; + } + function ownedReport(owner, id) { + if (!reports(owner).some(r => r.id === id)) return null; + const snapshot = reportSnapshot(owner, id); + if (!snapshot) return null; + snapshot.vm = SkinScore.withDisplayRecommendation(snapshot.vm, snapshot.payload && snapshot.payload.result); + snapshot.presentation = ReportUI.forDisplay(snapshot.vm, snapshot.payload && snapshot.payload.result, snapshot.presentation); + return snapshot; + } + return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available, + loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport }; }; diff --git a/h5/lib/taobao-auth.js b/h5/lib/taobao-auth.js new file mode 100644 index 0000000..e8a9106 --- /dev/null +++ b/h5/lib/taobao-auth.js @@ -0,0 +1,31 @@ +'use strict'; +const crypto = require('node:crypto'); + +// Cloud gateway query parameters + the exact, unread JSON body. Algorithm: +// https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832 +function signature(params, body, secret) { + const method = params.sign_method || ''; + if (!['', 'md5', 'hmac', 'hmac_md5', 'hmac_sha256'].includes(method)) return null; + const content = Object.keys(params).filter(key => key !== 'sign' && params[key] !== '').sort() + .map(key => key + params[key]).join('') + body; + if (method === 'hmac' || method === 'hmac_md5' || method === 'hmac_sha256') { + return crypto.createHmac(method === 'hmac_sha256' ? 'sha256' : 'md5', secret).update(content).digest('hex').toUpperCase(); + } + return crypto.createHash('md5').update((method === 'md5' ? secret : '') + content + secret).digest('hex').toUpperCase(); +} +function verify(url, body, env = process.env) { + if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null; + const params = Object.create(null); + for (const [key, value] of new URL(url, 'http://localhost').searchParams) { + if (key in params) return null; // Never ambiguously verify duplicate parameters. + params[key] = value; + } + if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') || + !/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null; + const expected = signature(params, body, env.TAOBAO_APP_SECRET); + if (!expected || expected.length !== params.sign.length || + !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null; + // Login challenge is single-use and signed inside the body, preventing replay. + return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') }; +} +module.exports = { signature, verify }; diff --git a/h5/lib/taobao-routes.js b/h5/lib/taobao-routes.js new file mode 100644 index 0000000..ce8d51b --- /dev/null +++ b/h5/lib/taobao-routes.js @@ -0,0 +1,69 @@ +'use strict'; +const TaobaoAuth = require('./taobao-auth'); +module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze }) { + const taobaoSessions = require('./taobao-session')({ session: accounts.taobaoSession }); + return async function (req, res, pathname) { + res.setHeader('Cache-Control', 'no-store'); + // The cloud gateway requires its own envelope. H5 responses stay unchanged. + const reply = (status, data) => sendJson(res, 200, { success: true, data: { ...data, http_status: status } }); + if (req.method !== 'POST') return reply(405, { ok: false, message: '请求方法不支持' }); + if (!accounts.sameOrigin(req)) return reply(403, { ok: false, message: '请求来源不匹配' }); + try { + const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8'); + const body = JSON.parse(raw); + if (pathname === '/api/taobao/challenge') { + const challenge = taobaoSessions.challenge(req.socket.remoteAddress); + return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' }); + } + if (pathname === '/api/taobao/session') { + if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' }); + const identity = TaobaoAuth.verify(req.url, raw); + if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' }); + return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) }); + } + const owner = accounts.taobaoSession(req); + if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' }); + if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } }); + if (pathname === '/api/taobao/photo') { + const { status, ...data } = taobaoSessions.upload(req, body); + return reply(status, { ok: status === 200, ...data }); + } + if (pathname === '/api/taobao/skin-analyze') { + const photos = taobaoSessions.takePhotos(req, body.photo_refs); + if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' }); + // Start once and poll: cloud gateway requests have a maximum 10s timeout. + const job = taobaoSessions.start(req, () => new Promise(resolve => { + const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } }; + handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' })); + }), true); + if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); } + return reply(202, { ok: true, job }); + } + if (pathname === '/api/taobao/analysis') { + const result = taobaoSessions.result(req, body.job); + return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' }); + } + if (pathname === '/api/taobao/reports') { + if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) }); + const report = accounts.ownedReport(owner.id, body.id); + return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' }); + } + if (pathname === '/api/taobao/logout') { + taobaoSessions.revoke(req); + accounts.taobaoLogout(req); + return reply(200, { ok: true }); + } + if (pathname === '/api/taobao/wrinkle-views') { + const job = taobaoSessions.start(req, async () => { + const views = await wrinkleJobs.run(owner, body.token); + return views ? { ok: true, wrinkle_views: views } : { ok: false, message: '标注任务已失效' }; + }); + return reply(job ? 202 : 429, job ? { ok: true, job } : { ok: false, message: '服务繁忙,请稍后再试' }); + } + return reply(404, { ok: false, message: '接口不存在' }); + } catch (e) { + const status = e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500; + return reply(status, { ok: false, message: status === 413 ? '请求数据过大' : status === 400 ? '请求数据异常' : '服务暂不可用,请稍后再试' }); + } + }; +}; diff --git a/h5/lib/taobao-session.js b/h5/lib/taobao-session.js new file mode 100644 index 0000000..9035160 --- /dev/null +++ b/h5/lib/taobao-session.js @@ -0,0 +1,98 @@ +'use strict'; +const crypto = require('node:crypto'); + +// Short-lived transfers/jobs/challenges. Identity/history live in accounts.js. +module.exports = function ({ session, now = Date.now } = {}) { + const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(); + const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024; + let bytes = 0; + function clearPhotos(row) { + for (const photo of Object.values(row.photos)) bytes -= photo.bytes; + row.photos = {}; + } + function sweep() { + for (const [id, row] of transfers) if (row.expires <= now() && !row.active) { clearPhotos(row); transfers.delete(id); } + for (const [id, until] of challenges) if (until <= now()) challenges.delete(id); + for (const [id, row] of rates) if (row.until <= now()) rates.delete(id); + for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id); + } + function challenge(address) { + sweep(); + const rate = rates.get(address) || { count: 0, until: now() + 60000 }; + if (rates.size >= 10000 && !rates.has(address)) return null; + rates.set(address, rate); + if (++rate.count > 30 || challenges.size >= 1000) return null; + const id = crypto.randomBytes(24).toString('hex'); + challenges.set(id, now() + 60000); + return id; + } + function consume(id) { + sweep(); + if (!challenges.has(id)) return false; + challenges.delete(id); return true; + } + function row(req, create = false) { + sweep(); + const owner = session(req); + if (!owner) return null; + if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime }); + return transfers.get(owner.id) || null; + } + function upload(req, body) { + const current = row(req, true); + if (!current) return { status: 401, message: '检测会话已失效,请重新开始' }; + if (current.active) return { status: 409, message: '检测进行中,请稍候' }; + const { view, upload_id: id, index, total, data } = body || {}; + if (!['front', 'left', 'right'].includes(view) || !/^[a-z0-9-]{8,64}$/.test(id || '') || + !Number.isInteger(total) || total < 1 || total > 64 || !Number.isInteger(index) || index < 0 || index >= total || + typeof data !== 'string' || !data.length || data.length > maxChunk || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) { + return { status: 400, message: '照片分片数据异常,请重新拍摄' }; + } + let photo = current.photos[view]; + if (!photo || photo.id !== id) { + if (index !== 0) return { status: 409, message: '照片上传已失效,请重试' }; + if (photo) bytes -= photo.bytes; + photo = current.photos[view] = { id, total, chunks: [], bytes: 0 }; + } + if (photo.total !== total || index > photo.chunks.length || (index < photo.chunks.length && photo.chunks[index] !== data)) { + return { status: 409, message: '照片上传顺序异常,请重试' }; + } + if (index === photo.chunks.length) { + if (photo.bytes + data.length > maxPhoto || bytes + data.length > 64 * 1024 * 1024) return { status: 413, message: '照片过大或服务繁忙,请稍后重试' }; + photo.chunks.push(data); photo.bytes += data.length; bytes += data.length; + } + current.expires = now() + lifetime; + return { status: 200, complete: photo.chunks.length === total, photo_ref: id }; + } + function takePhotos(req, refs) { + const current = row(req); + if (!current || current.active) return null; + if (!refs || !['front', 'left', 'right'].every(view => { + const photo = current.photos[view]; + return photo && photo.id === refs[view] && photo.chunks.length === photo.total; + })) return null; + const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')])); + clearPhotos(current); current.active = true; + return photos; + } + function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } } + function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } } + function start(req, run, releasePhotos = false) { + sweep(); + const owner = session(req); + if (!owner || jobs.size >= 1000) return null; + const id = crypto.randomBytes(24).toString('hex'); + const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null }; + jobs.set(id, job); + Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; }) + .finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); }); + return id; + } + function result(req, id) { + sweep(); + const job = jobs.get(id), owner = session(req); + return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null; + } + const timer = setInterval(sweep, 60000); timer.unref(); + return { challenge, consume, upload, takePhotos, finish, revoke, start, result }; +}; diff --git a/h5/lib/wrinkle-jobs.js b/h5/lib/wrinkle-jobs.js index a6f8fab..f9da66a 100644 --- a/h5/lib/wrinkle-jobs.js +++ b/h5/lib/wrinkle-jobs.js @@ -1,6 +1,6 @@ 'use strict'; const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'); -// Lazy, owner-bound, single-flight tasks. Only WeChat jobs persist. Guest photos +// Lazy, owner-bound, single-flight tasks. Account jobs persist. Guest photos // remain in RAM for at most five minutes; no paid work starts until requested. module.exports = function({dir, collect, load, complete, now = Date.now}) { const jobs = new Map(); @@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) { fs.renameSync(tmp,file); } function register(owner,record,context) { - const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:owner.kind==='wechat',status:'pending',context}; + const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context}; save(job);remember(job); return {token:job.token,status:'pending'}; } @@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) { let job=jobs.get(token); if (!job) { try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;} - if (job.owner!==owner.id || owner.kind!=='wechat') return null; + if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null; remember(job); } if (job.owner!==owner.id) return null; diff --git a/h5/server.js b/h5/server.js index 2f277d3..3ffc26a 100644 --- a/h5/server.js +++ b/h5/server.js @@ -60,6 +60,7 @@ const wrinkleJobs = require('./lib/wrinkle-jobs.js')({ /* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */ const SkinScore = require('./js/skin-score.js'); +const handleTaobao = require('./lib/taobao-routes.js')({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze }); /* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */ function loadCredentials() { @@ -421,16 +422,18 @@ async function callFacepp(front, left, right) { return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson; } -async function handleAnalyze(req, res) { +async function handleAnalyze(req, res, miniProgram = false, preparedBody = null, preparedOwner = null) { res.setHeader('Cache-Control', 'no-store'); let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库) let persist = false; try { - const owner = accounts.session(req); + const owner = miniProgram ? preparedOwner : accounts.session(req); if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' }); - persist = owner.kind === 'wechat'; if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' }); - const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8')); + const body = preparedBody || JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8')); + // A client-supplied channel must never change H5 guest retention behavior. + const channel = miniProgram ? 'taobao' : 'h5'; + persist = ['wechat', 'taobao'].includes(owner.kind); // 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图) const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : ''); const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : ''); @@ -457,6 +460,7 @@ async function handleAnalyze(req, res) { gender: profile.gender, region: profile.region, daily_skin_type: profile.daily_skin_type, + channel, img_front: photos.front || null, img_left: photos.left || null, img_right: photos.right || null, @@ -544,7 +548,7 @@ async function handleAnalyze(req, res) { } })); } catch (e) { - if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。 + if (rec && persist) { // H5 游客不保留异常记录;已登录账号保存检测记录。 fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR'); rec.updated_at = nowIso(); updateRecord(rec); @@ -592,6 +596,7 @@ function listItem(rec) { record_id: rec.record_id, created_at: rec.created_at, real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type, + channel: rec.channel || 'h5', shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status, capture_quality: rec.capture_quality || null, capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null, @@ -620,6 +625,7 @@ function matchQuery(rec, q) { if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false; } if (q.detect_status && rec.detect_status !== q.detect_status) return false; + if (q.channel && (rec.channel || 'h5') !== q.channel) return false; if (q.fail_code && rec.fail_code !== q.fail_code) return false; if (q.group && rec.group !== q.group) return false; if (q.q) { @@ -642,6 +648,7 @@ function handleRecordsQuery(req, res, url) { from: sp.get('from') || null, to: sp.get('to') || null, detect_status: sp.get('detect_status') || null, + channel: sp.get('channel') || null, fail_code: sp.get('fail_code') || null, group: sp.get('group') || null, dim: sp.get('dim') || null, @@ -746,9 +753,21 @@ function handlePhoto(req, res, id, which) { }); } -function handleStats(req, res) { - const all = readAllRecords().map(displayRecommendationRecord); +function handleStats(req, res, url) { + const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道 const now = Date.now(), today = BeijingTime.day(now); + const allRaw = readAllRecords().map(displayRecommendationRecord); + // 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计) + const by_channel = {}; + for (const r of allRaw) { + const ch = r.channel || 'h5'; + const b = by_channel[ch] || (by_channel[ch] = { total: 0, today_total: 0, success_total: 0, fail_total: 0 }); + b.total += 1; + if (BeijingTime.day(r.created_at) === today) b.today_total += 1; + if (r.detect_status === '成功') b.success_total += 1; + if (r.detect_status === '失败') b.fail_total += 1; + } + const all = channel ? allRaw.filter(r => (r.channel || 'h5') === channel) : allRaw; const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败'); const succeeded = finished.filter(r => r.detect_status === '成功'); const failed = finished.filter(r => r.detect_status === '失败'); @@ -798,6 +817,7 @@ function handleStats(req, res) { success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0, avg_cost_ms: avgCost, capture_funnel: captureFunnel.stats(now), + by_channel, fail_dist, dim_dist, group_dist, @@ -823,6 +843,7 @@ async function handleRetry(req, res, id) { created_at: nowIso(), updated_at: nowIso(), real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type, + channel: rec.channel || 'h5', // 重试记录沿用原渠道 img_front: photos.front || null, img_left: photos.left || null, img_right: photos.right || null, @@ -858,7 +879,8 @@ function csvCell(v) { function handleExport(req, res, url) { const q = { from: url.searchParams.get('from') || null, - to: url.searchParams.get('to') || null + to: url.searchParams.get('to') || null, + channel: url.searchParams.get('channel') || null }; try { q.timeRange = BeijingTime.range(q.from, q.to); } catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); } @@ -870,7 +892,7 @@ function handleExport(req, res, url) { const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态', '失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)', '紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档', - '均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本']; + '均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道']; const lines = [header.join(',')]; for (const r of filtered) { lines.push([ @@ -885,7 +907,8 @@ function handleExport(req, res, url) { r.recommendation ? r.recommendation.version : '旧版规则(历史记录)', r.recommendation ? r.recommendation.reason : null, r.capture_quality ? r.capture_quality.status : null, - r.capture_diagnostics ? r.capture_diagnostics.version : null + r.capture_diagnostics ? r.capture_diagnostics.version : null, + r.channel || 'h5' ].map(csvCell).join(',')); } const fromTag = (q.from || 'all').replace(/[^\d-]/g, ''); @@ -909,7 +932,7 @@ async function handleAdmin(req, res, pathname, url) { const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/); if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]); if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url); - if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res); + if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res, url); if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url); return sendJson(res, 404, { ok: false, message: '接口不存在' }); } catch (e) { @@ -920,6 +943,7 @@ async function handleAdmin(req, res, pathname, url) { http.createServer(async (req, res) => { const pathname = req.url.split('?')[0]; + if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname); if (await accounts.route(req, res, pathname)) return; if (req.method==='POST' && pathname==='/api/wrinkle-views') { const owner=accounts.session(req); diff --git a/taobao/README.md b/taobao/README.md new file mode 100644 index 0000000..02bc977 --- /dev/null +++ b/taobao/README.md @@ -0,0 +1,99 @@ +# 淘宝小程序开发说明 + +当前完成本地开发闭环:授权登录 → 基础档案 → 正/右/左三步手动拍摄或相册选图 → 服务端检测 → 七项报告 → 最近5份历史报告。真实淘宝授权、云网关、相机和图片组件仍需在关联 AppKey 的淘宝 IDE 与真机中验收。 + +## 本地启动 + +在本目录运行: + +```powershell +npm ci +npm run dev:backend +``` + +只监听回环地址: + +- `http://127.0.0.1:18180`:本地 H5 与淘宝网关模拟代理。 +- `http://127.0.0.1:18181`:实际共享 Node 后端。 +- `http://127.0.0.1:18180/dev/health`:确认样本模式。 +- `../temp/taobao-local-data/`:隔离的账号、照片、检测记录和报告。不会读写现有 `h5/data/`。 + +代理使用固定的本地测试身份并模拟淘宝网关签名;Face++ 返回仓库内的固定样本。模拟逻辑仅在开发启动脚本中,正式服务器不会接受客户端自报 `open_id`,也没有免验签的开发登录后门。本地 H5 的微信入口不启用;微信 `/state/` 授权由独立浏览器回归验证。 + +`config.js` 当前是本地配置:`transport: 'direct'`、端口18180。直连传输只允许 localhost/127.0.0.1;不要把它当成淘宝正式网络方案。IDE 中导入本目录并关联自己的 AppKey。桌面模拟器是否允许访问回环地址需以实际 IDE 为准;手机上的127.0.0.1指向手机自身。 + +## 文件职责 + +首页右上角“开发调试 · 相机验收”可直接进入相机页,不依赖淘宝登录、本地后端或网络。也可在IDE启动参数中指定页面 `pages/camera/camera`、参数 `debug=camera`。入口需要同时满足 `config.js` 中 `environment: 'development'`、`cameraDebug: true`、本机直连配置;改为正式/cloud配置后入口关闭,单独拼参数也不能绕过登录。 + +验收时先开启相机,按正脸→右脸→左脸拍摄/确认,第三张完成后仅在本页验证JPEG压缩并展示三张原始预览与压缩尺寸,不发检测请求、不生成报告、不播放远程语音,也不会写入正式档案/报告状态。临时预览用于检查前置照片方向;页面不判断拍摄姿态是否合格。退出时清空本页照片引用。 + +依次检查:首次授权、拒绝后相册仍可使用、授权设置恢复、前后镜头、三张方向、切后台恢复、压缩最长边≤1280。真实系统权限和相机画面仍需淘宝IDE/手机验收,自动测试仅验证代码流程。 + +- `pages/splash`:协议同意、淘宝授权登录、退出和历史入口。 +- `pages/profile`:账号隔离的基础信息缓存、出生年月和省市滚轮、肤质选择。 +- `pages/camera`:手动三步拍摄/预览确认;离页取消、重复点击保护;单画布顺序压缩成最长边1280的 JPEG。 +- `pages/report`:服务端返回的当前评分/话术、七项分析、护理选三、精华、真实图层和坐标标注;侧脸图层异步加载。 +- `pages/history`:本人最近5份完整报告。 +- `utils/transport.js`:本机代理或淘宝云 SDK 传输,保留反代子路径配置。 +- `utils/api.js`:账号凭据、64KiB照片分片、检测任务提交与轮询、历史报告。 +- `vendor/`:从 H5 同步的评分、报告呈现、文案及哈希清单,不手工修改。 +- `scripts/`、`test/`、`docs/`:开发与回归文件,IDE 配置排除上传。 + +H5 规则变更后运行 `npm run sync:shared`;`npm run check:shared` 会检查逐字节一致。在线报告优先使用服务端返回的 `vm/presentation`,原始检测分数和展示肌龄分别保存。 + +## 账号与共享后端 + +正式淘宝请求走 `cloud.application.httpRequest`。登录分两步:先获取60秒一次性 challenge,再发送经网关签名、含 challenge 与同意标记的登录请求。服务端验证全部查询参数和原始请求体的签名,从网关注入的 `open_id` 建立 `kind: taobao` 的稳定账号;不依赖前端上报身份。账号键保存为 appKey/openId 的哈希。 + +淘宝使用独立 Bearer 会话,微信继续使用原有 Cookie。两种会话不能互换。共用 `accounts` 存储和报告格式,按服务端身份隔离历史、图片上传、检测任务和侧脸标注。 + +新检测记录自动标记 `h5` 或 `taobao`,不信任请求体中的 `channel`。历史没有该字段的记录视为 H5。管理 API 支持 `?channel=taobao` 筛选;统计返回 `by_channel`;CSV在末尾追加渠道列,旧列的位置保持不变。后台页面本轮没有改布局。 + +## 云网关接入准备 + +这里仅说明未来配置,不会自动部署。 + +1. 关联淘宝 AppKey 与外部云应用,配置域名与资源域名权限。 +2. 只在后端环境配置 `TAOBAO_APP_KEY`、`TAOBAO_APP_SECRET`;Face++ 密钥沿用现有服务端设置。所有 Secret 均不得进入小程序代码。 +3. `config.js` 设置 `environment: 'production'`、`cameraDebug: false`、`transport: 'cloud'`、HTTPS `baseUrl`、`cloudAppId`、资源 `assetBase`。如果使用原来的 `/state/` 反代入口,设置 `basePath: '/state'`,资源根同时包含 `/state/assets/`。 +4. 外部/空应用 `cloudEnv` 为 `online`,这表示淘宝网关的环境,和本次是否部署生产服务器无关。 +5. 真实淘宝授权确认后才能创建账号。当前尚未填入真实 AppKey/Secret/cloudAppId,也没有宣称已完成真实平台联调。 + +云网关单次请求内容上限128KB、外部服务超时最多10秒。照片按64KiB base64顺序上传,每个用户的照片只可消费一次;检测与侧脸标注均用短请求提交、轮询结果,不能直接把三张大图发进一个45秒请求。中断轮询不会自动重复付费检测,已完成结果可在历史报告中读取。 + +官方依据: + +- [外部/空应用调用标准、128KB、10秒、响应封装](https://developer.alibaba.com/docs/doc.htm?articleId=121864&docType=1&treeId=836) +- [外部服务请求签名算法](https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832) +- [服务端获取平台OpenId](https://developer.alibaba.com/docs/doc.htm?articleId=121213&docType=1&treeId=804) +- [淘宝小程序外发请求接入说明](https://developer.alibaba.com/docs/doc.htm?articleId=121954&docType=1&treeId=842) + +## 自动验证 + +```powershell +npm run check:shared +npm test +npx playwright install chromium +npm run test:h5 +``` + +`npm test` 覆盖验签和重放、账号与历史隔离、照片分片与过期、异步检测、渠道筛选/CSV兼容、评分与七项话术、拍摄竞态、图片尺寸和省市联动。检测上游均为固定样本,不消耗线上检测额度。 + +`npm run test:h5` 运行现有 H5 的四组浏览器测试,覆盖游客不留存、HTTPS `/state/` 微信授权/退出/跨设备、完整报告/后台/CSV,以及异步侧脸图层。使用本地 Playwright,不改写旧测试;Windows上自动使用已安装 Git 附带的 OpenSSL。截图保存至 `../temp/taobao-h5-compatibility/`。 + +## 待平台验收 + +开发账号采用独立淘宝账号,由公司主账号管理员将其加入目标小程序的开发成员。公司完成主体认证、开发者入驻及应用创建;开发人员按后台要求完成个人账号认证,获得开发权限后再登录IDE关联应用。不能把店铺子账号的应用使用权限当作小程序开发权限,也不能只添加为体验者。当前账号尚未就绪,以下平台验收未完成。 + +相机权限单独处理:进入拍摄页先查询 `my.getSetting`;首次使用由用户点击“开启相机”后调用 `my.authorize({ scopes: 'scope.camera' })`,授权后再挂载原生camera组件。拒绝后保留相册路径,并提供用户主动打开 `my.openSetting` 的入口,不循环申请。相册选择使用独立的 `scope.album`;淘宝账号登录的 `scope.userInfo` 不代表相机已获授权。`app.json` 中的用途描述也不代表授权完成。平台控制台仍需确认对应权限包,并在“开发设置-scope授权项管理”填写用途。手机系统对淘宝App的相机/相册权限同样需要真机验证。 + +参考:[淘宝用户授权、权限范围与拒绝后的设置入口](https://developer.alibaba.com/docs/doc.htm?articleId=119119&docType=1&treeId=776)、[淘宝camera组件与CameraContext API](https://developer.alibaba.com/docs/doc.htm?articleId=120279&docType=1&treeId=776)。 + +- 真实 AppKey/cloudAppId、签名参数和授权权限包联调。 +- 真实网关传输报告大图时的返回体限制与内存占用;已减少小程序 `setData` 中重复照片和非当前Tab图层。 +- Android/iOS相机权限、前置相片方向、cover-view覆盖、系统相机降级、后台恢复、原生canvas及大图读取。 +- UI真机对齐。当前相机使用椭圆框和左右转头提示,尚未迁移H5精确取景轮廓与实时姿态自动拍摄。 +- 商品跳转尚需真实商品ID与平台能力验收;当前报告展示推荐产品,没有伪造商品链接。 + +以上是待真实平台验收的范围,不影响已通过的本地接口与H5回归结论。 diff --git a/taobao/app.acss b/taobao/app.acss new file mode 100644 index 0000000..6dc5c28 --- /dev/null +++ b/taobao/app.acss @@ -0,0 +1,83 @@ +/* ═════ 全局样式(对齐 H5 设计变量:--yellow #F5F078 / --ink #282828 / --bg #EFEFEF) ═════ */ +page { + background: #101823; + font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + color: #282828; + font-size: 28rpx; +} + +/* ─── 主按钮(品牌黄) ─── */ +.btn-primary { + display: flex; + align-items: center; + justify-content: center; + height: 96rpx; + border-radius: 48rpx; + background: #F5F078; + color: #282828; + font-size: 32rpx; + font-weight: 600; + border: none; +} +.btn-primary::after { border: none; } +.btn-primary.btn-disabled { opacity: 0.5; } + +/* ─── 白色卡片(报告页通用) ─── */ +.card { + background: #FFFFFF; + border-radius: 24rpx; + margin: 24rpx 32rpx 0; + padding: 32rpx; + box-sizing: border-box; +} +.card-heading { + font-size: 32rpx; + font-weight: 600; + margin-bottom: 24rpx; +} + +/* ─── 弹窗遮罩 ─── */ +.overlay { + position: fixed; + left: 0; top: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} +.popup { + width: 600rpx; + background: #FFFFFF; + border-radius: 24rpx; + padding: 48rpx 40rpx 40rpx; + box-sizing: border-box; + position: relative; +} +.popup-title { + font-size: 34rpx; + font-weight: 600; + text-align: center; + line-height: 1.5; + margin-bottom: 24rpx; +} +.popup-body { + font-size: 26rpx; + color: #4A4A4A; + line-height: 1.7; + margin-bottom: 32rpx; +} +.popup-close { + position: absolute; + top: 24rpx; + right: 24rpx; + width: 48rpx; + height: 48rpx; + line-height: 48rpx; + text-align: center; + font-size: 36rpx; + color: #868686; + background: none; + padding: 0; +} +.popup-close::after { border: none; } diff --git a/taobao/app.js b/taobao/app.js new file mode 100644 index 0000000..b8985b6 --- /dev/null +++ b/taobao/app.js @@ -0,0 +1,20 @@ +/* ═════ 1'me 肌肤状态检测 · 淘宝小程序 ═════ + * 复用 H5 版业务逻辑与后端接口(POST /api/skin-analyze),channel 固定为 'taobao' */ +App({ + globalData: { + // 档案(profile 页填写):{ real_age, gender, region, daily_skin_type } + profile: null, + // 三连拍 dataURL,送检口径固定为 [正脸, 左脸, 右脸](与 H5 photosByView 一致) + photos: null, + // 检测接口返回:{ result, side, face_rectangle } + payload: null + }, + + onLaunch: function () { + // 预取语音清单(失败静默,不影响主流程) + require('./utils/voice.js').init(); + }, + onHide: function () { + require('./utils/voice.js').stop(true); + } +}); diff --git a/taobao/app.json b/taobao/app.json new file mode 100644 index 0000000..63fa8e4 --- /dev/null +++ b/taobao/app.json @@ -0,0 +1,21 @@ +{ + "pages": [ + "pages/splash/splash", + "pages/profile/profile", + "pages/camera/camera", + "pages/report/report", + "pages/history/history" + ], + "window": { + "defaultTitle": "1'me在线肌肤测试", + "titleBarColor": "#FFFFFF", + "backgroundColor": "#101823", + "pullRefresh": false, + "allowsBounceVertical": "NO" + }, + "permission": { + "scope.camera": { + "desc": "用于拍摄面部照片进行肌肤状态检测" + } + } +} diff --git a/taobao/assets/README.md b/taobao/assets/README.md new file mode 100644 index 0000000..b483bdb --- /dev/null +++ b/taobao/assets/README.md @@ -0,0 +1,5 @@ +# 小程序内置资源 + +这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。 + +正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。 diff --git a/taobao/config.js b/taobao/config.js new file mode 100644 index 0000000..f043a90 --- /dev/null +++ b/taobao/config.js @@ -0,0 +1,13 @@ +// Public configuration only. AppSecret and Face++ keys stay on the server. +module.exports = { + environment: 'development', + cameraDebug: true, // Offline camera acceptance only; disabled outside local development. + transport: 'direct', // Local development. Use 'cloud' with real gateway configuration. + baseUrl: 'http://127.0.0.1:18180', + assetBase: 'http://127.0.0.1:18180/assets/', + cloudAppId: '', + cloudEnv: 'online', // External/empty cloud applications use online + basePath: '', // E.g. '/state' behind an existing reverse proxy + mock: false, + productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3 +}; diff --git a/taobao/docs/本地Review与提交说明-2026-10-19.md b/taobao/docs/本地Review与提交说明-2026-10-19.md new file mode 100644 index 0000000..cbea8ee --- /dev/null +++ b/taobao/docs/本地Review与提交说明-2026-10-19.md @@ -0,0 +1,28 @@ +# 本地 Review 与提交说明(2026-10-19) + +范围:当前淘宝小程序新增代码、共享后端的淘宝账号与渠道扩展、原有冲突解决结果。仅本地提交,不部署或修改线上服务。 + +## Review 结果 + +- Git 无未合并条目;检查了账号来源隔离、网关验签和登录 challenge、照片分片、异步检测、本人历史报告、相机权限及页面离开后的取消流程。 +- 修复淘宝档案页的省市选择:更换省份后取消,再打开时,城市列表必须恢复到已确认省份,不能残留取消的草稿城市。 +- 修复淘宝会话过期后的退出:401 表示会话已失效,清理本地身份和照片/报告状态并完成退出;网络失败仍提示错误,不伪称服务端已撤销会话。 +- 本轮新增修复均在淘宝目录。H5 页面、样式、评分及报告文案源文件未修改;共享后端扩展已做 H5 回归。 +- 当前没有真实淘宝开发账号。后续使用独立淘宝账号,由公司主账号管理员添加为目标应用的开发成员。 + +## 验证 + +- `cd taobao; npm test`:16 项通过,覆盖接口集成、身份与历史隔离、分片和任务、相机流程、权限、报告模型、省市取消恢复、过期退出。 +- `npm run check:shared`:淘宝 vendor 中评分、报告呈现和文案与 H5 一致。 +- `npm run test:h5`:四组浏览器回归通过(游客隐私、HTTPS `/state/` 微信授权及退出、报告持久化及后台 CSV、异步侧脸图层)。仅固定样本,不消耗付费接口。 +- 补查 10 个 H5 单元脚本:8 个通过,2 个已有失败。通过脚本为 accounts、wrinkle-jobs、wrinkle-views、weighted-total、display-age-recommendation、care-selection、acne-three-view、vita-independent。 +- 已有失败 `h5/test/tone-dark-circle.js:13`:预期透亮感 9.1,现有实现返回 9.25。使用 HEAD 评分源码及测试可复现,本轮未改评分。 +- 已有失败 `h5/test/factual-copy.js:9`:旧文案基线仍要求轮廓文案包含眼袋/黑眼圈描述,当前文案已移除;执行 HEAD 测试同样失败,相关源文件及旧基线本轮均未改动。保留问题供后续核对,未为本次提交放宽旧断言或修改 H5 行为。 + +## 继续开发前的已知边界 + +- 真实淘宝账号授权、AppKey/Secret、云网关及返回大图限制未实测。 +- Android/iOS 相机权限、照片方向、原生 canvas、cover-view 与视觉对齐待 IDE/真机验收。 +- 相机仍为三步手动拍摄,未迁移 H5 实时姿态与自动拍摄。 +- IDE 官方本地云应用调试使用 `test` 环境并需要初始化时传递启动 options;当前代码默认本机直连模拟代理,正式外部服务配置为 `online`。拿到开发权限后需接入并验证 IDE 调试链路,不能将本地样本通过等同于真实平台通过。 +- 商品跳转需要真实商品 ID 和平台能力配置。 diff --git a/taobao/docs/淘宝小程序调研与架构设计.md b/taobao/docs/淘宝小程序调研与架构设计.md new file mode 100644 index 0000000..fa74f71 --- /dev/null +++ b/taobao/docs/淘宝小程序调研与架构设计.md @@ -0,0 +1,15 @@ +# 淘宝小程序当前架构(2026-10-18) + +本轮用户确认:允许改共享后端,必须保持现有H5功能和显示一致;淘宝使用自己的账号体系与历史报告;共用后端并区分渠道;先本地验证,不动线上服务器。 + +此前V1.0方案中的三项判断已更正: + +- 外部服务使用淘宝云网关,不把支付宝 `my.request` 直接作为正式网络方案。 +- 淘宝账号来自网关签名校验后的平台OpenId,不沿用H5匿名游客,不接收客户端自报身份作为登录凭据。 +- 网关有限时、限体积,采用64KiB分片上传和服务端异步检测任务,而不是三张base64合并发送并等待45秒。 + +运行方式、模块职责、认证流程、共享规则同步、测试和真实平台待验收项统一维护在 [../README.md](../README.md),以实际代码和该说明为准。 + +后端主入口仍为 `h5/server.js`:H5继续使用原路由;淘宝扩展位于 `h5/lib/taobao-routes.js`、`taobao-auth.js`、`taobao-session.js`。账号使用同一个服务端存储,微信和淘宝分别限定会话来源与报告所有者。 + +H5页面、样式、前端脚本和素材本轮均未改动。渠道字段仅由服务端路由确定,H5游客即使传 `channel: taobao` 也不改变不留存行为。 diff --git a/taobao/mini.project.json b/taobao/mini.project.json new file mode 100644 index 0000000..f4441e8 --- /dev/null +++ b/taobao/mini.project.json @@ -0,0 +1,13 @@ +{ + "projectname": "juzi-skin-taobao", + "compileType": "mini", + "miniprogramRoot": "./", + "appid": "", + "exclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"], + "uploadExclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"], + "enableAppxNg": true, + "component2": true, + "transpile": { + "es6": true + } +} diff --git a/taobao/package-lock.json b/taobao/package-lock.json new file mode 100644 index 0000000..f62413d --- /dev/null +++ b/taobao/package-lock.json @@ -0,0 +1,71 @@ +{ + "name": "juzi-skin-taobao", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "juzi-skin-taobao", + "version": "0.1.0", + "dependencies": { + "@tbmp/mp-cloud-sdk": "1.5.10" + }, + "devDependencies": { + "playwright": "1.58.2" + } + }, + "node_modules/@tbmp/mp-cloud-sdk": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@tbmp/mp-cloud-sdk/-/mp-cloud-sdk-1.5.10.tgz", + "integrity": "sha512-KweMl44yjnU76jWEbuCyRpTDgxlgu6zCe41psre6IfjwGeQriSew/KymGESAs0SDjH0sAfFONo/tLR6ltrNa+w==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/taobao/package.json b/taobao/package.json new file mode 100644 index 0000000..23ab8ed --- /dev/null +++ b/taobao/package.json @@ -0,0 +1,14 @@ +{ + "name": "juzi-skin-taobao", + "version": "0.1.0", + "private": true, + "scripts": { + "sync:shared": "node scripts/sync-shared.cjs", + "check:shared": "node scripts/sync-shared.cjs --check", + "test": "node --test test/*.test.cjs", + "dev:backend": "node scripts/dev-backend.cjs", + "test:h5": "node test/h5-compatibility.cjs" + }, + "dependencies": { "@tbmp/mp-cloud-sdk": "1.5.10" }, + "devDependencies": { "playwright": "1.58.2" } +} diff --git a/taobao/pages/camera/camera.acss b/taobao/pages/camera/camera.acss new file mode 100644 index 0000000..09dd848 --- /dev/null +++ b/taobao/pages/camera/camera.acss @@ -0,0 +1,183 @@ +/* ═════ 拍照页样式 ═════ */ +.cam { + position: relative; + width: 100%; + height: 100vh; + background: #101823; +} +.cam-device { + width: 100%; + height: 100vh; +} + +/* ─── 步骤文案 ─── */ +.cam-step { + position: absolute; + top: 80rpx; + left: 0; + right: 0; + text-align: center; +} +.cam-step-no { + color: #FFFFFF; + font-size: 48rpx; + font-weight: 600; +} +.cam-step-tip { + color: #FFFFFF; + font-size: 40rpx; + font-weight: 600; + margin-top: 8rpx; +} + +/* ─── 取景蒙版(椭圆线框) ─── */ +.cam-mask { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} +.cam-oval { + width: 560rpx; + height: 720rpx; + border: 4rpx solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + box-sizing: border-box; +} +/* 侧脸步轮廓偏移提示(骨架版:向左/右微移示意朝向) */ +.mask-1 { transform: translateX(-60rpx); } /* 左脸出镜:耳在画面左 */ +.mask-2 { transform: translateX(60rpx); } /* 右脸出镜:耳在画面右 */ + +/* ─── 底部引导要点 ─── */ +.cam-guide { + position: absolute; + bottom: 260rpx; + left: 0; + right: 0; + display: flex; + justify-content: center; +} +.cam-guide-item { + color: #FFFFFF; + font-size: 26rpx; + padding: 0 24rpx; + border-right: 2rpx solid rgba(255, 255, 255, 0.4); +} +.cam-guide-item:last-child { border-right: none; } + +/* ─── 控制条 ─── */ +.cam-controls { + position: absolute; + bottom: 80rpx; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-around; +} +.cam-side { + width: 120rpx; + height: 88rpx; + line-height: 88rpx; + text-align: center; + color: #FFFFFF; + font-size: 28rpx; +} +.cam-shutter { + width: 144rpx; + height: 144rpx; + border: 6rpx solid #FFFFFF; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.cam-shutter-dot { + width: 108rpx; + height: 108rpx; + border-radius: 50%; + background: #F5F078; +} + +/* ─── 停顿卡 ─── */ +.cam-done { + position: absolute; + top: 45%; + left: 60rpx; + right: 60rpx; + background: rgba(0, 0, 0, 0.65); + color: #FFFFFF; + font-size: 28rpx; + text-align: center; + border-radius: 16rpx; + padding: 24rpx; +} + +/* ─── 拍后预览 ─── */ +.cam-preview { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + background: #000000; +} +.cam-preview-img { + width: 100%; + height: 78%; +} +.cam-preview-btns { + position: absolute; + bottom: 80rpx; + left: 0; + right: 0; + display: flex; + justify-content: space-around; +} +.cam-preview-btn { + width: 280rpx; + height: 88rpx; + line-height: 88rpx; + text-align: center; + border-radius: 44rpx; + border: 2rpx solid #FFFFFF; + color: #FFFFFF; + font-size: 30rpx; +} +.cam-preview-btn.ok { + background: #F5F078; + border-color: #F5F078; + color: #282828; + font-weight: 600; +} + +/* ─── 分析中 ─── */ +.cam-analyzing { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; +} +.cam-analyzing-text { + color: #FFFFFF; + font-size: 30rpx; +} + +/* ─── 隐藏画布(压缩降级用) ─── */ +.compress-canvas { + position: fixed; + left: -9999rpx; + top: -9999rpx; + width: 640px; + height: 640px; +} +.cam-analyzing { flex-direction: column; } +.cam-cancel { margin-top: 48rpx; color: #fff; font-size: 26rpx; padding: 24rpx; } +.camera-permission { padding: 56rpx 40rpx; color: #fff; line-height: 1.8; } +.camera-permission button { margin-top: 24rpx; } +.debug-banner { padding: 24rpx; background: #F5F078; color: #282828; font-size: 24rpx; } +.debug-results { padding: 32rpx; background: #101823; color: white; line-height: 1.8; } +.debug-title { font-size: 32rpx; font-weight: 600; } +.debug-result { margin: 24rpx 0; } +.debug-result image { display: block; width: 100%; height: 320rpx; } +.debug-results button { margin-top: 24rpx; } diff --git a/taobao/pages/camera/camera.axml b/taobao/pages/camera/camera.axml new file mode 100644 index 0000000..700390e --- /dev/null +++ b/taobao/pages/camera/camera.axml @@ -0,0 +1,77 @@ + + + 相机验收 · 免登录 · 照片只在本页处理,不上传 + + 三步拍摄与 JPEG 压缩已完成 + 请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。 + + + {{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB + + + + + + {{stepText}} · {{stepTip}} + {{permissionMessage}} + + + + + + + + + {{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}} + + + + + 开发调试 · 不上传照片 + {{stepText}} + {{stepTip}} + + + + + + + + + + {{item}} + + + + + 相册 + + + + 翻转 + + + + {{doneTip}} + + + + + + 重拍 + 使用照片 + + + + + + {{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}} + {{debugMode ? '退出验收' : '返回,稍后查看历史报告'}} + + + + + + diff --git a/taobao/pages/camera/camera.js b/taobao/pages/camera/camera.js new file mode 100644 index 0000000..2d683c2 --- /dev/null +++ b/taobao/pages/camera/camera.js @@ -0,0 +1,186 @@ +var api = require('../../utils/api'), image = require('../../utils/image'), voice = require('../../utils/voice'); +var permissions = require('../../utils/permissions'); +var development = require('../../utils/development'); +var STEPS = [ + { target: 'front', tip: '请单人正脸出镜', turn: '正脸居中', mask: 0 }, + { target: 'right', tip: '请右脸出镜', turn: '请向左转头', mask: 2 }, + { target: 'left', tip: '请左脸出镜', turn: '请向右转头', mask: 1 } +]; +Page({ + data: { stepIndex: 0, stepText: '1/3', stepTip: STEPS[0].tip, guides: ['正脸居中', '取下眼镜', '光线充足'], + maskStep: 0, devicePosition: 'front', camError: false, previewUrl: '', doneTip: '', analyzing: false, + compressWidth: 1, compressHeight: 1, cameraEnabled: false, permissionMessage: '拍摄面部照片用于肌肤检测,请开启相机权限。', + debugMode: false, debugComplete: false, debugPhotos: [] }, + onLoad: function (query) { + this.alive = true; this.visible = true; this.photos = []; this.busy = false; this.seq = 0; + this.debugMode = !!(query && query.debug === 'camera' && development.cameraDebugEnabled()); + this.setData({ debugMode: this.debugMode }); + if (this.debugMode) return; + if (!api.user() || !getApp().globalData.profile) my.redirectTo({ url: '/pages/splash/splash' }); + }, + onReady: function () { + this.pageReady = true; + if (this.cameraAuthorized) this.mountCamera(); + }, + onShow: function () { + this.visible = true; + if (!this.data.previewUrl && !this.data.analyzing && !this.data.debugComplete) this.renderStep(); + var that = this; + permissions.settings().then(function (current) { + if (!that.alive || !that.visible) return; + if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); } + else if (permissions.state(current, 'scope.camera') === false) that.setData({ cameraEnabled: false, permissionMessage: '相机权限未开启,可前往授权设置或使用相册。' }); + }); + }, + mountCamera: function () { + if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled || this.data.debugComplete) return; + var that = this; + this.setData({ cameraEnabled: true, camError: false }, function () { + if (!that.alive || !that.visible) return; + try { + that.cameraCtx = my.createCameraContext('skin-camera'); + if (!that.cameraCtx || !that.cameraCtx.takePhoto) that.onCameraError(); + } catch (_) { that.onCameraError(); } + }); + }, + onEnableCamera: function () { + if (this.authorizing) return; + var that = this; this.authorizing = true; + permissions.ensure('scope.camera').then(function () { that.cameraAuthorized = true; that.mountCamera(); }).catch(function (err) { + if (that.alive && that.visible) that.setData({ permissionMessage: err.message }); + }).then(function () { that.authorizing = false; }); + }, + onOpenSettings: function () { + var that = this; + permissions.open().then(function (current) { + if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); } + }).catch(function (err) { if (that.alive) my.showToast({ content: err.message, type: 'none' }); }); + }, + onHide: function () { this.pause(); }, + onUnload: function () { this.pause(); this.alive = false; this.photos = []; this.debugResults = []; }, + pause: function () { + this.visible = false; this.seq++; this.busy = false; + clearTimeout(this.nextTimer); + if (this.signal) this.signal.canceled = true; + voice.stop(true); + this.cameraCtx = null; + if (this.alive) this.setData({ cameraEnabled: false }); + if (this.alive && this.data.analyzing) this.setData({ analyzing: false, doneTip: '' }); + }, + renderStep: function () { + var cfg = STEPS[this.data.stepIndex]; + this.setData({ stepText: (this.data.stepIndex + 1) + '/3', stepTip: cfg.tip, + guides: [cfg.turn, '取下眼镜', '光线充足'], maskStep: cfg.mask, doneTip: '' }); + if (!this.debugMode) voice.play(voice.VOICE.step[this.data.stepIndex]); + }, + onCameraError: function () { + this.setData({ camError: true, cameraEnabled: false, permissionMessage: '相机暂不可用,请检查淘宝的系统相机权限,或使用系统相机/相册。' }); + my.showToast({ content: '相机不可用,请使用系统相机或相册', type: 'none' }); + }, + onCameraStop: function () { if (this.alive && this.visible) this.onCameraError(); }, + onShutter: function () { + if (this.busy || this.data.previewUrl || this.data.analyzing) return; + if (this.data.camError || !this.cameraCtx) return this.choose(['camera']); + var that = this, seq = this.seq; + this.busy = true; + this.cameraCtx.takePhoto({ quality: 'high', success: function (res) { + if (!that.alive || !that.visible || seq !== that.seq) return; + var file = res.tempImagePath || res.tempFilePath || res.apFilePath; + if (file) that.setData({ previewUrl: file }); + else my.showToast({ content: '拍摄失败,请重试', type: 'none' }); + }, fail: function () { + if (that.alive && that.visible) my.showToast({ content: '拍摄失败,请重试', type: 'none' }); + }, complete: function () { if (seq === that.seq) that.busy = false; } }); + }, + choose: function (sourceType) { + if (this.busy || this.data.previewUrl || this.data.analyzing) return; + var that = this; + this.busy = true; + // chooseImage has its own album scope; native capture also needs camera. + permissions.ensure('scope.album').then(function () { + if (sourceType[0] === 'camera') return permissions.ensure('scope.camera'); + }).then(function () { + if (!that.alive || !that.visible) { that.busy = false; return; } + my.chooseImage({ count: 1, sourceType: sourceType, + success: function (res) { + var paths = res.apFilePaths || res.tempFilePaths || []; + if (that.alive && paths[0]) that.setData({ previewUrl: paths[0] }); + }, fail: function () { if (that.alive && that.visible) my.showToast({ content: '未选取照片,可重试或检查系统权限', type: 'none' }); }, + complete: function () { that.busy = false; } }); + }).catch(function (err) { + that.busy = false; + if (that.alive && that.visible) { that.setData({ permissionMessage: err.message }); my.showToast({ content: err.message, type: 'none' }); } + }); + }, + onSystemCamera: function () { this.choose(['camera']); }, + onAlbum: function () { this.choose(['album']); }, + onFlip: function () { + if (this.busy || this.data.previewUrl || this.data.analyzing) return; + this.setData({ devicePosition: this.data.devicePosition === 'front' ? 'back' : 'front' }); + }, + onRetake: function () { if (!this.busy) this.setData({ previewUrl: '' }); }, + onConfirm: function () { + if (this.busy || !this.data.previewUrl || this.data.analyzing) return; + var that = this, step = this.data.stepIndex; + this.busy = true; this.photos[step] = this.data.previewUrl; + if (!this.debugMode) voice.play(voice.VOICE.shot, { cut: true }); + if (step === 2) { this.setData({ previewUrl: '' }); this.submitPhotos(); return; } + this.setData({ stepIndex: step + 1, previewUrl: '', doneTip: '拍摄完成。下一张:' + STEPS[step + 1].tip + ',' + STEPS[step + 1].turn }); + this.nextTimer = setTimeout(function () { + if (!that.alive || !that.visible) return; + that.busy = false; that.renderStep(); + }, 2000); + }, + submitPhotos: function () { + if (!this.photos[0] || !this.photos[1] || !this.photos[2]) return; + var that = this, seq = ++this.seq, dataUrls = []; + var debugResults = []; + this.signal = { canceled: false }; var signal = this.signal; + this.busy = true; this.setData({ analyzing: true, doneTip: '' }); + if (!this.debugMode) voice.play(voice.VOICE.analyzing); + // One shared native canvas: draw the photos sequentially. + this.photos.reduce(function (chain, photo) { + return chain.then(function () { + if (signal.canceled) throw { canceled: true }; + return image.compressToBase64(photo, that); + }).then(function (value) { + dataUrls.push(value); + if (that.debugMode) debugResults.push({ key: STEPS[debugResults.length].target, + title: STEPS[debugResults.length].tip.replace(/^请/, ''), photo: photo, + width: that.data.compressWidth, height: that.data.compressHeight, + kilobytes: Math.round(value.replace(/^data:[^,]+,/, '').length * 3 / 4 / 1024) }); + }); + }, Promise.resolve()).then(function () { + if (signal.canceled) throw { canceled: true }; + if (that.debugMode) { + if (!that.alive || !that.visible || seq !== that.seq) return; + that.busy = false; that.cameraCtx = null; + dataUrls = []; // Do not keep base64, upload, create an account or touch report state. + that.setData({ analyzing: false, cameraEnabled: false, debugComplete: true, debugPhotos: debugResults }); + return; + } + var ordered = [dataUrls[0], dataUrls[2], dataUrls[1]]; + return api.analyze(ordered, getApp().globalData.profile, signal).then(function (payload) { + if (!that.alive || !that.visible || seq !== that.seq) return; + getApp().globalData.photos = ordered; getApp().globalData.payload = payload; + voice.stop(true); my.redirectTo({ url: '/pages/report/report' }); + }); + }).catch(function (err) { + if (err.canceled || !that.alive || !that.visible || seq !== that.seq) return; + that.busy = false; that.setData({ analyzing: false }); + if (!that.debugMode) voice.play(voice.VOICE.fail, { cut: true }); + my.showToast({ content: err.message || '照片处理失败,请重新拍摄', type: 'none', duration: 3000 }); + }); + }, + onDebugRestart: function () { + if (!this.debugMode || !development.cameraDebugEnabled()) return; + this.photos = []; this.busy = false; this.seq++; + this.setData({ debugComplete: false, debugPhotos: [], stepIndex: 0, previewUrl: '', analyzing: false }); + this.renderStep(); this.onEnableCamera(); + }, + onCancel: function () { + if (this.debugMode) { this.photos = []; this.setData({ debugPhotos: [], previewUrl: '' }); } + this.pause(); my.navigateBack(); + }, + noop: function () {} +}); diff --git a/taobao/pages/camera/camera.json b/taobao/pages/camera/camera.json new file mode 100644 index 0000000..cbe6e42 --- /dev/null +++ b/taobao/pages/camera/camera.json @@ -0,0 +1,6 @@ +{ + "defaultTitle": "1'me在线肌肤测试", + "titleBarColor": "#101823", + "backgroundColor": "#101823", + "disableScroll": true +} diff --git a/taobao/pages/history/history.acss b/taobao/pages/history/history.acss new file mode 100644 index 0000000..3946c9c --- /dev/null +++ b/taobao/pages/history/history.acss @@ -0,0 +1,5 @@ +page { background: #EFEFEF; } +.history { padding-bottom: 48rpx; } +.history-note { padding: 32rpx; color: #666; text-align: center; } +.history-item { line-height: 1.9; } +.history-open { color: #666; text-align: right; } diff --git a/taobao/pages/history/history.axml b/taobao/pages/history/history.axml new file mode 100644 index 0000000..8ca3da6 --- /dev/null +++ b/taobao/pages/history/history.axml @@ -0,0 +1,10 @@ + + 最近5份检测报告 + 加载中… + {{error}},点击重试 + 暂无检测报告 + + {{item.date}}综合评分 {{item.total}} · 肌肤年龄 {{item.skinAge}} 岁 + 查看报告 › + + diff --git a/taobao/pages/history/history.js b/taobao/pages/history/history.js new file mode 100644 index 0000000..1076758 --- /dev/null +++ b/taobao/pages/history/history.js @@ -0,0 +1,34 @@ +var api = require('../../utils/api'); +Page({ + data: { items: [], loading: false, error: '' }, + onShow: function () { + if (!api.user()) { this.setData({ items: [] }); my.redirectTo({ url: '/pages/splash/splash' }); return; } + this.load(); + }, + onUnload: function () { this.closed = true; }, + load: function () { + var that = this; + this.setData({ loading: true, error: '' }); + api.request('reports').then(function (data) { + if (that.closed) return; + that.setData({ items: data.items.map(function (item) { + var date = new Date(item.created_at); + return { id: item.id, total: item.total == null ? '--' : item.total, skinAge: item.skinAge == null ? '--' : item.skinAge, + date: date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() }; + }) }); + }).catch(function (err) { if (!that.closed) that.setData({ error: err.message }); }) + .then(function () { if (!that.closed) that.setData({ loading: false }); }); + }, + onOpen: function (e) { + if (this.data.loading) return; + var that = this; + this.setData({ loading: true }); + api.request('reports', { id: e.currentTarget.dataset.id }).then(function (data) { + if (that.closed) return; + getApp().globalData.photos = data.report.photos; + getApp().globalData.payload = Object.assign({}, data.report.payload, { vm: data.report.vm, presentation: data.report.presentation }); + my.navigateTo({ url: '/pages/report/report' }); + }).catch(function (err) { my.showToast({ content: err.message, type: 'none' }); }) + .then(function () { if (!that.closed) that.setData({ loading: false }); }); + } +}); diff --git a/taobao/pages/history/history.json b/taobao/pages/history/history.json new file mode 100644 index 0000000..799d461 --- /dev/null +++ b/taobao/pages/history/history.json @@ -0,0 +1 @@ +{ "defaultTitle": "历史报告", "backgroundColor": "#EFEFEF" } diff --git a/taobao/pages/profile/profile.acss b/taobao/pages/profile/profile.acss new file mode 100644 index 0000000..0aa01b8 --- /dev/null +++ b/taobao/pages/profile/profile.acss @@ -0,0 +1,141 @@ +/* ═════ 档案填写页样式 ═════ */ +.profile { + position: relative; + min-height: 100vh; +} +.profile-bg { + position: fixed; + left: 0; top: 0; + width: 100%; + height: 100%; + /* 上半部分露出背景图,表单区由白色卡片承载 */ +} +.profile-body { + position: relative; + padding: 48rpx 32rpx 64rpx; + box-sizing: border-box; +} +.page-title { + color: #FFFFFF; + font-size: 44rpx; + font-weight: 600; + text-align: center; + margin-bottom: 48rpx; +} + +.form-row { + display: flex; + justify-content: space-between; +} +.field { margin-bottom: 32rpx; } +.field-half { width: 48%; } +.field-label { + color: #FFFFFF; + font-size: 28rpx; + margin-bottom: 16rpx; + text-align: center; +} +.pick { + display: flex; + align-items: center; + justify-content: space-between; + height: 88rpx; + background: #FFFFFF; + border-radius: 16rpx; + padding: 0 24rpx; + box-sizing: border-box; +} +.pick-value { + color: #282828; + font-size: 28rpx; +} +.chev { + color: #868686; + font-size: 28rpx; +} + +/* ─── 肤质卡 ─── */ +.skin-card { + background: #FFFFFF; + border-radius: 24rpx; + padding: 32rpx 24rpx; + box-sizing: border-box; +} +.skin-axis { + display: flex; + justify-content: center; + margin-bottom: 24rpx; +} +.axis-item { + display: flex; + align-items: center; + font-size: 24rpx; + color: #868686; + margin: 0 16rpx; +} +.chip { + display: inline-block; + width: 20rpx; + height: 20rpx; + border-radius: 50%; + margin-right: 8rpx; +} +.chip-dry { background: #EAE6E1; } +.chip-oily { background: #F5F078; } +.chip-balance { background: #DBF6D6; } + +.skin-stage { + display: flex; + align-items: center; +} +.skin-arrow { + width: 64rpx; + font-size: 56rpx; + color: #868686; + text-align: center; + flex-shrink: 0; +} +.skin-swiper { + flex: 1; + height: 460rpx; +} +.skin-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 16rpx; + box-sizing: border-box; +} +.skin-img { + width: 280rpx; + height: 280rpx; +} +.skin-name { + font-size: 32rpx; + font-weight: 600; + margin-top: 16rpx; +} +.skin-desc { + font-size: 24rpx; + color: #868686; + text-align: center; + margin-top: 8rpx; +} +.skin-dots { + display: flex; + justify-content: center; + margin-top: 16rpx; +} +.dot { + width: 12rpx; + height: 12rpx; + border-radius: 50%; + background: #E5E5E5; + margin: 0 6rpx; +} +.dot.on { background: #282828; } + +.btn-submit { margin-top: 24rpx; } +.picker-sheet { background: white; width: 100%; align-self: flex-end; border-radius: 24rpx 24rpx 0 0; } +.picker-actions { display: flex; justify-content: space-between; padding: 32rpx; } +.native-picker { width: 100%; height: 440rpx; text-align: center; line-height: 80rpx; } diff --git a/taobao/pages/profile/profile.axml b/taobao/pages/profile/profile.axml new file mode 100644 index 0000000..c405371 --- /dev/null +++ b/taobao/pages/profile/profile.axml @@ -0,0 +1,91 @@ + + + + + 您的肌肤状态 + + + + + 您的出生年月 + + + {{birthText}} + ⌄ + + + + + 您的性别 + + + {{genderText}} + ⌄ + + + + + + + + 您的所在地区 + + + {{regionText}} + ⌄ + + + + + + + 您的日常肤质 + + + 偏干 + 偏油 + 水油平衡 + + + ‹ + + + + + {{item.name}} + {{item.desc}} + + + + › + + + + + + + + + + + + + + + + 请拍摄或上传 + 正面及左右侧面照片 + + + + + + + 取消确定 + + {{item}} + {{item}} + + + + diff --git a/taobao/pages/profile/profile.js b/taobao/pages/profile/profile.js new file mode 100644 index 0000000..b74ad58 --- /dev/null +++ b/taobao/pages/profile/profile.js @@ -0,0 +1,176 @@ +/* ═════ 档案填写页:出生年月/性别/地区(原生 picker)+ 日常肤质选择(对齐 H5 #s-profile) ═════ + * 档案字段与 H5 一致:{ real_age, gender, region, daily_skin_type } */ +var api = require('../../utils/api.js'); +var REGION_DATA = require('../../utils/region-data.js'); + +// 肤质选项(与 H5 SKINS 一致,图片远程加载) +var SKINS = [ + { img: 'img/ui-20261009/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' }, + { img: 'img/ui-20261009/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' }, + { img: 'img/ui-20261009/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' }, + { img: 'img/ui-20261009/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' }, + { img: 'img/ui-20261009/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' } +]; + +var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1; +var YEARS = [], MONTHS = []; +for (var y = NOW_Y - 99; y <= NOW_Y - 1; y++) YEARS.push(y + '年'); +for (var m = 1; m <= 12; m++) MONTHS.push(m + '月'); + +function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁(与 H5 一致) + +Page({ + data: { + bgUrl: api.assetUrl('img/bg-splash.jpg'), + + // 出生年月(两列滚轮:年+月,默认 30 岁 1 月) + birthRange: [YEARS, MONTHS], + birthIndex: [YEARS.indexOf((NOW_Y - 30) + '年'), 0], + birthText: '', + birth: '', // 'YYYY-MM' + age: 0, // real_age + + // 性别 + genderRange: ['女', '男'], + genderText: '', + + // 所在地区(省 + 市两列) + regionRange: [ + REGION_DATA.map(function (p) { return p[0]; }), + REGION_DATA[0][1] + ], + regionIndex: [0, 0], + regionText: '', + + // 肤质轮播 + skins: SKINS.map(function (s) { + return { img: api.assetUrl(s.img), name: s.name, desc: s.desc }; + }), + skinIndex: 0, + + showGuide: false, picker: '', pickerValue: [0, 0] + }, + onLoad: function () { + if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; } + this.ownerId = api.user().id; + try { + var saved = my.getStorageSync({ key: 'juzi.taobao.profile.' + api.user().id }).data; + if (saved) { + saved.age = ageFromBirth(Number(saved.birth.split('-')[0]), Number(saved.birth.split('-')[1])); + this.setData(saved); + } + } catch (_) {} + }, + onShow: function () { + if (!api.user() || this.ownerId !== api.user().id) { + this.setData({ birthText: '', genderText: '', regionText: '', showGuide: false }); + my.redirectTo({ url: '/pages/splash/splash' }); + } + }, + onOpenBirth: function () { this.setData({ picker: 'birth', pickerValue: this.data.birthIndex.slice() }); }, + onOpenRegion: function () { + var index = this.data.regionIndex.slice(); + // A canceled draft may have changed the cities while the saved province stayed put. + this.setData({ picker: 'region', pickerValue: index, + regionRange: [REGION_DATA.map(function (p) { return p[0]; }), REGION_DATA[index[0]][1]] }); + }, + onPickerChange: function (e) { + var value = e.detail.value; + if (this.data.picker === 'region' && value[0] !== this.data.pickerValue[0]) value[1] = 0; + var patch = { pickerValue: value }; + if (this.data.picker === 'region') patch.regionRange = [REGION_DATA.map(function (p) { return p[0]; }), REGION_DATA[value[0]][1]]; + this.setData(patch); + }, + onPickerConfirm: function () { + var e = { detail: { value: this.data.pickerValue } }; + if (this.data.picker === 'birth') this.onBirthChange(e); else this.onRegionChange(e); + this.onPickerClose(); + }, + onPickerClose: function () { this.setData({ picker: '' }); }, + + /* ─── 出生年月 ─── */ + onBirthChange: function (e) { + var idx = e.detail.value; + var y = parseInt(YEARS[idx[0]], 10); + var m = idx[1] + 1; + this.setData({ + birthIndex: idx, + birthText: y + '年' + m + '月', + birth: y + '-' + (m < 10 ? '0' : '') + m, + age: ageFromBirth(y, m) + }); + }, + + /* ─── 性别 ─── */ + onGenderChange: function (e) { + this.setData({ genderText: this.data.genderRange[e.detail.value] }); + }, + + /* ─── 地区:省份列滚动时联动城市列 ─── */ + onRegionColumnChange: function (e) { + if (e.detail.column !== 0) return; + var cities = REGION_DATA[e.detail.value][1]; + this.setData({ 'regionRange[1]': cities, regionIndex: [e.detail.value, 0] }); + }, + onRegionChange: function (e) { + var idx = e.detail.value; + var prov = REGION_DATA[idx[0]][0]; + var city = REGION_DATA[idx[0]][1][idx[1]]; + // 直辖市/港澳台只显示一级(与 H5 一致) + var text = !city || city === prov ? prov : prov + ' ' + city; + this.setData({ regionIndex: idx, regionText: text }); + }, + + /* ─── 肤质轮播 ─── */ + onSkinChange: function (e) { + this.setData({ skinIndex: e.detail.current }); + }, + onSkinPrev: function () { + if (this.data.skinIndex > 0) this.setData({ skinIndex: this.data.skinIndex - 1 }); + }, + onSkinNext: function () { + if (this.data.skinIndex < SKINS.length - 1) this.setData({ skinIndex: this.data.skinIndex + 1 }); + }, + + /* ─── 提交 → 拍照引导弹窗 ─── */ + onSubmit: function () { + var d = this.data; + if (!d.birth || !(d.age >= 1 && d.age <= 99)) { + my.showToast({ content: '请选择出生年月', type: 'none' }); + return; + } + if (!d.genderText) { + my.showToast({ content: '请选择性别', type: 'none' }); + return; + } + if (!d.regionText) { + my.showToast({ content: '请选择所在地区', type: 'none' }); + return; + } + this.setData({ showGuide: true }); + }, + + onCloseGuide: function () { + this.setData({ showGuide: false }); + }, + + // 「开始」:档案入全局态,进入拍照页 + onStartCamera: function () { + if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; } + var d = this.data; + getApp().globalData.profile = { + real_age: d.age, + gender: d.genderText, + region: d.regionText, + daily_skin_type: SKINS[d.skinIndex].name + }; + my.setStorageSync({ key: 'juzi.taobao.profile.' + api.user().id, data: { + birth: d.birth, birthIndex: d.birthIndex, birthText: d.birthText, genderText: d.genderText, + regionIndex: d.regionIndex, regionText: d.regionText, regionRange: d.regionRange, skinIndex: d.skinIndex + } }); + this.setData({ showGuide: false }); + my.navigateTo({ url: '/pages/camera/camera' }); + }, + + noop: function () {} +}); diff --git a/taobao/pages/profile/profile.json b/taobao/pages/profile/profile.json new file mode 100644 index 0000000..6b5c3e1 --- /dev/null +++ b/taobao/pages/profile/profile.json @@ -0,0 +1,5 @@ +{ + "defaultTitle": "1'me在线肌肤测试", + "titleBarColor": "#101823", + "backgroundColor": "#101823" +} diff --git a/taobao/pages/report/report.acss b/taobao/pages/report/report.acss new file mode 100644 index 0000000..2e06ca1 --- /dev/null +++ b/taobao/pages/report/report.acss @@ -0,0 +1,245 @@ +/* ═════ 报告页样式(灰底白卡,对齐设计稿 08) ═════ */ +page { background: #EFEFEF; } +.report { padding-bottom: 64rpx; } +.report-title { + font-size: 40rpx; + font-weight: 600; + text-align: center; + padding: 32rpx 0 8rpx; +} + +/* ─── 总分卡 ─── */ +.card-summary { + display: flex; + align-items: center; + justify-content: space-around; +} +.ring-box { + position: relative; + width: 240rpx; + height: 240rpx; +} +.ring-canvas { + width: 240rpx; + height: 240rpx; +} +.ring-center { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.sum-age { + display: flex; + flex-direction: column; + align-items: center; +} +.stat-label { + font-size: 24rpx; + color: #868686; +} +.stat-value { display: flex; align-items: baseline; } +.stat-num { + font-size: 64rpx; + font-weight: 700; + color: #282828; +} +.stat-unit { + font-size: 24rpx; + color: #868686; + margin-left: 4rpx; +} +.age-pill { + margin-top: 12rpx; + font-size: 22rpx; + color: #282828; + background: #F5F078; + border-radius: 20rpx; + padding: 4rpx 20rpx; +} + +/* ─── 三维度 ─── */ +.dims-row { + display: flex; + justify-content: space-between; +} +.dim { + width: 31%; + position: relative; +} +.dim-name { + font-size: 26rpx; + color: #4A4A4A; +} +.dim-score { margin: 8rpx 0; } +.dim-int { + font-size: 48rpx; + font-weight: 700; +} +.dim-dec { + font-size: 28rpx; + color: #868686; +} +.dim.worst .dim-int, .dim.worst .dim-dec { color: #D30C0C; } +.dim-bar { + height: 8rpx; + background: #EFEFEF; + border-radius: 4rpx; + overflow: hidden; +} +.dim-bar-fill { + height: 100%; + background: #E5E5E5; + border-radius: 4rpx; +} +.dim.best .dim-bar-fill { background: #DBF6D6; } +.dim.worst .dim-bar-fill { background: #F5C6C6; } +.dim-flag { + position: absolute; + top: 0; + right: 0; + font-size: 20rpx; + color: #D30C0C; +} +.dims-note { margin-top: 24rpx; } +.dims-note-l1 { + font-size: 26rpx; + color: #868686; +} +.dims-focus { + color: #282828; + font-weight: 600; + text-decoration: underline; +} +.dims-note-l2 { + font-size: 24rpx; + color: #868686; + margin-top: 4rpx; +} + +/* ─── 6 分类 Tab ─── */ +.detail-tabs { + display: flex; + border-bottom: 2rpx solid #E5E5E5; + margin-bottom: 24rpx; +} +.dtab { + flex: 1; + text-align: center; + font-size: 28rpx; + color: #868686; + padding: 16rpx 0; +} +.dtab.active { + color: #282828; + font-weight: 600; + border-bottom: 4rpx solid #F5F078; +} +.ditem { margin-bottom: 32rpx; } +.ditem-title { + font-size: 28rpx; + font-weight: 600; + margin-bottom: 16rpx; +} +.ditem-img { + width: 100%; + border-radius: 16rpx; + margin-bottom: 16rpx; + background: #EFEFEF; +} +.ditem-para { + font-size: 26rpx; + color: #4A4A4A; + line-height: 1.7; +} + +/* ─── 降级标注 + 免责 ─── */ +.vm-notes { + font-size: 22rpx; + color: #868686; + padding: 16rpx 32rpx 0; + line-height: 1.6; +} +.report-disclaimer { + font-size: 22rpx; + color: #868686; + text-align: center; + line-height: 1.8; + padding: 24rpx 32rpx; +} + +/* ─── 护理建议 / 精华 ─── */ +.care-item { + font-size: 26rpx; + color: #4A4A4A; + line-height: 1.7; + margin-bottom: 16rpx; +} +.care-summary { + font-size: 24rpx; + color: #868686; + margin-top: 16rpx; +} +.essence-name { + font-size: 32rpx; + font-weight: 600; +} +.essence-triggers { + font-size: 24rpx; + color: #868686; + margin-top: 12rpx; +} + +/* ─── 产品推荐 ─── */ +.prod-title { + text-align: center; + font-size: 36rpx; + font-weight: 700; + padding: 40rpx 0 8rpx; +} +.prod-title text { display: block; line-height: 1.5; } +.prod-card { + display: flex; + align-items: center; + justify-content: space-between; +} +.prod-info { flex: 1; padding-right: 24rpx; } +.prod-badge { height: 40rpx; margin-bottom: 16rpx; } +.prod-name { + font-size: 30rpx; + font-weight: 600; + line-height: 1.4; +} +.prod-sub { + font-size: 22rpx; + color: #868686; + margin-top: 8rpx; +} +.prod-img { + width: 240rpx; + height: 240rpx; + border-radius: 16rpx; + flex-shrink: 0; +} + +.btn-more { + margin: 40rpx 32rpx 0; +} +.detail-tabs { display: block; white-space: nowrap; } +.detail-tabs-inner { display: flex; width: max-content; } +.dtab { flex-shrink: 0; padding: 16rpx 20rpx; } +.analysis-image { position: relative; width: 100%; overflow: hidden; border-radius: 16rpx; background: #202020; } +.analysis-photo { display: block; width: 100%; } +.dark-photo { opacity: .45; } +.analysis-layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%; } +.mark-box { position: absolute; box-sizing: border-box; border: 2rpx solid; border-radius: 4rpx; } +.side-views { display: flex; gap: 16rpx; margin: 24rpx 0; } +.side-view { flex: 1; min-width: 0; font-size: 24rpx; line-height: 1.8; } +.side-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.4); color: white; } +.side-note { color: #777; } +.detail-grade { color: #666; font-size: 24rpx; } +.care-name { font-weight: 600; margin-bottom: 8rpx; } +.dim-level { font-size: 22rpx; margin-top: 8rpx; color: #777; } +.ring-box { width: 120px; height: 120px; } diff --git a/taobao/pages/report/report.axml b/taobao/pages/report/report.axml new file mode 100644 index 0000000..ac655ef --- /dev/null +++ b/taobao/pages/report/report.axml @@ -0,0 +1,53 @@ + + 您的肌肤状态表现 + + + + {{total != null ? total : '--'}}分综合评分 + + 肌肤年龄{{skinAge != null ? skinAge : '--'}}岁{{ageNote}} + + + + {{item.name}}{{item.int}}.{{item.dec}} + {{item.level}} + 优先关注 + + 近期优先关注:{{focusName}}{{focusSlogan}} + + + + {{item.name}} + + + + {{item.name}} {{item.score}} · {{item.level}} + + + + + + + + {{side.title}} + + + 标注生成中… + {{side.note}} + + + {{item.text}} + + + + 您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。本次检测仅供当前肌肤状态参考。 + 护理建议 + {{item.name}}{{item.text}} + 好状态,是内外同调的长期节奏。 + + + {{product.title}}{{product.name}} + + + + diff --git a/taobao/pages/report/report.js b/taobao/pages/report/report.js new file mode 100644 index 0000000..6178afb --- /dev/null +++ b/taobao/pages/report/report.js @@ -0,0 +1,71 @@ +var api = require('../../utils/api'), report = require('../../utils/report-model'), config = require('../../config'); +Page({ + data: { total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '', + views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, sideViews: [] }, + onLoad: function () { + this.alive = true; this.visible = true; + this.ownerId = api.user() && api.user().id; + var gd = getApp().globalData; + if (!gd.payload || !gd.payload.result) { my.redirectTo({ url: '/pages/splash/splash' }); return; } + this.payload = gd.payload; this.photos = gd.photos || []; + try { this.setData(report.model(this.payload, this.photos, gd.profile || {})); } + catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); } + this.renderSides(this.payload.wrinkle_views); + }, + onReady: function () { this.ready = true; this.drawRing(this.data.total); this.loadSides(); }, + onShow: function () { + if (!api.user() || this.ownerId !== api.user().id) { + this.onHide(); this.setData({ frontPhoto: '', views: [], sideViews: [], careAdvice: [], product: null }); + my.redirectTo({ url: '/pages/splash/splash' }); return; + } + this.visible = true; if (this.ready) this.loadSides(); + }, + onHide: function () { this.visible = false; if (this.signal) this.signal.canceled = true; }, + onUnload: function () { this.onHide(); this.alive = false; this.photos = []; this.payload = null; }, + onTabChange: function (e) { + var i = Number(e.currentTarget.dataset.tab), view = this.data.views[i]; + if (!view) return; + this.setData({ viewIndex: i, activeLayers: report.layers(this.payload.result, view.key) }); + }, + onPhotoLoad: function (e) { + var i = Number(e.currentTarget.dataset.index), view = this.data.views[i]; + if (!view || !this.payload) return; + var points = report.marks(this.payload.result, view.key, e.detail.width, e.detail.height); + var patch = {}; patch['views[' + i + '].marks'] = points; this.setData(patch); + }, + renderSides: function (views) { + var that = this; + this.setData({ sideViews: ['left', 'right'].map(function (key, i) { + var item = views && views[key] || {}, layer = report.mapURL(item.result && item.result.face_maps && item.result.face_maps.texture_enhanced_lines); + return { key: key, title: i ? '右脸皱纹' : '左脸皱纹', photo: that.photos[i + 1] || '', layer: layer, + loading: item.status === 'pending', note: item.status === 'unavailable' ? '侧脸标注暂不可用' : '' }; + }) }); + }, + loadSides: function () { + var that = this, job = this.payload && this.payload.wrinkle_job; + if (!job || job.status === 'complete' || !this.visible || this.loadingSides) return; + this.loadingSides = true; this.signal = { canceled: false }; var signal = this.signal; + api.wrinkleViews(job.token, signal).then(function (data) { + if (!that.alive || !that.visible || signal.canceled) return; + that.payload.wrinkle_views = data.wrinkle_views; job.status = 'complete'; that.renderSides(data.wrinkle_views); + }).catch(function () { + if (that.alive && that.visible && !signal.canceled) that.renderSides({ left: { status: 'unavailable' }, right: { status: 'unavailable' } }); + }).then(function () { that.loadingSides = false; if (signal.canceled && that.alive && that.visible) that.loadSides(); }); + }, + drawRing: function (total) { + var ctx = my.createCanvasContext('score-ring'), ratio = total == null ? 0 : Math.max(0, Math.min(100, total)) / 100; + ctx.clearRect(0, 0, 120, 120); ctx.setLineWidth(8); + ctx.beginPath(); ctx.arc(60, 60, 52, 0, 2 * Math.PI); ctx.setStrokeStyle('#E5E5E5'); ctx.stroke(); + if (total != null) { + ctx.beginPath(); ctx.arc(60, 60, 52, -Math.PI / 2, -Math.PI / 2 + ratio * 2 * Math.PI); + ctx.setStrokeStyle('#F5F078'); ctx.setLineCap('round'); ctx.stroke(); + } + ctx.draw(); + }, + onMore: function () { + var id = this.data.product && config.productItemIds[this.data.product.tier]; + if (!id) return; + if (my.tb && my.tb.openDetail) my.tb.openDetail({ itemId: String(id) }); + }, + onHistory: function () { my.navigateTo({ url: '/pages/history/history' }); } +}); diff --git a/taobao/pages/report/report.json b/taobao/pages/report/report.json new file mode 100644 index 0000000..9795d93 --- /dev/null +++ b/taobao/pages/report/report.json @@ -0,0 +1,5 @@ +{ + "defaultTitle": "1'me在线肌肤测试", + "titleBarColor": "#FFFFFF", + "backgroundColor": "#EFEFEF" +} diff --git a/taobao/pages/splash/splash.acss b/taobao/pages/splash/splash.acss new file mode 100644 index 0000000..5a35de4 --- /dev/null +++ b/taobao/pages/splash/splash.acss @@ -0,0 +1,144 @@ +/* ═════ 启动页样式 ═════ */ +.splash { + position: relative; + width: 100%; + height: 100vh; + overflow: hidden; +} +.splash-bg { + position: absolute; + left: 0; top: 0; + width: 100%; + height: 100%; +} +.splash-slogan { + position: absolute; + top: 200rpx; + left: 0; + right: 0; + text-align: center; + color: #FFFFFF; +} +.splash-slogan text { + display: block; + font-size: 44rpx; + font-weight: 600; + line-height: 1.6; +} + +.splash-bottom { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 0 64rpx 96rpx; + box-sizing: border-box; +} +.brand-row { + display: flex; + align-items: center; + margin-bottom: 32rpx; +} +.brand-logo { + height: 48rpx; + margin-right: 16rpx; +} +.brand-text { + color: #FFFFFF; + font-size: 32rpx; + font-weight: 600; +} + +/* ─── 隐私授权弹窗 ─── */ +.privacy-row { + display: flex; + align-items: center; + margin-bottom: 32rpx; +} +.checkbox { + width: 36rpx; + height: 36rpx; + border: 2rpx solid #E5E5E5; + border-radius: 50%; + margin-right: 16rpx; + box-sizing: border-box; + position: relative; +} +.checkbox.checked { + background: #F5F078; + border-color: #F5F078; +} +.checkbox.checked::after { + content: ''; + position: absolute; + left: 10rpx; + top: 6rpx; + width: 8rpx; + height: 14rpx; + border-right: 4rpx solid #282828; + border-bottom: 4rpx solid #282828; + transform: rotate(45deg); +} +.privacy-text { + font-size: 26rpx; + color: #4A4A4A; +} +.privacy-link { + color: #282828; + text-decoration: underline; +} +.popup-btn { width: 100%; } + +/* ─── 隐私协议全文弹层 ─── */ +.overlay-policy { padding: 0; } +.policy-page { + position: relative; + width: 100%; + height: 100%; + background: #FFFFFF; +} +.policy-back { + position: absolute; + top: 24rpx; + left: 24rpx; + z-index: 2; + width: 64rpx; + height: 64rpx; + line-height: 56rpx; + font-size: 56rpx; + color: #282828; + background: none; + padding: 0; +} +.policy-back::after { border: none; } +.policy-scroll { + height: 100%; + box-sizing: border-box; + padding: 96rpx 40rpx 64rpx; +} +.policy-title { + font-size: 36rpx; + font-weight: 700; + margin-bottom: 40rpx; +} +.policy-h2 { + font-size: 30rpx; + font-weight: 600; + margin: 40rpx 0 16rpx; +} +.policy-p { + font-size: 26rpx; + color: #4A4A4A; + line-height: 1.8; + margin-bottom: 16rpx; +} +.policy-end { + text-align: center; + color: #868686; + font-size: 24rpx; + margin: 48rpx 0; +} +.account-links { display: flex; justify-content: center; gap: 40rpx; color: #fff; margin-top: 24rpx; font-size: 24rpx; } +.splash-slogan { top: 10.7%; } +.splash-slogan text { font-size: 44rpx; line-height: 1.4; } +.brand-row { justify-content: center; } +.brand-logo { width: 450rpx; height: 52rpx; margin: 0; } +.camera-debug-entry { position: absolute; z-index: 2; top: 24rpx; right: 24rpx; padding: 16rpx 24rpx; border-radius: 16rpx; color: #fff; background: rgba(0,0,0,.65); font-size: 24rpx; } diff --git a/taobao/pages/splash/splash.axml b/taobao/pages/splash/splash.axml new file mode 100644 index 0000000..7227a92 --- /dev/null +++ b/taobao/pages/splash/splash.axml @@ -0,0 +1,82 @@ + + + 开发调试 · 相机验收 + + + 1'me, + FROM THE INSIDE OUT + 以我为始 自有表现 + + + + + + + + + + + + + + + + 欢迎体验1'me + 肌肤状态评估服务! + + 授权淘宝账号后,我们将采集您的面部影像和基础档案,用于生成肌肤分析报告,并在服务器保存检测记录及照片,供您查看最近5份报告。照片将交由 Face⁺⁺ 提供检测分析。 + + + + 我已阅读并同意 + 相关隐私协议 + + + + + + + + + + + + Face⁺⁺个人信息与隐私保护政策 + + 前言 + 北京旷视科技有限公司及其关联公司(以下简称"我们"或"旷视")尊重并致力于保护个人信息与隐私。Face⁺⁺人工智能开放平台(以下简称"本平台")和/或Face⁺⁺人工智能开放平台服务(以下简称"本服务")作为旷视面向开发者的视觉智能开放平台与服务,严格参照《旷视个人信息与隐私保护政策》中的统一要求和标准处理相关个人信息;为进一步明确在本平台和本服务中可能涉及的个人信息处理实践情况,我们特制定了Face⁺⁺个人信息与隐私保护政策(以下简称"Face⁺⁺政策"),以介绍本平台和本服务中收集、使用、留存等个人信息的具体处理。 + Face⁺⁺政策拟帮助客户和用户了解:我们如何收集使用个人信息、如何保留和披露、如何保护、个人信息在全球范围内的转移、您的个人信息权利、如何处理儿童的个人信息、如何联系我们、政策如何更新。 + + 一、我们如何收集使用个人信息 + 旷视将与客户一道,在符合《个人信息保护法》第十三条规定的合法性基础的前提下,确保所收集和处理的个人信息已取得您的完整、有效授权同意。 + 为提供人脸和人体图像识别相关技术的能力,我们将基于客户主动提供的原始图片、视频等数据进一步识别和提取面部识别特征、人体区域、人体特征和关键点、手势等生物识别信息,用于实现人脸比对、人脸检测、人脸搜索、人脸美颜、人脸融合等具体功能。请您理解,如您拒绝提供原始图片、视频等数据,前述功能应用将无法实现。 + 提请您注意的是,该等功能项下处理的面部识别特征等生物识别信息属于个人敏感信息,只有在具有特定的目的和充分的必要性、并采取严格保护措施的情形下,我们才会进行处理。我们将依法向您单独告知处理该等敏感个人信息的必要性以及对您个人权益的影响,并在您通过弹窗、勾选等明示方式单独同意后,方进行该等处理;您拒绝单独同意的,我们将不处理您的上述生物识别信息,但不影响您使用本平台和本服务的其他功能。 + + 二、我们如何保留和披露您的个人信息 + 除法律法规另有规定外,我们将在实现处理目的所必要的最短时间内保留您的个人信息,并通过本政策或相关服务规则向您明确告知各类个人信息的具体保存期限。在本平台和本服务下,我们主要提供技术能力的具体应用,因此在具体功能实现后,对于客户提供的原始图片、视频等数据以及基于该等原始数据形成的服务成果,我们将尽快删除或进行匿名化处理。此外,根据《网络安全法》等强制性法律法规要求,我们将切实保留网络日志不少于六个月。 + + 三、我们如何保护您的个人信息 + 我们已采取了合理、可行的管理措施和技术措施,以保护所处理的个人信息并应对个人信息安全事件,包括:及时对个人信息进行去标识化处理;定期审查个人信息处理方式并不断强化 API 和 SDK 等技术工具的安全性;实施传输全程安全加密等保障手段。如发生或者可能发生个人信息泄露、篡改、丢失的,我们将立即采取补救措施,并按照法律法规要求及时通知您。 + + 四、个人信息在全球范围内的转移 + 我们的服务器位于中国和加拿大。由于中国法律法规要求,原则上,在中国境内收集和产生的个人信息将统一在中国境内服务器进行处理。对于中国境外的业务,根据客户的选择,您的个人信息可能转移至位于中国或加拿大的服务器上进行处理。如确有必要向境外提供您的个人信息,我们将遵守《个人信息保护法》及数据出境相关法律法规的规定。 + + 五、您的个人信息权利 + 您依法享有知情权、访问权、更正权、删除权、注销账户权、拒绝自动决策权、解释权、可携权、查询权、撤销权。您可以通过具体产品或服务中设置的权限管理或隐私设置功能直接撤回相应授权,也可以通过发送邮件至 business@megvii.com 提交相关请求,我们将在核实您的身份后及时处理。 + + 六、我们如何处理儿童的个人信息 + 我们的所有产品、网站和服务主要面向企业客户,并不会自行主动收集、处理儿童个人信息。如我们发现任何客户或用户在未经父母或监护人同意的情况下提供包含儿童个人信息的原始数据,我们将立即删除该等原始数据并不会就该等数据提供任何服务。为免歧义,我们将不满14周岁的任何人均视为儿童。 + + 七、如何联系我们 + 如您对本政策及您的个人信息存在任何相关问题、申/投诉,或您需要任何个人信息权利相关的协助,请及时联系我们。您可以通过向 business@megvii.com 发送邮件联系我们,并提供您的姓名(名称)和联系方式、详细请求及相应链接。一般而言,我们会在15个工作日内或法律法规规定的期限内尽快回复。 + + 八、Face⁺⁺政策如何更新 + 我们保留不时更新或修改 Face⁺⁺政策的权利。但未经您明确同意,我们不会削减您按照 Face⁺⁺政策所应享有的权利。对于重大变更,我们会提供更为显著的通知。您可通过本页面查看最新版本的 Face⁺⁺政策。 + + —— 全文完 —— + + + + diff --git a/taobao/pages/splash/splash.js b/taobao/pages/splash/splash.js new file mode 100644 index 0000000..a237e7b --- /dev/null +++ b/taobao/pages/splash/splash.js @@ -0,0 +1,70 @@ +/* ═════ 启动页:品牌底图 + Slogan + 隐私授权弹窗(对齐 H5 #s-splash + #p-privacy) ═════ */ +var api = require('../../utils/api.js'); +var development = require('../../utils/development'); + +Page({ + data: { + bgUrl: api.assetUrl('img/bg-splash.jpg'), // 底图远程加载 + logoUrl: api.assetUrl('img/ui-20261012/brand-lockup.png'), + showPrivacy: false, // 隐私授权弹窗 + agreed: false, // 隐私协议勾选 + showPolicy: false, busy: false, signedIn: false, showCameraDebug: development.cameraDebugEnabled() + }, + onShow: function () { + var that = this; + api.restore().then(function (user) { that.setData({ signedIn: !!user }); }).catch(function () {}); + }, + + // 「开始检测」→ 隐私授权弹窗 + onOpenPrivacy: function () { + if (api.user()) { my.navigateTo({ url: '/pages/profile/profile' }); return; } + this.setData({ showPrivacy: true }); + }, + onClosePrivacy: function () { + this.setData({ showPrivacy: false }); + }, + + // 勾选/取消隐私协议(点链接不触发,由 catchtap 分隔) + onToggleAgree: function () { + this.setData({ agreed: !this.data.agreed }); + }, + + onOpenPolicy: function () { + this.setData({ showPolicy: true }); + }, + onClosePolicy: function () { + this.setData({ showPolicy: false }); + }, + + // 「继续」:必须已勾选协议 + onAgreeContinue: function () { + if (this.data.busy) return; + if (!this.data.agreed) { + my.showToast({ content: '请先阅读并同意隐私协议', type: 'none' }); + return; + } + var that = this; + this.setData({ busy: true }); + api.login(true).then(function () { + that.setData({ showPrivacy: false, signedIn: true }); + my.navigateTo({ url: '/pages/profile/profile' }); + }).catch(function (err) { + my.showToast({ content: err.message, type: 'none' }); + }).then(function () { that.setData({ busy: false }); }); + }, + onHistory: function () { + if (!api.user()) { this.onOpenPrivacy(); return; } + my.navigateTo({ url: '/pages/history/history' }); + }, + onLogout: function () { + var that = this; + api.logout().then(function () { that.setData({ signedIn: false, agreed: false }); }) + .catch(function (err) { my.showToast({ content: err.message, type: 'none' }); }); + }, + onCameraDebug: function () { + if (!development.cameraDebugEnabled()) return; + my.navigateTo({ url: '/pages/camera/camera?debug=camera' }); + }, + + noop: function () {} // 弹窗卡片内点击拦截冒泡用 +}); diff --git a/taobao/pages/splash/splash.json b/taobao/pages/splash/splash.json new file mode 100644 index 0000000..6b5c3e1 --- /dev/null +++ b/taobao/pages/splash/splash.json @@ -0,0 +1,5 @@ +{ + "defaultTitle": "1'me在线肌肤测试", + "titleBarColor": "#101823", + "backgroundColor": "#101823" +} diff --git a/taobao/scripts/dev-backend.cjs b/taobao/scripts/dev-backend.cjs new file mode 100644 index 0000000..e4ee634 --- /dev/null +++ b/taobao/scripts/dev-backend.cjs @@ -0,0 +1,48 @@ +'use strict'; +// Local-only fixture backend and gateway simulator. Never contacts Face++/Taobao. +const http = require('node:http'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); +const root = path.resolve(__dirname, '../..'); +const dataRoot = path.join(root, 'temp/taobao-local-data'); +Object.assign(process.env, { NODE_ENV: 'test', PORT: '18181', HOST: '127.0.0.1', PUBLIC_BASE_URL: '', + JUZI_DATA_ROOT: dataRoot, WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', + SKIN_API_KEY: 'local-fixture', SKIN_API_SECRET: 'local-fixture', + TAOBAO_APP_KEY: 'local-fixture', TAOBAO_APP_SECRET: 'local-fixture-not-a-production-secret' }); +const context = { window: {} }; +vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), context); +const fixture = JSON.parse(JSON.stringify(context.window.MOCK_FIXTURE)); +global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ request_id: 'local-fixture', + result: structuredClone(fixture.result), face_rectangle: fixture.face_rectangle, + left_side_result: fixture.side.left, right_side_result: fixture.side.right }) }); +require(path.join(root, 'h5/server')); +const auth = require(path.join(root, 'h5/lib/taobao-auth')); +http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/dev/health') { + res.setHeader('Content-Type', 'application/json'); + return res.end(JSON.stringify({ ok: true, fixture: true, data_root: dataRoot })); + } + const chunks = []; let length = 0; + for await (const chunk of req) { + length += chunk.length; + if (length > 128 * 1024) { res.writeHead(413); return res.end(); } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString('utf8'); + if (url.pathname === '/api/taobao/session') { + url.search = new URLSearchParams({ app_key: process.env.TAOBAO_APP_KEY, + open_id: 'local-development-user', sign_method: 'md5' }).toString(); + const params = Object.fromEntries(url.searchParams); + url.searchParams.set('sign', auth.signature(params, raw, process.env.TAOBAO_APP_SECRET)); + } + const headers = { ...req.headers, host: '127.0.0.1:18181', 'content-length': Buffer.byteLength(raw) }; + delete headers.origin; + const upstream = http.request({ hostname: '127.0.0.1', port: 18181, path: url.pathname + url.search, + method: req.method, headers }, upstreamRes => { + res.writeHead(upstreamRes.statusCode, upstreamRes.headers); upstreamRes.pipe(res); + }); + upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); }); + upstream.end(raw); +}).listen(18180, '127.0.0.1', () => { + console.log('[local] Gateway simulator + H5: http://127.0.0.1:18180'); + console.log('[local] Fixture data only; account/report files: ' + dataRoot); +}); diff --git a/taobao/scripts/sync-shared.cjs b/taobao/scripts/sync-shared.cjs new file mode 100644 index 0000000..d9ee567 --- /dev/null +++ b/taobao/scripts/sync-shared.cjs @@ -0,0 +1,18 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'); +const root = path.resolve(__dirname, '..'), check = process.argv.includes('--check'); +const files = ['skin-score.js', 'report-ui.js', 'report-copy.js']; +const manifest = {}; +for (const name of files) { + const source = fs.readFileSync(path.join(root, '../h5/js', name)); + const dest = path.join(root, 'vendor', name); + // Git may convert line endings on Windows; manifest hashes stay portable. + manifest[name] = crypto.createHash('sha256').update(source.toString('utf8').replace(/\r\n/g, '\n')).digest('hex'); + if (check) { + if (!fs.existsSync(dest) || !source.equals(fs.readFileSync(dest))) throw Error('Shared logic drift: run npm run sync:shared (' + name + ')'); + } else { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, source); } +} +const dest = path.join(root, 'vendor/manifest.json'), expected = JSON.stringify(manifest, null, 2) + '\n'; +if (check) { if (fs.readFileSync(dest, 'utf8') !== expected) throw Error('Shared manifest drift'); } +else fs.writeFileSync(dest, expected); +console.log(check ? 'PASS: scoring, report UI and copy match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)'); diff --git a/taobao/test/backend.test.cjs b/taobao/test/backend.test.cjs new file mode 100644 index 0000000..123045c --- /dev/null +++ b/taobao/test/backend.test.cjs @@ -0,0 +1,160 @@ +'use strict'; +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); +const { spawn } = require('node:child_process'), { once } = require('node:events'); +const root = path.resolve(__dirname, '../..'), auth = require('../../h5/lib/taobao-auth'); +const env = { TAOBAO_APP_KEY: 'fixture-app', TAOBAO_APP_SECRET: 'fixture-secret' }; +const dir = fs.mkdtempSync(path.join(root, 'temp/taobao-integration-')); +const port = 18201, base = 'http://127.0.0.1:' + port; +let child, wechat, providerCalls = 0; +const fixtureContext = { window: {} }; +vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext); +const fixture = fixtureContext.window.MOCK_FIXTURE; +async function post(route, body = {}, token, params, cookie) { + const raw = JSON.stringify(body), q = { ...params }; + if (params) q.sign = auth.signature(q, raw, env.TAOBAO_APP_SECRET); + const res = await fetch(base + route + (params ? '?' + new URLSearchParams(q) : ''), { + method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(cookie ? { cookie } : {}) }, body: raw }); + return { response: res, data: await res.json() }; +} +async function login(openId) { + const challenge = (await post('/api/taobao/challenge')).data.data.challenge; + const body = { consent: true, challenge }; + const params = { app_key: env.TAOBAO_APP_KEY, open_id: openId, sign_method: 'md5' }; + const result = await post('/api/taobao/session', body, null, params); + assert.equal(result.data.data.ok, true); + return { ...result.data.data, body, params }; +} +async function upload(token) { + const refs = {}; + for (const [i, view] of ['front', 'left', 'right'].entries()) { + const b64 = (fixture.photos[i] || fixture.photos[0]).split(',')[1], total = Math.ceil(b64.length / 65536), id = view + '-fixture-' + Date.now(); + for (let index = 0; index < total; index++) { + const result = await post('/api/taobao/photo', { view, upload_id: id, index, total, data: b64.slice(index * 65536, (index + 1) * 65536) }, token); + assert.equal(result.data.data.ok, true); + assert.equal(result.data.data.complete, index === total - 1); + } + refs[view] = id; + } + return refs; +} +async function poll(token, job) { + for (let i = 0; i < 60; i++) { + const result = (await post('/api/taobao/analysis', { job }, token)).data.data; + if (result.status === 'complete') return result.payload; + await new Promise(resolve => setTimeout(resolve, 30)); + } + throw Error('job did not finish'); +} +before(async () => { + wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(dir, 'accounts'))[0]; + child = spawn(process.execPath, ['-e', ` + const fs=require('fs'),vm=require('vm'),w={}; + vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w}); + global.fetch=async()=>{fs.appendFileSync(process.env.JUZI_DATA_ROOT+'/provider-count','1\\n');await new Promise(r=>setTimeout(r,120));return {ok:true,status:200,json:async()=>({request_id:'fixture',result:structuredClone(w.MOCK_FIXTURE.result),face_rectangle:w.MOCK_FIXTURE.face_rectangle,left_side_result:w.MOCK_FIXTURE.side.left,right_side_result:w.MOCK_FIXTURE.side.right})};}; + require('./server'); + `], { cwd: path.join(root, 'h5'), env: { ...process.env, ...env, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1', + JUZI_DATA_ROOT: dir, PUBLIC_BASE_URL: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('backend startup timeout')), 15000); + child.stdout.on('data', data => { if (data.toString().includes('已启动')) { clearTimeout(timer); resolve(); } }); + child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code)); }); + child.stderr.on('data', data => process.stderr.write(data)); + }); +}); +after(async () => { + if (child && child.exitCode == null) { const exit = once(child, 'exit'); child.kill(); await exit; } + assert(dir.startsWith(path.join(root, 'temp') + path.sep)); fs.rmSync(dir, { recursive: true, force: true }); +}); +test('signed Taobao identities, replay defense, H5 isolation, shared report contract, persisted history and channel stats', async () => { + const unsigned = (await post('/api/taobao/session', { consent: true, challenge: 'fake', open_id: 'forged-user' })).data.data; + assert.equal(unsigned.http_status, 401); + const alice = await login('fixture-alice'), bob = await login('fixture-bob'); + assert.notEqual(alice.user.id, bob.user.id); + assert.equal((await login('fixture-alice')).user.id, alice.user.id, 'same platform account maps to stable identity'); + assert.equal((await post('/api/taobao/session', alice.body, null, alice.params)).data.data.http_status, 401, 'signed login cannot replay'); + const h5Session = await (await fetch(base + '/api/auth/session', { headers: { cookie: 'juzi_session=' + alice.token } })).json(); + assert.equal(h5Session.user, null, 'Taobao credentials cannot become a WeChat/H5 cookie'); + assert.equal((await post('/api/taobao/me', {}, wechat.token)).data.data.http_status, 401); + const refs = await upload(alice.token); + assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs }, bob.token)).data.data.http_status, 409, 'photos are owner bound'); + const profile = { real_age: 44, gender: '女', region: '上海市', daily_skin_type: '干性肌肤' }; + const started = (await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data; + assert.equal(started.http_status, 202); + assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data.http_status, 409, 'consumed photos cannot charge twice'); + assert.equal((await post('/api/taobao/analysis', { job: started.job }, bob.token)).data.data.http_status, 404); + const result = await poll(alice.token, started.job); + assert.equal(result.ok, true); assert.equal(result.report_saved, true); + const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui'); + const expected = SS.buildViewModel(result.result, result.side, { realAge: 44, skinType: '干性肌肤' }); + assert.deepEqual(result.vm, expected); assert.deepEqual(result.presentation, UI.build(expected, result.result)); + assert.equal(result.presentation.views.length, 7); assert.equal(result.presentation.care.length, 3); + assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, 1, 'primary report does not start side calls'); + const history = (await post('/api/taobao/reports', {}, alice.token)).data.data; + assert.equal(history.total, 1); + assert.equal((await post('/api/taobao/reports', { id: result.record_id }, bob.token)).data.data.http_status, 404); + assert.equal((await post('/api/taobao/reports', {}, bob.token)).data.data.total, 0); + assert.equal((await post('/api/taobao/reports', { id: result.record_id }, alice.token)).data.data.report.id, result.record_id); + const sideJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data; + assert.equal((await poll(alice.token, sideJob.job)).ok, true); + const count = fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length; + const cachedJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data; + await poll(alice.token, cachedJob.job); + assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, count, 'side requests are cached'); + const h5Body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile }; + const h5Wechat = await post('/api/skin-analyze', h5Body, null, null, 'juzi_session=' + wechat.token); + assert.equal(h5Wechat.data.ok, true); assert.equal(h5Wechat.data.report_saved, true); + assert.deepEqual(h5Wechat.data.vm, result.vm); assert.deepEqual(h5Wechat.data.presentation, result.presentation); + const guestLogin = await post('/api/auth/browser', { consent: true }); + const cookie = guestLogin.response.headers.getSetCookie().map(c => c.split(';')[0]).join(';'); + const recordsBefore = fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'); + const guest = await post('/api/skin-analyze', { ...h5Body, channel: 'taobao' }, null, null, cookie); + assert.equal(guest.data.ok, true); assert.equal(guest.data.report_saved, false); + assert.equal(fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'), recordsBefore, 'spoofed channel cannot change H5 guest retention'); + const stats = await (await fetch(base + '/api/admin/stats')).json(); + assert.equal(stats.by_channel.taobao.total, 1); assert.equal(stats.by_channel.h5.total, 1); + const filtered = await (await fetch(base + '/api/admin/records?channel=taobao')).json(); + assert.equal(filtered.total, 1); assert.equal(filtered.items[0].channel, 'taobao'); + const csv = await (await fetch(base + '/api/admin/export?channel=taobao')).text(); + const csvLines = csv.trim().split('\n'); + assert.equal(csvLines.length, 2); assert.equal(csvLines[0].split(',')[2], '年龄', 'existing CSV column positions remain stable'); + assert.equal(csvLines[0].split(',').at(-1).trim(), '渠道'); assert.equal(csvLines[1].split(',').at(-1).trim(), 'taobao'); + const oldAccounts = require('../../h5/lib/accounts')(path.join(dir, 'accounts'), {}); + assert.equal(oldAccounts.taobaoSession({ headers: { authorization: 'Bearer ' + alice.token } }).id, alice.user.id, 'restart reloads account/session'); + assert.equal(oldAccounts.reports(alice.user.id).length, 1); + await post('/api/taobao/logout', {}, bob.token); + assert.equal((await post('/api/taobao/me', {}, bob.token)).data.data.http_status, 401); +}); +test('native API client logs in, uploads within gateway limit, polls one detection, restores identity and honors cancellation', async () => { + const config = require('../config'), storage = new Map(); + config.baseUrl = base; config.transport = 'direct'; config.basePath = ''; + let requests = 0; + global.my = { + setStorageSync({ key, data }) { storage.set(key, data); }, + getStorageSync({ key }) { return { data: storage.get(key) }; }, + removeStorageSync({ key }) { storage.delete(key); }, + request(options) { + requests++; assert(Buffer.byteLength(options.data) < 128 * 1024); + const url = new URL(options.url); + if (url.pathname === '/api/taobao/session') { + const params = { app_key: env.TAOBAO_APP_KEY, open_id: 'native-client-user', sign_method: 'md5' }; + params.sign = auth.signature(params, options.data, env.TAOBAO_APP_SECRET); + url.search = new URLSearchParams(params).toString(); + } + fetch(url, { method: options.method, headers: options.headers, body: options.data }).then(async res => { + options.success({ status: res.status, data: await res.json() }); + }).catch(options.fail); + } + }; + const api = require('../utils/api'); + const user = await api.login(true); assert.equal(user.kind, 'taobao'); + const result = await api.analyze([fixture.photos[0], fixture.photos[0], fixture.photos[0]], { real_age: 30 }, { canceled: false }); + assert.equal(result.ok, true); assert.equal(result.report_saved, true); + assert.equal((await api.request('reports')).total, 1); + assert.equal((await api.restore()).id, user.id); + const before = requests; + await assert.rejects(api.analyze(['unused', 'unused', 'unused'], {}, { canceled: true }), err => err.canceled === true); + assert.equal(requests, before, 'cancelled pipeline does not send photos or start a paid job'); + await api.logout(); assert.equal(await api.restore(), null); + delete global.my; +}); diff --git a/taobao/test/frontend.test.cjs b/taobao/test/frontend.test.cjs new file mode 100644 index 0000000..bf76353 --- /dev/null +++ b/taobao/test/frontend.test.cjs @@ -0,0 +1,145 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); +const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui'); +const base = path.resolve(__dirname, '..'); +function page(file, deps = {}, extra = {}) { + let definition; const timers = []; + const context = { Page(value) { definition = value; }, require(name) { + if (name in deps) return deps[name]; + return require(path.resolve(path.dirname(path.join(base, file)), name)); + }, setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout(id) { timers[id - 1] = null; }, + Promise, console, Date, ...extra }; + vm.runInNewContext(fs.readFileSync(path.join(base, file), 'utf8'), context); + const instance = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), + setData(patch, cb) { + for (const [key, value] of Object.entries(patch)) { + const pieces = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let target = this.data; + for (const piece of pieces.slice(0, -1)) target = target[piece]; + target[pieces.at(-1)] = value; + } + if (cb) cb(); + } + }); + return { instance, timers, context }; +} +const flush = () => new Promise(resolve => setImmediate(resolve)); +test('offline camera debug skips identity/profile, never uploads or plays remote voice, and clears previews on exit', async () => { + let backendCalls = 0, voiceCalls = 0, exited = 0, authReads = 0; + const voice = { play() { voiceCalls++; }, stop() {}, VOICE: { step: [] } }; + const camera = page('pages/camera/camera.js', { + '../../utils/development': { cameraDebugEnabled: () => true }, + '../../utils/api': { user() { authReads++; return null; }, analyze() { backendCalls++; throw Error('debug must not upload'); } }, + '../../utils/voice': voice, + '../../utils/permissions': { settings: async () => ({}), state: () => undefined, ensure: async () => {} }, + '../../utils/image': { compressToBase64: async (photo, p) => { + p.setData({ compressWidth: 1280, compressHeight: 960 }); return 'data:image/jpeg;base64,/9j/'; + } } + }, { getApp() { throw Error('debug must not read/write global report state'); }, + my: { navigateBack() { exited++; }, redirectTo() { throw Error('debug must not redirect to login/report'); }, showToast() {} } }); + const p = camera.instance; + p.onLoad({ debug: 'camera' }); p.onReady(); p.onShow(); await flush(); + assert.equal(p.data.debugMode, true); assert.equal(authReads, 0); + for (const [i, photo] of ['front.jpg', 'right.jpg', 'left.jpg'].entries()) { + p.setData({ previewUrl: photo }); p.onConfirm(); if (i < 2) camera.timers[i](); + } + await flush(); assert.equal(p.data.debugComplete, true); + assert.deepEqual(Array.from(p.data.debugPhotos, v => v.key), ['front', 'right', 'left']); + assert(p.data.debugPhotos.every(v => v.width === 1280 && v.height === 960)); + assert.equal(backendCalls, 0); assert.equal(voiceCalls, 0); + p.onCancel(); assert.equal(p.data.debugPhotos.length, 0); assert.equal(p.photos.length, 0); assert.equal(exited, 1); +}); +test('camera debug URL cannot bypass normal login when the development gate is off', () => { + let redirected = 0; + const p = page('pages/camera/camera.js', { + '../../utils/development': { cameraDebugEnabled: () => false }, + '../../utils/api': { user: () => null } + }, { my: { redirectTo() { redirected++; } } }).instance; + p.onLoad({ debug: 'camera' }); assert.equal(redirected, 1); assert.equal(p.data.debugMode, false); +}); +test('development entry requires an explicit flag, development environment and local direct transport', () => { + const config = require('../config'), original = { ...config }, development = require('../utils/development'); + try { + Object.assign(config, { environment: 'development', cameraDebug: true, transport: 'direct', baseUrl: 'http://127.0.0.1:18180' }); + assert.equal(development.cameraDebugEnabled(), true); + for (const patch of [{ environment: 'production' }, { cameraDebug: false }, { transport: 'cloud' }, { baseUrl: 'https://skin.example.test' }]) { + const saved = { ...config }; Object.assign(config, patch); assert.equal(development.cameraDebugEnabled(), false); Object.assign(config, saved); + } + } finally { Object.assign(config, original); } +}); +test('Taobao report uses current H5 totals, displayed age, names, seven views, care selection and product', () => { + const report = require('../utils/report-model'); + for (const realAge of [24, 44, 60]) { + const result = structuredClone(SS.MOCK_RESULT), side = structuredClone(SS.MOCK_SIDE); + const model = SS.buildViewModel(result, side, { realAge, skinType: '干性肌肤' }); + const display = UI.build(model, result), bytes = JSON.stringify(model); + const rendered = report.model({ result, side, vm: model, presentation: display }, ['front'], {}); + assert.equal(rendered.total, SS.weightedTotal(model.dims)); assert.equal(rendered.skinAge, SS.reportSkinAge(model, result)); + assert.deepEqual(rendered.dims.map(d => d.name), ['紧塑力', '透亮感', '稳韧力']); + assert.deepEqual(rendered.views.map(v => v.key), ['contour', 'wrinkles', 'color', 'smooth', 'water', 'sens', 'acne']); + assert.deepEqual(rendered.careAdvice, display.care); assert.equal(rendered.product?.tier, display.product?.tier); + assert.equal(JSON.stringify(model), bytes, 'rendering does not mutate saved scores'); + } + assert.deepEqual(report.marks({ acne: { rectangle: [{ left: 10, top: 20, width: 20, height: 40 }] } }, 'acne', 100, 200)[0], + { id: 0, left: 10, top: 10, width: 20, height: 20, color: '#D30C0C' }); + assert.equal(report.mapURL('https://untrusted.example/fake-map'), ''); +}); +test('camera blocks duplicate shots/confirm, cancels late callbacks and submits correct left/right order with one shared canvas', async () => { + let captured, calls = 0, submitted = 0, ordered, resolver, navigated = 0; + const app = { globalData: { profile: { real_age: 30 } } }; + const api = { user: () => ({ id: 'a' }), analyze: (photos) => { submitted++; ordered = photos; return new Promise(resolve => { resolver = resolve; }); } }; + const voice = { play() {}, stop() {}, VOICE: { step: [], shot: '', analyzing: '', fail: '' } }; + const camera = page('pages/camera/camera.js', { '../../utils/api': api, '../../utils/voice': voice, + '../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {} }, + '../../utils/image': { compressToBase64: async src => src + '-base64' } }, { + getApp: () => app, my: { createCameraContext(id) { assert.equal(id, 'skin-camera'); return { takePhoto(opts) { captured = opts; calls++; } }; }, showToast() {}, redirectTo() { navigated++; } } + }); + const p = camera.instance; p.onLoad(); p.onReady(); p.onShow(); + await flush(); + p.onShutter(); p.onShutter(); assert.equal(calls, 1); + captured.success({ tempImagePath: 'front' }); captured.complete(); p.onConfirm(); p.onConfirm(); + assert.equal(p.data.stepIndex, 1); camera.timers[0](); + p.setData({ previewUrl: 'right' }); p.onConfirm(); camera.timers[1](); + p.setData({ previewUrl: 'left' }); p.onConfirm(); p.onConfirm(); + await flush(); assert.equal(submitted, 1); assert.deepEqual(Array.from(ordered), ['front-base64', 'left-base64', 'right-base64']); + p.onUnload(); resolver({ result: {} }); await flush(); assert.equal(navigated, 0, 'late result cannot navigate after leaving camera'); + assert.equal(app.globalData.payload, undefined); +}); +test('image pipeline resizes large images to at most 1280 and exports real JPEG before base64 reading', async () => { + let module = { exports: {} }, dimensions, exported; + const context = { module, Promise, my: { + getImageInfo(opts) { opts.success({ width: 4000, height: 3000 }); }, + createCanvasContext() { return { clearRect() {}, drawImage(src, x, y, w, h) { dimensions = [w, h]; }, + draw(reserve, cb) { cb(); }, toTempFilePath(opts) { exported = opts; opts.success({ apFilePath: 'resized.jpg' }); } }; }, + getFileSystemManager() { return { readFile(opts) { assert.equal(opts.filePath, 'resized.jpg'); opts.success({ data: '/9j/test' }); } }; } + } }; + vm.runInNewContext(fs.readFileSync(path.join(base, 'utils/image.js'), 'utf8'), context); + const result = await module.exports.compressToBase64('raw.heic', { setData(data, cb) { cb(); } }); + assert.deepEqual(dimensions, [1280, 960]); assert.equal(exported.fileType, 'jpg'); assert.equal(exported.quality, 0.88); + assert.equal(result, 'data:image/jpeg;base64,/9j/test'); +}); +test('province changes reset cities, canceled choices do not leak into reopening, and profiles stay account scoped', () => { + let saved; + const api = { assetUrl: v => v, user: () => ({ id: 'account-a' }) }, app = { globalData: {} }; + const p = page('pages/profile/profile.js', { '../../utils/api.js': api }, { getApp: () => app, + my: { setStorageSync(value) { saved = value; }, navigateTo() {}, getStorageSync() { return {}; } } }).instance; + p.onLoad(); p.onOpenRegion(); p.onPickerChange({ detail: { value: [2, 8] } }); + assert.equal(p.data.pickerValue[1], 0); p.onPickerConfirm(); assert.equal(p.data.regionIndex[1], 0); + const committedCities = Array.from(p.data.regionRange[1]); + p.onOpenRegion(); p.onPickerChange({ detail: { value: [3, 2] } }); p.onPickerClose(); + p.onOpenRegion(); + assert.deepEqual(Array.from(p.data.pickerValue), [2, 0]); + assert.deepEqual(Array.from(p.data.regionRange[1]), committedCities, 'reopened city list must match the committed province'); + p.setData({ birth: '1996-01', age: 30, birthText: '1996年1月', genderText: '女' }); + p.onStartCamera(); assert.equal(saved.key, 'juzi.taobao.profile.account-a'); assert.equal(app.globalData.profile.real_age, 30); +}); +test('native AXML event handlers resolve and all routes have JS/AXML/ACSS/JSON files', () => { + const pages = require('../app.json').pages; + for (const route of pages) { + for (const ext of ['js', 'axml', 'acss', 'json']) assert(fs.existsSync(path.join(base, route + '.' + ext)), route + '.' + ext); + const source = fs.readFileSync(path.join(base, route + '.js'), 'utf8'); + const markup = fs.readFileSync(path.join(base, route + '.axml'), 'utf8'); + for (const match of markup.matchAll(/(?:on[A-Z]\w*|catch[A-Z]\w*)="([A-Za-z]\w*)"/g)) { + assert(new RegExp('\\b' + match[1] + '\\s*:\\s*function').test(source), route + ' missing ' + match[1]); + } + } +}); diff --git a/taobao/test/h5-compatibility.cjs b/taobao/test/h5-compatibility.cjs new file mode 100644 index 0000000..39d7cef --- /dev/null +++ b/taobao/test/h5-compatibility.cjs @@ -0,0 +1,14 @@ +'use strict'; +const { spawnSync } = require('node:child_process'), path = require('node:path'), fs = require('node:fs'); +const root = path.resolve(__dirname, '../..'), artifacts = path.join(root, 'temp/taobao-h5-compatibility'); +const gitOpenSSL = 'C:\\Program Files\\Git\\usr\\bin'; +const testEnv = { ...process.env, H5_ARTIFACT_DIR: artifacts }; +if (process.platform === 'win32' && fs.existsSync(path.join(gitOpenSSL, 'openssl.exe'))) testEnv.Path = gitOpenSSL + path.delimiter + (process.env.Path || process.env.PATH); +fs.mkdirSync(artifacts, { recursive: true }); +for (const name of ['guest-privacy.mjs', 'wechat-browser.mjs', 'report-persistence.mjs', 'wrinkle-async-browser.mjs']) { + console.log('H5 compatibility: ' + name); + const result = spawnSync(process.execPath, ['--require', path.join(__dirname, 'legacy-playwright.cjs'), path.join(root, 'h5/test', name)], + { cwd: root, env: testEnv, stdio: 'inherit', timeout: 120000 }); + if (result.status !== 0) { if (result.error) console.error(result.error.message); process.exit(result.status || 1); } +} +console.log('PASS: H5 guest privacy, WeChat /state/ OAuth flow, full report persistence and async wrinkle layers'); diff --git a/taobao/test/legacy-playwright.cjs b/taobao/test/legacy-playwright.cjs new file mode 100644 index 0000000..1b2072b --- /dev/null +++ b/taobao/test/legacy-playwright.cjs @@ -0,0 +1,12 @@ +// Resolve the repository's unmodified H5 browser tests against this project's +// local Playwright installation, instead of requiring a global npm install. +const cp = require('node:child_process'), path = require('node:path'); +const original = cp.execSync; +cp.execSync = function (command, options) { + if (command === 'npm root -g') { + const value = path.resolve(__dirname, '../node_modules') + '\n'; + return options && options.encoding ? value : Buffer.from(value); + } + return original.apply(this, arguments); +}; +require('node:module').syncBuiltinESMExports(); diff --git a/taobao/test/permissions.test.cjs b/taobao/test/permissions.test.cjs new file mode 100644 index 0000000..da0ebdc --- /dev/null +++ b/taobao/test/permissions.test.cjs @@ -0,0 +1,30 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path'); +function setup(my) { + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/permissions.js'), 'utf8'), { module, Promise, Error, my }); + return module.exports; +} +test('camera and album authorization are separate from login; deny never loops and only explicit settings can restore', async () => { + let granted = { 'scope.userInfo': true }, calls = [], openCalls = 0; + const permissions = setup({ + getSetting({ success }) { success({ authSetting: granted }); }, + authorize({ scopes, success, fail }) { calls.push(scopes); if (scopes === 'scope.camera') fail(); else success(); }, + openSetting({ success }) { openCalls++; granted['scope.camera'] = true; success({ authSetting: granted }); } + }); + await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera'); + await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera'); + assert.deepEqual(calls, ['scope.camera']); assert.equal(openCalls, 0); + await permissions.ensure('scope.album'); assert.deepEqual(calls, ['scope.camera', 'scope.album']); + await permissions.open(); await permissions.ensure('scope.camera'); + assert.equal(calls.length, 2); assert.equal(openCalls, 1); +}); +test('existing denial does not reauthorize; short-form settings keys work and unknown scope requests on demand', async () => { + let calls = 0; + const permissions = setup({ getSetting({ success }) { success({ authSetting: { camera: false, album: true } }); }, + authorize() { calls++; } }); + await assert.rejects(permissions.ensure('scope.camera')); await permissions.ensure('scope.album'); assert.equal(calls, 0); + const first = setup({ getSetting({ success }) { success({ authSetting: {} }); }, + authorize({ scopes, success }) { assert.equal(scopes, 'scope.camera'); calls++; success(); } }); + await first.ensure('scope.camera'); assert.equal(calls, 1); +}); diff --git a/taobao/test/runtime.test.cjs b/taobao/test/runtime.test.cjs new file mode 100644 index 0000000..872f944 --- /dev/null +++ b/taobao/test/runtime.test.cjs @@ -0,0 +1,41 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'), crypto = require('node:crypto'); +const auth = require('../../h5/lib/taobao-auth'); +test('gateway signature covers raw JSON, identity, extra query fields and algorithm; duplicates fail closed', () => { + const env = { TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: 'secret' }, body = '{"consent":true,"challenge":"nonce"}'; + const params = { app_key: 'app', open_id: 'open-account', sign_method: 'md5', env: 'online' }; + const expected = crypto.createHash('md5').update('secretapp_keyappenvonlineopen_idopen-accountsign_methodmd5' + body + 'secret').digest('hex').toUpperCase(); + assert.equal(auth.signature(params, body, env.TAOBAO_APP_SECRET), expected); + const url = '/api/taobao/session?' + new URLSearchParams({ ...params, sign: expected }); + assert(auth.verify(url, body, env)); + assert.equal(auth.verify(url, body + ' ', env), null); + assert.equal(auth.verify(url.replace('open-account', 'forged-account'), body, env), null); + assert.equal(auth.verify(url + '&open_id=open-account', body, env), null); + assert.equal(auth.verify(url, body, {}), null); +}); +test('photo chunks are ordered, bounded, deduplicated, owner-bound, expiring and consumed once', async () => { + let now = 0; + const req = { headers: {}, owner: 'alice' }, other = { headers: {}, owner: 'bob' }; + const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: request => ({ id: request.owner, kind: 'taobao' }) }); + const piece = { view: 'front', upload_id: 'test-upload-id', index: 0, total: 2, data: 'AAAA' }; + assert.equal(runtime.upload(req, { ...piece, index: 1 }).status, 409); + assert.equal(runtime.upload(req, piece).complete, false); + assert.equal(runtime.upload(req, piece).complete, false, 'repeated identical chunk is idempotent'); + assert.equal(runtime.upload(req, { ...piece, data: 'BBBB' }).status, 409); + assert.equal(runtime.upload(req, { ...piece, data: 'A'.repeat(65537) }).status, 400); + assert.equal(runtime.upload(req, { ...piece, index: 1 }).complete, true); + assert.equal(runtime.takePhotos(other, { front: piece.upload_id }), null); + for (const view of ['left', 'right']) assert.equal(runtime.upload(req, { ...piece, view, total: 1 }).status, 200); + const refs = { front: piece.upload_id, left: piece.upload_id, right: piece.upload_id }; + assert(runtime.takePhotos(req, refs)); assert.equal(runtime.takePhotos(req, refs), null); + assert.equal(runtime.upload(req, piece).status, 409); + let complete; + const job = runtime.start(req, () => new Promise(resolve => { complete = resolve; }), true); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(runtime.result(other, job), null); assert.equal(runtime.result(req, job).status, 'running'); + complete({ ok: true }); await new Promise(resolve => setImmediate(resolve)); + assert.equal(runtime.result(req, job).status, 'complete'); + assert.equal(runtime.upload(req, piece).status, 200); + now = 5 * 60000 + 1; assert.equal(runtime.takePhotos(req, refs), null); + const challenge = runtime.challenge('local'); assert(runtime.consume(challenge)); assert(!runtime.consume(challenge)); +}); diff --git a/taobao/test/session.test.cjs b/taobao/test/session.test.cjs new file mode 100644 index 0000000..62e7159 --- /dev/null +++ b/taobao/test/session.test.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); + +test('logout succeeds for an expired session, while a failed server revocation remains visible', async () => { + const storage = new Map(), app = { globalData: {} }; + let logoutFailure; + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/api.js'), 'utf8'), { + module, Promise, Date, setTimeout, getApp: () => app, + my: { setStorageSync: ({ key, data }) => storage.set(key, data), + getStorageSync: ({ key }) => ({ data: storage.get(key) }), removeStorageSync: ({ key }) => storage.delete(key) }, + require(name) { + if (name === '../config') return { transport: 'direct' }; + if (name === './transport') return { request: async route => { + if (route.endsWith('/challenge')) return { ok: true, challenge: 'challenge' }; + if (route.endsWith('/session')) return { ok: true, token: 'token', user: { id: 'alice' } }; + if (route.endsWith('/logout')) { + if (logoutFailure === 'network') throw Error('network unavailable'); + return { ok: false, http_status: 401, message: 'session expired' }; + } + throw Error('unexpected route: ' + route); + } }; + throw Error('unexpected dependency: ' + name); + } + }); + const api = module.exports; + await api.login(true); + logoutFailure = 'network'; + await assert.rejects(api.logout(), /network unavailable/); + assert.equal(api.user().id, 'alice'); + app.globalData.photos = ['private-photo']; app.globalData.payload = { private: true }; + logoutFailure = 'expired'; + await api.logout(); + assert.equal(api.user(), null); assert.equal(storage.size, 0); + assert.equal(app.globalData.photos, null); assert.equal(app.globalData.payload, null); +}); diff --git a/taobao/test/transport.test.cjs b/taobao/test/transport.test.cjs new file mode 100644 index 0000000..24ce6d0 --- /dev/null +++ b/taobao/test/transport.test.cjs @@ -0,0 +1,21 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path'); +test('cloud transport initializes once, preserves subpath/auth, caps timeout and unwraps gateway envelopes', async () => { + const config = { transport: 'cloud', baseUrl: 'https://skin.example.test', basePath: '/state', cloudAppId: 'cloud-id', cloudEnv: 'online' }; + let initialized = 0, options; + const cloud = { init: async ({ env }) => { assert.equal(env, 'online'); initialized++; return true; }, + application: { httpRequest: async input => { options = input; return { ok: true, http_status: 200 }; } } }; + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/transport.js'), 'utf8'), { + module, Promise, require(name) { return name === '../config' ? config : { default: cloud }; } + }); + const transport = module.exports; + await transport.request('/api/taobao/me', {}, 'token'); await transport.request('/api/taobao/reports', {}, 'token'); + assert.equal(initialized, 1); assert.equal(options.path, '/state/api/taobao/reports'); + assert.equal(options.headers.Authorization, 'Bearer token'); assert.equal(options.exts.timeout, 10000); + assert.equal(options.exts.cloudAppId, 'cloud-id'); assert.equal(options.exts.domain, config.baseUrl); + assert.equal(transport.unwrap({ success: true, data: { ok: true } }).ok, true); + assert.throws(() => transport.unwrap({ success: false, errorMessage: 'gateway rejected' }), /gateway rejected/); + config.transport = 'direct'; + await assert.rejects(transport.request('/api/taobao/me', {}), /仅用于本机/); +}); diff --git a/taobao/utils/api.js b/taobao/utils/api.js new file mode 100644 index 0000000..3365892 --- /dev/null +++ b/taobao/utils/api.js @@ -0,0 +1,89 @@ +var config = require('../config'), transport = require('./transport'); +var token = '', user = null, storageKey = 'juzi.taobao.session.v1'; +function assetUrl(path) { return config.assetBase.replace(/\/?$/, '/') + path.replace(/^assets\//, ''); } +function clear() { + token = ''; user = null; + try { my.removeStorageSync({ key: storageKey }); } catch (_) {} + var app = typeof getApp === 'function' && getApp(); + if (app) { app.globalData.profile = null; app.globalData.photos = null; app.globalData.payload = null; } +} +function request(path, body) { + var requestToken = token; + return transport.request('/api/taobao/' + path, body || {}, requestToken).then(function (data) { + if (!data || !data.ok) { + var err = new Error(data && data.message || '服务暂不可用,请稍后重试'); + err.status = data && data.http_status; err.code = data && data.code; + if (err.status === 401 && requestToken === token) clear(); + throw err; + } + return data; + }); +} +function authorize() { + if (config.transport === 'direct') return Promise.resolve(); + return new Promise(function (resolve, reject) { + my.authorize({ scopes: 'scope.userInfo', success: resolve, fail: function () { reject(new Error('请授权淘宝账号后继续')); } }); + }); +} +function login(consent) { + if (!consent) return Promise.reject(new Error('请先阅读并同意隐私协议')); + return authorize().then(function () { return request('challenge'); }).then(function (data) { + return request('session', { consent: true, challenge: data.challenge }); + }).then(function (data) { + token = data.token; user = data.user; + my.setStorageSync({ key: storageKey, data: { token: token, user: user } }); + return user; + }); +} +function restore() { + try { var saved = my.getStorageSync({ key: storageKey }).data; if (saved) { token = saved.token || ''; user = saved.user || null; } } catch (_) {} + if (!token) return Promise.resolve(null); + var restoredToken = token; + return request('me').then(function (data) { if (token === restoredToken) user = data.user; return user; }); +} +function canceled(signal) { if (signal && signal.canceled) { var e = new Error('已取消检测'); e.canceled = true; throw e; } } +function wait(ms, signal) { return new Promise(function (resolve) { setTimeout(resolve, ms); }).then(function () { canceled(signal); }); } +function poll(job, signal, deadline) { + canceled(signal); + if (Date.now() >= deadline) throw new Error('检测仍在处理中,请稍后从历史报告查看'); + return request('analysis', { job: job }).then(function (data) { + canceled(signal); + if (data.status !== 'complete') return wait(1000, signal).then(function () { return poll(job, signal, deadline); }); + if (!data.payload || !data.payload.ok) throw new Error(data.payload && data.payload.message || '检测失败,请重试'); + return data.payload; + }); +} +function upload(view, photo, signal) { + var b64 = photo.replace(/^data:image\/[^;]+;base64,/, ''), size = 64 * 1024; + if (!b64 || b64.length > 4 * 1024 * 1024) return Promise.reject(new Error('照片过大,请重新拍摄')); + var id = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2), total = Math.ceil(b64.length / size); + var chain = Promise.resolve(); + for (var i = 0; i < total; i++) (function (index) { + chain = chain.then(function () { canceled(signal); return request('photo', { view: view, upload_id: id, index: index, total: total, data: b64.slice(index * size, (index + 1) * size) }); }); + })(i); + return chain.then(function () { return id; }); +} +function analyze(photos, profile, signal) { + if (config.mock) { + var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui'); + var vm = SS.buildViewModel(SS.MOCK_RESULT, SS.MOCK_SIDE, { realAge: profile.real_age, skinType: profile.daily_skin_type }); + return Promise.resolve({ ok: true, result: SS.MOCK_RESULT, side: SS.MOCK_SIDE, vm: vm, presentation: UI.build(vm, SS.MOCK_RESULT) }); + } + var refs = {}; + return ['front', 'left', 'right'].reduce(function (chain, view, i) { + return chain.then(function () { return upload(view, photos[i], signal); }).then(function (id) { refs[view] = id; }); + }, Promise.resolve()).then(function () { + canceled(signal); return request('skin-analyze', { photo_refs: refs, profile: profile }); + }).then(function (data) { return poll(data.job, signal, Date.now() + 90000); }); +} +function wrinkleViews(jobToken, signal) { + return request('wrinkle-views', { token: jobToken }).then(function (data) { return poll(data.job, signal, Date.now() + 90000); }); +} +function logout() { + return request('logout').catch(function (err) { + // An expired/revoked session is already signed out; network failures still surface. + if (err.status !== 401) throw err; + }).then(clear); +} +module.exports = { assetUrl: assetUrl, analyze: analyze, login: login, restore: restore, request: request, + user: function () { return user; }, logout: logout, wrinkleViews: wrinkleViews }; diff --git a/taobao/utils/development.js b/taobao/utils/development.js new file mode 100644 index 0000000..b15bbfb --- /dev/null +++ b/taobao/utils/development.js @@ -0,0 +1,6 @@ +var config = require('../config'); +function cameraDebugEnabled() { + return config.environment === 'development' && config.cameraDebug === true && config.transport === 'direct' && + /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl); +} +module.exports = { cameraDebugEnabled: cameraDebugEnabled }; diff --git a/taobao/utils/image.js b/taobao/utils/image.js new file mode 100644 index 0000000..9ba67ee --- /dev/null +++ b/taobao/utils/image.js @@ -0,0 +1,33 @@ +// Always redraw to JPEG: native compressImage changes quality, not dimensions. +var MAX_SIDE = 1280; +function compressToBase64(src, page) { + return new Promise(function (resolve, reject) { + my.getImageInfo({ src: src, success: function (info) { + if (page.alive === false) { reject({ canceled: true }); return; } + if (!(info.width > 0 && info.height > 0)) { reject(new Error('无法读取照片尺寸,请重新选择')); return; } + var scale = Math.min(1, MAX_SIDE / Math.max(info.width, info.height)); + var w = Math.max(1, Math.round(info.width * scale)), h = Math.max(1, Math.round(info.height * scale)); + page.setData({ compressWidth: w, compressHeight: h }, function () { + if (page.alive === false) { reject({ canceled: true }); return; } + try { + var ctx = my.createCanvasContext('compress-canvas'); + ctx.clearRect(0, 0, w, h); + ctx.drawImage(src, 0, 0, w, h); + ctx.draw(false, function () { + if (page.alive === false) { reject({ canceled: true }); return; } + ctx.toTempFilePath({ x: 0, y: 0, width: w, height: h, destWidth: w, destHeight: h, fileType: 'jpg', quality: 0.88, + success: function (result) { + var file = result.apFilePath || result.tempFilePath; + if (!file) { reject(new Error('照片处理失败,请重试')); return; } + try { + my.getFileSystemManager().readFile({ filePath: file, encoding: 'base64', + success: function (r) { resolve('data:image/jpeg;base64,' + r.data); }, fail: reject }); + } catch (e) { reject(new Error('当前淘宝版本不支持照片读取,请升级后重试')); } + }, fail: reject }); + }); + } catch (e) { reject(new Error('照片处理不可用,请升级淘宝后重试')); } + }); + }, fail: reject }); + }); +} +module.exports = { MAX_SIDE: MAX_SIDE, compressToBase64: compressToBase64 }; diff --git a/taobao/utils/permissions.js b/taobao/utils/permissions.js new file mode 100644 index 0000000..185b165 --- /dev/null +++ b/taobao/utils/permissions.js @@ -0,0 +1,39 @@ +// Taobao scopes are independent: userInfo login does not grant camera/album. +var denied = {}; +function state(settings, scope) { + settings = settings || {}; + var key = scope.replace(/^scope\./, ''); + return typeof settings[scope] === 'boolean' ? settings[scope] : settings[key]; +} +function settings() { + return new Promise(function (resolve) { + if (typeof my.getSetting !== 'function') { resolve({}); return; } + my.getSetting({ success: function (res) { resolve(res.authSetting || {}); }, fail: function () { resolve({}); } }); + }); +} +function rejected(scope) { + var err = new Error(scope === 'scope.camera' ? '相机权限未开启,可在授权设置中开启,或使用相册' : '相册权限未开启,请在授权设置中开启'); + err.permission = scope; return err; +} +function ensure(scope) { + return settings().then(function (current) { + if (state(current, scope) === true) { delete denied[scope]; return; } + if (state(current, scope) === false || denied[scope]) throw rejected(scope); + return new Promise(function (resolve, reject) { + if (typeof my.authorize !== 'function') { reject(new Error('当前淘宝版本不支持授权,请升级后重试')); return; } + my.authorize({ scopes: scope, success: function () { delete denied[scope]; resolve(); }, + fail: function () { denied[scope] = true; reject(rejected(scope)); } }); + }); + }); +} +function open() { + return new Promise(function (resolve, reject) { + if (typeof my.openSetting !== 'function') { reject(new Error('请在小程序右上角的授权设置中开启权限')); return; } + my.openSetting({ success: function (res) { + var current = res.authSetting || {}; + ['scope.camera', 'scope.album'].forEach(function (scope) { if (state(current, scope) === true) delete denied[scope]; }); + resolve(current); + }, fail: function () { reject(new Error('无法打开授权设置,请从小程序右上角进入')); } }); + }); +} +module.exports = { ensure: ensure, settings: settings, state: state, open: open }; diff --git a/taobao/utils/region-data.js b/taobao/utils/region-data.js new file mode 100644 index 0000000..5e7b231 --- /dev/null +++ b/taobao/utils/region-data.js @@ -0,0 +1,42 @@ +/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL); + 直辖市第二列只保留本市,另补港澳台与海外。 */ +// 小程序无 window 对象,改为 CommonJS 导出(数据本身与 H5 完全一致) +var REGION_DATA = [ + ["北京市", ["北京市"]], + ["天津市", ["天津市"]], + ["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]], + ["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]], + ["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]], + ["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]], + ["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]], + ["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]], + ["上海市", ["上海市"]], + ["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]], + ["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]], + ["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]], + ["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]], + ["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]], + ["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]], + ["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]], + ["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]], + ["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]], + ["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]], + ["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]], + ["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]], + ["重庆市", ["重庆市"]], + ["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]], + ["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]], + ["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]], + ["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]], + ["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]], + ["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]], + ["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]], + ["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]], + ["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]], + ["香港特别行政区", ["香港特别行政区"]], + ["澳门特别行政区", ["澳门特别行政区"]], + ["台湾省", ["台湾省"]], + ["海外", ["海外"]] +]; + +module.exports = REGION_DATA; diff --git a/taobao/utils/report-model.js b/taobao/utils/report-model.js new file mode 100644 index 0000000..425386b --- /dev/null +++ b/taobao/utils/report-model.js @@ -0,0 +1,56 @@ +var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui'), api = require('./api'); +function mapURL(value) { + if (typeof value !== 'string') return ''; + if (/^data:image\/(png|jpeg);base64,/.test(value)) return value; + if (value.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + value; + if (value.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + value; + return ''; +} +function model(payload, photos, profile) { + var vm = payload.vm || SS.buildViewModel(payload.result, payload.side, { realAge: profile.real_age, skinType: profile.daily_skin_type }); + vm = SS.withDisplayRecommendation(vm, payload.result); + var presentation = UI.forDisplay(vm, payload.result, payload.presentation); + var total = SS.weightedTotal(vm.dims), skinAge = SS.reportSkinAge(vm, payload.result); + var focus = UI.focusOf(vm); + var views = presentation.views.map(function (view) { + return { key: view.key, name: view.name, text: UI.customerText(view), score: UI.dimensionScore(view.S), level: view.level || '', + dim: view.key === 'wrinkles', alpha: view.key === 'color' ? 0.3 : 1, marks: [] }; + }); + var difference = skinAge != null && vm.realAge != null ? skinAge - vm.realAge : null; + return { total: total, skinAge: skinAge, ageNote: difference == null ? '' : difference === 0 ? '与实际年龄相当' : + difference < 0 ? '比实际年龄年轻' + -difference + '岁' : '比实际年龄大' + difference + '岁', + dims: ['firm', 'vita', 'stab'].map(function (key) { + var d = vm.dims[key] || {}, value = UI.dimensionScore(d.S), parts = value.split('.'); + return { key: key, name: UI.dimNames[key], int: parts[0], dec: parts[1] || '', + level: UI.levelForScore(d.S) || '', rank: focus && focus.key === key ? 'worst' : '', barWidth: d.S == null ? 0 : Math.min(100, d.S * 10) }; + }), focusName: focus ? focus.name : '', focusSlogan: focus ? focus.tip : '', views: views, viewIndex: 0, frontPhoto: photos[0] || '', activeLayers: [], + careAdvice: presentation.care || [], product: presentation.product ? Object.assign({}, presentation.product, { image: api.assetUrl(presentation.product.image) }) : null }; +} +function marks(result, key, width, height) { + if (!(width > 0 && height > 0)) return []; + var defs = key === 'acne' ? [['closed_comedones', '#E8A13D'], ['acne', '#D30C0C'], ['acne_pustule', '#D30C0C'], + ['acne_nodule', '#8E0000'], ['acne_mark', '#8750C8']] : key === 'color' ? [['melanin_mark', '#9F641F'], ['melasma', '#E68A2E'], ['freckle', '#8750C8']] : []; + var out = []; + defs.forEach(function (def) { + var source = result[def[0]] || {}, rectangles = (source.rectangle || []).slice(); + (source.polygon || []).forEach(function (points) { + points = points.filter(function (p) { return p && typeof p.x === 'number' && typeof p.y === 'number'; }); + if (points.length < 3) return; + var xs = points.map(function (p) { return p.x; }), ys = points.map(function (p) { return p.y; }); + var left = Math.min.apply(Math, xs), top = Math.min.apply(Math, ys); + rectangles.push({ left: left, top: top, width: Math.max.apply(Math, xs) - left, height: Math.max.apply(Math, ys) - top }); + }); + rectangles.forEach(function (r) { + if (![r.left, r.top, r.width, r.height].every(function (n) { return typeof n === 'number' && isFinite(n); }) || + r.left < 0 || r.top < 0 || r.width <= 0 || r.height <= 0 || r.left + r.width > width || r.top + r.height > height) return; + out.push({ id: out.length, left: 100 * r.left / width, top: 100 * r.top / height, + width: 100 * r.width / width, height: 100 * r.height / height, color: def[1] }); + }); + }); + return out; +} +function layers(result, key) { + var fields = { wrinkles: ['texture_enhanced_lines'], smooth: ['texture_enhanced_pores', 'texture_enhanced_blackheads'], color: ['brown_area'], water: ['water_area'], sens: ['red_area'] }; + return (fields[key] || []).map(function (field) { return mapURL((result.face_maps || {})[field]); }).filter(Boolean); +} +module.exports = { model: model, mapURL: mapURL, marks: marks, layers: layers }; diff --git a/taobao/utils/skin-score.js b/taobao/utils/skin-score.js new file mode 100644 index 0000000..feae708 --- /dev/null +++ b/taobao/utils/skin-score.js @@ -0,0 +1 @@ +module.exports = require('../vendor/skin-score'); diff --git a/taobao/utils/transport.js b/taobao/utils/transport.js new file mode 100644 index 0000000..6d1e3b1 --- /dev/null +++ b/taobao/utils/transport.js @@ -0,0 +1,44 @@ +var config = require('../config'); +var cloudReady; + +function unwrap(result) { + if (typeof result === 'string') result = JSON.parse(result); + if (result && typeof result.success === 'boolean') { + if (!result.success) throw new Error(result.errorMessage || '服务暂不可用'); + return typeof result.data === 'string' ? JSON.parse(result.data) : result.data; + } + return result; +} +function initCloud() { + if (!cloudReady) { + var sdk = require('@tbmp/mp-cloud-sdk'), cloud = sdk.default || sdk; + cloudReady = Promise.resolve(cloud.init({ env: config.cloudEnv })).then(function (ok) { + if (!ok) throw new Error('淘宝云服务初始化失败'); + return cloud; + }).catch(function (err) { cloudReady = null; throw err; }); + } + return cloudReady; +} +function request(path, body, token) { + var headers = { 'Content-Type': 'application/json;charset=UTF-8' }; + if (token) headers.Authorization = 'Bearer ' + token; + if (config.transport === 'direct') { + if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl)) return Promise.reject(new Error('直连接口仅用于本机联调')); + return new Promise(function (resolve, reject) { + my.request({ url: config.baseUrl + config.basePath + path, method: 'POST', headers: headers, data: JSON.stringify(body), + dataType: 'json', timeout: 10000, + success: function (res) { + try { + if ((res.status || res.statusCode) !== 200) throw new Error('服务暂不可用'); + resolve(unwrap(res.data)); + } catch (e) { reject(e); } + }, fail: function () { reject(new Error('网络异常,请稍后重试')); } }); + }); + } + if (!config.cloudAppId || !/^https:\/\//.test(config.baseUrl)) return Promise.reject(new Error('请先配置淘宝云应用和 HTTPS 服务地址')); + return initCloud().then(function (cloud) { + return cloud.application.httpRequest({ path: config.basePath + path, method: 'POST', headers: headers, + params: {}, body: body, exts: { cloudAppId: config.cloudAppId, domain: config.baseUrl, timeout: 10000 } }); + }).then(unwrap); +} +module.exports = { request: request, unwrap: unwrap }; diff --git a/taobao/utils/voice.js b/taobao/utils/voice.js new file mode 100644 index 0000000..beca0e8 --- /dev/null +++ b/taobao/utils/voice.js @@ -0,0 +1,117 @@ +/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════ + * 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音) + * 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音; + * 资源远程化:{ASSET_BASE}voice/.mp3,清单 manifest.json 缺失时默认全量尝试(404 静默) */ + +var api = require('./api.js'); + +// A/B/C 三类清单(与 H5 app.js VOICE 常量一致) +var VOICE = { + step: ['step1-front', 'step2-rightface', 'step3-leftface'], // A. 步骤引导 + hint: { // B. 姿态提示(手动快门模式下基本不用,保留契约) + '未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far', + '请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right', + '保持不动': 'hint-hold' + }, + countdown: 'countdown-321', shot: 'shot-done', // C. 拍摄与流程 + analyzing: 'analyzing', fail: 'analyze-fail' +}; + +var DEDUPE_MS = 4000; // 同条提示 4s 内不重复播 + +var audio = null; // InnerAudioContext 单实例 +var files = null; // null=清单未加载;{}=清单不可用(默认全量尝试) +var lastAt = {}; // id → 上次开播时间(去重) +var cur = null; // { id, kind:'flow'|'hint', token } +var queue = []; // 排队中的流程音 +var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新) +var waiters = []; // afterCurrent 回调:全部播报收尾后执行 + +// 拉取远程清单;失败则放行全量(语音缺失时 InnerAudioContext onError 静默跳过) +function init() { + if (audio) return; + if (typeof my.createInnerAudioContext !== 'function') return; + try { audio = my.createInnerAudioContext(); } catch (_) { return; } + audio.onEnded(function () { drain(); }); + audio.onError(function () { drain(); }); // 单条加载失败:跳过,继续队列 + files = {}; // Known voice IDs; unavailable files are skipped by onError. +} + +function ensure() { if (!audio) init(); } + +function playable(id) { return !!id && (files === null || !Object.keys(files).length || files[id]); } + +function start(id, kind) { + ensure(); + if (!audio) { drain(); return; } + lastAt[id] = Date.now(); + cur = { id: id, kind: kind, token: {} }; + var token = cur.token; + audio.stop(); + audio.src = api.assetUrl('voice/' + id + '.mp3'); + audio.play(); + // 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死 + cur.token = token; +} + +function stopCur() { + if (!cur) return; + cur = null; + if (audio) { try { audio.stop(); } catch (e) {} } +} + +function drain() { + cur = null; + if (queue.length) { start(queue.shift(), 'flow'); return; } + if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; } + var w = waiters; + waiters = []; + w.forEach(function (fn) { fn(); }); +} + +function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; } + +/** + * 播报一条语音 + * @param {string} id 文件名(去 .mp3) + * @param {object} [opts] hint:姿态提示(4s 去重);cut:打断在播与排队(失败提示用) + */ +function play(id, opts) { + opts = opts || {}; + if (!playable(id)) return; + ensure(); + if (opts.hint) { + if (lastAt[id] && Date.now() - lastAt[id] < DEDUPE_MS) return; + if (busyWithFlow()) { pendingHint = id; return; } + start(id, 'hint'); + return; + } + if (opts.cut) { queue = []; pendingHint = null; stopCur(); start(id, 'flow'); return; } + if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; } + start(id, 'flow'); +} + +function dropHint() { pendingHint = null; } // 换步/提示清空:挂起的旧提示作废 + +// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照页),否则让在播自然收尾(出报告) +function stop(hard) { + queue = []; + pendingHint = null; + waiters = []; + if (hard) stopCur(); +} + +// 全部播报(含排队与挂起)自然收尾后执行 cb;空闲则立即执行 +function afterCurrent(cb) { + if (!cur && !queue.length && !pendingHint) { cb(); return; } + waiters.push(cb); +} + +module.exports = { + VOICE: VOICE, + init: init, + play: play, + dropHint: dropHint, + stop: stop, + afterCurrent: afterCurrent +}; diff --git a/taobao/vendor/manifest.json b/taobao/vendor/manifest.json new file mode 100644 index 0000000..9f56631 --- /dev/null +++ b/taobao/vendor/manifest.json @@ -0,0 +1,5 @@ +{ + "skin-score.js": "1f3256fd8d5863de3407c2037df30b6a801ac49858a8a0acab7163f83f642abc", + "report-ui.js": "2c6acaad7cf6e9c826f062f9e79d419470b37da71ab86a5399897d62fd695caf", + "report-copy.js": "402aa543c3437f4e8043e2281b09d7e4d734c0dd2d103636c9f37fe8aa28d6c2" +} diff --git a/taobao/vendor/report-copy.js b/taobao/vendor/report-copy.js new file mode 100644 index 0000000..01dcebc --- /dev/null +++ b/taobao/vendor/report-copy.js @@ -0,0 +1,141 @@ +// Generated from reference/AI测肤报告页话术修改.md; run tools/build_report_copy.py. +(function(root,factory){if(typeof module==="object"&&module.exports)module.exports=factory();else root.ReportCopy=factory();})(typeof window!=="undefined"?window:this,function(){return { + "version": "2026-10-09", + "conclusion": { + "contour": { + "改善": "面部轮廓紧致度有待提升,下颌线清晰度不足,左右下颌线角度分别为左 {la}°/ 右 {ra}°;中面部容积略欠饱满,肌肤支撑力偏弱。", + "良好": "面部轮廓清晰,下颌线较为清晰,左右下颌线角度左 {la}°/ 右 {ra}°;中面部容积无明显流失;肌肤处于健康状态,支撑力表现良好。", + "优秀": "面部轮廓紧致立体,下颌线清晰;中面部容积饱满,肌肤支撑力充足。" + }, + "wrinkles": { + "改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。", + "良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。", + "优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。" + }, + "color": { + "改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。", + "良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;{dark_circle_note},整体状态稳定,局部透亮感稍弱。", + "优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积,{dark_circle_note};肤色均匀透亮。" + }, + "smooth": { + "改善": "当前肌肤平滑度还有提升空间,检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,肤感整体细腻度有提升空间,纹理不均。", + "良好": "肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,有轻微角质粗糙感。", + "优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。" + }, + "water": { + "改善": "角质层含水量较低,缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。", + "良好": "角质层含水量适中,缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。", + "优秀": "角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。" + }, + "sens": { + "改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。", + "良好": "泛红面积 {sens_area}%,肌肤存在局部轻微泛红;对外界环境有良好的耐受度,仅在接触强刺激物时偶有轻微反应。", + "优秀": "泛红面积 {sens_area}%,无明显泛红、敏感迹象;对外界刺激耐受度佳,肌肤处于稳定的健康状态。" + }, + "acne": { + "改善": "检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},肌肤状态不稳定,毛孔易堵塞,皮脂分泌波动明显。", + "良好": "检测到 {acne_cnt} 处皮损({acne_detail}),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。", + "优秀": "未检出明显痘痘、粉刺或闭口,无明显红肿、脓包迹象。" + } + }, + "care": { + "contour": { + "改善": { + "care": "重点进行轮廓紧致管理,关注面中、苹果肌与下颌线区域。日常增加紧致精华步骤,配合由下向上的轻柔提升按摩,帮助肌肤维持弹润和支撑感,让轮廓看起来更紧实。", + "lifestyle": "保证充足、规律的睡眠,让肌肤在夜间充分修护;坚持防晒,减少紫外线带来的老化影响;减少熬夜。" + }, + "良好": { + "care": "关注面中、苹果肌、下颌线等容易显疲态区域的支撑感。日常可叠加紧致精华步骤,并配合向上提升按摩手法,预防疲态引起的松弛与垮塌累积,稳住面部支撑线。", + "lifestyle": "日常注意防晒,保持规律作息;坚持适度运动,帮助肌肤维持活力。" + }, + "优秀": { + "care": "保持现有紧致抗衰护理节奏,日常叠加使用提亮精华,坚持防晒,加强光损伤防护与状态管理,持续关注面中疲态与轮廓支撑状态,让紧致状态更稳定、更持久。", + "lifestyle": "坚持防晒与规律作息,均衡饮食,保证优质蛋白、新鲜蔬果和健康脂肪摄入,适度运动。" + } + }, + "wrinkles": { + "改善": { + "care": "重视纹路管理,日常可使用含有肽类、胶原类成分的淡纹精华,针对眼周、法令纹等动态纹高发区域配合点按手法,遏制静态纹转化,帮肌肤恢复平滑细腻的状态。", + "lifestyle": "注意睡姿,减少面部长期受压;减少夸张表情,避免揉眼、托腮等表情性挤压习惯,减少纹路加深。" + }, + "良好": { + "care": "加强日常淡纹与肌肤弹润护理,叠加使用含肽类、胶原类成分的淡纹精华,提升肌肤水润度与饱满感,维持肤表平滑。", + "lifestyle": "减少夸张表情反复牵拉,睡前加强保湿修护。" + }, + "优秀": { + "care": "以预防初纹为核心,搭配抗初老型精华,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。", + "lifestyle": "保持良好作息与运动习惯,坚持防晒,减轻光老化与糖化负担,维持肌肤稳定状态。" + } + }, + "color": { + "改善": { + "care": "重点关照暗沉与色素易沉积区域,强化透亮与肤色均匀,严格防晒,配合温和焕亮或抗氧化精华帮助均匀肤色与透亮感。", + "lifestyle": "日常加强防晒与抗氧化护理,减少熬夜与压力引发的暗沉,适当增加富含维C的蔬果摄入。" + }, + "良好": { + "care": "加强日常光泽管理与局部暗沉护理,做好防晒与抗氧化打底;叠加使用温和焕亮精华,稳住当前肤色状态并提升整体通透感。", + "lifestyle": "持续做好防晒,保持作息规律,避免波动性色沉,守护均匀透亮感。" + }, + "优秀": { + "care": "维持现有防晒与抗氧化护肤习惯,叠加使用温和焕亮精华,巩固肤色均匀度与透亮感。", + "lifestyle": "坚持日常防晒,保持均衡饮食支持皮肤稳态。" + } + }, + "smooth": { + "改善": { + "care": "关注毛孔可见度与肤表粗糙感改善,加强角质管理,提升肌肤平滑细腻度。", + "lifestyle": "减少高糖高脂饮食,降低皮脂分泌刺激,定期温和调理角质,避免用手挤压黑头。" + }, + "良好": { + "care": "加强基础清洁与角质管理,关注鼻翼、下颌等易堵塞部位,提升毛孔通畅度与肌肤细腻度。", + "lifestyle": "温水洁面避免烫洗,保持床品清洁减少局部刺激。" + }, + "优秀": { + "care": "维持当前温和护理节奏,维护角质层健康与肌肤屏障稳定状态。", + "lifestyle": "保持规律清洁与保湿习惯,避免频繁更换功效类产品,减少肌肤适应负担。" + } + }, + "water": { + "改善": { + "care": "强化屏障锁水修复,注意水油平衡,每周2-3次深度补水,深度改善肌肤缺水状态。", + "lifestyle": "每日足量饮水,补充身体水分,改善干燥环境湿度,避免长时间空调直吹。" + }, + "良好": { + "care": "日常需注重水油平衡调理,避免过度清洁损伤皮肤屏障,注意为肌肤持续补水。", + "lifestyle": "每日足量饮水,环境干燥时及时增加肌肤水分补充。" + }, + "优秀": { + "care": "持续为皮肤补水与保湿,避免过度清洁与频繁去角质,巩固肌肤水润度与水油平衡。", + "lifestyle": "坚持多喝水与规律作息,季节交替时提前调整保湿方案。" + } + }, + "sens": { + "改善": { + "care": "关注屏障修复与舒缓,精简护肤流程,选择温和洁面方式,修复受损角质层。若状态持续无明显好转,可寻求专业护肤建议。", + "lifestyle": "减少外界刺激,如酒精、香精、高浓度酸类精华等成分,避免冷热骤变刺激。" + }, + "良好": { + "care": "保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。", + "lifestyle": "避免冷热骤变刺激肌肤屏障,运动后及时清洁降温。" + }, + "优秀": { + "care": "持续规律护理并按季节阶段性调整,巩固皮肤耐受稳态。", + "lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障稳态。" + } + }, + "acne": { + "改善": { + "care": "优先针对性调理痘区肌肤状态,精简护肤流程,避免频繁更换护肤方案与叠加功效成分;严重时及时就医。", + "lifestyle": "减少高糖高脂饮食与熬夜,避免手挤痘痘;保持床品、毛巾清洁,运动后及时清洁,减少汗液与皮脂堆积。" + }, + "良好": { + "care": "保持温和清洁与肌肤炎症调理,关注T区、下颌等易出油区域,提升毛孔通畅度,同时注意防晒与保湿。", + "lifestyle": "保持规律作息与清淡饮食,运动后及时清洁,减少汗液与皮脂堆积。" + }, + "优秀": { + "care": "坚持定期清洁,维持水油平衡与毛孔通畅,巩固皮肤耐受稳态。", + "lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障健康稳定。" + } + } + } +};}); diff --git a/taobao/vendor/report-ui.js b/taobao/vendor/report-ui.js new file mode 100644 index 0000000..fb4743a --- /dev/null +++ b/taobao/vendor/report-ui.js @@ -0,0 +1,199 @@ +/* Presentation only: original scores and recommendation rules remain in SkinScore. */ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('./report-copy.js')); + else root.ReportUI = factory(root.ReportCopy); +})(typeof window !== 'undefined' ? window : this, function(Copy) { + 'use strict'; + var defs = [ + ['contour', '轮廓', 'firm', 'contour'], ['wrinkles', '皱纹', 'firm', 'wrinkles'], + ['color', '肤色', 'vita', 'tone'], ['smooth', '平滑度', 'vita', 'smooth'], ['water', '水分', 'vita', 'tone'], + ['sens', '泛红', 'stab', 'sens'], ['acne', '痘痘', 'stab', 'acne'] + ]; + var products = [ + { tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '焕活光泽 透亮紧致', image: 'assets/img/ui-20261015/product-25.png' }, + { tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '恢复弹性 紧实利落', image: 'assets/img/ui-20261015/product-35.png' }, + { tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '强韧屏障 轮廓向上', image: 'assets/img/ui-20261015/product-50.png' } + ]; + var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' }; + var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' }; + var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' }; + function numeric(value) { return typeof value === 'number' && isFinite(value); } + function dimensionScore(value) { + return numeric(value) ? Math.min(9.9, Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-'; + } + var gradeRule = 'display-score-8-9-v1'; + var copyRule = 'wrinkles-excellent-acne-detected-water-contour-v4'; + function scoreOfP(P) { + return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null; + } + function levelForScore(S) { + if (!numeric(S)) return null; + var displayed = Number(dimensionScore(S)); + return displayed < 8 ? '改善' : displayed < 9 ? '良好' : '优秀'; + } + function internalLevel(S) { + return { '改善': '预警', '良好': '改善', '优秀': '良好' }[levelForScore(S)] || null; + } + // Read saved raw dimension values, never re-score historical reports or use + // the legacy focus.name (which contains the old dimension terminology). + function focusOf(vm) { + var selected = null; + ['firm', 'vita', 'stab'].forEach(function(key) { + var dim = vm.dims && vm.dims[key]; + if (dim && numeric(dim.P) && (!selected || dim.P < selected.score)) selected = { key: key, score: dim.P, name: dimNames[key], tip: focusTips[key] }; + }); + return selected; + } + function customerText(view) { + // Keep missing-data diagnostics in the saved/admin record, not on the + // customer report. Also suppress equivalent text in older snapshots. + if (view.valid === false || /本次未检出有效数据|本次未获取轮廓有效数据/.test(view.text || '')) return ''; + if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0]; + if (view.key !== 'contour') return view.text; + // Also cover immutable old presentations, without modifying stored copy. + return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, ''); + } + function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; } + var wrinkleDefs = [ + ['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'], + ['left_nasolabial', '左法令纹', ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], 'left_nasolabial_count'], + ['right_nasolabial', '右法令纹', ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], 'right_nasolabial_count'], + ['left_crowsfeet', '左鱼尾纹', ['left_crowsfeet_wrinkle_info'], 'left_crowsfeet_count'], + ['right_crowsfeet', '右鱼尾纹', ['right_crowsfeet_wrinkle_info'], 'right_crowsfeet_count'], + ['left_eye', '左眼部细纹', ['left_eye_wrinkle_info'], 'left_undereye_count'], + ['right_eye', '右眼部细纹', ['right_eye_wrinkle_info'], 'right_undereye_count'], + ['left_mouth', '左嘴角纹', ['left_mouth_wrinkle_info'], 'left_mouth_count'], + ['right_mouth', '右嘴角纹', ['right_mouth_wrinkle_info'], 'right_mouth_count'], + ['glabella', '眉间纹', ['glabella_wrinkle_info'], 'glabella_count'], + ['left_cheek', '左脸颊纹', ['left_cheek_wrinkle_info'], 'left_cheek_count'], + ['right_cheek', '右脸颊纹', ['right_cheek_wrinkle_info'], 'right_cheek_count'] + ]; + var wrinkleMetrics = [ + ['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'], + ['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'], + ['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'], + ['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例'] + ]; + function wrinkleDetails(result) { + result = result || {}; + return wrinkleDefs.map(function (def) { + var node = null; + def[2].some(function (key) { if (result[key] && typeof result[key] === 'object') { node = result[key]; return true; } return false; }); + var values = {}; + wrinkleMetrics.forEach(function (metric) { values[metric[0]] = scalar(node && node[metric[0]]); }); + // Region counts are a valid fallback; a missing count is never zero. + if (values.wrinkle_deep_num == null) values.wrinkle_deep_num = scalar(result.wrinkle_count && result.wrinkle_count[def[3]]); + var fine = result.fine_line || result.fine_line_count; + if (values.wrinkle_shallow_num == null) values.wrinkle_shallow_num = scalar(fine && fine[def[3]]); + return { key: def[0], name: def[1], metrics: values }; + }); + } + function fill(template, vars) { + return template.replace(/\{([a-z_]+)\}/g, function(_, key) { + var v = vars[key]; + return v == null ? '未获取' : numeric(v) ? String(Math.round(v * 100) / 100) : String(v); + }).replace(/\s+/g, ' ').trim(); + } + function count(result, key) { var v = result[key]; return v && numeric(v.count) ? v.count : null; } + function build(vm, result) { + result = result || {}; + var views = defs.map(function(d) { + var cat = vm.cats[d[3]], vars = Object.assign({}, cat.vars || {}); + var level, valid = numeric(cat.P); + var viewP = cat.P; + if (d[0] === 'color') { + viewP = numeric(vars.color_score) ? vars.color_score : vars.tone_scoring && vars.tone_scoring.color_score; + valid = vars.melanin != null && numeric(viewP); + } + if (d[0] === 'water') { + // Water copy/advice is independent of pigment/dark circles. Reuse + // the existing water cross-check; never change dimension scoring. + var waterScore = vars.water_score; + var waterValid = numeric(waterScore) && waterScore >= 0 && waterScore <= 100; + viewP = waterValid ? waterScore : null; + if (waterValid && numeric(vars.water_sev) && vars.water_sev >= 60 && vars.water_sev <= 100 && waterScore >= 80) viewP = 100 - vars.water_sev; + valid = waterValid; + } + var viewS = valid ? scoreOfP(viewP) : null; + level = levelForScore(viewS); + var template = valid && level ? Copy.conclusion[d[0]][level] : ''; + if (d[0] === 'contour') { + if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, ''); + } + if (d[0] === 'color') { + // Severity is an independent detection, not inferred from tone level + // or dark_circle_score. Missing/invalid severity must not imply mild. + var colorSeverity = scalar(result.dark_circle_severity); + var colorDescriptions = ['无明显黑眼圈', '仅存在轻度黑眼圈', '存在中度黑眼圈', '可见明显黑眼圈']; + if (numeric(colorSeverity) && colorSeverity % 1 === 0 && colorSeverity >= 0 && colorSeverity <= 3) { + vars.dark_circle_note = colorDescriptions[colorSeverity]; + } else { + template = template.replace(/\{dark_circle_note\}[,;]/g, ''); + } + } + if (d[0] === 'acne') { + var pustules = vars.acne_counts ? vars.acne_counts.acne_pustule : count(result, 'acne_pustule'); + var nodules = vars.acne_counts ? vars.acne_counts.acne_nodule : count(result, 'acne_nodule'); + var heavy = [pustules > 0 ? '脓包' : '', nodules > 0 ? '结节' : ''].filter(Boolean); + vars.heavy_note = heavy.length ? ',含' + heavy.join('、') + '等中重度皮损,炎症反应明显' : ''; + // Never downgrade the grade independently of the displayed score, + // but never claim absence when the API detected lesions either. + if (vars.acne_cnt > 0) template = '检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。'; + if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。'; + else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。'; + if (vars.acne_cnt != null && vars.acne_counts_complete === false) { + template = vars.acne_cnt > 0 + ? '本次已获取数据中检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},部分类型计数未获取,请结合检测图片查看。' + : '本次已获取的皮损计数为0,部分类型数据缺失,请结合检测图片查看。'; + } + } + // Missing counts are unknown, not zero: remove only the unavailable metric. + if (vars.fl_cnt == null) template = template.replace(/、细纹 \{fl_cnt\} 条/g, '(细纹计数未获取)'); + if (vars.wr_cnt == null) template = template.replace(/皱纹 \{wr_cnt\} 条/g, '皱纹计数未获取'); + if (vars.water_area == null) template = template.replace(/缺水面积 \{water_area\}%[、,]/g, '缺水面积未获取;'); + if (vars.water_sev == null) template = template.replace(/肌肤缺水比较严重 \{water_sev\},/g, ''); + if (vars.sens_area == null) template = template.replace(/泛红面积(?:达 | )\{sens_area\}%[,;]/g, '泛红面积未获取;'); + if (vars.pore_cnt == null) template = template.replace(/粗大毛孔 \{pore_cnt\} 颗/g, '毛孔计数未获取'); + if (vars.blackhead_cnt == null) template = template.replace(/黑头 \{blackhead_cnt\} 颗/g, '黑头计数未获取'); + if (d[0] === 'smooth' && valid && level === '优秀') { + // A high aggregate score does not mean every localized pore is absent. + if (vars.pore_cnt == null || vars.blackhead_cnt == null) template = '肌肤质感整体较好,但毛孔或黑头计数未完整获取,请结合检测图查看。'; + else if (vars.pore_cnt > 0 || vars.blackhead_cnt > 0) template = '肌肤质感整体较好;本次检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,局部仍有可改善之处。'; + } + var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null; + var advice = level ? Copy.care[d[0]][level] : null; + return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, S: viewS, level: level || null, + text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。', + details: details, valid: valid, advice: valid ? advice : null }; + }); + var focus = focusOf(vm); + function priority(view) { return focus && view.dim === focus.key ? 0 : 1; } + function order(a, b) { return a.P - b.P || priority(a) - priority(b) || views.indexOf(a) - views.indexOf(b); } + // Select globally weakest three FIRST, then move selected focus members + // to the front. Never insert an unselected stronger focus-category view. + var chosen = views.filter(function(v) { return v.valid && numeric(v.P) && v.advice; }).sort(order).slice(0,3); + chosen.sort(function(a,b) { return priority(a) - priority(b) || order(a,b); }); + var care = chosen.map(function(v) { return { key: v.key, name: v.name, dim: v.dim, P: v.P, level: v.level, + priority: priority(v) === 0, text: v.advice.care + v.advice.lifestyle }; }); + var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null; + return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15+grade-8-9+two-copy-fixes', gradeRule: gradeRule, copyRule: copyRule, careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus }; + } + // Refresh only presentation for old snapshots: scores, selected care order, + // product and original stored objects are never rewritten/recomputed. + function forDisplay(vm, result, saved) { + if (saved && vm.recommendation && vm.recommendation.ageBasis === 'display') { + var product=products.filter(function(p){return p.tier === vm.recommendation.tier;})[0] || null; + if(saved.product!==product)saved = Object.assign({}, saved, { product: product }); + } + if (!saved || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result); + var current = build(vm, result); + var care = (saved.care || []).map(function(item) { + // Pre-seven-item snapshots contain name/text only, without a stable key. + // Match exact known names; never infer from array position or insert items. + var view = current.views.filter(function(v) { return item.key ? v.key === item.key : v.name === item.name; })[0]; + return view && view.advice ? Object.assign({}, item, { key: view.key, level: view.level, text: view.advice.care + view.advice.lifestyle }) : item; + }); + return Object.assign({}, saved, { gradeRule: gradeRule, copyRule: copyRule, views: current.views, care: care }); + } + return { dimensionScore: dimensionScore, scoreOfP: scoreOfP, levelForScore: levelForScore, internalLevel: internalLevel, gradeRule: gradeRule, copyRule: copyRule, build: build, forDisplay: forDisplay, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText }; +}); diff --git a/taobao/vendor/skin-score.js b/taobao/vendor/skin-score.js new file mode 100644 index 0000000..ccb0df1 --- /dev/null +++ b/taobao/vendor/skin-score.js @@ -0,0 +1,1265 @@ +/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════ + * 评分契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/原始话术) + * 推荐覆盖契约:docs/产品推荐规则-肤龄版.md(2026-10-12 用户确认) + * 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。 + * + * 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核): + * - 分数类字段优先取 result.score_info.,缺失退化 result.(V2.2 §3.1 映射归一) + * - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键 + * - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n} + * - 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1:小数×100=%),缺失回退 sensitivity.sensitivity_area + * - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失) + * - 黑眼圈级取 dark_circle_severity.value,缺失退化 dark_circle.value(真值表口径:案例1=1 级,非 dark_circle 的 3) + * - sensitivity_area / red_spot_area / water_area / oily 分区 area ≤1 视为小数口径×100,否则按百分比直读 + * - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(文档未给公式,待客户确认) + * - {oil_desc} 按 V2.2 §7.2 由 oily_intensity 分区面积生成:T区出油面积X%、左右脸颊Y%/Z%,全脸 intensity=0 省略 + * - {st} 肤色分类、{skin_type_cn} 肤质类型按接口枚举映射中文(skintone 0–4 / skin_type 0–3,真值已校) + * - {spot_flag} 按 V2.2 §7.2:色斑基句 +「,检出褐色斑N处」「、痣N处」续接,为 0 整体省略 + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.SkinScore = factory(); +}(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + /* ─── 基础工具 ─── */ + function isNum(x) { return typeof x === 'number' && isFinite(x); } + function val(x) { // 解包 {value:n} / 数字 / 数字字符串 + if (x == null) return null; + if (isNum(x)) return x; + if (typeof x === 'object') { return isNum(x.value) ? x.value : null; } + var n = Number(x); + return isNaN(n) ? null : n; + } + function pick(r, key) { // score_info 优先,再顶层(§3.1 字段归一) + if (r == null) return null; + var si = r.score_info; + if (si && si[key] != null) { var v = val(si[key]); if (v != null) return v; } + return val(r[key]); + } + function cnt(node) { // 计数:.count 缺失退化 rectangle 数组长度 + if (node == null) return null; + var c = val(node.count); + if (c != null) return c; + if (node.rectangle && typeof node.rectangle.length === 'number') return node.rectangle.length; + return null; + } + function sumLeaves(node) { // 分区计数对象(真实 wrinkle_count/fine_line 为 {forehead_count:22,...} 形状):对数值叶子求和 + if (node == null || typeof node !== 'object') return val(node); + var sum = 0, any = false, k; + for (k in node) { + if (node[k] != null && typeof node[k] === 'object') continue; // 只看数值叶子,不递归嵌套对象 + var v = val(node[k]); + if (v != null) { sum += v; any = true; } + } + return any ? sum : null; + } + function cntField(r, key) { // 计数字段归一:标量/{value} 直读(score_info 优先),分区对象对数值叶子求和 + var v = pick(r, key); + if (v != null) return v; + var si = r.score_info; + var node = (si && si[key] != null) ? si[key] : r[key]; + return sumLeaves(node); + } + function round2(x) { return Math.round((x + 1e-9) * 100) / 100; } // 1e-9 修正 9.445 类浮点 + function sRaw(P) { return 7 + 3 * P / 100; } + function capDimensionScore(S) { return isNum(S) ? Math.min(9.9, S) : null; } + // One shared 100-point total for H5, saved reports and admin. Never use the + // upstream total or reweight an incomplete set of dimensions. + function weightedTotal(dims) { + if (!dims) return null; + var keys = ['firm', 'vita', 'stab'], weights = [4, 3, 3], total = 0; + for (var i = 0; i < keys.length; i++) { + var score = dims[keys[i]] && dims[keys[i]].S; + if (!isNum(score) || score < 0 || score > 10) return null; + total += capDimensionScore(score) * weights[i]; + } + return Math.round(total + 1e-9); + } + function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾; + if (P == null) return null; // 仅钳展示值,推荐边界比较仍用未舍入维度 S 原值 + var s = sRaw(P); + return round2(s < 7 ? 7 : (s > 10 ? 10 : s)); + } + function levelOf(P) { // 判档一律用未四舍五入 P 原值 + if (P == null) return null; + return P < 80 ? '预警' : (P < 90 ? '改善' : '良好'); + } + function fmt(x) { // 展示用:两位内,去掉多余的 .0 + if (x == null) return ''; + var v = round2(x); + return Math.round(v) === v ? String(Math.round(v)) : String(v); + } + function round1(x) { return Math.round((x + 1e-9) * 10) / 10; } + function fmtS(x) { return x == null ? '' : round2(x).toFixed(2); } // 综合文案 S:固定两位小数(真值「S=8.50」) + function fmtP(x) { return x == null ? '' : round1(x).toFixed(1); } // 综合文案 P:固定一位小数(真值「P=23.0」) + + /* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界), + * 严禁输出 None/undefined/— 占位(V2.2 第七章生成规则与 7.3 缺值规则)。 */ + function fill(tpl, vars) { + var parts = tpl.split(/([,;。])/); + var out = ''; + for (var i = 0; i < parts.length; i += 2) { + var clause = parts[i], delim = parts[i + 1] || ''; + var miss = false; + var c = clause.replace(/\{(\w+)\}/g, function (m, k) { + var v = vars[k]; + if (v === null || v === undefined || v === '') { miss = true; return m; } + return v; + }); + if (!miss) out += c + delim; // 缺失:整分句连同分隔符省略 + } + return out; + } + function joinParts(parts, sep) { + var out = []; + for (var i = 0; i < parts.length; i++) if (parts[i]) out.push(parts[i]); + return out.join(sep); + } + + /* ─── 字段归一:分区配置 ─── */ + var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(V2.2 §3.3④ 变体归一) + { keys: ['forehead_wrinkle_info'], cn: '抬头纹' }, + { keys: ['glabella_wrinkle_info'], cn: '眉间纹' }, + { keys: ['left_eye_wrinkle_info'], cn: '左眼周' }, + { keys: ['right_eye_wrinkle_info'], cn: '右眼周' }, + { keys: ['left_crowsfeet_wrinkle_info'], cn: '左鱼尾纹' }, + { keys: ['right_crowsfeet_wrinkle_info'], cn: '右鱼尾纹' }, + { keys: ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], cn: '左法令纹' }, + { keys: ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], cn: '右法令纹' }, + { keys: ['left_mouth_wrinkle_info'], cn: '左嘴角纹' }, + { keys: ['right_mouth_wrinkle_info'], cn: '右嘴角纹' }, + { keys: ['left_cheek_wrinkle_info'], cn: '左脸颊' }, + { keys: ['right_cheek_wrinkle_info'], cn: '右脸颊' } + ]; + var PORE_REGION_CN = { // 真实返回同时存在下划线/无下划线变体(如 pores_left_cheek_score 与 pores_leftcheek_score),均需中文名兜底 + forehead: '额头', left_cheek: '左脸颊', right_cheek: '右脸颊', jaw: '下巴', nose: '鼻部', + leftcheek: '左侧面颊', rightcheek: '右侧面颊', chin: '下巴' + }; + var SKINTONE_CN = { 0: '极白', 1: '白皙', 2: '自然', 3: '小麦色', 4: '黝黑' }; // skintone_ita.skintone 枚举(真值:0→极白、3→小麦色) + var SKIN_TYPE_CN = { 0: '油性', 1: '干性', 2: '中性', 3: '混合性' }; // skin_type.skin_type 枚举(真值:3→混合性) + function skinTypeCnOf(r, profile) { // {skin_type_cn}:接口 skin_type 优先,缺失回退用户自选肤质(V2.2 §8.5 肤质不参与分数) + var st = r ? r.skin_type : null; + var v = st != null && typeof st === 'object' ? val(st.skin_type != null ? st.skin_type : st.value) : val(st); + if (v != null && SKIN_TYPE_CN[v] != null) return SKIN_TYPE_CN[v]; + return (profile && profile.skinType) || null; + } + + /* ─── 分类计算(每项返回 { P, vars },P=null 表示缺项) ─── */ + + // 轮廓:P = min(眼袋severity映射, 下颌线角度分) + var EYE_POUCH_MAP = [100, 90, 79, 74]; // 0→100/1→90/2→79/3→74 + function jawScore(angle) { // 100−|偏离116–120°|×2,下限60 + var dev = angle < 116 ? 116 - angle : (angle > 120 ? angle - 120 : 0); + return Math.max(60, 100 - dev * 2); + } + function jawlineOf(side) { // 键名归一:*_jawline_info / *_jawline_angle / *_jawline_quality + if (!side) return null; + var info = null, k; + for (k in side) if (/_jawline_info$/.test(k)) { info = side[k]; break; } + if (!info) return null; + var angle = null, quality = null; + for (k in info) { + if (/_jawline_angle$/.test(k)) angle = val(info[k]); + else if (/_jawline_quality$/.test(k)) quality = val(info[k]); + } + return { angle: angle, quality: quality }; + } + function calcContour(r, sides, notes) { + var pouchSev = pick(r, 'eye_pouch_severity'); + if (pouchSev == null) pouchSev = pick(r, 'eye_pouch'); // 退化 + var pouchP = null, pouchLvl = null; + if (pouchSev != null) { + var idx = Math.round(pouchSev); // severity 可能返回非整数:取整后再做映射数组索引 + if (idx < 0 || idx > 3) { + notes.push('眼袋 severity 越界(' + pouchSev + '),按缺失处理,轮廓评分缺眼袋项'); + } else { + pouchLvl = idx; + pouchP = EYE_POUCH_MAP[idx]; + } + } else { + notes.push('眼袋 severity 字段缺失,轮廓评分缺眼袋项'); + } + + var la = null, ra = null, jawParts = [], jawScores = []; + var anySide = sides && (sides.left || sides.right); + var sidesMeta = [['left', '左', sides && sides.left], ['right', '右', sides && sides.right]]; + for (var i = 0; i < 2; i++) { + var tag = sidesMeta[i][1], side = sidesMeta[i][2]; + if (!side) continue; + var j = jawlineOf(side); + if (!j) { notes.push(tag + '侧脸未返回下颌线数据,下颌线未参与轮廓评分'); continue; } + if (j.quality !== 1) { // 仅 quality=1 角度有效 + notes.push(tag + '侧脸角度无效(quality=' + (j.quality == null ? '缺失' : j.quality) + '),下颌线未参与轮廓评分,侧脸角度需20°–120°,建议重拍'); + continue; + } + if (j.angle == null) { notes.push(tag + '侧脸下颌线角度缺失,未参与轮廓评分'); continue; } + if (tag === '左') la = j.angle; else ra = j.angle; + jawScores.push(jawScore(j.angle)); + } + var jawP = jawScores.length ? Math.min.apply(null, jawScores) : null; // 左右取较差侧 + if (!anySide) notes.push('未拍摄侧脸视图,轮廓评分仅由眼袋决定,建议补拍侧脸视图'); + else if (jawP == null) notes.push('双侧下颌线均不可用,轮廓评分仅由眼袋决定'); + + var cand = []; + if (pouchP != null) cand.push(pouchP); + if (jawP != null) cand.push(jawP); + if (!cand.length) { notes.push('轮廓:眼袋与下颌线数据均缺失,轮廓分类不参与评分'); return { P: null, vars: {} }; } + var dcSev = pick(r, 'dark_circle_severity'); // 黑眼圈级:dark_circle_severity 优先(真值口径),缺失退化 dark_circle + if (dcSev == null) dcSev = pick(r, 'dark_circle'); + return { + P: Math.min.apply(null, cand), + vars: { + la: la, ra: ra, + eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句) + dark_circle_sev: dcSev + } + }; + } + + // 皱纹:直接使用接口整体评分,不反向、不按最差分区或严重度限分。 + function calcWrinkles(r, notes) { + var P = pick(r, 'wrinkle_score'); + if (!isNum(P) || P < 0 || P > 100) { + P = null; + notes.push('皱纹:整体皱纹评分缺失或无效,不回退最差分区计算'); + } + var regions = [], used = {}, i, k, node; + for (i = 0; i < WRINKLE_REGIONS.length; i++) { + var rg = WRINKLE_REGIONS[i]; node = null; + for (var j = 0; j < rg.keys.length; j++) { + if (r[rg.keys[j]] != null) { node = r[rg.keys[j]]; used[rg.keys[j]] = 1; break; } + } + if (!node) continue; + var sc = val(node.wrinkle_score); + if (sc == null) continue; + regions.push({ cn: rg.cn, score: sc, sev: val(node.wrinkle_severity_level) }); + } + for (k in r) { // 兜底:未配置的 *_wrinkle_info / *_fold_info 变体也参与 + if (used[k] || !/(_wrinkle_info|_fold_info)$/.test(k)) continue; + node = r[k]; + if (!node || typeof node !== 'object') continue; + var s2 = val(node.wrinkle_score); + if (s2 == null) continue; + regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) }); + } + var worst = regions.length ? regions[0] : null, maxSev = null; + for (i = 0; i < regions.length; i++) { + if (regions[i].score > worst.score) worst = regions[i]; + if (regions[i].sev != null && (maxSev == null || regions[i].sev > maxSev)) maxSev = regions[i].sev; + } + var wrCnt = cntField(r, 'wrinkle_count'); + var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count + if (flCnt == null) flCnt = cntField(r, 'fine_line_count'); + return { + P: P, + vars: { + wr_cnt: wrCnt, + fl_cnt: flCnt, + overall_wrinkle_score: P, + worst_wr_region: worst ? worst.cn : null, + wr_max: worst ? worst.score : null, + max_sev: maxSev + } + }; + } + + // 光滑:P = 0.4×min(4分区 pores_type_score) + 0.3×blackhead_score + 0.3×rough_score;分区 severity≥2 锁79 + function calcSmooth(r, notes) { + var pts = (r.score_info && r.score_info.pores_type_score) || r.pores_type_score || null; + var poreMin = null, poreRegion = null, poreSevMax = null, k; + if (pts && typeof pts === 'object') { + for (k in pts) { + if (/_score$/.test(k)) { + var s = val(pts[k]); + if (s == null) continue; + var region = k.replace(/^pores_/, '').replace(/_score$/, ''); + if (poreMin == null || s < poreMin) { poreMin = s; poreRegion = PORE_REGION_CN[region] || region; } + } else if (/_severity$/.test(k)) { + var sv = val(pts[k]); + if (sv != null && (poreSevMax == null || sv > poreSevMax)) poreSevMax = sv; + } + } + } else { + notes.push('pores_type_score 缺失(score_info 内亦无),光滑评分缺毛孔项'); + } + // 真实 pores_type_score 内无 severity 键:锁所需数据缺失时留痕,避免锁静默失效 + if (pts && typeof pts === 'object' && poreSevMax == null) { + notes.push('毛孔分区严重度未返回,光滑上限锁未执行'); + } + var bh = pick(r, 'blackhead_score'); + var rough = pick(r, 'rough_score'); + if (bh == null) notes.push('blackhead_score 缺失,光滑评分缺黑头项'); + if (rough == null) notes.push('rough_score 缺失,光滑评分缺纹理项'); + + var factors = [[0.4, poreMin], [0.3, bh], [0.3, rough]], wSum = 0, acc = 0, n = 0, i; + for (i = 0; i < factors.length; i++) { + if (factors[i][1] == null) continue; // 单项缺失:剔除并按剩余权重归一 + wSum += factors[i][0]; acc += factors[i][0] * factors[i][1]; n++; + } + if (!n) { notes.push('光滑:毛孔/黑头/纹理三项全部缺失,光滑分类不参与评分'); return { P: null, vars: {} }; } + var P = acc / wSum; + if (poreSevMax != null && poreSevMax >= 2) P = Math.min(P, 79); + + var poreCnt = null; + if (r.enlarged_pore_count != null) { + if (isNum(val(r.enlarged_pore_count))) poreCnt = val(r.enlarged_pore_count); + else if (typeof r.enlarged_pore_count === 'object') { // 4 分区求和 + poreCnt = 0; + for (k in r.enlarged_pore_count) { var c = val(r.enlarged_pore_count[k]); if (c != null) poreCnt += c; } + } + } + var bhCnt = cnt(r.blackhead); + if (bhCnt == null) bhCnt = pick(r, 'blackhead_count'); + return { + P: P, + vars: { + worst_pore_region: poreRegion, + pore4_min: poreMin, + pore_cnt: poreCnt, + blackhead_cnt: bhCnt, + tex: rough, + oily: r.oily_intensity // {oil_desc} 数据源(V2.2 §7.2 分区面积) + } + }; + } + + // 肤色项 = 80%×(色素分−黄褐斑×10−雀斑×5) + 20%×黑眼圈分;肤色/水分仍各50%。 + // 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认) + function calcTone(r, notes) { + var melanin = pick(r, 'melanin_score'); + var darkCircle = pick(r, 'dark_circle_score'); + if (!isNum(darkCircle) || darkCircle < 0 || darkCircle > 100) darkCircle = null; + var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分) + var freckle = pick(r, 'freckle') || 0; + var waterScore = pick(r, 'water_score'); + var waterObj = r.water || {}; + var waterSev = val(waterObj.water_severity); + if (waterSev == null) waterSev = pick(r, 'water_severity'); + var waterArea = val(waterObj.water_area); + if (waterArea == null) waterArea = pick(r, 'water_area'); + if (waterArea != null && waterArea <= 1) waterArea = waterArea * 100; // 小数口径×100 → 百分比 + + var melaninItem = null, waterItem = null; + if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5; + else notes.push('melanin_score 缺失,肤色水润评分缺肤色项'); + var colorItem = null, colorWeight = 0, colorSum = 0; + if (melaninItem != null) { colorSum += melaninItem * 0.8; colorWeight += 0.8; } + if (darkCircle != null) { colorSum += darkCircle * 0.2; colorWeight += 0.2; } + else notes.push('dark_circle_score 缺失或无效,肤色项剔除黑眼圈并按有效权重归一'); + if (colorWeight) colorItem = colorSum / colorWeight; + if (waterScore != null) { + waterItem = waterScore; + if (waterSev != null && waterSev >= 60 && waterScore >= 80) { + waterItem = 100 - waterSev; + notes.push('水分交叉校验触发(water_severity=' + waterSev + ' 且 water_score=' + waterScore + '),水润项按 severity 换算为 ' + waterItem); + } + } else { + notes.push('water_score 缺失,肤色水润评分缺水润项'); + } + var items = [colorItem, waterItem], acc = 0, n = 0, i; + for (i = 0; i < 2; i++) if (items[i] != null) { acc += 0.5 * items[i]; n++; } + if (!n) { notes.push('肤色水润:肤色项与水润项均缺失,肤色水润分类不参与评分'); return { P: null, vars: {} }; } + var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一 + var itaObj = r.skintone_ita || {}; + var haObj = r.skin_hue_ha || {}; + var stRaw = itaObj.skin_tone != null ? itaObj.skin_tone + : (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone + : (itaObj.tone != null ? itaObj.tone : null)); + // 肤色分类枚举→中文(真值:0→极白、3→小麦色);未收录枚举值原样透出 + var st = stRaw != null && SKINTONE_CN[stRaw] != null ? SKINTONE_CN[stRaw] : stRaw; + // {spot_flag}(V2.2 §7.2):检出黄褐斑和/或雀斑写该种,均未检出写「未见明显色斑标记」;褐色斑/痣数>0 续接,为 0 整体省略 + var spotFlag = (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记'; + var bsCnt = cnt(r.brown_spot), moleCnt = cnt(r.mole); + if (bsCnt != null && bsCnt > 0) spotFlag += ',检出褐色斑' + fmt(bsCnt) + '处'; + if (moleCnt != null && moleCnt > 0) spotFlag += '、痣' + fmt(moleCnt) + '处'; + var itaV = val(itaObj.ITA != null ? itaObj.ITA : itaObj.value); + var haV = val(haObj.HA != null ? haObj.HA : haObj.value); + return { + P: P, + vars: { + ita: itaV == null ? null : round1(itaV), // 真值表为 1 位小数(58.64615→58.6、56.246513→56.2) + st: st, + ha: haV == null ? null : round1(haV), + melanin: melanin, + dark_circle_score: darkCircle, + color_score: colorItem, + tone_scoring: { version: 'color-dark-circle-20-v1', melanin_score: melanin, + pigment_adjusted_score: melaninItem, dark_circle_score: darkCircle, color_score: colorItem, + pigment_weight: 0.8, dark_circle_weight: 0.2, color_effective_weight: colorWeight, + water_score: waterScore, water_adjusted_score: waterItem }, + spot_flag: spotFlag, + water_score: waterScore, + water_area: waterArea, + water_sev: waterSev + } + }; + } + + // 敏感:P = min(sensitivity_score, 100−面积%×1.5);面积≥15% 锁79、≥30% 锁74 + function calcSens(r, notes) { + var sensObj = r.sensitivity || null; + var score = pick(r, 'sensitivity_score'); + if (score == null && sensObj) score = val(sensObj.sensitivity_score); + // 面积口径来源(V2.2 §3.1):优先 red_spot.red_spot_area(小数×100=%); + // 缺失再回退 sensitivity.sensitivity_area;两者均保留 ≤1→×100 启发式 + var areaRaw = r.red_spot && typeof r.red_spot === 'object' ? val(r.red_spot.red_spot_area) : null; + if (areaRaw == null) { + areaRaw = sensObj ? val(sensObj.sensitivity_area) : null; + if (areaRaw == null) areaRaw = pick(r, 'sensitivity_area'); + } + if (score == null && areaRaw == null) { + notes.push('sensitivity 对象缺失,敏感分类不参与评分与均分(禁止编造降级假分数)'); + return { P: null, vars: { missing: true } }; + } + // 面积口径:≤1 视为小数(如 0.001)×100 换算成百分比;否则按百分比直读(如 2.0 → 2%) + var areaPct = areaRaw == null ? null : (areaRaw <= 1 ? areaRaw * 100 : areaRaw); + var cand = []; + if (score != null) cand.push(score); + if (areaPct != null) cand.push(100 - areaPct * 1.5); + var P = Math.min.apply(null, cand); + if (areaPct != null) { + if (areaPct >= 30) P = Math.min(P, 74); + else if (areaPct >= 15) P = Math.min(P, 79); + } + return { P: P, vars: { sens_area: areaPct, sens_score: score } }; + } + + // 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50 + function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); } + var ACNE_TYPES = [ + ['closed_comedones', '闭口粉刺', 1], ['acne', '丘疹', 2], + ['acne_pustule', '脓包', 4], ['acne_nodule', '结节', 6], ['acne_mark', '痘印', 1.5] + ]; + function calcAcne(r, sides, notes) { + var score = pick(r, 'acne_score'); + var left = sides && sides.left, right = sides && sides.right; + var sources = { front: r, left: left && (left.left_acne_info || left.acne_info), right: right && (right.right_acne_info || right.acne_info) }; + var merged = {}, rows = [], total = 0, rawDeduct = 0, allNull = true, complete = true; + for (var i = 0; i < ACNE_TYPES.length; i++) { + var def = ACNE_TYPES[i], views = {}, max = null; + ['front', 'left', 'right'].forEach(function(view) { + var n = cnt(sources[view] && sources[view][def[0]]); + if (!isNum(n) || n < 0 || Math.floor(n) !== n) n = null; + views[view] = n; + if (n != null && (max == null || n > max)) max = n; + }); + merged[def[0]] = max; + if (max == null) { complete = false; notes.push('痘痘:' + def[1] + '三视图计数均缺失,不为该项编造扣分'); } + else { allNull = false; total += max; rawDeduct += max * def[2]; } + rows.push({ key: def[0], name: def[1], weight: def[2], views: views, count: max, + sources: Object.keys(views).filter(function(view) { return max != null && views[view] === max; }), + deduction: max == null ? null : max * def[2] }); + } + var deduct = Math.min(50, rawDeduct); + var audit = { version: 'three-view-type-max-v1', front_score: score, + mapped_score: score == null ? null : acneGrade(score), types: rows, + raw_deduction: allNull ? null : rawDeduct, deduction: allNull ? null : deduct, complete: complete }; + var vars = { acne_cnt: allNull ? null : total, acne_counts: merged, acne_counts_complete: complete, + acne_detail: rows.filter(function(row) { return row.count > 0; }).map(function(row) { return row.name + row.count + '处'; }).join('、'), acne_scoring: audit }; + if (score == null && allNull) { + notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分'); + return { P: null, vars: vars }; + } + if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定'); + var cand = []; + if (score != null) cand.push(acneGrade(score)); + if (!allNull) cand.push(100 - deduct); + return { P: Math.min.apply(null, cand), vars: vars }; + } + + /* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */ + function oilPct(node) { // oily_intensity 分区面积:≤1 小数×100,否则直读 + if (!node) return null; + var a = val(node.area); + return a == null ? null : (a <= 1 ? a * 100 : a); + } + function oilDesc(oily) { // {oil_desc}(V2.2 §7.2):T区出油面积X%、左右脸颊Y%/Z%;全脸 intensity=0 或数据缺失时整体省略 + if (!oily || typeof oily !== 'object') return ''; + var ff = oily.full_face; + if (ff && val(ff.intensity) === 0) return ''; + var t = oilPct(oily.t_zone), l = oilPct(oily.left_cheek), rr = oilPct(oily.right_cheek); + if (t == null && l == null && rr == null) return ''; + var parts = []; + if (t != null) parts.push('T区出油面积' + fmt(t) + '%'); + if (l != null && rr != null) parts.push('左右脸颊' + fmt(l) + '%/' + fmt(rr) + '%'); + else if (l != null) parts.push('左脸颊' + fmt(l) + '%'); + else if (rr != null) parts.push('右脸颊' + fmt(rr) + '%'); + return parts.join('、'); + } + function jawSeg(v, goodTpl) { // 组合下颌线分句;返回 '' 表示省略 + if (v.la == null && v.ra == null) return ''; + var ang = v.la != null && v.ra != null ? '下颌线角度左' + fmt(v.la) + '°/右' + fmt(v.ra) + '°' + : (v.la != null ? '下颌线角度左' + fmt(v.la) + '°' : '下颌线角度右' + fmt(v.ra) + '°'); + var outL = v.la != null && (v.la < 116 || v.la > 120); + var outR = v.ra != null && (v.ra < 116 || v.ra > 120); + var paren; + if (goodTpl && !outL && !outR) paren = '(处于标准区间)'; + else if (outL && outR) paren = '(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降)'; + else if (outL || outR) paren = '(标准区间116–120°,单侧偏离标准,提示下颌缘支撑力下降)'; + else paren = '(标准区间116–120°)'; + return ang + paren; + } + function eyeSeg(v) { + return joinParts([ + v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '', + v.dark_circle_sev != null ? '黑眼圈' + fmt(v.dark_circle_sev) + '级' : '' + ], '、'); + } + function textContour(P, v) { + var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v); + // V2.2 第七章预警档原文自「下颌线角度…」起,无额外前缀句 + if (lv === '预警') return fill('{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。', + { jaw: jaw, eye: eye }); + // V2.2 第七章改善档原文只含眼袋,不含黑眼圈 + if (lv === '改善') return fill('{jaw},{eye},眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。', + { jaw: jaw, eye: v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '' }); + return fill('面部轮廓紧致立体{jaw},{eye},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。', + { jaw: jaw ? ',' + jaw : '', eye: eye }); + } + function wrSeg(v) { + return joinParts([ + v.wr_cnt != null ? '全脸皱纹' + fmt(v.wr_cnt) + '条' : '', + v.fl_cnt != null ? '细纹' + fmt(v.fl_cnt) + '条' : '' + ], '、'); + } + function textWrinkles(P, v) { + var lv = levelOf(P), seg = wrSeg(v); + var paren = v.wr_max != null + ? (lv === '预警' + ? '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',最高严重度' + fmt(v.max_sev) + '级' : '') + ')' + : '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',严重度' + fmt(v.max_sev) + '级' : '') + ')') + : ''; + if (lv === '预警') return fill('{seg},最差区域为{r}{p},部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。', + { seg: seg, r: v.worst_wr_region, p: paren }); + if (lv === '改善') return fill('{seg},最差区域为{r}{p}。建议加强日常淡纹与弹润护理,保持肌肤滋润度。', + { seg: seg, r: v.worst_wr_region, p: paren }); + return fill('全脸12分区纹路平整(最差区域{r}{p2}){fl},未见明显静态纹。建议维持日常保湿与防晒,保持现有淡纹护理节奏。', + { r: v.worst_wr_region, p2: v.wr_max != null ? ',分区分' + fmt(v.wr_max) : '', fl: v.fl_cnt != null ? ',细纹合计' + fmt(v.fl_cnt) + '条' : '' }); + } + function cntSeg(v) { + return joinParts([ + v.pore_cnt != null ? '粗大毛孔' + fmt(v.pore_cnt) + '颗' : '', + v.blackhead_cnt != null ? '黑头' + fmt(v.blackhead_cnt) + '颗' : '' + ], '、'); + } + function textSmooth(P, v) { + var lv = levelOf(P), seg = cntSeg(v), oil = oilDesc(v.oily); + if (lv === '预警') return fill('皮肤表面平整度较低,最差毛孔区位于{r}{p},{c}{o}纹理分仅{t},整体细腻度不足。建议避免过度清洁,重点关注{r},配合低浓度酸类周期护理与柔润精华。', + { r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) }); + if (lv === '改善') return fill('皮肤表面整体较为平滑,毛孔最差区为{r}{p},{c}{o}纹理分{t},局部存在轻微角质粗糙感。建议加强基础清洁与低浓度酸类周期护理。', + { r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) }); + return fill('皮肤表面细腻平滑,最差毛孔区{r}控制良好(分区毛孔分{p}{c}),纹理分{t},角质层排列整齐。建议维持温和清洁与保湿节奏。', + { r: v.worst_pore_region, p: fmt(v.pore4_min), c: seg ? ',' + seg : '', t: fmt(v.tex) }); + } + function itaHdr(v) { // 「肤色ITA {ita}({st},HA {ha})」组合,缺项省略 + var stStr = (v.st === null || v.st === undefined || v.st === '') ? '' : String(v.st); // st=0 是合法肤色分类,不能被 falsy 吞掉 + var inner = joinParts([stStr, v.ha != null ? 'HA ' + fmt(v.ha) : ''], ','); + if (v.ita == null && !inner) return ''; + if (v.ita == null) return '肤色' + (inner ? '(' + inner + ')' : ''); + return '肤色ITA ' + fmt(v.ita) + (inner ? '(' + inner + ')' : ''); + } + function textTone(P, v) { + var lv = levelOf(P), hdr = itaHdr(v); + var wseg = joinParts([ + v.water_area != null ? '缺水面积' + fmt(v.water_area) + '%' : '', + v.water_sev != null ? '严重度' + fmt(v.water_sev) : '' + ], '、'); + if (wseg) wseg = ',' + wseg; + var vars = { h: hdr, m: fmt(v.melanin), f: v.spot_flag, w: fmt(v.water_score), ws: wseg }; + if (lv === '预警') return fill('{h},色素分{m},整体偏暗沉({f});水分分{w}{ws},水润度不足。建议加强透亮与补水护理,做好防晒,减少色素刺激。', vars); + if (lv === '改善') return fill('{h},色素分{m},整体稳定但存在局部暗沉倾向({f});水分分{w}{ws}。建议加强抗氧化与光泽管理,做好基础防晒与补水。', vars); + return fill('{h},色素分{m},肤色均匀透亮({f});水分分{w}{ws}。色素与水润状态均衡,建议持续做好防晒与基础保湿。', vars); + } + function textSens(P, v) { + if (v.missing) return '本次检测未获取到敏感度数据(sensitivity 字段缺失),敏感分类暂不参与评分,建议重新拍摄检测以获取完整结果。'; + var lv = levelOf(P), vars = { a: fmt(v.sens_area), s: fmt(v.sens_score) }; + if (lv === '预警') return fill('泛红面积达{a}%,红区分{s},屏障受损信号明显、敏感区域较大。建议立即转为舒缓修护路线,停用酸类与强清洁,必要时寻求专业建议。', vars); + if (lv === '改善') return fill('泛红面积{a}%,红区分{s},存在局部轻微泛红与屏障波动。建议使用舒缓修护类产品,减少刺激源,注意温差与清洁力度。', vars); + return fill('泛红面积{a}%,红区分{s},屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', vars); + } + function textAcne(P, v) { + var lv = levelOf(P); + // 「检出N处皮损(明细)」拼接成句(V2.2 第七章预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议…) + var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : ''; + if (lv === '预警') return (found ? found + '。' : '') + + '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。'; + if (lv === '改善') return (found ? found + ',' : '') + + '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。'; + // 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句 + if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。'; + if (v.acne_counts_complete === false) return (found ? found + '。' : '') + '部分类型皮损计数未获取,请结合检测图片查看。'; + if (found) return found + '。建议维持清洁与控油节奏,注意作息与饮食。'; + return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。'; + } + + /* ─── 常量表 ─── */ + var CAT_DEFS = [ // 顺序即最差分类并列取舍优先级:轮廓>皱纹>光滑>肤色水润>敏感>痘痘 + { key: 'contour', name: '轮廓', dim: 'firm', calc: calcContour, text: textContour }, + { key: 'wrinkles', name: '皱纹', dim: 'firm', calc: calcWrinkles, text: textWrinkles }, + { key: 'smooth', name: '光滑', dim: 'vita', calc: calcSmooth, text: textSmooth }, + { key: 'tone', name: '肤色水润', dim: 'vita', calc: calcTone, text: textTone }, + { key: 'sens', name: '敏感', dim: 'stab', calc: calcSens, text: textSens }, + { key: 'acne', name: '痘痘', dim: 'stab', calc: calcAcne, text: textAcne } + ]; + var DIM_DEFS = [ + { key: 'firm', name: '紧塑力', tip: '轮廓支撑与紧致度:重点针对下颌线与眼周使用紧致类精华,配合由下向上的提拉按摩手法' }, + { key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' }, + { key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' } + ]; + var LIFESTYLE = { // V2.2 §8.4 全文 + contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深', + wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', + smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食', + tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏', + sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤', + acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入' + }; + var ESSENCE = { // V2.2 §8.3 三档全文(前缀「建议搭配使用X:」为报告页拼接胶) + 1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' }, + 2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' }, + 3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' } + }; + + // 2026-10-12 已确认的业务规则。只接收原始肤龄和三维度未舍入 S, + // 不使用实际年龄、展示肤龄、六分类均分或最差分类;历史快照不重算。 + function recommendProduct(skinAge, scores) { + scores = scores || {}; + var firm = isNum(scores.firm) ? scores.firm : null; + var vita = isNum(scores.vita) ? scores.vita : null; + var stab = isNum(scores.stab) ? scores.stab : null; + var rec = { version: '2026-10-16-display-skin-age-v2', ageBasis: 'display', skinAge: skinAge, + baseTier: null, tier: null, boundary: null, adjustment: 'none', + scores: { firm: firm, vita: vita, stab: stab }, reason: '', triggers: [] }; + if (!isNum(skinAge) || skinAge <= 0) { + rec.adjustment = 'unavailable'; + rec.reason = '未获取有效展示肌龄,暂不推荐具体产品;不以实际年龄代替。'; + return rec; + } + rec.baseTier = skinAge < 35 ? 1 : (skinAge < 50 ? 2 : 3); + rec.tier = rec.baseTier; + rec.triggers.push('肤龄基础分组'); + if (skinAge >= 32 && skinAge <= 38) rec.boundary = 35; + else if (skinAge >= 47 && skinAge <= 53) rec.boundary = 50; + if (rec.boundary != null && skinAge < rec.boundary && firm != null && firm < 8) { + rec.tier++; + rec.adjustment = 'up'; + rec.reason = '处于' + rec.boundary + '岁下侧边界,紧塑力低于8分,升一档。'; + rec.triggers.push(rec.boundary + '岁边界低分升档'); + } else if (rec.boundary != null && skinAge >= rec.boundary && firm != null && firm >= 9 + && ((vita != null && vita >= 9) || (stab != null && stab >= 9))) { + rec.tier--; + rec.adjustment = 'down'; + rec.reason = '处于' + rec.boundary + '岁上侧边界,紧塑力≥9且透亮感或稳韧力≥9,降一档。'; + rec.triggers.push(rec.boundary + '岁边界高分降档'); + } else if (skinAge < 32) { + rec.reason = '展示肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。'; + } else if (rec.boundary != null) { + rec.reason = '处于' + rec.boundary + '岁边界,未满足升降档条件,保持肤龄基础档。'; + } else { + rec.reason = '不在35±3或50±3岁边界内,仅按展示肌龄推荐。'; + } + return rec; + } + + /* ─── V2.3 §8 护理建议 ─── */ + var SKIN_TYPE_TEXT = { // §8.6 五类映射【肤质定制】段全文 + 联动句 + '干性肌肤': { base: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。', + link: function (c) { return c.tone && c.tone.level === '预警' ? '当前已处于明显缺水状态,建议暂缓一切控油类产品' : ''; } }, + '油性肌肤': { base: '你的日常肤质为油性肌肤:皮脂分泌旺盛,毛孔易被氧化油脂堵塞。护理以温和控油与毛孔疏通为核心,可选择含烟酰胺、水杨酸的清爽配方,但清洁仍需适度,避免过度去脂反而刺激代偿性出油。', + link: function (c) { return c.tone && c.tone.level === '预警' ? '需警惕外油内干:控油同时必须叠加保湿,避免屏障受损' : ''; } }, + '中性肌肤': { base: '你的日常肤质为中性肌肤:水油分泌平衡,角质状态稳定,是理想的肌肤基础态。护理以维持现状为核心,精简护肤流程,做好基础清洁、保湿与防晒即可,无需叠加过多功效产品。', + link: function () { return ''; } }, + '混合偏干肌肤': { base: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。', + link: function (c) { return c.smooth && c.smooth.level === '预警' ? '干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理' : ''; } }, + '混合偏油肌肤': { base: '你的日常肤质为混合偏油肌肤:T区出油旺盛而两颊偏干,呈典型外油内干倾向。建议分区护理:T区使用控油清爽产品,两颊加强保湿;全脸避免高滋润面霜,可选用质地轻薄的保湿乳分区涂抹。', + link: function (c) { return c.tone && c.tone.level === '预警' ? '外油内干状态下切勿加大清洁力度,补水优先于控油' : ''; } } + }; + function skinTypeKey(name) { // 归一:用户自选五类名;未知/缺失按中性 + var keys = ['混合偏干肌肤', '混合偏油肌肤', '干性肌肤', '油性肌肤', '中性肌肤']; + for (var i = 0; i < keys.length; i++) if (name && name.indexOf(keys[i].replace('肌肤', '')) >= 0) return keys[i]; + return '中性肌肤'; + } + var CARE_POINTS = { // §8.7 护理要点库(6分类×3档,逐字;肤色水润=色沉+水分并列,先色沉后水分) + contour: { + '预警': '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。', + '改善': '加强弹韧护理补充胶原支架营养,关注易显疲态区域的支撑力,日常可增加紧致精华步骤并配合向上按摩。', + '良好': '维持现有抗老护理节奏,做好光损伤防护与状态管理,巩固真皮胶原网络密度。' + }, + wrinkles: { + '预警': '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。', + '改善': '加强日常淡纹与弹润护理,维持真皮基质含水量,保持肌肤饱满平滑。', + '良好': '以抗初老预防为核心,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。' + }, + smooth: { + '预警': '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。', + '改善': '加强基础清洁与角质调理,关注鼻翼、下颌等易粗糙部位,提升毛孔通畅度与柔滑度。', + '良好': '维持当前温和护理节奏,保护角质层排列与微循环稳态。' + }, + tone: { // 每档 = [色沉段, 水分段](并列输出,先色沉后水分) + '预警': ['强化透亮与色素代谢管理,严格广谱防晒,配合温和抗氧化成分(如维C衍生物)抑制黑色素转运。', '强化屏障锁水修复,停用控油类产品,改用含神经酰胺的封闭性面霜,每周2-3次补水面膜快速纠正缺水状态。'], + '改善': ['加强日常光泽管理与局部色素防控,做好基础防晒与抗氧化打底。', '注重水油平衡调理,选用清爽保湿乳液,避免过度清洁带走皮脂膜。'], + '良好': ['维持现有防护与护理习惯,巩固肤色均匀度与透亮感。', '维持现有补水习惯,巩固水合状态与水油平衡。'] + }, + sens: { + '预警': '优先屏障修复与舒缓管理,精简护肤流程,避免频繁更换方案与叠加功效成分,修复受损角质层。', + '改善': '保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。', + '良好': '维持规律护理并按季节阶段性调整,巩固皮肤耐受稳态。' + }, + acne: { // 痘痘:V1.2 无原文,V2.3 本版新撰 + '预警': '优先抗炎与痘肌修护,避免挤压与厚重型封闭产品,选用含水杨酸或壬二酸的清爽配方,局部点涂抗痘精华,必要时寻求皮肤科处理。', + '改善': '维持温和清洁与控油节奏,注意枕巾与手部卫生,避免叠加过多功效产品刺激皮损。', + '良好': '维持现有清洁控油习惯,保持作息与饮食规律,防止毛孔堵塞复发。' + } + }; + + /* ─── 主入口 ─── */ + function buildViewModel(apiResult, sideResults, profile) { + var r = apiResult || {}; + profile = profile || {}; + var notes = []; + var realAge = val(profile.realAge); + + // 仅展示年龄限制在实际年龄±5岁,原始肌龄继续用于产品推荐。 + var skinAge = val(r.skin_age); + var skinAgeDisplay = reportSkinAge({ skinAgeRaw: skinAge, realAge: realAge }); + var ageClampNote = skinAgeDisplay != null && skinAgeDisplay !== skinAge + ? '生物可视年龄已按实际年龄±5岁限制展示;接口原始肌龄保留用于推荐。' : ''; + if (realAge == null) { + notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄'); + } + + var skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质 + + // 6 分类 + var cats = {}, sList = [], i, def, res; + for (i = 0; i < CAT_DEFS.length; i++) { + def = CAT_DEFS[i]; + res = (def.key === 'contour' || def.key === 'acne') ? def.calc(r, sideResults, notes) : def.calc(r, notes); + cats[def.key] = { + name: def.name, dim: def.dim, + vars: res.vars, // 原始模板变量供新版展示层使用,不参与评分规则变更 + P: res.P, S: sShow(res.P), level: levelOf(res.P), + text: res.P == null + ? (res.vars.missing ? def.text(null, res.vars) : '本次检测' + def.name + '分类数据缺失,暂不参与评分。') + : def.text(res.P, res.vars) + }; + if (res.P != null) sList.push(sRaw(res.P)); + } + + // 三维度(木桶 min,缺项剔除) + var dims = {}; + for (i = 0; i < DIM_DEFS.length; i++) { + var d = DIM_DEFS[i], ps = [], j; + for (j = 0; j < CAT_DEFS.length; j++) { + if (CAT_DEFS[j].dim !== d.key) continue; + var P = cats[CAT_DEFS[j].key].P; + if (P != null) ps.push(P); + } + var dP = ps.length ? Math.min.apply(null, ps) : null; + if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分'); + dims[d.key] = { name: d.name, P: dP, S: capDimensionScore(sShow(dP)), level: levelOf(dP) }; + } + + // 均分(判档用未四舍五入原值)与最差分类(并列按 CAT_DEFS 顺序取前者) + var avgRaw = null, avgS = null; + if (sList.length) { + var sum = 0; + for (i = 0; i < sList.length; i++) sum += sList[i]; + avgRaw = sum / sList.length; + avgS = round2(avgRaw); + } + var worst = null; + for (i = 0; i < CAT_DEFS.length; i++) { + def = CAT_DEFS[i]; + var cP = cats[def.key].P; + if (cP == null) continue; + if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) }; + } + + var recommendation = recommendProduct(skinAgeDisplay, { + firm: dims.firm.P == null ? null : sRaw(dims.firm.P), + vita: dims.vita.P == null ? null : sRaw(dims.vita.P), + stab: dims.stab.P == null ? null : sRaw(dims.stab.P) + }); + var tier = recommendation.tier, triggers = recommendation.triggers; + var group = tier == null ? null : ['25+组', '35+组', '50+组'][tier - 1]; + var essence = { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name, + text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: triggers }; + if (tier == null) notes.push(recommendation.reason); + + // 近期优先关注:最差维度 + var focus = { name: '', tip: '' }, fP = null; + for (i = 0; i < DIM_DEFS.length; i++) { + var dd = DIM_DEFS[i], dP2 = dims[dd.key].P; + if (dP2 == null) continue; + if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; } + } + + // 综合文案(V2.2 §8.2 模板) + var summary; + if (avgRaw == null) { + summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。'; + } else { + var ageSeg = ''; + if (realAge != null) { + ageSeg = '实际年龄' + realAge + '岁'; + if (skinAge != null) ageSeg += '(接口测得肌肤年龄' + skinAge + '岁,差' + Math.abs(skinAge - realAge) + '岁)'; + } else if (skinAge != null) { + ageSeg = '接口测得肌肤年龄' + skinAge + '岁'; + } + var head = joinParts([ageSeg, group ? '产品组别' + group : ''], ','); + // 优先改善:P 最低两项 + var sorted = []; + for (i = 0; i < CAT_DEFS.length; i++) { + def = CAT_DEFS[i]; + if (cats[def.key].P != null) sorted.push({ name: def.name, P: cats[def.key].P }); + } + sorted.sort(function (a, b) { return a.P - b.P; }); + var improve = ''; + if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmtP(sorted[1].P) + '),其余维度维持现有护理节奏。'; + else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + '),其余维度维持现有护理节奏。'; + var tail = joinParts([ + skinTypeCn ? '整体肤质' + skinTypeCn : '', + oilDesc(r.oily_intensity) + ], ','); + summary = '【综合】' + (head ? head + ';' : '') + + '六分类S均分' + fmtS(avgRaw) + (worst ? '、最差分类' + worst.name + ' S=' + fmtS(sRaw(worst.P)) : '') + '。' + + (tier == null ? recommendation.reason : '按肤龄及边界规则(触发:' + triggers.join('、') + ')推荐' + essence.name) + + (improve ? ':' + improve : '。') + + (tail ? tail + '。' : ''); + } + + /* 护理建议(V2.3 §8.5 五段固定顺序:【肤质】【最差分类1】【最差分类2】【精华】【生活方式】; + §8.5.1 输出纪律:段内不得出现 P/S 数值、档位名、判据、字段名等内部标注) */ + var careAdvice = []; + var stDef = SKIN_TYPE_TEXT[skinTypeKey(profile.skinType)]; + var linkSeg = stDef.link(cats); + careAdvice.push({ tag: '肤质', text: stDef.base + (linkSeg ? linkSeg + '。' : '') }); + if (sorted && sorted.length >= 2) { + for (i = 0; i < 2; i++) { + var ck = CAT_DEFS.filter(function (d) { return d.name === sorted[i].name; })[0].key; + var lv2 = cats[ck].level || '良好', pts = CARE_POINTS[ck][lv2]; + careAdvice.push({ tag: sorted[i].name, text: Array.isArray(pts) ? pts.join('') : pts }); + } + } + careAdvice.push({ tag: '精华', text: essence.text }); + careAdvice.push({ tag: '生活方式', text: worst ? LIFESTYLE[worst.catKey] : '' }); + + return { + total: weightedTotal(dims), + totalRule: 'dimensions-433-v1', + vitaScoreRule: 'combined-tone-smooth-min-v2', + wrinkleScoreRule: 'api-overall-v1', + acneScoreRule: 'three-view-type-max-v1', + skinAgeRaw: skinAge, + skinAgeDisplay: skinAgeDisplay, + ageClampNote: ageClampNote, + realAge: realAge, + dims: dims, + cats: cats, + group: group, + essence: essence, + recommendation: recommendation, + avgS: avgS, + worst: worst, + focus: focus, + summary: summary, + lifestyle: worst ? LIFESTYLE[worst.catKey] : '', + careAdvice: careAdvice, + notes: notes + }; + } + + /* ─── Mock 数据(pro 形状;数值对齐测试文档「图片 13」案例) ─── */ + var MOCK_RESULT = { + score_info: { + total_score: 79, + wrinkle_score: 23, // Keep this synthetic fixture's other baseline results stable. + water_score: 83, + rough_score: 87, + sensitivity_score: 98, + melanin_score: 80, + blackhead_score: 88, + acne_score: 86, + oily_intensity_score: 42, + pores_type_score: { // §3.1:嵌套在 score_info 内 + pores_forehead_score: 73, pores_left_cheek_score: 86, + pores_right_cheek_score: 85, pores_jaw_score: 90, + pores_forehead_severity: 1, pores_left_cheek_severity: 1, + pores_right_cheek_severity: 1, pores_jaw_severity: 1 + } + }, + skin_age: { value: 20 }, + skin_type: { skin_type: 3 }, + oily_intensity: { // 真实形状:分区 {area(小数),intensity},{oil_desc} 数据源(数值对齐案例1) + t_zone: { area: 0.57, intensity: 2 }, left_cheek: { area: 0.2, intensity: 1 }, + right_cheek: { area: 0.02, intensity: 0 }, chin_area: { area: 0, intensity: 0 }, + full_face: { area: 0.4, intensity: 2 } + }, + eye_pouch: { value: 0, confidence: 0.75 }, + eye_pouch_severity: { value: 0 }, + dark_circle: { value: 1, confidence: 0.63 }, + wrinkle_count: { value: 11 }, + forehead_wrinkle_info: { wrinkle_score: 20, wrinkle_severity_level: 1 }, + glabella_wrinkle_info: { wrinkle_score: 15, wrinkle_severity_level: 1 }, + left_eye_wrinkle_info: { wrinkle_score: 30, wrinkle_severity_level: 1 }, + right_eye_wrinkle_info: { wrinkle_score: 25, wrinkle_severity_level: 1 }, + left_crowsfeet_wrinkle_info: { wrinkle_score: 40, wrinkle_severity_level: 2 }, + right_crowsfeet_wrinkle_info: { wrinkle_score: 35, wrinkle_severity_level: 1 }, + left_nasolabial_wrinkle_info: { wrinkle_score: 60, wrinkle_severity_level: 2 }, + right_nasolabial_fold_info: { wrinkle_score: 77, wrinkle_severity_level: 3 }, // _fold_info 变体 + left_mouth_wrinkle_info: { wrinkle_score: 45, wrinkle_severity_level: 2 }, + right_mouth_wrinkle_info: { wrinkle_score: 50, wrinkle_severity_level: 2 }, + left_cheek_wrinkle_info: { wrinkle_score: 10, wrinkle_severity_level: 0 }, + right_cheek_wrinkle_info: { wrinkle_score: 12, wrinkle_severity_level: 0 }, + water: { water_score: 83, water_area: 15.0, water_severity: 12 }, + sensitivity: { sensitivity_score: 98, sensitivity_area: 0.001, sensitivity_intensity: 30 }, // 0.001=小数口径→0.1% + melasma: { value: 0 }, + freckle: { value: 0 }, + skintone_ita: { ITA: 71.8 }, + skin_hue_ha: { HA: 2 }, + enlarged_pore_count: { forehead: 150, left_cheek: 66, right_cheek: 50, jaw: 30 }, // 合计 296 + blackhead: { count: 39, rectangle: [] }, + closed_comedones: { count: 3, rectangle: [{}, {}, {}] }, + acne: { count: 3, rectangle: [{}, {}, {}] }, // 丘疹 + acne_pustule: { count: 1, rectangle: [{}] }, + acne_nodule: { count: 0, rectangle: [] }, + acne_mark: { count: 1, rectangle: [{}] }, + mole: { count: 2, rectangle: [{}, {}] } + }; + var MOCK_SIDE = { + left: { left_jawline_info: { left_jawline_angle: 117.2, left_jawline_angle_level: 0, left_jawline_quality: 1 } }, + right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8 + }; + + // Apply the current display rule to new and historical reports without + // modifying their raw age or stored snapshot. Recommendation uses this age. + function reportSkinAge(vm, result) { + var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age); + if (!isNum(raw) || raw < 0) return null; + var actual = val(vm && vm.realAge); + if (!isNum(actual) || actual < 0) return raw; + return Math.max(actual - 5, Math.min(actual + 5, raw)); + } + function withDisplayRecommendation(vm, result) { + if (!vm) return vm; + var age = reportSkinAge(vm, result), scores = {}; + ['firm','vita','stab'].forEach(function(key) { + var d = vm.dims && vm.dims[key]; + var saved = vm.recommendation && vm.recommendation.scores && vm.recommendation.scores[key]; + scores[key] = isNum(saved) ? saved : d && isNum(d.P) ? sRaw(d.P) : d && isNum(d.S) ? d.S : null; + }); + var recommendation = recommendProduct(age, scores), tier = recommendation.tier; + return Object.assign({}, vm, { skinAgeDisplay: age, recommendation: recommendation, + group: tier == null ? null : ['25+组','35+组','50+组'][tier-1], + essence: { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name, + text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: recommendation.triggers } }); + } + return { + withDisplayRecommendation: withDisplayRecommendation, + capDimensionScore: capDimensionScore, + weightedTotal: weightedTotal, + reportSkinAge: reportSkinAge, + buildViewModel: buildViewModel, + recommendProduct: recommendProduct, + MOCK_RESULT: MOCK_RESULT, + MOCK_SIDE: MOCK_SIDE + }; +})); + +/* ─── Node 自测:node h5/js/skin-score.js ─── */ +if (typeof require === 'function' && typeof module !== 'undefined' && require.main === module) { + (function () { + var SS = module.exports; + var pass = 0, fail = 0; + function ok(cond, msg) { + if (cond) { pass++; console.log(' ✓ ' + msg); } + else { fail++; console.error(' ✗ FAIL: ' + msg); } + } + function approx(a, b) { return a != null && Math.abs(a - b) < 1e-9; } + function clone(o) { return JSON.parse(JSON.stringify(o)); } + function collectTexts(vm) { // 收集 viewModel 全部面向用户的字符串 + var out = [], k; + out.push(vm.summary, vm.lifestyle, vm.ageClampNote, vm.focus.name, vm.focus.tip, vm.essence.name, vm.essence.text); + for (k in vm.cats) out.push(vm.cats[k].text); + for (k = 0; k < vm.notes.length; k++) out.push(vm.notes[k]); + return out; + } + var BAD = /None|undefined|NaN|null|—|\{[a-z_0-9]+\}/i; + function checkTexts(vm, tag) { + var ts = collectTexts(vm), bad = []; + for (var i = 0; i < ts.length; i++) if (BAD.test(ts[i])) bad.push(ts[i]); + ok(bad.length === 0, tag + ':话术无 None/undefined/NaN/null/—/未替换占位' + (bad.length ? ' → ' + bad[0] : '')); + } + + console.log('== T0 MOCK 全量(数值对拍测试文档「图片 13」) =='); + var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' }); + ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)'); + ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)'); + ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P直接使用整体评分23,与最差分区77无关'); + ok(approx(vm.cats.contour.P, 94.8), '轮廓P = min(眼袋100, 下颌线较差侧94.8) = 94.8'); + ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)'); + ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85'); + ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)'); + ok(vm.dims.firm.P === 23 && vm.dims.vita.P === Math.min(vm.cats.smooth.P,vm.cats.tone.P) && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 平滑度与肤色水润最低分 / 稳定力85'); + ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制'); + ok(vm.group === '25+组', '原始肌龄20 → 25+组'); + ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1, + '肌龄小于32,即使皱纹低分也固定25+,不再使用最差项升档'); + ok(vm.avgS === 9.32, '六分类S均分 9.32'); + ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹'); + ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力'); + ok(vm.total === 88, '综合得分 = round(7.69×4+9.45×3+9.55×3) = 88,不使用接口总分79'); + ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('25+束紧精华') > 0, '综合文案生成'); + ok(vm.cats.smooth.text.indexOf('粗大毛孔296颗、黑头39颗') >= 0 && vm.cats.smooth.text.indexOf('纹理分87') >= 0, + '光滑话术含粗大毛孔/黑头/纹理分变量(分区计数 4 区求和 296)'); + checkTexts(vm, 'T0'); + + console.log('== T1 上限锁 =='); + var r1 = clone(SS.MOCK_RESULT); + var rk; + for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95 + r1.right_nasolabial_fold_info.wrinkle_severity_level = 3; + r1.score_info.wrinkle_score = 95; + ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=3 限分'); + r1.right_nasolabial_fold_info.wrinkle_severity_level = 2; + ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=2 限分'); + var r1b = clone(SS.MOCK_RESULT); + r1b.score_info.pores_type_score.pores_forehead_severity = 2; // 自然 81.7 + ok(SS.buildViewModel(r1b, SS.MOCK_SIDE, { realAge: 25 }).cats.smooth.P === 79, '毛孔分区 severity=2 → 光滑锁 79'); + checkTexts(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }), 'T1'); + + console.log('== T2 木桶 min(显式构造) =='); + var vm2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 40 }); + ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P) + && vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P) + && vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)'); + ok(vm2.group === '35+组', '实际年龄40、原始肌龄20、展示肌龄35,按展示肌龄推荐35+组'); + + console.log('== T3 原始肌龄展示 =='); + var r3 = clone(SS.MOCK_RESULT); + r3.skin_age = { value: 24 }; + var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕'); + r3.skin_age = { value: 40 }; + var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm3b.skinAgeDisplay === 30 && vm3b.ageClampNote !== '' && vm3b.skinAgeRaw === 40, '实际年龄25、原始肌龄40:展示30,原值不改变'); + + console.log('== T4 最差项不再覆盖肤龄推荐 =='); + var r4 = clone(SS.MOCK_RESULT); + r4.score_info.melanin_score = 0; + r4.score_info.water_score = 0; + r4.melasma = { value: 1 }; + r4.freckle = { value: 1 }; + r4.water = { water_score: 0, water_area: 40, water_severity: 70 }; // sev≥60 但 score<80,不触发换算 + var vm4 = SS.buildViewModel(r4, SS.MOCK_SIDE, { realAge: 25 }); + ok(approx(vm4.cats.tone.P, -7.5), '肤色水润P = 0.5×(0−10−5)+0.5×0 = −7.5(P 原值不动,不夹逼)'); + ok(vm4.cats.tone.S === 7, '展示层 S 钳到7,评分公式不变'); + ok(vm4.essence.name === '25+束紧精华', '原始肌龄20,即使最差项S<7也不升档'); + checkTexts(vm4, 'T4'); + + console.log('== T5 缺数据降级(V2.2 第十章异常与边界) =='); + var r5 = clone(SS.MOCK_RESULT); + delete r5.sensitivity; + delete r5.score_info.sensitivity_score; + var vm5 = SS.buildViewModel(r5, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm5.cats.sens.P === null && vm5.cats.sens.text.indexOf('缺失') >= 0, 'sensitivity 缺失 → 敏感 P=null + 数据缺失提示(不编假分数)'); + ok(vm5.dims.stab.P === 85, '稳定力剔除敏感项 = 痘痘P 85'); + var hasSensNote = false, k; + for (k = 0; k < vm5.notes.length; k++) if (vm5.notes[k].indexOf('sensitivity') >= 0) hasSensNote = true; + ok(hasSensNote, 'notes 记录 sensitivity 缺失降级'); + ok(vm5.avgS === 9.2, '均分剔除敏感项按 5 分类计算(未舍入 9.196 → 展示 9.2)'); + var vm5b = SS.buildViewModel(clone(SS.MOCK_RESULT), null, { realAge: 25 }); + ok(vm5b.cats.contour.P === 100 && vm5b.notes.join('|').indexOf('侧脸') >= 0, '无侧脸 → 轮廓 P 由眼袋单独决定并记 note'); + var vm5c = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, {}); + ok(vm5c.group === '25+组' && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === '' + && vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0, + '实际年龄缺失不钳制,但原始肌龄仍可推荐25+'); + checkTexts(vm5, 'T5'); + + console.log('== T6 下颌线 quality 降级 =='); + var vm6 = SS.buildViewModel(clone(SS.MOCK_RESULT), { + left: { left_jawline_info: { left_jawline_angle: 100, left_jawline_angle_level: 1, left_jawline_quality: 3 } }, + right: { right_jawline_info: { right_jawline_angle: 118, right_jawline_angle_level: 0, right_jawline_quality: 1 } } + }, { realAge: 25 }); + ok(vm6.cats.contour.P === 100, '左 quality=3 不参与,右 118° 标准 → 轮廓 P=min(100,100)=100'); + ok(vm6.notes.join('|').indexOf('左侧脸角度无效(quality=3)') >= 0, 'notes 含「左侧脸角度无效(quality=3)」'); + + console.log('== T7 AR列数值对拍(第二条) =='); + var vm7 = SS.buildViewModel({ + score_info: { sensitivity_score: 30 }, + sensitivity: { sensitivity_score: 30, sensitivity_area: 0.103 } // 图片37:10.3% + }, null, { realAge: 29 }); + ok(vm7.cats.sens.P === 30, '敏感P = min(30, 100−10.3×1.5=84.55) = 30(AR列对拍)'); + + console.log('== T8 count 缺失退化 rectangle 长度 =='); + var r8 = clone(SS.MOCK_RESULT); + r8.acne_pustule = { rectangle: [{}, {}] }; // 无 count → 2 个脓包 + var vm8 = SS.buildViewModel(r8, SS.MOCK_SIDE, { realAge: 25 }); + // 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85 + ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)'); + ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列'); + var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处,改善档 + ok(vm8b.cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),以闭口粉刺、丘疹为主') >= 0 + && vm8b.cats.acne.text.indexOf('含脓包、结节') < 0, '改善档逐字(V2.2 第七章无 heavy_note 分句)'); + var r8c = clone(SS.MOCK_RESULT); + r8c.score_info.acne_score = 70; // 分级映射 → 55,预警档 + ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('检出8处皮损(闭口粉刺3处、丘疹3处、脓包1处、痘印1处)。建议尽快进行针对性抗痘护理') >= 0, + '预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议尽快…(V2.2 第七章)'); + + console.log('== T9 真实接口字段形状(对齐 reference/facepp-skinanalyze/examples 样本) =='); + // (5)(9) 毛孔分区无下划线变体键 + 无 severity 键 + var r9 = clone(SS.MOCK_RESULT); + r9.score_info.pores_type_score = { pores_forehead_score: 60, pores_leftcheek_score: 50, pores_rightcheek_score: 72, pores_jaw_score: 84 }; + var vm9 = SS.buildViewModel(r9, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9.cats.smooth.text.indexOf('左侧面颊') >= 0 && vm9.cats.smooth.text.indexOf('leftcheek') < 0, + '毛孔分区无下划线变体 pores_leftcheek_score → 中文「左侧面颊」,话术无英文键名残留'); + ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」'); + checkTexts(vm9, 'T9-毛孔'); + + // (6) 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1 字段映射) + var r9b = clone(SS.MOCK_RESULT); + r9b.red_spot = { red_spot_area: 0.006, red_spot_intensity: 3 }; // 小数口径 → 0.6% + r9b.sensitivity.sensitivity_area = 0.5; // 若干扰字段被误读会变 50% → 锁 74 + var vm9b = SS.buildViewModel(r9b, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9b.cats.sens.P === 98 && vm9b.cats.sens.text.indexOf('泛红面积0.6%') >= 0, + '敏感面积优先 red_spot.red_spot_area:0.006×100=0.6%,P=min(98, 100−0.9)=98(不回退 sensitivity_area=0.5)'); + ok(SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }).cats.sens.P === 98, + 'red_spot 缺失 → 回退 sensitivity.sensitivity_area(0.001 小数口径→0.1%,P=98)'); + + // (7) wrinkle_count/fine_line 为分区计数对象(真实样本形状,无 .value) + var r9d = clone(SS.MOCK_RESULT); + r9d.wrinkle_count = { forehead_count: 22, left_undereye_count: 58, right_undereye_count: 44, glabella_count: 14, left_crowsfeet_count: 10, right_crowsfeet_count: 7, left_mouth_count: 1, right_mouth_count: 1, left_nasolabial_count: 1, right_nasolabial_count: 1, left_cheek_count: 0, right_cheek_count: 0 }; + r9d.fine_line = { forehead_count: 18, left_undereye_count: 57, right_undereye_count: 46, glabella_count: 9, left_crowsfeet_count: 11, right_crowsfeet_count: 9, left_cheek_count: 0, right_cheek_count: 0 }; + var vm9d = SS.buildViewModel(r9d, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9d.cats.wrinkles.text.indexOf('全脸皱纹159条') >= 0 && vm9d.cats.wrinkles.text.indexOf('细纹150条') >= 0, + 'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条'); + checkTexts(vm9d, 'T9-皱纹计数'); + + // (8) 肤色分类真实键名 skintone_ita.skintone:枚举→中文(0→极白),ITA/HA 真值表口径 1 位小数 + var r9e = clone(SS.MOCK_RESULT); + r9e.skintone_ita = { ITA: 58.64615, skintone: 0 }; + r9e.skin_hue_ha = { HA: 56.246513, skin_hue: 0 }; + var vm9e = SS.buildViewModel(r9e, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.6(极白,HA 56.2)') >= 0, 'skintone_ita.skintone=0 → 中文「极白」,ITA/HA 各 1 位小数(58.64615→58.6、56.246513→56.2)'); + + // (14) {spot_flag} 褐色斑/痣续接(V2.2 §7.2):基句 + 「,检出褐色斑N处」「、痣N处」,为 0 省略 + var vm9e2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // MOCK:色斑均未检出、mole=2 + ok(vm9e2.cats.tone.text.indexOf('(未见明显色斑标记、痣2处)') >= 0, '{spot_flag} 均未检出 + 痣2处 → 「未见明显色斑标记、痣2处」'); + var r9e3 = clone(SS.MOCK_RESULT); + r9e3.melasma = { value: 1 }; + r9e3.brown_spot = { count: 23 }; + r9e3.mole = { count: 1 }; + ok(SS.buildViewModel(r9e3, SS.MOCK_SIDE, { realAge: 25 }).cats.tone.text.indexOf('(黄褐斑,检出褐色斑23处、痣1处)') >= 0, + '{spot_flag} 黄褐斑 + 褐色斑23处 + 痣1处 → 案例2 真值句式'); + + // (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.2 第七章逐字) + var r9f = clone(SS.MOCK_RESULT); + r9f.eye_pouch_severity = { value: 1.6 }; + var vm9f = SS.buildViewModel(r9f, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9f.cats.contour.P === 79 && vm9f.cats.contour.text.indexOf('眼袋2级') >= 0, + '眼袋 severity 非整数 1.6 → 取整 2 → 映射 79(min(79, 下颌线94.8),预警档)'); + var vm9f2 = SS.buildViewModel(clone(SS.MOCK_RESULT), { // 下颌线 117°/110° → jawP=88 → P=88 改善档 + left: { left_jawline_info: { left_jawline_angle: 117, left_jawline_quality: 1 } }, + right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } } + }, { realAge: 25 }); + ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0, + '轮廓改善档含「眼袋0级」且无「黑眼圈」(V2.2 第七章改善档原文无黑眼圈)'); + var r9g = clone(SS.MOCK_RESULT); + r9g.eye_pouch_severity = { value: 9 }; + var vm9g = SS.buildViewModel(r9g, SS.MOCK_SIDE, { realAge: 25 }); + ok(approx(vm9g.cats.contour.P, 94.8) && vm9g.notes.join('|').indexOf('眼袋 severity 越界(9)') >= 0, + '眼袋 severity 越界 9 → 按缺失处理记 note,轮廓 P 仅由下颌线 94.8 决定'); + checkTexts(vm9f, 'T9-眼袋'); + + // (13) 痘痘良好档计数全缺失 → 缺数据提示句,不声称「未检出」 + var r9h = clone(SS.MOCK_RESULT); + delete r9h.closed_comedones; delete r9h.acne; delete r9h.acne_pustule; delete r9h.acne_nodule; delete r9h.acne_mark; + r9h.score_info.acne_score = 96; // 分级映射 100 → 良好档 + var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0, + '痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)'); + // 预警档「检出N处皮损(明细)」为固定首句,明细为 0 的类型不列(真实样本形状:丘疹3+闭口6) + var r9i = clone(SS.MOCK_RESULT); + r9i.acne_pustule = { count: 0, rectangle: [] }; + r9i.acne_mark = { count: 0, rectangle: [] }; + r9i.score_info.acne_score = 70; // 分级映射 55 → 预警档 + var vm9i = SS.buildViewModel(r9i, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm9i.cats.acne.text.indexOf('检出6处皮损(闭口粉刺3处、丘疹3处)。建议尽快') >= 0, + '痘痘预警档 → 计数明细正常输出(为0类型不列)'); + + console.log('== T11 水润 severity 换算触发(V2.2 §3 交叉校验) =='); + var r11 = clone(SS.MOCK_RESULT); + r11.score_info.water_score = 85; + r11.water = { water_area: 10, water_severity: 70 }; // sev≥60 且 score≥80 → 水润项 = 100−70 = 30 + var vm11 = SS.buildViewModel(r11, SS.MOCK_SIDE, { realAge: 25 }); + ok(approx(vm11.cats.tone.P, 55), '肤色水润P = 0.5×80+0.5×30 = 55(water_severity=70 且 water_score=85 → 水润项按 severity 换算)'); + ok(vm11.notes.join('|').indexOf('水润项按 severity 换算为 30') >= 0, 'notes 记录水分交叉校验触发'); + ok(vm4.cats.tone.P === -7.5, '对照:T4 sev=70 但 score=0<80 → 不触发换算(P=−7.5 不变)'); + + console.log('== T12 {oil_desc} 与 {skin_type_cn}(V2.2 §7.2/§8.2) =='); + var vm12 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); + ok(vm12.cats.smooth.text.indexOf('T区出油面积57%、左右脸颊20%/2%,纹理分87') >= 0, + '{oil_desc} = T区出油面积57%、左右脸颊20%/2%(oily_intensity 0.57/0.2/0.02 小数×100)'); + ok(vm12.summary.indexOf('整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。') >= 0, + '综合文案尾句 = 案例1 真值句式(skin_type=3→混合性 + oil_desc 无尾逗号)'); + var r12b = clone(SS.MOCK_RESULT); + r12b.oily_intensity.full_face.intensity = 0; + var vm12b = SS.buildViewModel(r12b, SS.MOCK_SIDE, { realAge: 25 }); + ok(vm12b.cats.smooth.text.indexOf('出油') < 0 && vm12b.summary.indexOf('出油') < 0, '全脸 intensity=0 → {oil_desc} 整体省略'); + var r12c = clone(SS.MOCK_RESULT); + delete r12c.skin_type; + ok(SS.buildViewModel(r12c, SS.MOCK_SIDE, { realAge: 25, skinType: '油性肌肤' }).summary.indexOf('整体肤质油性肌肤') >= 0, + 'skin_type 缺失 → {skin_type_cn} 回退用户自选肤质'); + checkTexts(vm12, 'T12'); + + console.log('== T10 真值对拍:案例1 完整原始返回 vs 真值表(xlsx 第3行/V2.2 实测) =='); + (function () { + var raw = null, fs; + try { + fs = require('fs'); + var paths = [__dirname + '/../../reference/facepp-skinanalyze/examples/ref_3view_raw.json', + 'reference/facepp-skinanalyze/examples/ref_3view_raw.json']; + for (var i = 0; i < paths.length; i++) { try { raw = fs.readFileSync(paths[i], 'utf8'); break; } catch (e) {} } + } catch (e) {} + if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; } + var d = JSON.parse(raw); + // Legacy reference assertions isolate unrelated calculations/copy. The + // new scalar source/rule is covered by test/wrinkle-overall.js. + d.result.score_info.wrinkle_score = 23; + d.result.score_info.dark_circle_score = d.result.score_info.melanin_score - ((d.result.melasma || {}).value || 0) * 10 - ((d.result.freckle || {}).value || 0) * 5; + var vm = SS.buildViewModel(d.result, { left: d.left_side_result, right: d.right_side_result }, { realAge: 30 }); + ok(approx(vm.cats.contour.P, 75.5) && vm.cats.contour.S === 9.27 && vm.cats.contour.level === '预警', + '轮廓 P=75.5 / S=9.27 / 预警(min(眼袋1级→90, 下颌线较差侧103.75°→75.5))'); + ok(vm.cats.wrinkles.P === 23 && vm.dims.firm.P === 23 && vm.dims.firm.S === 7.69, '皱纹 P=23,紧塑力木桶 23 / S=7.69'); + ok(approx(vm.cats.smooth.P, 57.2) && vm.dims.vita.P === 57.2 && vm.dims.vita.S === 8.72, '光滑 P=57.2,活力感 57.2 / S=8.72'); + ok(vm.cats.tone.P === 85, '肤色水润 P = 0.5×92+0.5×78 = 85'); + ok(vm.cats.sens.P === 99 && vm.cats.acne.P === 70 && vm.dims.stab.P === 70 && vm.dims.stab.S === 9.1, + '敏感 99 / 痘痘 70(min(分级70, 100−12)),稳定力 70 / S=9.1'); + ok(vm.avgS === 9.05 && vm.worst.catKey === 'wrinkles' && vm.group === '25+组' + && vm.essence.name === '25+束紧精华' && vm.essence.triggers.join('、') === '肤龄基础分组', + '评分不变,原始肌龄27推荐25+,不再因最差皱纹升档'); + ok(vm.skinAgeRaw === 27 && vm.skinAgeDisplay === 27 && vm.ageClampNote === '', '肤龄 27(实际30)未触发钳制、原值不改写'); + ok(vm.cats.contour.text === '下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。', + '轮廓文案逐字 = 真值表(黑眼圈取 dark_circle_severity=1,非 dark_circle=3)'); + ok(vm.cats.wrinkles.text === '全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。', + '皱纹文案逐字 = 真值表'); + ok(vm.cats.smooth.text === '皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。', + '光滑文案逐字 = 真值表'); + ok(vm.cats.tone.text === '肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。', + '肤色水润文案逐字 = 真值表'); + ok(vm.cats.sens.text === '泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', + '敏感文案逐字 = 真值表'); + ok(vm.cats.acne.text === '检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。', + '痘痘文案逐字 = 真值表(无 heavy_note)'); + ok(vm.summary === '【综合】实际年龄30岁(接口测得肌肤年龄27岁,差3岁),产品组别25+组;六分类S均分9.05、最差分类皱纹 S=7.69。按肤龄及边界规则(触发:肤龄基础分组)推荐25+束紧精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。', + '综合文案仅产品判据按2026-10-12规则替换,其余逐字不变'); + ok(vm.lifestyle === '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', '生活方式 = 最差分类皱纹条'); + }()); + + console.log('----------------------------------------'); + console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败'); + if (fail > 0) process.exit(1); + }()); +}