feat: 我的页整合扫一扫/设置与提现、all-chapters去重、内容上传API、文档与后台登录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
lib/admin-auth.ts
Normal file
92
lib/admin-auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 后台管理员登录鉴权:生成/校验签名 Cookie,不暴露账号密码
|
||||
* 账号密码从环境变量读取,默认 admin / key123456(与 .cursorrules 一致)
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
const COOKIE_NAME = 'admin_session'
|
||||
const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
|
||||
const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
|
||||
|
||||
export function getAdminCredentials() {
|
||||
return {
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'key123456',
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyAdminCredentials(username: string, password: string): boolean {
|
||||
const { username: u, password: p } = getAdminCredentials()
|
||||
return username === u && password === p
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac('sha256', SECRET).update(payload).digest('base64url')
|
||||
}
|
||||
|
||||
/** 生成签名 token,写入 Cookie 用 */
|
||||
export function createAdminToken(): string {
|
||||
const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
|
||||
const payload = `${exp}`
|
||||
const sig = sign(payload)
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
/** 校验 Cookie 中的 token */
|
||||
export function verifyAdminToken(token: string | null | undefined): boolean {
|
||||
if (!token || typeof token !== 'string') return false
|
||||
const dot = token.indexOf('.')
|
||||
if (dot === -1) return false
|
||||
const payload = token.slice(0, dot)
|
||||
const sig = token.slice(dot + 1)
|
||||
const exp = parseInt(payload, 10)
|
||||
if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
|
||||
const expected = sign(payload)
|
||||
if (typeof expected !== 'string' || typeof sig !== 'string') return false
|
||||
if (sig.length !== expected.length) return false
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminCookieName() {
|
||||
return COOKIE_NAME
|
||||
}
|
||||
|
||||
export function getAdminCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: MAX_AGE_SEC,
|
||||
path: '/',
|
||||
}
|
||||
}
|
||||
|
||||
/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
|
||||
export function getAdminTokenFromRequest(request: Request): string | null {
|
||||
const cookieHeader = request.headers.get('cookie')
|
||||
if (!cookieHeader) return null
|
||||
const name = COOKIE_NAME + '='
|
||||
const start = cookieHeader.indexOf(name)
|
||||
if (start === -1) return null
|
||||
const valueStart = start + name.length
|
||||
const end = cookieHeader.indexOf(';', valueStart)
|
||||
const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
/** 若未登录则返回 401 Response,供各 admin API 使用 */
|
||||
export function requireAdminResponse(request: Request): Response | null {
|
||||
const token = getAdminTokenFromRequest(request)
|
||||
if (!verifyAdminToken(token)) {
|
||||
return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -510,6 +510,20 @@ export const bookData: Part[] = [
|
||||
isFree: false,
|
||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.14 大健康私域:一个月150万的70后.md",
|
||||
},
|
||||
{
|
||||
id: "9.15",
|
||||
title: "第102场|今年第一个红包你发给谁",
|
||||
price: 1,
|
||||
isFree: false,
|
||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.15 第102场|今年第一个红包你发给谁.md",
|
||||
},
|
||||
{
|
||||
id: "9.16",
|
||||
title: "第103场|号商、某客与炸房",
|
||||
price: 1,
|
||||
isFree: false,
|
||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.16 第103场|号商、某客与炸房.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
173
lib/db.ts
173
lib/db.ts
@@ -1,31 +1,47 @@
|
||||
/**
|
||||
* 数据库连接配置
|
||||
* 使用MySQL数据库存储用户、订单、推广关系等数据
|
||||
* 优先从环境变量读取,便于本地/部署分离;未设置时使用默认值
|
||||
*/
|
||||
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
// 腾讯云外网数据库配置
|
||||
const DB_CONFIG = {
|
||||
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
|
||||
port: 14413,
|
||||
user: 'cdb_outerroot',
|
||||
password: 'Zhiqun1984',
|
||||
database: 'soul_miniprogram',
|
||||
host: process.env.MYSQL_HOST || '56b4c23f6853c.gz.cdb.myqcloud.com',
|
||||
port: Number(process.env.MYSQL_PORT || '14413'),
|
||||
user: process.env.MYSQL_USER || 'cdb_outerroot',
|
||||
password: process.env.MYSQL_PASSWORD || 'Zhiqun1984',
|
||||
database: process.env.MYSQL_DATABASE || 'soul_miniprogram',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
acquireTimeout: 60000,
|
||||
timeout: 60000,
|
||||
connectTimeout: 10000, // 10 秒,连接不可达时快速失败,避免长时间挂起
|
||||
acquireTimeout: 15000,
|
||||
reconnect: true
|
||||
}
|
||||
|
||||
// 本地无数据库时可通过 SKIP_DB=1 跳过连接,接口将使用默认配置
|
||||
const SKIP_DB = process.env.SKIP_DB === '1' || process.env.SKIP_DB === 'true'
|
||||
|
||||
// 连接池
|
||||
let pool: mysql.Pool | null = null
|
||||
// 连接类错误只打一次日志,避免刷屏
|
||||
let connectionErrorLogged = false
|
||||
|
||||
function isConnectionError(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException)?.code
|
||||
return (
|
||||
code === 'ETIMEDOUT' ||
|
||||
code === 'ECONNREFUSED' ||
|
||||
code === 'PROTOCOL_CONNECTION_LOST' ||
|
||||
code === 'ENOTFOUND'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库连接池
|
||||
* 获取数据库连接池(SKIP_DB 时不创建)
|
||||
*/
|
||||
export function getPool() {
|
||||
export function getPool(): mysql.Pool | null {
|
||||
if (SKIP_DB) return null
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
...DB_CONFIG,
|
||||
@@ -41,12 +57,30 @@ export function getPool() {
|
||||
* 执行SQL查询
|
||||
*/
|
||||
export async function query(sql: string, params?: any[]) {
|
||||
const connection = getPool()
|
||||
if (!connection) {
|
||||
throw new Error('数据库未配置或已跳过 (SKIP_DB)')
|
||||
}
|
||||
// mysql2 内部会读 params.length,不能传 undefined
|
||||
const safeParams = Array.isArray(params) ? params : []
|
||||
try {
|
||||
const connection = getPool()
|
||||
const [results] = await connection.execute(sql, params)
|
||||
return results
|
||||
const [results] = await connection.execute(sql, safeParams)
|
||||
// 确保调用方拿到的始终是数组,避免 undefined.length 报错
|
||||
if (Array.isArray(results)) return results
|
||||
if (results != null) return [results]
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('数据库查询错误:', error)
|
||||
if (isConnectionError(error)) {
|
||||
if (!connectionErrorLogged) {
|
||||
connectionErrorLogged = true
|
||||
console.warn(
|
||||
'[DB] 数据库连接不可用,将使用本地默认配置。',
|
||||
(error as Error).message
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error('数据库查询错误:', error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -57,7 +91,7 @@ export async function query(sql: string, params?: any[]) {
|
||||
export async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库表结构...')
|
||||
|
||||
|
||||
// 用户表(完整字段)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -88,33 +122,31 @@ export async function initDatabase() {
|
||||
INDEX idx_referred_by (referred_by)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 尝试添加可能缺失的字段(用于升级已有数据库)
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
try {
|
||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
// 兼容 MySQL 5.7:IF NOT EXISTS 在 5.7 不支持,先检查列是否存在
|
||||
const addColumnIfMissing = async (colName: string, colDef: string) => {
|
||||
try {
|
||||
const rows = await query(
|
||||
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?",
|
||||
[colName]
|
||||
) as any[]
|
||||
if (!rows?.length) {
|
||||
await query(`ALTER TABLE users ADD COLUMN ${colName} ${colDef}`)
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
await addColumnIfMissing('session_key', 'VARCHAR(100)')
|
||||
await addColumnIfMissing('password', 'VARCHAR(100)')
|
||||
await addColumnIfMissing('referred_by', 'VARCHAR(50)')
|
||||
await addColumnIfMissing('is_admin', 'BOOLEAN DEFAULT FALSE')
|
||||
await addColumnIfMissing('match_count_today', 'INT DEFAULT 0')
|
||||
await addColumnIfMissing('last_match_date', 'DATE')
|
||||
await addColumnIfMissing('withdrawn_earnings', 'DECIMAL(10,2) DEFAULT 0')
|
||||
|
||||
console.log('用户表初始化完成')
|
||||
|
||||
// 订单表
|
||||
|
||||
// 订单表(含 referrer_id/referral_code、status 含 created/expired)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
@@ -125,9 +157,11 @@ export async function initDatabase() {
|
||||
product_id VARCHAR(50),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
description VARCHAR(200),
|
||||
status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending',
|
||||
status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired') DEFAULT 'created',
|
||||
transaction_id VARCHAR(100),
|
||||
pay_time TIMESTAMP NULL,
|
||||
referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID,用于分销归属',
|
||||
referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码,便于对账与展示',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
@@ -136,7 +170,7 @@ export async function initDatabase() {
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 推广绑定关系表
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS referral_bindings (
|
||||
@@ -162,7 +196,7 @@ export async function initDatabase() {
|
||||
INDEX idx_expiry_date (expiry_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 匹配记录表
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS match_records (
|
||||
@@ -181,7 +215,7 @@ export async function initDatabase() {
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 推广访问记录表(用于统计「通过链接进的人数」)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS referral_visits (
|
||||
@@ -197,7 +231,7 @@ export async function initDatabase() {
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 系统配置表
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
@@ -210,7 +244,7 @@ export async function initDatabase() {
|
||||
INDEX idx_config_key (config_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
// 章节内容表 - 存储书籍所有章节
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
@@ -234,12 +268,12 @@ export async function initDatabase() {
|
||||
INDEX idx_sort_order (sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
|
||||
console.log('数据库表结构初始化完成')
|
||||
|
||||
|
||||
// 插入默认配置
|
||||
await initDefaultConfig()
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化数据库失败:', error)
|
||||
throw error
|
||||
@@ -267,13 +301,13 @@ async function initDefaultConfig() {
|
||||
maxMatchesPerDay: 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await query(`
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
||||
`, ['match_config', JSON.stringify(matchConfig), '匹配功能配置'])
|
||||
|
||||
|
||||
// 推广配置
|
||||
const referralConfig = {
|
||||
distributorShare: 90, // 推广者分成比例
|
||||
@@ -281,15 +315,15 @@ async function initDefaultConfig() {
|
||||
bindingDays: 30, // 绑定有效期(天)
|
||||
userDiscount: 5 // 用户优惠比例
|
||||
}
|
||||
|
||||
|
||||
await query(`
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
||||
`, ['referral_config', JSON.stringify(referralConfig), '推广功能配置'])
|
||||
|
||||
|
||||
console.log('默认配置初始化完成')
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化默认配置失败:', error)
|
||||
}
|
||||
@@ -297,20 +331,23 @@ async function initDefaultConfig() {
|
||||
|
||||
/**
|
||||
* 获取系统配置
|
||||
* 连接不可达时返回 null,由上层使用本地默认配置,不重复打日志
|
||||
*/
|
||||
export async function getConfig(key: string) {
|
||||
try {
|
||||
const results = await query(
|
||||
'SELECT config_value FROM system_config WHERE config_key = ?',
|
||||
[key]
|
||||
) as any[]
|
||||
|
||||
if (results.length > 0) {
|
||||
return results[0].config_value
|
||||
)
|
||||
const rows = Array.isArray(results) ? results : (results != null ? [results] : [])
|
||||
if (rows != null && rows.length > 0) {
|
||||
return (rows[0] as any)?.config_value ?? null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
if (!isConnectionError(error)) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -321,13 +358,13 @@ export async function getConfig(key: string) {
|
||||
export async function setConfig(key: string, value: any, description?: string) {
|
||||
try {
|
||||
await query(`
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_value = VALUES(config_value),
|
||||
description = COALESCE(VALUES(description), description)
|
||||
`, [key, JSON.stringify(value), description])
|
||||
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('设置配置失败:', error)
|
||||
@@ -336,4 +373,4 @@ export async function setConfig(key: string, value: any, description?: string) {
|
||||
}
|
||||
|
||||
// 导出数据库实例
|
||||
export default { getPool, query, initDatabase, getConfig, setConfig }
|
||||
export default { getPool, query, initDatabase, getConfig, setConfig }
|
||||
|
||||
56
lib/password.ts
Normal file
56
lib/password.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
|
||||
* 使用 Node crypto.scrypt,存储格式 saltHex:hashHex,兼容旧明文密码
|
||||
*/
|
||||
|
||||
import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
|
||||
|
||||
const SALT_LEN = 16
|
||||
const KEYLEN = 32
|
||||
|
||||
function bufferToHex(buf: Buffer): string {
|
||||
return buf.toString('hex')
|
||||
}
|
||||
|
||||
function hexToBuffer(hex: string): Buffer {
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 对明文密码做哈希,存入数据库
|
||||
* 格式: saltHex:hashHex(约 97 字符,适配 VARCHAR(100))
|
||||
* 与 verifyPassword 一致:内部先 trim,保证注册/登录/重置用同一套规则
|
||||
*/
|
||||
export function hashPassword(plain: string): string {
|
||||
const trimmed = String(plain).trim()
|
||||
const salt = Buffer.allocUnsafe(SALT_LEN)
|
||||
randomFillSync(salt)
|
||||
const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||
return bufferToHex(salt) + ':' + bufferToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密码:支持新格式(salt:hash)与旧明文(兼容历史数据)
|
||||
* 与 hashPassword 一致:对输入先 trim 再参与校验
|
||||
*/
|
||||
export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
|
||||
const trimmed = String(plain).trim()
|
||||
if (stored == null || stored === '') {
|
||||
return trimmed === ''
|
||||
}
|
||||
if (stored.includes(':')) {
|
||||
const [saltHex, hashHex] = stored.split(':')
|
||||
if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const salt = hexToBuffer(saltHex)
|
||||
const expected = hexToBuffer(hashHex)
|
||||
const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return trimmed === stored
|
||||
}
|
||||
Reference in New Issue
Block a user