Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
T
kdletters 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
我的页签恢复邀请好友入口,AGC 客户端账号菜单接入邀请码与玩家社区
- 主站「我的」页签常用功能宫格补回「邀请好友」,邀请码与邀请链接拆成两个复制动作
- 邀请弹层下沉到 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 与客户端专用弹层样式,原地删除旧邀请弹层实现
- 同步决策记录与产品基线文档中邀请奖励、跨端复用与文案口径
2026-10-01 19:46:18 +08:00

377 lines
12 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_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, &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"
);
}
}