小程序接入支付功能
This commit is contained in:
1 parent
edf1e51846
commit
07a90e860d
38 files changed
+3644
-90
No files matched your search
+241
@@ -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 }
|
||||
Reference in new issue
Block a user