v0.5.4: 全面修复 — template literal URL, Cookie验证, 用户默认is_active, 默认账号路由, 空间信息, 密钥清理, promoForm修复

修复:
- quark-share.ts/storage.ts: 9处template literal ${}缺失导致fetch URL写死
- user/routes.ts: testCloudConnectionWithCookie缺await + 按cloudType分发驱动
- credential.service.ts: INSERT缺?参数 (9values/10cols)
- user/routes.ts: 用户新增网盘默认is_active=0
- admin.routes.ts: 新增PUT /admin/cloud-configs/:id/primary路由
- database.ts: is_primary列迁移
- UserDashboard.vue: 保存时传递storage_used/storage_total
- SystemConfig.vue: promoForm const重赋值bug
- config/index.ts: 移除泄露的默认密钥token
This commit is contained in:
2026-05-19 23:09:11 +08:00
parent 39724e6e73
commit d7b055f88b
212 changed files with 4337 additions and 51 deletions

View File

@@ -22,18 +22,18 @@ export function getSystemConfig(key: string): string | null {
export function updateSystemConfig(key: string, value: string): void {
const db = getDb();
db.prepare(
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
).run(value, localTimestamp(), key);
"INSERT INTO system_configs (key, value, updated_at, description) VALUES (?, ?, ?, '') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
).run(key, value, localTimestamp());
}
export function updateSystemConfigs(entries: { key: string; value: string }[]): void {
const db = getDb();
const update = db.prepare(
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
const upsert = db.prepare(
"INSERT INTO system_configs (key, value, updated_at, description) VALUES (?, ?, ?, '') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
);
const tx = db.transaction((items: { key: string; value: string }[]) => {
for (const item of items) {
update.run(item.value, localTimestamp(), item.key);
upsert.run(item.key, item.value, localTimestamp());
}
});
tx(entries);

View File

@@ -131,6 +131,7 @@ export function saveCloudConfig(data: {
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
cloud_type_uid = COALESCE(?, cloud_type_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
@@ -138,7 +139,7 @@ export function saveCloudConfig(data: {
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
} else {
const existing = db.prepare(
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
@@ -149,6 +150,7 @@ export function saveCloudConfig(data: {
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
cloud_type_uid = COALESCE(?, cloud_type_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
@@ -156,11 +158,11 @@ export function saveCloudConfig(data: {
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
} else {
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, cloud_type_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
}
}
@@ -266,8 +268,8 @@ export async function testCloudConnection(id: number): Promise<{
const cookieUid = extractCookieUid(cookie);
db.prepare(
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, cookie_uid = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, cookieUid, localTimestamp(), id);
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, cookie_uid = ?, cloud_type_uid = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, cookieUid, cookieUid, localTimestamp(), id);
return {
success: true,
@@ -295,21 +297,54 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
storage_total?: string;
}> {
try {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败Cookie 无效或已过期' };
if (cloudType === 'quark') {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败Cookie 无效或已过期' };
}
const nickname = (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfo();
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} else if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败Cookie 无效或已过期(需包含 BDUSS' };
}
const info = await driver.getUserInfo();
const storage = await driver.getStorageInfo();
return {
success: true,
message: '连接成功',
nickname: info?.nickname || '百度网盘',
storage_used: storage.used,
storage_total: storage.total,
};
} else if (cloudType === 'aliyun') {
const { AliyunDriver } = require('./drivers/aliyun.driver');
const driver = new AliyunDriver({ cookie, nickname: '' });
const nickname = await driver.getNickname();
return {
success: true,
message: nickname ? '连接成功' : '连接成功(无法获取昵称)',
nickname: nickname || '阿里云盘',
};
} else {
return {
success: true,
message: 'Cookie 已保存(该网盘类型暂不支持连接测试)',
nickname: cloudType,
};
}
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
const storage = await driver.getStorageInfo();
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} catch (err: any) {
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}

View File

@@ -12,7 +12,7 @@ export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(q.getCommonParams());
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_TOKEN?${params.toString()}`, {
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.SHARE_PAGE_TOKEN}?${params.toString()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
@@ -57,7 +57,7 @@ export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_DETAIL?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.SHARE_PAGE_DETAIL}?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
@@ -107,7 +107,7 @@ export async function getShareFiles(cookie, pwdId, stoken) {
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_SAVE?${q.makeQuery()}`, {
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.SHARE_PAGE_SAVE}?${q.makeQuery()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -154,7 +154,7 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.TASK}?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
@@ -179,7 +179,7 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE_RENAME?${q.makeQuery()}`, {
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.FILE_RENAME}?${q.makeQuery()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
@@ -206,7 +206,7 @@ export async function createShareLink(cookie, fileId) {
for (const st of shareTypes) {
await q.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE + "?"${q.makeQuery()}`, {
const response = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.SHARE}?${q.makeQuery()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -255,7 +255,7 @@ export async function createShareLink(cookie, fileId) {
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PASSWORD?${q.makeQuery()}`, {
const response = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.SHARE_PASSWORD}?${q.makeQuery()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
@@ -291,7 +291,7 @@ async function waitForShareTask(cookie, taskId, timeoutMs) {
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.TASK}?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches

View File

@@ -255,7 +255,7 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
*/
export async function createDir(cookie, dirName, parentFid = '0') {
try {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE + '?'${q.makeQuery()}`, {
const resp = await fetch(`${q.QUARK_DRIVE_HOST}${q.EP.FILE}?${q.makeQuery()}`, {
method: 'POST',
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -27,8 +27,8 @@ export interface Config {
dbPath: string;
}
const DEFAULT_JWT_SECRETS = ['', 'cloudsearch-jwt-secret-dev', 'cloudsearch-jwt-secret-2024', 'your-super-secret-jwt-key-change-me'];
const DEFAULT_PASSWORDS = ['', 'admin123', 'admin', 'password', '123456', '0nL5kLhMIJ1121PYmQb25A'];
const DEFAULT_JWT_SECRETS = ['CHANGEME-jwt-placeholder-1', 'CHANGEME-jwt-placeholder-2'];
const DEFAULT_PASSWORDS = ['admin123', 'admin', 'password', 'CHANGEME-admin-password-placeholder'];
function loadOrGenerateSecret(key: string, envKey: string, defaultVal: string, isDefault: (v: string) => boolean, byteLen: number): string {
const envVal = process.env[envKey];
@@ -78,9 +78,9 @@ const config: Config = {
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001',
jwtSecret: loadOrGenerateSecret('jwt_secret', 'JWT_SECRET', 'cloudsearch-jwt-secret-dev', (v) => DEFAULT_JWT_SECRETS.includes(v), 32),
jwtSecret: loadOrGenerateSecret('jwt_secret', 'JWT_SECRET', 'CHANGEME-jwt-placeholder-1', (v) => DEFAULT_JWT_SECRETS.includes(v), 32),
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: loadOrGenerateSecret('admin_password', 'ADMIN_PASSWORD', 'admin123', (v) => DEFAULT_PASSWORDS.includes(v), 16),
adminPassword: loadOrGenerateSecret('admin_password', 'ADMIN_PASSWORD', 'CHANGEME-admin-password-placeholder', (v) => DEFAULT_PASSWORDS.includes(v), 16),
validation: {
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),

View File

@@ -118,6 +118,14 @@ function runMigrations(db: Database.Database): void {
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS promotion_platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
join_url TEXT NOT NULL DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS content_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT UNIQUE NOT NULL,
@@ -133,6 +141,8 @@ function runMigrations(db: Database.Database): void {
migrateSaveRecords(db);
migrateContentCache(db);
migrateCloudConfigs(db);
migratePushUsersForAuth(db);
migratePromotionPlatforms(db);
// Performance indexes on cloud_configs (after all columns exist)
db.exec(`
@@ -272,6 +282,66 @@ function migrateCloudConfigs(db: Database.Database): void {
db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL");
console.log('[DB] cloud_configs migration: cloud_type_uid column added');
}
// v0.4.14: Backfill cloud_type_uid from cookie __uid (also for existing columns)
try {
const rows = db.prepare("SELECT id, cookie FROM cloud_configs WHERE cloud_type_uid IS NULL AND cookie IS NOT NULL").all() as any[];
let backfilled = 0;
const updateStmt = db.prepare('UPDATE cloud_configs SET cloud_type_uid = ? WHERE id = ?');
for (const row of rows) {
const match = row.cookie.match(/__uid=([^;]+)/);
if (match) {
updateStmt.run(match[1], row.id);
backfilled++;
}
}
if (backfilled > 0) {
console.log('[DB] cloud_configs migration: backfilled ' + backfilled + ' cloud_type_uid from cookie');
}
} catch (e) {
// Cookie might be encrypted, skip
}
// v0.5.4: Add is_primary — marks the default account per cloud type
const hasIsPrimary = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_primary%'").get();
if (!hasIsPrimary) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
console.log('[DB] cloud_configs migration: is_primary column added');
}
}
// v0.5.0: Add password_hash and role to push_users for user system
function migratePushUsersForAuth(db: Database.Database): void {
if (!db) return;
// Add password_hash column
const hasPasswordHash = db.prepare("SELECT sql FROM sqlite_master WHERE name='push_users' AND sql LIKE '%password_hash%'").get();
if (!hasPasswordHash) {
db.exec("ALTER TABLE push_users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''");
console.log('[DB] push_users migration: password_hash column added');
}
// Add role column
const hasRole = db.prepare("SELECT sql FROM sqlite_master WHERE name='push_users' AND sql LIKE '%role%'").get();
if (!hasRole) {
db.exec("ALTER TABLE push_users ADD COLUMN role TEXT DEFAULT 'user'");
console.log('[DB] push_users migration: role column added');
}
}
// v0.5.3: Add promotion_platforms table
function migratePromotionPlatforms(db: Database.Database): void {
if (!db) return;
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='promotion_platforms'").get();
if (!hasTable) {
db.exec(`
CREATE TABLE IF NOT EXISTS promotion_platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
join_url TEXT NOT NULL DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
`);
console.log('[DB] promotion_platforms table created');
}
}

View File

@@ -10,6 +10,7 @@ import { getDb } from './database/database';
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
import rateLimiter from './middleware/rate-limit';
import routes from './routes';
import userRoutes from './user/routes';
import { pansouWebProxy } from './proxy/pansou-web';
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
import { refreshAllStorageInfo } from './cloud/cloud.service';
@@ -57,6 +58,7 @@ app.use(rateLimiter);
// ============ Routes ============
app.use('/api/uploads', express.static(config.uploadDir));
app.use('/api/user', userRoutes);
app.use('/api', routes);
// ============ Health Check增强版覆盖 Redis / PanSou / VideoParser 状态) ============

View File

@@ -249,6 +249,28 @@ router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Respons
// Daily Check-in
// ═══════════════════════════════════════
/** PUT /api/admin/cloud-configs/:id/primary — set this config as the primary/default account for its cloud type */
router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => {
const id = Number(req.params.id);
const { primary } = req.body;
try {
const db = getDb();
const config = db.prepare('SELECT id, cloud_type FROM cloud_configs WHERE id = ?').get(id) as any;
if (!config) { res.status(404).json({ error: '网盘配置不存在' }); return; }
if (primary) {
// Unset primary for all other configs of the same cloud type
db.prepare('UPDATE cloud_configs SET is_primary = 0 WHERE cloud_type = ?').run(config.cloud_type);
// Set this one as primary
db.prepare('UPDATE cloud_configs SET is_primary = 1 WHERE id = ?').run(id);
} else {
db.prepare('UPDATE cloud_configs SET is_primary = 0 WHERE id = ?').run(id);
}
res.json({ success: true, is_primary: primary ? 1 : 0 });
} catch (err: any) {
res.status(500).json({ error: err.message || '操作失败' });
}
});
/** POST /api/admin/cloud-configs/:id/checkin */
router.post('/admin/cloud-configs/:id/checkin', async (req: Request, res: Response) => {
try {
@@ -882,5 +904,70 @@ router.get('/admin/daily-report/last-run', (_req, res) => {
}
});
// ── 推广平台管理 (Promotion Platforms for Registration) ──
/** GET /api/promotion-platforms — public, list all platforms for registration page */
router.get('/promotion-platforms', (_req: Request, res: Response) => {
try {
const db = getDb();
const platforms = db.prepare('SELECT id, name, join_url, sort_order FROM promotion_platforms ORDER BY sort_order, id').all();
const { getSystemConfig } = require('../admin/system-config.service');
const qrTitle = getSystemConfig('promotion_qr_title') || '扫码加入推广团队';
res.json({ title: qrTitle, platforms });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** GET /api/admin/promotion-platforms — admin list */
router.get('/admin/promotion-platforms', (_req: Request, res: Response) => {
try {
const db = getDb();
const platforms = db.prepare('SELECT id, name, join_url, sort_order, created_at FROM promotion_platforms ORDER BY sort_order, id').all();
res.json(platforms);
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** POST /api/admin/promotion-platforms — admin create */
router.post('/admin/promotion-platforms', (req: Request, res: Response) => {
try {
const db = getDb();
const { name, join_url, sort_order } = req.body;
if (!name || !join_url) { res.status(400).json({ error: '平台名称和邀请链接不能为空' }); return; }
const result = db.prepare(
'INSERT INTO promotion_platforms (name, join_url, sort_order) VALUES (?, ?, ?)'
).run(name, join_url, sort_order || 0);
res.json({ id: result.lastInsertRowid, name, join_url, sort_order });
} catch (e: any) {
if (e.message && e.message.includes('UNIQUE')) {
res.status(409).json({ error: '该平台名称已存在' });
} else {
res.status(500).json({ error: e.message || '创建失败' });
}
}
});
/** PUT /api/admin/promotion-platforms/:id — admin update */
router.put('/admin/promotion-platforms/:id', (req: Request, res: Response) => {
try {
const db = getDb();
const id = Number(req.params.id);
const { name, join_url, sort_order } = req.body;
const existing = db.prepare('SELECT id FROM promotion_platforms WHERE id = ?').get(id);
if (!existing) { res.status(404).json({ error: '平台不存在' }); return; }
db.prepare('UPDATE promotion_platforms SET name = ?, join_url = ?, sort_order = ? WHERE id = ?')
.run(name, join_url, sort_order || 0, id);
res.json({ success: true });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** DELETE /api/admin/promotion-platforms/:id — admin delete */
router.delete('/admin/promotion-platforms/:id', (req: Request, res: Response) => {
try {
const db = getDb();
const id = Number(req.params.id);
db.prepare('DELETE FROM promotion_platforms WHERE id = ?').run(id);
res.json({ success: true });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
export default router;

View File

@@ -0,0 +1,91 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import { getDb } from '../database/database';
import config from '../config';
// Use a separate secret for user JWT, fallback to main JWT secret
function getUserJwtSecret(): string {
return config.jwtSecret;
}
export interface UserPayload {
userId: number;
account: string;
role: string;
}
// Extend Express Request
declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}
export function registerUser(account: string, password: string): { success: boolean; message: string; userId?: number } {
const db = getDb();
if (!db) return { success: false, message: '数据库未初始化' };
// Validate account format: 平台-手机号
if (!account || !account.includes('-')) {
return { success: false, message: '账号格式:推广平台-手机号(如:蜂小推-13800138000' };
}
// Check if already exists
const existing = db.prepare('SELECT id FROM push_users WHERE account = ?').get(account) as any;
if (existing && existing.password_hash) {
return { success: false, message: '该账号已注册' };
}
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(password, salt);
if (existing) {
// Update existing push_user with password
db.prepare('UPDATE push_users SET password_hash = ?, role = ? WHERE id = ?').run(hash, 'user', existing.id);
return { success: true, message: '注册成功(已绑定已有推送账号)', userId: existing.id };
} else {
const result = db.prepare(
"INSERT INTO push_users (account, password_hash, role, notify_config) VALUES (?, ?, 'user', '{}')"
).run(account, hash);
return { success: true, message: '注册成功', userId: result.lastInsertRowid as number };
}
}
export function loginUser(account: string, password: string): { success: boolean; message: string; token?: string } {
const db = getDb();
if (!db) return { success: false, message: '数据库未初始化' };
const row = db.prepare('SELECT id, account, password_hash, role FROM push_users WHERE account = ?').get(account) as any;
if (!row || !row.password_hash) {
return { success: false, message: '账号不存在或未注册' };
}
if (!bcrypt.compareSync(password, row.password_hash)) {
return { success: false, message: '密码错误' };
}
const payload: UserPayload = { userId: row.id, account: row.account, role: row.role || 'user' };
const token = jwt.sign(payload, getUserJwtSecret(), { expiresIn: '7d' });
return { success: true, message: '登录成功', token };
}
export function userAuthMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: '请先登录', code: 401 });
return;
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, getUserJwtSecret()) as UserPayload;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: '登录已过期,请重新登录', code: 401 });
}
}

View File

@@ -0,0 +1,139 @@
import { Router, Request, Response } from 'express';
import { registerUser, loginUser, userAuthMiddleware } from './auth.service';
import { getDb } from '../database/database';
import { saveCloudConfig, deleteCloudConfig, testCloudConnectionWithCookie } from '../cloud/credential.service';
import { getAllCloudTypes } from '../cloud/cloud-types.service';
const router = Router();
// ── Public ──────────────────────────────────────────────────────
router.post('/register', (req: Request, res: Response) => {
const { platform, phone, password } = req.body;
// Support both new format (platform + phone) and legacy format (account)
let account: string;
if (platform && phone) {
account = platform + '-' + phone;
} else if (req.body.account) {
account = req.body.account;
} else {
res.status(400).json({ error: '请选择推广平台并填写手机号' }); return;
}
if (!account || !password) { res.status(400).json({ error: '账号和密码不能为空' }); return; }
if (password.length < 6) { res.status(400).json({ error: '密码至少6位' }); return; }
const result = registerUser(account, password);
if (result.success) res.json({ success: true, message: result.message, userId: result.userId });
else res.status(400).json({ error: result.message });
});
router.post('/login', (req: Request, res: Response) => {
const { account, password } = req.body;
if (!account || !password) { res.status(400).json({ error: '账号和密码不能为空' }); return; }
const result = loginUser(account, password);
if (result.success) res.json({ success: true, token: result.token, message: result.message });
else res.status(401).json({ error: result.message });
});
// ── Protected ───────────────────────────────────────────────────
router.use(userAuthMiddleware);
router.get('/profile', (req: Request, res: Response) => {
const db = getDb();
const user = db.prepare('SELECT id, account, role, notify_config, created_at FROM push_users WHERE id = ?').get(req.user!.userId) as any;
if (!user) { res.status(404).json({ error: '用户不存在' }); return; }
res.json({ id: user.id, account: user.account, role: user.role, notifyConfig: user.notify_config, createdAt: user.created_at });
});
// ── 转存日志 ──────────────────────────────────────────────────
router.get('/save-records', (req: Request, res: Response) => {
const db = getDb();
const page = Math.max(1, parseInt(String(req.query.page)) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(String(req.query.pageSize)) || 20));
const offset = (page - 1) * pageSize;
const total = (db.prepare('SELECT COUNT(*) as count FROM save_records WHERE promotion_account = ?').get(req.user!.account) as any).count;
const records = db.prepare(
'SELECT id, source_type, source_title, source_url, share_url, share_pwd, file_size, file_count, folder_count, status, error_message, folder_name, duration_ms, created_at FROM save_records WHERE promotion_account = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'
).all(req.user!.account, pageSize, offset);
res.json({ total, page, pageSize, records });
});
// ── 网盘管理 ──────────────────────────────────────────────────
router.get('/cloud-configs', (req: Request, res: Response) => {
const db = getDb();
const configs = db.prepare(
'SELECT id, cloud_type, nickname, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total, is_active, verification_status, consecutive_failures, last_used_at, total_saves, created_at FROM cloud_configs WHERE promotion_account = ? AND is_active = 1 ORDER BY created_at DESC'
).all(req.user!.account);
res.json(configs);
});
// Cookie 验证(保存前)
router.post('/cloud-configs/:cloudType/test', async (req: Request, res: Response) => {
const { cookie } = req.body;
if (!cookie) { res.status(400).json({ error: 'Cookie不能为空' }); return; }
try {
const result = await testCloudConnectionWithCookie(String(req.params.cloudType), cookie);
res.json(result);
} catch (e: any) { res.status(500).json({ error: e.message || '验证失败' }); }
});
router.post('/cloud-configs', (req: Request, res: Response) => {
const { cloud_type, cookie, nickname, promotion_account } = req.body;
if (!cloud_type || !cookie) { res.status(400).json({ error: '网盘类型和Cookie不能为空' }); return; }
try {
const config = saveCloudConfig({ cloud_type, cookie, nickname: nickname || '', promotion_account: promotion_account || req.user!.account, is_active: 0 });
const { cookie: _, ...safe } = config as any;
res.json(safe);
} catch (e: any) { res.status(500).json({ error: e.message || '保存失败' }); }
});
router.put('/cloud-configs/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const existing = db.prepare('SELECT id FROM cloud_configs WHERE id = ? AND promotion_account = ?').get(id, req.user!.account) as any;
if (!existing) { res.status(403).json({ error: '无权操作此网盘配置' }); return; }
const { cloud_type, cookie, nickname, promotion_account } = req.body;
try {
const config = saveCloudConfig({ id, cloud_type: cloud_type || undefined, cookie: cookie || undefined, nickname, promotion_account: promotion_account || req.user!.account });
const { cookie: _, ...safe } = config as any;
res.json(safe);
} catch (e: any) { res.status(500).json({ error: e.message || '保存失败' }); }
});
router.delete('/cloud-configs/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const existing = db.prepare('SELECT id FROM cloud_configs WHERE id = ? AND promotion_account = ?').get(id, req.user!.account) as any;
if (!existing) { res.status(403).json({ error: '无权操作此网盘配置' }); return; }
deleteCloudConfig(id);
res.json({ success: true });
});
// ── 可用网盘类型(仅返回管理员已启用的) ──────────────────
router.get('/enabled-cloud-types', (_req: Request, res: Response) => {
const allTypes = getAllCloudTypes();
const enabled = allTypes.filter(ct => ct.enabled && ct.type !== 'others' && ct.type !== 'magnet' && ct.type !== 'ed2k');
res.json(enabled);
});
// ── 推送配置 ──────────────────────────────────────────────────
router.get('/notify-config', (req: Request, res: Response) => {
const db = getDb();
const user = db.prepare('SELECT notify_config FROM push_users WHERE id = ?').get(req.user!.userId) as any;
res.json({ notifyConfig: user?.notify_config || '{}' });
});
router.put('/notify-config', (req: Request, res: Response) => {
const db = getDb();
const { notifyConfig } = req.body;
if (!notifyConfig) { res.status(400).json({ error: '推送配置不能为空' }); return; }
db.prepare("UPDATE push_users SET notify_config = ?, updated_at = datetime('now','localtime') WHERE id = ?")
.run(typeof notifyConfig === 'string' ? notifyConfig : JSON.stringify(notifyConfig), req.user!.userId);
res.json({ success: true });
});
export default router;