// MySQL 连接池、建表、字段备注同步 // 表结构写成数据(下面的 TABLES),建表语句和「备注同步」都由它生成,不会两处写法对不上 const fileConfig = require('./read-config.js') const { COPY } = require('./copy.js') const settings = { host: process.env.PAY_DB_HOST || (fileConfig.db && fileConfig.db.host) || '127.0.0.1', port: Number(process.env.PAY_DB_PORT || (fileConfig.db && fileConfig.db.port) || 3306), user: process.env.PAY_DB_USER || (fileConfig.db && fileConfig.db.user) || '', password: process.env.PAY_DB_PASSWORD || (fileConfig.db && fileConfig.db.password) || '', database: process.env.PAY_DB_NAME || (fileConfig.db && fileConfig.db.database) || '', } // 默认:配了库名和账号就走 MySQL,否则继续用 JSONL 文件 // PAY_STORE=jsonl / mysql 可以强制指定(自测强制用文件,不碰你的库) const useMysql = process.env.PAY_STORE ? process.env.PAY_STORE === 'mysql' : Boolean(settings.database && settings.user) const TABLES = { orders: { comment: '订单表,金额单位分', columns: [ { name: 'out_trade_no', type: 'VARCHAR(32)', notNull: true, comment: '业务单号,幂等键' }, { name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' }, { name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' }, { name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' }, { name: 'amount', type: 'INT', notNull: true, default: '0', comment: '下单金额(分)' }, { name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'created'", comment: 'created 已下单 delivered 已发货' }, { name: 'wx_order_id', type: 'VARCHAR(64)', comment: '平台单号,唯一' }, { name: 'paid_fee', type: 'INT', comment: '实付金额(分)' }, { name: 'notified', type: 'TINYINT', notNull: true, default: '0', comment: '已通知平台发货' }, { name: 'refunded', type: 'TINYINT', notNull: true, default: '0', comment: '已退款' }, { name: 'refund_order_id', type: 'VARCHAR(32)', comment: '退款单号' }, { name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '下单时间' }, { name: 'delivered_at', type: 'DATETIME(3)', comment: '发货时间' }, { name: 'refunded_at', type: 'DATETIME(3)', comment: '退款时间' }, ], keys: [ 'PRIMARY KEY (out_trade_no)', 'UNIQUE KEY uniq_wx_order (wx_order_id)', 'KEY idx_openid (openid)', 'KEY idx_pending (refunded, created_at)', ], }, codes: { comment: '激活码表,退款只作废不删行', columns: [ { name: 'code', type: 'VARCHAR(24)', notNull: true, comment: '激活码,主键' }, { name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID(等级)' }, { name: 'out_trade_no', type: 'VARCHAR(32)', comment: '绑定订单号,唯一' }, { name: 'openid', type: 'VARCHAR(64)', comment: '归属用户 openid' }, { name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' }, { name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'issued'", comment: 'issued 已发出 revoked 已作废' }, { name: 'batch', type: 'VARCHAR(32)', comment: '批次号,备用' }, { name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '生成时间' }, { name: 'bound_at', type: 'DATETIME(3)', comment: '绑定时间' }, { name: 'used_at', type: 'DATETIME(3)', comment: '核销时间,暂无核销' }, { name: 'revoked_at', type: 'DATETIME(3)', comment: '作废时间' }, ], keys: ['PRIMARY KEY (code)', 'UNIQUE KEY uniq_order (out_trade_no)', 'KEY idx_openid (openid)'], }, goods: { comment: '道具价目表,价格需与 MP 后台一致', columns: [ { name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' }, { name: 'name', type: 'VARCHAR(64)', notNull: true, comment: '显示名' }, { name: 'price', type: 'INT', notNull: true, comment: '单价(分)' }, { name: 'enabled', type: 'TINYINT', notNull: true, default: '1', comment: '0 下架 1 在售' }, { name: 'admin_only', type: 'TINYINT', notNull: true, default: '0', comment: '1 仅管理员可购买' }, { name: 'note', type: 'VARCHAR(255)', comment: '购买页提示文案,例如优惠信息' }, { name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' }, ], keys: ['PRIMARY KEY (product_id)'], }, users: { comment: '用户表,激活码看 codes 表', columns: [ { name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' }, { name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '首次使用时间' }, { name: 'last_seen_at', type: 'DATETIME(3)', notNull: true, comment: '最近一次活跃' }, { name: 'is_admin', type: 'TINYINT', notNull: true, default: '0', comment: '0 普通 1 管理员' }, ], keys: ['PRIMARY KEY (openid)', 'KEY idx_created (created_at)'], }, settings: { comment: '页面文案配置,改完最多 1 分钟生效', columns: [ { name: 'cfg_key', type: 'VARCHAR(64)', notNull: true, comment: '配置项,如 buy.title' }, { name: 'cfg_value', type: 'VARCHAR(255)', notNull: true, comment: '文案内容' }, { name: 'remark', type: 'VARCHAR(255)', comment: '这个配置显示在哪里' }, { name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' }, ], keys: ['PRIMARY KEY (cfg_key)'], }, } let pool = null function getPool() { if (pool) return pool let mysql try { mysql = require('mysql2/promise') } catch (err) { throw new Error('没装 mysql2,请先在 server 目录执行 npm install') } pool = mysql.createPool( Object.assign({}, settings, { charset: 'utf8mb4', waitForConnections: true, connectionLimit: 5, supportBigNumbers: true, bigNumberStrings: false, // 大整数当数字返回,别变字符串 }) ) return pool } const quoteComment = (text) => String(text).replace(/'/g, "''") function columnSql(column) { return [ `\`${column.name}\` ${column.type}`, column.notNull ? 'NOT NULL' : 'NULL', column.default === undefined ? '' : `DEFAULT ${column.default}`, `COMMENT '${quoteComment(column.comment)}'`, ] .filter(Boolean) .join(' ') } // 早期版本用 BIGINT 存毫秒时间戳,这里自动换成 DATETIME(3),跑一次就没了 async function upgradeTimeColumns(db) { const [columns] = await db.query( `SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND DATA_TYPE = 'bigint' AND COLUMN_NAME LIKE '%\\_at'`, [settings.database] ) for (const column of columns) { const table = column.TABLE_NAME const name = column.COLUMN_NAME await db.query(`ALTER TABLE \`${table}\` ADD COLUMN \`${name}_new\` DATETIME(3) NULL`) await db.query( `UPDATE \`${table}\` SET \`${name}_new\` = FROM_UNIXTIME(\`${name}\` / 1000) WHERE \`${name}\` IS NOT NULL` ) await db.query(`ALTER TABLE \`${table}\` DROP COLUMN \`${name}\``) await db.query(`ALTER TABLE \`${table}\` CHANGE \`${name}_new\` \`${name}\` DATETIME(3) NULL`) } return columns.length } // 表已经存在时建表语句不会生效,所以单独把「字段备注 + 表备注」同步一遍 // ponytail: 一个 ALTER 会把整表重建,数据量小(几百行)无所谓;真到几十万行再改成按需比对 async function syncComments(db, name, table) { const [rows] = await db.query( `SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [settings.database, name] ) const current = new Map(rows.map((row) => [row.COLUMN_NAME, row.COLUMN_COMMENT])) const stale = table.columns.filter((column) => current.has(column.name) && current.get(column.name) !== column.comment) if (stale.length) { await db.query(`ALTER TABLE \`${name}\` ${stale.map((column) => 'MODIFY COLUMN ' + columnSql(column)).join(', ')}`) } const [tableRows] = await db.query( `SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [settings.database, name] ) if (tableRows.length && tableRows[0].TABLE_COMMENT !== table.comment) { await db.query(`ALTER TABLE \`${name}\` COMMENT = '${quoteComment(table.comment)}'`) } return stale.length } // 表已存在时建表语句改不了结构,所以按声明把缺的列补上(只加列,不动已有数据) // 注意:以后新增 NOT NULL 列一定要带 DEFAULT,否则在非空表上会加不进去 async function addMissingColumns(db, name, table) { const [rows] = await db.query( `SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [settings.database, name] ) const existing = new Set(rows.map((row) => row.COLUMN_NAME)) const missing = table.columns.filter((column) => !existing.has(column.name)) for (const column of missing) { await db.query(`ALTER TABLE \`${name}\` ADD COLUMN ${columnSql(column)}`) } return missing.length } async function initSchema(goodsSeed) { const db = getPool() for (const [name, table] of Object.entries(TABLES)) { const columns = table.columns.map(columnSql).join(', ') await db.query( `CREATE TABLE IF NOT EXISTS \`${name}\` (${columns}, ${table.keys.join(', ')}) ` + `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='${quoteComment(table.comment)}'` ) } let added = 0 for (const [name, table] of Object.entries(TABLES)) added += await addMissingColumns(db, name, table) if (added) console.log(`已给已有表补上 ${added} 个字段`) const converted = await upgradeTimeColumns(db) if (converted) console.log(`已把 ${converted} 个时间列从时间戳改成可读时间`) let commented = 0 for (const [name, table] of Object.entries(TABLES)) commented += await syncComments(db, name, table) if (commented) console.log(`已同步 ${commented} 个字段备注`) // 价目表空的时候用代码里的价目表播种一次;之后改价直接改库(记得 MP 后台一起改) const [rows] = await db.query('SELECT COUNT(*) AS n FROM goods') if (rows[0].n === 0) { for (const productId of Object.keys(goodsSeed)) { const goods = goodsSeed[productId] await db.query('INSERT IGNORE INTO goods (product_id, name, price, enabled, updated_at) VALUES (?, ?, ?, 1, ?)', [ String(productId), goods.name, goods.price, new Date(), ]) } } // admin_only 以代码里的价目表为准(价格、名称还是以库里为准,方便改价不发版) for (const productId of Object.keys(goodsSeed)) { await db.query('UPDATE goods SET admin_only = ? WHERE product_id = ?', [ goodsSeed[productId].adminOnly ? 1 : 0, String(productId), ]) } // 文案配置:缺哪个补哪个,只刷新备注列,不覆盖你已经改过的 cfg_value for (const [key, item] of Object.entries(COPY)) { await db.query( `INSERT INTO settings (cfg_key, cfg_value, remark, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE remark = VALUES(remark)`, [key, item.value, item.comment, new Date()] ) } return db } module.exports = { settings, useMysql, getPool, initSchema }