小程序接入支付功能
This commit is contained in:
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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
|
||||
# 虚拟支付密钥,不要入库
|
||||
server/config.js
|
||||
|
||||
# 订单与激活码运行数据
|
||||
server/data/
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "开通使用",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "SpineViewer",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? '是' : '否'),
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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: '',
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 只负责把 config.js 读出来(没有就返回空对象),避免 index.js / db.js 互相 require
|
||||
try {
|
||||
module.exports = require('./config.js')
|
||||
} catch (err) {
|
||||
module.exports = {}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
@@ -0,0 +1,457 @@
|
||||
// 用法:node server/test.js
|
||||
// 1) 纯函数断言(HMAC 官方向量、价目表、signData 字段、单号格式、激活码格式)
|
||||
// 2) 起一个假的微信接口 + 真的 server/index.js,走一遍下单、发货、我的
|
||||
const assert = require('assert')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
const { GOODS, hmacSha256, newOutTradeNo, buildSignData, paySig } = require('./sign.js')
|
||||
const { newActivationCode } = require('./code.js')
|
||||
|
||||
const APP_KEY = 'test-appkey'
|
||||
const SANDBOX_APP_KEY = 'test-sandbox-appkey'
|
||||
const OFFER_ID = '1450663371'
|
||||
const SESSION_KEY = 'sk-test'
|
||||
const OPENID = 'oTestUser'
|
||||
const PUSH_TOKEN = 'test-push-token'
|
||||
|
||||
// ---------- 1. 纯函数 ----------
|
||||
assert.strictEqual(
|
||||
hmacSha256(Buffer.alloc(20, 0x0b), 'Hi There'), // RFC 4231 官方向量
|
||||
'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
{ 90001: GOODS[90001].price, 10001: GOODS[10001].price, 10002: GOODS[10002].price, 10000: GOODS[10000].price },
|
||||
{ 90001: 10, 10001: 3900, 10002: 6900, 10000: 9900 }
|
||||
)
|
||||
|
||||
const sample = buildSignData({ offerId: OFFER_ID, productId: 10000, price: 9900, outTradeNo: 'T1700ABCDEF', attach: 'test-10000' })
|
||||
assert.deepStrictEqual(JSON.parse(sample), {
|
||||
offerId: OFFER_ID,
|
||||
buyQuantity: 1,
|
||||
env: 0,
|
||||
currencyType: 'CNY',
|
||||
productId: '10000',
|
||||
goodsPrice: 9900,
|
||||
outTradeNo: 'T1700ABCDEF',
|
||||
attach: 'test-10000',
|
||||
})
|
||||
assert.notStrictEqual(
|
||||
paySig(APP_KEY, 'requestVirtualPayment', sample),
|
||||
paySig(APP_KEY, 'requestVirtualPayment', sample + ' '),
|
||||
'签名必须认原文'
|
||||
)
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const no = newOutTradeNo(1700000000000 + i)
|
||||
assert.ok(no.length >= 8 && no.length <= 32, '单号长度越界: ' + no)
|
||||
assert.ok(/^[0-9A-Za-z_\-|*@]+$/.test(no), '单号含非法字符: ' + no)
|
||||
assert.ok(!no.startsWith('_'), '单号不能以下划线开头: ' + no)
|
||||
}
|
||||
|
||||
// 激活码:4 组 4 位、不含容易看错的 I/L/O/U、不重复
|
||||
const codes = new Set()
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const made = newActivationCode()
|
||||
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(made), '激活码格式不对: ' + made)
|
||||
codes.add(made)
|
||||
}
|
||||
assert.strictEqual(codes.size, 1000, '激活码不应重复')
|
||||
|
||||
// ---------- 2. 起假微信接口 ----------
|
||||
const seen = { notifyCalls: 0, unpaid: null, failNotify: false }
|
||||
const fakeWx = http.createServer((req, res) => {
|
||||
const [urlPath, query] = req.url.split('?')
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => (raw += chunk))
|
||||
req.on('end', () => {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
const reply = (obj) => res.end(JSON.stringify(obj))
|
||||
if (urlPath === '/sns/jscode2session') {
|
||||
const jsCode = new URLSearchParams(query).get('js_code')
|
||||
seen.code = jsCode
|
||||
return reply({ openid: jsCode === 'code-other' ? 'oOtherUser' : OPENID, session_key: SESSION_KEY })
|
||||
}
|
||||
if (urlPath === '/cgi-bin/token') return reply({ access_token: 'tok-test', expires_in: 7200 })
|
||||
if (urlPath === '/xpay/query_order') {
|
||||
seen.queryBody = raw
|
||||
seen.querySig = new URLSearchParams(query).get('pay_sig')
|
||||
seen.queryToken = new URLSearchParams(query).get('access_token')
|
||||
const { order_id: orderId, env } = JSON.parse(raw)
|
||||
const status = orderId === seen.unpaid ? 1 : 2 // 1 = 已创建未支付
|
||||
return reply({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
order: { order_id: orderId, status, paid_fee: 10, left_fee: 10, wx_order_id: `wx-${env}-${orderId}` },
|
||||
})
|
||||
}
|
||||
if (urlPath === '/xpay/notify_provide_goods') {
|
||||
seen.notifyCalls++
|
||||
seen.notifyBody = raw
|
||||
seen.notifyToken = new URLSearchParams(query).get('access_token')
|
||||
seen.notifySig = new URLSearchParams(query).get('pay_sig')
|
||||
if (seen.failNotify) return reply({ errcode: 44002, errmsg: 'empty post data' })
|
||||
return reply({ errcode: 0, errmsg: 'ok' })
|
||||
}
|
||||
if (urlPath === '/xpay/refund_order') {
|
||||
seen.refundBody = raw
|
||||
seen.refundSig = new URLSearchParams(query).get('pay_sig')
|
||||
return reply({ errcode: 0, errmsg: 'ok', refund_order_id: JSON.parse(raw).refund_order_id })
|
||||
}
|
||||
res.statusCode = 404
|
||||
reply({ errcode: -1, errmsg: 'no such path: ' + urlPath })
|
||||
})
|
||||
})
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)))
|
||||
}
|
||||
|
||||
function fetchJson(port, urlPath, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = payload === undefined ? null : JSON.stringify(payload)
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port, path: urlPath, method: body ? 'POST' : 'GET', headers: body ? { 'Content-Type': 'application/json' } : {} },
|
||||
(res) => {
|
||||
let raw = ''
|
||||
res.on('data', (chunk) => (raw += chunk))
|
||||
res.on('end', () => resolve(JSON.parse(raw)))
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.end(body || undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// 需要看原始响应的场合(推送接口回的是 XML / 纯文本)
|
||||
function rawRequest(port, urlPath, options) {
|
||||
const opts = options || {}
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port, path: urlPath, method: opts.method || (opts.body ? 'POST' : 'GET') },
|
||||
(res) => {
|
||||
let raw = ''
|
||||
res.on('data', (chunk) => (raw += chunk))
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: raw }))
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.end(opts.body || undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// 消息推送的签名:sha1(sort([token, timestamp, nonce]).join(''))
|
||||
function pushQuery(token) {
|
||||
const timestamp = '1700000000'
|
||||
const nonce = 'abc123'
|
||||
const signature = crypto.createHash('sha1').update([token || PUSH_TOKEN, timestamp, nonce].sort().join('')).digest('hex')
|
||||
return `?signature=${signature}×tamp=${timestamp}&nonce=${nonce}`
|
||||
}
|
||||
|
||||
function startServer(env) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [path.join(__dirname, 'index.js')], {
|
||||
env: Object.assign({}, process.env, env),
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
})
|
||||
let out = ''
|
||||
child.stdout.on('data', (chunk) => {
|
||||
out += chunk
|
||||
const port = (out.match(/http:\/\/localhost:(\d+)/) || [])[1]
|
||||
if (port) resolve({ port: Number(port), child })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 轮询等某个单号出现激活码(定时补发货是异步的)
|
||||
async function waitForCode(port, outTradeNo, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
||||
const hit = mine.list.find((item) => item.outTradeNo === outTradeNo)
|
||||
if (hit) return hit
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const wxPort = await listen(fakeWx)
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpay-data-'))
|
||||
// 先放一条管理员记录:沙箱、补单道具、退款这些管理员功能要靠它
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'users.jsonl'),
|
||||
JSON.stringify({ openid: OPENID, createdAt: 1700000000000, lastSeenAt: 1700000000000, isAdmin: true }) + '\n'
|
||||
)
|
||||
// 文案配置:只覆盖标题,其它项应该退回代码里的默认值
|
||||
fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ 'buy.title': '限时特惠', 'buy.button': '立即开通' }))
|
||||
const serverEnv = {
|
||||
PORT: '0',
|
||||
PAY_POLL_MS: '0', // 主流程把定时任务关掉,免得跟断言的时序打架
|
||||
PAY_STORE: 'jsonl', // 自测一律用文件后端,绝不碰你的数据库
|
||||
WX_API_ORIGIN: `http://127.0.0.1:${wxPort}`,
|
||||
DATA_DIR: dataDir,
|
||||
PAY_APP_ID: 'wxtest',
|
||||
PAY_APP_SECRET: 'secret-test',
|
||||
PAY_OFFER_ID: OFFER_ID,
|
||||
PAY_APP_KEY: APP_KEY,
|
||||
PAY_SANDBOX_APP_KEY: SANDBOX_APP_KEY,
|
||||
PAY_PUSH_TOKEN: PUSH_TOKEN,
|
||||
}
|
||||
const { port, child } = await startServer(serverEnv)
|
||||
|
||||
try {
|
||||
assert.deepStrictEqual(await fetchJson(port, '/health'), {
|
||||
ok: true,
|
||||
missing: [],
|
||||
sandbox: true,
|
||||
goods: ['90001', '10001', '10002', '10000'], // 价格从低到高
|
||||
store: 'jsonl',
|
||||
routes: ['/pay/order', '/pay/deliver', '/pay/my', '/pay/pending', '/pay/refund', '/pay/me'],
|
||||
})
|
||||
|
||||
// 下单:签名要能被独立重算出来
|
||||
const paid = await fetchJson(port, '/pay/order', { productId: '10000', code: 'code-1' })
|
||||
assert.strictEqual(paid.ok, true, JSON.stringify(paid))
|
||||
assert.strictEqual(seen.code, 'code-1')
|
||||
const { signData, paySig: gotPaySig, signature, mode } = paid.payData
|
||||
assert.strictEqual(mode, 'short_series_goods')
|
||||
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
|
||||
assert.strictEqual(signature, hmacSha256(SESSION_KEY, signData))
|
||||
const parsed = JSON.parse(signData)
|
||||
assert.strictEqual(parsed.offerId, OFFER_ID)
|
||||
assert.strictEqual(parsed.productId, '10000')
|
||||
assert.strictEqual(parsed.goodsPrice, 9900)
|
||||
assert.strictEqual(parsed.env, 0)
|
||||
assert.strictEqual(gotPaySig, paySig(APP_KEY, 'requestVirtualPayment', signData))
|
||||
assert.strictEqual(paid.outTradeNo, parsed.outTradeNo)
|
||||
|
||||
// 页面重进后能找回还没发货的单(补发货用)
|
||||
const pending = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
||||
assert.strictEqual(pending.outTradeNo, paid.outTradeNo)
|
||||
// 半路放弃、根本没付款的单,不该被当成「待发货」提示
|
||||
seen.unpaid = paid.outTradeNo
|
||||
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
|
||||
seen.unpaid = null
|
||||
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-other' }), { ok: true, outTradeNo: '' })
|
||||
|
||||
// 沙箱单:env=1、用沙箱 AppKey 签名,和现网签名不能一样
|
||||
const sandbox = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: 1 })
|
||||
assert.strictEqual(sandbox.ok, true, JSON.stringify(sandbox))
|
||||
const sandboxSignData = sandbox.payData.signData
|
||||
assert.strictEqual(JSON.parse(sandboxSignData).env, 1)
|
||||
assert.strictEqual(JSON.parse(sandboxSignData).goodsPrice, 10)
|
||||
assert.strictEqual(sandbox.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', sandboxSignData))
|
||||
assert.notStrictEqual(sandbox.payData.paySig, paySig(APP_KEY, 'requestVirtualPayment', sandboxSignData))
|
||||
|
||||
// 前端万一把 env 传成字符串 '1',也必须按沙箱签,绝不能悄悄走现网真扣钱
|
||||
const stringEnv = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-1', env: '1' })
|
||||
assert.strictEqual(JSON.parse(stringEnv.payData.signData).env, 1)
|
||||
assert.strictEqual(stringEnv.payData.paySig, paySig(SANDBOX_APP_KEY, 'requestVirtualPayment', stringEnv.payData.signData))
|
||||
|
||||
// 未发布的道具 ID 不该能下单
|
||||
const bad = await fetchJson(port, '/pay/order', { productId: '12345', code: 'code-1' })
|
||||
assert.strictEqual(bad.ok, false)
|
||||
assert.ok(/未知道具/.test(bad.error), bad.error)
|
||||
|
||||
// 我是谁 + 我能看到哪些道具:管理员才多看到「补单专用」(0.1 元测试价)
|
||||
const me = await fetchJson(port, '/pay/me', { code: 'code-1' })
|
||||
assert.strictEqual(me.isAdmin, true)
|
||||
assert.deepStrictEqual(
|
||||
me.goods.map((item) => [item.productId, item.price]),
|
||||
[
|
||||
['90001', 10],
|
||||
['10001', 3900],
|
||||
['10002', 6900],
|
||||
['10000', 9900],
|
||||
]
|
||||
)
|
||||
assert.ok(me.goods.find((item) => item.productId === '90001').note, '备注文案要能带回小程序')
|
||||
// 页面文案:库里有就用库里的,没有就退回默认值
|
||||
assert.strictEqual(me.copy['buy.title'], '限时特惠')
|
||||
assert.strictEqual(me.copy['buy.button'], '立即开通') // 库里的覆盖生效
|
||||
assert.strictEqual(me.copy['tab.buy'], '开通')
|
||||
const normalMe = await fetchJson(port, '/pay/me', { code: 'code-other' })
|
||||
assert.strictEqual(normalMe.isAdmin, false)
|
||||
assert.ok(!normalMe.goods.some((item) => item.productId === '90001'), '普通用户不该看到补单专用')
|
||||
// 普通用户直接调接口也绕不过去
|
||||
const sneakyGoods = await fetchJson(port, '/pay/order', { productId: '90001', code: 'code-other' })
|
||||
assert.strictEqual(sneakyGoods.ok, false)
|
||||
assert.ok(/管理员/.test(sneakyGoods.error), sneakyGoods.error)
|
||||
const sneakySandbox = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-other', env: 1 })
|
||||
assert.strictEqual(sneakySandbox.ok, false)
|
||||
assert.ok(/沙箱/.test(sneakySandbox.error), sneakySandbox.error)
|
||||
|
||||
// 查单该用 pay_sig + access_token
|
||||
seen.unpaid = paid.outTradeNo
|
||||
const unpaid = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(unpaid.ok, true)
|
||||
assert.strictEqual(unpaid.paid, false, '没付款就不能发货')
|
||||
assert.strictEqual(unpaid.code, undefined)
|
||||
assert.strictEqual(seen.notifyCalls, 0, '没付款不该通知平台发货')
|
||||
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 0, order_id: paid.outTradeNo })
|
||||
assert.strictEqual(seen.querySig, paySig(APP_KEY, '/xpay/query_order', seen.queryBody))
|
||||
assert.strictEqual(seen.queryToken, 'tok-test')
|
||||
|
||||
// 付款后发货:发一张码 + 通知平台
|
||||
seen.unpaid = null
|
||||
const delivered = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(delivered.paid, true)
|
||||
assert.ok(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){3}$/.test(delivered.code), delivered.code)
|
||||
assert.strictEqual(delivered.productName, '永久激活码')
|
||||
assert.strictEqual(delivered.notified, true)
|
||||
assert.deepStrictEqual(JSON.parse(seen.notifyBody), { order_id: paid.outTradeNo, wx_order_id: 'wx-0-' + paid.outTradeNo, env: 0 })
|
||||
assert.strictEqual(seen.notifyToken, 'tok-test')
|
||||
assert.strictEqual(seen.notifyCalls, 1)
|
||||
// 平台要求 pay_sig,缺了会回 268490002
|
||||
assert.strictEqual(seen.notifySig, paySig(APP_KEY, '/xpay/notify_provide_goods', seen.notifyBody))
|
||||
|
||||
// 幂等:同一单再点一次,还是同一张码,也不再重复通知平台
|
||||
const again = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(again.code, delivered.code)
|
||||
assert.strictEqual(seen.notifyCalls, 1)
|
||||
|
||||
// 别人的单不能领
|
||||
const stolen = await fetchJson(port, '/pay/deliver', { outTradeNo: paid.outTradeNo, code: 'code-other' })
|
||||
assert.strictEqual(stolen.ok, false)
|
||||
assert.ok(/不属于当前用户/.test(stolen.error), stolen.error)
|
||||
|
||||
// 我的:本人能看到,别人看不到
|
||||
const sandboxDelivered = await fetchJson(port, '/pay/deliver', { outTradeNo: sandbox.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(sandboxDelivered.paid, true, JSON.stringify(sandboxDelivered))
|
||||
assert.deepStrictEqual(JSON.parse(seen.queryBody), { openid: OPENID, env: 1, order_id: sandbox.outTradeNo })
|
||||
assert.strictEqual(seen.querySig, paySig(SANDBOX_APP_KEY, '/xpay/query_order', seen.queryBody))
|
||||
// 沙箱单不通知平台:通知次数还停在前面的现网单上
|
||||
assert.strictEqual(seen.notifyCalls, 1, '沙箱单不该调 notify_provide_goods')
|
||||
assert.strictEqual(sandboxDelivered.notified, true)
|
||||
assert.strictEqual(sandboxDelivered.productName, '补单专用')
|
||||
|
||||
const mine = await fetchJson(port, '/pay/my', { code: 'code-1' })
|
||||
assert.deepStrictEqual(
|
||||
mine.list.map((item) => [item.code, item.productName]).sort(),
|
||||
[
|
||||
[delivered.code, '永久激活码'],
|
||||
[sandboxDelivered.code, '补单专用'],
|
||||
].sort()
|
||||
)
|
||||
const others = await fetchJson(port, '/pay/my', { code: 'code-other' })
|
||||
assert.deepStrictEqual(others.list, [])
|
||||
|
||||
// 发过货的单不再出现在待发货里
|
||||
const pendingAfter = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
||||
assert.notStrictEqual(pendingAfter.outTradeNo, paid.outTradeNo)
|
||||
assert.notStrictEqual(pendingAfter.outTradeNo, sandbox.outTradeNo)
|
||||
|
||||
// 平台拒绝通知时:码照样给用户,但要把平台原话带回去,方便直接在手机上看到原因
|
||||
// 先把试探 env 的那笔也发掉,保证后面只剩一笔未完成单,断言不被时序搅浑
|
||||
await fetchJson(port, '/pay/deliver', { outTradeNo: stringEnv.outTradeNo, code: 'code-1' })
|
||||
const failOrder = await fetchJson(port, '/pay/order', { productId: '10001', code: 'code-1' })
|
||||
seen.failNotify = true
|
||||
const failed = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(failed.paid, true)
|
||||
assert.ok(failed.code, '平台通知失败也要把激活码给用户')
|
||||
assert.strictEqual(failed.notified, false)
|
||||
assert.ok(/44002/.test(failed.notifyError), failed.notifyError)
|
||||
seen.failNotify = false
|
||||
|
||||
// 码发了但平台没确认的单,要能找回来继续重试通知
|
||||
const pendingRetry = await fetchJson(port, '/pay/pending', { code: 'code-1' })
|
||||
assert.strictEqual(pendingRetry.outTradeNo, failOrder.outTradeNo)
|
||||
const retried = await fetchJson(port, '/pay/deliver', { outTradeNo: failOrder.outTradeNo, code: 'code-1' })
|
||||
assert.strictEqual(retried.code, failed.code, '重试要给回同一张码')
|
||||
assert.strictEqual(retried.notified, true)
|
||||
// 通知成功后就不再提示了
|
||||
assert.deepStrictEqual(await fetchJson(port, '/pay/pending', { code: 'code-1' }), { ok: true, outTradeNo: '' })
|
||||
|
||||
// ---------- 发货推送 ----------
|
||||
// 配置 URL 时的 GET 校验:签名对就原样回 echostr,签名错要 403
|
||||
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery() + '&echostr=hello-123')).body, 'hello-123')
|
||||
assert.strictEqual((await rawRequest(port, '/pay/notify' + pushQuery('wrong-token') + '&echostr=hello')).status, 403)
|
||||
|
||||
const pushOrder = await fetchJson(port, '/pay/order', { productId: '10002', code: 'code-1' })
|
||||
const pushXml =
|
||||
'<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)
|
||||
})
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user