const fs = require('fs') const path = require('path') const Database = require('better-sqlite3') const axios = require('axios') const crypto = require('crypto') const dataDir = path.join(process.cwd(), 'data') if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }) const dbPath = path.join(dataDir, 'style_check.db') const db = new Database(dbPath) db.pragma('journal_mode = WAL') db.pragma('synchronous = NORMAL') const configDir = path.join(process.cwd(), 'config') if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }) const configPath = path.join(configDir, 'style_check.json') if (!fs.existsSync(configPath)) { fs.writeFileSync(configPath, JSON.stringify({ profile: { nickname: '上海通勤风', city: '上海', height_cm: 176, weight_kg: 65, style_keywords: '简约质感,运动休闲融合,低饱和度', avoid_keywords: '西服,大logo,荧光色,夸张印花' }, ai: { enabled: true, api_key: '', api_base: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', vision_model: 'qwen-vl-plus', timeout_ms: 30000 } }, null, 2)) } const readConfig = () => { try { return JSON.parse(fs.readFileSync(configPath, 'utf-8')) } catch { return {} } } db.exec(` CREATE TABLE IF NOT EXISTS user_profile ( id INTEGER PRIMARY KEY CHECK (id = 1), nickname TEXT, city TEXT, height_cm INTEGER, weight_kg INTEGER, style_keywords TEXT, avoid_keywords TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS mirror_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_group TEXT NOT NULL, rule_text TEXT NOT NULL, weight INTEGER NOT NULL DEFAULT 10, active_flag INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS outfit_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, scene TEXT NOT NULL, temp_zone TEXT NOT NULL, top_desc TEXT NOT NULL, bottom_desc TEXT NOT NULL, shoes_desc TEXT NOT NULL, outer_desc TEXT DEFAULT '', accessory_desc TEXT DEFAULT '', notes TEXT DEFAULT '', active_flag INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS mirror_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, check_date TEXT NOT NULL, scene TEXT NOT NULL, temp_zone TEXT NOT NULL, total_score INTEGER NOT NULL, result_level TEXT NOT NULL, issues_json TEXT DEFAULT '[]', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS apparel_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_name TEXT NOT NULL, slot TEXT NOT NULL, category TEXT NOT NULL, temp_zone TEXT NOT NULL DEFAULT 'ALL', season_tag TEXT DEFAULT '', brand_options TEXT DEFAULT '', price_min INTEGER, price_max INTEGER, keywords TEXT DEFAULT '', notes TEXT DEFAULT '', active_flag INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS outfit_item_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, outfit_id INTEGER NOT NULL, item_id INTEGER NOT NULL, slot TEXT NOT NULL, rank_no INTEGER NOT NULL DEFAULT 1, match_type TEXT NOT NULL DEFAULT 'auto', confidence INTEGER NOT NULL DEFAULT 0, matched_keyword TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')), UNIQUE(outfit_id, item_id, slot) ); CREATE TABLE IF NOT EXISTS ai_audits ( id INTEGER PRIMARY KEY AUTOINCREMENT, scene TEXT NOT NULL, temp_zone TEXT NOT NULL, image_hash TEXT DEFAULT '', total_score INTEGER NOT NULL DEFAULT 0, result_level TEXT NOT NULL DEFAULT 'warn', summary TEXT DEFAULT '', features_json TEXT DEFAULT '{}', rules_json TEXT DEFAULT '{}', library_json TEXT DEFAULT '{}', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS apparel_item_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, profile_key TEXT NOT NULL DEFAULT 'mature_relaxed', image_url TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'web_seed', caption TEXT DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), UNIQUE(item_id, image_url) ); CREATE INDEX IF NOT EXISTS idx_style_outfit_scene_temp ON outfit_templates(scene, temp_zone, active_flag); CREATE INDEX IF NOT EXISTS idx_style_check_date ON mirror_checks(check_date); CREATE INDEX IF NOT EXISTS idx_style_apparel_slot_temp ON apparel_items(slot, temp_zone, active_flag); CREATE INDEX IF NOT EXISTS idx_style_links_outfit_slot ON outfit_item_links(outfit_id, slot, rank_no); CREATE INDEX IF NOT EXISTS idx_style_ai_created ON ai_audits(created_at); CREATE INDEX IF NOT EXISTS idx_style_item_images_item ON apparel_item_images(item_id, profile_key, sort_order); CREATE TABLE IF NOT EXISTS wave_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_url TEXT NOT NULL, source_url TEXT, evaluation TEXT DEFAULT '', notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')), UNIQUE(source_url) ); CREATE TABLE IF NOT EXISTS korean_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_url TEXT NOT NULL, source_url TEXT, evaluation TEXT DEFAULT '', notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')), UNIQUE(source_url) ); `) const ensureColumn = (tableName, columnName, sql) => { const cols = db.prepare(`PRAGMA table_info(${tableName})`).all() const has = cols.some(c => String(c.name) === String(columnName)) if (has) return db.exec(sql) } ensureColumn('mirror_rules', 'profile_key', `ALTER TABLE mirror_rules ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`) ensureColumn('outfit_templates', 'profile_key', `ALTER TABLE outfit_templates ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`) ensureColumn('mirror_checks', 'profile_key', `ALTER TABLE mirror_checks ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`) ensureColumn('apparel_items', 'profile_key', `ALTER TABLE apparel_items ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`) ensureColumn('ai_audits', 'profile_key', `ALTER TABLE ai_audits ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`) db.exec(` CREATE TABLE IF NOT EXISTS style_profiles ( profile_key TEXT PRIMARY KEY, profile_name TEXT NOT NULL, slogan TEXT DEFAULT '', theme TEXT NOT NULL DEFAULT 'dark' ); CREATE INDEX IF NOT EXISTS idx_rules_profile ON mirror_rules(profile_key, active_flag, sort_order); CREATE INDEX IF NOT EXISTS idx_outfits_profile ON outfit_templates(profile_key, active_flag, scene, temp_zone); CREATE INDEX IF NOT EXISTS idx_items_profile ON apparel_items(profile_key, active_flag, slot, temp_zone); CREATE INDEX IF NOT EXISTS idx_checks_profile ON mirror_checks(profile_key, check_date); CREATE INDEX IF NOT EXISTS idx_ai_profile ON ai_audits(profile_key, created_at); `) db.prepare(`UPDATE mirror_rules SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run() db.prepare(`UPDATE outfit_templates SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run() db.prepare(`UPDATE mirror_checks SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run() db.prepare(`UPDATE apparel_items SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run() db.prepare(`UPDATE ai_audits SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run() db.prepare(` INSERT INTO style_profiles (profile_key, profile_name, slogan, theme) VALUES ('mature_relaxed', '中老年休闲沉稳版', '稳重有质感,低饱和、利落、耐看', 'dark'), ('mature_fashion40', '中老年流行时尚版', '50岁按40岁时尚审美:懂流行、有表达、不另类', 'fashion') ON CONFLICT(profile_key) DO UPDATE SET profile_name=excluded.profile_name, slogan=excluded.slogan, theme=excluded.theme `).run() const cfg = readConfig() const defaultProfile = (cfg && cfg.profile && typeof cfg.profile === 'object') ? cfg.profile : {} db.prepare(` INSERT INTO user_profile (id, nickname, city, height_cm, weight_kg, style_keywords, avoid_keywords, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(id) DO NOTHING `).run( String(defaultProfile.nickname || '上海通勤风'), String(defaultProfile.city || '上海'), Number(defaultProfile.height_cm || 176), Number(defaultProfile.weight_kg || 65), String(defaultProfile.style_keywords || '简约质感,运动休闲融合,低饱和度'), String(defaultProfile.avoid_keywords || '西服,大logo,荧光色,夸张印花') ) const ruleCount = db.prepare(`SELECT COUNT(1) AS c FROM mirror_rules`).get().c || 0 if (!ruleCount) { const seedRules = [ ['overall', '颜色不超过3种(黑/灰/藏蓝/卡其/米白/军绿)', 15, 10], ['overall', '全身无大logo、无夸张印花、无荧光色', 12, 20], ['overall', '肩线平整、衣长不过臀、裤脚不堆褶', 15, 30], ['top', '上衣合身不紧绷,领口不变形', 10, 40], ['bottom', '下装不紧身不垮裆,线条利落', 10, 50], ['shoes', '鞋面干净,鞋型不笨重', 10, 60], ['vibe', '整体气质清爽干练,有活力不装嫩', 18, 70] ] const stmt = db.prepare(`INSERT INTO mirror_rules (rule_group, rule_text, weight, sort_order) VALUES (?, ?, ?, ?)`) const tx = db.transaction((rows) => { rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3])) }) tx(seedRules) } const outfitCount = db.prepare(`SELECT COUNT(1) AS c FROM outfit_templates`).get().c || 0 if (!outfitCount) { const seedOutfits = [ ['通勤休闲 A', 'commute', '12-22℃', '浅灰亨利领', '深蓝直筒休闲裤', '小白鞋', '轻量短夹克', '简约手表', '上海通勤稳定款'], ['周末轻运动 A', 'weekend', '22-30℃', '纯色小logo卫衣', '黑灰修身牛仔裤', '复古跑鞋', '', '棒球帽', '健身后可直接穿'], ['轻商务会面 A', 'business_light', '12-22℃', '浅蓝牛津纺衬衫', '卡其直筒裤', '哑光乐福鞋', '薄针织开衫', '细框眼镜', '成熟但不老气'], ['冬季通勤 A', 'commute', '5-12℃', '半高领羊绒打底', '深灰加厚休闲裤', '复古慢跑鞋', '藏蓝短款羽绒服', '简约手表', '保暖不臃肿'], ['盛夏通勤 A', 'commute', '30℃+', '浅蓝亚麻短袖衬衫', '浅卡其休闲短裤', '小白鞋', '轻薄防晒衬衫', '简约皮带', '清爽透气'] ] const stmt = db.prepare(` INSERT INTO outfit_templates (name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) `) const tx = db.transaction((rows) => { rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8])) }) tx(seedOutfits) } const apparelCount = db.prepare(`SELECT COUNT(1) AS c FROM apparel_items`).get().c || 0 if (!apparelCount) { const seedItems = [ ['纯色圆领T恤(春秋)', 'top', 'T恤', '12-22℃', 'spring_autumn', '优衣库、马登、余闲', 79, 199, '圆领,T恤,纯色,白,麻灰,黑', '合身不紧绷,领口不变形'], ['亨利领T恤(春秋)', 'top', '亨利领', '12-22℃', 'spring_autumn', '单农、海上公路、杰克琼斯', 299, 499, '亨利领,T恤,灰,藏蓝', '比Polo年轻,比T恤精致'], ['薄款牛津纺衬衫(春秋)', 'top', '衬衫', '12-22℃', 'spring_autumn', '雅戈尔、十如仕、威可多', 399, 680, '牛津纺,衬衫,浅蓝,白,条纹', '免烫、微宽松'], ['轻量短款夹克(春秋)', 'outer', '夹克', '12-22℃', 'spring_autumn', '七匹狼、单农、之禾', 599, 1299, '夹克,短款,防风,软壳,藏蓝,炭灰', '长度到腰胯'], ['薄针织V领开衫(春秋)', 'outer', '针织开衫', '12-22℃', 'spring_autumn', '鄂尔多斯、单农、优衣库', 399, 899, '针织,开衫,V领,灰,藏蓝', '贴身不紧身'], ['简约小logo卫衣(春秋)', 'top', '卫衣', '12-22℃', 'spring_autumn', '杰克琼斯、马登、余闲', 299, 499, '卫衣,小logo,深灰,黑', '利落版型'], ['深灰/藏蓝直筒休闲裤(春秋)', 'bottom', '休闲裤', '12-22℃', 'spring_autumn', '雅戈尔、七匹狼、海上公路', 299, 599, '直筒,休闲裤,深灰,藏蓝,微弹', '不阔不紧'], ['深蓝修身牛仔裤(春秋)', 'bottom', '牛仔裤', '12-22℃', 'spring_autumn', '杰克琼斯、李维斯、优衣库', 499, 699, '牛仔裤,深蓝,修身', '基础水洗'], ['高端束脚卫裤(春秋)', 'bottom', '卫裤', '12-22℃', 'spring_autumn', '单农、余闲、优衣库', 299, 499, '束脚,卫裤,黑,深灰', '通勤+健身两用'], ['小白鞋/简约板鞋', 'shoes', '板鞋', 'ALL', 'all', 'Common Projects、New Balance、匡威', 599, 1299, '小白鞋,板鞋,简约,白', '通勤日常都能穿'], ['复古慢跑鞋', 'shoes', '慢跑鞋', 'ALL', 'all', 'New Balance、Asics、Nike', 699, 1299, '慢跑鞋,复古跑鞋,跑鞋,灰,白,黑', '运动休闲融合'], ['纯色短袖T恤(夏)', 'top', '短袖T恤', '22-30℃', 'summer', '优衣库、马登、余闲', 79, 199, '短袖,T恤,纯色,白,麻灰,藏蓝,浅卡其', '透气不贴身'], ['亨利领短袖T恤(夏)', 'top', '短袖亨利领', '22-30℃', 'summer', '单农、海上公路、杰克琼斯', 299, 399, '短袖,亨利领,灰,藏蓝', '夏季精致款'], ['亚麻/天丝短袖衬衫(夏)', 'top', '短袖衬衫', '30℃+', 'summer', '雅戈尔、十如仕、之禾', 499, 799, '亚麻,天丝,短袖衬衫,浅蓝,白,条纹', '透气慵懒'], ['简约Polo衫(夏)', 'top', 'Polo', '22-30℃', 'summer', '七匹狼、雅戈尔、单农', 399, 599, 'Polo,麻灰,藏蓝,浅卡其', '无大logo'], ['轻薄防晒衬衫(夏)', 'outer', '防晒衬衫', '30℃+', 'summer', '优衣库、单农、北面', 299, 499, '防晒衬衫,轻薄,浅灰,米白', '可当外套'], ['直筒休闲短裤(夏)', 'bottom', '短裤', '30℃+', 'summer', '雅戈尔、七匹狼、海上公路', 299, 499, '短裤,直筒,浅卡其,深灰', '膝盖上2cm'], ['亚麻/天丝休闲长裤(夏)', 'bottom', '休闲长裤', '30℃+', 'summer', '之禾、单农、雅戈尔', 499, 799, '亚麻长裤,天丝长裤,浅灰,米白', '透气垂感'], ['轻薄束脚卫裤(夏)', 'bottom', '轻薄卫裤', '22-30℃', 'summer', '单农、余闲、优衣库', 299, 399, '轻薄卫裤,束脚,速干', '健身后直接穿'], ['透气休闲凉鞋/乐福鞋', 'shoes', '凉鞋乐福', '30℃+', 'summer', 'ECCO、Clarks、百丽', 699, 1299, '凉鞋,乐福鞋,无鞋带,透气', '夏季通勤友好'], ['加厚长袖T恤(冬)', 'top', '长袖T恤', '5-12℃', 'winter', '优衣库、马登、余闲', 129, 249, '长袖T恤,加厚,白,麻灰,黑', '打底基础款'], ['半高领打底衫(冬)', 'top', '高领打底', '5-12℃', 'winter', '鄂尔多斯、单农、优衣库', 399, 899, '半高领,高领,打底,羊绒,羊毛', '保暖不臃肿'], ['加厚亨利领T恤(冬)', 'top', '亨利领', '5-12℃', 'winter', '单农、海上公路、杰克琼斯', 399, 599, '加厚,亨利领,T恤,灰,藏蓝', '冬季精致层次'], ['中厚针织毛衣/V领开衫(冬)', 'outer', '针织毛衣', '5-12℃', 'winter', '鄂尔多斯、单农、之禾', 599, 1299, '毛衣,针织,V领,开衫,灰,藏蓝,黑', '保暖有型'], ['轻量短款羽绒服/棉服(冬)', 'outer', '羽绒服', '5-12℃', 'winter', '单农、北面、优衣库', 899, 1999, '羽绒服,棉服,短款,藏蓝,炭灰,黑', '修身不臃肿'], ['中长款羊毛风衣(冬)', 'outer', '风衣', '5-12℃', 'winter', '鄂尔多斯、之禾、威可多', 1600, 3200, '风衣,羊毛,中长款,炭灰,藏蓝', '挺括抗风'], ['加厚直筒休闲裤(冬)', 'bottom', '加厚休闲裤', '5-12℃', 'winter', '雅戈尔、七匹狼、海上公路', 399, 699, '加厚,直筒,休闲裤,深灰,藏蓝,黑', '加薄绒'], ['加厚修身牛仔裤(冬)', 'bottom', '加厚牛仔裤', '5-12℃', 'winter', '杰克琼斯、李维斯、优衣库', 599, 799, '加厚,牛仔裤,深蓝,黑灰', '基础款'], ['加厚束脚卫裤(冬)', 'bottom', '加厚卫裤', '5-12℃', 'winter', '单农、余闲、优衣库', 399, 599, '加厚,束脚,卫裤,黑,深灰', '健身+日常'], ['哑光休闲皮鞋/乐福鞋(冬)', 'shoes', '皮鞋乐福', '5-12℃', 'winter', 'ECCO、Clarks、百丽', 899, 1599, '皮鞋,乐福鞋,哑光,加薄绒', '成熟不老气'], ['简约手表', 'accessory', '手表', 'ALL', 'all', '天梭、美度、海鸥', 2000, 5000, '手表,金属表带,皮质表带', '少而精'], ['细框眼镜', 'accessory', '眼镜', 'ALL', 'all', '木九十、暴龙、精工', 500, 1500, '眼镜,细框', '斯文减龄'], ['纯色棒球帽', 'accessory', '帽子', 'ALL', 'all', 'New Era、优衣库、单农', 99, 299, '棒球帽,灰,黑,藏蓝', '运动休闲融合'], ['简约皮带', 'accessory', '皮带', 'ALL', 'all', '金利来、七匹狼、ECCO', 299, 599, '皮带,黑,深棕', '无大logo'] ] const stmt = db.prepare(` INSERT INTO apparel_items (item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) `) const tx = db.transaction((rows) => { rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9])) }) tx(seedItems) } const fashionRuleCount = db.prepare(`SELECT COUNT(1) AS c FROM mirror_rules WHERE profile_key='mature_fashion40'`).get().c || 0 if (!fashionRuleCount) { const seedRules = [ ['overall', '颜色控制在4种以内,并至少有1处时尚点缀', 12, 10], ['overall', '允许小面积图案,但禁止大logo、荧光色、夸张印花', 14, 20], ['overall', '主轮廓要明确:上窄下松或上松下窄', 12, 30], ['top', '上衣有设计感但不过度:领型/门襟/面料至少一项有亮点', 10, 40], ['bottom', '下装线条干净,避免紧绷和拖沓', 10, 50], ['shoes', '鞋保持干净,允许复古或轻设计但不笨重', 10, 60], ['vibe', '整体像懂时尚的40岁审美:有表达、不另类', 18, 70] ] const stmt = db.prepare(`INSERT INTO mirror_rules (rule_group, rule_text, weight, sort_order, profile_key) VALUES (?, ?, ?, ?, 'mature_fashion40')`) const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3]))) tx(seedRules) } const fashionOutfitCount = db.prepare(`SELECT COUNT(1) AS c FROM outfit_templates WHERE profile_key='mature_fashion40'`).get().c || 0 if (!fashionOutfitCount) { const seedOutfits = [ ['时尚通勤 A', 'commute', '12-22℃', '深灰半高领打底', '锥形九分西装感休闲裤', '干净复古跑鞋', '短款立领夹克', '金属表带手表', '成熟通勤但有时尚表达'], ['城市周末 A', 'weekend', '22-30℃', '微廓形落肩T恤', '垂感直筒裤', '低帮板鞋', '轻机能衬衫外搭', '细框墨镜', '有层次但不过度'], ['社交会面 A', 'business_light', '12-22℃', '暗纹亨利领针织', '炭灰锥形裤', '哑光乐福鞋', '短风衣', '简约皮带', '像看过杂志的成熟气质'], ['冬季时尚 A', 'commute', '5-12℃', '黑色羊毛半高领', '深灰加厚直筒裤', '复古运动鞋', '炭灰短款羽绒服', '针织围巾', '保暖+时髦平衡'], ['盛夏时尚 A', 'commute', '30℃+', '米白天丝短袖衬衫', '浅灰轻薄锥形裤', '透气乐福鞋', '薄防晒衬衫', '金属框眼镜', '轻熟时尚不油腻'] ] const stmt = db.prepare(` INSERT INTO outfit_templates (name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, profile_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now')) `) const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]))) tx(seedOutfits) } const fashionItemCount = db.prepare(`SELECT COUNT(1) AS c FROM apparel_items WHERE profile_key='mature_fashion40'`).get().c || 0 if (!fashionItemCount) { const seedItems = [ ['微廓形落肩T恤', 'top', '时尚T恤', '22-30℃', 'summer', '优衣库、UR、GXG', 129, 299, '落肩,T恤,微廓形,纯色', '保留宽松感但利落'], ['暗纹亨利领针织', 'top', '针织上衣', '12-22℃', 'spring_autumn', '海澜之家、太平鸟男装、杰克琼斯', 299, 599, '暗纹,亨利领,针织', '成熟且时尚'], ['短款立领夹克', 'outer', '夹克', '12-22℃', 'spring_autumn', 'GXG、太平鸟男装、七匹狼', 499, 999, '短款,立领,夹克,结构感', '提升轮廓'], ['轻机能衬衫外搭', 'outer', '机能衬衫', '22-30℃', 'summer', '优衣库、UR、海澜之家', 199, 499, '机能,衬衫,外搭,轻薄', '层次和功能感'], ['短风衣', 'outer', '风衣', '12-22℃', 'spring_autumn', '雅戈尔、报喜鸟、太平鸟男装', 699, 1499, '风衣,短款,挺括', '不拖沓更年轻'], ['锥形九分休闲裤', 'bottom', '锥形裤', '12-22℃', 'all', '雅戈尔、海澜之家、优衣库', 299, 699, '锥形,九分,休闲裤', '显腿型'], ['垂感直筒裤', 'bottom', '垂感裤', '22-30℃', 'summer', 'UR、太平鸟男装、优衣库', 199, 599, '垂感,直筒,轻薄', '线条干净'], ['浅灰轻薄锥形裤', 'bottom', '夏季锥形裤', '30℃+', 'summer', '优衣库、海澜之家、GXG', 199, 499, '浅灰,轻薄,锥形', '夏季不闷'], ['干净复古跑鞋', 'shoes', '复古跑鞋', 'ALL', 'all', 'New Balance、Asics、Nike', 499, 999, '复古跑鞋,低饱和,干净', '时尚感核心单品'], ['透气乐福鞋', 'shoes', '乐福鞋', '30℃+', 'summer', 'ECCO、Clarks、百丽', 599, 1299, '乐福鞋,透气,低帮', '轻熟社交'], ['金属表带手表', 'accessory', '手表', 'ALL', 'all', '天梭、海鸥、卡西欧', 800, 3500, '手表,金属表带,简约', '强化成熟时尚感'], ['细框墨镜', 'accessory', '眼镜', 'ALL', 'all', '暴龙、木九十、陌森', 299, 999, '墨镜,细框,金属', '点睛不夸张'] ] const stmt = db.prepare(` INSERT INTO apparel_items (item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now')) `) const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9]))) tx(seedItems) } let needRebuildFashionLinks = false const fashionMissingRows = [ ['深灰半高领打底', 'top', '打底上衣', '5-12℃', 'winter', '优衣库、凡客诚品、HLA海澜之家', 79, 199, '半高领,打底,深灰,纯棉,美利奴', '保暖不臃肿,单穿或内搭都显利落'], ['低帮板鞋', 'shoes', '板鞋', 'ALL', 'all', '回力、飞跃、安踏', 89, 299, '低帮,板鞋,纯色,复古', '经典白黑藏蓝,百搭通勤'], ['哑光乐福鞋', 'shoes', '乐福鞋', 'ALL', 'all', '森达、奥康、红蜻蜓', 299, 599, '哑光,乐福鞋,牛皮,简约', '轻熟商务感,不浮夸'], ['黑色羊毛半高领', 'top', '高领打底', '5-12℃', 'winter', '优衣库、凡客、七匹狼', 129, 299, '羊毛,半高领,黑色,抗起球', '冬季内搭核心,更显年轻'], ['深灰加厚直筒裤', 'bottom', '加厚休闲裤', '5-12℃', 'winter', '优衣库EZY裤、海澜之家、凡客', 159, 299, '加厚,直筒,垂感,抗皱', '冬季保暖不臃肿'], ['复古运动鞋', 'shoes', '复古跑鞋', 'ALL', 'all', '安踏、李宁、New Balance 574', 299, 599, '复古,低饱和,缓震,百搭', '舒适与时尚兼顾,避免笨重'], ['炭灰短款羽绒服', 'outer', '羽绒服', '5-12℃', 'winter', '波司登、鸭鸭、优衣库', 499, 899, '炭灰,短款,立领,90绒,哑光', '提升腰线显高,避免拖沓'], ['针织围巾', 'accessory', '围巾', 'ALL', 'winter', '优衣库、网易严选、无印良品', 59, 199, '围巾,羊毛,羊绒,纯色,窄版', '保暖点缀,减轻厚重感'], ['米白天丝短袖衬衫', 'top', '短袖衬衫', '30℃+', 'summer', '凡客诚品、优衣库、海澜之家', 99, 249, '米白,天丝,短袖衬衫,抗皱', '夏季通勤垂顺不闷汗'], ['薄防晒衬衫外搭', 'outer', '防晒衬衫', '30℃+', 'summer', '优衣库AIRism、蕉下、凡客', 129, 249, '防晒,UPF50+,轻薄,透气,外搭', '增加层次,避免单调'] ] { const hasStmt = db.prepare(`SELECT id FROM apparel_items WHERE profile_key='mature_fashion40' AND item_name=? LIMIT 1`) const insertStmt = db.prepare(` INSERT INTO apparel_items (item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now')) `) const tx = db.transaction((rows) => { let inserted = 0 rows.forEach(r => { const exists = hasStmt.get(r[0]) if (exists) return insertStmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9]) inserted += 1 }) return inserted }) const insertedCount = tx(fashionMissingRows) needRebuildFashionLinks = insertedCount > 0 } const PROFILE_KEYS = new Set(['mature_relaxed', 'mature_fashion40']) const normProfileKey = v => { const s = String(v || '').trim() if (PROFILE_KEYS.has(s)) return s return 'mature_relaxed' } const SCENES = new Set(['commute', 'weekend', 'business_light']) const TEMP_ZONES = new Set(['5-12℃', '12-22℃', '22-30℃', '30℃+']) const normScene = v => { const s = String(v || '').trim() if (SCENES.has(s)) return s return 'commute' } const zoneByTemp = tempC => { const n = Number(tempC) if (!Number.isFinite(n)) return '' if (n < 12) return '5-12℃' if (n < 22) return '12-22℃' if (n < 30) return '22-30℃' return '30℃+' } const normZone = (zone, tempC) => { const z = String(zone || '').trim() if (TEMP_ZONES.has(z)) return z return zoneByTemp(tempC) || '12-22℃' } const splitKeywords = text => String(text || '').split(/[,,、/+\s]+/).map(v => v.trim()).filter(Boolean) const normText = text => String(text || '').toLowerCase().replace(/\s+/g, '') const localImageUrlPrefix = '/images/style_check_images' const localImageRoot = path.join(process.cwd(), 'public', 'images', 'style_check_images') if (!fs.existsSync(localImageRoot)) fs.mkdirSync(localImageRoot, { recursive: true }) const isLocalImageUrl = (url) => String(url || '').startsWith(`${localImageUrlPrefix}/`) const guessExtByContentType = (ct) => { const c = String(ct || '').toLowerCase() if (c.includes('image/jpeg') || c.includes('image/jpg')) return '.jpg' if (c.includes('image/png')) return '.png' if (c.includes('image/webp')) return '.webp' if (c.includes('image/gif')) return '.gif' return '.jpg' } const saveImageBufferToLocal = (profileKey, itemId, buffer, ext, orderHint = 1) => { const dir = path.join(localImageRoot, profileKey, String(itemId)) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) const ts = Date.now() const rnd = Math.floor(Math.random() * 100000) const fileName = `${ts}_${orderHint}_${rnd}${ext}` const abs = path.join(dir, fileName) fs.writeFileSync(abs, buffer) return `${localImageUrlPrefix}/${profileKey}/${itemId}/${fileName}` } const downloadImageToLocal = async (url, profileKey, itemId, orderHint = 1) => { try { const res = await axios.get(url, { timeout: 10000, responseType: 'arraybuffer', maxRedirects: 10, validateStatus: s => s >= 200 && s < 400, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', Accept: 'image/*,*/*;q=0.8' } }) const ct = String((res.headers && (res.headers['content-type'] || res.headers['Content-Type'])) || '') if (!ct.toLowerCase().includes('image/')) throw new Error('not_image_content_type: ' + ct) const buf = Buffer.from(res.data) if (!buf || buf.length < 2000) throw new Error('image_too_small: ' + buf.length) const ext = guessExtByContentType(ct) return saveImageBufferToLocal(profileKey, itemId, buf, ext, orderHint) } catch (err) { console.error(`downloadImageToLocal error for ${url}:`, err.message) throw err } } const listActiveLibraryItems = (profileKey = 'mature_relaxed') => db.prepare(` SELECT id, item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes FROM apparel_items WHERE active_flag=1 AND profile_key=? ORDER BY slot ASC, id ASC `).all(profileKey) const listItemImages = (itemId, profileKey) => db.prepare(` SELECT id, item_id, profile_key, image_url, source_type, caption, sort_order, created_at FROM apparel_item_images WHERE item_id=? AND profile_key=? ORDER BY sort_order ASC, id ASC `).all(itemId, profileKey) const guessSlotQueryWord = slot => { const s = String(slot || '') if (s === 'top') return 'top' if (s === 'bottom') return 'pants' if (s === 'shoes') return 'shoes' if (s === 'outer') return 'jacket' if (s === 'accessory') return 'accessory' return 'fashion' } const buildWebSeedUrls = async (item) => { const base = `${item.item_name} ${item.category || ''} ${item.keywords || ''}`.trim() const q = `${base} 男装` try { const res = await axios.get(`https://image.so.com/j?q=${encodeURIComponent(q)}&sn=0&pn=10`, { timeout: 5000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36' } }) const list = res.data && res.data.list if (Array.isArray(list)) { return list.map(x => x.img).filter(x => x && x.startsWith('http')) } } catch (e) { console.error('[StyleCheck] buildWebSeedUrls error:', e.message) } return [] } const getImageMissingReport = (profileKey = 'mature_relaxed') => { const items = db.prepare(` SELECT i.id, i.item_name, i.slot, COUNT(img.id) AS image_count FROM apparel_items i LEFT JOIN apparel_item_images img ON img.item_id=i.id AND img.profile_key=i.profile_key AND img.image_url LIKE '${localImageUrlPrefix}/%' WHERE i.active_flag=1 AND i.profile_key=? GROUP BY i.id, i.item_name, i.slot HAVING image_count < 3 ORDER BY i.slot ASC, i.id ASC `).all(profileKey) return items } const parseImageDataUrl = (dataUrl) => { const s = String(dataUrl || '') const m = s.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]+)$/) if (!m) return null const mime = m[1].toLowerCase() const b64 = m[2] const extMap = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' } const ext = extMap[mime] if (!ext) return null try { const buf = Buffer.from(b64, 'base64') if (!buf || !buf.length) return null return { mime, ext, buffer: buf } } catch { return null } } const saveUploadedLibraryImage = (profileKey, itemId, dataUrl) => { const parsed = parseImageDataUrl(dataUrl) if (!parsed) throw new Error('invalid_image_data_url') return saveImageBufferToLocal(profileKey, itemId, parsed.buffer, parsed.ext, 999) } let backgroundFetchTimer = null let isBackgroundFetchEnabled = false // Logging system for background fetch const bgFetchState = { status: 'stopped', // 'stopped', 'running', 'completed' startTime: null, endTime: null, totalItemsToProcess: 0, itemsProcessed: 0, imagesDownloaded: 0, errors: [], lastRunTime: null } const getBgFetchReport = () => { return { ...bgFetchState, uptime_minutes: bgFetchState.startTime ? Math.round((Date.now() - bgFetchState.startTime) / 60000) : 0, progress: bgFetchState.totalItemsToProcess > 0 ? Math.round((bgFetchState.itemsProcessed / bgFetchState.totalItemsToProcess) * 100) + '%' : '0%' } } const stopBackgroundFetch = () => { if (backgroundFetchTimer) { clearInterval(backgroundFetchTimer) backgroundFetchTimer = null } isBackgroundFetchEnabled = false bgFetchState.status = bgFetchState.status === 'running' ? 'stopped' : bgFetchState.status bgFetchState.endTime = Date.now() console.log('[StyleCheck] Background fetch stopped. Report:', getBgFetchReport()) } const startBackgroundFetch = () => { if (backgroundFetchTimer) return isBackgroundFetchEnabled = true bgFetchState.status = 'running' bgFetchState.startTime = Date.now() bgFetchState.endTime = null bgFetchState.itemsProcessed = 0 bgFetchState.imagesDownloaded = 0 bgFetchState.errors = [] bgFetchState.isProcessing = false console.log('[StyleCheck] Background fetch started. Will run every 5 minutes.') const runFetch = async () => { if (!isBackgroundFetchEnabled) return if (bgFetchState.isProcessing) { console.log('[StyleCheck] Previous fetch is still running, skipping this tick.') return } bgFetchState.isProcessing = true bgFetchState.lastRunTime = Date.now() try { console.log('[StyleCheck] Running scheduled background fetch...') // We will check both profiles const profiles = ['mature_relaxed', 'mature_fashion40'] // Calculate total items missing images on first run if (bgFetchState.totalItemsToProcess === 0) { let missing = 0 for (const pk of profiles) { const items = db.prepare(`SELECT id FROM apparel_items WHERE active_flag=1 AND profile_key=?`).all(pk) for (const item of items) { const count = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE item_id=? AND profile_key=? AND image_url LIKE '/images/%'`).get(item.id, pk).c if (count < 3) missing++ } } bgFetchState.totalItemsToProcess = missing } let totalNewImages = 0 let totalMissingRemaining = 0 let processedSomething = false for (const pk of profiles) { const countBefore = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE profile_key=? AND image_url LIKE '/images/%'`).get(pk).c const result = await ensureItemLocalImages(pk, 3, false, 1) // only process 1 item per run to avoid rate limits const countAfter = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE profile_key=? AND image_url LIKE '/images/%'`).get(pk).c const newImages = countAfter - countBefore totalNewImages += newImages totalMissingRemaining += result.missing_count if (newImages > 0 || (result.missing_count > 0 && result.item_count > 0)) { processedSomething = true } } if (totalNewImages > 0) { bgFetchState.imagesDownloaded += totalNewImages bgFetchState.itemsProcessed += 1 } else if (processedSomething && bgFetchState.itemsProcessed < bgFetchState.totalItemsToProcess) { bgFetchState.itemsProcessed += 1 } if (totalMissingRemaining === 0) { console.log('[StyleCheck] All items in all profiles have enough images. Stopping background fetch.') bgFetchState.status = 'completed' bgFetchState.itemsProcessed = bgFetchState.totalItemsToProcess // ensure progress hits 100% stopBackgroundFetch() } else { console.log(`[StyleCheck] Processed. Still ${totalMissingRemaining} items missing images total. Total downloaded so far: ${bgFetchState.imagesDownloaded}`) } } catch (e) { console.error('[StyleCheck] Background fetch error:', e.message) bgFetchState.errors.push(`[${new Date().toISOString()}] ${e.message}`) // Keep only last 10 errors if (bgFetchState.errors.length > 10) bgFetchState.errors.shift() } finally { bgFetchState.isProcessing = false } } // Run first time after 5s, then every 5 mins setTimeout(runFetch, 5000) backgroundFetchTimer = setInterval(runFetch, 5 * 60 * 1000) } const delay = ms => new Promise(r => setTimeout(r, ms)) const callVisionJudgeItemImage = async (localUrl, item) => { try { const ai = getAiConfig() if (!ai.enabled || !ai.apiKey) { console.log(`[AI Judge] Disabled or missing API key, skipping judge for ${item.item_name}`) return true // Fallback to accept if AI is disabled } const abs = path.join(process.cwd(), 'public', localUrl.replace(/^\//, '').replace(/\//g, path.sep)) if (!fs.existsSync(abs)) return false const buf = fs.readFileSync(abs) const ext = path.extname(abs).toLowerCase() const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg' const dataUrl = `data:${mime};base64,${buf.toString('base64')}` const systemPrompt = [ '你是资深的男装时尚买手与图像审核专家。', `当前需要判断该图片是否适合作为【${item.item_name}】(类别:${item.category})的商品展示图。`, `商品关键词参考:${item.keywords || '无'}。`, '审核要求:', '1. 必须是【男性服装】或【男性模特】穿着展示,或者清晰的单品图。如果出现明显女性模特、童装、无关风景、表情包、二维码、纯文字截图等,请直接判定为不合格。', '2. 图片质量要求清晰,服装主体完整可见。', '3. 符合商品本身的描述与类别。', '必须严格输出JSON格式,不要包含任何解释或Markdown符号:', '{"approved": true或false, "reason": "简短判定理由"}' ].join('\n') const payload = { model: ai.visionModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: [ { type: 'text', text: '请审核图片是否合格,直接输出JSON' }, { type: 'image_url', image_url: { url: dataUrl } } ] } ], temperature: 0.1 } const response = await axios.post(ai.apiBase, payload, { timeout: ai.timeoutMs * 2, headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' } }) const content = response.data && response.data.choices && response.data.choices[0] && response.data.choices[0].message ? response.data.choices[0].message.content : '' const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content) const jsonMatch = text.match(/\{[\s\S]*\}/) if (jsonMatch) { const j = JSON.parse(jsonMatch[0]) console.log(`[AI Judge] ${item.item_name} -> ${j.approved ? '✅ PASS' : '❌ REJECT'} : ${j.reason}`) return !!j.approved } else { console.log(`[AI Judge] Failed to parse JSON for ${item.item_name}, fallback to true`) return true } } catch (e) { console.error(`[AI Judge] Error for ${item.item_name}:`, e.message) return true // fail safe to not block downloading completely } } const ensureItemLocalImages = async (profileKey = 'mature_relaxed', targetPerItem = 5, force = false, limitItems = 0) => { const items = db.prepare(` SELECT id, item_name, slot, category, keywords FROM apparel_items WHERE active_flag=1 AND profile_key=? ORDER BY id ASC `).all(profileKey) // If limit is set, we prioritize items that don't have enough local images let targetItems = items if (limitItems > 0 && !force) { const missingItems = [] for (const item of items) { const current = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url)) if (current.length < targetPerItem) { missingItems.push(item) } } targetItems = missingItems } const ins = db.prepare(` INSERT OR IGNORE INTO apparel_item_images (item_id, profile_key, image_url, source_type, caption, sort_order) VALUES (?, ?, ?, ?, '', ?) `) const delRemote = db.prepare(` DELETE FROM apparel_item_images WHERE item_id=? AND profile_key=? AND image_url NOT LIKE '${localImageUrlPrefix}/%' `) const report = [] let processedItems = 0 for (const item of targetItems) { if (limitItems > 0 && processedItems >= limitItems) break if (force) { const old = listItemImages(item.id, profileKey) old.forEach(one => { const u = String(one.image_url || '') if (u.startsWith(`${localImageUrlPrefix}/`)) { const abs = path.join(process.cwd(), 'public', u.replace(/^\//, '').replace(/\//g, path.sep)) if (fs.existsSync(abs)) { try { fs.unlinkSync(abs) } catch {} } } }) db.prepare(`DELETE FROM apparel_item_images WHERE item_id=? AND profile_key=?`).run(item.id, profileKey) } const current = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url)) let localCount = current.length let order = localCount + 1 const existedUrls = new Set(current.map(x => String(x.image_url || ''))) const oldRemote = listItemImages(item.id, profileKey).filter(r => !isLocalImageUrl(r.image_url)) for (const one of oldRemote) { if (localCount >= targetPerItem) break const u = String(one.image_url || '') if (!u || !/^https?:\/\//i.test(u)) continue try { const localUrl = await downloadImageToLocal(u, profileKey, item.id, order) if (!existedUrls.has(localUrl)) { ins.run(item.id, profileKey, localUrl, 'web_localized', order) existedUrls.add(localUrl) localCount += 1 order += 1 } } catch {} } if (localCount < targetPerItem) { const candidates = await buildWebSeedUrls(item) for (const u of candidates) { if (localCount >= targetPerItem) break try { const localUrl = await downloadImageToLocal(u, profileKey, item.id, order) if (!existedUrls.has(localUrl)) { let isApproved = true if (profileKey === 'mature_fashion40') { console.log(`[AI Judge] Checking new image for ${item.item_name}...`) await delay(30000) // Sleep 30 seconds before calling AI to be slow and gentle isApproved = await callVisionJudgeItemImage(localUrl, item) } if (isApproved) { ins.run(item.id, profileKey, localUrl, 'web_download', order) existedUrls.add(localUrl) localCount += 1 order += 1 } else { // Delete rejected image const abs = path.join(process.cwd(), 'public', localUrl.replace(/^\//, '').replace(/\//g, path.sep)) if (fs.existsSync(abs)) { try { fs.unlinkSync(abs) } catch {} } } if (profileKey === 'mature_fashion40') { await delay(30000) // Sleep another 30 seconds after processing to space out } } } catch {} } } delRemote.run(item.id, profileKey) if (localCount < 3) { report.push({ id: item.id, item_name: item.item_name, slot: item.slot, image_count: localCount }) } else { processedItems += 1 } } // Compute accurate missing count at the end const finalMissing = [] for (const item of items) { const localCount = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url)).length if (localCount < targetPerItem) { finalMissing.push({ id: item.id, item_name: item.item_name, slot: item.slot, image_count: localCount }) } } return { item_count: items.length, missing: finalMissing, missing_count: finalMissing.length } } const buildCandidatesForSlot = (slot, descText, tempZone, items) => { const desc = String(descText || '').trim() if (!desc) return [] const nDesc = normText(desc) const candidates = items .filter(it => it.slot === slot) .map(it => { const kws = splitKeywords(it.keywords) let hit = '' let score = 0 if (nDesc.includes(normText(it.item_name))) { score += 55 hit = it.item_name } for (const kw of kws) { if (!kw) continue if (nDesc.includes(normText(kw))) { score += 24 if (!hit) hit = kw } } if (String(it.temp_zone || 'ALL') === String(tempZone || '')) score += 20 if (String(it.temp_zone || 'ALL') === 'ALL') score += 10 return { item: it, score, hit } }) .filter(x => x.score >= 30) .sort((a, b) => b.score - a.score || Number(a.item.id) - Number(b.item.id)) return candidates.slice(0, 3) } const evaluateOutfit = (outfit, items) => { const slotInputs = [ ['top', outfit.top_desc], ['bottom', outfit.bottom_desc], ['shoes', outfit.shoes_desc], ['outer', outfit.outer_desc], ['accessory', outfit.accessory_desc] ] const links = [] const unmatched = [] slotInputs.forEach(([slot, text]) => { const t = String(text || '').trim() if (!t) return const candidates = buildCandidatesForSlot(slot, t, outfit.temp_zone, items) if (!candidates.length) { unmatched.push({ outfit_id: outfit.id, outfit_name: outfit.name, slot, text: t }) return } candidates.forEach((c, idx) => { links.push({ outfit_id: outfit.id, item_id: c.item.id, slot, rank_no: idx + 1, match_type: 'auto', confidence: c.score, matched_keyword: c.hit || '' }) }) }) return { links, unmatched } } // === WAVE Crawler & AI === const waveCrawlerState = { isRunning: false, currentPage: 1, manualQueue: [], // 存放手动上传的 URL 队列 logs: [] } const waveImageRoot = path.join(process.cwd(), 'public', 'images', 'wave_references') if (!fs.existsSync(waveImageRoot)) fs.mkdirSync(waveImageRoot, { recursive: true }) const logWave = msg => { console.log('[WAVE]', msg) waveCrawlerState.logs.push(`[${new Date().toISOString()}] ${msg}`) if (waveCrawlerState.logs.length > 50) waveCrawlerState.logs.shift() } // === KOREAN Crawler & AI === const koreanCrawlerState = { isRunning: false, currentPage: 1, manualQueue: [], logs: [] } const koreanImageRoot = path.join(process.cwd(), 'public', 'images', 'korean_references') if (!fs.existsSync(koreanImageRoot)) fs.mkdirSync(koreanImageRoot, { recursive: true }) const logKorean = msg => { console.log('[KOREAN]', msg) koreanCrawlerState.logs.push(`[${new Date().toISOString()}] ${msg}`) if (koreanCrawlerState.logs.length > 50) koreanCrawlerState.logs.shift() } const callVisionKoreanJudge = async (dataUrl) => { const ai = getAiConfig() if (!ai.enabled || !ai.apiKey) return { approved: false, reason: 'ai_disabled' } const systemPrompt = [ '你是资深的男装时尚买手与图像审核专家。', '当前需要判断该男士穿搭图片是否适合【韩式男装穿搭参考:简约、高级感、清爽、通勤或周末适用】的标准。', '要求:', '1. 必须是【男性服装】或【男性模特】穿着展示。', '2. 图片质量要求清晰,服装主体完整可见。', '3. 穿搭要体现韩式审美:比如大衣、风衣、毛衣、衬衫的合理叠穿,干净利落的裤型,避免过度花哨和街头风,侧重气质与质感。', '必须严格输出JSON格式,不要包含任何解释或Markdown符号:', '{', ' "approved": true或false,', ' "reason": "如果不合格,给出简短淘汰理由",', ' "evaluation": "如果合格,给出穿搭评价,分析色彩、材质、层次等韩式亮点(100字左右)",', ' "notes": "如果合格,给出日常借鉴与注意事项(比如内搭处理、版型选择等,100字左右)"', '}' ].join('\n') const payload = { model: ai.visionModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: [ { type: 'text', text: '请审核图片是否符合韩式穿搭合格,直接输出JSON' }, { type: 'image_url', image_url: { url: dataUrl } } ] } ], temperature: 0.1 } try { const response = await axios.post(ai.apiBase, payload, { timeout: ai.timeoutMs * 2, headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' } }) const content = response.data?.choices?.[0]?.message?.content || '' const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content) const jsonMatch = text.match(/\{[\s\S]*\}/) if (jsonMatch) { return JSON.parse(jsonMatch[0]) } } catch(e) {} return { approved: false, reason: 'api_error_or_parse_fail' } } const callVisionWaveJudge = async (dataUrl) => { const ai = getAiConfig() if (!ai.enabled || !ai.apiKey) return { approved: false, reason: 'ai_disabled' } const systemPrompt = [ '你是资深的男装时尚买手与图像审核专家。', '当前需要判断该男士穿搭图片是否适合【50岁按40岁时尚审美:懂流行、有表达、不另类、通勤适用、不能装嫩】的标准。', '要求:', '1. 必须是【男性服装】或【男性模特】穿着展示。', '2. 图片质量要求清晰,服装主体完整可见。', '3. 穿搭不能过于花哨、另类或街头装嫩,也不能太老气古板,要体现质感、层次与成熟男士的得体表达。', '必须严格输出JSON格式,不要包含任何解释或Markdown符号:', '{', ' "approved": true或false,', ' "reason": "如果不合格,给出简短淘汰理由",', ' "evaluation": "如果合格,给出穿搭评价,分析色彩、材质、层次等亮点(100字左右)",', ' "notes": "如果合格,给出日常借鉴与注意事项(比如裤长、内搭处理等,100字左右)"', '}' ].join('\n') const payload = { model: ai.visionModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: [ { type: 'text', text: '请审核图片是否合格,直接输出JSON' }, { type: 'image_url', image_url: { url: dataUrl } } ] } ], temperature: 0.1 } try { const response = await axios.post(ai.apiBase, payload, { timeout: ai.timeoutMs * 2, headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' } }) const content = response.data?.choices?.[0]?.message?.content || '' const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content) const jsonMatch = text.match(/\{[\s\S]*\}/) if (jsonMatch) { return JSON.parse(jsonMatch[0]) } } catch(e) {} return { approved: false, reason: 'api_error_or_parse_fail' } } const startWaveCrawler = async () => { if (waveCrawlerState.isRunning) return waveCrawlerState.isRunning = true logWave('WAVE Crawler started') while (waveCrawlerState.isRunning) { try { let uniqueUrls = [] // 优先处理手动上传的队列 if (waveCrawlerState.manualQueue.length > 0) { logWave(`Processing manual queue, remaining: ${waveCrawlerState.manualQueue.length}`) // 每次取10张处理,避免单次循环太长 uniqueUrls = waveCrawlerState.manualQueue.splice(0, 10) } else { const url = `https://wear.jp/men-coordinate/?pageno=${waveCrawlerState.currentPage}` logWave(`Fetching page: ${url}`) const res = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' }, timeout: 15000 }) const html = res.data // WEAR updated their image CDN to images.wear2.jp const matches = html.match(/https:\/\/images\.wear2\.jp\/coordinate\/[^"']+_1000\.jpg/ig) || html.match(/https:\/\/images\.wear2\.jp\/coordinate\/[^"']+\.jpg/ig) if (!matches || matches.length === 0) { logWave('No images found on page, waiting 5 mins before retry...') await delay(300000) continue } // Prefer high-res _1000.jpg versions uniqueUrls = [...new Set(matches)] uniqueUrls = uniqueUrls.filter(u => u.includes('_1000.jpg') || !uniqueUrls.some(other => other !== u && other.replace('_1000', '') === u)) logWave(`Found ${uniqueUrls.length} images on page ${waveCrawlerState.currentPage}`) } for (const imgUrl of uniqueUrls) { if (!waveCrawlerState.isRunning) break const exists = db.prepare(`SELECT id FROM wave_references WHERE source_url=?`).get(imgUrl) if (exists) { logWave(`Skip already crawled: ${imgUrl}`) continue } logWave(`Processing image: ${imgUrl}`) // Delay 60s - 120s to be gentle const waitMs = 60000 + Math.floor(Math.random() * 60000) logWave(`Sleeping for ${Math.round(waitMs/1000)}s...`) await delay(waitMs) if (!waveCrawlerState.isRunning) break let buf try { const imgRes = await axios.get(imgUrl, { responseType: 'arraybuffer', timeout: 10000 }) buf = Buffer.from(imgRes.data) } catch(e) { logWave(`Failed to download ${imgUrl}: ${e.message}`) continue } const b64 = buf.toString('base64') const dataUrl = `data:image/jpeg;base64,${b64}` const aiRes = await callVisionWaveJudge(dataUrl) if (!aiRes || !aiRes.approved) { logWave(`AI Rejected: ${aiRes ? aiRes.reason : 'unknown'}`) db.prepare(`INSERT OR IGNORE INTO wave_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run('REJECTED', imgUrl, aiRes ? aiRes.reason : 'reject', '') continue } logWave(`AI Approved! Saving...`) const fileName = `wave_${Date.now()}_${Math.floor(Math.random()*1000)}.jpg` const localPath = `/images/wave_references/${fileName}` fs.writeFileSync(path.join(waveImageRoot, fileName), buf) db.prepare(`INSERT OR IGNORE INTO wave_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run(localPath, imgUrl, aiRes.evaluation || '', aiRes.notes || '') } // 如果不是在处理手动队列,才翻页 if (waveCrawlerState.manualQueue.length === 0) { waveCrawlerState.currentPage += 1 } } catch(e) { logWave(`Error in crawler loop: ${e.message}`) await delay(60000) } } logWave('WAVE Crawler stopped') } const startKoreanCrawler = async () => { if (koreanCrawlerState.isRunning) return koreanCrawlerState.isRunning = true logKorean('KOREAN Crawler started') while (koreanCrawlerState.isRunning) { try { let uniqueUrls = [] if (koreanCrawlerState.manualQueue.length > 0) { logKorean(`Processing manual queue, remaining: ${koreanCrawlerState.manualQueue.length}`) uniqueUrls = koreanCrawlerState.manualQueue.splice(0, 10) } else { logKorean(`Fetching page: ${koreanCrawlerState.currentPage} from onthelook`) const query = ` query HomeQueryV2($gender: [String], $styleTagIds: [Int], $season: [String], $page: Int, $perPage: Int) { result: Posts( filter: {styleTagIds: $styleTagIds, isHome: true, status: "ACTIVE", gender: $gender, season: $season} page: $page perPage: $perPage ) { items { id imageUrl detail } } } `; const variables = { "styleTagIds": [27,43,4,1], "gender": ["MEN"], "height": [], "weight": [], "season": ["SPRING","SUMMER","WINTER","FALL"], "page": koreanCrawlerState.currentPage, "perPage": 20 }; const res = await axios.post('https://api.onthelook.co.kr/graphql', { query, variables }, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' }, timeout: 15000 }) const items = res.data?.data?.result?.items || [] if (!items || items.length === 0) { logKorean('No images found on page, waiting 5 mins before retry...') await delay(300000) continue } uniqueUrls = items.map(item => item.imageUrl).filter(Boolean) logKorean(`Found ${uniqueUrls.length} images on page ${koreanCrawlerState.currentPage}`) } for (const imgUrl of uniqueUrls) { if (!koreanCrawlerState.isRunning) break const exists = db.prepare(`SELECT id FROM korean_references WHERE source_url=?`).get(imgUrl) if (exists) { logKorean(`Skip already crawled: ${imgUrl}`) continue } logKorean(`Processing image: ${imgUrl}`) // Delay 60s - 120s to be gentle const waitMs = 60000 + Math.floor(Math.random() * 60000) logKorean(`Sleeping for ${Math.round(waitMs/1000)}s...`) await delay(waitMs) if (!koreanCrawlerState.isRunning) break let buf try { const imgRes = await axios.get(imgUrl, { responseType: 'arraybuffer', timeout: 10000 }) buf = Buffer.from(imgRes.data) } catch(e) { logKorean(`Failed to download ${imgUrl}: ${e.message}`) continue } const b64 = buf.toString('base64') const dataUrl = `data:image/jpeg;base64,${b64}` const aiRes = await callVisionKoreanJudge(dataUrl) if (!aiRes || !aiRes.approved) { logKorean(`AI Rejected: ${aiRes ? aiRes.reason : 'unknown'}`) db.prepare(`INSERT OR IGNORE INTO korean_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run('REJECTED', imgUrl, aiRes ? aiRes.reason : 'reject', '') continue } logKorean(`AI Approved! Saving...`) const fileName = `korean_${Date.now()}_${Math.floor(Math.random()*1000)}.jpg` const localPath = `/images/korean_references/${fileName}` fs.writeFileSync(path.join(koreanImageRoot, fileName), buf) db.prepare(`INSERT OR IGNORE INTO korean_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run(localPath, imgUrl, aiRes.evaluation || '', aiRes.notes || '') } if (koreanCrawlerState.manualQueue.length === 0) { koreanCrawlerState.currentPage += 1 } } catch(e) { logKorean(`Error in crawler loop: ${e.message}`) await delay(60000) } } logKorean('KOREAN Crawler stopped') } const rebuildLinksForOutfitId = outfitId => { const outfit = db.prepare(` SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, profile_key FROM outfit_templates WHERE id=? AND active_flag=1 `).get(outfitId) db.prepare(`DELETE FROM outfit_item_links WHERE outfit_id=?`).run(outfitId) if (!outfit) return { links: [], unmatched: [] } const items = listActiveLibraryItems(outfit.profile_key) const result = evaluateOutfit(outfit, items) const stmt = db.prepare(` INSERT OR IGNORE INTO outfit_item_links (outfit_id, item_id, slot, rank_no, match_type, confidence, matched_keyword) VALUES (?, ?, ?, ?, ?, ?, ?) `) result.links.forEach(r => stmt.run(r.outfit_id, r.item_id, r.slot, r.rank_no, r.match_type, r.confidence, r.matched_keyword)) return result } const rebuildAllOutfitLinks = (profileKey = 'mature_relaxed') => { const outfits = db.prepare(` SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, profile_key FROM outfit_templates WHERE active_flag=1 AND profile_key=? ORDER BY id ASC `).all(profileKey) const items = listActiveLibraryItems(profileKey) const tx = db.transaction(() => { db.prepare(` DELETE FROM outfit_item_links WHERE outfit_id IN (SELECT id FROM outfit_templates WHERE profile_key=?) `).run(profileKey) const stmt = db.prepare(` INSERT OR IGNORE INTO outfit_item_links (outfit_id, item_id, slot, rank_no, match_type, confidence, matched_keyword) VALUES (?, ?, ?, ?, ?, ?, ?) `) const unmatched = [] let matchedCount = 0 outfits.forEach(outfit => { const r = evaluateOutfit(outfit, items) r.links.forEach(one => { stmt.run(one.outfit_id, one.item_id, one.slot, one.rank_no, one.match_type, one.confidence, one.matched_keyword) matchedCount += 1 }) unmatched.push(...r.unmatched) }) return { unmatched, matchedCount, outfitCount: outfits.length } }) return tx() } if (needRebuildFashionLinks) { try { rebuildAllOutfitLinks('mature_fashion40') } catch {} } const readUnmatchedReport = (profileKey = 'mature_relaxed') => { const outfits = db.prepare(` SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc FROM outfit_templates WHERE active_flag=1 AND profile_key=? ORDER BY id ASC `).all(profileKey) const items = listActiveLibraryItems(profileKey) const rows = [] outfits.forEach(outfit => { const r = evaluateOutfit(outfit, items) rows.push(...r.unmatched) }) return rows } const toBool = v => { if (v === true) return true const s = String(v || '').trim().toLowerCase() return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' } const anyText = v => String(v == null ? '' : v).trim() const extractJson = text => { const s = String(text || '').trim() if (!s) return null try { return JSON.parse(s) } catch {} const fenced = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/i) if (fenced && fenced[1]) { try { return JSON.parse(fenced[1]) } catch {} } const start = s.indexOf('{') const end = s.lastIndexOf('}') if (start >= 0 && end > start) { const body = s.slice(start, end + 1) try { return JSON.parse(body) } catch {} } return null } const getAiConfig = () => { const cfg = readConfig() const ai = (cfg && cfg.ai && typeof cfg.ai === 'object') ? cfg.ai : {} const envKey = String(process.env.DASHSCOPE_API_KEY || process.env.OPENAI_API_KEY || '').trim() return { enabled: ai.enabled !== false, apiKey: String(ai.api_key || envKey || '').trim(), apiBase: String(ai.api_base || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').trim(), visionModel: String(ai.vision_model || 'qwen-vl-plus').trim(), timeoutMs: Math.max(5000, Math.min(90000, Number(ai.timeout_ms || 30000))) } } const normalizeFeatures = raw => { const f = raw && typeof raw === 'object' ? raw : {} const items = (f.items && typeof f.items === 'object') ? f.items : {} const top = (items.top && typeof items.top === 'object') ? items.top : {} const bottom = (items.bottom && typeof items.bottom === 'object') ? items.bottom : {} const shoes = (items.shoes && typeof items.shoes === 'object') ? items.shoes : {} const outer = (items.outer && typeof items.outer === 'object') ? items.outer : {} const accessory = (items.accessory && typeof items.accessory === 'object') ? items.accessory : {} const overall = (f.overall && typeof f.overall === 'object') ? f.overall : {} const inferVisible = part => { const explicit = part && Object.prototype.hasOwnProperty.call(part, 'visible') ? part.visible : undefined if (explicit !== undefined) return toBool(explicit) const d = anyText(part && (part.desc || part.name)).toLowerCase() if (!d) return false if (['未知', '不明', '未见', '遮挡', '看不见', 'unknown', 'not visible', 'unseen'].some(k => d.includes(k))) return false return true } return { items: { top: { desc: anyText(top.desc || top.name), visible: inferVisible(top), color: anyText(top.color), fit: anyText(top.fit), logo_level: anyText(top.logo_level), pattern_level: anyText(top.pattern_level), fluorescent: toBool(top.fluorescent), shoulder: anyText(top.shoulder), length: anyText(top.length), collar: anyText(top.collar) }, bottom: { desc: anyText(bottom.desc || bottom.name), visible: inferVisible(bottom), color: anyText(bottom.color), fit: anyText(bottom.fit), logo_level: anyText(bottom.logo_level), pattern_level: anyText(bottom.pattern_level), fluorescent: toBool(bottom.fluorescent), hem_stacking: toBool(bottom.hem_stacking), too_tight: toBool(bottom.too_tight), sagging: toBool(bottom.sagging) }, shoes: { desc: anyText(shoes.desc || shoes.name), visible: inferVisible(shoes), color: anyText(shoes.color), fit: anyText(shoes.fit), logo_level: anyText(shoes.logo_level), pattern_level: anyText(shoes.pattern_level), fluorescent: toBool(shoes.fluorescent), clean: toBool(shoes.clean), bulky: toBool(shoes.bulky) }, outer: { desc: anyText(outer.desc || outer.name), visible: inferVisible(outer), color: anyText(outer.color), fit: anyText(outer.fit), logo_level: anyText(outer.logo_level), pattern_level: anyText(outer.pattern_level), fluorescent: toBool(outer.fluorescent) }, accessory: { desc: anyText(accessory.desc || accessory.name), visible: inferVisible(accessory), color: anyText(accessory.color), fit: anyText(accessory.fit), logo_level: anyText(accessory.logo_level), pattern_level: anyText(accessory.pattern_level), fluorescent: toBool(accessory.fluorescent) } }, overall: { color_count: Number.isFinite(Number(overall.color_count)) ? Number(overall.color_count) : 0, big_logo: toBool(overall.big_logo), bold_pattern: toBool(overall.bold_pattern), fluorescent: toBool(overall.fluorescent), vibe: anyText(overall.vibe) } } } const parseVisionResponse = (data, fallbackImageDataUrl) => { const content = data && data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : '' const rawText = Array.isArray(content) ? content.map(x => (x && typeof x === 'object' ? String(x.text || '') : String(x || ''))).join('\n') : String(content || '') const parsed = extractJson(rawText) if (parsed) return normalizeFeatures(parsed) return normalizeFeatures({ items: { top: { desc: '' }, bottom: { desc: '' }, shoes: { desc: '' }, outer: { desc: '' }, accessory: { desc: '' } }, overall: { color_count: 0, vibe: 'unknown' }, source: fallbackImageDataUrl ? 'fallback_empty_parse' : 'fallback_no_image' }) } const callVisionExtract = async imageDataUrl => { const ai = getAiConfig() if (!ai.enabled) throw new Error('ai_disabled') if (!ai.apiKey) throw new Error(`dashscope_api_key_missing 请配置以下任一方式: 1) 环境变量:DASHSCOPE_API_KEY 2) 文件配置:${configPath} 的 ai.api_key`) const systemPrompt = [ '你是穿搭图像结构化识别器。', '必须严格输出JSON,不要输出任何解释。', 'JSON字段:items.top/bottom/shoes/outer/accessory,每个对象至少包含 desc,color,fit,logo_level,pattern_level,fluorescent。', 'bottom 额外包含 hem_stacking,too_tight,sagging;top 额外包含 shoulder,length,collar;shoes 额外包含 clean,bulky。', 'overall包含 color_count,big_logo,bold_pattern,fluorescent,vibe。', 'logo_level取值建议:none/small/large;pattern_level取值建议:none/mild/bold;vibe取值建议:clean_energetic/neutral/mismatch。' ].join('\n') const userPrompt = '请识别该穿搭照片并返回指定JSON。' const payload = { model: ai.visionModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: [ { type: 'text', text: userPrompt }, { type: 'image_url', image_url: { url: imageDataUrl } } ] } ], temperature: 0.1 } const response = await axios.post(ai.apiBase, payload, { timeout: ai.timeoutMs, headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' } }) return parseVisionResponse(response && response.data, imageDataUrl) } const containsAny = (text, arr) => { const t = String(text || '').toLowerCase() return arr.some(k => t.includes(k)) } const evaluateRules = (features, profileKey = 'mature_relaxed') => { const rules = [] const pushRule = (id, title, pass, detail, weight, applicable = true) => rules.push({ id, title, pass, detail, weight, applicable }) const colorCount = Number(features.overall.color_count || 0) const allItems = [features.items.top, features.items.bottom, features.items.shoes, features.items.outer, features.items.accessory] const topVisible = toBool(features.items.top.visible) const bottomVisible = toBool(features.items.bottom.visible) const shoesVisible = toBool(features.items.shoes.visible) const hasLargeLogo = features.overall.big_logo || allItems.some(x => containsAny(x.logo_level, ['large', 'big', '明显', '大'])) const hasBoldPattern = features.overall.bold_pattern || allItems.some(x => containsAny(x.pattern_level, ['bold', '夸张'])) const hasFluorescent = features.overall.fluorescent || allItems.some(x => toBool(x.fluorescent)) const shoulderBad = containsAny(features.items.top.shoulder, ['sloped', 'uneven', '歪', '塌']) const overHip = containsAny(features.items.top.length, ['over_hip', 'overhip', '过臀', '太长']) const hemStacking = toBool(features.items.bottom.hem_stacking) const fitBad = containsAny(features.items.top.fit, ['tight', 'oversize', '过紧', '过松']) const collarBad = containsAny(features.items.top.collar, ['deformed', '变形', '皱']) const bottomBad = toBool(features.items.bottom.too_tight) || toBool(features.items.bottom.sagging) const shoesBad = !toBool(features.items.shoes.clean) || toBool(features.items.shoes.bulky) const vibeBad = containsAny(features.overall.vibe, ['mismatch', '老气', '装嫩']) || String(features.overall.vibe || '').trim() === '' const colorLimit = profileKey === 'mature_fashion40' ? 4 : 3 pushRule('r1', profileKey === 'mature_fashion40' ? '颜色不超过4种并保留时尚点缀' : '颜色不超过3种', colorCount > 0 ? colorCount <= colorLimit : true, `识别颜色数量:${colorCount || '未知'}`, 15) pushRule('r2', '全身无大logo、无夸张印花、无荧光色', !(hasLargeLogo || hasBoldPattern || hasFluorescent), `大logo:${hasLargeLogo} 印花夸张:${hasBoldPattern} 荧光:${hasFluorescent}`, 12) pushRule('r3', '肩线平整、衣长不过臀、裤脚不堆褶', !(shoulderBad || overHip || hemStacking), `肩线异常:${shoulderBad} 过臀:${overHip} 裤脚堆褶:${hemStacking}`, 15, topVisible || bottomVisible) pushRule('r4', '上衣合身不紧绷,领口不变形', !(fitBad || collarBad), `上衣版型异常:${fitBad} 领口异常:${collarBad}`, 10, topVisible) pushRule('r5', '下装不紧身不垮裆,线条利落', !bottomBad, `下装问题:${bottomBad}`, 10, bottomVisible) pushRule('r6', '鞋面干净,鞋型不笨重', !shoesBad, `鞋面或鞋型问题:${shoesBad}`, 10, shoesVisible) pushRule('r7', profileKey === 'mature_fashion40' ? '整体像懂时尚的40岁审美:有表达、不另类' : '整体气质清爽干练,有活力不装嫩', !vibeBad, `整体气质判断:${features.overall.vibe || '未知'}`, 18) const scoringRules = rules.filter(r => r.applicable !== false) const full = scoringRules.reduce((s, r) => s + r.weight, 0) || 1 const got = scoringRules.filter(r => r.pass).reduce((s, r) => s + r.weight, 0) const totalScore = Math.round(got * 100 / full) const failRules = scoringRules.filter(r => !r.pass) const level = totalScore >= 80 ? 'pass' : totalScore >= 60 ? 'warn' : 'fail' return { total_score: totalScore, result_level: level, rules, fail_rules: failRules, scoring_rule_count: scoringRules.length } } const evaluateLibraryConflict = (features, tempZone, profileKey = 'mature_relaxed') => { const pseudo = { id: -1, name: 'ai_audit', temp_zone: tempZone || '12-22℃', top_desc: features.items.top.desc, bottom_desc: features.items.bottom.desc, shoes_desc: features.items.shoes.desc, outer_desc: features.items.outer.desc, accessory_desc: features.items.accessory.desc } const items = listActiveLibraryItems(profileKey) const evalRes = evaluateOutfit(pseudo, items) const visibleBySlot = { top: toBool(features.items.top.visible), bottom: toBool(features.items.bottom.visible), shoes: toBool(features.items.shoes.visible), outer: toBool(features.items.outer.visible), accessory: toBool(features.items.accessory.visible) } const visibleSlots = Object.keys(visibleBySlot).filter(k => visibleBySlot[k]) const unmatchedVisible = evalRes.unmatched.filter(u => visibleBySlot[String(u.slot || '')]) const topBySlot = {} evalRes.links.forEach(r => { if (!topBySlot[r.slot] || r.rank_no < topBySlot[r.slot].rank_no) topBySlot[r.slot] = r }) const itemIds = Object.keys(topBySlot).map(k => Number(topBySlot[k].item_id)).filter(Boolean) let similar = [] if (itemIds.length) { const inClause = itemIds.map(() => '?').join(',') similar = db.prepare(` SELECT o.id, o.name, o.temp_zone, COUNT(1) AS overlap_count FROM outfit_item_links l JOIN outfit_templates o ON o.id = l.outfit_id WHERE o.active_flag=1 AND o.profile_key=? AND l.item_id IN (${inClause}) GROUP BY o.id, o.name, o.temp_zone ORDER BY overlap_count DESC, o.id DESC LIMIT 3 `).all(profileKey, ...itemIds) } const duplicateLikely = similar.length > 0 && Number(similar[0].overlap_count || 0) >= 3 const conflictList = [] if (unmatchedVisible.length) { unmatchedVisible.forEach(u => conflictList.push(`未匹配服饰库:${u.slot}=${u.text}`)) } if (duplicateLikely) { conflictList.push(`与已有搭配高度重复:${similar[0].name}`) } return { duplicate_likely: duplicateLikely, visible_slots: visibleSlots, unmatched_visible_count: unmatchedVisible.length, similar_outfits: similar, unmatched: evalRes.unmatched, unmatched_visible: unmatchedVisible, linked: evalRes.links, conflict_list: conflictList } } const buildAuditSummary = (ruleResult, libResult) => { if (ruleResult.result_level === 'pass' && !libResult.conflict_list.length) return '符合规则,且与服饰库无明显冲突' const parts = [] if (ruleResult.fail_rules.length) parts.push(`规则未通过${ruleResult.fail_rules.length}项`) if (libResult.unmatched_visible && libResult.unmatched_visible.length) parts.push(`可见部位库未匹配${libResult.unmatched_visible.length}项`) if (libResult.duplicate_likely) parts.push('与现有搭配重复度较高') return parts.join(',') || '请人工复核' } const ensureProfileLinks = profileKey => { const c = db.prepare(` SELECT COUNT(1) AS c FROM outfit_item_links l JOIN outfit_templates o ON o.id=l.outfit_id WHERE o.active_flag=1 AND o.profile_key=? `).get(profileKey) const linkedCount = c && c.c ? Number(c.c) : 0 if (linkedCount > 0) return try { rebuildAllOutfitLinks(profileKey) } catch {} } ensureProfileLinks('mature_relaxed') ensureProfileLinks('mature_fashion40') const bindRoutes = app => { // 端口分离:8977 为自动化脚本实例(含爬虫),8976 为日常实例(仅数据查询) const port = String(process.env.PORT || '8976') const isAutomationInstance = (port === '8977') app.use('/api/style_check', (req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate') res.set('Pragma', 'no-cache') res.set('Expires', '0') next() }) app.get('/api/style_check/ping', (req, res) => { res.json({ ok: true, ts: Date.now() }) }) app.get('/api/style_check/profiles', (req, res) => { try { const rows = db.prepare(`SELECT profile_key, profile_name, slogan, theme FROM style_profiles ORDER BY profile_key ASC`).all() res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/profile', (req, res) => { try { const row = db.prepare(`SELECT * FROM user_profile WHERE id=1`).get() || {} res.json({ ok: true, profile: row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.put('/api/style_check/profile', (req, res) => { try { const b = req.body || {} db.prepare(` UPDATE user_profile SET nickname=?, city=?, height_cm=?, weight_kg=?, style_keywords=?, avoid_keywords=?, updated_at=datetime('now') WHERE id=1 `).run( String(b.nickname || ''), String(b.city || ''), Number(b.height_cm || 0) || null, Number(b.weight_kg || 0) || null, String(b.style_keywords || ''), String(b.avoid_keywords || '') ) const row = db.prepare(`SELECT * FROM user_profile WHERE id=1`).get() || {} res.json({ ok: true, profile: row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/rules', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const rows = db.prepare(` SELECT id, rule_group, rule_text, weight, active_flag, sort_order FROM mirror_rules WHERE active_flag=1 AND profile_key=? ORDER BY sort_order ASC, id ASC `).all(profileKey) res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/ai/audit', async (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key) const scene = normScene(b.scene) const tempZone = normZone(b.temp_zone, b.temp_c) const imageDataUrl = String(b.image_data_url || '').trim() const hasImage = imageDataUrl.startsWith('data:image/') const override = b.features_override && typeof b.features_override === 'object' ? b.features_override : null if (!hasImage && !override) return res.status(400).json({ ok: false, error: 'image_or_features_required' }) const features = override ? normalizeFeatures(override) : await callVisionExtract(imageDataUrl) const ruleResult = evaluateRules(features, profileKey) const libraryResult = evaluateLibraryConflict(features, tempZone, profileKey) const visibleCount = Number(libraryResult.visible_slots ? libraryResult.visible_slots.length : 0) const unmatchedVisible = Number(libraryResult.unmatched_visible_count || 0) const unmatchedRatio = visibleCount > 0 ? unmatchedVisible / visibleCount : 0 const hardLibraryFail = visibleCount >= 3 && unmatchedVisible >= 3 && unmatchedRatio >= 0.75 const finalLevel = ruleResult.result_level === 'fail' || hardLibraryFail ? 'fail' : (ruleResult.result_level === 'warn' || libraryResult.conflict_list.length ? 'warn' : 'pass') const summary = buildAuditSummary(ruleResult, libraryResult) const score = Math.max(0, Math.min(100, Number(ruleResult.total_score || 0))) const imageHash = hasImage ? crypto.createHash('sha1').update(imageDataUrl.slice(0, 2048)).digest('hex') : 'override' db.prepare(` INSERT INTO ai_audits (profile_key, scene, temp_zone, image_hash, total_score, result_level, summary, features_json, rules_json, library_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( profileKey, scene, tempZone, imageHash, score, finalLevel, summary, JSON.stringify(features), JSON.stringify(ruleResult), JSON.stringify(libraryResult) ) res.json({ ok: true, report: { profile_key: profileKey, scene, temp_zone: tempZone, total_score: score, result_level: finalLevel, summary, features, rule_result: ruleResult, library_result: libraryResult } }) } catch (e) { const msg = String((e && e.message) || e) res.status(500).json({ ok: false, error: msg }) } }) app.get('/api/style_check/ai/audits/recent', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const limit = Math.max(1, Math.min(50, Number(req.query.limit || 10))) const rows = db.prepare(` SELECT id, profile_key, scene, temp_zone, image_hash, total_score, result_level, summary, features_json, rules_json, library_json, created_at FROM ai_audits WHERE profile_key=? ORDER BY id DESC LIMIT ? `).all(profileKey, limit).map(r => ({ ...r, features: (() => { try { return JSON.parse(String(r.features_json || '{}')) } catch { return {} } })(), rules: (() => { try { return JSON.parse(String(r.rules_json || '{}')) } catch { return {} } })(), library: (() => { try { return JSON.parse(String(r.library_json || '{}')) } catch { return {} } })() })) res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/outfits', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const scene = String(req.query.scene || '').trim() const tempZone = String(req.query.temp_zone || '').trim() const q = String(req.query.q || '').trim().toLowerCase() const includeLinks = String(req.query.include_links || '') === '1' let rows = db.prepare(` SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, active_flag, created_at, updated_at FROM outfit_templates WHERE active_flag=1 AND profile_key=? ORDER BY id DESC `).all(profileKey) if (scene) rows = rows.filter(r => String(r.scene) === scene) if (tempZone) rows = rows.filter(r => String(r.temp_zone) === tempZone) if (q) { rows = rows.filter(r => { const s = [r.name, r.top_desc, r.bottom_desc, r.shoes_desc, r.outer_desc, r.accessory_desc, r.notes].map(v => String(v || '').toLowerCase()).join(' ') return s.includes(q) }) } if (includeLinks) { rows = rows.map(r => { const linked = db.prepare(` SELECT l.slot, l.rank_no, l.confidence, i.item_name FROM outfit_item_links l JOIN apparel_items i ON i.id = l.item_id WHERE l.outfit_id=? ORDER BY l.slot ASC, l.rank_no ASC `).all(r.id) const grouped = {} linked.forEach(one => { const k = String(one.slot || '') if (!grouped[k]) grouped[k] = [] grouped[k].push(String(one.item_name || '')) }) const linkedItemsSummary = Object.keys(grouped).map(k => `${k}:${grouped[k].join(' / ')}`).join(';') return { ...r, linked_items: linked, linked_items_summary: linkedItemsSummary } }) } res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/library/items', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const slot = String(req.query.slot || '').trim() const tempZone = String(req.query.temp_zone || '').trim() const q = String(req.query.q || '').trim().toLowerCase() let rows = db.prepare(` SELECT id, item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, updated_at FROM apparel_items WHERE active_flag=1 AND profile_key=? ORDER BY slot ASC, id ASC `).all(profileKey) if (slot) rows = rows.filter(r => String(r.slot) === slot) if (tempZone) rows = rows.filter(r => String(r.temp_zone) === tempZone || String(r.temp_zone) === 'ALL') if (q) { rows = rows.filter(r => { const s = [r.item_name, r.category, r.brand_options, r.keywords, r.notes].map(v => String(v || '').toLowerCase()).join(' ') return s.includes(q) }) } rows = rows.map(r => { const images = listItemImages(Number(r.id), profileKey).filter(x => isLocalImageUrl(x.image_url)) return { ...r, image_count: images.length, image_preview: images.slice(0, 1).map(x => x.image_url)[0] || '' } }) res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/library/items/:id/images', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const itemId = Number(req.params.id) if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' }) const item = db.prepare(`SELECT id FROM apparel_items WHERE id=? AND profile_key=? AND active_flag=1`).get(itemId, profileKey) if (!item) return res.status(404).json({ ok: false, error: 'item_not_found' }) const rows = listItemImages(itemId, profileKey).filter(x => isLocalImageUrl(x.image_url)) res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/library/items', (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key) const itemName = String(b.item_name || '').trim() const slot = String(b.slot || '').trim() const category = String(b.category || '').trim() const tempZone = String(b.temp_zone || 'ALL').trim() || 'ALL' if (!itemName) return res.status(400).json({ ok: false, error: 'item_name_required' }) if (!slot) return res.status(400).json({ ok: false, error: 'slot_required' }) if (!category) return res.status(400).json({ ok: false, error: 'category_required' }) const info = db.prepare(` INSERT INTO apparel_items (item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, active_flag, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) `).run( itemName, slot, category, tempZone, String(b.season_tag || ''), String(b.brand_options || ''), Number.isFinite(Number(b.price_min)) ? Number(b.price_min) : null, Number.isFinite(Number(b.price_max)) ? Number(b.price_max) : null, String(b.keywords || ''), String(b.notes || ''), profileKey ) const itemId = Number(info.lastInsertRowid) const row = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId) res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.put('/api/style_check/library/items/:id', (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key || req.query.profile_key) const itemId = Number(req.params.id) if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' }) const current = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId) if (!current) return res.status(404).json({ ok: false, error: 'item_not_found' }) if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' }) db.prepare(` UPDATE apparel_items SET item_name=?, slot=?, category=?, temp_zone=?, season_tag=?, brand_options=?, price_min=?, price_max=?, keywords=?, notes=?, updated_at=datetime('now') WHERE id=? `).run( String(b.item_name == null ? current.item_name : b.item_name), String(b.slot == null ? current.slot : b.slot), String(b.category == null ? current.category : b.category), String(b.temp_zone == null ? current.temp_zone : b.temp_zone), String(b.season_tag == null ? current.season_tag : b.season_tag), String(b.brand_options == null ? current.brand_options : b.brand_options), Number.isFinite(Number(b.price_min)) ? Number(b.price_min) : current.price_min, Number.isFinite(Number(b.price_max)) ? Number(b.price_max) : current.price_max, String(b.keywords == null ? current.keywords : b.keywords), String(b.notes == null ? current.notes : b.notes), itemId ) const row = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId) res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/library/items/:id/images', async (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key || req.query.profile_key) const itemId = Number(req.params.id) if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' }) const item = db.prepare(`SELECT id FROM apparel_items WHERE id=? AND profile_key=? AND active_flag=1`).get(itemId, profileKey) if (!item) return res.status(404).json({ ok: false, error: 'item_not_found' }) const imageDataUrl = String(b.image_data_url || '').trim() const directUrl = String(b.image_url || '').trim() let imageUrl = '' let sourceType = 'manual_url_fetch' if (imageDataUrl) { imageUrl = saveUploadedLibraryImage(profileKey, itemId, imageDataUrl) sourceType = 'manual_upload' } else if (directUrl.startsWith('http://') || directUrl.startsWith('https://')) { imageUrl = await downloadImageToLocal(directUrl, profileKey, itemId, Number(b.sort_order || 999)) } else { return res.status(400).json({ ok: false, error: 'image_data_url_or_image_url_required' }) } const sortOrder = Math.max(1, Number(b.sort_order || 999)) const caption = String(b.caption || '').trim() const info = db.prepare(` INSERT INTO apparel_item_images (item_id, profile_key, image_url, source_type, caption, sort_order) VALUES (?, ?, ?, ?, ?, ?) `).run(itemId, profileKey, imageUrl, sourceType, caption, sortOrder) const row = db.prepare(`SELECT * FROM apparel_item_images WHERE id=?`).get(info.lastInsertRowid) res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.delete('/api/style_check/library/images/:imageId', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key || (req.body || {}).profile_key) const imageId = Number(req.params.imageId) if (!Number.isFinite(imageId) || imageId <= 0) return res.status(400).json({ ok: false, error: 'invalid_image_id' }) const row = db.prepare(`SELECT * FROM apparel_item_images WHERE id=? AND profile_key=?`).get(imageId, profileKey) if (!row) return res.status(404).json({ ok: false, error: 'image_not_found' }) db.prepare(`DELETE FROM apparel_item_images WHERE id=?`).run(imageId) const url = String(row.image_url || '') if (url.startsWith(`${localImageUrlPrefix}/`)) { const abs = path.join(process.cwd(), 'public', url.replace(/^\//, '').replace(/\//g, path.sep)) if (fs.existsSync(abs)) { try { fs.unlinkSync(abs) } catch {} } } res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/library/images/missing_report', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const rows = getImageMissingReport(profileKey) res.json({ ok: true, profile_key: profileKey, missing_count: rows.length, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/library/images/fetch_local', async (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key) const targetPerItem = Math.max(3, Math.min(8, Number(b.target_per_item || 5))) const force = String(b.force || '1') !== '0' const result = await ensureItemLocalImages(profileKey, targetPerItem, force, 0) res.json({ ok: true, profile_key: profileKey, ...result }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) // 爬虫控制接口 & 后台抓取:仅在自动化实例(8977)启用 if (isAutomationInstance) { app.post('/api/style_check/library/images/bg_fetch/start', (req, res) => { startBackgroundFetch() res.json({ ok: true, message: 'Background fetch started' }) }) app.post('/api/style_check/library/images/bg_fetch/stop', (req, res) => { stopBackgroundFetch() res.json({ ok: true, message: 'Background fetch stopped' }) }) } if (isAutomationInstance) { app.get('/api/style_check/library/images/bg_fetch/status', (req, res) => { res.json({ ok: true, is_running: isBackgroundFetchEnabled, report: getBgFetchReport() }) }) } // Start background fetch automatically when the app starts (仅自动化实例) if (isAutomationInstance) { startBackgroundFetch() } app.post('/api/style_check/library/rebuild_links', (req, res) => { try { const profileKey = normProfileKey((req.body || {}).profile_key) const result = rebuildAllOutfitLinks(profileKey) res.json({ ok: true, profile_key: profileKey, matched_count: Number(result.matchedCount || 0), outfit_count: Number(result.outfitCount || 0), unmatched_count: result.unmatched.length, unmatched: result.unmatched }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/library/unmatched_report', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const rows = readUnmatchedReport(profileKey) res.json({ ok: true, unmatched_count: rows.length, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/outfits', (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key) const name = String(b.name || '').trim() if (!name) return res.status(400).json({ ok: false, error: 'name_required' }) const scene = normScene(b.scene) const tempZone = normZone(b.temp_zone, b.temp_c) const info = db.prepare(` INSERT INTO outfit_templates (name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, profile_key, active_flag, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) `).run( name, scene, tempZone, String(b.top_desc || ''), String(b.bottom_desc || ''), String(b.shoes_desc || ''), String(b.outer_desc || ''), String(b.accessory_desc || ''), String(b.notes || ''), profileKey ) const row = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(info.lastInsertRowid) try { rebuildLinksForOutfitId(Number(info.lastInsertRowid)) } catch {} res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.put('/api/style_check/outfits/:id', (req, res) => { try { const id = Number(req.params.id) if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ ok: false, error: 'invalid_id' }) const b = req.body || {} const profileKey = normProfileKey(b.profile_key || req.query.profile_key || 'mature_relaxed') const current = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(id) if (!current) return res.status(404).json({ ok: false, error: 'not_found' }) if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' }) const name = String(b.name == null ? current.name : b.name).trim() if (!name) return res.status(400).json({ ok: false, error: 'name_required' }) const scene = normScene(b.scene == null ? current.scene : b.scene) const tempZone = normZone(b.temp_zone == null ? current.temp_zone : b.temp_zone, b.temp_c) db.prepare(` UPDATE outfit_templates SET name=?, scene=?, temp_zone=?, top_desc=?, bottom_desc=?, shoes_desc=?, outer_desc=?, accessory_desc=?, notes=?, updated_at=datetime('now') WHERE id=? `).run( name, scene, tempZone, String(b.top_desc == null ? current.top_desc : b.top_desc), String(b.bottom_desc == null ? current.bottom_desc : b.bottom_desc), String(b.shoes_desc == null ? current.shoes_desc : b.shoes_desc), String(b.outer_desc == null ? current.outer_desc : b.outer_desc), String(b.accessory_desc == null ? current.accessory_desc : b.accessory_desc), String(b.notes == null ? current.notes : b.notes), id ) const row = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(id) try { rebuildLinksForOutfitId(id) } catch {} res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.delete('/api/style_check/outfits/:id', (req, res) => { try { const id = Number(req.params.id) const profileKey = normProfileKey(req.query.profile_key || (req.body || {}).profile_key || 'mature_relaxed') if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ ok: false, error: 'invalid_id' }) const current = db.prepare(`SELECT profile_key FROM outfit_templates WHERE id=?`).get(id) if (!current) return res.status(404).json({ ok: false, error: 'not_found' }) if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' }) db.prepare(`UPDATE outfit_templates SET active_flag=0, updated_at=datetime('now') WHERE id=?`).run(id) db.prepare(`DELETE FROM outfit_item_links WHERE outfit_id=?`).run(id) res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/today', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const scene = normScene(req.query.scene) const tempZone = normZone(req.query.temp_zone, req.query.temp_c) const rows = db.prepare(` SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes FROM outfit_templates WHERE active_flag=1 AND profile_key=? AND scene=? AND temp_zone=? ORDER BY id ASC `).all(profileKey, scene, tempZone) const fallbacks = rows.length ? [] : db.prepare(` SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes FROM outfit_templates WHERE active_flag=1 AND profile_key=? AND scene=? ORDER BY id ASC LIMIT 3 `).all(profileKey, scene) const chosen = rows[0] || fallbacks[0] || null res.json({ ok: true, profile_key: profileKey, scene, temp_zone: tempZone, chosen, alternatives: (rows.length ? rows.slice(1, 4) : fallbacks.slice(1, 4)) }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/checks', (req, res) => { try { const b = req.body || {} const profileKey = normProfileKey(b.profile_key) const scene = normScene(b.scene) const tempZone = normZone(b.temp_zone, b.temp_c) const totalScore = Number.isFinite(Number(b.total_score)) ? Number(b.total_score) : 0 const resultLevel = String(b.result_level || (totalScore >= 80 ? 'pass' : totalScore >= 60 ? 'warn' : 'fail')) const issues = Array.isArray(b.issues) ? b.issues.slice(0, 50) : [] const today = String((b.check_date || '')).trim() || new Date().toISOString().slice(0, 10) const info = db.prepare(` INSERT INTO mirror_checks (profile_key, check_date, scene, temp_zone, total_score, result_level, issues_json) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(profileKey, today, scene, tempZone, totalScore, resultLevel, JSON.stringify(issues)) const row = db.prepare(`SELECT * FROM mirror_checks WHERE id=?`).get(info.lastInsertRowid) res.json({ ok: true, row }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.get('/api/style_check/checks/recent', (req, res) => { try { const profileKey = normProfileKey(req.query.profile_key) const limit = Math.max(1, Math.min(60, Number(req.query.limit || 7))) const rows = db.prepare(` SELECT id, profile_key, check_date, scene, temp_zone, total_score, result_level, issues_json, created_at FROM mirror_checks WHERE profile_key=? ORDER BY id DESC LIMIT ? `).all(profileKey, limit).map(r => ({ ...r, issues: (() => { try { const arr = JSON.parse(String(r.issues_json || '[]')) return Array.isArray(arr) ? arr : [] } catch { return [] } })() })) res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) // === WAVE API === app.get('/api/style_check/wave', (req, res) => { try { const page = Math.max(1, parseInt(req.query.page || '1', 10)) const limit = Math.max(1, Math.min(500, parseInt(req.query.limit || '20', 10))) const offset = (page - 1) * limit const rows = db.prepare(` SELECT id, image_url, source_url, evaluation, notes, created_at FROM wave_references WHERE image_url != 'REJECTED' ORDER BY id DESC LIMIT ? OFFSET ? `).all(limit, offset) const total = db.prepare(`SELECT count(*) as c FROM wave_references WHERE image_url != 'REJECTED'`).get().c res.json({ ok: true, rows, total, page, limit }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) if (isAutomationInstance) { app.get('/api/style_check/wave/status', (req, res) => { try { const totalProcessed = db.prepare(`SELECT count(*) as c FROM wave_references`).get().c const totalApproved = db.prepare(`SELECT count(*) as c FROM wave_references WHERE image_url != 'REJECTED'`).get().c res.json({ ok: true, isRunning: waveCrawlerState.isRunning, currentPage: waveCrawlerState.currentPage, logs: waveCrawlerState.logs, totalProcessed, totalApproved }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) } // wave 爬虫控制接口:仅在自动化实例(8977)启用 if (isAutomationInstance) { app.post('/api/style_check/wave/toggle', (req, res) => { try { if (waveCrawlerState.isRunning) { waveCrawlerState.isRunning = false logWave('User requested stop') } else { startWaveCrawler() } res.json({ ok: true, isRunning: waveCrawlerState.isRunning }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/wave/upload_links', (req, res) => { try { const b = req.body || {} if (Array.isArray(b.links) && b.links.length > 0) { waveCrawlerState.manualQueue.push(...b.links) logWave(`Received ${b.links.length} manual links. Queue size: ${waveCrawlerState.manualQueue.length}`) if (!waveCrawlerState.isRunning) { startWaveCrawler() } } res.json({ ok: true, queueLength: waveCrawlerState.manualQueue.length, isRunning: waveCrawlerState.isRunning }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) } app.get('/api/style_check/korean', (req, res) => { try { const page = Math.max(1, parseInt(req.query.page || '1', 10)) const limit = Math.max(1, Math.min(500, parseInt(req.query.limit || '20', 10))) const offset = (page - 1) * limit const rows = db.prepare(` SELECT id, image_url, source_url, evaluation, notes, created_at FROM korean_references WHERE image_url != 'REJECTED' ORDER BY id DESC LIMIT ? OFFSET ? `).all(limit, offset) const total = db.prepare(`SELECT count(*) as c FROM korean_references WHERE image_url != 'REJECTED'`).get().c res.json({ ok: true, rows, total, page, limit }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) if (isAutomationInstance) { app.get('/api/style_check/korean/status', (req, res) => { try { const totalProcessed = db.prepare(`SELECT count(*) as c FROM korean_references`).get().c const totalApproved = db.prepare(`SELECT count(*) as c FROM korean_references WHERE image_url != 'REJECTED'`).get().c res.json({ ok: true, isRunning: koreanCrawlerState.isRunning, currentPage: koreanCrawlerState.currentPage, logs: koreanCrawlerState.logs, totalProcessed, totalApproved }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) } // korean 爬虫控制接口:仅在自动化实例(8977)启用 if (isAutomationInstance) { app.post('/api/style_check/korean/toggle', (req, res) => { try { if (koreanCrawlerState.isRunning) { koreanCrawlerState.isRunning = false logKorean('User requested stop') } else { startKoreanCrawler() } res.json({ ok: true, isRunning: koreanCrawlerState.isRunning }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) app.post('/api/style_check/korean/upload_links', (req, res) => { try { const b = req.body || {} if (Array.isArray(b.links) && b.links.length > 0) { koreanCrawlerState.manualQueue.push(...b.links) logKorean(`Received ${b.links.length} manual links. Queue size: ${koreanCrawlerState.manualQueue.length}`) if (!koreanCrawlerState.isRunning) { startKoreanCrawler() } } res.json({ ok: true, queueLength: koreanCrawlerState.manualQueue.length, isRunning: koreanCrawlerState.isRunning }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) } // 非自动化实例(8976):将爬虫控制接口反向代理到 8977 自动化实例,避免前端跨域 / Mixed Content if (!isAutomationInstance) { const AUTOMATION_TARGET = process.env.AUTOMATION_TARGET || 'http://127.0.0.1:8977' const proxyToAutomation = async (req, res) => { try { const r = await axios({ method: req.method, url: AUTOMATION_TARGET + req.originalUrl, data: req.body, headers: { 'content-type': req.headers['content-type'] || 'application/json' }, timeout: 15000, validateStatus: () => true, responseType: 'json' }) res.status(r.status).json(r.data) } catch (e) { res.status(502).json({ ok: false, error: '8977 自动化服务不可达: ' + String(e.message || e) }) } } // POST 接口:控制类 const automationPostRoutes = [ '/api/style_check/library/images/bg_fetch/start', '/api/style_check/library/images/bg_fetch/stop', '/api/style_check/wave/toggle', '/api/style_check/wave/upload_links', '/api/style_check/korean/toggle', '/api/style_check/korean/upload_links' ] automationPostRoutes.forEach(p => app.post(p, proxyToAutomation)) // GET 接口:状态类(isRunning/currentPage/logs 在 8977 进程内存,必须反代) const automationGetRoutes = [ '/api/style_check/wave/status', '/api/style_check/korean/status', '/api/style_check/library/images/bg_fetch/status' ] automationGetRoutes.forEach(p => app.get(p, proxyToAutomation)) } } module.exports = { bindRoutes }