feat: add wechat mini program virtual payment
This commit is contained in:
@@ -109,6 +109,8 @@ WECHAT_MOCK_USER_ID="wx-mock-user"
|
|||||||
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
||||||
WECHAT_MOCK_DISPLAY_NAME="微信旅人"
|
WECHAT_MOCK_DISPLAY_NAME="微信旅人"
|
||||||
WECHAT_MOCK_AVATAR_URL=""
|
WECHAT_MOCK_AVATAR_URL=""
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
||||||
|
|
||||||
# Model name for chat completions.
|
# Model name for chat completions.
|
||||||
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible
|
|||||||
GENARRATIVE_LLM_BASE_URL=
|
GENARRATIVE_LLM_BASE_URL=
|
||||||
GENARRATIVE_LLM_API_KEY=
|
GENARRATIVE_LLM_API_KEY=
|
||||||
GENARRATIVE_LLM_MODEL=
|
GENARRATIVE_LLM_MODEL=
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||||
|
|||||||
2
deploy/env/api-server.env.example
vendored
2
deploy/env/api-server.env.example
vendored
@@ -109,6 +109,8 @@ WECHAT_AUTHORIZE_ENDPOINT=https://open.weixin.qq.com/connect/qrconnect
|
|||||||
WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token
|
WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token
|
||||||
WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo
|
WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo
|
||||||
WECHAT_STATE_TTL_MINUTES=15
|
WECHAT_STATE_TTL_MINUTES=15
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||||
|
|
||||||
ALIYUN_OSS_BUCKET=
|
ALIYUN_OSS_BUCKET=
|
||||||
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。
|
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。
|
||||||
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
||||||
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
||||||
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端微信通知或查询确认后写入订单为准。
|
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。
|
||||||
- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
|
- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
|
||||||
|
|
||||||
## 关键文件
|
## 关键文件
|
||||||
@@ -30,6 +30,8 @@ WECHAT_PAY_PROVIDER=real
|
|||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId>
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId>
|
||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
|
||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token>
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
|
||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
|||||||
- `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。
|
- `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。
|
||||||
- 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。
|
- 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。
|
||||||
- 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
|
- 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
|
||||||
|
- 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 `/api/profile/recharge/wechat/virtual-notify`。
|
||||||
|
- `WECHAT_MINIPROGRAM_MESSAGE_TOKEN` 和 `WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入;后端会先校验 `signature/msg_signature`,再用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。
|
||||||
|
- 安全模式下,GET 验证会直接返回解密后的 `echostr`;POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`。
|
||||||
|
|
||||||
## 验收命令
|
## 验收命令
|
||||||
|
|
||||||
@@ -59,5 +64,6 @@ npm run check:encoding
|
|||||||
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
||||||
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。
|
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。
|
||||||
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
||||||
|
- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify` 和 `xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`(JSON 入站回 JSON,XML 入站回 XML),错误时带具体 `ErrMsg` 便于微信侧重试与排障。
|
||||||
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
||||||
- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
|
- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。
|
// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。
|
||||||
// 示例:https://game.example.com/
|
// 示例:https://game.example.com/
|
||||||
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
|
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
|
||||||
const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world/';
|
const WEB_VIEW_ENTRY_URL = 'https://outfield-outlook-reprocess.ngrok-free.dev';
|
||||||
|
|
||||||
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
|
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
|
||||||
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
|
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
|
||||||
const API_BASE_URL = 'https://www.genarrative.world/';
|
const API_BASE_URL = 'https://outfield-outlook-reprocess.ngrok-free.dev';
|
||||||
|
|
||||||
// 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。
|
// 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。
|
||||||
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
|
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
|
||||||
|
|||||||
53
server-rs/Cargo.lock
generated
53
server-rs/Cargo.lock
generated
@@ -17,6 +17,17 @@ dependencies = [
|
|||||||
"pom",
|
"pom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -78,12 +89,15 @@ checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
|
|||||||
name = "api-server"
|
name = "api-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cbc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"image",
|
"image",
|
||||||
@@ -118,6 +132,7 @@ dependencies = [
|
|||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"shared-contracts",
|
"shared-contracts",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
@@ -351,6 +366,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -415,6 +439,15 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.60"
|
version = "1.2.60"
|
||||||
@@ -453,6 +486,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.16.3"
|
version = "0.16.3"
|
||||||
@@ -1461,6 +1504,16 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "insta"
|
name = "insta"
|
||||||
version = "1.47.2"
|
version = "1.47.2"
|
||||||
|
|||||||
@@ -89,16 +89,19 @@ shared-logging = { path = "crates/shared-logging", default-features = false }
|
|||||||
spacetime-client = { path = "crates/spacetime-client", default-features = false }
|
spacetime-client = { path = "crates/spacetime-client", default-features = false }
|
||||||
|
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
aes = "0.8"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
cbc = { version = "0.1", features = ["alloc"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
hex = "0.4"
|
||||||
image = { version = "0.25", default-features = false }
|
image = { version = "0.25", default-features = false }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
langchainrust = "0.2.18"
|
langchainrust = "0.2.18"
|
||||||
@@ -109,6 +112,7 @@ ring = "0.17"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
socket2 = "0.6"
|
socket2 = "0.6"
|
||||||
spacetimedb = "2.2.0"
|
spacetimedb = "2.2.0"
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes = { workspace = true }
|
||||||
async-stream = { workspace = true }
|
async-stream = { workspace = true }
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
cbc = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||||
http-body-util = { workspace = true }
|
http-body-util = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||||
@@ -44,6 +47,7 @@ hmac = { workspace = true }
|
|||||||
ring = { workspace = true }
|
ring = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha1 = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||||
shared-kernel = { workspace = true }
|
shared-kernel = { workspace = true }
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ use crate::{
|
|||||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||||
submit_visual_novel_message, update_visual_novel_work,
|
submit_visual_novel_message, update_visual_novel_work,
|
||||||
},
|
},
|
||||||
wechat_pay::handle_wechat_pay_notify,
|
wechat_pay::{
|
||||||
|
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
|
||||||
|
handle_wechat_virtual_payment_notify,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
@@ -70,6 +73,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/profile/recharge/wechat/notify",
|
"/api/profile/recharge/wechat/notify",
|
||||||
post(handle_wechat_pay_notify),
|
post(handle_wechat_pay_notify),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/recharge/wechat/virtual-notify",
|
||||||
|
get(handle_wechat_virtual_payment_message_push_verify)
|
||||||
|
.post(handle_wechat_virtual_payment_notify),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
||||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -94,13 +94,11 @@ pub async fn generate_character_visual(
|
|||||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
let settings = require_openai_image_settings(&state)?
|
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
&request_context,
|
||||||
&request_context,
|
Some(owner_user_id.clone()),
|
||||||
Some(owner_user_id.clone()),
|
Some(character_id.clone()),
|
||||||
Some(character_id.clone()),
|
);
|
||||||
)
|
|
||||||
;
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
|||||||
&model,
|
&model,
|
||||||
&prompt,
|
&prompt,
|
||||||
)?;
|
)?;
|
||||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
|
let settings = require_openai_image_settings(state)?
|
||||||
Some(owner_user_id.to_string()),
|
.with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone()));
|
||||||
Some(character_id.clone()),
|
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
state
|
state
|
||||||
.ai_task_service()
|
.ai_task_service()
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ pub struct AppConfig {
|
|||||||
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
|
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
|
||||||
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
|
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
|
||||||
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
||||||
|
pub wechat_mini_program_message_token: Option<String>,
|
||||||
|
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
|
||||||
pub wechat_mini_program_virtual_payment_env: u8,
|
pub wechat_mini_program_virtual_payment_env: u8,
|
||||||
pub oss_bucket: Option<String>,
|
pub oss_bucket: Option<String>,
|
||||||
pub oss_endpoint: Option<String>,
|
pub oss_endpoint: Option<String>,
|
||||||
@@ -244,6 +246,8 @@ impl Default for AppConfig {
|
|||||||
wechat_mini_program_virtual_payment_offer_id: None,
|
wechat_mini_program_virtual_payment_offer_id: None,
|
||||||
wechat_mini_program_virtual_payment_app_key: None,
|
wechat_mini_program_virtual_payment_app_key: None,
|
||||||
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
||||||
|
wechat_mini_program_message_token: None,
|
||||||
|
wechat_mini_program_message_encoding_aes_key: None,
|
||||||
wechat_mini_program_virtual_payment_env: 0,
|
wechat_mini_program_virtual_payment_env: 0,
|
||||||
oss_bucket: None,
|
oss_bucket: None,
|
||||||
oss_endpoint: None,
|
oss_endpoint: None,
|
||||||
@@ -598,8 +602,11 @@ impl AppConfig {
|
|||||||
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
|
||||||
config.wechat_mini_program_virtual_payment_sandbox_app_key =
|
config.wechat_mini_program_virtual_payment_sandbox_app_key =
|
||||||
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
|
||||||
if let Some(env) =
|
config.wechat_mini_program_message_token =
|
||||||
read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
|
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
|
||||||
|
config.wechat_mini_program_message_encoding_aes_key =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
|
||||||
|
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
|
||||||
&& env <= 1
|
&& env <= 1
|
||||||
{
|
{
|
||||||
config.wechat_mini_program_virtual_payment_env = env;
|
config.wechat_mini_program_virtual_payment_env = env;
|
||||||
@@ -1396,6 +1403,8 @@ mod tests {
|
|||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||||
|
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||||
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
||||||
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
||||||
@@ -1412,18 +1421,17 @@ mod tests {
|
|||||||
"WECHAT_PAY_NOTIFY_URL",
|
"WECHAT_PAY_NOTIFY_URL",
|
||||||
"https://api.example.com/api/profile/recharge/wechat/notify",
|
"https://api.example.com/api/profile/recharge/wechat/notify",
|
||||||
);
|
);
|
||||||
std::env::set_var(
|
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001");
|
||||||
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID",
|
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001");
|
||||||
"offer-001",
|
|
||||||
);
|
|
||||||
std::env::set_var(
|
|
||||||
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY",
|
|
||||||
"app-key-001",
|
|
||||||
);
|
|
||||||
std::env::set_var(
|
std::env::set_var(
|
||||||
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
|
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
|
||||||
"sandbox-app-key-001",
|
"sandbox-app-key-001",
|
||||||
);
|
);
|
||||||
|
std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001");
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||||
|
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
||||||
|
);
|
||||||
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
|
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,13 +1456,27 @@ mod tests {
|
|||||||
Some("platform-serial-001")
|
Some("platform-serial-001")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.wechat_mini_program_virtual_payment_offer_id.as_deref(),
|
config
|
||||||
|
.wechat_mini_program_virtual_payment_offer_id
|
||||||
|
.as_deref(),
|
||||||
Some("offer-001")
|
Some("offer-001")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.wechat_mini_program_virtual_payment_app_key.as_deref(),
|
config
|
||||||
|
.wechat_mini_program_virtual_payment_app_key
|
||||||
|
.as_deref(),
|
||||||
Some("app-key-001")
|
Some("app-key-001")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_mini_program_message_token.as_deref(),
|
||||||
|
Some("message-token-001")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.wechat_mini_program_message_encoding_aes_key
|
||||||
|
.as_deref(),
|
||||||
|
Some("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config
|
config
|
||||||
.wechat_mini_program_virtual_payment_sandbox_app_key
|
.wechat_mini_program_virtual_payment_sandbox_app_key
|
||||||
@@ -1476,6 +1498,8 @@ mod tests {
|
|||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
|
||||||
|
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
|
||||||
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image(
|
|||||||
"scene_image",
|
"scene_image",
|
||||||
asset_id.as_str(),
|
asset_id.as_str(),
|
||||||
async {
|
async {
|
||||||
let settings = require_openai_image_settings(&state)?
|
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
&request_context,
|
||||||
&request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
normalized.profile_id.clone(),
|
||||||
normalized.profile_id.clone(),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let reference_image =
|
let reference_image =
|
||||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||||
|
|||||||
@@ -1052,6 +1052,7 @@ mod tests {
|
|||||||
external_api_audit_state: None,
|
external_api_audit_state: None,
|
||||||
external_api_audit_user_id: None,
|
external_api_audit_user_id: None,
|
||||||
external_api_audit_profile_id: None,
|
external_api_audit_profile_id: None,
|
||||||
|
external_api_audit_request_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -414,12 +414,11 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
|
|
||||||
let settings = require_openai_image_settings(state)
|
let settings = require_openai_image_settings(state)
|
||||||
.map(|settings| {
|
.map(|settings| {
|
||||||
settings
|
settings.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.clone()),
|
||||||
Some(profile_id.clone()),
|
)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||||
|
|||||||
@@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene(
|
|||||||
config: &Match3DConfigJson,
|
config: &Match3DConfigJson,
|
||||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||||
) -> Result<Match3DMaterialSheet, AppError> {
|
) -> Result<Match3DMaterialSheet, AppError> {
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let prompt = build_match3d_item_spritesheet_prompt();
|
let prompt = build_match3d_item_spritesheet_prompt();
|
||||||
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
||||||
|
|||||||
@@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
|||||||
reference_image_srcs: Vec<String>,
|
reference_image_srcs: Vec<String>,
|
||||||
) -> Result<Match3DAssetUpload, AppError> {
|
) -> Result<Match3DAssetUpload, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
||||||
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
||||||
@@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
|
||||||
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
||||||
@@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let reference_image = load_match3d_container_reference_image()?;
|
let reference_image = load_match3d_container_reference_image()?;
|
||||||
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
||||||
|
|||||||
@@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
level_name: &str,
|
level_name: &str,
|
||||||
puzzle_image: &PuzzleDownloadedImage,
|
puzzle_image: &PuzzleDownloadedImage,
|
||||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||||
let settings = require_puzzle_vector_engine_settings(state)?
|
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(session_id.to_string()),
|
||||||
Some(session_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
let scene_generated = create_puzzle_vector_engine_image_generation(
|
||||||
|
|||||||
@@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
self.external_api_audit_user_id = user_id;
|
self.external_api_audit_user_id = user_id;
|
||||||
self.external_api_audit_profile_id = profile_id;
|
self.external_api_audit_profile_id = profile_id;
|
||||||
self.external_api_audit_request_id =
|
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
|
||||||
Some(request_context.request_id().to_string());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||||
|
|||||||
@@ -1240,8 +1240,7 @@ fn build_wechat_virtual_pay_params(
|
|||||||
}
|
}
|
||||||
let sign_data = sign_data.to_string();
|
let sign_data = sign_data.to_string();
|
||||||
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
|
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
|
||||||
let signature =
|
let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
|
||||||
calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
|
|
||||||
|
|
||||||
Ok(WechatMiniProgramVirtualPayParamsResponse {
|
Ok(WechatMiniProgramVirtualPayParamsResponse {
|
||||||
mode: mode.to_string(),
|
mode: mode.to_string(),
|
||||||
@@ -2342,8 +2341,9 @@ mod tests {
|
|||||||
has_points_recharged: false,
|
has_points_recharged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001")
|
let params =
|
||||||
.expect("membership virtual pay params should build");
|
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001")
|
||||||
|
.expect("membership virtual pay params should build");
|
||||||
let sign_data: Value =
|
let sign_data: Value =
|
||||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||||
let attach: Value = serde_json::from_str(
|
let attach: Value = serde_json::from_str(
|
||||||
@@ -2439,13 +2439,9 @@ mod tests {
|
|||||||
has_points_recharged: true,
|
has_points_recharged: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_wechat_virtual_pay_params(
|
let params =
|
||||||
&state,
|
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-points-60")
|
||||||
¢er,
|
.expect("points virtual pay params should build");
|
||||||
&order,
|
|
||||||
"openid-user-points-60",
|
|
||||||
)
|
|
||||||
.expect("points virtual pay params should build");
|
|
||||||
let sign_data: Value =
|
let sign_data: Value =
|
||||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||||
let attach: Value = serde_json::from_str(
|
let attach: Value = serde_json::from_str(
|
||||||
@@ -2554,9 +2550,8 @@ mod tests {
|
|||||||
fn wechat_virtual_payment_signatures_match_official_examples() {
|
fn wechat_virtual_payment_signatures_match_official_examples() {
|
||||||
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
|
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
|
||||||
|
|
||||||
let pay_sig =
|
let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
|
||||||
calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
|
.expect("pay signature should build");
|
||||||
.expect("pay signature should build");
|
|
||||||
let signature = calc_wechat_virtual_payment_user_signature_with_key(
|
let signature = calc_wechat_virtual_payment_user_signature_with_key(
|
||||||
"9hAb/NEYUlkaMBEsmFgzig==",
|
"9hAb/NEYUlkaMBEsmFgzig==",
|
||||||
post_body,
|
post_body,
|
||||||
|
|||||||
@@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url(
|
|||||||
size: &str,
|
size: &str,
|
||||||
failure_context: &str,
|
failure_context: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let generated = create_openai_image_generation(
|
let generated = create_openai_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
use std::{fs, path::Path, sync::Arc};
|
use std::{fs, path::Path, sync::Arc};
|
||||||
|
|
||||||
|
use aes::Aes256;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
Json,
|
||||||
http::{HeaderMap, StatusCode},
|
extract::{Query, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
|
||||||
use ring::{
|
use ring::{
|
||||||
aead,
|
aead,
|
||||||
rand::{SecureRandom, SystemRandom},
|
rand::{SecureRandom, SystemRandom},
|
||||||
@@ -13,11 +17,13 @@ use ring::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use sha1::Sha1;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
|
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
|
||||||
};
|
};
|
||||||
use shared_kernel::offset_datetime_to_unix_micros;
|
use shared_kernel::offset_datetime_to_unix_micros;
|
||||||
|
use std::convert::TryInto;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -43,6 +49,10 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
|
|||||||
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
|
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
|
||||||
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
|
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
|
||||||
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
|
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
|
||||||
|
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
|
||||||
|
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
|
||||||
|
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
|
||||||
|
const WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES: usize = 4;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum WechatPayClient {
|
pub enum WechatPayClient {
|
||||||
@@ -92,6 +102,22 @@ pub struct WechatPayNotifyOrder {
|
|||||||
pub success_time: Option<String>,
|
pub success_time: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct WechatVirtualPaymentNotifyOrder {
|
||||||
|
out_trade_no: String,
|
||||||
|
transaction_id: Option<String>,
|
||||||
|
paid_at_micros: Option<i64>,
|
||||||
|
event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WechatVirtualPaymentNotifyResponse {
|
||||||
|
#[serde(rename = "ErrCode")]
|
||||||
|
err_code: i32,
|
||||||
|
#[serde(rename = "ErrMsg")]
|
||||||
|
err_msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum WechatPayError {
|
pub enum WechatPayError {
|
||||||
Disabled,
|
Disabled,
|
||||||
@@ -220,6 +246,45 @@ struct WechatPayQueryOrderResponse {
|
|||||||
success_time: Option<String>,
|
success_time: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatVirtualPaymentNotifyBody {
|
||||||
|
#[serde(rename = "Event", alias = "event")]
|
||||||
|
event: String,
|
||||||
|
#[serde(rename = "OutTradeNo", alias = "out_trade_no", default)]
|
||||||
|
out_trade_no: Option<String>,
|
||||||
|
#[serde(rename = "MchOrderId", alias = "mch_order_id", default)]
|
||||||
|
mch_order_id: Option<String>,
|
||||||
|
#[serde(rename = "WeChatPayInfo", alias = "wechat_pay_info", default)]
|
||||||
|
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatVirtualPaymentNotifyPayInfo {
|
||||||
|
#[serde(rename = "MchOrderNo", alias = "mch_order_no", default)]
|
||||||
|
mch_order_no: Option<String>,
|
||||||
|
#[serde(rename = "TransactionId", alias = "transaction_id", default)]
|
||||||
|
transaction_id: Option<String>,
|
||||||
|
#[serde(rename = "PaidTime", alias = "paid_time", default)]
|
||||||
|
paid_time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct WechatMiniProgramMessagePushQuery {
|
||||||
|
signature: Option<String>,
|
||||||
|
timestamp: Option<String>,
|
||||||
|
nonce: Option<String>,
|
||||||
|
echostr: Option<String>,
|
||||||
|
msg_signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatMiniProgramEncryptedMessage {
|
||||||
|
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
|
||||||
|
to_user_name: Option<String>,
|
||||||
|
#[serde(rename = "Encrypt", alias = "encrypt")]
|
||||||
|
encrypt: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl WechatPayClient {
|
impl WechatPayClient {
|
||||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||||
if !config.wechat_pay_enabled {
|
if !config.wechat_pay_enabled {
|
||||||
@@ -806,6 +871,172 @@ pub async fn handle_wechat_pay_notify(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let token = match read_wechat_message_push_config(
|
||||||
|
state.config.wechat_mini_program_message_token.as_deref(),
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||||
|
) {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||||
|
};
|
||||||
|
let aes_key = match read_wechat_message_push_config(
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_message_encoding_aes_key
|
||||||
|
.as_deref(),
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||||
|
};
|
||||||
|
let signature = query
|
||||||
|
.msg_signature
|
||||||
|
.as_deref()
|
||||||
|
.or(query.signature.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("");
|
||||||
|
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
let echostr = query.echostr.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() {
|
||||||
|
return build_wechat_message_push_verify_error_response(WechatPayError::InvalidRequest(
|
||||||
|
"微信消息推送校验参数不完整".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) {
|
||||||
|
return build_wechat_message_push_verify_error_response(WechatPayError::InvalidSignature(
|
||||||
|
"微信消息推送校验签名无效".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match decrypt_wechat_message_push_ciphertext(
|
||||||
|
aes_key,
|
||||||
|
echostr,
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_app_id
|
||||||
|
.as_deref()
|
||||||
|
.or(state.config.wechat_app_id.as_deref()),
|
||||||
|
) {
|
||||||
|
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||||||
|
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_wechat_virtual_payment_notify(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||||||
|
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||||
|
};
|
||||||
|
let token = match read_wechat_message_push_config(
|
||||||
|
state.config.wechat_mini_program_message_token.as_deref(),
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||||
|
) {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||||
|
};
|
||||||
|
let aes_key = match read_wechat_message_push_config(
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_message_encoding_aes_key
|
||||||
|
.as_deref(),
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||||
|
};
|
||||||
|
let signature = query
|
||||||
|
.msg_signature
|
||||||
|
.as_deref()
|
||||||
|
.or(query.signature.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("");
|
||||||
|
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||||||
|
return build_virtual_payment_notify_error_response(
|
||||||
|
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||||||
|
response_format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !verify_wechat_message_push_signature(
|
||||||
|
token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
encrypted_payload.encrypt.as_str(),
|
||||||
|
signature,
|
||||||
|
) {
|
||||||
|
return build_virtual_payment_notify_error_response(
|
||||||
|
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||||||
|
response_format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||||||
|
aes_key,
|
||||||
|
encrypted_payload.encrypt.as_str(),
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_app_id
|
||||||
|
.as_deref()
|
||||||
|
.or(state.config.wechat_app_id.as_deref()),
|
||||||
|
) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||||
|
};
|
||||||
|
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||||||
|
Ok(notify) => notify,
|
||||||
|
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||||
|
};
|
||||||
|
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||||||
|
info!(
|
||||||
|
event = notify.event.as_str(),
|
||||||
|
order_id = notify.out_trade_no.as_str(),
|
||||||
|
"收到非订单入账虚拟支付推送"
|
||||||
|
);
|
||||||
|
return build_virtual_payment_notify_success_response(response_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||||||
|
if state
|
||||||
|
.spacetime_client()
|
||||||
|
.mark_profile_recharge_order_paid(
|
||||||
|
notify.out_trade_no.clone(),
|
||||||
|
paid_at_micros,
|
||||||
|
notify.transaction_id.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
order_id = notify.out_trade_no.as_str(),
|
||||||
|
"确认微信虚拟支付订单失败"
|
||||||
|
);
|
||||||
|
return build_virtual_payment_notify_error_response(
|
||||||
|
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||||||
|
response_format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
event = notify.event.as_str(),
|
||||||
|
order_id = notify.out_trade_no.as_str(),
|
||||||
|
"微信虚拟支付推送已确认订单入账"
|
||||||
|
);
|
||||||
|
|
||||||
|
build_virtual_payment_notify_success_response(response_format)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||||
match error {
|
match error {
|
||||||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
@@ -875,6 +1106,320 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
|||||||
map_wechat_pay_error(error)
|
map_wechat_pay_error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_wechat_message_push_config<'a>(
|
||||||
|
value: Option<&'a str>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<&'a str, WechatPayError> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||||||
|
let message = match error {
|
||||||
|
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||||||
|
WechatPayError::InvalidConfig(message)
|
||||||
|
| WechatPayError::InvalidRequest(message)
|
||||||
|
| WechatPayError::RequestFailed(message)
|
||||||
|
| WechatPayError::Upstream(message)
|
||||||
|
| WechatPayError::Deserialize(message)
|
||||||
|
| WechatPayError::Crypto(message)
|
||||||
|
| WechatPayError::InvalidSignature(message) => message,
|
||||||
|
};
|
||||||
|
(StatusCode::BAD_REQUEST, message).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_wechat_mini_program_message_push_payload(
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
|
||||||
|
serde_json::from_slice(body).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信消息推送 JSON 解析失败:{error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_wechat_message_push_signature(
|
||||||
|
token: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
nonce: &str,
|
||||||
|
value: &str,
|
||||||
|
signature: &str,
|
||||||
|
) -> bool {
|
||||||
|
let mut parts = [token, timestamp, nonce, value];
|
||||||
|
parts.sort_unstable();
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(parts.join("").as_bytes());
|
||||||
|
let expected = hex::encode(hasher.finalize());
|
||||||
|
expected.eq_ignore_ascii_case(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_wechat_message_push_ciphertext(
|
||||||
|
encoding_aes_key: &str,
|
||||||
|
ciphertext: &str,
|
||||||
|
expected_app_id: Option<&str>,
|
||||||
|
) -> Result<String, WechatPayError> {
|
||||||
|
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)?;
|
||||||
|
let ciphertext = BASE64_STANDARD
|
||||||
|
.decode(ciphertext.as_bytes())
|
||||||
|
.map_err(|error| {
|
||||||
|
WechatPayError::Crypto(format!("微信消息推送密文 Base64 解码失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
|
||||||
|
let cipher = cbc::Decryptor::<Aes256>::new_from_slices(&key, iv)
|
||||||
|
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送 AES 初始化失败:{error}")))?;
|
||||||
|
let decrypted = cipher
|
||||||
|
.decrypt_padded_vec_mut::<NoPadding>(&ciphertext)
|
||||||
|
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送密文解密失败:{error}")))?;
|
||||||
|
let plaintext = remove_wechat_message_push_pkcs7_padding(&decrypted)?;
|
||||||
|
let payload = parse_wechat_message_push_plaintext(&plaintext)?;
|
||||||
|
if let Some(app_id) = expected_app_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
&& payload.app_id != app_id
|
||||||
|
{
|
||||||
|
return Err(WechatPayError::InvalidSignature(
|
||||||
|
"微信消息推送明文 appid 校验失败".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(payload.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_wechat_message_push_encoding_aes_key(
|
||||||
|
encoding_aes_key: &str,
|
||||||
|
) -> Result<Vec<u8>, WechatPayError> {
|
||||||
|
if encoding_aes_key.chars().count() != WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES {
|
||||||
|
return Err(WechatPayError::InvalidConfig(format!(
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 必须是 {WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES} 位"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let padded_key = format!("{encoding_aes_key}=");
|
||||||
|
let key = BASE64_STANDARD
|
||||||
|
.decode(padded_key.as_bytes())
|
||||||
|
.map_err(|error| {
|
||||||
|
WechatPayError::InvalidConfig(format!(
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY Base64 解析失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if key.len() != WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES {
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 解码后长度必须为 32 字节".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_wechat_message_push_pkcs7_padding(plaintext: &[u8]) -> Result<Vec<u8>, WechatPayError> {
|
||||||
|
let Some(&pad_len) = plaintext.last() else {
|
||||||
|
return Err(WechatPayError::Deserialize(
|
||||||
|
"微信消息推送明文为空".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pad_len = pad_len as usize;
|
||||||
|
if pad_len == 0 || pad_len > 32 || pad_len > plaintext.len() {
|
||||||
|
return Err(WechatPayError::Deserialize(
|
||||||
|
"微信消息推送 PKCS7 填充无效".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if plaintext[plaintext.len() - pad_len..]
|
||||||
|
.iter()
|
||||||
|
.any(|byte| *byte as usize != pad_len)
|
||||||
|
{
|
||||||
|
return Err(WechatPayError::Deserialize(
|
||||||
|
"微信消息推送 PKCS7 填充校验失败".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(plaintext[..plaintext.len() - pad_len].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WechatMessagePushPlaintext {
|
||||||
|
message: String,
|
||||||
|
app_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_wechat_message_push_plaintext(
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<WechatMessagePushPlaintext, WechatPayError> {
|
||||||
|
if plaintext.len()
|
||||||
|
< WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES + WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES + 1
|
||||||
|
{
|
||||||
|
return Err(WechatPayError::Deserialize(
|
||||||
|
"微信消息推送明文长度不足".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let len_offset = WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES;
|
||||||
|
let length_bytes: [u8; WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] = plaintext
|
||||||
|
[len_offset..len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| WechatPayError::Deserialize("微信消息推送长度字段解析失败".to_string()))?;
|
||||||
|
let message_len = u32::from_be_bytes(length_bytes) as usize;
|
||||||
|
let message_start = len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES;
|
||||||
|
let message_end = message_start + message_len;
|
||||||
|
if plaintext.len() <= message_end {
|
||||||
|
return Err(WechatPayError::Deserialize(
|
||||||
|
"微信消息推送明文长度与内容不匹配".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let app_id_start = message_end;
|
||||||
|
let message =
|
||||||
|
String::from_utf8(plaintext[message_start..message_end].to_vec()).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信消息推送明文不是合法 UTF-8:{error}"))
|
||||||
|
})?;
|
||||||
|
let app_id =
|
||||||
|
String::from_utf8(plaintext[app_id_start..plaintext.len()].to_vec()).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信消息推送 appid 不是合法 UTF-8:{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(WechatMessagePushPlaintext { message, app_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_virtual_payment_notify(
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
|
||||||
|
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
|
||||||
|
return build_virtual_payment_notify_order(
|
||||||
|
notify.event,
|
||||||
|
notify.out_trade_no,
|
||||||
|
notify.mch_order_id,
|
||||||
|
notify.wechat_pay_info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = std::str::from_utf8(body).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}"))
|
||||||
|
})?;
|
||||||
|
let event = extract_virtual_payment_text_value(text, "Event")
|
||||||
|
.ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))?;
|
||||||
|
let out_trade_no = extract_virtual_payment_text_value(text, "OutTradeNo");
|
||||||
|
let mch_order_id = extract_virtual_payment_text_value(text, "MchOrderId");
|
||||||
|
let wechat_pay_info = extract_virtual_payment_block(text, "WeChatPayInfo").map(|inner| {
|
||||||
|
WechatVirtualPaymentNotifyPayInfo {
|
||||||
|
mch_order_no: extract_virtual_payment_text_value(&inner, "MchOrderNo"),
|
||||||
|
transaction_id: extract_virtual_payment_text_value(&inner, "TransactionId"),
|
||||||
|
paid_time: extract_virtual_payment_text_value(&inner, "PaidTime")
|
||||||
|
.and_then(|value| value.parse::<i64>().ok()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
build_virtual_payment_notify_order(event, out_trade_no, mch_order_id, wechat_pay_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_virtual_payment_notify_order(
|
||||||
|
event: String,
|
||||||
|
out_trade_no: Option<String>,
|
||||||
|
mch_order_id: Option<String>,
|
||||||
|
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
|
||||||
|
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
|
||||||
|
let event = event.trim().to_string();
|
||||||
|
if event.is_empty() {
|
||||||
|
return Err(WechatPayError::InvalidRequest(
|
||||||
|
"微信虚拟支付推送缺少 Event".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let out_trade_no = out_trade_no
|
||||||
|
.or(mch_order_id)
|
||||||
|
.or_else(|| {
|
||||||
|
wechat_pay_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|info| info.mch_order_no.clone())
|
||||||
|
})
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WechatPayError::InvalidRequest("微信虚拟支付推送缺少 OutTradeNo".to_string())
|
||||||
|
})?;
|
||||||
|
let transaction_id = wechat_pay_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|info| info.transaction_id.clone())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let paid_at_micros = wechat_pay_info
|
||||||
|
.and_then(|info| info.paid_time)
|
||||||
|
.map(|paid_time| paid_time.saturating_mul(1_000_000));
|
||||||
|
|
||||||
|
Ok(WechatVirtualPaymentNotifyOrder {
|
||||||
|
out_trade_no,
|
||||||
|
transaction_id,
|
||||||
|
paid_at_micros,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_virtual_payment_text_value(text: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = text.find(&open)? + open.len();
|
||||||
|
let end = text[start..].find(&close)? + start;
|
||||||
|
let raw = &text[start..end];
|
||||||
|
Some(trim_virtual_payment_text_value(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_virtual_payment_block(text: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = text.find(&open)? + open.len();
|
||||||
|
let end = text[start..].find(&close)? + start;
|
||||||
|
Some(text[start..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_virtual_payment_text_value(value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if let Some(inner) = trimmed
|
||||||
|
.strip_prefix("<![CDATA[")
|
||||||
|
.and_then(|value| value.strip_suffix("]]>"))
|
||||||
|
{
|
||||||
|
return inner.trim().to_string();
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_virtual_payment_notify_error_response(
|
||||||
|
error: WechatPayError,
|
||||||
|
response_format: VirtualPaymentNotifyResponseFormat,
|
||||||
|
) -> Response {
|
||||||
|
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||||||
|
let message = match error {
|
||||||
|
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||||||
|
WechatPayError::InvalidConfig(message)
|
||||||
|
| WechatPayError::InvalidRequest(message)
|
||||||
|
| WechatPayError::RequestFailed(message)
|
||||||
|
| WechatPayError::Upstream(message)
|
||||||
|
| WechatPayError::Deserialize(message)
|
||||||
|
| WechatPayError::Crypto(message)
|
||||||
|
| WechatPayError::InvalidSignature(message) => message,
|
||||||
|
};
|
||||||
|
build_virtual_payment_notify_response(response_format, 1, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_virtual_payment_notify_success_response(
|
||||||
|
response_format: VirtualPaymentNotifyResponseFormat,
|
||||||
|
) -> Response {
|
||||||
|
build_virtual_payment_notify_response(response_format, 0, "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_virtual_payment_notify_response(
|
||||||
|
response_format: VirtualPaymentNotifyResponseFormat,
|
||||||
|
err_code: i32,
|
||||||
|
err_msg: impl Into<String>,
|
||||||
|
) -> Response {
|
||||||
|
let err_msg = err_msg.into();
|
||||||
|
match response_format {
|
||||||
|
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||||||
|
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
VirtualPaymentNotifyResponseFormat::Xml => {
|
||||||
|
let body = format!(
|
||||||
|
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||||||
|
);
|
||||||
|
let mut response = (StatusCode::OK, body).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn with_wechat_pay_json_headers(
|
fn with_wechat_pay_json_headers(
|
||||||
builder: reqwest::RequestBuilder,
|
builder: reqwest::RequestBuilder,
|
||||||
platform_serial_no: &str,
|
platform_serial_no: &str,
|
||||||
@@ -965,6 +1510,45 @@ fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_wechat_virtual_payment_notify_response(
|
||||||
|
err_code: i32,
|
||||||
|
err_msg: impl Into<String>,
|
||||||
|
) -> WechatVirtualPaymentNotifyResponse {
|
||||||
|
WechatVirtualPaymentNotifyResponse {
|
||||||
|
err_code,
|
||||||
|
err_msg: err_msg.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum VirtualPaymentNotifyResponseFormat {
|
||||||
|
Json,
|
||||||
|
Xml,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_virtual_payment_notify_response_format(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
) -> VirtualPaymentNotifyResponseFormat {
|
||||||
|
let content_type = headers
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if content_type.contains("xml") {
|
||||||
|
return VirtualPaymentNotifyResponseFormat::Xml;
|
||||||
|
}
|
||||||
|
let body_trimmed = body
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|byte| byte.is_ascii_whitespace())
|
||||||
|
.next();
|
||||||
|
match body_trimmed {
|
||||||
|
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||||||
|
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
|
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
|
||||||
value
|
value
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -1330,6 +1914,7 @@ impl std::error::Error for WechatPayError {}
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mock_pay_params_use_request_payment_shape() {
|
fn mock_pay_params_use_request_payment_shape() {
|
||||||
@@ -1551,4 +2136,141 @@ mod tests {
|
|||||||
assert_eq!(notify.transaction_id, None);
|
assert_eq!(notify.transaction_id, None);
|
||||||
assert_eq!(notify.trade_state, "SUCCESS");
|
assert_eq!(notify.trade_state, "SUCCESS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_virtual_payment_notify_supports_goods_event_json() {
|
||||||
|
let notify = parse_virtual_payment_notify(
|
||||||
|
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"TransactionId":"wx-1","PaidTime":1710000000}}"#,
|
||||||
|
)
|
||||||
|
.expect("virtual payment notify should parse");
|
||||||
|
|
||||||
|
assert_eq!(notify.event, "xpay_goods_deliver_notify");
|
||||||
|
assert_eq!(notify.out_trade_no, "order-1");
|
||||||
|
assert_eq!(notify.transaction_id.as_deref(), Some("wx-1"));
|
||||||
|
assert_eq!(notify.paid_at_micros, Some(1_710_000_000_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_virtual_payment_notify_supports_coin_event_xml() {
|
||||||
|
let notify = parse_virtual_payment_notify(
|
||||||
|
br#"<xml><Event><![CDATA[xpay_coin_pay_notify]]></Event><OutTradeNo><![CDATA[order-2]]></OutTradeNo><WeChatPayInfo><TransactionId><![CDATA[wx-2]]></TransactionId><PaidTime>1710000001</PaidTime></WeChatPayInfo></xml>"#,
|
||||||
|
)
|
||||||
|
.expect("virtual payment xml notify should parse");
|
||||||
|
|
||||||
|
assert_eq!(notify.event, "xpay_coin_pay_notify");
|
||||||
|
assert_eq!(notify.out_trade_no, "order-2");
|
||||||
|
assert_eq!(notify.transaction_id.as_deref(), Some("wx-2"));
|
||||||
|
assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_virtual_payment_notify_rejects_missing_order_no() {
|
||||||
|
let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#)
|
||||||
|
.expect_err("missing order id should fail");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
WechatPayError::InvalidRequest(message) => {
|
||||||
|
assert!(message.contains("OutTradeNo"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wechat_message_push_signature_uses_sorted_sha1_parts() {
|
||||||
|
let token = "token-1";
|
||||||
|
let timestamp = "1710000000";
|
||||||
|
let nonce = "nonce-1";
|
||||||
|
let encrypt = "encrypted-payload";
|
||||||
|
let signature = build_wechat_message_push_test_signature(token, timestamp, nonce, encrypt);
|
||||||
|
|
||||||
|
assert!(verify_wechat_message_push_signature(
|
||||||
|
token, timestamp, nonce, encrypt, &signature
|
||||||
|
));
|
||||||
|
assert!(!verify_wechat_message_push_signature(
|
||||||
|
token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
"tampered-payload",
|
||||||
|
&signature
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wechat_message_push_decrypts_safe_mode_ciphertext() {
|
||||||
|
let app_id = "wx-test-app";
|
||||||
|
let message = r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#;
|
||||||
|
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
|
||||||
|
let encrypted =
|
||||||
|
encrypt_wechat_message_push_test_ciphertext(&encoding_aes_key, message, app_id);
|
||||||
|
|
||||||
|
let decrypted =
|
||||||
|
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some(app_id))
|
||||||
|
.expect("encrypted message should decrypt");
|
||||||
|
|
||||||
|
assert_eq!(decrypted, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wechat_message_push_rejects_mismatched_app_id() {
|
||||||
|
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
|
||||||
|
let encrypted = encrypt_wechat_message_push_test_ciphertext(
|
||||||
|
&encoding_aes_key,
|
||||||
|
r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#,
|
||||||
|
"wx-real-app",
|
||||||
|
);
|
||||||
|
|
||||||
|
let error =
|
||||||
|
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some("wx-other"))
|
||||||
|
.expect_err("mismatched app id should fail");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
WechatPayError::InvalidSignature(message) => {
|
||||||
|
assert!(message.contains("appid"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_wechat_message_push_test_signature(
|
||||||
|
token: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
nonce: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut parts = [token, timestamp, nonce, value];
|
||||||
|
parts.sort_unstable();
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(parts.join("").as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_wechat_message_push_test_encoding_aes_key() -> String {
|
||||||
|
let raw_key = std::array::from_fn::<_, 32, _>(|index| index as u8);
|
||||||
|
BASE64_STANDARD
|
||||||
|
.encode(raw_key)
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_wechat_message_push_test_ciphertext(
|
||||||
|
encoding_aes_key: &str,
|
||||||
|
message: &str,
|
||||||
|
app_id: &str,
|
||||||
|
) -> String {
|
||||||
|
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)
|
||||||
|
.expect("test aes key should decode");
|
||||||
|
let mut plaintext = Vec::new();
|
||||||
|
plaintext.extend_from_slice(b"0123456789abcdef");
|
||||||
|
plaintext.extend_from_slice(&(message.as_bytes().len() as u32).to_be_bytes());
|
||||||
|
plaintext.extend_from_slice(message.as_bytes());
|
||||||
|
plaintext.extend_from_slice(app_id.as_bytes());
|
||||||
|
let pad_len = 32 - (plaintext.len() % 32);
|
||||||
|
plaintext.extend(std::iter::repeat(pad_len as u8).take(pad_len));
|
||||||
|
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
|
||||||
|
let cipher = cbc::Encryptor::<Aes256>::new_from_slices(&key, iv)
|
||||||
|
.expect("test aes cipher should init");
|
||||||
|
let encrypted = cipher.encrypt_padded_vec_mut::<NoPadding>(&plaintext);
|
||||||
|
BASE64_STANDARD.encode(encrypted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1461,8 +1461,10 @@ mod tests {
|
|||||||
assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音"));
|
assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
|
async fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
|
||||||
|
let state = crate::state::AppState::new(crate::config::AppConfig::default())
|
||||||
|
.expect("state should build");
|
||||||
let payload = WoodenFishWorkspaceCreateRequest {
|
let payload = WoodenFishWorkspaceCreateRequest {
|
||||||
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
|
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
|
||||||
work_title: "今日敲木鱼".to_string(),
|
work_title: "今日敲木鱼".to_string(),
|
||||||
@@ -1475,7 +1477,9 @@ mod tests {
|
|||||||
floating_words: vec![],
|
floating_words: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let draft = build_wooden_fish_draft(&payload);
|
let draft = build_wooden_fish_draft(&payload, &state)
|
||||||
|
.await
|
||||||
|
.expect("draft should build");
|
||||||
|
|
||||||
assert!(draft.hit_sound_prompt.is_none());
|
assert!(draft.hit_sound_prompt.is_none());
|
||||||
let asset = draft
|
let asset = draft
|
||||||
|
|||||||
@@ -1524,8 +1524,14 @@ mod tests {
|
|||||||
payload["wechatMiniProgramPayParams"]["signData"],
|
payload["wechatMiniProgramPayParams"]["signData"],
|
||||||
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
||||||
);
|
);
|
||||||
assert_eq!(payload["wechatMiniProgramPayParams"]["paySig"], json!("pay-sig"));
|
assert_eq!(
|
||||||
assert_eq!(payload["wechatMiniProgramPayParams"]["signature"], json!("user-sig"));
|
payload["wechatMiniProgramPayParams"]["paySig"],
|
||||||
|
json!("pay-sig")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["wechatMiniProgramPayParams"]["signature"],
|
||||||
|
json!("user-sig")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default defineConfig(({mode}) => {
|
|||||||
chunkSizeWarningLimit: 1000,
|
chunkSizeWarningLimit: 1000,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ["outfield-outlook-reprocess.ngrok-free.dev"],
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
// Do not modify; file watching is disabled to prevent flickering during agent edits.
|
// Do not modify; file watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
|||||||
Reference in New Issue
Block a user