|
|
import Database from 'better-sqlite3'; |
|
|
import path from 'path'; |
|
|
import fs from 'fs'; |
|
|
import { fileURLToPath } from 'url'; |
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url); |
|
|
const __dirname = path.dirname(__filename); |
|
|
|
|
|
const DATA_DIR = path.join(__dirname, 'data'); |
|
|
const DB_PATH = path.join(DATA_DIR, 'lifekline.db'); |
|
|
|
|
|
|
|
|
if (!fs.existsSync(DATA_DIR)) { |
|
|
fs.mkdirSync(DATA_DIR, { recursive: true }); |
|
|
} |
|
|
|
|
|
|
|
|
const db = new Database(DB_PATH); |
|
|
|
|
|
|
|
|
db.pragma('journal_mode = WAL'); |
|
|
|
|
|
|
|
|
const initDatabase = () => { |
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS users ( |
|
|
id TEXT PRIMARY KEY, |
|
|
email TEXT UNIQUE NOT NULL, |
|
|
password_hash TEXT NOT NULL, |
|
|
points INTEGER DEFAULT 1000, |
|
|
role TEXT DEFAULT 'user', |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
last_login_at TEXT, |
|
|
login_count INTEGER DEFAULT 0 |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS user_inputs ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT, |
|
|
name TEXT, |
|
|
gender TEXT NOT NULL, |
|
|
birth_year INTEGER, |
|
|
year_pillar TEXT, |
|
|
month_pillar TEXT, |
|
|
day_pillar TEXT, |
|
|
hour_pillar TEXT, |
|
|
start_age INTEGER, |
|
|
first_da_yun TEXT, |
|
|
model_name TEXT, |
|
|
api_base_url TEXT, |
|
|
use_custom_api INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
ip_address TEXT, |
|
|
user_agent TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS analyses ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT, |
|
|
input_id TEXT, |
|
|
cost INTEGER DEFAULT 0, |
|
|
model_used TEXT, |
|
|
chart_data TEXT, |
|
|
analysis_data TEXT, |
|
|
created_at TEXT NOT NULL, |
|
|
processing_time_ms INTEGER, |
|
|
status TEXT DEFAULT 'completed', |
|
|
error_message TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id), |
|
|
FOREIGN KEY (input_id) REFERENCES user_inputs(id) |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS system_logs ( |
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
|
level TEXT NOT NULL, |
|
|
message TEXT NOT NULL, |
|
|
details TEXT, |
|
|
user_id TEXT, |
|
|
ip_address TEXT, |
|
|
created_at TEXT NOT NULL |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS knowledge_articles ( |
|
|
id TEXT PRIMARY KEY, |
|
|
slug TEXT UNIQUE NOT NULL, |
|
|
title TEXT NOT NULL, |
|
|
category TEXT NOT NULL, |
|
|
level INTEGER DEFAULT 1, |
|
|
tags TEXT, |
|
|
summary TEXT NOT NULL, |
|
|
content TEXT NOT NULL, |
|
|
view_count INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
published INTEGER DEFAULT 1 |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS cases ( |
|
|
id TEXT PRIMARY KEY, |
|
|
title TEXT NOT NULL, |
|
|
persona TEXT NOT NULL, |
|
|
curve_type TEXT NOT NULL, |
|
|
chart_data TEXT NOT NULL, |
|
|
highlights TEXT, |
|
|
narrative TEXT NOT NULL, |
|
|
tags TEXT, |
|
|
view_count INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
published INTEGER DEFAULT 1 |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS celebrity_cases ( |
|
|
id TEXT PRIMARY KEY, |
|
|
name TEXT NOT NULL, |
|
|
name_cn TEXT NOT NULL, |
|
|
category TEXT NOT NULL, |
|
|
category_cn TEXT NOT NULL, |
|
|
birth_date TEXT NOT NULL, |
|
|
birth_location_city TEXT, |
|
|
birth_location_lat REAL, |
|
|
birth_location_lng REAL, |
|
|
description TEXT NOT NULL, |
|
|
tags TEXT, |
|
|
year_pillar TEXT, |
|
|
month_pillar TEXT, |
|
|
day_pillar TEXT, |
|
|
hour_pillar TEXT, |
|
|
chart_data TEXT, |
|
|
highlights TEXT, |
|
|
hotness_score INTEGER DEFAULT 0, |
|
|
view_count INTEGER DEFAULT 0, |
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP, |
|
|
published INTEGER DEFAULT 1 |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS user_profiles ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT NOT NULL, |
|
|
name TEXT NOT NULL, |
|
|
gender TEXT NOT NULL CHECK (gender IN ('male', 'female')), |
|
|
birth_year INTEGER NOT NULL, |
|
|
year_pillar TEXT, |
|
|
month_pillar TEXT, |
|
|
day_pillar TEXT, |
|
|
hour_pillar TEXT, |
|
|
start_age INTEGER, |
|
|
first_da_yun TEXT, |
|
|
birth_place TEXT, |
|
|
is_default INTEGER DEFAULT 0 CHECK (is_default IN (0, 1)), |
|
|
is_deleted INTEGER DEFAULT 0 CHECK (is_deleted IN (0, 1)), |
|
|
deleted_at TEXT, |
|
|
core_document_status TEXT DEFAULT 'pending' CHECK (core_document_status IN ('pending', 'generating', 'ready', 'failed')), |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
try { |
|
|
db.exec(`ALTER TABLE user_profiles ADD COLUMN is_deleted INTEGER DEFAULT 0`); |
|
|
} catch (e) { } |
|
|
try { |
|
|
db.exec(`ALTER TABLE user_profiles ADD COLUMN deleted_at TEXT`); |
|
|
} catch (e) { } |
|
|
try { |
|
|
db.exec(`ALTER TABLE user_profiles ADD COLUMN core_document_status TEXT DEFAULT 'pending'`); |
|
|
} catch (e) { } |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS share_rewards ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT NOT NULL, |
|
|
platform TEXT NOT NULL CHECK (platform IN ('wechat', 'weibo', 'qq', 'douyin', 'xiaohongshu', 'other')), |
|
|
analysis_id TEXT, |
|
|
points_rewarded INTEGER DEFAULT 300, |
|
|
shared_at TEXT NOT NULL, |
|
|
ip_address TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS fortune_cache ( |
|
|
id TEXT PRIMARY KEY, |
|
|
profile_id TEXT NOT NULL, |
|
|
fortune_type TEXT NOT NULL CHECK (fortune_type IN ('daily', 'monthly', 'yearly')), |
|
|
date_key TEXT NOT NULL, |
|
|
predictions TEXT NOT NULL, |
|
|
created_at TEXT NOT NULL, |
|
|
expires_at TEXT NOT NULL, |
|
|
FOREIGN KEY (profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS user_preferences ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT NOT NULL UNIQUE, |
|
|
theme TEXT DEFAULT 'light' CHECK (theme IN ('light', 'dark')), |
|
|
notifications_enabled INTEGER DEFAULT 1 CHECK (notifications_enabled IN (0, 1)), |
|
|
default_profile_id TEXT, |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|
|
FOREIGN KEY (default_profile_id) REFERENCES user_profiles(id) ON DELETE SET NULL |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS daily_fortune_detail ( |
|
|
id TEXT PRIMARY KEY, |
|
|
profile_id TEXT NOT NULL, |
|
|
date_key TEXT NOT NULL, |
|
|
fortune_data TEXT NOT NULL, |
|
|
ai_enhanced INTEGER DEFAULT 0, |
|
|
model_used TEXT, |
|
|
points_cost INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
expires_at TEXT, |
|
|
FOREIGN KEY (profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS monthly_kline_cache ( |
|
|
id TEXT PRIMARY KEY, |
|
|
profile_id TEXT NOT NULL, |
|
|
year INTEGER NOT NULL, |
|
|
kline_data TEXT NOT NULL, |
|
|
model_used TEXT, |
|
|
created_at TEXT NOT NULL, |
|
|
expires_at TEXT NOT NULL, |
|
|
FOREIGN KEY (profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS daily_kline_cache ( |
|
|
id TEXT PRIMARY KEY, |
|
|
profile_id TEXT NOT NULL, |
|
|
year INTEGER NOT NULL, |
|
|
month INTEGER NOT NULL, |
|
|
kline_data TEXT NOT NULL, |
|
|
model_used TEXT, |
|
|
created_at TEXT NOT NULL, |
|
|
expires_at TEXT NOT NULL, |
|
|
FOREIGN KEY (profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS bazi_analysis_cache ( |
|
|
id TEXT PRIMARY KEY, |
|
|
bazi_hash TEXT NOT NULL, -- 四柱哈希 (SHA256前16位) |
|
|
gender TEXT NOT NULL, -- 性别 (male/female) |
|
|
|
|
|
-- L1: 结构数据 (JSON) |
|
|
structural_data TEXT NOT NULL, -- 五行、大运、十神、八字数组 |
|
|
|
|
|
-- L2: 命理骨架 (JSON) - 核心分析内容 |
|
|
personality_core TEXT, -- 性格核心特征 |
|
|
career_core TEXT, -- 事业核心方向 |
|
|
wealth_core TEXT, -- 财运核心特征 |
|
|
marriage_core TEXT, -- 婚姻核心特点 |
|
|
health_core TEXT, -- 健康注意事项 |
|
|
|
|
|
-- L3: K线数据 (JSON) |
|
|
kline_data TEXT, -- 100年K线数据 |
|
|
peak_years TEXT, -- 巅峰年份数组 |
|
|
trough_years TEXT, -- 低谷年份数组 |
|
|
|
|
|
-- L4: 扩展分析维度 (JSON) |
|
|
crypto_core TEXT, -- 币圈交易分析 |
|
|
monthly_fortune TEXT, -- 本月运势 |
|
|
yearly_fortune TEXT, -- 今年运势 |
|
|
lucky_elements TEXT, -- 幸运元素 (颜色/方向/属相/数字) |
|
|
physical_traits TEXT, -- 个人特征 (相貌/体型/皮肤/性格) |
|
|
key_dates TEXT, -- 重点日期 |
|
|
past_events TEXT, -- 过往大事件 |
|
|
future_events TEXT, -- 今年大事件 |
|
|
|
|
|
-- 元数据 |
|
|
model_used TEXT, -- 使用的AI模型 |
|
|
version INTEGER DEFAULT 1, -- 算法版本,用于缓存失效 |
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS vouchers ( |
|
|
id TEXT PRIMARY KEY, |
|
|
points INTEGER NOT NULL, |
|
|
created_at TEXT NOT NULL, |
|
|
used_at TEXT, |
|
|
used_by TEXT, |
|
|
FOREIGN KEY (used_by) REFERENCES users(id) |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS email_subscriptions ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT NOT NULL UNIQUE, |
|
|
email_verified INTEGER DEFAULT 0, |
|
|
verification_token TEXT, |
|
|
verification_sent_at TEXT, |
|
|
verified_at TEXT, |
|
|
sub_daily_fortune INTEGER DEFAULT 0, |
|
|
sub_monthly_fortune INTEGER DEFAULT 0, |
|
|
sub_yearly_fortune INTEGER DEFAULT 0, |
|
|
sub_birthday_reminder INTEGER DEFAULT 0, |
|
|
sub_low_points INTEGER DEFAULT 1, |
|
|
sub_feature_updates INTEGER DEFAULT 1, |
|
|
sub_promotions INTEGER DEFAULT 0, |
|
|
binding_reward_claimed INTEGER DEFAULT 0, |
|
|
subscription_reward_claimed INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
updated_at TEXT, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS email_logs ( |
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
|
user_id TEXT, |
|
|
email_type TEXT NOT NULL, |
|
|
recipient TEXT NOT NULL, |
|
|
subject TEXT NOT NULL, |
|
|
status TEXT DEFAULT 'pending', |
|
|
error_message TEXT, |
|
|
sent_at TEXT, |
|
|
created_at TEXT NOT NULL |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE TABLE IF NOT EXISTS password_reset_tokens ( |
|
|
id TEXT PRIMARY KEY, |
|
|
user_id TEXT NOT NULL, |
|
|
token TEXT NOT NULL UNIQUE, |
|
|
expires_at TEXT NOT NULL, |
|
|
used INTEGER DEFAULT 0, |
|
|
created_at TEXT NOT NULL, |
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|
|
) |
|
|
`); |
|
|
|
|
|
|
|
|
db.exec(` |
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); |
|
|
CREATE INDEX IF NOT EXISTS idx_user_inputs_user_id ON user_inputs(user_id); |
|
|
CREATE INDEX IF NOT EXISTS idx_user_inputs_created_at ON user_inputs(created_at); |
|
|
CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON analyses(user_id); |
|
|
CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON analyses(created_at); |
|
|
CREATE INDEX IF NOT EXISTS idx_system_logs_created_at ON system_logs(created_at); |
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge_articles(category); |
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_slug ON knowledge_articles(slug); |
|
|
CREATE INDEX IF NOT EXISTS idx_cases_curve_type ON cases(curve_type); |
|
|
CREATE INDEX IF NOT EXISTS idx_celebrity_cases_category ON celebrity_cases(category); |
|
|
CREATE INDEX IF NOT EXISTS idx_celebrity_cases_hotness ON celebrity_cases(hotness_score); |
|
|
|
|
|
-- 新表索引 |
|
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id ON user_profiles(user_id); |
|
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id_default ON user_profiles(user_id, is_default); |
|
|
CREATE INDEX IF NOT EXISTS idx_share_rewards_user_id ON share_rewards(user_id); |
|
|
CREATE INDEX IF NOT EXISTS idx_share_rewards_shared_at ON share_rewards(shared_at); |
|
|
CREATE INDEX IF NOT EXISTS idx_fortune_cache_profile_type_date ON fortune_cache(profile_id, fortune_type, date_key); |
|
|
CREATE INDEX IF NOT EXISTS idx_fortune_cache_expires_at ON fortune_cache(expires_at); |
|
|
CREATE INDEX IF NOT EXISTS idx_user_preferences_user_id ON user_preferences(user_id); |
|
|
|
|
|
-- 八字缓存索引 (永久缓存) |
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_bazi_cache_hash_gender ON bazi_analysis_cache(bazi_hash, gender); |
|
|
CREATE INDEX IF NOT EXISTS idx_bazi_cache_created_at ON bazi_analysis_cache(created_at); |
|
|
|
|
|
-- 每日运势详情索引 |
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_fortune_profile_date ON daily_fortune_detail(profile_id, date_key); |
|
|
CREATE INDEX IF NOT EXISTS idx_daily_fortune_expires_at ON daily_fortune_detail(expires_at); |
|
|
|
|
|
-- 月度K线缓存索引 |
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_monthly_kline_profile_year ON monthly_kline_cache(profile_id, year); |
|
|
|
|
|
-- 日度K线缓存索引 |
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_kline_profile_yearmonth ON daily_kline_cache(profile_id, year, month); |
|
|
|
|
|
-- 邮箱订阅索引 |
|
|
CREATE INDEX IF NOT EXISTS idx_email_subscriptions_user_id ON email_subscriptions(user_id); |
|
|
|
|
|
-- 邮件日志索引 |
|
|
CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); |
|
|
CREATE INDEX IF NOT EXISTS idx_email_logs_created_at ON email_logs(created_at); |
|
|
|
|
|
-- 密码重置令牌索引 |
|
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token); |
|
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); |
|
|
`); |
|
|
|
|
|
|
|
|
|
|
|
const celebrityMigrations = [ |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN analysis_data TEXT', |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN scores TEXT', |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN financial_data TEXT', |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN honors TEXT', |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN analysis_generated_at TEXT', |
|
|
'ALTER TABLE celebrity_cases ADD COLUMN analysis_version INTEGER DEFAULT 0', |
|
|
]; |
|
|
|
|
|
for (const migration of celebrityMigrations) { |
|
|
try { |
|
|
db.exec(migration); |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
console.log('✓ 数据库初始化完成'); |
|
|
}; |
|
|
|
|
|
|
|
|
initDatabase(); |
|
|
|
|
|
|
|
|
|
|
|
export const createUser = (id, email, passwordHash, points = 1000) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO users (id, email, password_hash, points, created_at) |
|
|
VALUES (?, ?, ?, ?, ?) |
|
|
`); |
|
|
const now = new Date().toISOString(); |
|
|
stmt.run(id, email, passwordHash, points, now); |
|
|
return { id, email, points, createdAt: now }; |
|
|
}; |
|
|
|
|
|
export const getUserByEmail = (email) => { |
|
|
const stmt = db.prepare('SELECT * FROM users WHERE email = ?'); |
|
|
const row = stmt.get(email); |
|
|
if (!row) return null; |
|
|
return { |
|
|
id: row.id, |
|
|
email: row.email, |
|
|
passwordHash: row.password_hash, |
|
|
points: row.points, |
|
|
role: row.role, |
|
|
createdAt: row.created_at, |
|
|
lastLoginAt: row.last_login_at, |
|
|
loginCount: row.login_count, |
|
|
}; |
|
|
}; |
|
|
|
|
|
export const getUserById = (id) => { |
|
|
const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); |
|
|
const row = stmt.get(id); |
|
|
if (!row) return null; |
|
|
return { |
|
|
id: row.id, |
|
|
email: row.email, |
|
|
passwordHash: row.password_hash, |
|
|
points: row.points, |
|
|
role: row.role, |
|
|
createdAt: row.created_at, |
|
|
lastLoginAt: row.last_login_at, |
|
|
loginCount: row.login_count, |
|
|
}; |
|
|
}; |
|
|
|
|
|
export const updateUserPoints = (userId, newPoints) => { |
|
|
const stmt = db.prepare('UPDATE users SET points = ?, updated_at = ? WHERE id = ?'); |
|
|
stmt.run(newPoints, new Date().toISOString(), userId); |
|
|
}; |
|
|
|
|
|
export const updateUserLogin = (userId) => { |
|
|
const stmt = db.prepare(` |
|
|
UPDATE users |
|
|
SET last_login_at = ?, login_count = login_count + 1, updated_at = ? |
|
|
WHERE id = ? |
|
|
`); |
|
|
const now = new Date().toISOString(); |
|
|
stmt.run(now, now, userId); |
|
|
}; |
|
|
|
|
|
export const getAllUsers = (limit = 100, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT id, email, points, role, created_at, last_login_at, login_count |
|
|
FROM users |
|
|
ORDER BY created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
return stmt.all(limit, offset).map(row => ({ |
|
|
id: row.id, |
|
|
email: row.email, |
|
|
points: row.points, |
|
|
role: row.role, |
|
|
createdAt: row.created_at, |
|
|
lastLoginAt: row.last_login_at, |
|
|
loginCount: row.login_count, |
|
|
})); |
|
|
}; |
|
|
|
|
|
export const getUserCount = () => { |
|
|
const stmt = db.prepare('SELECT COUNT(*) as count FROM users'); |
|
|
return stmt.get().count; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const saveUserInput = (inputData) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO user_inputs ( |
|
|
id, user_id, name, gender, birth_year, year_pillar, month_pillar, |
|
|
day_pillar, hour_pillar, start_age, first_da_yun, model_name, |
|
|
api_base_url, use_custom_api, created_at, ip_address, user_agent |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
inputData.id, |
|
|
inputData.userId || null, |
|
|
inputData.name || null, |
|
|
inputData.gender, |
|
|
inputData.birthYear || null, |
|
|
inputData.yearPillar || null, |
|
|
inputData.monthPillar || null, |
|
|
inputData.dayPillar || null, |
|
|
inputData.hourPillar || null, |
|
|
inputData.startAge || null, |
|
|
inputData.firstDaYun || null, |
|
|
inputData.modelName || null, |
|
|
inputData.apiBaseUrl || null, |
|
|
inputData.useCustomApi ? 1 : 0, |
|
|
now, |
|
|
inputData.ipAddress || null, |
|
|
inputData.userAgent || null |
|
|
); |
|
|
|
|
|
return { ...inputData, createdAt: now }; |
|
|
}; |
|
|
|
|
|
export const getUserInputs = (userId, limit = 20, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM user_inputs |
|
|
WHERE user_id = ? |
|
|
ORDER BY created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
return stmt.all(userId, limit, offset); |
|
|
}; |
|
|
|
|
|
export const getAllInputs = (limit = 100, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT ui.*, u.email as user_email |
|
|
FROM user_inputs ui |
|
|
LEFT JOIN users u ON ui.user_id = u.id |
|
|
ORDER BY ui.created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
return stmt.all(limit, offset); |
|
|
}; |
|
|
|
|
|
export const getInputCount = () => { |
|
|
const stmt = db.prepare('SELECT COUNT(*) as count FROM user_inputs'); |
|
|
return stmt.get().count; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const saveAnalysis = (analysisData) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO analyses ( |
|
|
id, user_id, input_id, cost, model_used, chart_data, analysis_data, |
|
|
created_at, processing_time_ms, status, error_message |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
analysisData.id, |
|
|
analysisData.userId || null, |
|
|
analysisData.inputId || null, |
|
|
analysisData.cost || 0, |
|
|
analysisData.modelUsed || null, |
|
|
JSON.stringify(analysisData.chartData || []), |
|
|
JSON.stringify(analysisData.analysisData || {}), |
|
|
now, |
|
|
analysisData.processingTimeMs || null, |
|
|
analysisData.status || 'completed', |
|
|
analysisData.errorMessage || null |
|
|
); |
|
|
|
|
|
return { ...analysisData, createdAt: now }; |
|
|
}; |
|
|
|
|
|
export const getAnalysesByUserId = (userId, limit = 20, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM analyses |
|
|
WHERE user_id = ? |
|
|
ORDER BY created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
return stmt.all(userId, limit, offset).map(row => ({ |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
inputId: row.input_id, |
|
|
cost: row.cost, |
|
|
modelUsed: row.model_used, |
|
|
chartData: JSON.parse(row.chart_data || '[]'), |
|
|
analysisData: JSON.parse(row.analysis_data || '{}'), |
|
|
createdAt: row.created_at, |
|
|
processingTimeMs: row.processing_time_ms, |
|
|
status: row.status, |
|
|
})); |
|
|
}; |
|
|
|
|
|
export const getAnalysisById = (id) => { |
|
|
const stmt = db.prepare('SELECT * FROM analyses WHERE id = ?'); |
|
|
const row = stmt.get(id); |
|
|
if (!row) return null; |
|
|
return { |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
inputId: row.input_id, |
|
|
cost: row.cost, |
|
|
modelUsed: row.model_used, |
|
|
chartData: JSON.parse(row.chart_data || '[]'), |
|
|
analysisData: JSON.parse(row.analysis_data || '{}'), |
|
|
createdAt: row.created_at, |
|
|
processingTimeMs: row.processing_time_ms, |
|
|
status: row.status, |
|
|
}; |
|
|
}; |
|
|
|
|
|
export const getAllAnalyses = (limit = 100, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT a.*, u.email as user_email |
|
|
FROM analyses a |
|
|
LEFT JOIN users u ON a.user_id = u.id |
|
|
ORDER BY a.created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
return stmt.all(limit, offset).map(row => ({ |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
userEmail: row.user_email, |
|
|
cost: row.cost, |
|
|
modelUsed: row.model_used, |
|
|
createdAt: row.created_at, |
|
|
status: row.status, |
|
|
})); |
|
|
}; |
|
|
|
|
|
export const getAnalysisCount = () => { |
|
|
const stmt = db.prepare('SELECT COUNT(*) as count FROM analyses'); |
|
|
return stmt.get().count; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const logEvent = (level, message, details = null, userId = null, ipAddress = null) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO system_logs (level, message, details, user_id, ip_address, created_at) |
|
|
VALUES (?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
stmt.run(level, message, details ? JSON.stringify(details) : null, userId, ipAddress, new Date().toISOString()); |
|
|
}; |
|
|
|
|
|
export const getSystemLogs = (limit = 100, offset = 0, level = null) => { |
|
|
let sql = 'SELECT * FROM system_logs'; |
|
|
const params = []; |
|
|
|
|
|
if (level) { |
|
|
sql += ' WHERE level = ?'; |
|
|
params.push(level); |
|
|
} |
|
|
|
|
|
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; |
|
|
params.push(limit, offset); |
|
|
|
|
|
const stmt = db.prepare(sql); |
|
|
return stmt.all(...params); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const getStats = () => { |
|
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; |
|
|
const inputCount = db.prepare('SELECT COUNT(*) as count FROM user_inputs').get().count; |
|
|
const analysisCount = db.prepare('SELECT COUNT(*) as count FROM analyses').get().count; |
|
|
const totalPoints = db.prepare('SELECT SUM(points) as total FROM users').get().total || 0; |
|
|
const totalCost = db.prepare('SELECT SUM(cost) as total FROM analyses').get().total || 0; |
|
|
|
|
|
|
|
|
const today = new Date().toISOString().split('T')[0]; |
|
|
const todayUsers = db.prepare(`SELECT COUNT(*) as count FROM users WHERE created_at LIKE '${today}%'`).get().count; |
|
|
const todayAnalyses = db.prepare(`SELECT COUNT(*) as count FROM analyses WHERE created_at LIKE '${today}%'`).get().count; |
|
|
|
|
|
return { |
|
|
userCount, |
|
|
inputCount, |
|
|
analysisCount, |
|
|
totalPoints, |
|
|
totalCost, |
|
|
todayUsers, |
|
|
todayAnalyses, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const migrateFromJson = async (jsonDbPath) => { |
|
|
try { |
|
|
const jsonData = JSON.parse(fs.readFileSync(jsonDbPath, 'utf-8')); |
|
|
|
|
|
|
|
|
const insertUser = db.prepare(` |
|
|
INSERT OR IGNORE INTO users (id, email, password_hash, points, created_at) |
|
|
VALUES (?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
for (const user of jsonData.users || []) { |
|
|
insertUser.run(user.id, user.email, user.passwordHash, user.points, user.createdAt); |
|
|
} |
|
|
|
|
|
|
|
|
const insertAnalysis = db.prepare(` |
|
|
INSERT OR IGNORE INTO analyses (id, user_id, cost, chart_data, analysis_data, created_at) |
|
|
VALUES (?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const insertInput = db.prepare(` |
|
|
INSERT OR IGNORE INTO user_inputs ( |
|
|
id, user_id, name, gender, birth_year, year_pillar, month_pillar, |
|
|
day_pillar, hour_pillar, start_age, first_da_yun, created_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
for (const analysis of jsonData.analyses || []) { |
|
|
const inputId = `input_${analysis.id}`; |
|
|
const input = analysis.input || {}; |
|
|
|
|
|
|
|
|
insertInput.run( |
|
|
inputId, |
|
|
analysis.userId, |
|
|
input.name || null, |
|
|
input.gender || 'Male', |
|
|
input.birthYear || null, |
|
|
input.yearPillar || null, |
|
|
input.monthPillar || null, |
|
|
input.dayPillar || null, |
|
|
input.hourPillar || null, |
|
|
input.startAge || null, |
|
|
input.firstDaYun || null, |
|
|
analysis.createdAt |
|
|
); |
|
|
|
|
|
|
|
|
insertAnalysis.run( |
|
|
analysis.id, |
|
|
analysis.userId, |
|
|
analysis.cost || 0, |
|
|
JSON.stringify(analysis.result?.chartData || []), |
|
|
JSON.stringify(analysis.result?.analysis || {}), |
|
|
analysis.createdAt |
|
|
); |
|
|
} |
|
|
|
|
|
console.log(`✓ 迁移完成: ${jsonData.users?.length || 0} 用户, ${jsonData.analyses?.length || 0} 分析记录`); |
|
|
return true; |
|
|
} catch (err) { |
|
|
console.error('迁移失败:', err); |
|
|
return false; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const createArticle = (articleData) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO knowledge_articles ( |
|
|
id, slug, title, category, level, tags, summary, content, |
|
|
view_count, created_at, updated_at, published |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
articleData.id, |
|
|
articleData.slug, |
|
|
articleData.title, |
|
|
articleData.category, |
|
|
articleData.level || 1, |
|
|
articleData.tags ? JSON.stringify(articleData.tags) : null, |
|
|
articleData.summary, |
|
|
articleData.content, |
|
|
0, |
|
|
now, |
|
|
null, |
|
|
articleData.published !== undefined ? (articleData.published ? 1 : 0) : 1 |
|
|
); |
|
|
|
|
|
return { ...articleData, createdAt: now }; |
|
|
}; |
|
|
|
|
|
export const getArticleBySlug = (slug) => { |
|
|
const stmt = db.prepare('SELECT * FROM knowledge_articles WHERE slug = ? AND published = 1'); |
|
|
const row = stmt.get(slug); |
|
|
if (!row) return null; |
|
|
return { |
|
|
id: row.id, |
|
|
slug: row.slug, |
|
|
title: row.title, |
|
|
category: row.category, |
|
|
level: row.level, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
summary: row.summary, |
|
|
content: row.content, |
|
|
viewCount: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
export const getArticles = (category, limit = 20, offset = 0) => { |
|
|
let sql = 'SELECT * FROM knowledge_articles WHERE published = 1'; |
|
|
const params = []; |
|
|
|
|
|
if (category) { |
|
|
sql += ' AND category = ?'; |
|
|
params.push(category); |
|
|
} |
|
|
|
|
|
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; |
|
|
params.push(limit, offset); |
|
|
|
|
|
const stmt = db.prepare(sql); |
|
|
return stmt.all(...params).map(row => ({ |
|
|
id: row.id, |
|
|
slug: row.slug, |
|
|
title: row.title, |
|
|
category: row.category, |
|
|
level: row.level, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
summary: row.summary, |
|
|
viewCount: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
})); |
|
|
}; |
|
|
|
|
|
export const incrementArticleView = (slug) => { |
|
|
const stmt = db.prepare('UPDATE knowledge_articles SET view_count = view_count + 1 WHERE slug = ?'); |
|
|
stmt.run(slug); |
|
|
}; |
|
|
|
|
|
export const searchArticles = (query, limit = 10) => { |
|
|
const searchPattern = `%${query}%`; |
|
|
const stmt = db.prepare(` |
|
|
SELECT id, slug, title, category, summary |
|
|
FROM knowledge_articles |
|
|
WHERE published = 1 AND (title LIKE ? OR summary LIKE ?) |
|
|
LIMIT ? |
|
|
`); |
|
|
return stmt.all(searchPattern, searchPattern, limit).map(row => ({ |
|
|
id: row.id, |
|
|
slug: row.slug, |
|
|
title: row.title, |
|
|
category: row.category, |
|
|
summary: row.summary, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const createCase = (caseData) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO cases ( |
|
|
id, title, persona, curve_type, chart_data, highlights, narrative, |
|
|
tags, view_count, created_at, updated_at, published |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
caseData.id, |
|
|
caseData.title, |
|
|
caseData.persona, |
|
|
caseData.curveType, |
|
|
JSON.stringify(caseData.chartData), |
|
|
caseData.highlights ? JSON.stringify(caseData.highlights) : null, |
|
|
caseData.narrative, |
|
|
caseData.tags ? JSON.stringify(caseData.tags) : null, |
|
|
0, |
|
|
now, |
|
|
null, |
|
|
caseData.published !== undefined ? (caseData.published ? 1 : 0) : 1 |
|
|
); |
|
|
|
|
|
return { ...caseData, createdAt: now }; |
|
|
}; |
|
|
|
|
|
export const getCaseById = (id) => { |
|
|
const stmt = db.prepare('SELECT * FROM cases WHERE id = ? AND published = 1'); |
|
|
const row = stmt.get(id); |
|
|
if (!row) return null; |
|
|
return { |
|
|
id: row.id, |
|
|
title: row.title, |
|
|
persona: row.persona, |
|
|
curveType: row.curve_type, |
|
|
chartData: JSON.parse(row.chart_data), |
|
|
highlights: row.highlights ? JSON.parse(row.highlights) : [], |
|
|
narrative: row.narrative, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
viewCount: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
export const getCases = (curveType, limit = 20, offset = 0) => { |
|
|
let sql = 'SELECT * FROM cases WHERE published = 1'; |
|
|
const params = []; |
|
|
|
|
|
if (curveType) { |
|
|
sql += ' AND curve_type = ?'; |
|
|
params.push(curveType); |
|
|
} |
|
|
|
|
|
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; |
|
|
params.push(limit, offset); |
|
|
|
|
|
const stmt = db.prepare(sql); |
|
|
return stmt.all(...params).map(row => ({ |
|
|
id: row.id, |
|
|
title: row.title, |
|
|
persona: row.persona, |
|
|
curveType: row.curve_type, |
|
|
chartData: JSON.parse(row.chart_data), |
|
|
highlights: row.highlights ? JSON.parse(row.highlights) : [], |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
viewCount: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
})); |
|
|
}; |
|
|
|
|
|
export const incrementCaseView = (id) => { |
|
|
const stmt = db.prepare('UPDATE cases SET view_count = view_count + 1 WHERE id = ?'); |
|
|
stmt.run(id); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createCelebrityCase = (caseData) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR REPLACE INTO celebrity_cases ( |
|
|
id, name, name_cn, category, category_cn, birth_date, birth_location_city, |
|
|
birth_location_lat, birth_location_lng, description, tags, year_pillar, |
|
|
month_pillar, day_pillar, hour_pillar, chart_data, highlights, |
|
|
hotness_score, view_count, created_at, published |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
caseData.id, |
|
|
caseData.name, |
|
|
caseData.name_cn, |
|
|
caseData.category, |
|
|
caseData.category_cn, |
|
|
caseData.birth_date, |
|
|
caseData.birth_location_city || null, |
|
|
caseData.birth_location_lat || null, |
|
|
caseData.birth_location_lng || null, |
|
|
caseData.description, |
|
|
caseData.tags ? JSON.stringify(caseData.tags) : null, |
|
|
caseData.year_pillar || null, |
|
|
caseData.month_pillar || null, |
|
|
caseData.day_pillar || null, |
|
|
caseData.hour_pillar || null, |
|
|
caseData.chart_data ? JSON.stringify(caseData.chart_data) : null, |
|
|
caseData.highlights ? JSON.stringify(caseData.highlights) : null, |
|
|
caseData.hotness_score || 0, |
|
|
caseData.view_count || 0, |
|
|
now, |
|
|
caseData.published !== undefined ? (caseData.published ? 1 : 0) : 1 |
|
|
); |
|
|
|
|
|
return { ...caseData, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getCelebrityCases = (category = null, limit = 20, offset = 0) => { |
|
|
let sql = 'SELECT * FROM celebrity_cases WHERE published = 1'; |
|
|
const params = []; |
|
|
|
|
|
if (category) { |
|
|
sql += ' AND category = ?'; |
|
|
params.push(category); |
|
|
} |
|
|
|
|
|
sql += ' ORDER BY hotness_score DESC, view_count DESC LIMIT ? OFFSET ?'; |
|
|
params.push(limit, offset); |
|
|
|
|
|
const stmt = db.prepare(sql); |
|
|
return stmt.all(...params).map(row => ({ |
|
|
id: row.id, |
|
|
name: row.name, |
|
|
name_cn: row.name_cn, |
|
|
category: row.category, |
|
|
category_cn: row.category_cn, |
|
|
birth_date: row.birth_date, |
|
|
birth_location_city: row.birth_location_city, |
|
|
birth_location_lat: row.birth_location_lat, |
|
|
birth_location_lng: row.birth_location_lng, |
|
|
description: row.description, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
year_pillar: row.year_pillar, |
|
|
month_pillar: row.month_pillar, |
|
|
day_pillar: row.day_pillar, |
|
|
hour_pillar: row.hour_pillar, |
|
|
chart_data: row.chart_data ? JSON.parse(row.chart_data) : null, |
|
|
highlights: row.highlights ? JSON.parse(row.highlights) : [], |
|
|
hotness_score: row.hotness_score, |
|
|
view_count: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
export const getTrendingCelebrityCases = (limit = 10) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM celebrity_cases |
|
|
WHERE published = 1 |
|
|
ORDER BY hotness_score DESC, view_count DESC |
|
|
LIMIT ? |
|
|
`); |
|
|
|
|
|
return stmt.all(limit).map(row => ({ |
|
|
id: row.id, |
|
|
name: row.name, |
|
|
name_cn: row.name_cn, |
|
|
category: row.category, |
|
|
category_cn: row.category_cn, |
|
|
description: row.description, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
hotness_score: row.hotness_score, |
|
|
view_count: row.view_count, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
export const getCelebrityCaseById = (id) => { |
|
|
const stmt = db.prepare('SELECT * FROM celebrity_cases WHERE id = ? AND published = 1'); |
|
|
const row = stmt.get(id); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
name: row.name, |
|
|
name_cn: row.name_cn, |
|
|
category: row.category, |
|
|
category_cn: row.category_cn, |
|
|
birth_date: row.birth_date, |
|
|
birth_location_city: row.birth_location_city, |
|
|
birth_location_lat: row.birth_location_lat, |
|
|
birth_location_lng: row.birth_location_lng, |
|
|
description: row.description, |
|
|
tags: row.tags ? JSON.parse(row.tags) : [], |
|
|
year_pillar: row.year_pillar, |
|
|
month_pillar: row.month_pillar, |
|
|
day_pillar: row.day_pillar, |
|
|
hour_pillar: row.hour_pillar, |
|
|
chart_data: row.chart_data ? JSON.parse(row.chart_data) : null, |
|
|
highlights: row.highlights ? JSON.parse(row.highlights) : [], |
|
|
hotness_score: row.hotness_score, |
|
|
view_count: row.view_count, |
|
|
createdAt: row.created_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const incrementCelebrityCaseView = (id) => { |
|
|
const stmt = db.prepare('UPDATE celebrity_cases SET view_count = view_count + 1 WHERE id = ?'); |
|
|
stmt.run(id); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createUserProfile = (input) => { |
|
|
|
|
|
const countStmt = db.prepare('SELECT COUNT(*) as count FROM user_profiles WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)'); |
|
|
const { count } = countStmt.get(input.userId); |
|
|
|
|
|
if (count >= 10) { |
|
|
throw new Error('用户最多只能创建10个档案'); |
|
|
} |
|
|
|
|
|
|
|
|
if (input.isDefault) { |
|
|
const clearDefaultStmt = db.prepare('UPDATE user_profiles SET is_default = 0 WHERE user_id = ?'); |
|
|
clearDefaultStmt.run(input.userId); |
|
|
} |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO user_profiles ( |
|
|
id, user_id, name, gender, birth_year, year_pillar, month_pillar, |
|
|
day_pillar, hour_pillar, start_age, first_da_yun, birth_place, |
|
|
is_default, created_at, updated_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
input.id, |
|
|
input.userId, |
|
|
input.name, |
|
|
input.gender, |
|
|
input.birthYear, |
|
|
input.yearPillar || null, |
|
|
input.monthPillar || null, |
|
|
input.dayPillar || null, |
|
|
input.hourPillar || null, |
|
|
input.startAge || null, |
|
|
input.firstDaYun || null, |
|
|
input.birthPlace || null, |
|
|
input.isDefault ? 1 : 0, |
|
|
now, |
|
|
now |
|
|
); |
|
|
|
|
|
return { ...input, id: input.id, createdAt: now, updatedAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getUserProfiles = (userId, limit = 10) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM user_profiles |
|
|
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL) |
|
|
ORDER BY is_default DESC, created_at DESC |
|
|
LIMIT ? |
|
|
`); |
|
|
return stmt.all(userId, limit).map(row => ({ |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
name: row.name, |
|
|
gender: row.gender, |
|
|
birthYear: row.birth_year, |
|
|
yearPillar: row.year_pillar, |
|
|
monthPillar: row.month_pillar, |
|
|
dayPillar: row.day_pillar, |
|
|
hourPillar: row.hour_pillar, |
|
|
startAge: row.start_age, |
|
|
firstDaYun: row.first_da_yun, |
|
|
birthPlace: row.birth_place, |
|
|
isDefault: row.is_default === 1, |
|
|
coreDocumentStatus: row.core_document_status || 'pending', |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
export const getUserProfileById = (id) => { |
|
|
const stmt = db.prepare('SELECT * FROM user_profiles WHERE id = ? AND (is_deleted = 0 OR is_deleted IS NULL)'); |
|
|
const row = stmt.get(id); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
name: row.name, |
|
|
gender: row.gender, |
|
|
birthYear: row.birth_year, |
|
|
yearPillar: row.year_pillar, |
|
|
monthPillar: row.month_pillar, |
|
|
dayPillar: row.day_pillar, |
|
|
hourPillar: row.hour_pillar, |
|
|
startAge: row.start_age, |
|
|
firstDaYun: row.first_da_yun, |
|
|
birthPlace: row.birth_place, |
|
|
isDefault: row.is_default === 1, |
|
|
coreDocumentStatus: row.core_document_status || 'pending', |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const updateUserProfile = (id, data) => { |
|
|
const fields = []; |
|
|
const values = []; |
|
|
|
|
|
Object.entries(data).forEach(([key, value]) => { |
|
|
if (value !== undefined && key !== 'id' && key !== 'userId') { |
|
|
fields.push(`${key} = ?`); |
|
|
values.push(value); |
|
|
} |
|
|
}); |
|
|
|
|
|
if (fields.length === 0) return null; |
|
|
|
|
|
fields.push('updated_at = ?'); |
|
|
values.push(new Date().toISOString()); |
|
|
values.push(id); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
UPDATE user_profiles |
|
|
SET ${fields.join(', ')} |
|
|
WHERE id = ? |
|
|
`); |
|
|
|
|
|
stmt.run(...values); |
|
|
return getUserProfileById(id); |
|
|
}; |
|
|
|
|
|
|
|
|
export const setDefaultProfile = (userId, profileId) => { |
|
|
|
|
|
const clearStmt = db.prepare('UPDATE user_profiles SET is_default = 0 WHERE user_id = ?'); |
|
|
clearStmt.run(userId); |
|
|
|
|
|
|
|
|
const setStmt = db.prepare('UPDATE user_profiles SET is_default = 1, updated_at = ? WHERE id = ? AND user_id = ?'); |
|
|
const now = new Date().toISOString(); |
|
|
const result = setStmt.run(now, profileId, userId); |
|
|
|
|
|
return result.changes > 0; |
|
|
}; |
|
|
|
|
|
|
|
|
export const deleteUserProfile = (id) => { |
|
|
const now = new Date().toISOString(); |
|
|
const stmt = db.prepare('UPDATE user_profiles SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?'); |
|
|
const result = stmt.run(now, now, id); |
|
|
return result.changes > 0; |
|
|
}; |
|
|
|
|
|
|
|
|
export const hardDeleteUserProfile = (id) => { |
|
|
const stmt = db.prepare('DELETE FROM user_profiles WHERE id = ?'); |
|
|
const result = stmt.run(id); |
|
|
return result.changes > 0; |
|
|
}; |
|
|
|
|
|
|
|
|
export const updateProfileCoreDocumentStatus = (id, status) => { |
|
|
const stmt = db.prepare('UPDATE user_profiles SET core_document_status = ?, updated_at = ? WHERE id = ?'); |
|
|
const result = stmt.run(status, new Date().toISOString(), id); |
|
|
return result.changes > 0; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createShareReward = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO share_rewards ( |
|
|
id, user_id, platform, analysis_id, points_rewarded, shared_at, ip_address |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
data.id, |
|
|
data.userId, |
|
|
data.platform, |
|
|
data.analysisId || null, |
|
|
data.pointsRewarded || 300, |
|
|
now, |
|
|
data.ipAddress || null |
|
|
); |
|
|
|
|
|
|
|
|
updateUserPoints(data.userId, (data.currentPoints || 0) + (data.pointsRewarded || 300)); |
|
|
|
|
|
return { |
|
|
id: data.id, |
|
|
userId: data.userId, |
|
|
platform: data.platform, |
|
|
analysisId: data.analysisId, |
|
|
pointsRewarded: data.pointsRewarded || 300, |
|
|
sharedAt: now, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getUserShareRewards = (userId, limit = 50, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM share_rewards |
|
|
WHERE user_id = ? |
|
|
ORDER BY shared_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
|
|
|
return stmt.all(userId, limit, offset).map(row => ({ |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
platform: row.platform, |
|
|
analysisId: row.analysis_id, |
|
|
pointsRewarded: row.points_rewarded, |
|
|
sharedAt: row.shared_at, |
|
|
ipAddress: row.ip_address, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
export const getTodayShareCount = (userId) => { |
|
|
const today = new Date().toISOString().split('T')[0]; |
|
|
const stmt = db.prepare(` |
|
|
SELECT COUNT(*) as count FROM share_rewards |
|
|
WHERE user_id = ? AND shared_at LIKE ? |
|
|
`); |
|
|
const result = stmt.get(userId, `${today}%`); |
|
|
return result.count; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createFortuneCache = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR REPLACE INTO fortune_cache ( |
|
|
id, profile_id, fortune_type, date_key, predictions, created_at, expires_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
data.id, |
|
|
data.profileId, |
|
|
data.fortuneType, |
|
|
data.dateKey, |
|
|
JSON.stringify(data.predictions), |
|
|
now, |
|
|
data.expiresAt |
|
|
); |
|
|
|
|
|
return { ...data, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getFortuneCache = (profileId, fortuneType, dateKey) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM fortune_cache |
|
|
WHERE profile_id = ? AND fortune_type = ? AND date_key = ? AND expires_at > ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(profileId, fortuneType, dateKey, new Date().toISOString()); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
profileId: row.profile_id, |
|
|
fortuneType: row.fortune_type, |
|
|
dateKey: row.date_key, |
|
|
predictions: JSON.parse(row.predictions), |
|
|
createdAt: row.created_at, |
|
|
expiresAt: row.expires_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const cleanExpiredFortuneCache = () => { |
|
|
const stmt = db.prepare('DELETE FROM fortune_cache WHERE expires_at <= ?'); |
|
|
const result = stmt.run(new Date().toISOString()); |
|
|
return result.changes; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createUserPreferences = (userId) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR IGNORE INTO user_preferences ( |
|
|
id, user_id, theme, notifications_enabled, created_at, updated_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
const id = `pref_${userId}_${Date.now()}`; |
|
|
|
|
|
stmt.run( |
|
|
id, |
|
|
userId, |
|
|
'light', |
|
|
1, |
|
|
now, |
|
|
now |
|
|
); |
|
|
|
|
|
return { |
|
|
id, |
|
|
userId, |
|
|
theme: 'light', |
|
|
notificationsEnabled: true, |
|
|
createdAt: now, |
|
|
updatedAt: now, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getUserPreferences = (userId) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM user_preferences WHERE user_id = ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(userId); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
theme: row.theme, |
|
|
notificationsEnabled: row.notifications_enabled === 1, |
|
|
defaultProfileId: row.default_profile_id, |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const updateUserPreferences = (userId, data) => { |
|
|
const fields = []; |
|
|
const values = []; |
|
|
|
|
|
Object.entries(data).forEach(([key, value]) => { |
|
|
if (value !== undefined && key !== 'id' && key !== 'userId') { |
|
|
|
|
|
let dbKey = key; |
|
|
if (key === 'notificationsEnabled') dbKey = 'notifications_enabled'; |
|
|
if (key === 'defaultProfileId') dbKey = 'default_profile_id'; |
|
|
|
|
|
fields.push(`${dbKey} = ?`); |
|
|
if (key === 'notificationsEnabled') { |
|
|
values.push(value ? 1 : 0); |
|
|
} else { |
|
|
values.push(value); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
if (fields.length === 0) return null; |
|
|
|
|
|
fields.push('updated_at = ?'); |
|
|
values.push(new Date().toISOString()); |
|
|
values.push(userId); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
UPDATE user_preferences |
|
|
SET ${fields.join(', ')} |
|
|
WHERE user_id = ? |
|
|
`); |
|
|
|
|
|
stmt.run(...values); |
|
|
return getUserPreferences(userId); |
|
|
}; |
|
|
|
|
|
|
|
|
export const getDb = () => db; |
|
|
|
|
|
|
|
|
export const closeDb = () => db.close(); |
|
|
|
|
|
export const nowIso = () => new Date().toISOString(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const saveDailyFortuneDetail = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR REPLACE INTO daily_fortune_detail ( |
|
|
id, profile_id, date_key, fortune_data, ai_enhanced, model_used, points_cost, created_at, expires_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
data.id, |
|
|
data.profileId, |
|
|
data.dateKey, |
|
|
JSON.stringify(data.fortuneData), |
|
|
data.aiEnhanced ? 1 : 0, |
|
|
data.modelUsed || null, |
|
|
data.pointsCost || 0, |
|
|
now, |
|
|
data.expiresAt || null |
|
|
); |
|
|
|
|
|
return { ...data, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getDailyFortuneDetail = (profileId, dateKey) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM daily_fortune_detail |
|
|
WHERE profile_id = ? AND date_key = ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(profileId, dateKey); |
|
|
if (!row) return null; |
|
|
|
|
|
|
|
|
if (row.expires_at && new Date(row.expires_at) < new Date()) { |
|
|
return null; |
|
|
} |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
profileId: row.profile_id, |
|
|
dateKey: row.date_key, |
|
|
fortuneData: JSON.parse(row.fortune_data), |
|
|
aiEnhanced: row.ai_enhanced === 1, |
|
|
modelUsed: row.model_used, |
|
|
pointsCost: row.points_cost, |
|
|
createdAt: row.created_at, |
|
|
expiresAt: row.expires_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const hasDailyFortuneAiEnhanced = (profileId, dateKey) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT ai_enhanced FROM daily_fortune_detail |
|
|
WHERE profile_id = ? AND date_key = ? AND ai_enhanced = 1 |
|
|
`); |
|
|
const row = stmt.get(profileId, dateKey); |
|
|
return !!row; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const saveMonthlyKlineCache = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR REPLACE INTO monthly_kline_cache ( |
|
|
id, profile_id, year, kline_data, model_used, created_at, expires_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
data.id, |
|
|
data.profileId, |
|
|
data.year, |
|
|
JSON.stringify(data.klineData), |
|
|
data.modelUsed || null, |
|
|
now, |
|
|
data.expiresAt |
|
|
); |
|
|
|
|
|
return { ...data, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getMonthlyKlineCache = (profileId, year) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM monthly_kline_cache |
|
|
WHERE profile_id = ? AND year = ? AND expires_at > ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(profileId, year, new Date().toISOString()); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
profileId: row.profile_id, |
|
|
year: row.year, |
|
|
klineData: JSON.parse(row.kline_data), |
|
|
modelUsed: row.model_used, |
|
|
createdAt: row.created_at, |
|
|
expiresAt: row.expires_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const saveDailyKlineCache = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT OR REPLACE INTO daily_kline_cache ( |
|
|
id, profile_id, year, month, kline_data, model_used, created_at, expires_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
stmt.run( |
|
|
data.id, |
|
|
data.profileId, |
|
|
data.year, |
|
|
data.month, |
|
|
JSON.stringify(data.klineData), |
|
|
data.modelUsed || null, |
|
|
now, |
|
|
data.expiresAt |
|
|
); |
|
|
|
|
|
return { ...data, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getDailyKlineCache = (profileId, year, month) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM daily_kline_cache |
|
|
WHERE profile_id = ? AND year = ? AND month = ? AND expires_at > ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(profileId, year, month, new Date().toISOString()); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
profileId: row.profile_id, |
|
|
year: row.year, |
|
|
month: row.month, |
|
|
klineData: JSON.parse(row.kline_data), |
|
|
modelUsed: row.model_used, |
|
|
createdAt: row.created_at, |
|
|
expiresAt: row.expires_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const cleanAllExpiredCache = () => { |
|
|
const now = new Date().toISOString(); |
|
|
let totalCleaned = 0; |
|
|
|
|
|
|
|
|
const stmt1 = db.prepare('DELETE FROM fortune_cache WHERE expires_at <= ?'); |
|
|
totalCleaned += stmt1.run(now).changes; |
|
|
|
|
|
|
|
|
const stmt2 = db.prepare('DELETE FROM daily_fortune_detail WHERE expires_at IS NOT NULL AND expires_at <= ?'); |
|
|
totalCleaned += stmt2.run(now).changes; |
|
|
|
|
|
|
|
|
const stmt3 = db.prepare('DELETE FROM monthly_kline_cache WHERE expires_at <= ?'); |
|
|
totalCleaned += stmt3.run(now).changes; |
|
|
|
|
|
|
|
|
const stmt4 = db.prepare('DELETE FROM daily_kline_cache WHERE expires_at <= ?'); |
|
|
totalCleaned += stmt4.run(now).changes; |
|
|
|
|
|
return totalCleaned; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const generateVoucherCode = () => { |
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; |
|
|
let code = ''; |
|
|
for (let i = 0; i < 8; i++) { |
|
|
code += chars.charAt(Math.floor(Math.random() * chars.length)); |
|
|
} |
|
|
return code; |
|
|
}; |
|
|
|
|
|
|
|
|
export const createVoucher = (points) => { |
|
|
const code = generateVoucherCode(); |
|
|
const now = new Date().toISOString(); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO vouchers (id, points, created_at) |
|
|
VALUES (?, ?, ?) |
|
|
`); |
|
|
|
|
|
stmt.run(code, points, now); |
|
|
|
|
|
return { code, points, createdAt: now }; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getVoucher = (code) => { |
|
|
const stmt = db.prepare('SELECT * FROM vouchers WHERE id = ?'); |
|
|
const row = stmt.get(code.toUpperCase()); |
|
|
|
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
code: row.id, |
|
|
points: row.points, |
|
|
createdAt: row.created_at, |
|
|
usedAt: row.used_at, |
|
|
usedBy: row.used_by, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const redeemVoucher = (code, userId) => { |
|
|
const voucher = getVoucher(code); |
|
|
|
|
|
if (!voucher) { |
|
|
return { success: false, error: 'INVALID_CODE', message: '无效的兑换码' }; |
|
|
} |
|
|
|
|
|
if (voucher.usedAt) { |
|
|
return { success: false, error: 'ALREADY_USED', message: '该兑换码已被使用' }; |
|
|
} |
|
|
|
|
|
|
|
|
const user = getUserById(userId); |
|
|
if (!user) { |
|
|
return { success: false, error: 'USER_NOT_FOUND', message: '用户不存在' }; |
|
|
} |
|
|
|
|
|
const newBalance = user.points + voucher.points; |
|
|
const now = new Date().toISOString(); |
|
|
|
|
|
|
|
|
const updateVoucher = db.prepare('UPDATE vouchers SET used_at = ?, used_by = ? WHERE id = ?'); |
|
|
const updateUser = db.prepare('UPDATE users SET points = ?, updated_at = ? WHERE id = ?'); |
|
|
|
|
|
const transaction = db.transaction(() => { |
|
|
updateVoucher.run(now, userId, code.toUpperCase()); |
|
|
updateUser.run(newBalance, now, userId); |
|
|
}); |
|
|
|
|
|
transaction(); |
|
|
|
|
|
return { |
|
|
success: true, |
|
|
points: voucher.points, |
|
|
newBalance, |
|
|
message: `成功兑换 ${voucher.points} 点券`, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createEmailSubscription = (userId) => { |
|
|
const id = `email_sub_${userId}_${Date.now()}`; |
|
|
const now = new Date().toISOString(); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO email_subscriptions ( |
|
|
id, user_id, created_at, updated_at |
|
|
) VALUES (?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
stmt.run(id, userId, now, now); |
|
|
|
|
|
return { |
|
|
id, |
|
|
userId, |
|
|
emailVerified: false, |
|
|
verificationToken: null, |
|
|
verificationSentAt: null, |
|
|
verifiedAt: null, |
|
|
subDailyFortune: false, |
|
|
subMonthlyFortune: false, |
|
|
subYearlyFortune: false, |
|
|
subBirthdayReminder: false, |
|
|
subLowPoints: true, |
|
|
subFeatureUpdates: true, |
|
|
subPromotions: false, |
|
|
bindingRewardClaimed: false, |
|
|
subscriptionRewardClaimed: false, |
|
|
createdAt: now, |
|
|
updatedAt: now, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getEmailSubscription = (userId) => { |
|
|
const stmt = db.prepare('SELECT * FROM email_subscriptions WHERE user_id = ?'); |
|
|
const row = stmt.get(userId); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
emailVerified: row.email_verified === 1, |
|
|
verificationToken: row.verification_token, |
|
|
verificationSentAt: row.verification_sent_at, |
|
|
verifiedAt: row.verified_at, |
|
|
subDailyFortune: row.sub_daily_fortune === 1, |
|
|
subMonthlyFortune: row.sub_monthly_fortune === 1, |
|
|
subYearlyFortune: row.sub_yearly_fortune === 1, |
|
|
subBirthdayReminder: row.sub_birthday_reminder === 1, |
|
|
subLowPoints: row.sub_low_points === 1, |
|
|
subFeatureUpdates: row.sub_feature_updates === 1, |
|
|
subPromotions: row.sub_promotions === 1, |
|
|
bindingRewardClaimed: row.binding_reward_claimed === 1, |
|
|
subscriptionRewardClaimed: row.subscription_reward_claimed === 1, |
|
|
createdAt: row.created_at, |
|
|
updatedAt: row.updated_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const updateEmailSubscription = (userId, data) => { |
|
|
const fields = []; |
|
|
const values = []; |
|
|
|
|
|
const fieldMap = { |
|
|
emailVerified: 'email_verified', |
|
|
verificationToken: 'verification_token', |
|
|
verificationSentAt: 'verification_sent_at', |
|
|
verifiedAt: 'verified_at', |
|
|
subDailyFortune: 'sub_daily_fortune', |
|
|
subMonthlyFortune: 'sub_monthly_fortune', |
|
|
subYearlyFortune: 'sub_yearly_fortune', |
|
|
subBirthdayReminder: 'sub_birthday_reminder', |
|
|
subLowPoints: 'sub_low_points', |
|
|
subFeatureUpdates: 'sub_feature_updates', |
|
|
subPromotions: 'sub_promotions', |
|
|
bindingRewardClaimed: 'binding_reward_claimed', |
|
|
subscriptionRewardClaimed: 'subscription_reward_claimed', |
|
|
}; |
|
|
|
|
|
Object.entries(data).forEach(([key, value]) => { |
|
|
if (value !== undefined && fieldMap[key]) { |
|
|
const dbKey = fieldMap[key]; |
|
|
fields.push(`${dbKey} = ?`); |
|
|
|
|
|
if (typeof value === 'boolean') { |
|
|
values.push(value ? 1 : 0); |
|
|
} else { |
|
|
values.push(value); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
if (fields.length === 0) return null; |
|
|
|
|
|
fields.push('updated_at = ?'); |
|
|
values.push(new Date().toISOString()); |
|
|
values.push(userId); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
UPDATE email_subscriptions |
|
|
SET ${fields.join(', ')} |
|
|
WHERE user_id = ? |
|
|
`); |
|
|
|
|
|
stmt.run(...values); |
|
|
return getEmailSubscription(userId); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createEmailLog = (data) => { |
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO email_logs ( |
|
|
user_id, email_type, recipient, subject, status, error_message, sent_at, created_at |
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
const now = new Date().toISOString(); |
|
|
const result = stmt.run( |
|
|
data.userId || null, |
|
|
data.emailType, |
|
|
data.recipient, |
|
|
data.subject, |
|
|
data.status || 'pending', |
|
|
data.errorMessage || null, |
|
|
data.sentAt || null, |
|
|
now |
|
|
); |
|
|
|
|
|
return { |
|
|
id: result.lastInsertRowid, |
|
|
userId: data.userId, |
|
|
emailType: data.emailType, |
|
|
recipient: data.recipient, |
|
|
subject: data.subject, |
|
|
status: data.status || 'pending', |
|
|
errorMessage: data.errorMessage, |
|
|
sentAt: data.sentAt, |
|
|
createdAt: now, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getEmailLogs = (userId, limit = 50, offset = 0) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM email_logs |
|
|
WHERE user_id = ? |
|
|
ORDER BY created_at DESC |
|
|
LIMIT ? OFFSET ? |
|
|
`); |
|
|
|
|
|
return stmt.all(userId, limit, offset).map(row => ({ |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
emailType: row.email_type, |
|
|
recipient: row.recipient, |
|
|
subject: row.subject, |
|
|
status: row.status, |
|
|
errorMessage: row.error_message, |
|
|
sentAt: row.sent_at, |
|
|
createdAt: row.created_at, |
|
|
})); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const createPasswordResetToken = (userId, token, expiresAt) => { |
|
|
const id = `reset_${userId}_${Date.now()}`; |
|
|
const now = new Date().toISOString(); |
|
|
|
|
|
const stmt = db.prepare(` |
|
|
INSERT INTO password_reset_tokens ( |
|
|
id, user_id, token, expires_at, created_at |
|
|
) VALUES (?, ?, ?, ?, ?) |
|
|
`); |
|
|
|
|
|
stmt.run(id, userId, token, expiresAt, now); |
|
|
|
|
|
return { |
|
|
id, |
|
|
userId, |
|
|
token, |
|
|
expiresAt, |
|
|
used: false, |
|
|
createdAt: now, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getPasswordResetToken = (token) => { |
|
|
const stmt = db.prepare(` |
|
|
SELECT * FROM password_reset_tokens |
|
|
WHERE token = ? AND used = 0 AND expires_at > ? |
|
|
`); |
|
|
|
|
|
const row = stmt.get(token, new Date().toISOString()); |
|
|
if (!row) return null; |
|
|
|
|
|
return { |
|
|
id: row.id, |
|
|
userId: row.user_id, |
|
|
token: row.token, |
|
|
expiresAt: row.expires_at, |
|
|
used: row.used === 1, |
|
|
createdAt: row.created_at, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
export const markPasswordResetTokenUsed = (token) => { |
|
|
const stmt = db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?'); |
|
|
const result = stmt.run(token); |
|
|
return result.changes > 0; |
|
|
}; |
|
|
|
|
|
|
|
|
export const cleanupExpiredTokens = () => { |
|
|
const stmt = db.prepare('DELETE FROM password_reset_tokens WHERE expires_at <= ? OR used = 1'); |
|
|
const result = stmt.run(new Date().toISOString()); |
|
|
return result.changes; |
|
|
}; |
|
|
|
|
|
|