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