小程序接入支付功能

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

No files matched your search

@@ -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
<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>
```
#### 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
@@ -0,0 +1,6 @@
{
"ownerId": "538640",
"slug": "miniprogram-virtualpay-person",
"version": "1.0.2",
"publishedAt": 1788233178091
}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
# 虚拟支付密钥,不要入库
server/config.js
# 订单与激活码运行数据
server/data/
+17 -2
View File
@@ -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"
+50 -2
View File
@@ -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;
}
+5 -1
View File
@@ -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",
+302
View File
@@ -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
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "开通使用",
"usingComponents": {}
}
+34
View File
@@ -0,0 +1,34 @@
<view class="page">
<view class="head">
<view class="head-title">{{copy.title}}</view>
<view class="head-sub">{{isAdmin ? (env === 1 ? '沙箱环境 · 不扣钱,只有开发者工具能拉起' : '现网环境 · 会真扣款(管理员可见)') : copy.subtitle}}</view>
<view wx:if="{{isAdmin}}" class="env-switch">
<view class="env-chip {{env === 0 ? 'on' : ''}}" data-env="0" bindtap="onEnv">现网</view>
<view class="env-chip {{env === 1 ? 'on' : ''}}" data-env="1" bindtap="onEnv">沙箱</view>
</view>
</view>
<view class="card" wx:for="{{goods}}" wx:key="id" data-id="{{item.id}}" bindtap="onBuy">
<view class="card-main">
<view class="card-name">{{item.name}}<text wx:if="{{item.isBest}}" class="card-badge">推荐</text></view>
<view wx:if="{{item.note}}" class="card-note">{{item.note}}</view>
<view wx:if="{{isAdmin}}" class="card-id">道具 ID {{item.id}}</view>
</view>
<view class="card-right">
<view class="card-price">¥{{item.priceText}}</view>
<view class="card-btn {{buying === item.id ? 'dim' : ''}}">{{buying === item.id ? '进行中…' : copy.button}}</view>
</view>
</view>
<view wx:if="{{result}}" class="result {{result.ok ? 'ok' : 'bad'}}">
<view class="result-title">{{result.title}}</view>
<view wx:if="{{result.detail}}" class="result-detail">{{result.detail}}</view>
<view wx:if="{{result.code}}" class="result-code" bindtap="copyCode">{{result.code}}</view>
<view wx:if="{{lastOrder && isAdmin}}" class="result-order">业务单号 {{lastOrder}}</view>
<view wx:if="{{lastOrder && needPay}}" class="result-btn" bindtap="onRetryPay">重新支付</view>
<view wx:if="{{lastOrder && !needPay}}" class="result-btn" bindtap="onDeliver">{{isAdmin ? '确认发货' : '重新获取激活码'}}</view>
</view>
<view class="tip">{{copy.tip}}</view>
<view wx:if="{{isAdmin}}" class="tip dev">管理员备注:下单、查单、发货都走你自己的服务端(server/index.js),发货以查单结果为准;沙箱用自己的沙箱 AppKey 和订单池,和现网互不影响。</view>
</view>
+182
View File
@@ -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);
}
+90
View File
@@ -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' }),
})
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "SpineViewer",
"usingComponents": {}
}
+95
View File
@@ -0,0 +1,95 @@
<view class="page">
<view class="glow"></view>
<!-- ===== 顶部 ===== -->
<view class="hero">
<view class="hero-title">美术师的省心小工具</view>
<view class="hero-title-sub">你的 <text class="hero-accent">Spine 数据资产</text> 可以进 Eagle 啦</view>
<view class="hero-sub">
SpineViewer 接管 skel / skel.bytes / json / atlas / atlas.txt,自动在素材库中配对骨骼、图集与贴图,识别版本并加载本地运行时。点开任意一个文件,就能实时播放、切换皮肤、导出缩略图。
</view>
<view class="hero-cta">
<view class="btn primary" bindtap="goBuy">查看套餐</view>
<view class="btn ghost" bindtap="goPreview">试用版体验</view>
</view>
<view class="stats">
<view class="stat" wx:for="{{stats}}" wx:key="label">
<view class="stat-value">{{item.value}}</view>
<view class="stat-label">{{item.label}}</view>
</view>
</view>
<view class="chips">
<text class="chip on" wx:for="{{fileChips}}" wx:key="*this">{{item}}</text>
</view>
<view class="note">png / jpg / webp 仍由 Eagle 原生预览</view>
</view>
<!-- ===== 小程序定位 ===== -->
<view class="notice">
<view class="notice-title">这里是试用版</view>
<view class="notice-desc">试用版只支持 Spine 4.2 导出的文件;桌面插件的完整能力包括 3.4–4.3 全版本识别、皮肤切换、时间轴、缩略图生成。</view>
<view class="notice-link" bindtap="goPreview">去试用版看看 →</view>
</view>
<!-- ===== 功能特性 ===== -->
<view class="section">
<view class="kicker">FEATURES</view>
<view class="h2">功能特性</view>
<view class="lead">下面每一项都对应插件的真实行为:跨 item 文件夹配对、旧版二进制解析、WebGL 渲染、缩略图补刷……不只是「能打开文件」。</view>
<view class="card" wx:for="{{features}}" wx:key="title">
<view class="card-title">{{item.title}}</view>
<view class="card-desc">{{item.desc}}</view>
</view>
</view>
<!-- ===== 支持版本 ===== -->
<view class="section alt">
<view class="kicker">VERSIONS</view>
<view class="h2">支持版本</view>
<view class="lead">覆盖 Spine 3.4 – 4.3:新版二进制直接支持,旧版二进制由同版本运行时与解析层处理。</view>
<view class="rows">
<view class="row" wx:for="{{versions}}" wx:key="ver">
<view class="row-ver">{{item.ver}}</view>
<view class="row-desc">{{item.desc}}</view>
</view>
</view>
</view>
<!-- ===== 使用流程 ===== -->
<view class="section">
<view class="kicker">WORKFLOW</view>
<view class="h2">从导入到播放</view>
<view class="step" wx:for="{{steps}}" wx:key="*this">
<view class="step-index">{{index + 1}}</view>
<view class="step-text">{{item}}</view>
</view>
</view>
<!-- ===== 安装 ===== -->
<view class="section alt">
<view class="kicker">INSTALL</view>
<view class="h2">安装使用</view>
<view class="lead">在 Eagle 插件社区获取插件包,然后在 Eagle 插件面板(快捷键 P)安装即可。</view>
<view class="step" wx:for="{{install}}" wx:key="*this">
<view class="step-index">{{index + 1}}</view>
<view class="step-text">{{item}}</view>
</view>
<view class="btn ghost wide" bindtap="copySite">复制插件页网址</view>
<view class="note">这里打不开外部网页,复制后在浏览器里打开就行。</view>
</view>
<!-- ===== 常见问题 ===== -->
<view class="section">
<view class="kicker">FAQ</view>
<view class="h2">常见问题</view>
<view class="faq" wx:for="{{faqs}}" wx:key="q">
<view class="faq-q">{{item.q}}</view>
<view class="faq-a">{{item.a}}</view>
</view>
</view>
<view class="foot">小程序版仅支持 4.2 版本</view>
</view>
+313
View File
@@ -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);
}
+79
View File
@@ -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())
},
})
+5
View File
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "我的",
"enablePullDownRefresh": true,
"usingComponents": {}
}
+31
View File
@@ -0,0 +1,31 @@
<view class="page">
<view class="head">
<view class="head-title">我的激活码</view>
<view class="head-sub">点一下激活码即可复制</view>
</view>
<view wx:if="{{error}}" class="banner">{{error}}</view>
<view wx:if="{{loading && !list.length}}" class="banner">加载中…</view>
<view wx:for="{{list}}" wx:key="code" class="card" data-code="{{item.code}}" bindtap="copyCode">
<view class="card-name">{{item.productName}}<text wx:if="{{item.env === 1}}" class="card-tag">沙箱测试</text></view>
<view class="card-code">{{item.code}}</view>
<view class="card-bottom">
<view class="card-time">{{item.timeText}}</view>
<view
wx:if="{{isAdmin && !isIos && item.env === 0}}"
class="card-refund"
data-order="{{item.outTradeNo}}"
catchtap="onRefund"
>申请退款</view>
</view>
</view>
<view wx:if="{{isIos && list.length}}" class="tip">iPhone 上买的订单开发者退不了,需要退款请到 App Store → 购买记录里申请。</view>
<view wx:if="{{!loading && !list.length && !error}}" class="placeholder">
<view class="placeholder-icon">🎫</view>
<view class="placeholder-title">还没有激活码</view>
<view class="placeholder-desc">去「购买测试」买一个,发货后这里就会出现</view>
</view>
</view>
+102
View File
@@ -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;
}
+2 -2
View File
@@ -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 ? '是' : '否'),
+7 -12
View File
@@ -15,9 +15,9 @@
<!-- 空状态 -->
<view wx:if="{{status === 'idle'}}" class="stage-mask">
<view class="empty-icon">🎬</view>
<view class="empty-title">Spine 动画预览</view>
<view class="empty-sub">支持 Spine 4.2 导出的 .skel / .atlas / .png</view>
<view class="empty-tip">把三个文件发到「文件传输助手」后在下方选择;真机可一次多选,PC 可分多次选</view>
<view class="empty-title">Spine 画面预览</view>
<view class="empty-sub">小程序版本可支持 Spine 4.2 导出的 .skel / .atlas / .png</view>
<view class="empty-tip">先把 .skel / .atlas / 图片发到「文件传输助手」,再在下方一次选中即可;文件只在本地解析,不会上传</view>
</view>
<!-- 加载中 -->
@@ -39,17 +39,12 @@
<!-- ===== 控制面板 ===== -->
<view class="sheet">
<view class="grabber"></view>
<!-- 文件 -->
<view class="section">
<view class="section-title">文件</view>
<button class="pick-btn" bindtap="chooseFiles">
<text class="pick-icon">📁</text>
<text class="pick-text">选择文件</text>
<text class="pick-hint">可多选</text>
<text class="pick-text">{{pickedCount ? '重新选择文件' : '选择文件'}}</text>
</button>
<view class="pick-sub">可一次选中多个文件 · .skel / .atlas / .png</view>
<view wx:if="{{pickedCount}}" class="chips">
<view wx:for="{{pickedFiles}}" wx:key="id" class="chip chip-{{item.kind}}">
@@ -58,7 +53,7 @@
</view>
</view>
<view wx:if="{{missingText}}" class="missing">● {{missingText}}</view>
<view wx:if="{{missingText}}" class="missing">{{missingText}}</view>
<view wx:if="{{pickedCount}}" class="link" bindtap="clearFiles">清空已选</view>
</view>
@@ -90,7 +85,7 @@
<slider
class="param-slider"
min="0.1" max="3" step="0.1" value="{{speed}}"
activeColor="#4a6cf7" backgroundColor="#e9ecf5" block-color="#4a6cf7" block-size="16"
activeColor="#f2353b" backgroundColor="#2c2c35" block-color="#f2353b" block-size="16"
bindchange="onSpeedChange"
/>
<text class="param-val">{{speed}}x</text>
+55 -66
View File
@@ -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);
}
+13 -4
View File
@@ -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": {}
}
}
+1 -1
View File
@@ -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,
+12
View File
@@ -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 }
+19
View File
@@ -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: '',
},
}
+14
View File
@@ -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 }
+241
View File
@@ -0,0 +1,241 @@
// MySQL 连接池、建表、字段备注同步
// 表结构写成数据(下面的 TABLES),建表语句和「备注同步」都由它生成,不会两处写法对不上
const fileConfig = require('./read-config.js')
const { COPY } = require('./copy.js')
const settings = {
host: process.env.PAY_DB_HOST || (fileConfig.db && fileConfig.db.host) || '127.0.0.1',
port: Number(process.env.PAY_DB_PORT || (fileConfig.db && fileConfig.db.port) || 3306),
user: process.env.PAY_DB_USER || (fileConfig.db && fileConfig.db.user) || '',
password: process.env.PAY_DB_PASSWORD || (fileConfig.db && fileConfig.db.password) || '',
database: process.env.PAY_DB_NAME || (fileConfig.db && fileConfig.db.database) || '',
}
// 默认:配了库名和账号就走 MySQL,否则继续用 JSONL 文件
// PAY_STORE=jsonl / mysql 可以强制指定(自测强制用文件,不碰你的库)
const useMysql = process.env.PAY_STORE ? process.env.PAY_STORE === 'mysql' : Boolean(settings.database && settings.user)
const TABLES = {
orders: {
comment: '订单表,金额单位分',
columns: [
{ name: 'out_trade_no', type: 'VARCHAR(32)', notNull: true, comment: '业务单号,幂等键' },
{ name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' },
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' },
{ name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' },
{ name: 'amount', type: 'INT', notNull: true, default: '0', comment: '下单金额(分)' },
{ name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'created'", comment: 'created 已下单 delivered 已发货' },
{ name: 'wx_order_id', type: 'VARCHAR(64)', comment: '平台单号,唯一' },
{ name: 'paid_fee', type: 'INT', comment: '实付金额(分)' },
{ name: 'notified', type: 'TINYINT', notNull: true, default: '0', comment: '已通知平台发货' },
{ name: 'refunded', type: 'TINYINT', notNull: true, default: '0', comment: '已退款' },
{ name: 'refund_order_id', type: 'VARCHAR(32)', comment: '退款单号' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '下单时间' },
{ name: 'delivered_at', type: 'DATETIME(3)', comment: '发货时间' },
{ name: 'refunded_at', type: 'DATETIME(3)', comment: '退款时间' },
],
keys: [
'PRIMARY KEY (out_trade_no)',
'UNIQUE KEY uniq_wx_order (wx_order_id)',
'KEY idx_openid (openid)',
'KEY idx_pending (refunded, created_at)',
],
},
codes: {
comment: '激活码表,退款只作废不删行',
columns: [
{ name: 'code', type: 'VARCHAR(24)', notNull: true, comment: '激活码,主键' },
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID(等级)' },
{ name: 'out_trade_no', type: 'VARCHAR(32)', comment: '绑定订单号,唯一' },
{ name: 'openid', type: 'VARCHAR(64)', comment: '归属用户 openid' },
{ name: 'env', type: 'TINYINT', notNull: true, default: '0', comment: '0 现网 1 沙箱' },
{ name: 'status', type: 'VARCHAR(16)', notNull: true, default: "'issued'", comment: 'issued 已发出 revoked 已作废' },
{ name: 'batch', type: 'VARCHAR(32)', comment: '批次号,备用' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '生成时间' },
{ name: 'bound_at', type: 'DATETIME(3)', comment: '绑定时间' },
{ name: 'used_at', type: 'DATETIME(3)', comment: '核销时间,暂无核销' },
{ name: 'revoked_at', type: 'DATETIME(3)', comment: '作废时间' },
],
keys: ['PRIMARY KEY (code)', 'UNIQUE KEY uniq_order (out_trade_no)', 'KEY idx_openid (openid)'],
},
goods: {
comment: '道具价目表,价格需与 MP 后台一致',
columns: [
{ name: 'product_id', type: 'VARCHAR(32)', notNull: true, comment: '道具 ID' },
{ name: 'name', type: 'VARCHAR(64)', notNull: true, comment: '显示名' },
{ name: 'price', type: 'INT', notNull: true, comment: '单价(分)' },
{ name: 'enabled', type: 'TINYINT', notNull: true, default: '1', comment: '0 下架 1 在售' },
{ name: 'admin_only', type: 'TINYINT', notNull: true, default: '0', comment: '1 仅管理员可购买' },
{ name: 'note', type: 'VARCHAR(255)', comment: '购买页提示文案,例如优惠信息' },
{ name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' },
],
keys: ['PRIMARY KEY (product_id)'],
},
users: {
comment: '用户表,激活码看 codes 表',
columns: [
{ name: 'openid', type: 'VARCHAR(64)', notNull: true, comment: '用户 openid' },
{ name: 'created_at', type: 'DATETIME(3)', notNull: true, comment: '首次使用时间' },
{ name: 'last_seen_at', type: 'DATETIME(3)', notNull: true, comment: '最近一次活跃' },
{ name: 'is_admin', type: 'TINYINT', notNull: true, default: '0', comment: '0 普通 1 管理员' },
],
keys: ['PRIMARY KEY (openid)', 'KEY idx_created (created_at)'],
},
settings: {
comment: '页面文案配置,改完最多 1 分钟生效',
columns: [
{ name: 'cfg_key', type: 'VARCHAR(64)', notNull: true, comment: '配置项,如 buy.title' },
{ name: 'cfg_value', type: 'VARCHAR(255)', notNull: true, comment: '文案内容' },
{ name: 'remark', type: 'VARCHAR(255)', comment: '这个配置显示在哪里' },
{ name: 'updated_at', type: 'DATETIME(3)', notNull: true, comment: '修改时间' },
],
keys: ['PRIMARY KEY (cfg_key)'],
},
}
let pool = null
function getPool() {
if (pool) return pool
let mysql
try {
mysql = require('mysql2/promise')
} catch (err) {
throw new Error('没装 mysql2,请先在 server 目录执行 npm install')
}
pool = mysql.createPool(
Object.assign({}, settings, {
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 5,
supportBigNumbers: true,
bigNumberStrings: false, // 大整数当数字返回,别变字符串
})
)
return pool
}
const quoteComment = (text) => String(text).replace(/'/g, "''")
function columnSql(column) {
return [
`\`${column.name}\` ${column.type}`,
column.notNull ? 'NOT NULL' : 'NULL',
column.default === undefined ? '' : `DEFAULT ${column.default}`,
`COMMENT '${quoteComment(column.comment)}'`,
]
.filter(Boolean)
.join(' ')
}
// 早期版本用 BIGINT 存毫秒时间戳,这里自动换成 DATETIME(3),跑一次就没了
async function upgradeTimeColumns(db) {
const [columns] = await db.query(
`SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND DATA_TYPE = 'bigint' AND COLUMN_NAME LIKE '%\\_at'`,
[settings.database]
)
for (const column of columns) {
const table = column.TABLE_NAME
const name = column.COLUMN_NAME
await db.query(`ALTER TABLE \`${table}\` ADD COLUMN \`${name}_new\` DATETIME(3) NULL`)
await db.query(
`UPDATE \`${table}\` SET \`${name}_new\` = FROM_UNIXTIME(\`${name}\` / 1000) WHERE \`${name}\` IS NOT NULL`
)
await db.query(`ALTER TABLE \`${table}\` DROP COLUMN \`${name}\``)
await db.query(`ALTER TABLE \`${table}\` CHANGE \`${name}_new\` \`${name}\` DATETIME(3) NULL`)
}
return columns.length
}
// 表已经存在时建表语句不会生效,所以单独把「字段备注 + 表备注」同步一遍
// ponytail: 一个 ALTER 会把整表重建,数据量小(几百行)无所谓;真到几十万行再改成按需比对
async function syncComments(db, name, table) {
const [rows] = await db.query(
`SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
const current = new Map(rows.map((row) => [row.COLUMN_NAME, row.COLUMN_COMMENT]))
const stale = table.columns.filter((column) => current.has(column.name) && current.get(column.name) !== column.comment)
if (stale.length) {
await db.query(`ALTER TABLE \`${name}\` ${stale.map((column) => 'MODIFY COLUMN ' + columnSql(column)).join(', ')}`)
}
const [tableRows] = await db.query(
`SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
if (tableRows.length && tableRows[0].TABLE_COMMENT !== table.comment) {
await db.query(`ALTER TABLE \`${name}\` COMMENT = '${quoteComment(table.comment)}'`)
}
return stale.length
}
// 表已存在时建表语句改不了结构,所以按声明把缺的列补上(只加列,不动已有数据)
// 注意:以后新增 NOT NULL 列一定要带 DEFAULT,否则在非空表上会加不进去
async function addMissingColumns(db, name, table) {
const [rows] = await db.query(
`SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
[settings.database, name]
)
const existing = new Set(rows.map((row) => row.COLUMN_NAME))
const missing = table.columns.filter((column) => !existing.has(column.name))
for (const column of missing) {
await db.query(`ALTER TABLE \`${name}\` ADD COLUMN ${columnSql(column)}`)
}
return missing.length
}
async function initSchema(goodsSeed) {
const db = getPool()
for (const [name, table] of Object.entries(TABLES)) {
const columns = table.columns.map(columnSql).join(', ')
await db.query(
`CREATE TABLE IF NOT EXISTS \`${name}\` (${columns}, ${table.keys.join(', ')}) ` +
`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='${quoteComment(table.comment)}'`
)
}
let added = 0
for (const [name, table] of Object.entries(TABLES)) added += await addMissingColumns(db, name, table)
if (added) console.log(`已给已有表补上 ${added} 个字段`)
const converted = await upgradeTimeColumns(db)
if (converted) console.log(`已把 ${converted} 个时间列从时间戳改成可读时间`)
let commented = 0
for (const [name, table] of Object.entries(TABLES)) commented += await syncComments(db, name, table)
if (commented) console.log(`已同步 ${commented} 个字段备注`)
// 价目表空的时候用代码里的价目表播种一次;之后改价直接改库(记得 MP 后台一起改)
const [rows] = await db.query('SELECT COUNT(*) AS n FROM goods')
if (rows[0].n === 0) {
for (const productId of Object.keys(goodsSeed)) {
const goods = goodsSeed[productId]
await db.query('INSERT IGNORE INTO goods (product_id, name, price, enabled, updated_at) VALUES (?, ?, ?, 1, ?)', [
String(productId),
goods.name,
goods.price,
new Date(),
])
}
}
// admin_only 以代码里的价目表为准(价格、名称还是以库里为准,方便改价不发版)
for (const productId of Object.keys(goodsSeed)) {
await db.query('UPDATE goods SET admin_only = ? WHERE product_id = ?', [
goodsSeed[productId].adminOnly ? 1 : 0,
String(productId),
])
}
// 文案配置:缺哪个补哪个,只刷新备注列,不覆盖你已经改过的 cfg_value
for (const [key, item] of Object.entries(COPY)) {
await db.query(
`INSERT INTO settings (cfg_key, cfg_value, remark, updated_at) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE remark = VALUES(remark)`,
[key, item.value, item.comment, new Date()]
)
}
return db
}
module.exports = { settings, useMysql, getPool, initSchema }
+582
View File
@@ -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}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${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 = '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>'
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(`<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${err.message}]]></ErrMsg></xml>`)
}
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)
})
+62
View File
@@ -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)
})
+14
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
// 只负责把 config.js 读出来(没有就返回空对象),避免 index.js / db.js 互相 require
try {
module.exports = require('./config.js')
} catch (err) {
module.exports = {}
}
+47
View File
@@ -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 }
+154
View File
@@ -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,
}),
}
+230
View File
@@ -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 }
},
}
+5
View File
@@ -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')
+107
View File
@@ -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)
})
+457
View File
@@ -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}&timestamp=${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 =
'<xml>' +
'<ToUserName><![CDATA[gh_test]]></ToUserName>' +
'<MsgType><![CDATA[event]]></MsgType>' +
'<Event><![CDATA[xpay_goods_deliver_notify]]></Event>' +
`<OpenId><![CDATA[${OPENID}]]></OpenId>` +
`<OutTradeNo><![CDATA[${pushOrder.outTradeNo}]]></OutTradeNo>` +
'<Env>0</Env>' +
'<WeChatPayInfo><MchOrderNo><![CDATA[wx-push-1]]></MchOrderNo></WeChatPayInfo>' +
'<GoodsInfo><ProductId><![CDATA[10002]]></ProductId><Quantity>1</Quantity></GoodsInfo>' +
'</xml>'
const notifyCallsBeforePush = seen.notifyCalls
const pushRes = await rawRequest(port, '/pay/notify' + pushQuery(), { body: pushXml, method: 'POST' })
assert.ok(/<ErrCode>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(/<ErrCode>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)
})
+40
View File
@@ -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 }