feat: send puzzle result subscribe messages

This commit is contained in:
kdletters
2026-06-08 11:49:11 +08:00
parent 49e4d085b3
commit 38d9c292ae
17 changed files with 704 additions and 33 deletions

View File

@@ -111,6 +111,9 @@ WECHAT_MOCK_DISPLAY_NAME="微信旅人"
WECHAT_MOCK_AVATAR_URL="" WECHAT_MOCK_AVATAR_URL=""
WECHAT_MINIPROGRAM_MESSAGE_TOKEN="" WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true"
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU"
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
# 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"

View File

@@ -55,6 +55,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin``buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods``productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin``buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods``productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED``WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;后端只在拼图资产生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning不影响生成主链路。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:

View File

@@ -33,6 +33,9 @@ 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_TOKEN=<微信消息推送 Token>
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID=m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE=formal
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
``` ```
@@ -69,4 +72,5 @@ npm run check:encoding
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底后端收到虚拟支付消息推送并入账后会发布订单更新SSE 先推当前订单快照,再在订单结束时推 `done` - WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底后端收到虚拟支付消息推送并入账后会发布订单更新SSE 先推当前订单快照,再在订单结束时推 `done`
- 小程序订阅消息用于拼图 AI 创作生成结果通知:通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。

View File

