Files
soul-yongping/lib/db.ts

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 数据库连接配置
* 使用MySQL数据库存储用户、订单、推广关系等数据
* 优先从环境变量读取,便于本地/部署分离;未设置时使用默认值
*/
import mysql from 'mysql2/promise'
const DB_CONFIG = {
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',
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(): mysql.Pool | null {
if (SKIP_DB) return null
if (!pool) {
pool = mysql.createPool({
...DB_CONFIG,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
})
}
return pool
}
/**
* 执行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 : []
console.log('[DB Query] SQL:', sql.slice(0, 100))
console.log('[DB Query] Params Type:', typeof params, '| Is Array:', Array.isArray(params), '| safeParams:', safeParams)
try {
const [results] = await connection.execute(sql, safeParams)
// 确保调用方拿到的始终是数组,避免 undefined.length 报错
if (Array.isArray(results)) return results
if (results != null) return [results]
return []
} catch (error) {
if (isConnectionError(error)) {
if (!connectionErrorLogged) {
connectionErrorLogged = true
console.warn(
'[DB] 数据库连接不可用,将使用本地默认配置。',
(error as Error).message
)
}
} else {
console.error('数据库查询错误:', error)
}
throw error
}
}
/**
* 初始化数据库表结构
*/
export async function initDatabase() {
try {
console.log('开始初始化数据库表结构...')
// 用户表(完整字段)
await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(50) PRIMARY KEY,
open_id VARCHAR(100) UNIQUE,
session_key VARCHAR(100) COMMENT '微信session_key',
nickname VARCHAR(100),
avatar VARCHAR(500),
phone VARCHAR(20),
password VARCHAR(100) COMMENT '密码(可选)',
wechat_id VARCHAR(100) COMMENT '用户填写的微信号',
referral_code VARCHAR(20) UNIQUE,
referred_by VARCHAR(50) COMMENT '推荐人ID',
purchased_sections JSON DEFAULT '[]',
has_full_book BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE COMMENT '是否管理员',
earnings DECIMAL(10,2) DEFAULT 0 COMMENT '已提现收益',
pending_earnings DECIMAL(10,2) DEFAULT 0 COMMENT '待提现收益',
withdrawn_earnings DECIMAL(10,2) DEFAULT 0 COMMENT '累计已提现',
referral_count INT DEFAULT 0 COMMENT '推广人数',
match_count_today INT DEFAULT 0 COMMENT '今日匹配次数',
last_match_date DATE COMMENT '最后匹配日期',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_open_id (open_id),
INDEX idx_phone (phone),
INDEX idx_referral_code (referral_code),
INDEX idx_referred_by (referred_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 尝试添加可能缺失的字段(用于升级已有数据库)
// 兼容 MySQL 5.7IF 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('用户表初始化完成')
// 订单表
await query(`
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(50) PRIMARY KEY,
order_sn VARCHAR(50) UNIQUE NOT NULL,
user_id VARCHAR(50) NOT NULL,
open_id VARCHAR(100) NOT NULL,
product_type ENUM('section', 'fullbook', 'match') NOT NULL,
product_id VARCHAR(50),
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(200),
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),
INDEX idx_order_sn (order_sn),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 推广绑定关系表
await query(`
CREATE TABLE IF NOT EXISTS referral_bindings (
id VARCHAR(50) PRIMARY KEY,
referrer_id VARCHAR(50) NOT NULL,
referee_id VARCHAR(50) NOT NULL,
referral_code VARCHAR(20) NOT NULL,
status ENUM('active', 'converted', 'expired') DEFAULT 'active',
binding_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expiry_date TIMESTAMP NOT NULL,
conversion_date TIMESTAMP NULL,
commission_amount DECIMAL(10,2) DEFAULT 0,
order_id VARCHAR(50) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (referrer_id) REFERENCES users(id),
FOREIGN KEY (referee_id) REFERENCES users(id),
FOREIGN KEY (order_id) REFERENCES orders(id),
UNIQUE KEY unique_referrer_referee (referrer_id, referee_id),
INDEX idx_referrer_id (referrer_id),
INDEX idx_referee_id (referee_id),
INDEX idx_status (status),
INDEX idx_expiry_date (expiry_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 匹配记录表
await query(`
CREATE TABLE IF NOT EXISTS match_records (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
match_type ENUM('partner', 'investor', 'mentor', 'team') NOT NULL,
phone VARCHAR(20),
wechat_id VARCHAR(100),
matched_user_id VARCHAR(50),
match_score INT,
status ENUM('pending', 'matched', 'contacted') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user_id (user_id),
INDEX idx_match_type (match_type),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 推广访问记录表(用于统计「通过链接进的人数」)
await query(`
CREATE TABLE IF NOT EXISTS referral_visits (
id INT AUTO_INCREMENT PRIMARY KEY,
referrer_id VARCHAR(50) NOT NULL COMMENT '推广者ID',
visitor_id VARCHAR(50) COMMENT '访客ID可能为空',
visitor_openid VARCHAR(100) COMMENT '访客openId',
source VARCHAR(50) DEFAULT 'miniprogram' COMMENT '来源miniprogram/web/share',
page VARCHAR(200) COMMENT '落地页路径',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_referrer_id (referrer_id),
INDEX idx_visitor_id (visitor_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 系统配置表
await query(`
CREATE TABLE IF NOT EXISTS system_config (
id INT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) UNIQUE NOT NULL,
config_value JSON NOT NULL,
description VARCHAR(200),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 章节内容表 - 存储书籍所有章节
await query(`
CREATE TABLE IF NOT EXISTS chapters (
id VARCHAR(20) PRIMARY KEY COMMENT '章节ID如1.1、preface等',
part_id VARCHAR(20) NOT NULL COMMENT '所属篇ID如part-1',
part_title VARCHAR(100) NOT NULL COMMENT '篇标题,如第一篇|真实的人',
chapter_id VARCHAR(20) NOT NULL COMMENT '所属章ID如chapter-1',
chapter_title VARCHAR(200) NOT NULL COMMENT '章标题如第1章人与人之间的底层逻辑',
section_title VARCHAR(200) NOT NULL COMMENT '节标题',
content LONGTEXT NOT NULL COMMENT '章节正文内容Markdown格式',
word_count INT DEFAULT 0 COMMENT '字数统计',
is_free BOOLEAN DEFAULT FALSE COMMENT '是否免费章节',
price DECIMAL(10,2) DEFAULT 1.00 COMMENT '单章价格',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
status ENUM('draft', 'published', 'archived') DEFAULT 'published' COMMENT '状态',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_part_id (part_id),
INDEX idx_chapter_id (chapter_id),
INDEX idx_status (status),
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
}
}
/**
* 初始化默认配置
*/
async function initDefaultConfig() {
try {
// 匹配类型配置
const matchConfig = {
matchTypes: [
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, enabled: true },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, enabled: true }
],
freeMatchLimit: 3,
matchPrice: 1,
settings: {
enableFreeMatches: true,
enablePaidMatches: true,
maxMatchesPerDay: 10
}
}
await query(`
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, // 推广者分成比例
minWithdrawAmount: 10, // 最小提现金额
bindingDays: 30, // 绑定有效期(天)
userDiscount: 5 // 用户优惠比例
}
await query(`
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)
}
}
/**
* 获取系统配置
* 连接不可达时返回 null由上层使用本地默认配置不重复打日志
*/
export async function getConfig(key: string) {
try {
const results = await query(
'SELECT config_value FROM system_config WHERE config_key = ?',
[key]
)
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) {
if (!isConnectionError(error)) {
console.error('获取配置失败:', error)
}
return null
}
}
/**
* 设置系统配置
*/
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
config_value = VALUES(config_value),
description = COALESCE(VALUES(description), description)
`, [key, JSON.stringify(value), description])
return true
} catch (error) {
console.error('设置配置失败:', error)
return false
}
}
// 导出数据库实例
export default { getPool, query, initDatabase, getConfig, setConfig }