小程序接入支付功能

This commit is contained in:
Yeomai committed 2026-09-29 01:14:24 +08:00
1 parent edf1e51846
commit 07a90e860d
38 files changed
+3644 -90

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
// 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 }