@@ -15,6 +15,10 @@ const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。 // 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。
const MINI_PROGRAM_ENV = 'release'; const MINI_PROGRAM_ENV = 'release';
// 中文注释AI 创作生成结果订阅消息模板,需与微信公众平台后台的模板 ID 保持一致。
const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID =
'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
const WEB_VIEW_SOURCE_QUERY = { const WEB_VIEW_SOURCE_QUERY = {
clientType: 'mini_program', clientType: 'mini_program',
@@ -25,6 +29,7 @@ module.exports = {
API_BASE_URL, API_BASE_URL,
DEV_API_BASE_URL, DEV_API_BASE_URL,
DEV_WEB_VIEW_ENTRY_URL, DEV_WEB_VIEW_ENTRY_URL,
GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
MINI_PROGRAM_APP_ID, MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV, MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL, WEB_VIEW_ENTRY_URL,

View File

@@ -5,6 +5,7 @@ const {
API_BASE_URL, API_BASE_URL,
DEV_API_BASE_URL, DEV_API_BASE_URL,
DEV_WEB_VIEW_ENTRY_URL, DEV_WEB_VIEW_ENTRY_URL,
GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
MINI_PROGRAM_APP_ID, MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV, MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL, WEB_VIEW_ENTRY_URL,
@@ -20,6 +21,8 @@ const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120; const PAY_RESULT_RECHECK_DELAY_MS = 120;
const WEB_VIEW_SHARE_TITLE = '陶泥儿'; const WEB_VIEW_SHARE_TITLE = '陶泥儿';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
const SUBSCRIBE_MESSAGE_TYPE = 'genarrative:request-subscribe-message';
const GENERATION_RESULT_SUBSCRIBE_SCENE = 'generation-result';
function showWebViewShareMenu() { function showWebViewShareMenu() {
if (typeof wx.showShareMenu !== 'function') { if (typeof wx.showShareMenu !== 'function') {
@@ -415,6 +418,36 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) {
}); });
} }
function requestGenerationResultSubscribeMessage() {
return new Promise((resolve) => {
if (!GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID) {
resolve({ ok: false, reason: 'missing_template_id' });
return;
}
if (typeof wx.requestSubscribeMessage !== 'function') {
resolve({ ok: false, reason: 'unsupported' });
return;
}
wx.requestSubscribeMessage({
tmplIds: [GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID],
success(result) {
resolve({
ok: result[GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID] === 'accept',
result,
});
},
fail(error) {
console.warn('[web-view] request subscribe message failed', error);
resolve({
ok: false,
reason: error && error.errMsg ? error.errMsg : 'failed',
});
},
});
});
}
async function resolveAuthResult(displayName) { async function resolveAuthResult(displayName) {
const code = await wxLogin(); const code = await wxLogin();
const response = await requestMiniProgramLogin(code, displayName); const response = await requestMiniProgramLogin(code, displayName);
@@ -712,7 +745,23 @@ Page({
}, },
handleWebViewMessage(event) { handleWebViewMessage(event) {
// 中文注释:支付由独立 native 页面承接web-view 消息只保留调试输出。 const messages =
event && event.detail && Array.isArray(event.detail.data)
? event.detail.data
: [];
const shouldRequestSubscribe = messages.some((message) => {
const payload = message && typeof message === 'object' ? message : {};
return (
payload.type === SUBSCRIBE_MESSAGE_TYPE &&
payload.scene === GENERATION_RESULT_SUBSCRIBE_SCENE
);
});
if (shouldRequestSubscribe) {
void requestGenerationResultSubscribeMessage();
return;
}
// 中文注释:支付由独立 native 页面承接,其他 web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail); console.info('[web-view] message', event.detail);
}, },

12
server-rs/Cargo.lock generated
View File

@@ -129,6 +129,7 @@ dependencies = [
"platform-llm", "platform-llm",
"platform-oss", "platform-oss",
"platform-speech", "platform-speech",
"platform-wechat",
"reqwest 0.12.28", "reqwest 0.12.28",
"ring", "ring",
"serde", "serde",
@@ -2508,6 +2509,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "platform-wechat"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"tracing",
"url",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"

View File

@@ -37,6 +37,7 @@ members = [
"crates/platform-hyper3d", "crates/platform-hyper3d",
"crates/platform-image", "crates/platform-image",
"crates/platform-llm", "crates/platform-llm",
"crates/platform-wechat",
"crates/platform-speech", "crates/platform-speech",
"crates/platform-agent", "crates/platform-agent",
"crates/shared-contracts", "crates/shared-contracts",
@@ -85,6 +86,7 @@ platform-image = { path = "crates/platform-image", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false }
platform-speech = { path = "crates/platform-speech", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false }
platform-wechat = { path = "crates/platform-wechat", default-features = false }
shared-contracts = { path = "crates/shared-contracts", default-features = false } shared-contracts = { path = "crates/shared-contracts", default-features = false }
shared-kernel = { path = "crates/shared-kernel", default-features = false } shared-kernel = { path = "crates/shared-kernel", default-features = false }
shared-logging = { path = "crates/shared-logging", default-features = false } shared-logging = { path = "crates/shared-logging", default-features = false }

View File

@@ -44,6 +44,7 @@ platform-image = { workspace = true }
platform-llm = { workspace = true } platform-llm = { workspace = true }
platform-oss = { workspace = true } platform-oss = { workspace = true }
platform-speech = { workspace = true } platform-speech = { workspace = true }
platform-wechat = { workspace = true }
hmac = { workspace = true } hmac = { workspace = true }
ring = { workspace = true } ring = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -100,6 +100,10 @@ pub struct AppConfig {
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_token: Option<String>,
pub wechat_mini_program_message_encoding_aes_key: Option<String>, pub wechat_mini_program_message_encoding_aes_key: Option<String>,
pub wechat_mini_program_subscribe_message_enabled: bool,
pub wechat_mini_program_generation_result_template_id: Option<String>,
pub wechat_mini_program_subscribe_message_endpoint: String,
pub wechat_mini_program_subscribe_message_state: 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>,
@@ -250,6 +254,13 @@ impl Default for AppConfig {
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_token: None,
wechat_mini_program_message_encoding_aes_key: None, wechat_mini_program_message_encoding_aes_key: None,
wechat_mini_program_subscribe_message_enabled: true,
wechat_mini_program_generation_result_template_id: Some(
"m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(),
),
wechat_mini_program_subscribe_message_endpoint:
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(),
wechat_mini_program_subscribe_message_state: "formal".to_string(),
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,
@@ -613,6 +624,26 @@ impl AppConfig {
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
config.wechat_mini_program_message_encoding_aes_key = config.wechat_mini_program_message_encoding_aes_key =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]); read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
if let Some(enabled) =
read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"])
{
config.wechat_mini_program_subscribe_message_enabled = enabled;
}
if let Some(template_id) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"])
{
config.wechat_mini_program_generation_result_template_id = Some(template_id);
}
if let Some(endpoint) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"])
{
config.wechat_mini_program_subscribe_message_endpoint = endpoint;
}
if let Some(state) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"])
{
config.wechat_mini_program_subscribe_message_state = state;
}
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
&& env <= 1 && env <= 1
{ {
@@ -1419,6 +1450,9 @@ mod tests {
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_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
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");
@@ -1446,6 +1480,12 @@ mod tests {
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
); );
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true");
std::env::set_var(
"WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID",
"tmpl-generation-result",
);
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial");
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
} }
@@ -1497,6 +1537,14 @@ mod tests {
.as_deref(), .as_deref(),
Some("sandbox-app-key-001") Some("sandbox-app-key-001")
); );
assert!(config.wechat_mini_program_subscribe_message_enabled);
assert_eq!(
config
.wechat_mini_program_generation_result_template_id
.as_deref(),
Some("tmpl-generation-result")
);
assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial");
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1); assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
unsafe { unsafe {
@@ -1514,6 +1562,9 @@ mod tests {
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_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
} }
} }

View File

@@ -92,6 +92,7 @@ mod volcengine_speech;
mod wechat_auth; mod wechat_auth;
mod wechat_pay; mod wechat_pay;
mod wechat_provider; mod wechat_provider;
mod wechat_subscribe_message;
mod wooden_fish; mod wooden_fish;
mod work_author; mod work_author;
mod work_play_tracking; mod work_play_tracking;

View File

@@ -2,6 +2,7 @@ use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError}; use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind}; use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind}; use platform_oss::{OssError, OssErrorKind};
use platform_wechat::{WechatError, WechatErrorKind};
use serde_json::json; use serde_json::json;
use crate::http_error::AppError; use crate::http_error::AppError;
@@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
AppError::from_status(status).with_message(error.to_string()) AppError::from_status(status).with_message(error.to_string())
} }
pub fn map_wechat_error(error: WechatError) -> AppError {
let status = match error.kind() {
WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
WechatErrorKind::RequestFailed
| WechatErrorKind::DeserializeFailed
| WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError { pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) { match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value), Ok(value) => error.with_header("retry-after", value),

View File

@@ -58,16 +58,15 @@ use spacetime_client::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
SpacetimeClientError,
}; };
use std::convert::Infallible; use std::convert::Infallible;
@@ -106,6 +105,10 @@ use crate::{
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext, request_context::RequestContext,
state::{AppState, PuzzleApiState}, state::{AppState, PuzzleApiState},
wechat_subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_author::resolve_puzzle_work_author_by_user_id, work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
}; };

