diff --git a/.agents/skills/personal-virtual-payment/SKILL.md b/.agents/skills/personal-virtual-payment/SKILL.md
new file mode 100644
index 0000000..1e8258f
--- /dev/null
+++ b/.agents/skills/personal-virtual-payment/SKILL.md
@@ -0,0 +1,250 @@
+---
+name: personal-virtual-payment
+description: Guide for integrating WeChat Mini Program virtual payment for personal-entity developers (individual, resident ID, utility category). Covers activation, item purchase (short_series_goods), delivery push, order query, refund, settlement and fees. Trigger when the user asks to enable or integrate virtual payment for a personal mini program, or configure wx.requestVirtualPayment. 个人主体小程序虚拟支付接入指引。
+---
+
+# 个人主体小程序虚拟支付接入
+
+为进一步支持个人开发者实现可持续经营,激发个人主体的创新活力,微信小程序平台将在符合条件的类目范围内,向个人主体小程序开放虚拟支付能力。
+
+## Overview
+
+本技能为**个人主体**小程序提供虚拟支付接入流程,覆盖从开通到上线的完整闭环:
+
+- 核对开通条件(个人主体、工具类目、认证备案)
+- MP 后台开通虚拟支付并获取关键参数
+- 道具直购接入(`wx.requestVirtualPayment`)
+- 服务端签名、发货推送、查单兜底
+- 退款、结算、费率、发票说明
+- 部署前检查清单验收
+
+**适用范围**:仅个人主体小程序,服务类目含「工具」,月支付限额 10 万元。
+
+## 1. 开通条件核对
+
+| 条件 | 说明 |
+| --- | --- |
+| 个人主体 | 小程序主体为个人,开发者持有居民身份证 |
+| 服务类目 | 小程序服务类目含「工具」 |
+| 认证与备案 | 已完成小程序认证、备案 |
+
+> **限额**:个人主体小程序**月支付限额为 10 万元**(含 Android、iOS 等所有终端)。
+
+## 2. 开通流程(MP 后台)
+
+打开浏览器登录微信开放平台(mp.weixin.qq.com):
+
+1. 左侧栏找到并点击【支付与交易 - 虚拟支付】
+2. 点击【开通】→ 勾选同意协议 → 填写个人资料(个人身份信息、提现账户、支付管理员)
+3. 提交后等待审核,一般为 5 分钟内
+4. 刷新页面、确认审核通过后,扫码签约
+
+### 开通后获取 3 个关键信息
+
+| 信息 | 它是什么 | 在哪找 |
+| --- | --- | --- |
+| AppID | 小程序的「身份证号」 | MP 后台 → 设置 |
+| OfferID | 你的支付账号 | 虚拟支付 → 基本配置 |
+| 现网AppKey | 支付密钥 | 虚拟支付 → 基本配置 |
+
+同时,需在【道具管理】里创建商品(道具),记下道具 ID 和价格,并完成发布。
+
+### iOS 支付额外条件
+
+如需在 iOS 端使用支付:
+1. **配置小程序简称**(Apple 支付需展示 display name)。配置入口:mp.weixin.qq.com → 虚拟支付 → 基础配置
+2. 配置后,在虚拟支付 → 基本配置中**开通苹果 IAP 支付**
+
+## 3. 开发接入
+
+### 3.1 支付与现有业务流程串接
+
+虚拟支付只是把「收钱」环节接入小程序已有业务流程。整体闭环:
+
+```text
+用户打开小程序 → 登录 → 选择要购买的功能/道具 → 点击「购买」
+ ↓
+① 前端请求服务器下单 → 服务器生成唯一单号 outTradeNo,保存订单(状态:待支付)
+ ↓
+② 服务器返回 payData → 前端调用 wx.requestVirtualPayment 拉起支付
+ ↓
+③ 支付成功后,服务器通过两条路径确认并完成发货:
+ 路径 A:收到平台「发货推送」→ 直接发货
+ 路径 B:推送丢失时,定时调用 query_order 查单 → 查到已支付就补发货
+ ↓
+④ 前端查询自己服务器的订单状态 → 展示「购买成功」并开放权益
+```
+
+关键点:
+- `outTradeNo`:商家自己生成的业务单号,下单时必填,须唯一
+- `wx_order_id`:平台返回的单号,跟踪订单、发货、对账以此为准
+- 发货以「发货推送」为主,推送丢失时用 `query_order` 查单兜底;**前端支付回调不作为发货依据**
+
+### 3.2 前端接入(小程序端)
+
+调用基础库接口 `wx.requestVirtualPayment`(签名 = 防伪校验)。payData 由服务端生成并签名。
+
+```javascript
+function buyGoods(payData) {
+ wx.requestVirtualPayment({
+ ...payData,
+ success(res) { wx.showToast({ title: '支付成功' }); },
+ fail(err) { wx.showToast({ title: '支付失败', icon: 'none' }); }
+ });
+}
+```
+
+**iOS 版本限制**:iOS 端需微信客户端 **8.0.68 及以上版本**。调用支付前先校验,不满足时提示用户更新微信至最新版。
+
+**payData 核心字段**:
+
+| 字段 | 说明 |
+| --- | --- |
+| signData | 支付参数 JSON 字符串(服务端生成): |
+| ├ offerId | OfferID(虚拟支付商户号) |
+| ├ buyQuantity | 购买数量 |
+| ├ env | 环境标识,固定填 0 |
+| ├ currencyType | 币种,固定填 CNY |
+| ├ productId | 道具 ID |
+| ├ goodsPrice | 道具单价(分),需与后台道具价格一致 |
+| ├ outTradeNo | 业务单号(必填,须唯一,如 `T` + 时间戳 + 随机数) |
+| └ attach | 透传数据(必填,发货时原样透传) |
+| mode | 固定 `short_series_goods`(道具直购) |
+| paySig | 服务端用 AppKey 对 `requestVirtualPayment&signData` 做 HMAC-SHA256 |
+| signature | 服务端用 sessionKey 对 signData 做 HMAC-SHA256(用户态签名) |
+
+### 3.3 服务端接入
+
+#### 3.3.1 接收发货推送
+
+- 需先部署「消息推送接收」能力。使用**腾讯云开发**时,可借助微信开发者工具 Skills 自动完成该部分部署与配置;其余服务端逻辑(下单、查单、发货)由代码实现
+- 在 MP 后台【虚拟支付 → 基本配置 → 基础配置 → 发货推送配置】填服务器 URL
+
+推送核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| Event | 固定 `xpay_goods_deliver_notify` |
+| OpenId | 用户 openid(确定发货给谁) |
+| OutTradeNo | 商家下单时传入的业务单号 |
+| WeChatPayInfo.MchOrderNo | 平台单号 wx_order_id(跟踪订单、幂等去重,以此为准) |
+| GoodsInfo.ProductId | 道具 ID |
+| GoodsInfo.Quantity | 购买数量(按数量发放) |
+
+服务器处理逻辑:
+1. 解析 XML,取平台单号 `wx_order_id`(位于 `WeChatPayInfo.MchOrderNo` 字段)
+2. **幂等**(同一订单重复通知只发一次货):查本地订单表,若该 wx_order_id 已发货则直接返回成功
+3. 发货:根据 `OpenId` 和道具 ID,给对应用户发放对应数量,更新订单状态
+4. 返回 `0` 表示成功,否则平台会重试(最多 15 次)
+
+应答格式:
+
+```xml
+0
+```
+
+#### 3.3.2 查询订单(兜底发货)
+
+接口:`POST /xpay/query_order`(带 pay_sig 签名)
+
+```json
+{
+ "openid": "用户openid",
+ "env": 0,
+ "order_id": "业务订单号 outTradeNo"
+}
+```
+
+> ⚠️ 参数名是 `order_id`(开发者传入的 outTradeNo),不是 `out_trade_no`。
+
+用于:推送丢失时主动查单补发货,建议定时每 5 分钟查一次,查到已支付就发货。
+
+#### 3.3.3 服务端接口清单
+
+| 接口 | 作用 |
+| --- | --- |
+| `POST /pay/order` | 生成 outTradeNo + signData + paySig,返回给前端去支付 |
+| `POST /pay/notify` | 接收发货推送,验签后发货,返回 XML |
+| `POST /pay/query` | 转发调用 query_order,供兜底查单 |
+
+### 3.4 签名算法(两套签名)
+
+#### 支付签名 paySig(服务端算,AppKey 是密钥)
+
+```python
+import hmac, hashlib
+
+def calc_pay_sig(uri, post_body, appkey):
+ """uri 不带问号参数。
+ - C 端(wx.requestVirtualPayment 下单):uri 固定为 requestVirtualPayment
+ - B 端(服务端 /xpay/* 接口):uri 为实际接口路径,如 /xpay/query_order
+ """
+ msg = uri + '&' + post_body # post_body 是真正发出去的原始字符串
+ return hmac.new(appkey.encode('utf-8'), msg.encode('utf-8'),
+ hashlib.sha256).hexdigest()
+```
+
+#### 用户态签名 signature(服务端算,sessionKey 是密钥)
+
+```python
+def calc_signature(post_body, session_key):
+ return hmac.new(session_key.encode('utf-8'), post_body.encode('utf-8'),
+ hashlib.sha256).hexdigest()
+```
+
+## 4. 退款、结算与订单查询
+
+### 4.1 退款
+
+| 终端 | 退款方式 | 说明 |
+| --- | --- | --- |
+| Android 等 | 开发者主动退款 | 在 MP 后台【虚拟支付 → 交易订单】操作退款,或用 `refund_order` 接口发起;退款完成后收到 `xpay_refund_notify` 推送 |
+| iOS | 用户向 App Store 申请 | 开发者无法主动退款;用户申请后由 Apple 决定,退款成功收到 `xpay_refund_notify` |
+
+> 手续费规则:支付时间 180 天以内的退款,平台退还手续费;超过 180 天的退款,平台不退还手续费。
+
+### 4.2 结算
+
+| 终端 | 结算周期 | 费率 | 说明 |
+| --- | --- | --- | --- |
+| Android 等 | T+3 | 1% | 腾讯技术服务费 |
+| iOS | 约 45-60 天 | 12% | Apple 佣金 |
+
+- **提现**:资金到账后,可在 MP 后台【虚拟支付】查看账户余额、每日账单并发起提现
+- **发票**:可在**次月 5 号后**申请开具上个月的腾讯技术服务费发票
+
+### 4.3 订单查询
+
+- 页面查询:MP 后台【虚拟支付 → 交易订单】,可按支付渠道切换「普通支付 / Apple 支付」
+- 接口查询:`query_order`(见 3.3.2),可用于核对订单与退款状态
+
+## 5. Agent 注意事项
+
+1. **用户告知**:向用户确认开放条件(个人主体、工具类目、认证备案)与**月支付限额 10 万元**,并说明退款、结算与费率规则(见第四节)
+2. **接入范围**:只做道具直购,不引入代币等额外功能
+3. **outTradeNo**:每次下单重新生成、保证唯一(8-32 位,不能以下划线开头),不能复用
+4. **签名**:严格按 3.4 节实现;`post_body` 必须与实际请求体完全一致(不格式化、不改键顺序);`appkey` 用正式 AppKey;`sessionKey` 通过 `auth.code2Session` 获取
+5. **发货**:以 `wx_order_id` 做幂等去重;`success` 回调可能丢失(用户异常退出会丢),以「发货推送」为主、`query_order` 查单兜底
+6. **金额与 env**:金额单位是「分」,全程不要换算;`env` 固定填 `0`(正式环境/现网)
+
+## 6. 部署前检查清单
+
+- [ ] 开放条件确认:个人主体 + 中国大陆居民身份证 + 服务类目为「工具」+ 已完成认证、备案
+- [ ] 已了解并告知用户:全终端月支付限额为 10 万元
+- [ ] MP 后台虚拟支付已开通
+- [ ] 已拿到 AppID / OfferID / 现网AppKey
+- [ ] 若需 iOS 支付:已配置小程序简称
+- [ ] 消息推送接收已部署(腾讯云开发可用开发者工具 Skills 自动完成)
+- [ ] MP 后台已配置发货推送 URL,并完成一笔测试支付验证
+- [ ] 服务器验签逻辑通过(签名函数与官方文档签名示例核对一致)
+- [ ] 发货推送已做幂等(以 wx_order_id 去重)
+- [ ] 前端 wx.requestVirtualPayment 打通,支付成功并收到发货推送
+- [ ] 兜底查单逻辑已就绪(query_order)
+- [ ] 已向用户说明退款规则、结算周期与费率(Android 1%、iOS 12%)
+- [ ] 上线后用小额真单验证:支付 → 推送 → 发货 → 后台账单金额一致
+
+## 参考资源
+
+- 个人主体接入指引:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/person
+- 客户端 API:https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestVirtualPayment.html
+- 小程序简称配置:https://developers.weixin.qq.com/community/develop/doc/0004221455ce78d413893d4b75b009
diff --git a/.agents/skills/personal-virtual-payment/_meta.json b/.agents/skills/personal-virtual-payment/_meta.json
new file mode 100644
index 0000000..4d50a8d
--- /dev/null
+++ b/.agents/skills/personal-virtual-payment/_meta.json
@@ -0,0 +1,6 @@
+{
+ "ownerId": "538640",
+ "slug": "miniprogram-virtualpay-person",
+ "version": "1.0.2",
+ "publishedAt": 1788233178091
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b6ee4b0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+
+# 虚拟支付密钥,不要入库
+server/config.js
+
+# 订单与激活码运行数据
+server/data/
diff --git a/app.json b/app.json
index 5a0d4d2..6ca871c 100644
--- a/app.json
+++ b/app.json
@@ -1,14 +1,29 @@
{
"pages": [
- "pages/viewer/viewer"
+ "pages/home/home",
+ "pages/viewer/viewer",
+ "pages/buy/buy",
+ "pages/mine/mine"
],
"window": {
- "navigationBarTitleText": "Spine 动画预览",
+ "navigationBarTitleText": "Spine 画面预览",
"navigationBarBackgroundColor": "#16181d",
"navigationBarTextStyle": "white",
"backgroundColor": "#16181d",
"backgroundTextStyle": "light"
},
+ "tabBar": {
+ "color": "#8b93a7",
+ "selectedColor": "#4a6cf7",
+ "backgroundColor": "#16181d",
+ "borderStyle": "black",
+ "list": [
+ { "pagePath": "pages/home/home", "text": "首页" },
+ { "pagePath": "pages/buy/buy", "text": "开通" },
+ { "pagePath": "pages/viewer/viewer", "text": "预览" },
+ { "pagePath": "pages/mine/mine", "text": "我的" }
+ ]
+ },
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
diff --git a/app.wxss b/app.wxss
index eee52c3..b73a608 100644
--- a/app.wxss
+++ b/app.wxss
@@ -1,7 +1,26 @@
page {
- background: #16181d;
+ /* 圆角规范:结构块(卡片/按钮/面板/提示条)用 --r-lg;
+ 嵌在里面的小块(如分段控件的选中项)用 --r-md;
+ 只有标签、徽标这类才用 --r-pill 做胶囊 */
+ --r-lg: 20rpx;
+ --r-md: 14rpx;
+ --r-pill: 999rpx;
+
+ /* 配色跟官网 spineviewer.yeomai.site 保持一致 */
+ --bg: #131317;
+ --bg-2: #17171c;
+ --panel: #1c1c22;
+ --panel-2: #24242c;
+ --border: #2c2c35;
+ --text: #e9e9ee;
+ --text-dim: #9d9da8;
+ --accent: #f2353b;
+ --accent-2: #ff6a4d;
+ --cyan: #4fc1ff;
+
+ background: var(--bg);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Helvetica, sans-serif;
- color: #1f2430;
+ color: var(--text);
box-sizing: border-box;
}
@@ -14,3 +33,32 @@ button {
button::after {
border: none;
}
+
+/* 占位页(待接功能)共用样式 */
+.placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ padding: 0 60rpx;
+ text-align: center;
+}
+
+.placeholder-icon {
+ font-size: 76rpx;
+ margin-bottom: 24rpx;
+}
+
+.placeholder-title {
+ font-size: 36rpx;
+ font-weight: 600;
+ color: #ffffff;
+ margin-bottom: 16rpx;
+}
+
+.placeholder-desc {
+ font-size: 26rpx;
+ color: rgba(255, 255, 255, 0.55);
+ line-height: 1.7;
+}
diff --git a/package.json b/package.json
index 39a034a..3a35ce7 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,11 @@
"version": "1.0.0",
"description": "微信小程序:Spine 4.2 动画预览(skel + atlas + png),纯本地运行,无云端依赖",
"scripts": {
- "build:spine": "esbuild tools/spine-entry.js --bundle --format=cjs --platform=browser --main-fields=main,module --target=es2018 --minify --outfile=vendor/spine.js"
+ "build:spine": "esbuild tools/spine-entry.js --bundle --format=cjs --platform=browser --main-fields=main,module --target=es2018 --minify --outfile=vendor/spine.js",
+ "pay:server": "node server/index.js",
+ "pay:test": "node server/test.js",
+ "pay:test:db": "node server/test-db.js",
+ "pay:migrate": "node server/migrate.js"
},
"devDependencies": {
"@esotericsoftware/spine-core": "^4.2.120",
diff --git a/pages/buy/buy.js b/pages/buy/buy.js
new file mode 100644
index 0000000..f0845ce
--- /dev/null
+++ b/pages/buy/buy.js
@@ -0,0 +1,302 @@
+const { post, get, login } = require('../../utils/pay-api.js')
+
+// 道具列表(与 MP 后台一致,单位:分)
+const GOODS = [
+ { id: '90001', name: '补单专用', price: 10, adminOnly: true, note: '0.1 元测试用,iOS 最低 1 元买不了' },
+ { id: '10001', name: '基础激活码', price: 3900 },
+ { id: '10002', name: '高级激活码', price: 6900 },
+ { id: '10000', name: '永久激活码', price: 9900 },
+]
+
+// 只把常见的几个错误码翻译成人话,方便对着测
+const PAY_ERR = {
+ 1001: '参数错误',
+ '-1': '支付失败',
+ '-2': '用户取消支付',
+ '-4': '风控拦截',
+ '-15002': 'outTradeNo 重复,换新单号重试',
+ '-15005': '用户态签名 signature 错误',
+ '-15006': '支付签名 paySig 错误',
+ '-15007': 'session_key 过期,重进小程序再试',
+ '-15010': '道具未发布(未发布的道具不能下单)',
+ '-15011': '现网版本 env 只能填 0',
+ '-15013': 'goodsPrice 与后台道具价格不一致',
+ '-15014': '道具刚发布未生效,等几分钟再试',
+}
+
+const ORDER_STATUS = {
+ 0: '订单初始化',
+ 1: '订单创建成功(未支付)',
+ 2: '已支付,待发货',
+ 3: '发货中',
+ 4: '已发货',
+ 5: '已退款',
+ 6: '已关闭',
+ 7: '退款失败',
+ 8: '用户退款完成',
+ 9: '回收广告金完成',
+ 10: '分账回退完成',
+}
+
+const IOS_MIN_VERSION = [8, 0, 68]
+const BASE_LIB_MIN_VERSION = '2.19.2'
+
+// 上次问出来的身份先当初始值:切到本页时,补单道具和沙箱开关就不会先闪一下再消失
+const cachedIsAdmin = Boolean(wx.getStorageSync('isAdmin'))
+
+// 页面文案的默认值(和 server/copy.js 保持一致);数据库 settings 表里的同名配置会覆盖它
+const DEFAULT_COPY = {
+ tab: '开通',
+ title: '选择套餐',
+ subtitle: '一次付费,激活码自动发放到「我的」',
+ button: '购买',
+ tip: '购买成功后激活码会自动发放,在「我的」里可以随时查看和复制。同一笔订单重复点只会拿到同一张码,不用担心重复扣款。',
+}
+
+// 上次拿到的文案先当初始值,避免切页时文字闪一下
+const cachedCopy = Object.assign({}, DEFAULT_COPY, wx.getStorageSync('copy') || {})
+
+// 底部标签的文字也走配置(本页是 tabBar 的第 1 项:首页、开通、预览、我的)
+function applyTabText(text) {
+ if (!text || text === cachedCopy.tab) return
+ try {
+ wx.setTabBarItem({ index: 1, text })
+ } catch (err) {
+ console.warn('设置标签文字失败', err)
+ }
+}
+
+// 1234 分 -> 12.34
+function yuan(fen) {
+ return (fen / 100).toFixed(2).replace(/\.?0+$/, '')
+}
+
+// 价目表转成页面要的字段,顺便按身份过滤掉管理员专属道具
+function toView(list, isAdmin) {
+ const view = list
+ .filter((item) => isAdmin || !item.adminOnly)
+ .sort((a, b) => a.price - b.price) // 价格从低到高
+ .map((item) => ({
+ id: String(item.productId || item.id),
+ name: item.name,
+ price: item.price,
+ priceText: yuan(item.price),
+ note: item.note || '',
+ isBest: false,
+ }))
+ if (view.length) view[view.length - 1].isBest = true // 最贵那档标「推荐」
+ return view
+}
+
+// 官方示例同款版本比较
+function compareVersion(v1, v2) {
+ const a = String(v1).split('.')
+ const b = String(v2).split('.')
+ const len = Math.max(a.length, b.length)
+ while (a.length < len) a.push('0')
+ while (b.length < len) b.push('0')
+ for (let i = 0; i < len; i++) {
+ const n1 = parseInt(a[i], 10)
+ const n2 = parseInt(b[i], 10)
+ if (n1 > n2) return 1
+ if (n1 < n2) return -1
+ }
+ return 0
+}
+
+Page({
+ data: {
+ goods: toView(GOODS, cachedIsAdmin),
+ isAdmin: cachedIsAdmin, // 沙箱、补单道具、退款都是管理员功能,后端也会拦
+ copy: cachedCopy,
+ env: 0, // 0 现网(真机),1 沙箱(只在开发者工具里能拉起,且不扣钱)
+ buying: '',
+ result: null,
+ lastOrder: '',
+ needPay: false, // 上一笔还没付款成功(取消、失败),按钮就提示「重新支付」
+ retryProductId: '',
+ },
+
+ onEnv(e) {
+ this.setData({ env: Number(e.currentTarget.dataset.env), result: null, lastOrder: '' })
+ },
+
+ // 付过钱但没发货的单(关过小程序也算),重新进来还能补发货
+ async onShow() {
+ await this.loadMe()
+ if (this.data.lastOrder) return
+ try {
+ const code = await login()
+ const data = await post('/pay/pending', { code })
+ if (!data.outTradeNo) return
+ this.setData({
+ lastOrder: data.outTradeNo,
+ env: data.env === 1 ? 1 : 0,
+ result: { ok: true, title: '有一笔订单还没发货', detail: '刚才付过款的话,点「确认发货」把激活码补出来' },
+ })
+ } catch (err) {
+ console.warn('查询待发货订单失败', err.message)
+ }
+ },
+
+ // 后端带回「我是不是管理员」和该用户能看到的价格表(补单道具只有管理员能看到)
+ async loadMe() {
+ let me
+ try {
+ me = await post('/pay/me', { code: await login() })
+ } catch (err) {
+ // 拉不到就保持上次的身份和列表,别让管理员因为一次网络抖动丢了开关
+ console.warn('获取用户信息失败,沿用上次的身份:', err.message)
+ return
+ }
+ const isAdmin = Boolean(me.isAdmin)
+ wx.setStorageSync('isAdmin', isAdmin)
+ const list = me.goods && me.goods.length ? me.goods : GOODS
+ const fromServer = me.copy || {} // 服务端还没更新时这里会是空的,全部走默认值
+ const copy = {
+ tab: fromServer['tab.buy'] || DEFAULT_COPY.tab,
+ title: fromServer['buy.title'] || DEFAULT_COPY.title,
+ subtitle: fromServer['buy.subtitle'] || DEFAULT_COPY.subtitle,
+ button: fromServer['buy.button'] || DEFAULT_COPY.button,
+ tip: fromServer['buy.tip'] || DEFAULT_COPY.tip,
+ }
+ wx.setStorageSync('copy', copy)
+ applyTabText(copy.tab)
+ this.setData({ isAdmin, env: isAdmin ? this.data.env : 0, goods: toView(list, isAdmin), copy })
+ },
+
+ async onBuy(e) {
+ const productId = e.currentTarget.dataset.id
+ console.log('[购买] 点击', productId, 'env=', this.data.env, 'buying=', this.data.buying || '空')
+ if (this.data.buying) {
+ console.log('[购买] 上一次还在处理中,这次点击被忽略')
+ return
+ }
+ this.setData({ needPay: false, retryProductId: productId })
+ try {
+ // 检查也放进 try 里:万一环境判断本身抛错,也要在结果区看到原因
+ if (!this.checkEnv(productId)) {
+ console.log('[购买] 环境检查没通过')
+ return
+ }
+ this.setData({ buying: productId, result: null })
+ const code = await login()
+ console.log('[购买] 拿到登录 code,开始下单')
+ const order = await post('/pay/order', { productId, code, env: this.data.env })
+ if (!order.payData || !order.payData.signData) throw new Error('服务端没返回支付参数,请查看服务器日志')
+ console.log('[购买] 拿到支付参数,拉起支付')
+ this.setData({ lastOrder: order.outTradeNo })
+ await this.pay(order.payData)
+ this.setData({ result: { ok: true, title: '支付成功', detail: '正在发货…' } })
+ await this.onDeliver()
+ } catch (err) {
+ console.log('[购买] 中断:', err.message)
+ if (err.fromPay) {
+ // 支付没成功(取消/失败/风控):这次没扣钱,提示重新支付
+ this.setData({
+ needPay: true,
+ result: {
+ ok: false,
+ title: err.errCode === -2 ? '已取消支付' : '支付未完成',
+ detail: err.errCode === -2 ? '本次没有扣款,点下方可以重新支付' : err.message,
+ },
+ })
+ } else this.fail(err.message)
+ } finally {
+ this.setData({ buying: '' })
+ }
+ },
+
+ // 基础库和微信版本不满足时,拉起支付必失败,提前拦掉
+ checkEnv(productId) {
+ const sys = wx.getSystemInfoSync()
+ if (!(compareVersion(sys.SDKVersion, BASE_LIB_MIN_VERSION) >= 0 || wx.canIUse('requestVirtualPayment'))) {
+ return this.fail(`基础库版本过低(${sys.SDKVersion}),虚拟支付需要 ≥ ${BASE_LIB_MIN_VERSION}`)
+ }
+ if (sys.platform === 'ios' && compareVersion(sys.version, IOS_MIN_VERSION.join('.')) < 0) {
+ return this.fail('iOS 需微信 8.0.68 及以上,请先更新微信')
+ }
+ if (this.data.env === 1 && sys.platform === 'ios') {
+ return this.fail('沙箱不支持 iOS,苹果支付只有现网。请用开发者工具的模拟器,或切到安卓真机')
+ }
+ // 苹果支付最低 1 元,低于这个金额的单 iOS 上必然被拒(错误码 -15001)
+ if (sys.platform === 'ios') {
+ // 用页面当前展示的价格判断(改价从数据库来,可能和内置价目表不一致)
+ const goods = this.data.goods.find((item) => item.id === productId) || GOODS.find((item) => item.id === productId) || {}
+ if ((goods.price || 0) < 100) {
+ return this.fail(`iOS 最低支付 1 元,「${goods.name}」只有 ¥${yuan(goods.price || 0)},iPhone 上买不了`)
+ }
+ }
+ return true
+ },
+
+ pay(payData) {
+ return new Promise((resolve, reject) => {
+ wx.requestVirtualPayment(
+ Object.assign({}, payData, {
+ success: resolve,
+ fail: (err) => {
+ const error = new Error(`errCode ${err.errCode}:${PAY_ERR[err.errCode] || err.errMsg}`)
+ error.errCode = err.errCode
+ error.fromPay = true // 标记「卡在支付这一步」,用来决定按钮提示什么
+ reject(error)
+ },
+ })
+ )
+ })
+ },
+
+ // 重新支付:按上一笔的道具再走一遍下单+拉起
+ onRetryPay() {
+ if (!this.data.retryProductId) return
+ this.onBuy({ currentTarget: { dataset: { id: this.data.retryProductId } } })
+ },
+
+ // 支付回调可能丢,所以发货一律以服务端查单为准;重复点也只是把同一张码再给一遍
+ async onDeliver() {
+ const outTradeNo = this.data.lastOrder
+ if (!outTradeNo) return
+ wx.showLoading({ title: '发货中', mask: true })
+ try {
+ const code = await login()
+ this.showDeliver(await post('/pay/deliver', { outTradeNo, code }))
+ } catch (err) {
+ this.fail(err.message)
+ } finally {
+ wx.hideLoading()
+ }
+ },
+
+ showDeliver(data) {
+ if (!data.paid) {
+ return this.setData({
+ result: {
+ ok: false,
+ title: '订单状态:' + (ORDER_STATUS[data.status] || data.status),
+ detail: '还没查到支付成功,等半分钟再点「确认发货」',
+ },
+ })
+ }
+ this.setData({
+ result: {
+ ok: true,
+ title: '购买成功 · ' + (data.productName || ''),
+ code: data.code,
+ detail: data.notified
+ ? '激活码已生成,点一下即可复制'
+ : `平台没确认发货:${data.notifyError || '未知原因'}(激活码已生成,不影响使用;稍后再点一次「确认发货」重试)`,
+ },
+ })
+ },
+
+ copyCode() {
+ if (!this.data.result || !this.data.result.code) return
+ wx.setClipboardData({ data: this.data.result.code })
+ },
+
+ fail(message) {
+ this.setData({ buying: '', result: { ok: false, title: '失败', detail: message, code: '' } })
+ wx.showToast({ title: message, icon: 'none', duration: 3000 })
+ return false
+ },
+})
diff --git a/pages/buy/buy.json b/pages/buy/buy.json
new file mode 100644
index 0000000..74c3e00
--- /dev/null
+++ b/pages/buy/buy.json
@@ -0,0 +1,4 @@
+{
+ "navigationBarTitleText": "开通使用",
+ "usingComponents": {}
+}
diff --git a/pages/buy/buy.wxml b/pages/buy/buy.wxml
new file mode 100644
index 0000000..4d03225
--- /dev/null
+++ b/pages/buy/buy.wxml
@@ -0,0 +1,34 @@
+
+
+ {{copy.title}}
+ {{isAdmin ? (env === 1 ? '沙箱环境 · 不扣钱,只有开发者工具能拉起' : '现网环境 · 会真扣款(管理员可见)') : copy.subtitle}}
+
+ 现网
+ 沙箱
+
+
+
+
+
+ {{item.name}}推荐
+ {{item.note}}
+ 道具 ID {{item.id}}
+
+
+ ¥{{item.priceText}}
+ {{buying === item.id ? '进行中…' : copy.button}}
+
+
+
+
+ {{result.title}}
+ {{result.detail}}
+ {{result.code}}
+ 业务单号 {{lastOrder}}
+ 重新支付
+ {{isAdmin ? '确认发货' : '重新获取激活码'}}
+
+
+ {{copy.tip}}
+ 管理员备注:下单、查单、发货都走你自己的服务端(server/index.js),发货以查单结果为准;沙箱用自己的沙箱 AppKey 和订单池,和现网互不影响。
+
diff --git a/pages/buy/buy.wxss b/pages/buy/buy.wxss
new file mode 100644
index 0000000..ac70c3c
--- /dev/null
+++ b/pages/buy/buy.wxss
@@ -0,0 +1,182 @@
+.page {
+ min-height: 100vh;
+ padding: 32rpx 28rpx 48rpx;
+ background: var(--bg);
+}
+
+.head {
+ margin-bottom: 28rpx;
+}
+
+.head-title {
+ font-size: 40rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.head-sub {
+ margin-top: 8rpx;
+ font-size: 24rpx;
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.env-switch {
+ display: flex;
+ width: 320rpx;
+ margin-top: 22rpx;
+ padding: 6rpx;
+ border-radius: var(--r-pill);
+ background: var(--panel);
+}
+
+.env-chip {
+ flex: 1;
+ height: 58rpx;
+ line-height: 58rpx;
+ text-align: center;
+ border-radius: var(--r-pill);
+ font-size: 25rpx;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.env-chip.on {
+ background: var(--accent);
+ color: #ffffff;
+ font-weight: 600;
+}
+
+.card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 26rpx 28rpx;
+ margin-bottom: 20rpx;
+ border-radius: var(--r-lg);
+ background: var(--panel);
+}
+
+.card:active {
+ background: var(--panel-2);
+}
+
+.card-name {
+ font-size: 32rpx;
+ font-weight: 500;
+ color: #ffffff;
+}
+
+.card-badge {
+ margin-left: 14rpx;
+ padding: 2rpx 14rpx;
+ border-radius: var(--r-pill);
+ background: rgba(242, 53, 59, 0.18);
+ color: var(--accent-2);
+ font-size: 20rpx;
+ vertical-align: 4rpx;
+}
+
+.card-id {
+ margin-top: 8rpx;
+ font-size: 22rpx;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.card-note {
+ margin-top: 10rpx;
+ font-size: 23rpx;
+ line-height: 1.5;
+ color: #f0b45c;
+}
+
+.card-right {
+ display: flex;
+ align-items: center;
+}
+
+.card-price {
+ font-size: 32rpx;
+ font-weight: 600;
+ color: #ffffff;
+ margin-right: 22rpx;
+}
+
+.card-btn {
+ min-width: 132rpx;
+ height: 62rpx;
+ line-height: 62rpx;
+ text-align: center;
+ border-radius: var(--r-pill);
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
+ color: #ffffff;
+ font-size: 26rpx;
+}
+
+.card-btn.dim {
+ opacity: 0.6;
+}
+
+.result {
+ margin-top: 12rpx;
+ padding: 26rpx 28rpx;
+ border-radius: var(--r-lg);
+ background: var(--panel);
+ border-left: 6rpx solid var(--accent);
+}
+
+.result.bad {
+ border-left-color: #ff6b6b;
+}
+
+.result-title {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.result-detail {
+ margin-top: 10rpx;
+ font-size: 25rpx;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.result-order {
+ margin-top: 10rpx;
+ font-size: 23rpx;
+ color: rgba(255, 255, 255, 0.4);
+ word-break: break-all;
+}
+
+.result-code {
+ margin-top: 18rpx;
+ padding: 20rpx 0;
+ border-radius: var(--r-lg);
+ background: rgba(79, 193, 255, 0.12);
+ color: var(--cyan);
+ font-size: 38rpx;
+ font-weight: 600;
+ letter-spacing: 4rpx;
+ text-align: center;
+}
+
+.result-btn {
+ display: inline-block;
+ margin-top: 20rpx;
+ padding: 12rpx 30rpx;
+ border-radius: var(--r-pill);
+ background: rgba(79, 193, 255, 0.16);
+ color: var(--cyan);
+ font-size: 25rpx;
+}
+
+.tip {
+ margin-top: 26rpx;
+ font-size: 22rpx;
+ line-height: 1.7;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.tip.dev {
+ margin-top: 12rpx;
+ color: rgba(255, 255, 255, 0.22);
+}
diff --git a/pages/home/home.js b/pages/home/home.js
new file mode 100644
index 0000000..08f040a
--- /dev/null
+++ b/pages/home/home.js
@@ -0,0 +1,90 @@
+const SITE_URL = 'https://spineviewer.yeomai.site/'
+
+Page({
+ data: {
+ stats: [
+ { value: '9', label: '内置运行时版本' },
+ { value: '3.4–4.3', label: '覆盖的 Spine 版本' },
+ { value: '8', label: '界面语言 · 简繁英日韩俄西德' },
+ { value: '400px', label: '缩略图自动生成' },
+ ],
+ fileChips: ['skel', 'skel.bytes', 'json', 'atlas', 'atlas.txt'],
+ features: [
+ {
+ title: '本地运行时',
+ desc: '官方运行时全部放在插件本地 lib/ 目录,不依赖 CDN 下载。激活校验需要联网,其余预览过程都在本地完成。',
+ },
+ {
+ title: '跨文件夹自动配对',
+ desc: 'Eagle 把每个文件拆成独立 item 文件夹,插件扫描同级目录自动关联骨骼、atlas 与贴图,忽略 -pro / -ess / -pma 等后缀。',
+ },
+ {
+ title: '9 个版本自动识别',
+ desc: '从骨骼数据读取版本号并匹配运行时:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3,带 -beta 的完整版本号也会显示在水印里。',
+ },
+ {
+ title: '画面与皮肤切换',
+ desc: '浮动播放列表按需自适应、条目多时独立滚动;切换皮肤后按 setup pose 重建槽位附件,画面立即刷新。',
+ },
+ {
+ title: '完整时间轴',
+ desc: '拖动定位、显示时长与帧号、秒刻度,蓝色短线标出骨骼 / 插槽 / 约束的关键帧位置,悬停即可查看。',
+ },
+ {
+ title: '缩略图自动化',
+ desc: '导入即渲染首帧缩略图(400×400),右下角带 Spine 官方 badge、左下角标注版本号;资源暂时缺失时先给回退图,补齐后自动重渲。',
+ },
+ ],
+ versions: [
+ { ver: '3.4 / 3.5', desc: '使用官方 3.4.02、3.5.51 运行时与匹配的二进制分支,已验证多组官方示例的姿势对比。' },
+ { ver: '3.6 / 3.7', desc: '额外带二进制解析兼容层(移植自官方同版本 SkeletonBinary),支持 3.7 的 IK 压缩 / 拉伸与音频事件。' },
+ { ver: '3.8 – 4.3', desc: '二进制文件完整支持;4.3 等带 -beta 后缀的完整版本号也会显示在缩略图水印中。' },
+ ],
+ steps: [
+ '导入整套文件:把 .skel / .json + .atlas + .png 放在同一个文件夹导入 Eagle',
+ '自动配对资源:点击任一文件,插件从同级 item 文件夹定位骨骼、atlas 与贴图',
+ '识别版本:读取骨骼数据里的版本号,加载匹配的本地运行时',
+ '预览与缩略图:实时播放画面,缩略图自动生成,资源补齐后自动重渲并刷新列表',
+ ],
+ install: [
+ '在 Eagle 插件社区获取 SpineViewer 插件包,插件页提供下载与安装说明',
+ '双击 .eagleplugin 安装;或在 Eagle 插件面板(快捷键 P)右键选择「打包 / 加载插件」',
+ '把同一套 Spine 导出文件放进同一个文件夹导入 Eagle,点击任意文件即可预览',
+ ],
+ faqs: [
+ { q: '插件会接管普通图片吗?', a: '不会。只接管 skel、skel.bytes、json、atlas、atlas.txt,普通图片仍由 Eagle 原生预览。' },
+ {
+ q: '预乘(PMA)设置保存在哪里?',
+ a: '存在 Eagle 用户数据目录的 SpineViewer/preview-settings/ 下,按素材库与素材 ID 区分;不会修改 metadata.json、标签或注释。',
+ },
+ {
+ q: '3.6 / 3.7 的二进制解析可靠吗?',
+ a: '解析层移植自官方同版本,已验证多套官方示例;文件损坏、截断或版本不匹配会直接报告解析错误,不会用错误姿势渲染。',
+ },
+ {
+ q: '缩略图是怎么生成的?',
+ a: '在 Node 里用 @napi-rs/canvas 跨 item 文件夹读取资源渲染首帧,尺寸 400×400,右下角为 Spine 官方 badge,左下角是完整版本号。移除这个依赖只影响缩略图,画面预览不受影响。',
+ },
+ {
+ q: '试用版和插件是什么关系?',
+ a: '试用版只支持 Spine 4.2 导出的文件,用来快速看一眼效果;3.4–4.3 全版本识别、皮肤切换、时间轴、缩略图这些完整能力都在桌面插件里。',
+ },
+ ],
+ },
+
+ goPreview() {
+ wx.switchTab({ url: '/pages/viewer/viewer' })
+ },
+
+ goBuy() {
+ wx.switchTab({ url: '/pages/buy/buy' })
+ },
+
+ // 个人主体小程序打不开外部网页,只能把网址给用户自己复制
+ copySite() {
+ wx.setClipboardData({
+ data: SITE_URL,
+ success: () => wx.showToast({ title: '网址已复制', icon: 'none' }),
+ })
+ },
+})
diff --git a/pages/home/home.json b/pages/home/home.json
new file mode 100644
index 0000000..524e027
--- /dev/null
+++ b/pages/home/home.json
@@ -0,0 +1,4 @@
+{
+ "navigationBarTitleText": "SpineViewer",
+ "usingComponents": {}
+}
diff --git a/pages/home/home.wxml b/pages/home/home.wxml
new file mode 100644
index 0000000..395677a
--- /dev/null
+++ b/pages/home/home.wxml
@@ -0,0 +1,95 @@
+
+
+
+
+
+ 美术师的省心小工具
+ 你的 Spine 数据资产 可以进 Eagle 啦
+
+ SpineViewer 接管 skel / skel.bytes / json / atlas / atlas.txt,自动在素材库中配对骨骼、图集与贴图,识别版本并加载本地运行时。点开任意一个文件,就能实时播放、切换皮肤、导出缩略图。
+
+
+
+ 查看套餐
+ 试用版体验
+
+
+
+
+ {{item.value}}
+ {{item.label}}
+
+
+
+
+ {{item}}
+
+ png / jpg / webp 仍由 Eagle 原生预览
+
+
+
+
+ 这里是试用版
+ 试用版只支持 Spine 4.2 导出的文件;桌面插件的完整能力包括 3.4–4.3 全版本识别、皮肤切换、时间轴、缩略图生成。
+ 去试用版看看 →
+
+
+
+
+ FEATURES
+ 功能特性
+ 下面每一项都对应插件的真实行为:跨 item 文件夹配对、旧版二进制解析、WebGL 渲染、缩略图补刷……不只是「能打开文件」。
+
+ {{item.title}}
+ {{item.desc}}
+
+
+
+
+
+ VERSIONS
+ 支持版本
+ 覆盖 Spine 3.4 – 4.3:新版二进制直接支持,旧版二进制由同版本运行时与解析层处理。
+
+
+ {{item.ver}}
+ {{item.desc}}
+
+
+
+
+
+
+ WORKFLOW
+ 从导入到播放
+
+ {{index + 1}}
+ {{item}}
+
+
+
+
+
+ INSTALL
+ 安装使用
+ 在 Eagle 插件社区获取插件包,然后在 Eagle 插件面板(快捷键 P)安装即可。
+
+ {{index + 1}}
+ {{item}}
+
+ 复制插件页网址
+ 这里打不开外部网页,复制后在浏览器里打开就行。
+
+
+
+
+ FAQ
+ 常见问题
+
+ {{item.q}}
+ {{item.a}}
+
+
+
+
+
diff --git a/pages/home/home.wxss b/pages/home/home.wxss
new file mode 100644
index 0000000..e39d925
--- /dev/null
+++ b/pages/home/home.wxss
@@ -0,0 +1,313 @@
+.page {
+ position: relative;
+ min-height: 100vh;
+ padding: 0 28rpx 70rpx;
+ background: var(--bg);
+ overflow: hidden;
+}
+
+/* 顶部两团辉光,对应官网的红 / 青径向光 */
+.glow {
+ position: absolute;
+ left: -30%;
+ top: -200rpx;
+ width: 160%;
+ height: 1000rpx;
+ pointer-events: none;
+ background: radial-gradient(560rpx 340rpx at 30% 38%, rgba(242, 53, 59, 0.22), transparent 66%),
+ radial-gradient(520rpx 320rpx at 72% 26%, rgba(79, 193, 255, 0.16), transparent 68%);
+}
+
+/* ===== 顶部 ===== */
+.hero {
+ position: relative;
+ padding: 56rpx 4rpx 0;
+}
+
+.hero-title {
+ margin-top: 0;
+ font-size: 54rpx;
+ line-height: 1.24;
+ font-weight: 800;
+ color: var(--text);
+}
+
+.hero-title-sub {
+ margin-top: 16rpx;
+ font-size: 38rpx;
+ line-height: 1.4;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.hero-accent {
+ color: var(--accent-2);
+}
+
+.hero-sub {
+ margin-top: 22rpx;
+ font-size: 25rpx;
+ line-height: 1.8;
+ color: var(--text-dim);
+}
+
+.hero-cta {
+ display: flex;
+ margin-top: 40rpx;
+}
+
+.btn {
+ flex: 1;
+ height: 92rpx;
+ line-height: 92rpx;
+ text-align: center;
+ border-radius: var(--r-lg);
+ font-size: 29rpx;
+ font-weight: 600;
+}
+
+.btn.primary {
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
+ color: #ffffff;
+ box-shadow: 0 12rpx 26rpx rgba(242, 53, 59, 0.26);
+}
+
+.btn.ghost {
+ margin-left: 20rpx;
+ border: 2rpx solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+}
+
+/* 单独一行的宽按钮 */
+.btn.ghost.wide {
+ display: block;
+ flex: none;
+ width: 100%;
+ margin: 30rpx 0 0;
+}
+
+.stats {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ margin-top: 36rpx;
+}
+
+.stat {
+ width: 48%;
+ margin-bottom: 16rpx;
+ padding: 22rpx 24rpx;
+ border: 2rpx solid var(--border);
+ border-radius: var(--r-lg);
+ background: rgba(28, 28, 34, 0.74);
+}
+
+.stat-value {
+ font-size: 40rpx;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.stat-label {
+ margin-top: 6rpx;
+ font-size: 21rpx;
+ line-height: 1.5;
+ color: var(--text-dim);
+}
+
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 30rpx;
+}
+
+.chip {
+ margin: 0 14rpx 14rpx 0;
+ padding: 10rpx 22rpx;
+ border-radius: var(--r-pill);
+ border: 2rpx solid var(--border);
+ background: var(--panel);
+ color: var(--text-dim);
+ font-size: 21rpx;
+}
+
+.chip.on {
+ color: var(--cyan);
+ border-color: rgba(79, 193, 255, 0.4);
+ background: rgba(79, 193, 255, 0.08);
+}
+
+.note {
+ margin-top: 4rpx;
+ font-size: 22rpx;
+ line-height: 1.7;
+ color: var(--text-dim);
+}
+
+/* ===== 小程序定位 ===== */
+.notice {
+ margin-top: 46rpx;
+ padding: 30rpx 32rpx;
+ border: 2rpx solid var(--border);
+ border-radius: var(--r-lg);
+ background: var(--panel);
+}
+
+.notice-title {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: var(--accent-2);
+}
+
+.notice-desc {
+ margin-top: 12rpx;
+ font-size: 24rpx;
+ line-height: 1.75;
+ color: var(--text-dim);
+}
+
+.notice-link {
+ margin-top: 18rpx;
+ font-size: 24rpx;
+ color: var(--cyan);
+}
+
+/* ===== 通用分节 ===== */
+.section {
+ position: relative;
+ margin-top: 76rpx;
+}
+
+.section.alt {
+ margin: 76rpx -28rpx 0;
+ padding: 56rpx 28rpx;
+ background: var(--bg-2);
+ border-top: 2rpx solid var(--border);
+ border-bottom: 2rpx solid var(--border);
+}
+
+.kicker {
+ font-size: 20rpx;
+ font-weight: 700;
+ letter-spacing: 4rpx;
+ color: var(--accent);
+}
+
+.h2 {
+ margin-top: 14rpx;
+ font-size: 40rpx;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.lead {
+ margin: 16rpx 0 30rpx;
+ font-size: 24rpx;
+ line-height: 1.75;
+ color: var(--text-dim);
+}
+
+.card {
+ padding: 26rpx 30rpx;
+ margin-bottom: 18rpx;
+ border: 2rpx solid var(--border);
+ border-radius: var(--r-lg);
+ background: var(--panel);
+}
+
+.card-title {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.card-desc {
+ margin-top: 12rpx;
+ font-size: 24rpx;
+ line-height: 1.7;
+ color: var(--text-dim);
+}
+
+/* ===== 版本表 ===== */
+.rows {
+ border: 2rpx solid var(--border);
+ border-radius: var(--r-lg);
+ background: var(--panel);
+ overflow: hidden;
+}
+
+.row {
+ padding: 26rpx 30rpx;
+ border-bottom: 2rpx solid var(--border);
+}
+
+.row:last-child {
+ border-bottom: none;
+}
+
+.row-ver {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: var(--cyan);
+}
+
+.row-desc {
+ margin-top: 10rpx;
+ font-size: 23rpx;
+ line-height: 1.7;
+ color: var(--text-dim);
+}
+
+/* ===== 步骤 ===== */
+.step {
+ display: flex;
+ align-items: flex-start;
+ margin-bottom: 24rpx;
+}
+
+.step-index {
+ flex-shrink: 0;
+ width: 48rpx;
+ height: 48rpx;
+ line-height: 48rpx;
+ text-align: center;
+ border-radius: var(--r-pill);
+ border: 2rpx solid rgba(242, 53, 59, 0.35);
+ background: rgba(242, 53, 59, 0.12);
+ color: var(--accent-2);
+ font-size: 23rpx;
+}
+
+.step-text {
+ flex: 1;
+ margin-left: 20rpx;
+ padding-top: 6rpx;
+ font-size: 25rpx;
+ line-height: 1.7;
+ color: var(--text);
+}
+
+/* ===== 常见问题 ===== */
+.faq {
+ margin-bottom: 26rpx;
+}
+
+.faq-q {
+ font-size: 27rpx;
+ color: var(--text);
+}
+
+.faq-a {
+ margin-top: 10rpx;
+ font-size: 24rpx;
+ line-height: 1.75;
+ color: var(--text-dim);
+}
+
+.foot {
+ margin-top: 60rpx;
+ font-size: 22rpx;
+ line-height: 1.7;
+ color: rgba(157, 157, 168, 0.6);
+}
diff --git a/pages/mine/mine.js b/pages/mine/mine.js
new file mode 100644
index 0000000..c4d06d8
--- /dev/null
+++ b/pages/mine/mine.js
@@ -0,0 +1,79 @@
+const { post, login } = require('../../utils/pay-api.js')
+
+// 同购买页:用上次的身份当初始值,退款按钮不会先冒出来再消失
+const cachedIsAdmin = Boolean(wx.getStorageSync('isAdmin'))
+
+function formatTime(timestamp) {
+ const d = new Date(timestamp)
+ const pad = (n) => (n < 10 ? '0' + n : '' + n)
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+Page({
+ data: {
+ loading: false,
+ list: [],
+ error: '',
+ isIos: false,
+ isAdmin: cachedIsAdmin, // 退款目前是管理员功能
+ },
+
+ // 每次切到这一页都刷新一次,买完回来看得到
+ onShow() {
+ // iPhone 上买的订单只能用户自己去 App Store 申请退款,按钮就不显示了
+ this.setData({ isIos: wx.getSystemInfoSync().platform === 'ios' })
+ this.load()
+ },
+
+ async load() {
+ this.setData({ loading: true, error: '' })
+ try {
+ const code = await login()
+ const data = await post('/pay/my', { code })
+ wx.setStorageSync('isAdmin', Boolean(data.isAdmin))
+ this.setData({
+ isAdmin: Boolean(data.isAdmin),
+ list: (data.list || []).map((item) => Object.assign({}, item, { timeText: formatTime(item.deliveredAt) })),
+ })
+ } catch (err) {
+ this.setData({ error: err.message })
+ } finally {
+ this.setData({ loading: false })
+ }
+ },
+
+ copyCode(e) {
+ wx.setClipboardData({ data: e.currentTarget.dataset.code })
+ },
+
+ onRefund(e) {
+ const outTradeNo = e.currentTarget.dataset.order
+ wx.showModal({
+ title: '申请退款',
+ content: '退款后这张激活码会作废,确定要退吗?',
+ confirmText: '退款',
+ cancelText: '再想想',
+ success: (res) => {
+ if (res.confirm) this.doRefund(outTradeNo)
+ },
+ })
+ },
+
+ async doRefund(outTradeNo) {
+ wx.showLoading({ title: '退款中', mask: true })
+ try {
+ const code = await login()
+ await post('/pay/refund', { outTradeNo, code })
+ wx.showToast({ title: '退款已发起', icon: 'none' })
+ await this.load()
+ } catch (err) {
+ wx.showToast({ title: err.message, icon: 'none', duration: 3000 })
+ } finally {
+ wx.hideLoading()
+ }
+ },
+
+ onPullDownRefresh() {
+ this.load().then(() => wx.stopPullDownRefresh())
+ },
+})
diff --git a/pages/mine/mine.json b/pages/mine/mine.json
new file mode 100644
index 0000000..7fa22eb
--- /dev/null
+++ b/pages/mine/mine.json
@@ -0,0 +1,5 @@
+{
+ "navigationBarTitleText": "我的",
+ "enablePullDownRefresh": true,
+ "usingComponents": {}
+}
diff --git a/pages/mine/mine.wxml b/pages/mine/mine.wxml
new file mode 100644
index 0000000..1fbb756
--- /dev/null
+++ b/pages/mine/mine.wxml
@@ -0,0 +1,31 @@
+
+
+ 我的激活码
+ 点一下激活码即可复制
+
+
+ {{error}}
+ 加载中…
+
+
+ {{item.productName}}沙箱测试
+ {{item.code}}
+
+ {{item.timeText}}
+ 申请退款
+
+
+
+ iPhone 上买的订单开发者退不了,需要退款请到 App Store → 购买记录里申请。
+
+
+ 🎫
+ 还没有激活码
+ 去「购买测试」买一个,发货后这里就会出现
+
+
diff --git a/pages/mine/mine.wxss b/pages/mine/mine.wxss
new file mode 100644
index 0000000..e3c5ca8
--- /dev/null
+++ b/pages/mine/mine.wxss
@@ -0,0 +1,102 @@
+.page {
+ min-height: 100vh;
+ padding: 32rpx 28rpx 48rpx;
+ background: var(--bg);
+}
+
+.head {
+ margin-bottom: 28rpx;
+}
+
+.head-title {
+ font-size: 40rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.head-sub {
+ margin-top: 8rpx;
+ font-size: 24rpx;
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.banner {
+ padding: 22rpx 26rpx;
+ margin-bottom: 20rpx;
+ border-radius: var(--r-lg);
+ background: var(--panel);
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 25rpx;
+ line-height: 1.6;
+}
+
+.card {
+ padding: 26rpx 28rpx;
+ margin-bottom: 20rpx;
+ border-radius: var(--r-lg);
+ background: var(--panel);
+}
+
+.card:active {
+ background: var(--panel-2);
+}
+
+.card-name {
+ font-size: 26rpx;
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.card-tag {
+ margin-left: 14rpx;
+ padding: 2rpx 14rpx;
+ border-radius: var(--r-pill);
+ background: rgba(245, 158, 11, 0.16);
+ color: #f0b45c;
+ font-size: 20rpx;
+}
+
+.card-code {
+ margin-top: 14rpx;
+ font-size: 38rpx;
+ font-weight: 600;
+ letter-spacing: 4rpx;
+ color: var(--cyan);
+ word-break: break-all;
+}
+
+.card-time {
+ font-size: 22rpx;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.card-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 12rpx;
+}
+
+.card-refund {
+ padding: 6rpx 22rpx;
+ border-radius: var(--r-pill);
+ border: 2rpx solid rgba(255, 255, 255, 0.14);
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 22rpx;
+}
+
+.card-refund:active {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.tip {
+ margin-top: 10rpx;
+ font-size: 22rpx;
+ line-height: 1.7;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+/* 空态跟着标题走,不用撑满整屏 */
+.placeholder {
+ height: auto;
+ padding: 140rpx 40rpx 0;
+}
diff --git a/pages/viewer/viewer.js b/pages/viewer/viewer.js
index 3efe247..2d7555f 100644
--- a/pages/viewer/viewer.js
+++ b/pages/viewer/viewer.js
@@ -349,7 +349,7 @@ Page({
const animations = skeletonData.animations.map((a) => a.name)
if (!animations.length) {
- this.setData({ status: 'error', errorMsg: '该骨骼文件里没有任何动画' })
+ this.setData({ status: 'error', errorMsg: '该骨骼文件里没有可播放的内容' })
return
}
@@ -390,7 +390,7 @@ Page({
skeletonData.slots.length +
'插槽 · ' +
animations.length +
- '动画 · ' +
+ '段画面 · ' +
atlas.pages.length +
'图集页 · 文件声明PMA ' +
(this._pma ? '是' : '否'),
diff --git a/pages/viewer/viewer.wxml b/pages/viewer/viewer.wxml
index 979663b..4a2a885 100644
--- a/pages/viewer/viewer.wxml
+++ b/pages/viewer/viewer.wxml
@@ -15,9 +15,9 @@
🎬
- Spine 动画预览
- 支持 Spine 4.2 导出的 .skel / .atlas / .png
- 把三个文件发到「文件传输助手」后在下方选择;真机可一次多选,PC 可分多次选
+ Spine 画面预览
+ 小程序版本可支持 Spine 4.2 导出的 .skel / .atlas / .png
+ 先把 .skel / .atlas / 图片发到「文件传输助手」,再在下方一次选中即可;文件只在本地解析,不会上传
@@ -39,17 +39,12 @@
-
-
- 文件
-
+ 可一次选中多个文件 · .skel / .atlas / .png
@@ -58,7 +53,7 @@
- ● {{missingText}}
+ {{missingText}}
清空已选
@@ -90,7 +85,7 @@
{{speed}}x
diff --git a/pages/viewer/viewer.wxss b/pages/viewer/viewer.wxss
index 3f4ad56..704ccd4 100644
--- a/pages/viewer/viewer.wxss
+++ b/pages/viewer/viewer.wxss
@@ -3,7 +3,7 @@
display: flex;
flex-direction: column;
height: 100vh;
- background: #16181d;
+ background: var(--bg);
}
/* ===== 预览区(深色)===== */
@@ -11,7 +11,7 @@
position: relative;
flex: 1;
min-height: 0;
- background: linear-gradient(180deg, #21252d 0%, #14161b 100%);
+ background: linear-gradient(180deg, var(--panel) 0%, #14161b 100%);
}
.stage-canvas {
@@ -70,26 +70,18 @@
left: 22rpx;
top: 22rpx;
padding: 8rpx 18rpx;
- border-radius: 999rpx;
+ border-radius: var(--r-pill);
background: rgba(0, 0, 0, 0.34);
color: rgba(255, 255, 255, 0.72);
font-size: 22rpx;
}
-/* ===== 控制面板(白色圆角卡片)===== */
+/* ===== 控制面板(深色,和预览区统一)===== */
.sheet {
- background: #ffffff;
- border-radius: 32rpx 32rpx 0 0;
- padding: 0 28rpx 28rpx;
- box-shadow: 0 -10rpx 36rpx rgba(0, 0, 0, 0.3);
-}
-
-.grabber {
- width: 76rpx;
- height: 8rpx;
- border-radius: 4rpx;
- background: #e3e6ee;
- margin: 14rpx auto 18rpx;
+ background: var(--bg-2);
+ border-radius: var(--r-lg) var(--r-lg) 0 0;
+ padding: 30rpx 28rpx 28rpx;
+ box-shadow: 0 -10rpx 36rpx rgba(0, 0, 0, 0.45);
}
.section {
@@ -101,10 +93,10 @@
}
.section-title {
- font-size: 23rpx;
- color: #9aa1b1;
+ font-size: 26rpx;
+ color: rgba(255, 255, 255, 0.92);
font-weight: 600;
- letter-spacing: 2rpx;
+ letter-spacing: 1rpx;
margin-bottom: 14rpx;
}
@@ -114,35 +106,29 @@
align-items: center;
justify-content: center;
width: 100%;
- height: 94rpx;
+ height: 100rpx;
padding: 0;
- border-radius: 16rpx;
- background: linear-gradient(135deg, #4a6cf7 0%, #6d8bff 100%);
+ border-radius: var(--r-lg);
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #ffffff;
- font-size: 30rpx;
font-weight: 500;
- box-shadow: 0 8rpx 20rpx rgba(74, 108, 247, 0.3);
+ box-shadow: 0 10rpx 24rpx rgba(242, 53, 59, 0.28);
}
.pick-btn::after {
border: none;
}
-.pick-icon {
- font-size: 32rpx;
- margin-right: 12rpx;
-}
-
.pick-text {
- font-size: 30rpx;
+ font-size: 31rpx;
+ letter-spacing: 2rpx;
}
-.pick-hint {
- margin-left: 14rpx;
- padding: 4rpx 14rpx;
- border-radius: 999rpx;
- background: rgba(255, 255, 255, 0.22);
- font-size: 21rpx;
+.pick-sub {
+ margin-top: 16rpx;
+ text-align: center;
+ font-size: 22rpx;
+ color: rgba(255, 255, 255, 0.38);
}
/* ===== 文件类型标签 ===== */
@@ -157,26 +143,26 @@
align-items: center;
margin: 0 12rpx 12rpx 0;
padding: 8rpx 18rpx;
- border-radius: 999rpx;
- background: #f3f5f9;
+ border-radius: var(--r-pill);
+ background: var(--panel-2);
}
.chip-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
- background: #94a3b8;
+ background: var(--text-dim);
margin-right: 10rpx;
flex-shrink: 0;
}
.chip-name {
font-size: 23rpx;
- color: #4a5264;
+ color: rgba(255, 255, 255, 0.72);
}
.chip-skel .chip-dot {
- background: #4a6cf7;
+ background: var(--accent);
}
.chip-atlas .chip-dot {
@@ -192,24 +178,27 @@
}
.missing {
- margin-top: 2rpx;
- font-size: 24rpx;
- color: #f59e0b;
+ margin-top: 18rpx;
+ padding: 14rpx 20rpx;
+ border-radius: var(--r-lg);
+ background: rgba(245, 158, 11, 0.1);
+ font-size: 23rpx;
+ color: #f0b45c;
}
.link {
margin-top: 14rpx;
font-size: 24rpx;
- color: #9aa1b1;
+ color: rgba(255, 255, 255, 0.45);
}
.link.inline {
margin-top: 0;
margin-left: 18rpx;
- color: #4a6cf7;
+ color: var(--cyan);
}
-/* ===== 动画选择 ===== */
+/* ===== 播放选择 ===== */
.select {
display: flex;
align-items: center;
@@ -217,27 +206,27 @@
height: 86rpx;
padding: 0 26rpx;
margin-bottom: 14rpx;
- background: #f5f6fa;
- border-radius: 14rpx;
+ background: var(--panel-2);
+ border-radius: var(--r-lg);
}
.select-label {
font-size: 29rpx;
font-weight: 500;
- color: #1f2430;
+ color: #ffffff;
}
.select-arrow {
font-size: 26rpx;
- color: #9aa1b1;
+ color: rgba(255, 255, 255, 0.4);
}
/* ===== 分段控件 ===== */
.segment {
display: flex;
padding: 6rpx;
- background: #f2f4f8;
- border-radius: 14rpx;
+ background: var(--panel-2);
+ border-radius: var(--r-lg);
}
.seg {
@@ -246,15 +235,15 @@
line-height: 68rpx;
text-align: center;
font-size: 25rpx;
- color: #7b8290;
- border-radius: 10rpx;
+ color: rgba(255, 255, 255, 0.55);
+ border-radius: var(--r-md);
}
.seg.on {
- background: #ffffff;
- color: #4a6cf7;
+ background: #3d262e;
+ color: var(--cyan);
font-weight: 600;
- box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
+ box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.25);
}
/* ===== 参数行 ===== */
@@ -268,7 +257,7 @@
width: 76rpx;
flex-shrink: 0;
font-size: 26rpx;
- color: #6b7280;
+ color: rgba(255, 255, 255, 0.55);
}
.param-slider {
@@ -282,7 +271,7 @@
text-align: right;
font-size: 25rpx;
font-weight: 600;
- color: #4a6cf7;
+ color: var(--cyan);
}
/* ===== 缩放步进器 ===== */
@@ -290,8 +279,8 @@
display: flex;
align-items: center;
margin-left: 16rpx;
- background: #f2f4f8;
- border-radius: 12rpx;
+ background: var(--panel-2);
+ border-radius: var(--r-lg);
overflow: hidden;
}
@@ -301,11 +290,11 @@
line-height: 64rpx;
text-align: center;
font-size: 32rpx;
- color: #4a5264;
+ color: rgba(255, 255, 255, 0.75);
}
.step-btn:active {
- background: #e6e9f2;
+ background: var(--panel-2);
}
.step-val {
@@ -313,12 +302,12 @@
text-align: center;
font-size: 25rpx;
font-weight: 600;
- color: #1f2430;
+ color: #ffffff;
}
.gesture-hint {
margin-top: 4rpx;
text-align: center;
font-size: 22rpx;
- color: #b6bcc9;
+ color: rgba(255, 255, 255, 0.3);
}
diff --git a/project.config.json b/project.config.json
index 5b4199b..33c3182 100644
--- a/project.config.json
+++ b/project.config.json
@@ -29,9 +29,18 @@
"libVersion": "3.17.0",
"packOptions": {
"ignore": [
- { "type": "folder", "value": "node_modules" },
- { "type": "folder", "value": "tools" },
- { "type": "folder", "value": ".workbuddy" }
+ {
+ "value": "node_modules",
+ "type": "folder"
+ },
+ {
+ "value": "tools",
+ "type": "folder"
+ },
+ {
+ "value": ".workbuddy",
+ "type": "folder"
+ }
],
"include": []
},
@@ -41,4 +50,4 @@
"tabSize": 2
},
"simulatorPluginLibVersion": {}
-}
\ No newline at end of file
+}
diff --git a/project.private.config.json b/project.private.config.json
index 77830a3..9fe38c3 100644
--- a/project.private.config.json
+++ b/project.private.config.json
@@ -2,7 +2,7 @@
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "spine-viewer",
"setting": {
- "compileHotReLoad": true,
+ "compileHotReLoad": false,
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
diff --git a/server/code.js b/server/code.js
new file mode 100644
index 0000000..0394c3a
--- /dev/null
+++ b/server/code.js
@@ -0,0 +1,12 @@
+// 激活码生成:16 位、4 组、80 位随机
+// 字符表去掉了容易看错的 I/L/O/U,正好 32 个,刚好整除 256,取模不偏
+const crypto = require('crypto')
+
+const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
+
+function newActivationCode() {
+ const raw = Array.from(crypto.randomBytes(16), (b) => ALPHABET[b % ALPHABET.length]).join('')
+ return (raw.match(/.{4}/g) || []).join('-')
+}
+
+module.exports = { newActivationCode, ALPHABET }
diff --git a/server/config.example.js b/server/config.example.js
new file mode 100644
index 0000000..3ad8a5a
--- /dev/null
+++ b/server/config.example.js
@@ -0,0 +1,19 @@
+// 复制本文件为同目录下的 config.js 再填值(config.js 不入库)
+// 也可以用环境变量 PAY_APP_ID / PAY_APP_SECRET / PAY_OFFER_ID / PAY_APP_KEY 覆盖
+module.exports = {
+ appId: 'wxb2de4030b2cff601', // MP 后台 → 设置 → 基本设置
+ appSecret: '', // MP 后台 → 设置 → 开发设置 → 小程序密钥(code2Session 要用)
+ offerId: '1450663371', // MP 后台 → 虚拟支付 → 基本配置
+ appKey: '', // MP 后台 → 虚拟支付 → 基本配置 → 现网 AppKey
+ sandboxAppKey: '', // 同上「沙箱AppKey」,只在开发者工具里用沙箱(env=1)时需要
+ pushToken: '', // MP 后台 → 开发管理 → 消息推送 → Token(要和后台填的一模一样)
+
+ // 配了 db 就走 MySQL(宝塔 → 数据库里建库和账号),不配就继续用 data/orders.jsonl
+ db: {
+ host: '127.0.0.1',
+ port: 3306,
+ user: '',
+ password: '',
+ database: '',
+ },
+}
diff --git a/server/copy.js b/server/copy.js
new file mode 100644
index 0000000..94ef78a
--- /dev/null
+++ b/server/copy.js
@@ -0,0 +1,14 @@
+// 页面文案的默认值。数据库 settings 表里的同名配置会覆盖它,所以改文案不用发版
+// 备注会写进库里,方便在 phpMyAdmin 里看清每个配置显示在哪
+const COPY = {
+ 'tab.buy': { value: '开通', comment: '底部「开通」标签的文字' },
+ 'buy.title': { value: '选择套餐', comment: '开通页的大标题' },
+ 'buy.subtitle': { value: '一次付费,激活码自动发放到「我的」', comment: '开通页副标题(普通用户看到的)' },
+ 'buy.button': { value: '购买', comment: '套餐卡片上的按钮文字' },
+ 'buy.tip': {
+ value: '购买成功后激活码会自动发放,在「我的」里可以随时查看和复制。同一笔订单重复点只会拿到同一张码,不用担心重复扣款。',
+ comment: '开通页底部说明',
+ },
+}
+
+module.exports = { COPY }
diff --git a/server/db.js b/server/db.js
new file mode 100644
index 0000000..f542dfb
--- /dev/null
+++ b/server/db.js
@@ -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 }
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..36e3ab0
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,582 @@
+// 虚拟支付下单服务:给小程序返回签名后的支付参数,并提供查单
+// 启动:node server/index.js(默认端口 3000,用 PORT 覆盖)
+const http = require('http')
+const https = require('https')
+const crypto = require('crypto')
+const { GOODS, newOutTradeNo, buildSignData, paySig, userSignature } = require('./sign.js')
+const store = require('./store.js')
+const { COPY } = require('./copy.js')
+
+// 密钥优先读环境变量,其次读同目录 config.js(config.js 不入库)
+const fileConfig = require('./read-config.js')
+
+const config = {
+ appId: process.env.PAY_APP_ID || fileConfig.appId,
+ appSecret: process.env.PAY_APP_SECRET || fileConfig.appSecret,
+ offerId: process.env.PAY_OFFER_ID || fileConfig.offerId,
+ appKey: process.env.PAY_APP_KEY || fileConfig.appKey,
+ sandboxAppKey: process.env.PAY_SANDBOX_APP_KEY || fileConfig.sandboxAppKey,
+ pushToken: process.env.PAY_PUSH_TOKEN || fileConfig.pushToken,
+}
+
+const WX_ORIGIN = process.env.WX_API_ORIGIN || 'https://api.weixin.qq.com'
+const XPAY_QUERY_URI = '/xpay/query_order'
+const XPAY_NOTIFY_URI = '/xpay/notify_provide_goods'
+const XPAY_REFUND_URI = '/xpay/refund_order'
+const PORT = Number(process.env.PORT ?? 3000)
+const MAX_BODY = 4096
+// 定时查单兜底的间隔,设 0 关掉;回看窗口限制一下,免得半路放弃的单被无限查下去
+const POLL_MS = Number(process.env.PAY_POLL_MS ?? 5 * 60 * 1000)
+const PENDING_WINDOW_MS = 24 * 60 * 60 * 1000
+
+// 价目表和页面文案都以数据库为准(表里没写就退回代码里的默认值),缓存 60 秒
+let configCache = { at: 0, goods: [], copy: {} }
+
+async function refreshConfig() {
+ const fromDb = await store.listGoods()
+ const list = fromDb.length
+ ? fromDb
+ : Object.keys(GOODS).map((id) => ({
+ productId: id,
+ name: GOODS[id].name,
+ price: GOODS[id].price,
+ enabled: true,
+ adminOnly: Boolean(GOODS[id].adminOnly),
+ note: GOODS[id].note || '',
+ }))
+ // 文案配置读不到不该挡住下单,退回默认文案就行
+ const settings = await store.getSettings().catch((err) => {
+ console.error('读取文案配置失败:', err.message)
+ return {}
+ })
+ const copy = {}
+ for (const [key, item] of Object.entries(COPY)) copy[key] = settings[key] || item.value
+ configCache = {
+ at: Date.now(),
+ goods: list.slice().sort((a, b) => a.price - b.price), // 价格从低到高,页面和后台顺序一致
+ copy,
+ }
+}
+
+async function currentConfig() {
+ if (Date.now() - configCache.at >= 60000) await refreshConfig()
+ return configCache
+}
+
+const allGoods = async () => (await currentConfig()).goods
+const allCopy = async () => (await currentConfig()).copy
+
+const goodsOf = async (productId) =>
+ (await allGoods()).find((goods) => String(goods.productId) === String(productId) && goods.enabled) || null
+
+const nameOf = async (productId) => ((await goodsOf(productId)) || {}).name || String(productId)
+
+// 管理员判定:只看用户表的 is_admin(查不到就按普通用户算)
+async function isAdmin(openid) {
+ const user = await store.getUser(openid)
+ return Boolean(user && user.isAdmin)
+}
+
+const sha1 = (text) => crypto.createHash('sha1').update(text).digest('hex')
+const isPaid = (status) => status >= 2 && status <= 4 // 2 已支付待发货 / 3 发货中 / 4 已发货
+
+// 从推送的 XML 里取字段,值可能在 CDATA 里
+function xmlTag(xml, name) {
+ const matched = new RegExp(`<${name}>(?:)?${name}>`).exec(xml)
+ return matched ? matched[1].trim() : ''
+}
+
+// 0 现网、1 沙箱;两套 AppKey 不能混用,混了会报 -15006 签名错误
+function appKeyOf(env) {
+ if (env === 1) {
+ if (!config.sandboxAppKey) throw new Error('没配沙箱 AppKey(sandboxAppKey),沙箱下单用不了')
+ return config.sandboxAppKey
+ }
+ return config.appKey
+}
+
+function request(url, body) {
+ return new Promise((resolve, reject) => {
+ const client = url.startsWith('https:') ? https : http
+ const options = {
+ method: body ? 'POST' : 'GET',
+ // 请求体原样发出,pay_sig 是对这串原文算的
+ headers: body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {},
+ }
+ const req = client.request(url, options, (res) => {
+ let raw = ''
+ res.on('data', (chunk) => (raw += chunk))
+ res.on('end', () => {
+ if (!raw.trim()) return resolve({}) // 有的接口成功时返回体是空的
+ try {
+ resolve(JSON.parse(raw))
+ } catch (err) {
+ reject(new Error('微信接口返回的不是 JSON:' + raw.slice(0, 200)))
+ }
+ })
+ })
+ req.on('error', reject)
+ req.end(body || undefined)
+ })
+}
+
+// wx.login 的 code 换 openid + session_key
+async function code2Session(code) {
+ if (!code) throw new Error('缺少 code(前端要先 wx.login)')
+ const res = await request(
+ `${WX_ORIGIN}/sns/jscode2session?appid=${config.appId}&secret=${config.appSecret}` +
+ `&js_code=${code}&grant_type=authorization_code`
+ )
+ if (!res.openid) throw new Error(`code2Session 失败:${res.errcode} ${res.errmsg}`)
+ // 顺手记一笔用户:首次出现建行,之后只更新活跃时间。这里失败不该挡住支付
+ try {
+ await store.touchUser(res.openid)
+ } catch (err) {
+ console.error('记录用户失败:', err.message)
+ }
+ return res
+}
+
+let tokenCache = { value: '', expireAt: 0 }
+async function getAccessToken() {
+ if (tokenCache.value && Date.now() < tokenCache.expireAt) return tokenCache.value
+ const res = await request(
+ `${WX_ORIGIN}/cgi-bin/token?grant_type=client_credential&appid=${config.appId}&secret=${config.appSecret}`
+ )
+ if (!res.access_token) throw new Error(`获取 access_token 失败:${res.errcode} ${res.errmsg}`)
+ tokenCache = { value: res.access_token, expireAt: Date.now() + (res.expires_in - 300) * 1000 }
+ return tokenCache.value
+}
+
+// 下单:生成业务单号 + 双签名,返回给前端去拉起支付
+async function createPay(productId, code, env) {
+ const goods = await goodsOf(productId)
+ if (!goods) throw new Error('未知道具 ID:' + productId)
+ const appKey = appKeyOf(env)
+
+ const session = await code2Session(code)
+ // 沙箱能免费拿货、补单道具是测试价,这两件事只有管理员能用(不能只靠前端藏按钮)
+ const admin = await isAdmin(session.openid)
+ if (goods.adminOnly && !admin) throw new Error(`「${goods.name}」只有管理员能购买`)
+ if (env === 1 && !admin) throw new Error('沙箱环境只有管理员能用')
+
+ const outTradeNo = newOutTradeNo()
+ const signData = buildSignData({
+ offerId: config.offerId,
+ productId,
+ price: goods.price,
+ outTradeNo,
+ attach: 'test-' + productId,
+ env,
+ })
+
+ // 先落一条「已创建」,发货时靠它认人(防止别人拿单号来领别人的货)
+ await store.saveOrder({
+ outTradeNo,
+ openid: session.openid,
+ productId: String(productId),
+ env,
+ amount: goods.price,
+ status: 'created',
+ createdAt: Date.now(),
+ })
+
+ return {
+ outTradeNo,
+ env,
+ payData: {
+ signData,
+ paySig: paySig(appKey, 'requestVirtualPayment', signData),
+ signature: userSignature(session.session_key, signData),
+ mode: 'short_series_goods',
+ },
+ }
+}
+
+// 查单:支付成功回调可能丢,用这个确认真实订单状态
+async function queryOrderRaw(outTradeNo, openid, env) {
+ const token = await getAccessToken()
+ const body = JSON.stringify({ openid, env, order_id: outTradeNo })
+ const url = `${WX_ORIGIN}${XPAY_QUERY_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_QUERY_URI, body)}`
+ const res = await request(url, body)
+ return { errcode: res.errcode, errmsg: res.errmsg, order: res.order }
+}
+
+// 通知平台「已发货」。文档的调用示例里只写了 access_token,但平台实际要 pay_sig
+// (少了会回 268490002 签名字段[pay_sig]为空),所以按查单那套一起签上
+async function notifyProvideGoods(record) {
+ const env = record.env || 0
+ const token = await getAccessToken()
+ const payload = { order_id: record.outTradeNo, env }
+ if (record.wxOrderId) payload.wx_order_id = record.wxOrderId // 查单没给内部单号就别塞空串
+ const body = JSON.stringify(payload)
+ const url = `${WX_ORIGIN}${XPAY_NOTIFY_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_NOTIFY_URI, body)}`
+ const res = await request(url, body)
+ console.log(new Date().toISOString(), 'notify_provide_goods', body, '->', JSON.stringify(res))
+ if (res.errcode) throw new Error(`通知平台发货失败:${res.errcode} ${res.errmsg}`)
+ await store.markNotified(record.outTradeNo)
+}
+
+// 平台通知失败不算发货失败:钱已经付了,激活码先给到用户,下次调用再补通知
+async function tryNotify(record) {
+ if (record.notified) return { ok: true }
+ // 沙箱是模拟环境,平台侧不需要真实发货回执,跳过通知免得反复白调接口
+ if ((record.env || 0) === 1) {
+ await store.markNotified(record.outTradeNo)
+ return { ok: true }
+ }
+ try {
+ await notifyProvideGoods(record)
+ return { ok: true }
+ } catch (err) {
+ console.error('通知平台发货失败:', err.message)
+ return { ok: false, error: err.message }
+ }
+}
+
+// 发激活码 + 通知平台。不依赖前端登录,所以定时补发货和发货推送也能复用
+async function deliverRecord(record, order) {
+ let detail = order
+ if (!detail) {
+ const queried = await queryOrderRaw(record.outTradeNo, record.openid, record.env || 0)
+ detail = queried.order || {}
+ }
+ const delivered = await store.assignCode(record.outTradeNo, {
+ wxOrderId: detail.wx_order_id || record.wxOrderId || '',
+ paidFee: detail.paid_fee || 0,
+ })
+ const notify = await tryNotify(delivered)
+ return {
+ paid: true,
+ status: detail.status || 2,
+ code: delivered.code,
+ productId: delivered.productId,
+ productName: await nameOf(delivered.productId),
+ notified: notify.ok,
+ notifyError: notify.error || '',
+ }
+}
+
+function deliveredResult(record, productName) {
+ return {
+ paid: true,
+ status: 4,
+ code: record.code,
+ productId: record.productId,
+ productName,
+ }
+}
+
+// 发货:查到已支付就发激活码,并告诉平台已发货;同一单重复调用只发一次(幂等)
+async function deliver(outTradeNo, code) {
+ const session = await code2Session(code)
+ const record = await store.get(outTradeNo)
+ if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
+ if (record.openid !== session.openid) throw new Error('这笔订单不属于当前用户')
+ if (record.refunded) throw new Error('这笔订单已经退过款了')
+
+ // 已经发过货:把原来那张码再给一遍
+ if (record.code) {
+ const notify = await tryNotify(record)
+ return Object.assign(deliveredResult(record, await nameOf(record.productId)), {
+ notified: notify.ok,
+ notifyError: notify.error || '',
+ })
+ }
+
+ const { order } = await queryOrderRaw(outTradeNo, session.openid, record.env || 0)
+ const status = (order && order.status) || 0
+ if (!isPaid(status)) {
+ return { paid: false, status, productId: record.productId, productName: await nameOf(record.productId) }
+ }
+ return deliverRecord(record, order)
+}
+
+// 退款:iOS 的 Apple 订单不能走这里(只能用户去 App Store 申请),前端会隐藏按钮
+async function refund(outTradeNo, code) {
+ const session = await code2Session(code)
+ const record = await store.get(outTradeNo)
+ if (!record) throw new Error('没有这笔业务单号:' + outTradeNo)
+ if (record.openid !== session.openid) throw new Error('这笔订单不属于当前用户')
+ if (record.refunded) throw new Error('这笔订单已经退过了')
+ if (!(await isAdmin(session.openid))) throw new Error('退款目前只有管理员能操作')
+
+ const env = record.env || 0
+ const { order } = await queryOrderRaw(outTradeNo, session.openid, env)
+ const status = (order && order.status) || 0
+ if (!isPaid(status)) throw new Error('这笔订单没有可退的支付')
+ // left_fee 要用查单返回的值,自己算会被平台拒(268490016)
+ const leftFee = order.left_fee != null ? order.left_fee : order.paid_fee || 0
+ if (leftFee <= 0) throw new Error('这笔订单没有可退金额(可能已经退过款)')
+
+ const refundOrderId = 'R' + outTradeNo
+ const body = JSON.stringify({
+ openid: session.openid,
+ order_id: outTradeNo,
+ refund_order_id: refundOrderId,
+ left_fee: leftFee,
+ refund_fee: leftFee,
+ biz_meta: 'refund',
+ refund_reason: 3, // 意愿问题,用户主动退款
+ req_from: 2, // 用户自己发起
+ env,
+ })
+ const token = await getAccessToken()
+ const url = `${WX_ORIGIN}${XPAY_REFUND_URI}?access_token=${token}&pay_sig=${paySig(appKeyOf(env), XPAY_REFUND_URI, body)}`
+ const res = await request(url, body)
+ console.log(new Date().toISOString(), 'refund_order', body, '->', JSON.stringify(res))
+ if (res.errcode && res.errcode !== 268490014) throw new Error(`退款失败:${res.errcode} ${res.errmsg}`) // 14 = 退款进行中
+
+ // 激活码作废:「我的」和待发货都不会再带这张码
+ await store.markRefunded(outTradeNo, refundOrderId)
+ return { refundOrderId, refundFee: leftFee }
+}
+// 我的:当前用户已经买到的激活码
+async function myCodes(code) {
+ const session = await code2Session(code)
+ const admin = await isAdmin(session.openid)
+ const list = []
+ for (const record of await store.listByOpenid(session.openid)) {
+ list.push({
+ outTradeNo: record.outTradeNo,
+ code: record.code,
+ productId: record.productId,
+ productName: await nameOf(record.productId),
+ env: record.env || 0,
+ deliveredAt: record.deliveredAt,
+ })
+ }
+ return { list, isAdmin: admin }
+}
+
+// 还没发货的最近一笔:页面重进后仍能点「确认发货」补上(支付回调丢了也能救)
+async function pendingOrder(code) {
+ const session = await code2Session(code)
+ // 只看最近几笔,并且要平台确认真的付了钱——半路放弃的单(订单初始化)不该一直提示发货
+ const candidates = await store.listPendingByOpenid(session.openid)
+ for (const record of candidates) {
+ const { order } = await queryOrderRaw(record.outTradeNo, session.openid, record.env || 0)
+ const status = (order && order.status) || 0
+ if (status >= 2 && status <= 4) {
+ return {
+ outTradeNo: record.outTradeNo,
+ productId: record.productId,
+ productName: await nameOf(record.productId),
+ env: record.env || 0,
+ status,
+ }
+ }
+ }
+ return { outTradeNo: '' }
+}
+
+function readJson(req) {
+ return new Promise((resolve, reject) => {
+ let raw = ''
+ req.on('data', (chunk) => {
+ raw += chunk
+ if (raw.length > MAX_BODY) {
+ reject(new Error('请求体过大'))
+ req.destroy()
+ }
+ })
+ req.on('end', () => {
+ try {
+ resolve(raw ? JSON.parse(raw) : {})
+ } catch (err) {
+ reject(new Error('请求体不是合法 JSON'))
+ }
+ })
+ })
+}
+
+// 发货推送的 XML 原文(明文模式),带体积上限
+function readText(req) {
+ return new Promise((resolve, reject) => {
+ let raw = ''
+ req.on('data', (chunk) => {
+ raw += chunk
+ if (raw.length > MAX_BODY * 4) {
+ reject(new Error('推送内容过大'))
+ req.destroy()
+ }
+ })
+ req.on('end', () => resolve(raw))
+ req.on('error', reject)
+ })
+}
+
+// 平台推来「已发货」(xpay_goods_deliver_notify):幂等发货,回 0 平台就不再重试
+async function handleDeliverPush(xml) {
+ const event = xmlTag(xml, 'Event')
+ if (event !== 'xpay_goods_deliver_notify') {
+ console.log(new Date().toISOString(), '推送事件(忽略):', event || '(没有 Event)')
+ return
+ }
+ const outTradeNo = xmlTag(xml, 'OutTradeNo')
+ const openid = xmlTag(xml, 'OpenId')
+ if (!outTradeNo || !openid) throw new Error('推送里缺 OutTradeNo 或 OpenId')
+
+ const env = Number(xmlTag(xml, 'Env')) === 1 ? 1 : 0
+ let record = await store.get(outTradeNo)
+ if (!record) {
+ // 极少见:本地没这笔单(比如订单表被删了),按推送内容补一条,别漏发
+ record = await store.saveOrder({
+ outTradeNo,
+ openid,
+ productId: xmlTag(xml, 'ProductId'),
+ env,
+ amount: 0,
+ status: 'created',
+ createdAt: Date.now(),
+ })
+ }
+ if (record.code) return // 幂等:同一单重复推送只发一次
+
+ // 推送这边回了 0,平台就认为发货完成了,所以直接标记 notified,不用再调通知接口
+ const delivered = await store.assignCode(outTradeNo, {
+ wxOrderId: xmlTag(xml, 'MchOrderNo') || record.wxOrderId || '',
+ notified: true,
+ })
+ console.log(new Date().toISOString(), '发货推送', outTradeNo, '->', delivered.code)
+}
+
+// 消息推送入口:GET 是后台配置 URL 时的校验,POST 是发货/退款等事件(明文模式)
+async function handlePush(req, res) {
+ const params = new URL(req.url, 'http://localhost').searchParams
+ const expected = sha1([config.pushToken, params.get('timestamp'), params.get('nonce')].sort().join(''))
+ if (!config.pushToken || params.get('signature') !== expected) {
+ res.statusCode = 403
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+ return res.end('signature 校验失败')
+ }
+ if (req.method === 'GET') {
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+ return res.end(params.get('echostr') || '')
+ }
+
+ const xml = '0'
+ try {
+ await handleDeliverPush(await readText(req))
+ } catch (err) {
+ console.error(new Date().toISOString(), '处理发货推送失败:', err.message)
+ res.setHeader('Content-Type', 'text/xml; charset=utf-8')
+ // 出错要回非 0,平台才会重试
+ return res.end(`1`)
+ }
+ res.setHeader('Content-Type', 'text/xml; charset=utf-8')
+ res.end(xml)
+}
+
+// 定时查单兜底:用户付完直接关掉小程序(不再打开购买页)也能自动发货
+async function pollPending() {
+ for (const record of await store.listPendingAll(Date.now() - PENDING_WINDOW_MS)) {
+ try {
+ const { order } = await queryOrderRaw(record.outTradeNo, record.openid, record.env || 0)
+ if (!isPaid((order && order.status) || 0)) continue
+ if (record.code) await tryNotify(record)
+ else await deliverRecord(record, order)
+ } catch (err) {
+ console.error('定时补发货失败:', record.outTradeNo, err.message)
+ }
+ }
+}
+
+function send(res, data, status) {
+ res.statusCode = status
+ res.end(JSON.stringify(data))
+}
+
+const ROUTES = {
+ '/pay/order': async (body) =>
+ Object.assign(
+ { ok: true },
+ // 只认数字 1,字符串 '1' 也要当沙箱:万一前端把 env 传成字符串,不能悄悄签成现网真扣钱
+ await createPay(String(body.productId), body.code, Number(body.env) === 1 ? 1 : 0)
+ ),
+ '/pay/deliver': async (body) => Object.assign({ ok: true }, await deliver(String(body.outTradeNo), body.code)),
+ '/pay/my': async (body) => Object.assign({ ok: true }, await myCodes(body.code)),
+ '/pay/pending': async (body) => Object.assign({ ok: true }, await pendingOrder(body.code)),
+ '/pay/refund': async (body) => Object.assign({ ok: true }, await refund(String(body.outTradeNo), body.code)),
+ // 登录态 + 该用户能看到的道具(管理员才看得到沙箱和补单道具)
+ '/pay/me': async (body) => {
+ const session = await code2Session(body.code)
+ const admin = await isAdmin(session.openid)
+ return {
+ ok: true,
+ isAdmin: admin,
+ goods: (await allGoods()).filter((item) => item.enabled && (admin || !item.adminOnly)),
+ copy: await allCopy(),
+ }
+ },
+}
+
+const server = http.createServer(async (req, res) => {
+ const path = req.url.split('?')[0]
+ const startedAt = Date.now()
+ let summary = ''
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
+ try {
+ if (path === '/pay/notify') {
+ summary = 'push'
+ return await handlePush(req, res)
+ }
+ if (path === '/health') {
+ const missing = ['appId', 'appSecret', 'offerId', 'appKey'].filter((key) => !config[key])
+ summary = 'ok'
+ return send(
+ res,
+ {
+ ok: missing.length === 0,
+ missing,
+ sandbox: Boolean(config.sandboxAppKey),
+ goods: (await allGoods()).map((item) => String(item.productId)),
+ store: store.backend,
+ routes: Object.keys(ROUTES), // 用来一眼确认服务器上跑的是不是最新代码
+ },
+ 200
+ )
+ }
+ if (!ROUTES[path]) {
+ summary = '未知接口'
+ return send(res, { ok: false, error: '未知接口:' + path }, 404)
+ }
+ if (req.method !== 'POST') {
+ summary = '方法不对'
+ return send(res, { ok: false, error: '请用 POST' }, 405)
+ }
+ const data = await ROUTES[path](await readJson(req))
+ // 下单带上环境,事后一眼看出这笔是现网还是沙箱
+ summary = data.ok ? 'ok' : `失败:${data.error}`
+ if (path === '/pay/order') summary += ` env=${data.env}`
+ send(res, data, 200)
+ } catch (err) {
+ summary = `异常:${err.message}`
+ // 业务失败也回 200 + ok:false,前端统一按 ok 判断
+ send(res, { ok: false, error: err.message }, 200)
+ } finally {
+ // 每个请求都留一行,哪怕是「前端压根没调过来」也能看出来
+ console.log(new Date().toISOString(), req.method, path, summary, `${Date.now() - startedAt}ms`)
+ }
+})
+
+// 存储层先就绪(MySQL 要建表、播种价目表),失败就直接退出,别带着坏库对外服务
+store
+ .ready()
+ .then(() => {
+ server.listen(PORT, () => {
+ console.log(`xpay 服务已启动:http://localhost:${server.address().port}(存储:${store.backend})`)
+ const missing = ['appId', 'appSecret', 'offerId', 'appKey'].filter((key) => !config[key])
+ if (missing.length) console.warn('还缺配置:' + missing.join('、') + '(写在 server/config.js 或环境变量里)')
+ if (!config.sandboxAppKey) console.warn('没配沙箱 AppKey,沙箱(env=1)下单会失败')
+ if (!config.pushToken) console.warn('没配 pushToken,平台的发货推送会被拒(查单兜底仍然可用)')
+ })
+ if (POLL_MS > 0) {
+ setInterval(pollPending, POLL_MS)
+ console.log(`定时补发货已开启:每 ${Math.round(POLL_MS / 1000)} 秒扫一次没发完的单`)
+ }
+ })
+ .catch((err) => {
+ console.error('存储层初始化失败:', err.message)
+ process.exit(1)
+ })
diff --git a/server/migrate.js b/server/migrate.js
new file mode 100644
index 0000000..ffd49ca
--- /dev/null
+++ b/server/migrate.js
@@ -0,0 +1,62 @@
+// 把 orders.jsonl 里的历史订单导进 MySQL。可重复跑(按单号 upsert,不覆盖已有激活码)
+// 用法:node server/migrate.js
+const fs = require('fs')
+const path = require('path')
+const store = require('./store.js')
+
+const FILE = process.env.DATA_DIR
+ ? path.join(process.env.DATA_DIR, 'orders.jsonl')
+ : path.join(__dirname, 'data', 'orders.jsonl')
+
+async function main() {
+ await store.ready()
+ if (store.backend !== 'mysql') {
+ throw new Error('现在走的是 JSONL 后端,先在 config.js 里配好 db(库名 + 账号)再跑迁移')
+ }
+ if (!fs.existsSync(FILE)) {
+ console.log('没有找到 ' + FILE + ',不用迁移')
+ return
+ }
+
+ // 一行一条、同一单号可能有多行(改状态就追加),后写的覆盖先写的
+ const records = new Map()
+ let badLines = 0
+ for (const line of fs.readFileSync(FILE, 'utf8').split('\n')) {
+ if (!line.trim()) continue
+ try {
+ const record = JSON.parse(line)
+ records.set(record.outTradeNo, record)
+ } catch (err) {
+ badLines++
+ }
+ }
+
+ let withCode = 0
+ let amount = 0
+ const firstSeen = new Map() // openid -> 最早的下单时间,用来还原「首次使用时间」
+ for (const record of records.values()) {
+ await store.saveOrder(record)
+ if (record.code) withCode++
+ amount += record.amount || 0
+ const seen = firstSeen.get(record.openid)
+ if (record.openid && (!seen || record.createdAt < seen)) firstSeen.set(record.openid, record.createdAt)
+ }
+
+ // 历史订单里的用户也补进用户表,首次使用时间取他们最早那笔单
+ for (const [openid, createdAt] of firstSeen) await store.touchUser(openid, createdAt)
+
+ // 核对:库里的单数和码数应该不小于文件里读出来的
+ const stats = await store.stats()
+ console.log('迁移完成:')
+ console.log(` 文件里有 ${records.size} 笔单(${withCode} 笔带激活码,跳过 ${badLines} 行坏数据)`)
+ console.log(
+ ` 库里有 ${stats.orders} 笔单、${stats.codes} 个激活码、${stats.users} 个用户,其中 ${stats.pending} 笔还没发完`
+ )
+ console.log(` 本次导入的金额合计 ${amount} 分`)
+ console.log(' 请在「我的」页确认激活码都在,再决定要不要把 orders.jsonl 归档')
+}
+
+main().catch((err) => {
+ console.error('迁移失败:', err.message)
+ process.exit(1)
+})
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..8c76e7c
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "mini-viewer-server",
+ "private": true,
+ "description": "虚拟支付下单 / 发货 / 退款服务",
+ "scripts": {
+ "start": "node index.js",
+ "migrate": "node migrate.js",
+ "test": "node test.js",
+ "test:db": "node test-db.js"
+ },
+ "dependencies": {
+ "mysql2": "^3.0.0"
+ }
+}
diff --git a/server/read-config.js b/server/read-config.js
new file mode 100644
index 0000000..88782f0
--- /dev/null
+++ b/server/read-config.js
@@ -0,0 +1,6 @@
+// 只负责把 config.js 读出来(没有就返回空对象),避免 index.js / db.js 互相 require
+try {
+ module.exports = require('./config.js')
+} catch (err) {
+ module.exports = {}
+}
diff --git a/server/sign.js b/server/sign.js
new file mode 100644
index 0000000..322f646
--- /dev/null
+++ b/server/sign.js
@@ -0,0 +1,47 @@
+// 虚拟支付签名与下单参数(纯函数,index.js 与 test.js 共用)
+const crypto = require('crypto')
+
+// 道具价目表(单位:分),必须与 MP 后台【虚拟支付 → 道具管理】的价格一致
+const GOODS = {
+ // 0.1 元的测试道具,只给管理员用
+ 90001: { name: '补单专用', price: 10, adminOnly: true, note: '0.1 元测试用,iOS 最低 1 元买不了' },
+ 10001: { name: '基础激活码', price: 3900 },
+ 10002: { name: '高级激活码', price: 6900 },
+ 10000: { name: '永久激活码', price: 9900 },
+}
+
+function hmacSha256(key, message) {
+ return crypto.createHmac('sha256', key).update(message, 'utf8').digest('hex')
+}
+
+// outTradeNo:8-32 位,仅数字/大小写字母/_-|*@,不能以下划线开头,每单都要换新号
+function newOutTradeNo(now) {
+ return 'T' + (now || Date.now()) + Math.random().toString(36).slice(2, 8).toUpperCase()
+}
+
+// 返回的字符串要原样传给 wx.requestVirtualPayment,签名与传参必须是同一串原文
+function buildSignData(params) {
+ return JSON.stringify({
+ offerId: params.offerId,
+ buyQuantity: params.buyQuantity || 1,
+ env: params.env === undefined ? 0 : params.env, // 0 现网,1 沙箱(沙箱还要换沙箱 AppKey)
+ currencyType: 'CNY',
+ productId: String(params.productId),
+ goodsPrice: params.price, // 单位分,不要换算成元
+ outTradeNo: params.outTradeNo,
+ attach: params.attach,
+ })
+}
+
+// paySig = HMAC-SHA256(AppKey, uri + '&' + body)
+// C 端下单 uri 固定为 requestVirtualPayment;B 端接口用实际路径,如 /xpay/query_order
+function paySig(appKey, uri, body) {
+ return hmacSha256(appKey, uri + '&' + body)
+}
+
+// 用户态签名 = HMAC-SHA256(sessionKey, signData)
+function userSignature(sessionKey, signData) {
+ return hmacSha256(sessionKey, signData)
+}
+
+module.exports = { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig, userSignature }
diff --git a/server/store-jsonl.js b/server/store-jsonl.js
new file mode 100644
index 0000000..2e480f5
--- /dev/null
+++ b/server/store-jsonl.js
@@ -0,0 +1,154 @@
+// 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,
+ }),
+}
diff --git a/server/store-mysql.js b/server/store-mysql.js
new file mode 100644
index 0000000..24bd0c8
--- /dev/null
+++ b/server/store-mysql.js
@@ -0,0 +1,230 @@
+// 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',
+ '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,
+ 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.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 }
+ },
+}
diff --git a/server/store.js b/server/store.js
new file mode 100644
index 0000000..8132c5f
--- /dev/null
+++ b/server/store.js
@@ -0,0 +1,5 @@
+// 存储层入口:config.js 里配了 db(库名 + 账号)就走 MySQL,没配就继续用 JSONL 文件
+// 两边实现同一套接口,切换只改配置,出问题能立刻切回来
+const db = require('./db.js')
+
+module.exports = db.useMysql ? require('./store-mysql.js') : require('./store-jsonl.js')
diff --git a/server/test-db.js b/server/test-db.js
new file mode 100644
index 0000000..66424ea
--- /dev/null
+++ b/server/test-db.js
@@ -0,0 +1,107 @@
+// MySQL 存储层自测:会往库里写几条 TEST- 开头的测试数据,跑完自动删掉
+// 用法:node server/test-db.js(需要 config.js 里配好 db)
+const assert = require('assert')
+const store = require('./store.js')
+const { getPool, settings } = require('./db.js')
+
+const TEST_OPENID = 'oTestStoreUser'
+
+async function main() {
+ if (store.backend !== 'mysql') {
+ throw new Error('现在走的是 JSONL 后端:请在 config.js 里配好 db(库名 + 账号)再跑这个自测')
+ }
+ await store.ready()
+
+ const outTradeNo = 'TEST' + Date.now()
+ const createdAt = Date.now()
+
+ try {
+ await store.saveOrder({
+ outTradeNo,
+ openid: TEST_OPENID,
+ productId: '10001',
+ env: 0,
+ amount: 3900,
+ status: 'created',
+ createdAt,
+ })
+
+ const created = await store.get(outTradeNo)
+ assert.strictEqual(created.openid, TEST_OPENID)
+ assert.strictEqual(created.amount, 3900)
+ assert.strictEqual(created.code, '', '刚下单时不该有激活码')
+ assert.strictEqual(created.createdAt, createdAt)
+
+ // 时间列要是可读的 DATETIME(不是时间戳),且毫秒能原样存取
+ const [columnTypes] = await getPool().query(
+ `SELECT DATA_TYPE FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'codes' AND COLUMN_NAME = 'created_at'`,
+ [settings.database]
+ )
+ assert.strictEqual(columnTypes[0].DATA_TYPE, 'datetime', '时间列应该是 DATETIME,不该是时间戳')
+
+ const delivered = await store.assignCode(outTradeNo, { wxOrderId: 'wx-test-1', paidFee: 3900 })
+ assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
+ assert.strictEqual(delivered.status, 'delivered')
+
+ // 幂等:同一单再发一次还是同一张码(唯一索引挡住)
+ const again = await store.assignCode(outTradeNo, {})
+ assert.strictEqual(again.code, delivered.code)
+
+ let mine = await store.listByOpenid(TEST_OPENID)
+ assert.deepStrictEqual(mine.map((o) => o.code), [delivered.code])
+
+ await store.markNotified(outTradeNo)
+ assert.strictEqual((await store.get(outTradeNo)).notified, true)
+
+ // 退款后码作废:从「我的」和待发货里都消失,但记录还在
+ await store.markRefunded(outTradeNo, 'R' + outTradeNo)
+ mine = await store.listByOpenid(TEST_OPENID)
+ assert.deepStrictEqual(mine, [], '退款后不该再出现在我的激活码里')
+ const [codeRows] = await getPool().query('SELECT status FROM codes WHERE out_trade_no = ?', [outTradeNo])
+ assert.strictEqual(codeRows[0].status, 'revoked')
+
+ // 价目表播种:至少有四个道具,价格和 MP 后台一致
+ const goods = await store.listGoods()
+ assert.ok(goods.length >= 4, 'goods 表应该有播种的价目表')
+ assert.strictEqual(goods.find((g) => g.productId === '10000').price, 9900)
+ assert.strictEqual(goods.find((g) => g.productId === '90001').price, 10)
+ // 文案配置表:默认值会播种进去,备注列告诉你在哪显示
+ const settings = await store.getSettings()
+ assert.strictEqual(settings['buy.title'], '选择套餐')
+ const [copyRows] = await getPool().query(`SELECT remark FROM settings WHERE cfg_key = 'buy.title'`)
+ assert.strictEqual(copyRows[0].remark, '开通页的大标题')
+
+ const stats = await store.stats()
+ assert.ok(stats.orders >= 1)
+ assert.ok(stats.users >= 1, '用户表应该有记录')
+
+ // 用户表:首次使用时间建一次就不该再被覆盖,管理员标志默认 0
+ const [userRows] = await getPool().query('SELECT * FROM users WHERE openid = ?', [TEST_OPENID])
+ assert.strictEqual(userRows.length, 1)
+ assert.strictEqual(userRows[0].is_admin, 0)
+ const firstSeen = userRows[0].created_at.getTime()
+ await store.touchUser(TEST_OPENID)
+ const [againUsers] = await getPool().query('SELECT created_at, last_seen_at FROM users WHERE openid = ?', [TEST_OPENID])
+ assert.strictEqual(againUsers[0].created_at.getTime(), firstSeen, '首次使用时间不该被覆盖')
+ assert.ok(againUsers[0].last_seen_at.getTime() >= firstSeen)
+
+ // 管理员标志从 users.is_admin 读
+ assert.strictEqual((await store.getUser(TEST_OPENID)).isAdmin, false)
+ await getPool().query('UPDATE users SET is_admin = 1 WHERE openid = ?', [TEST_OPENID])
+ assert.strictEqual((await store.getUser(TEST_OPENID)).isAdmin, true)
+
+ console.log(`数据库自测通过(当前库里 ${stats.orders} 笔单 / ${stats.codes} 个码 / ${stats.users} 个用户)`)
+ } finally {
+ // 收尾:把测试数据删掉,别脏了真库
+ await getPool().query('DELETE FROM codes WHERE out_trade_no = ?', [outTradeNo])
+ await getPool().query('DELETE FROM orders WHERE out_trade_no = ?', [outTradeNo])
+ await getPool().query('DELETE FROM users WHERE openid = ?', [TEST_OPENID])
+ await getPool().end()
+ }
+}
+
+main().catch((err) => {
+ console.error('数据库自测失败:', err.message)
+ process.exit(1)
+})
diff --git a/server/test.js b/server/test.js
new file mode 100644
index 0000000..e0c5074
--- /dev/null
+++ b/server/test.js
@@ -0,0 +1,457 @@
+// 用法:node server/test.js
+// 1) 纯函数断言(HMAC 官方向量、价目表、signData 字段、单号格式、激活码格式)
+// 2) 起一个假的微信接口 + 真的 server/index.js,走一遍下单、发货、我的
+const assert = require('assert')
+const crypto = require('crypto')
+const fs = require('fs')
+const http = require('http')
+const os = require('os')
+const path = require('path')
+const { spawn } = require('child_process')
+const { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig } = require('./sign.js')
+const { newActivationCode } = require('./code.js')
+
+const APP_KEY = 'test-appkey'
+const SANDBOX_APP_KEY = 'test-sandbox-appkey'
+const OFFER_ID = '1450663371'
+const SESSION_KEY = 'sk-test'
+const OPENID = 'oTestUser'
+const PUSH_TOKEN = 'test-push-token'
+
+// ---------- 1. 纯函数 ----------
+assert.strictEqual(
+ hmacSha256(Buffer.alloc(20, 0x0b), 'Hi There'), // RFC 4231 官方向量
+ 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
+)
+assert.deepStrictEqual(
+ { 90001: GOODS[90001].price, 10001: GOODS[10001].price, 10002: GOODS[10002].price, 10000: GOODS[10000].price },
+ { 90001: 10, 10001: 3900, 10002: 6900, 10000: 9900 }
+)
+
+const sample = buildSignData({ offerId: OFFER_ID, productId: 10000, price: 9900, outTradeNo: 'T1700ABCDEF', attach: 'test-10000' })
+assert.deepStrictEqual(JSON.parse(sample), {
+ offerId: OFFER_ID,
+ buyQuantity: 1,
+ env: 0,
+ currencyType: 'CNY',
+ productId: '10000',
+ goodsPrice: 9900,
+ outTradeNo: 'T1700ABCDEF',
+ attach: 'test-10000',
+})
+assert.notStrictEqual(
+ paySig(APP_KEY, 'requestVirtualPayment', sample),
+ paySig(APP_KEY, 'requestVirtualPayment', sample + ' '),
+ '签名必须认原文'
+)
+for (let i = 0; i < 500; i++) {
+ const no = newOutTradeNo(1700000000000 + i)
+ assert.ok(no.length >= 8 && no.length <= 32, '单号长度越界: ' + no)
+ assert.ok(/^[0-9A-Za-z_\-|*@]+$/.test(no), '单号含非法字符: ' + no)
+ assert.ok(!no.startsWith('_'), '单号不能以下划线开头: ' + no)
+}
+
+// 激活码:4 组 4 位、不含容易看错的 I/L/O/U、不重复
+const codes = new Set()
+for (let i = 0; i < 1000; i++) {
+ const made = newActivationCode()
+ assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(made), '激活码格式不对: ' + made)
+ codes.add(made)
+}
+assert.strictEqual(codes.size, 1000, '激活码不应重复')
+
+// ---------- 2. 起假微信接口 ----------
+const seen = { notifyCalls: 0, unpaid: null, failNotify: false }
+const fakeWx = http.createServer((req, res) => {
+ const [urlPath, query] = req.url.split('?')
+ let raw = ''
+ req.on('data', (chunk) => (raw += chunk))
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json')
+ const reply = (obj) => res.end(JSON.stringify(obj))
+ if (urlPath === '/sns/jscode2session') {
+ const jsCode = new URLSearchParams(query).get('js_code')
+ seen.code = jsCode
+ return reply({ openid: jsCode === 'code-other' ? 'oOtherUser' : OPENID, session_key: SESSION_KEY })
+ }
+ if (urlPath === '/cgi-bin/token') return reply({ access_token: 'tok-test', expires_in: 7200 })
+ if (urlPath === '/xpay/query_order') {
+ seen.queryBody = raw
+ seen.querySig = new URLSearchParams(query).get('pay_sig')
+ seen.queryToken = new URLSearchParams(query).get('access_token')
+ const { order_id: orderId, env } = JSON.parse(raw)
+ const status = orderId === seen.unpaid ? 1 : 2 // 1 = 已创建未支付
+ return reply({
+ errcode: 0,
+ errmsg: 'ok',
+ order: { order_id: orderId, status, paid_fee: 10, left_fee: 10, wx_order_id: `wx-${env}-${orderId}` },
+ })
+ }
+ if (urlPath === '/xpay/notify_provide_goods') {
+ seen.notifyCalls++
+ seen.notifyBody = raw
+ seen.notifyToken = new URLSearchParams(query).get('access_token')
+ seen.notifySig = new URLSearchParams(query).get('pay_sig')
+ if (seen.failNotify) return reply({ errcode: 44002, errmsg: 'empty post data' })
+ return reply({ errcode: 0, errmsg: 'ok' })
+ }
+ if (urlPath === '/xpay/refund_order') {
+ seen.refundBody = raw
+ seen.refundSig = new URLSearchParams(query).get('pay_sig')
+ return reply({ errcode: 0, errmsg: 'ok', refund_order_id: JSON.parse(raw).refund_order_id })
+ }
+ res.statusCode = 404
+ reply({ errcode: -1, errmsg: 'no such path: ' + urlPath })
+ })
+})
+
+function listen(server) {
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
+}
+
+function fetchJson(port, urlPath, payload) {
+ return new Promise((resolve, reject) => {
+ const body = payload === undefined ? null : JSON.stringify(payload)
+ const req = http.request(
+ { host: '127.0.0.1', port, path: urlPath, method: body ? 'POST' : 'GET', headers: body ? { 'Content-Type': 'application/json' } : {} },
+ (res) => {
+ let raw = ''
+ res.on('data', (chunk) => (raw += chunk))
+ res.on('end', () => resolve(JSON.parse(raw)))
+ }
+ )
+ req.on('error', reject)
+ req.end(body || undefined)
+ })
+}
+
+// 需要看原始响应的场合(推送接口回的是 XML / 纯文本)
+function rawRequest(port, urlPath, options) {
+ const opts = options || {}
+ return new Promise((resolve, reject) => {
+ const req = http.request(
+ { host: '127.0.0.1', port, path: urlPath, method: opts.method || (opts.body ? 'POST' : 'GET') },
+ (res) => {
+ let raw = ''
+ res.on('data', (chunk) => (raw += chunk))
+ res.on('end', () => resolve({ status: res.statusCode, body: raw }))
+ }
+ )
+ req.on('error', reject)
+ req.end(opts.body || undefined)
+ })
+}
+
+// 消息推送的签名:sha1(sort([token, timestamp, nonce]).join(''))
+function pushQuery(token) {
+ const timestamp = '1700000000'
+ const nonce = 'abc123'
+ const signature = crypto.createHash('sha1').update([token || PUSH_TOKEN, timestamp, nonce].sort().join('')).digest('hex')
+ return `?signature=${signature}×tamp=${timestamp}&nonce=${nonce}`
+}
+
+function startServer(env) {
+ return new Promise((resolve) => {
+ const child = spawn(process.execPath, [path.join(__dirname, 'index.js')], {
+ env: Object.assign({}, process.env, env),
+ stdio: ['ignore', 'pipe', 'inherit'],
+ })
+ let out = ''
+ child.stdout.on('data', (chunk) => {
+ out += chunk
+ const port = (out.match(/http:\/\/localhost:(\d+)/) || [])[1]
+ if (port) resolve({ port: Number(port), child })
+ })
+ })
+}
+
+// 轮询等某个单号出现激活码(定时补发货是异步的)
+async function waitForCode(port, outTradeNo, timeoutMs) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
+ const hit = mine.list.find((item) => item.outTradeNo === outTradeNo)
+ if (hit) return hit
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+ return null
+}
+
+async function main() {
+ const wxPort = await listen(fakeWx)
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-data-'))
+ // 先放一条管理员记录:沙箱、补单道具、退款这些管理员功能要靠它
+ fs.writeFileSync(
+ path.join(dataDir, 'users.jsonl'),
+ JSON.stringify({ openid: OPENID, createdAt: 1700000000000, lastSeenAt: 1700000000000, isAdmin: true }) + '\n'
+ )
+ // 文案配置:只覆盖标题,其它项应该退回代码里的默认值
+ fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ 'buy.title': '限时特惠', 'buy.button': '立即开通' }))
+ const serverEnv = {
+ PORT: '0',
+ PAY_POLL_MS: '0', // 主流程把定时任务关掉,免得跟断言的时序打架
+ PAY_STORE: 'jsonl', // 自测一律用文件后端,绝不碰你的数据库
+ WX_API_ORIGIN: `http://127.0.0.1:${wxPort}`,
+ DATA_DIR: dataDir,
+ PAY_APP_ID: 'wxtest',
+ PAY_APP_SECRET: 'secret-test',
+ PAY_OFFER_ID: OFFER_ID,
+ PAY_APP_KEY: APP_KEY,
+ PAY_SANDBOX_APP_KEY: SANDBOX_APP_KEY,
+ PAY_PUSH_TOKEN: PUSH_TOKEN,
+ }
+ const { port, child } = await startServer(serverEnv)
+
+ try {
+ assert.deepStrictEqual(await fetchJson(port, '/health'), {
+ ok: true,
+ missing: [],
+ sandbox: true,
+ goods: ['90001', '10001', '10002', '10000'], // 价格从低到高
+ store: 'jsonl',
+ routes: ['/pay/order', '/pay/deliver', '/pay/my', '/pay/pending', '/pay/refund', '/pay/me'],
+ })
+
+ // 下单:签名要能被独立重算出来
+ const paid = await fetchJson(port, '/pay/order', { productId: '10000', code: 'code-1' })
+ assert.strictEqual(paid.ok, true, JSON.stringify(paid))
+ assert.strictEqual(seen.code, 'code-1')
+ const { signData, paySig: gotPaySig, signature, mode } = paid.payData
+ assert.strictEqual(mode, 'short_series_goods')
+ assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
+ assert.strictEqual(signature, hmacSha256(SESSION_KEY, signData))
+ const parsed = JSON.parse(signData)
+ assert.strictEqual(parsed.offerId, OFFER_ID)
+ assert.strictEqual(parsed.productId, '10000')
+ assert.strictEqual(parsed.goodsPrice, 9900)
+ assert.strictEqual(parsed.env, 0)
+ assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
+ assert.strictEqual(paid.outTradeNo, parsed.outTradeNo)
+
+ // 页面重进后能找回还没发货的单(补发货用)
+ const pending = await fetchJson(port, '/pay/pending', { code: 'code-1' })
+ assert.strictEqual(pending.outTradeNo, paid.outTradeNo)
+ // 半路放弃、根本没付款的单,不该被当成「待发货」提示
+ seen.unpaid = paid.outTradeNo
+ assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
+ seen.unpaid = null
+ assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-other' }), { ok: true, outTradeNo: '' })
+
+ // 沙箱单:env=1、用沙箱 AppKey 签名,和现网签名不能一样
+ const sandbox = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: 1 })
+ assert.strictEqual(sandbox.ok, true, JSON.stringify(sandbox))
+ const sandboxSignData = sandbox.payData.signData
+ assert.strictEqual(JSON.parse(sandboxSignData).env, 1)
+ assert.strictEqual(JSON.parse(sandboxSignData).goodsPrice, 10)
+ assert.strictEqual(sandbox.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', sandboxSignData))
+ assert.notStrictEqual(sandbox.payData.paySig, paySig(APP_KEY, 'requestVirtualPayment', sandboxSignData))
+
+ // 前端万一把 env 传成字符串 '1',也必须按沙箱签,绝不能悄悄走现网真扣钱
+ const stringEnv = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: '1' })
+ assert.strictEqual(JSON.parse(stringEnv.payData.signData).env, 1)
+ assert.strictEqual(stringEnv.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', stringEnv.payData.signData))
+
+ // 未发布的道具 ID 不该能下单
+ const bad = await fetchJson(port, '/pay/order', { productId: '12345', code: 'code-1' })
+ assert.strictEqual(bad.ok, false)
+ assert.ok(/未知道具/.test(bad.error), bad.error)
+
+ // 我是谁 + 我能看到哪些道具:管理员才多看到「补单专用」(0.1 元测试价)
+ const me = await fetchJson(port, '/pay/me', { code: 'code-1' })
+ assert.strictEqual(me.isAdmin, true)
+ assert.deepStrictEqual(
+ me.goods.map((item) => [item.productId, item.price]),
+ [
+ ['90001', 10],
+ ['10001', 3900],
+ ['10002', 6900],
+ ['10000', 9900],
+ ]
+ )
+ assert.ok(me.goods.find((item) => item.productId === '90001').note, '备注文案要能带回小程序')
+ // 页面文案:库里有就用库里的,没有就退回默认值
+ assert.strictEqual(me.copy['buy.title'], '限时特惠')
+ assert.strictEqual(me.copy['buy.button'], '立即开通') // 库里的覆盖生效
+ assert.strictEqual(me.copy['tab.buy'], '开通')
+ const normalMe = await fetchJson(port, '/pay/me', { code: 'code-other' })
+ assert.strictEqual(normalMe.isAdmin, false)
+ assert.ok(!normalMe.goods.some((item) => item.productId === '90001'), '普通用户不该看到补单专用')
+ // 普通用户直接调接口也绕不过去
+ const sneakyGoods = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-other' })
+ assert.strictEqual(sneakyGoods.ok, false)
+ assert.ok(/管理员/.test(sneakyGoods.error), sneakyGoods.error)
+ const sneakySandbox = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other', env: 1 })
+ assert.strictEqual(sneakySandbox.ok, false)
+ assert.ok(/沙箱/.test(sneakySandbox.error), sneakySandbox.error)
+
+ // 查单该用 pay_sig + access_token
+ seen.unpaid = paid.outTradeNo
+ const unpaid = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
+ assert.strictEqual(unpaid.ok, true)
+ assert.strictEqual(unpaid.paid, false, '没付款就不能发货')
+ assert.strictEqual(unpaid.code, undefined)
+ assert.strictEqual(seen.notifyCalls, 0, '没付款不该通知平台发货')
+ assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 0, order_id: paid.outTradeNo })
+ assert.strictEqual(seen.querySig, paySig(APP_KEY, '/xpay/query_order', seen.queryBody))
+ assert.strictEqual(seen.queryToken, 'tok-test')
+
+ // 付款后发货:发一张码 + 通知平台
+ seen.unpaid = null
+ const delivered = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
+ assert.strictEqual(delivered.paid, true)
+ assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
+ assert.strictEqual(delivered.productName, '永久激活码')
+ assert.strictEqual(delivered.notified, true)
+ assert.deepStrictEqual(JSON.parse(seen.notifyBody), { order_id: paid.outTradeNo, wx_order_id: 'wx-0-' + paid.outTradeNo, env: 0 })
+ assert.strictEqual(seen.notifyToken, 'tok-test')
+ assert.strictEqual(seen.notifyCalls, 1)
+ // 平台要求 pay_sig,缺了会回 268490002
+ assert.strictEqual(seen.notifySig, paySig(APP_KEY, '/xpay/notify_provide_goods', seen.notifyBody))
+
+ // 幂等:同一单再点一次,还是同一张码,也不再重复通知平台
+ const again = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
+ assert.strictEqual(again.code, delivered.code)
+ assert.strictEqual(seen.notifyCalls, 1)
+
+ // 别人的单不能领
+ const stolen = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-other' })
+ assert.strictEqual(stolen.ok, false)
+ assert.ok(/不属于当前用户/.test(stolen.error), stolen.error)
+
+ // 我的:本人能看到,别人看不到
+ const sandboxDelivered = await fetchJson(port, '/pay/deliver', { outTradeNo: sandbox.outTradeNo, code: 'code-1' })
+ assert.strictEqual(sandboxDelivered.paid, true, JSON.stringify(sandboxDelivered))
+ assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 1, order_id: sandbox.outTradeNo })
+ assert.strictEqual(seen.querySig, paySig(SANDBOX_APP_KEY, '/xpay/query_order', seen.queryBody))
+ // 沙箱单不通知平台:通知次数还停在前面的现网单上
+ assert.strictEqual(seen.notifyCalls, 1, '沙箱单不该调 notify_provide_goods')
+ assert.strictEqual(sandboxDelivered.notified, true)
+ assert.strictEqual(sandboxDelivered.productName, '补单专用')
+
+ const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
+ assert.deepStrictEqual(
+ mine.list.map((item) => [item.code, item.productName]).sort(),
+ [
+ [delivered.code, '永久激活码'],
+ [sandboxDelivered.code, '补单专用'],
+ ].sort()
+ )
+ const others = await fetchJson(port, '/pay/my', { code: 'code-other' })
+ assert.deepStrictEqual(others.list, [])
+
+ // 发过货的单不再出现在待发货里
+ const pendingAfter = await fetchJson(port, '/pay/pending', { code: 'code-1' })
+ assert.notStrictEqual(pendingAfter.outTradeNo, paid.outTradeNo)
+ assert.notStrictEqual(pendingAfter.outTradeNo, sandbox.outTradeNo)
+
+ // 平台拒绝通知时:码照样给用户,但要把平台原话带回去,方便直接在手机上看到原因
+ // 先把试探 env 的那笔也发掉,保证后面只剩一笔未完成单,断言不被时序搅浑
+ await fetchJson(port, '/pay/deliver', { outTradeNo: stringEnv.outTradeNo, code: 'code-1' })
+ const failOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-1' })
+ seen.failNotify = true
+ const failed = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
+ assert.strictEqual(failed.paid, true)
+ assert.ok(failed.code, '平台通知失败也要把激活码给用户')
+ assert.strictEqual(failed.notified, false)
+ assert.ok(/44002/.test(failed.notifyError), failed.notifyError)
+ seen.failNotify = false
+
+ // 码发了但平台没确认的单,要能找回来继续重试通知
+ const pendingRetry = await fetchJson(port, '/pay/pending', { code: 'code-1' })
+ assert.strictEqual(pendingRetry.outTradeNo, failOrder.outTradeNo)
+ const retried = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
+ assert.strictEqual(retried.code, failed.code, '重试要给回同一张码')
+ assert.strictEqual(retried.notified, true)
+ // 通知成功后就不再提示了
+ assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
+
+ // ---------- 发货推送 ----------
+ // 配置 URL 时的 GET 校验:签名对就原样回 echostr,签名错要 403
+ assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery() + '&echostr=hello-123')).body, 'hello-123')
+ assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery('wrong-token') + '&echostr=hello')).status, 403)
+
+ const pushOrder = await fetchJson(port, '/pay/order', { productId: '10002', code: 'code-1' })
+ const pushXml =
+ '' +
+ '' +
+ '' +
+ '' +
+ `` +
+ `` +
+ '0' +
+ '' +
+ '1' +
+ ''
+ const notifyCallsBeforePush = seen.notifyCalls
+ const pushRes = await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })
+ assert.ok(/0<\/ErrCode>/.test(pushRes.body), pushRes.body)
+ const afterPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
+ const pushCode = afterPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo)
+ assert.ok(pushCode && pushCode.productName === '高级激活码', '推送应该把货发出来')
+ // 推送已经回了 0,就不该再去调 notify_provide_goods
+ assert.strictEqual(seen.notifyCalls, notifyCallsBeforePush, '推送场景不用再手动通知平台')
+ // 同一个单再推一次:幂等,码不变
+ assert.ok(/0<\/ErrCode>/.test((await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })).body))
+ const afterSecondPush = await fetchJson(port, '/pay/my', { code: 'code-1' })
+ assert.strictEqual(afterSecondPush.list.find((item) => item.outTradeNo === pushOrder.outTradeNo).code, pushCode.code)
+
+ // ---------- 退款 ----------
+ const refunded = await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })
+ assert.strictEqual(refunded.ok, true, JSON.stringify(refunded))
+ const refundBody = JSON.parse(seen.refundBody)
+ assert.strictEqual(refundBody.order_id, pushOrder.outTradeNo)
+ assert.strictEqual(refundBody.left_fee, 10)
+ assert.strictEqual(refundBody.refund_fee, 10)
+ assert.strictEqual(refundBody.env, 0)
+ assert.ok(/^[\w-]{8,32}$/.test(refundBody.refund_order_id), refundBody.refund_order_id)
+ assert.strictEqual(seen.refundSig, paySig(APP_KEY, '/xpay/refund_order', seen.refundBody))
+ // 退完就不在「我的」里了,也不能重复退
+ const afterRefund = await fetchJson(port, '/pay/my', { code: 'code-1' })
+ assert.ok(!afterRefund.list.some((item) => item.outTradeNo === pushOrder.outTradeNo), '退款后激活码要作废')
+ assert.strictEqual((await fetchJson(port, '/pay/refund', { outTradeNo: pushOrder.outTradeNo, code: 'code-1' })).ok, false)
+
+ // 普通用户能正常买单,但退款是管理员功能
+ const normalOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other' })
+ assert.strictEqual(normalOrder.ok, true, JSON.stringify(normalOrder))
+ await fetchJson(port, '/pay/deliver', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
+ const normalRefund = await fetchJson(port, '/pay/refund', { outTradeNo: normalOrder.outTradeNo, code: 'code-other' })
+ assert.strictEqual(normalRefund.ok, false)
+ assert.ok(/管理员/.test(normalRefund.error), normalRefund.error)
+
+ // ---------- 定时查单补发货 ----------
+ const cronDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-cron-'))
+ const cron = await startServer(Object.assign({}, serverEnv, { DATA_DIR: cronDataDir, PAY_POLL_MS: '200' }))
+ try {
+ const cronOrder = await fetchJson(cron.port, '/pay/order', { productId: '10001', code: 'code-1' })
+ const autoCode = await waitForCode(cron.port, cronOrder.outTradeNo, 5000)
+ assert.ok(autoCode, '付了钱但没进页面,定时任务也应该自动发货')
+ } finally {
+ cron.child.kill()
+ fs.rmSync(cronDataDir, { recursive: true, force: true })
+ }
+
+ // 落盘了:重启也能恢复
+ assert.ok(fs.existsSync(path.join(dataDir, 'orders.jsonl')), '订单文件没写出来')
+
+ // 用户表:首次使用会建行;这条是我们预置的管理员,标志要保持 true 不被覆盖
+ const userRows = fs
+ .readFileSync(path.join(dataDir, 'users.jsonl'), 'utf8')
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line))
+ assert.strictEqual(userRows[0].openid, OPENID)
+ assert.strictEqual(userRows[0].isAdmin, true)
+ assert.ok(userRows[0].createdAt > 0, '首次使用时间没记上')
+
+ console.log('服务端测试通过:下单签名、查单、发货幂等、归属校验、我的列表、发货推送、退款、定时补发货')
+ } finally {
+ child.kill()
+ fakeWx.close()
+ fs.rmSync(dataDir, { recursive: true, force: true })
+ }
+}
+
+main().catch((err) => {
+ console.error('测试失败:', err.message)
+ process.exit(1)
+})
diff --git a/utils/pay-api.js b/utils/pay-api.js
new file mode 100644
index 0000000..094b88d
--- /dev/null
+++ b/utils/pay-api.js
@@ -0,0 +1,40 @@
+// 支付后端地址(已配到 MP 后台 request 合法域名);换域名/路径只改这一行
+const API_BASE = 'https://mini.yeomai.site'
+
+// 调自己的服务端,后端统一返回 { ok, ... }
+function post(path, data) {
+ return new Promise((resolve, reject) => {
+ wx.request({
+ url: API_BASE + path,
+ method: 'POST',
+ data,
+ timeout: 10000, // 卡住也要有结论,别让页面一直停在「进行中」
+ success: (res) => {
+ const body = res.data || {}
+ if (body.ok) resolve(body)
+ else reject(new Error(body.error || '后端返回异常(HTTP ' + res.statusCode + ')'))
+ },
+ fail: (err) => reject(new Error('请求后端失败:' + (err.errMsg || '') + '(域名是否已加入 request 合法域名?)')),
+ })
+ })
+}
+
+// 拿 wx.login 的 code,服务端用它换 openid + sessionKey
+function login() {
+ return new Promise((resolve, reject) => {
+ // wx.login 偶尔会不回调,加个兜底,避免整个流程卡死
+ const timer = setTimeout(() => reject(new Error('wx.login 超时,请重试')), 8000)
+ wx.login({
+ success: (res) => {
+ clearTimeout(timer)
+ resolve(res.code)
+ },
+ fail: () => {
+ clearTimeout(timer)
+ reject(new Error('wx.login 失败,拿不到 sessionKey'))
+ },
+ })
+ })
+}
+
+module.exports = { API_BASE, post, login }