Files
2026-09-29 01:22:34 +08:00

234 lines
7.6 KiB
JavaScript

// MySQL 后端:订单一张表,激活码单独一张表(唯一索引保证一单一码、码不重复)
const { getPool, initSchema } = require('./db.js')
const { GOODS } = require('./sign.js')
const { newActivationCode } = require('./code.js')
const ORDER_COLUMNS = [
'out_trade_no',
'openid',
'product_id',
'env',
'channel',
'amount',
'status',
'wx_order_id',
'paid_fee',
'notified',
'refunded',
'refund_order_id',
'created_at',
'delivered_at',
'refunded_at',
]
const SELECT_ORDER = 'SELECT o.*, c.code FROM orders o LEFT JOIN codes c ON c.out_trade_no = o.out_trade_no'
const placeholders = (n) => Array(n).fill('?').join(', ')
// 库里存可读的 DATETIME,对外(接口、内存)还是毫秒时间戳,只在读写边界转换
const toDbDate = (ms) => (ms == null ? null : new Date(Number(ms)))
const toMs = (value) => (value == null ? null : value instanceof Date ? value.getTime() : Number(value))
function toRecord(row) {
return {
outTradeNo: row.out_trade_no,
openid: row.openid,
productId: row.product_id,
env: row.env,
channel: row.channel || '',
amount: row.amount,
status: row.status,
code: row.code || '',
wxOrderId: row.wx_order_id || '',
paidFee: row.paid_fee,
notified: Boolean(row.notified),
refunded: Boolean(row.refunded),
refundOrderId: row.refund_order_id || '',
createdAt: toMs(row.created_at),
deliveredAt: toMs(row.delivered_at),
refundedAt: toMs(row.refunded_at),
}
}
async function saveOrder(record) {
const db = getPool()
const values = [
record.outTradeNo,
record.openid,
String(record.productId),
record.env || 0,
record.channel || null,
record.amount || 0,
record.status || 'created',
record.wxOrderId || null,
record.paidFee == null ? null : record.paidFee,
record.notified ? 1 : 0,
record.refunded ? 1 : 0,
record.refundOrderId || null,
toDbDate(record.createdAt || Date.now()),
toDbDate(record.deliveredAt),
toDbDate(record.refundedAt),
]
const updates = ORDER_COLUMNS.filter((c) => c !== 'out_trade_no').map((c) => `${c} = VALUES(${c})`).join(', ')
await db.query(
`INSERT INTO orders (${ORDER_COLUMNS.join(', ')}) VALUES (${placeholders(ORDER_COLUMNS.length)}) ON DUPLICATE KEY UPDATE ${updates}`,
values
)
if (record.code) {
// 一单一码:out_trade_no 唯一索引兜底,重复写不动已有的码
await db.query(
`INSERT INTO codes (code, product_id, out_trade_no, openid, env, status, created_at, bound_at)
VALUES (?, ?, ?, ?, ?, 'issued', ?, ?)
ON DUPLICATE KEY UPDATE code = code`,
[
record.code,
String(record.productId),
record.outTradeNo,
record.openid,
record.env || 0,
toDbDate(record.deliveredAt || Date.now()),
toDbDate(record.deliveredAt || Date.now()),
]
)
}
return record
}
module.exports = {
backend: 'mysql',
ready: () => initSchema(GOODS),
get: async (outTradeNo) => {
const [rows] = await getPool().query(`${SELECT_ORDER} WHERE o.out_trade_no = ?`, [outTradeNo])
return rows.length ? toRecord(rows[0]) : null
},
saveOrder,
// 发货:codes 表里已经有就直接返回(幂等);撞号或并发重复发就重来一次
assignCode: async (outTradeNo, extra) => {
for (let attempt = 0; attempt < 5; attempt++) {
const record = await module.exports.get(outTradeNo)
if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
if (record.code) return record
const updated = Object.assign(
{},
record,
{ status: 'delivered', code: newActivationCode(), deliveredAt: Date.now(), notified: false },
extra
)
try {
await saveOrder(updated)
return updated
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err
}
}
throw new Error('激活码连续撞号,请重试')
},
markNotified: async (outTradeNo) => {
await getPool().query('UPDATE orders SET notified = 1 WHERE out_trade_no = ?', [outTradeNo])
},
markRefunded: async (outTradeNo, refundOrderId) => {
const now = Date.now()
await getPool().query('UPDATE orders SET refunded = 1, refund_order_id = ?, refunded_at = ? WHERE out_trade_no = ?', [
refundOrderId,
toDbDate(now),
outTradeNo,
])
// 码作废但不删除:将来做核销时按 status 拒绝
await getPool().query(`UPDATE codes SET status = 'revoked', revoked_at = ? WHERE out_trade_no = ?`, [
toDbDate(now),
outTradeNo,
])
},
listByOpenid: async (openid) => {
const [rows] = await getPool().query(
`${SELECT_ORDER} WHERE o.openid = ? AND c.code IS NOT NULL AND o.refunded = 0 ORDER BY o.delivered_at DESC`,
[openid]
)
return rows.map(toRecord)
},
listPendingByOpenid: async (openid) => {
const [rows] = await getPool().query(
`${SELECT_ORDER}
WHERE o.openid = ? AND o.refunded = 0 AND (c.code IS NULL OR o.notified = 0)
ORDER BY o.created_at DESC LIMIT 3`,
[openid]
)
return rows.map(toRecord)
},
listPendingAll: async (since) => {
const [rows] = await getPool().query(
`${SELECT_ORDER}
WHERE o.refunded = 0 AND o.created_at >= ? AND (c.code IS NULL OR o.notified = 0)
ORDER BY o.created_at ASC`,
[toDbDate(since)]
)
return rows.map(toRecord)
},
listGoods: async () => {
const [rows] = await getPool().query(
'SELECT product_id, name, price, enabled, admin_only, note FROM goods ORDER BY price ASC, product_id ASC'
)
return rows.map((row) => ({
productId: row.product_id,
name: row.name,
price: row.price,
enabled: Boolean(row.enabled),
adminOnly: Boolean(row.admin_only),
note: row.note || '',
}))
},
// 用户信息(管理员标志就从这来)
getUser: async (openid) => {
const [rows] = await getPool().query('SELECT openid, created_at, last_seen_at, is_admin FROM users WHERE openid = ?', [
openid,
])
if (!rows.length) return null
return {
openid: rows[0].openid,
createdAt: toMs(rows[0].created_at),
lastSeenAt: toMs(rows[0].last_seen_at),
isAdmin: Boolean(rows[0].is_admin),
}
},
// 页面文案配置(settings 表里有什么就是什么,兜底在 index.js)
getSettings: async () => {
const [rows] = await getPool().query('SELECT cfg_key, cfg_value FROM settings')
const settings = {}
for (const row of rows) settings[row.cfg_key] = row.cfg_value
return settings
},
// 用户首次出现就插一行,之后每次只更新活跃时间(首次使用时间不会被覆盖)
touchUser: async (openid, firstSeenAt) => {
const now = new Date()
const created = firstSeenAt ? new Date(Number(firstSeenAt)) : now
await getPool().query(
`INSERT INTO users (openid, created_at, last_seen_at, is_admin) VALUES (?, ?, ?, 0)
ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at)`,
[openid, created, now]
)
},
// 核对用:订单数、激活码数、未完成的单
stats: async () => {
const db = getPool()
const [[orders], [codes], [pending], [users]] = await Promise.all([
db.query('SELECT COUNT(*) AS n FROM orders'),
db.query('SELECT COUNT(*) AS n FROM codes'),
db.query(`SELECT COUNT(*) AS n FROM orders o LEFT JOIN codes c ON c.out_trade_no = o.out_trade_no
WHERE o.refunded = 0 AND (c.code IS NULL OR o.notified = 0)`),
db.query('SELECT COUNT(*) AS n FROM users'),
])
return { orders: orders[0].n, codes: codes[0].n, pending: pending[0].n, users: users[0].n }
},
}