View File

@@ -617,13 +617,14 @@ pub async fn execute_puzzle_agent_action(
let log_session_id = session_id.clone(); let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone(); let log_owner_user_id = owner_user_id.clone();
async move { async move {
let failed_at_micros = current_utc_micros();
let result = state let result = state
.spacetime_client() .spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id, session_id,
owner_user_id, owner_user_id: owner_user_id.clone(),
error_message, error_message,
failed_at_micros: current_utc_micros(), failed_at_micros,
}) })
.await; .await;
if let Err(error) = result { if let Err(error) = result {
@@ -634,6 +635,19 @@ pub async fn execute_puzzle_agent_action(
message = %error, message = %error,
"拼图草稿失败态回写失败,继续返回原始错误" "拼图草稿失败态回写失败,继续返回原始错误"
); );
} else {
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id,
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
} }
} }
}; };
@@ -677,10 +691,7 @@ pub async fn execute_puzzle_agent_action(
); );
state state
.spacetime_client() .spacetime_client()
.get_puzzle_agent_session( .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
compile_session_id.clone(),
owner_user_id.clone(),
)
.await .await
.map(mark_puzzle_initial_generation_started_snapshot) .map(mark_puzzle_initial_generation_started_snapshot)
.map_err(map_puzzle_client_error) .map_err(map_puzzle_client_error)
@@ -696,8 +707,7 @@ pub async fn execute_puzzle_agent_action(
.map_err(map_puzzle_compile_error); .map_err(map_puzzle_compile_error);
match compiled_session { match compiled_session {
Ok(compiled_session) => { Ok(compiled_session) => {
let response_session = let response_session = mark_puzzle_initial_generation_started_snapshot(
mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(), compiled_session.clone(),
); );
let background_state = state.clone(); let background_state = state.clone();
@@ -708,13 +718,15 @@ pub async fn execute_puzzle_agent_action(
let background_reference_image_src = let background_reference_image_src =
primary_reference_image_src.map(str::to_string); primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone(); let background_image_model = payload.image_model.clone();
let background_work_name = compiled_session
.draft
.as_ref()
.map(|draft| draft.work_title.clone());
let background_billing_asset_id = let background_billing_asset_id =
format!("{background_session_id}:compile_puzzle_draft"); format!("{background_session_id}:compile_puzzle_draft");
tokio::spawn(async move { tokio::spawn(async move {
let operation_owner_user_id = let operation_owner_user_id = background_owner_user_id.clone();
background_owner_user_id.clone(); let background_root_state = background_state.root_state().clone();
let background_root_state =
background_state.root_state().clone();
let operation_state = background_state.clone(); let operation_state = background_state.clone();
let result = execute_billable_asset_operation_with_cost( let result = execute_billable_asset_operation_with_cost(
&background_root_state, &background_root_state,
@@ -739,6 +751,23 @@ pub async fn execute_puzzle_agent_action(
.await; .await;
match result { match result {
Ok(session) => { Ok(session) => {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
work_name: session
.draft
.as_ref()
.map(|draft| draft.work_title.clone()),
status:
GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points:
PUZZLE_IMAGE_GENERATION_POINTS_COST,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
tracing::info!( tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER, provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id, session_id = %session.session_id,
@@ -748,15 +777,15 @@ pub async fn execute_puzzle_agent_action(
} }
Err(error) => { Err(error) => {
let error_message = error.body_text(); let error_message = error.body_text();
let failed_at_micros = current_utc_micros();
let failure_result = background_state let failure_result = background_state
.spacetime_client() .spacetime_client()
.mark_puzzle_draft_generation_failed( .mark_puzzle_draft_generation_failed(
PuzzleDraftCompileFailureRecordInput { PuzzleDraftCompileFailureRecordInput {
session_id: background_session_id.clone(), session_id: background_session_id.clone(),
owner_user_id: background_owner_user_id owner_user_id: background_owner_user_id.clone(),
.clone(),
error_message: error_message.clone(), error_message: error_message.clone(),
failed_at_micros: current_utc_micros(), failed_at_micros,
}, },
) )
.await; .await;
@@ -768,6 +797,20 @@ pub async fn execute_puzzle_agent_action(
message = %mark_error, message = %mark_error,
"拼图首图后台生成失败态回写失败" "拼图首图后台生成失败态回写失败"
); );
} else {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
work_name: background_work_name.clone(),
status:
GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
} }
tracing::warn!( tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER, provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -778,9 +821,7 @@ pub async fn execute_puzzle_agent_action(
); );
} }
} }
unregister_puzzle_background_compile_task( unregister_puzzle_background_compile_task(&background_session_id);
&background_session_id,
);
}); });
Ok(response_session) Ok(response_session)
} }
@@ -1428,6 +1469,29 @@ pub async fn execute_puzzle_agent_action(
}; };
let session = session?; let session = session?;
if operation_type == "compile_puzzle_draft"
&& session
.draft
.as_ref()
.is_some_and(|draft| draft.generation_status == "ready")
{
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: owner_user_id.clone(),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: if payload.ai_redraw.unwrap_or(true) {
PUZZLE_IMAGE_GENERATION_POINTS_COST
} else {
0
},
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),

View File

@@ -10,12 +10,12 @@ use std::{
use axum::extract::FromRef; use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_ai::{AiTaskService, InMemoryAiTaskStore};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_auth::{ use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService, RefreshSessionService, WechatAuthService, WechatAuthStateService,
}; };
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_runtime::RuntimeSnapshotRecord; use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)] #[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
@@ -27,6 +27,7 @@ use platform_auth::{
}; };
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError}; use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig};
use serde_json::Value; use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
@@ -251,6 +252,7 @@ pub struct AppStateInner {
wechat_auth_state_service: WechatAuthStateService, wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService, wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider, wechat_provider: WechatProvider,
wechat_client: WechatClient,
wechat_pay_client: WechatPayClient, wechat_pay_client: WechatPayClient,
#[cfg_attr(not(test), allow(dead_code))] #[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService, ai_task_service: AiTaskService,
@@ -385,6 +387,7 @@ impl AppState {
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config); let wechat_provider = build_wechat_provider(&config);
let wechat_client = build_wechat_client(&config);
let wechat_pay_client = let wechat_pay_client =
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?; WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
let refresh_session_service = let refresh_session_service =
@@ -424,6 +427,7 @@ impl AppState {
wechat_auth_state_service, wechat_auth_state_service,
wechat_auth_service, wechat_auth_service,
wechat_provider, wechat_provider,
wechat_client,
wechat_pay_client, wechat_pay_client,
ai_task_service, ai_task_service,
spacetime_client, spacetime_client,
@@ -776,6 +780,10 @@ impl AppState {
&self.wechat_provider &self.wechat_provider
} }
pub fn wechat_client(&self) -> &WechatClient {
&self.wechat_client
}
pub fn wechat_pay_client(&self) -> &WechatPayClient { pub fn wechat_pay_client(&self) -> &WechatPayClient {
&self.wechat_pay_client &self.wechat_pay_client
} }
@@ -1333,6 +1341,17 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
Ok(Some(OssClient::new(oss_config))) Ok(Some(OssClient::new(oss_config)))
} }
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
app_secret: config.wechat_mini_program_app_secret.clone(),
stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(),
subscribe_message_endpoint: config
.wechat_mini_program_subscribe_message_endpoint
.clone(),
})
}
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> { fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config let Some(api_key) = config
.llm_api_key .llm_api_key

View File

@@ -0,0 +1,197 @@
use std::collections::BTreeMap;
use axum::http::StatusCode;
use platform_wechat::WechatSubscribeMessageRequest;
use shared_kernel::format_timestamp_micros;
use tracing::{info, warn};
use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState};
const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成";
const DEFAULT_WORK_NAME: &str = "AI创作作品";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GenerationResultSubscribeMessageStatus {
Succeeded,
Failed,
}
#[derive(Clone, Debug)]
pub struct GenerationResultSubscribeMessage {
pub owner_user_id: String,
pub work_name: Option<String>,
pub status: GenerationResultSubscribeMessageStatus,
pub consumed_points: u64,
pub completed_at_micros: i64,
pub page: Option<String>,
}
pub async fn send_generation_result_subscribe_message_after_completion(
state: &AppState,
message: GenerationResultSubscribeMessage,
) {
if let Err(error) = send_generation_result_subscribe_message(state, message).await {
warn!(
error = %error,
"微信小程序生成结果订阅消息发送失败,已忽略"
);
}
}
async fn send_generation_result_subscribe_message(
state: &AppState,
message: GenerationResultSubscribeMessage,
) -> Result<(), AppError> {
if !state.config.wechat_mini_program_subscribe_message_enabled {
return Ok(());
}
let template_id = state
.config
.wechat_mini_program_generation_result_template_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信订阅消息模板 ID 未配置")
})?;
let user = state
.auth_user_service()
.get_user_by_id(&message.owner_user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信订阅消息用户失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在")
})?;
let openid = user
.wechat_account
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("用户未绑定微信小程序 openid")
})?;
state
.wechat_client()
.send_subscribe_message(WechatSubscribeMessageRequest {
touser: openid.to_string(),
template_id: template_id.to_string(),
page: message
.page
.clone()
.or_else(|| Some("/pages/web-view/index".to_string())),
miniprogram_state: Some(
normalize_miniprogram_state(
&state.config.wechat_mini_program_subscribe_message_state,
)
.to_string(),
),
lang: Some("zh_CN".to_string()),
data: build_generation_result_template_data(&message),
})
.await
.map_err(map_wechat_error)?;
info!(
owner_user_id = %message.owner_user_id,
template_id,
"微信小程序生成结果订阅消息已发送"
);
Ok(())
}
fn build_generation_result_template_data(
message: &GenerationResultSubscribeMessage,
) -> BTreeMap<String, String> {
BTreeMap::from([
(
"thing1".to_string(),
truncate_template_value(GENERATION_RESULT_TASK_NAME, 20),
),
(
"phrase2".to_string(),
truncate_template_value(message.status.template_status_label(), 5),
),
(
"time4".to_string(),
truncate_template_value(
&format_generation_completed_time(message.completed_at_micros),
20,
),
),
(
"thing5".to_string(),
truncate_template_value(
message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME),
20,
),
),
(
"number6".to_string(),
truncate_template_value(&message.consumed_points.to_string(), 32),
),
])
}
impl GenerationResultSubscribeMessageStatus {
fn template_status_label(self) -> &'static str {
match self {
Self::Succeeded => "已完成",
Self::Failed => "生成失败",
}
}
}
fn truncate_template_value(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
let mut result = String::new();
for character in trimmed.chars().take(max_chars) {
result.push(character);
}
if result.is_empty() {
DEFAULT_WORK_NAME.to_string()
} else {
result
}
}
fn format_generation_completed_time(completed_at_micros: i64) -> String {
format_timestamp_micros(completed_at_micros)
.replace('T', " ")
.split('.')
.next()
.unwrap_or_default()
.to_string()
}
fn normalize_miniprogram_state(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"developer" | "develop" | "dev" => "developer",
"trial" => "trial",
_ => "formal",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_generation_result_template_uses_failed_status_and_zero_points() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
work_name: Some("首关拼图".to_string()),
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: 1_762_000_000_000_000,
page: None,
});
assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败"));
assert_eq!(data.get("number6").map(String::as_str), Some("0"));
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "platform-wechat"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View File

