Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
T
k88936 675dfe0c8f account_api 抽出 error_field 消除 message/code 的重复解析
error_message / error_code 只差字段名,合并到 error_field(body, field)
调用点保留 error_message / error_code 两个薄封装
2026-10-02 22:08:44 +08:00

387 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 账户与钱包 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_field(body: &str, field: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(body).ok()?;
let error = value.get("error").unwrap_or(&value);
error
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|detail| !detail.is_empty())
.map(ToString::to_string)
}
fn error_message(body: &str) -> Option<String> {
error_field(body, "message")
}
fn error_code(body: &str) -> Option<String> {
error_field(body, "code")
}
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)
.or_else(|| error_code(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) {
let detail = error_message(body)
.or_else(|| error_code(body))
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
return Err(format!("{fallback}:{detail}"));
}
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, &center.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"
);
}
}