41b882e1fe
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Failing after 36s
Project CI / Repository checks (pull_request) Failing after 26s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 12m41s
- 主站「我的」页签常用功能宫格补回「邀请好友」,邀请码与邀请链接拆成两个复制动作 - 邀请弹层下沉到 packages/shared/src/components/PlatformProfileReferralModal,主站与客户端共用一份实现 - 邀请奖励说明不再写死次数与金额,每日上限由 todayInviterRewardCount 与 todayInviterRewardRemaining 推导 - 奖励说明从面板正文移入右上角帮助按钮,鼠标悬浮展开、触屏与键盘点击开合 - 帮助按钮复用平台 .platform-icon-button 配色,说明气泡复用 PlatformStatusMessage 的 warning token - AGC 客户端账号菜单新增「邀请码」与「玩家社区」两项,弹层标题与菜单入口文案保持一致 - 新增 Tauri 命令 read_profile_referral_invite_center,用登录 origin 补全 inviteLinkPath 后交给渲染层 - 客户端邀请中心按登录账号隔离,切号重置并作废在飞响应,不本地另存邀请码 - 新命令登记进 apps/ai-game-creator-shell/scripts/check-config.mjs 的 native-only 白名单门禁 - 微信群与 QQ 群二维码从 media/social-media-group 迁入共享组件 assets - 移除已下线文件的 Tailwind @source 与客户端专用弹层样式,原地删除旧邀请弹层实现 - 同步决策记录与产品基线文档中邀请奖励、跨端复用与文案口径
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
//! 账户与钱包 typed facade。
|
||
//!
|
||
//! 渲染层只提交结构化业务参数(商品 ID、订单 ID、兑换码);平台 origin、Bearer、
|
||
//! 响应 envelope 解析与错误分类都在 Rust 内完成。access token 过期时返回稳定的
|
||
//! `authentication-required`,由调用方按既有会话续期策略决定是否重试一次。
|
||
|
||
use crate::http_client::agc_main_site_client_builder;
|
||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||
use reqwest::{Method, StatusCode};
|
||
use serde::de::DeserializeOwned;
|
||
use serde::Serialize;
|
||
use serde_json::Value;
|
||
use shared_contracts::runtime::{
|
||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
|
||
ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
|
||
ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||
};
|
||
use std::time::Duration;
|
||
use url::Url;
|
||
|
||
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||
const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||
const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
|
||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||
const MAX_BUSINESS_ID_CHARS: usize = 128;
|
||
const MAX_REDEEM_CODE_CHARS: usize = 128;
|
||
|
||
fn require_session() -> Result<PlatformSessionSnapshot, String> {
|
||
current_platform_session()
|
||
.ok_or_else(|| "authentication-required: 请先登录后再继续".to_string())
|
||
}
|
||
|
||
fn build_client() -> Result<reqwest::Client, String> {
|
||
agc_main_site_client_builder()
|
||
.connect_timeout(Duration::from_secs(10))
|
||
.timeout(HTTP_TIMEOUT)
|
||
.build()
|
||
.map_err(|_| "创建账户网络客户端失败".to_string())
|
||
}
|
||
|
||
fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result<String, String> {
|
||
let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/')))
|
||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||
{
|
||
let mut path = url
|
||
.path_segments_mut()
|
||
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||
for segment in segments {
|
||
if segment.is_empty() || segment.contains(['/', '?', '#']) {
|
||
return Err("账户请求路径无效".to_string());
|
||
}
|
||
path.push(segment);
|
||
}
|
||
}
|
||
Ok(url.to_string())
|
||
}
|
||
|
||
fn bounded_business_id(value: &str, label: &str) -> Result<String, String> {
|
||
let value = value.trim();
|
||
if value.is_empty()
|
||
|| value.chars().count() > MAX_BUSINESS_ID_CHARS
|
||
|| value.chars().any(char::is_control)
|
||
{
|
||
return Err(format!("{label}无效"));
|
||
}
|
||
Ok(value.to_string())
|
||
}
|
||
|
||
fn error_message(body: &str) -> Option<String> {
|
||
let value = serde_json::from_str::<Value>(body).ok()?;
|
||
let error = value.get("error").unwrap_or(&value);
|
||
error
|
||
.get("message")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
|
||
if status == StatusCode::UNAUTHORIZED {
|
||
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
|
||
}
|
||
if status == StatusCode::FORBIDDEN {
|
||
return format!(
|
||
"permission-denied: {}",
|
||
error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||
);
|
||
}
|
||
let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||
format!("{fallback}:{detail}")
|
||
}
|
||
|
||
fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||
let value: Value =
|
||
serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?;
|
||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||
return Err(format!(
|
||
"{fallback}:{}",
|
||
error_message(body).unwrap_or_else(|| "服务端请求失败".to_string())
|
||
));
|
||
}
|
||
Ok(value.get("data").cloned().unwrap_or(value))
|
||
}
|
||
|
||
/// 客户端邀请码弹层需要的完整数据。
|
||
///
|
||
/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
|
||
/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct ClientProfileReferralInviteCenter {
|
||
pub(crate) center: ProfileReferralInviteCenterResponse,
|
||
pub(crate) invite_url: String,
|
||
}
|
||
|
||
fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
|
||
if invite_link_path.trim().is_empty() {
|
||
return api_base_url.trim_end_matches('/').to_string();
|
||
}
|
||
match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
|
||
.and_then(|base| base.join(invite_link_path))
|
||
{
|
||
Ok(url) => url.to_string(),
|
||
Err(_) => invite_link_path.to_string(),
|
||
}
|
||
}
|
||
|
||
async fn request_json<T: DeserializeOwned>(
|
||
client: &reqwest::Client,
|
||
snapshot: &PlatformSessionSnapshot,
|
||
method: Method,
|
||
segments: &[&str],
|
||
body: Option<Value>,
|
||
fallback: &str,
|
||
) -> Result<T, String> {
|
||
let url = endpoint(snapshot, segments)?;
|
||
let mut request = client
|
||
.request(method, &url)
|
||
.bearer_auth(&snapshot.access_token)
|
||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
|
||
.header(reqwest::header::ACCEPT, "application/json");
|
||
if let Some(body) = body {
|
||
request = request.json(&body);
|
||
}
|
||
let response = request.send().await.map_err(|error| {
|
||
if error.is_timeout() {
|
||
format!("{fallback}:请求超时,请稍后重试")
|
||
} else {
|
||
format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试")
|
||
}
|
||
})?;
|
||
let status = response.status();
|
||
let text = response
|
||
.text()
|
||
.await
|
||
.map_err(|_| format!("{fallback}:读取响应失败"))?;
|
||
if !status.is_success() {
|
||
return Err(map_http_error(status, &text, fallback));
|
||
}
|
||
let data = unwrap_envelope(&text, fallback)?;
|
||
serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_profile_recharge_center() -> Result<ProfileRechargeCenterResponse, String>
|
||
{
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::GET,
|
||
&["api", "profile", "recharge-center"],
|
||
None,
|
||
"读取泥点明细失败",
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerResponse, String> {
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::GET,
|
||
&["api", "profile", "wallet-ledger"],
|
||
None,
|
||
"读取泥点账单失败",
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_profile_referral_invite_center(
|
||
) -> Result<ClientProfileReferralInviteCenter, String> {
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
let center: ProfileReferralInviteCenterResponse = request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::GET,
|
||
&["api", "profile", "referrals", "invite-center"],
|
||
None,
|
||
"读取邀请码失败",
|
||
)
|
||
.await?;
|
||
let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
|
||
Ok(ClientProfileReferralInviteCenter { center, invite_url })
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn create_profile_recharge_order(
|
||
product_id: String,
|
||
) -> Result<CreateProfileRechargeOrderResponse, String> {
|
||
let product_id = bounded_business_id(&product_id, "充值商品标识")?;
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::POST,
|
||
&["api", "profile", "recharge", "orders"],
|
||
Some(serde_json::json!({
|
||
"productId": product_id,
|
||
"paymentChannel": "wechat_native",
|
||
})),
|
||
"充值失败",
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn confirm_wechat_profile_recharge_order(
|
||
order_id: String,
|
||
) -> Result<ConfirmWechatProfileRechargeOrderResponse, String> {
|
||
let order_id = bounded_business_id(&order_id, "充值订单标识")?;
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::POST,
|
||
&[
|
||
"api",
|
||
"profile",
|
||
"recharge",
|
||
"orders",
|
||
order_id.as_str(),
|
||
"wechat",
|
||
"confirm",
|
||
],
|
||
None,
|
||
"确认微信支付订单失败",
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn redeem_profile_reward_code(
|
||
code: String,
|
||
) -> Result<RedeemProfileRewardCodeResponse, String> {
|
||
let code = code.trim();
|
||
if code.is_empty() || code.chars().count() > MAX_REDEEM_CODE_CHARS {
|
||
return Err("兑换码无效".to_string());
|
||
}
|
||
let snapshot = require_session()?;
|
||
let client = build_client()?;
|
||
request_json(
|
||
&client,
|
||
&snapshot,
|
||
Method::POST,
|
||
&["api", "profile", "redeem-codes", "redeem"],
|
||
Some(serde_json::json!({ "code": code })),
|
||
"兑换失败",
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn snapshot() -> PlatformSessionSnapshot {
|
||
PlatformSessionSnapshot {
|
||
user_id: "user-1".to_string(),
|
||
access_token: "token".to_string(),
|
||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||
identity_generation: 1,
|
||
revision: 1,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn endpoint_encodes_path_segments_and_keeps_origin() {
|
||
assert_eq!(
|
||
endpoint(
|
||
&snapshot(),
|
||
&["api", "profile", "recharge", "orders", "order-1", "wechat", "confirm"]
|
||
)
|
||
.expect("endpoint"),
|
||
"https://dev.genarrative.world/api/profile/recharge/orders/order-1/wechat/confirm"
|
||
);
|
||
assert!(endpoint(&snapshot(), &["api", "bad/id"]).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn expires_and_permissions_keep_stable_categories() {
|
||
assert_eq!(
|
||
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
|
||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
|
||
);
|
||
assert_eq!(
|
||
map_http_error(
|
||
StatusCode::FORBIDDEN,
|
||
r#"{"error":{"message":"无权"}}"#,
|
||
"读取失败"
|
||
),
|
||
"permission-denied: 无权"
|
||
);
|
||
assert_eq!(
|
||
map_http_error(
|
||
StatusCode::BAD_REQUEST,
|
||
r#"{"error":{"message":"余额不足"}}"#,
|
||
"充值失败"
|
||
),
|
||
"充值失败:余额不足"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_unwrap_reports_in_band_failures() {
|
||
assert_eq!(
|
||
unwrap_envelope(r#"{"ok":true,"data":{"walletBalance":1}}"#, "读取失败")
|
||
.expect("envelope")["walletBalance"],
|
||
1
|
||
);
|
||
assert_eq!(
|
||
unwrap_envelope(
|
||
r#"{"ok":false,"error":{"message":"登录已失效"}}"#,
|
||
"读取失败"
|
||
)
|
||
.expect_err("in-band failure"),
|
||
"读取失败:登录已失效"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn business_identifiers_are_bounded_before_network() {
|
||
assert!(bounded_business_id(" ", "充值商品标识").is_err());
|
||
assert!(bounded_business_id("product-1", "充值商品标识").is_ok());
|
||
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
|
||
assert!(bounded_business_id("bad\nid", "订单").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn invite_link_resolves_against_login_origin() {
|
||
assert_eq!(
|
||
resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
|
||
"https://dev.genarrative.world/?inviteCode=SY12345678"
|
||
);
|
||
assert_eq!(
|
||
resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
|
||
"https://dev.genarrative.world/invite/SY12345678"
|
||
);
|
||
assert_eq!(
|
||
resolve_invite_url("https://dev.genarrative.world", ""),
|
||
"https://dev.genarrative.world"
|
||
);
|
||
}
|
||
}
|