feat(rednote): add isolated persistent browser H5 entry

This commit is contained in:
xsl
2026-10-19 22:59:10 +08:00
parent 9eec4369da
commit 4af825a5e4
24 changed files with 707 additions and 45 deletions
+24 -12
View File
@@ -20,8 +20,10 @@ const PORT = Number(process.env.PORT) || 8080;
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
const HOST = process.env.HOST || '127.0.0.1';
const ROOT = __dirname;
const entry = require('./lib/entry-config')(process.env, ROOT);
const providerFetch = entry.rednote ? require('./lib/provider-gate')((...args) => fetch(...args)) : (...args) => fetch(...args);
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
const DATA_ROOT = process.env.JUZI_DATA_ROOT ? path.resolve(process.env.JUZI_DATA_ROOT) : path.join(ROOT, 'data');
const DATA_ROOT = entry.dataRoot;
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
@@ -34,7 +36,7 @@ const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
dir:path.join(DATA_ROOT,'wrinkle-jobs'),
collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
})),
load(owner,id) {
const snapshot=accounts.reportSnapshot(owner,id);
@@ -116,6 +118,9 @@ function serveStatic(req, res, pathname) {
if (file === DATA_ROOT || file.startsWith(DATA_ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
if (entry.rednote && ['index.html', 'admin.html'].some(name => file === path.join(ROOT, name))) {
data = Buffer.from(require('./lib/rednote-view')(data.toString('utf8'), path.basename(file) === 'admin.html'));
}
const ext = path.extname(file);
// Revalidate every time: updates remain immediate; unchanged resources use
// 304 instead of downloading again. APIs/private reports never enter here.
@@ -360,7 +365,7 @@ async function runDetect(rec, front, left, right, persist = true, deferWrinkles
const fpJson = await callFacepp(front, left, right);
if (fpJson.result && !fpJson.error_message && !deferWrinkles) {
fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,
gapMs:process.env.NODE_ENV==='test'?0:4000,
// Legacy admin retries may still collect inline; customer reports use
// deferred tasks instead. Missing side maps never fail a report.
@@ -404,7 +409,7 @@ async function callFacepp(front, left, right) {
for (let attempt = 0; attempt < 2; attempt++) {
fetchErr = null; fpRes = null; fpJson = null;
try {
fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
fpRes = await providerFetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
fpJson = await fpRes.json().catch(() => ({}));
} catch (e) { // 60s 超时(AbortError)/网络异常:无 fpJson,与 5xx/并发超限统一走「等 4s 重试 1 次」
fetchErr = e;
@@ -432,8 +437,8 @@ async function handleAnalyze(req, res, miniProgram = false, preparedBody = null,
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
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);
const channel = miniProgram ? 'taobao' : entry.channel;
persist = ['wechat', 'taobao', 'browser_persistent'].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 : '');
@@ -572,9 +577,10 @@ function sendJson(res, status, obj) {
/* 记录里照片的在线 URL(有存才给) */
function photoUrls(rec) {
const urls = {};
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
const prefix = entry.rednote ? '/rednote' : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`;
return urls;
}
@@ -941,9 +947,10 @@ async function handleAdmin(req, res, pathname, url) {
}
}
let rednoteAnalyses = 0;
http.createServer(async (req, res) => {
const pathname = req.url.split('?')[0];
if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname);
if (pathname.startsWith('/api/taobao/')) return entry.rednote ? sendJson(res, 404, { ok: false }) : 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);
@@ -962,10 +969,15 @@ http.createServer(async (req, res) => {
return sendJson(res, accepted ? 200 : 400, { ok: accepted });
} catch (e) { return sendJson(res, e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500, { ok: false }); }
}
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'POST' && pathname === '/api/skin-analyze') {
if (!entry.rednote) return handleAnalyze(req, res);
if (rednoteAnalyses) return sendJson(res, 429, { ok: false, code: 'BUSY', message: '当前检测人数较多,请稍后重试' });
rednoteAnalyses++;
try { return await handleAnalyze(req, res); } finally { rednoteAnalyses--; }
}
if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
return res.end(JSON.stringify(entry.rednote ? { ok: true, channel: entry.channel } : { ok: true }));
}
if (pathname.startsWith('/api/admin/')) {
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));