// JSONL 后端:一行一条 JSON 追加写(改状态就追加新行),启动时读一遍建索引 // 量小、单进程够用;也是没配数据库时的默认后端,自测和回滚都靠它 const fs = require('fs') const path = require('path') const { newActivationCode: randomCode } = require('./code.js') const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data') const FILE = path.join(DATA_DIR, 'orders.jsonl') const USERS_FILE = path.join(DATA_DIR, 'users.jsonl') const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json') const orders = new Map() // outTradeNo -> 最新一条记录 const usedCodes = new Set() const users = new Map() // openid -> 记录 const persistedSeen = new Map() // openid -> 最近一次落盘的活跃时间 function newActivationCode() { for (;;) { const code = randomCode() if (!usedCodes.has(code)) return code } } function load() { orders.clear() usedCodes.clear() users.clear() persistedSeen.clear() if (fs.existsSync(FILE)) { fs.readFileSync(FILE, 'utf8') .split('\n') .forEach((line) => { if (!line.trim()) return const record = JSON.parse(line) orders.set(record.outTradeNo, record) // 同一单号,后写的覆盖先写的 if (record.code) usedCodes.add(record.code) }) } if (fs.existsSync(USERS_FILE)) { fs.readFileSync(USERS_FILE, 'utf8') .split('\n') .forEach((line) => { if (!line.trim()) return const record = JSON.parse(line) users.set(record.openid, record) persistedSeen.set(record.openid, record.lastSeenAt) }) } } load() module.exports = { backend: 'jsonl', file: FILE, ready: async () => {}, get: async (outTradeNo) => orders.get(outTradeNo) || null, // 全量落一条(新建、改状态、迁移导入都走它) saveOrder: async (record) => { fs.mkdirSync(DATA_DIR, { recursive: true }) fs.appendFileSync(FILE, JSON.stringify(record) + '\n') orders.set(record.outTradeNo, record) if (record.code) usedCodes.add(record.code) return record }, // 发货:生成激活码并绑定;已经有码就直接返回(幂等) assignCode: async (outTradeNo, extra) => { const record = orders.get(outTradeNo) if (!record) throw new Error('没有这笔业务单号:' + outTradeNo) if (record.code) return record return module.exports.saveOrder( Object.assign( {}, record, { status: 'delivered', code: newActivationCode(), deliveredAt: Date.now(), notified: false }, extra ) ) }, markNotified: async (outTradeNo) => { const record = orders.get(outTradeNo) if (!record) return await module.exports.saveOrder(Object.assign({}, record, { notified: true })) }, markRefunded: async (outTradeNo, refundOrderId) => { const record = orders.get(outTradeNo) if (!record) return await module.exports.saveOrder(Object.assign({}, record, { refunded: true, refundOrderId, refundedAt: Date.now() })) }, listByOpenid: async (openid) => Array.from(orders.values()) .filter((o) => o.openid === openid && o.code && !o.refunded) .sort((a, b) => b.deliveredAt - a.deliveredAt), listPendingByOpenid: async (openid) => Array.from(orders.values()) .filter((o) => o.openid === openid && !o.refunded && (!o.code || !o.notified)) .sort((a, b) => b.createdAt - a.createdAt), listPendingAll: async (since) => Array.from(orders.values()) .filter((o) => !o.refunded && (!o.code || !o.notified) && o.createdAt >= since) .sort((a, b) => a.createdAt - b.createdAt), listGoods: async () => [], getUser: async (openid) => { const record = users.get(openid) return record ? { openid, createdAt: record.createdAt, lastSeenAt: record.lastSeenAt, isAdmin: Boolean(record.isAdmin) } : null }, // 文件后端:想改文案就在 data/settings.json 里写 {"buy.title": "xx"} getSettings: async () => { if (!fs.existsSync(SETTINGS_FILE)) return {} try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) } catch (err) { console.error('settings.json 解析失败:', err.message) return {} } }, touchUser: async (openid, firstSeenAt) => { const now = Date.now() const existed = users.get(openid) const record = { openid, createdAt: existed ? existed.createdAt : firstSeenAt || now, lastSeenAt: now, isAdmin: existed ? existed.isAdmin : false, } users.set(openid, record) // 活跃时间最多每小时落一次盘,免得文件被刷爆 if (!persistedSeen.has(openid) || now - persistedSeen.get(openid) > 3600000) { fs.mkdirSync(DATA_DIR, { recursive: true }) fs.appendFileSync(USERS_FILE, JSON.stringify(record) + '\n') persistedSeen.set(openid, now) } return record }, // 核对用:订单数、激活码数、未完成的单 stats: async () => ({ orders: orders.size, codes: Array.from(orders.values()).filter((o) => o.code).length, pending: Array.from(orders.values()).filter((o) => !o.refunded && (!o.code || !o.notified)).length, users: users.size, }), }