@@ -0,0 +1,234 @@
use std::{collections::BTreeMap, error::Error, fmt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::warn;
use url::Url;
pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/stable_token";
pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatConfig {
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub stable_access_token_endpoint: String,
pub subscribe_message_endpoint: String,
}
#[derive(Clone, Debug)]
pub struct WechatClient {
client: Client,
config: WechatConfig,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatSubscribeMessageRequest {
pub touser: String,
pub template_id: String,
pub page: Option<String>,
pub miniprogram_state: Option<String>,
pub lang: Option<String>,
pub data: BTreeMap<String, String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum WechatError {
InvalidConfig(String),
RequestFailed(String),
DeserializeFailed(String),
Upstream(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WechatErrorKind {
InvalidConfig,
RequestFailed,
DeserializeFailed,
Upstream,
}
#[derive(Debug, Deserialize)]
struct WechatStableAccessTokenResponse {
access_token: Option<String>,
errcode: Option<i64>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatSubscribeMessageResponse {
errcode: i64,
errmsg: Option<String>,
}
#[derive(Debug, Serialize)]
struct WechatTemplateDataValue {
value: String,
}
impl WechatClient {
pub fn new(config: WechatConfig) -> Self {
Self {
client: Client::new(),
config,
}
}
pub async fn send_subscribe_message(
&self,
request: WechatSubscribeMessageRequest,
) -> Result<(), WechatError> {
let app_id = self
.config
.app_id
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
let app_secret = self
.config
.app_secret
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
let access_token = self.request_access_token(app_id, app_secret).await?;
let mut send_url =
Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}"))
})?;
send_url
.query_pairs_mut()
.append_pair("access_token", &access_token);
let data = request
.data
.into_iter()
.map(|(key, value)| (key, WechatTemplateDataValue { value }))
.collect::<BTreeMap<_, _>>();
let payload = json!({
"touser": request.touser,
"template_id": request.template_id,
"page": request.page,
"miniprogram_state": request.miniprogram_state,
"lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()),
"data": data,
});
let response = self
.client
.post(send_url.as_str())
.json(&payload)
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信订阅消息请求失败");
WechatError::RequestFailed("微信订阅消息请求失败".to_string())
})?
.json::<WechatSubscribeMessageResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信订阅消息响应解析失败");
WechatError::DeserializeFailed("微信订阅消息响应非法".to_string())
})?;
if response.errcode != 0 {
return Err(WechatError::Upstream(format!(
"微信订阅消息发送失败:{}",
response.errmsg.unwrap_or_else(|| format!(
"subscribeMessage.send 返回错误 {}",
response.errcode
))
)));
}
Ok(())
}
async fn request_access_token(
&self,
app_id: &str,
app_secret: &str,
) -> Result<String, WechatError> {
let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
})?;
let payload = self
.client
.post(url.as_str())
.json(&json!({
"grant_type": "client_credential",
"appid": app_id,
"secret": app_secret,
"force_refresh": false,
}))
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 stable_token 请求失败");
WechatError::RequestFailed("微信 stable_token 请求失败".to_string())
})?
.json::<WechatStableAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 stable_token 响应解析失败");
WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string())
})?;
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
return Err(WechatError::Upstream(format!(
"微信 stable_token 返回错误:{}",
payload
.errmsg
.unwrap_or_else(|| format!("errcode={errcode}"))
)));
}
payload
.access_token
.and_then(|value| non_empty_owned(value))
.ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()))
}
}
impl WechatError {
pub fn kind(&self) -> WechatErrorKind {
match self {
Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig,
Self::RequestFailed(_) => WechatErrorKind::RequestFailed,
Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed,
Self::Upstream(_) => WechatErrorKind::Upstream,
}
}
}
impl fmt::Display for WechatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::RequestFailed(message)
| Self::DeserializeFailed(message)
| Self::Upstream(message) => f.write_str(message),
}
}
}
impl Error for WechatError {}
fn non_empty(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn non_empty_owned(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}