Files
Toolbox/src/server/style_check.js
T

2469 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }