修复买断制付费评审问题:后台消耗统计、定价上限校验、付费入口自动化断言
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Successful in 5m36s

- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数
- profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase
- module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言
- 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源
- api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement
- 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行
- spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例
- E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数
- 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)
This commit is contained in:
2026-10-06 02:24:40 +08:00
parent 4d901eec65
commit dcef9b62a8
15 changed files with 980 additions and 188 deletions
+160 -29
View File
@@ -2781,27 +2781,72 @@ async fn fetch_admin_dashboard_wallet_stats(
if !range.contains_day_key(day_key) {
continue;
}
match source_type.as_str() {
"asset_operation_consume" | "llm_router_consume" if amount_delta < 0 => {
stats
.consumed_mud_points
.add(day_key, amount_delta.unsigned_abs());
}
// 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
"points_recharge" if amount_delta > 0 => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
_ => {}
}
accumulate_admin_dashboard_wallet_ledger_row(
&mut stats,
&source_type,
amount_delta,
day_key,
);
}
stats
}
/// 单条钱包流水对后台面板的贡献。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminDashboardWalletLedgerEffect {
/// 真实扣费:资产操作、LLM Router 与买断制购买。
Consume,
/// 退还 / 冲正:生成失败与精选审核返还,以及 LLM Router 的正向冲正流水。
Refund,
/// 充值入账。
Recharge,
/// 不计入消耗、退还或充值。
Ignore,
}
/// 后台消耗口径的来源白名单:与运行时消费投影(`GamePurchase` 也计入消耗)保持一致,
/// 新增流水来源时必须同步这里,否则后台统计会静默丢失这一类消耗。
fn resolve_admin_dashboard_wallet_ledger_effect(
source_type: &str,
amount_delta: i64,
) -> AdminDashboardWalletLedgerEffect {
match source_type {
"asset_operation_consume" | "llm_router_consume" | "game_purchase" if amount_delta < 0 => {
AdminDashboardWalletLedgerEffect::Consume
}
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
AdminDashboardWalletLedgerEffect::Refund
}
"points_recharge" if amount_delta > 0 => AdminDashboardWalletLedgerEffect::Recharge,
_ => AdminDashboardWalletLedgerEffect::Ignore,
}
}
/// 把一条已通过日期筛选的流水累加进后台统计。
fn accumulate_admin_dashboard_wallet_ledger_row(
stats: &mut AdminDashboardWalletStats,
source_type: &str,
amount_delta: i64,
day_key: i64,
) {
match resolve_admin_dashboard_wallet_ledger_effect(source_type, amount_delta) {
AdminDashboardWalletLedgerEffect::Consume => {
stats
.consumed_mud_points
.add(day_key, amount_delta.unsigned_abs());
}
AdminDashboardWalletLedgerEffect::Refund => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Recharge => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Ignore => {}
}
}
async fn fetch_admin_dashboard_user_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
@@ -4800,6 +4845,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option<String> {
13 => "daily_free_reset",
14 => "recharge_refund_recovery",
15 => "llm_router_consume",
16 => "game_purchase",
_ => return Some(Value::Array(items.to_vec()).to_string()),
}
.to_string(),
@@ -5090,14 +5136,15 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory,
EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record,
admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload,
admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_enum_labels, build_admin_database_table_row,
build_admin_editor_showcase_campaign_image_confirm_request,
AdminDashboardGranularity, AdminDashboardSeries, AdminDashboardWalletLedgerEffect,
AdminDashboardWalletStats, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
SpacetimeSchemaResponse, accumulate_admin_dashboard_wallet_ledger_row,
admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
admin_editor_asset_group_payload, admin_editor_asset_payload_from_record,
admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_dashboard_chart, build_admin_database_enum_labels,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit,
@@ -5110,10 +5157,11 @@ mod tests {
parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response,
parse_spacetime_sql_count_response, parse_timestamp_text_to_micros,
resolve_admin_dashboard_range, resolve_admin_dashboard_range_at,
resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters,
sum_admin_user_recharged_cents, timestamp_value_to_micros, trim_preview,
validate_admin_editor_asset_cursor, validate_admin_external_api_key_query,
verify_admin_password, wallet_ledger_source_type_to_string,
resolve_admin_dashboard_wallet_ledger_effect, resolve_admin_database_table_sql_limit,
resolve_admin_editor_asset_filters, sum_admin_user_recharged_cents,
timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor,
validate_admin_external_api_key_query, verify_admin_password,
wallet_ledger_source_type_to_string,
};
use std::collections::BTreeMap;
@@ -6311,6 +6359,89 @@ mod tests {
wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(),
Some("llm_router_consume")
);
// 买断制购买是第 17 个变体(索引 16);漏映射时后台会直接展示原始数组文本。
assert_eq!(
wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(),
Some("game_purchase")
);
assert_eq!(
wallet_ledger_source_type_to_string(&json!("GamePurchase")).as_deref(),
Some("game_purchase")
);
}
/// `game_purchase` 必须计入后台「消耗泥点」,否则买断消耗会整类从后台统计里丢失。
#[test]
fn dashboard_wallet_ledger_effect_counts_game_purchase_as_consumption() {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_consume", -40),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", -7),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", -30),
AdminDashboardWalletLedgerEffect::Consume
);
// LLM Router 的正向冲正按退还对冲消耗;买断制没有退还来源,正向流水不计入。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", 7),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_refund", 40),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("points_recharge", 100),
AdminDashboardWalletLedgerEffect::Recharge
);
// 未列入白名单的来源按不计入处理,避免新增变体被悄悄算进某一类。
for source_type in [
"snapshot_sync",
"membership_period_grant",
"redeem_code_reward",
] {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect(source_type, 100),
AdminDashboardWalletLedgerEffect::Ignore,
"source_type={source_type}"
);
}
// 买断制购买没有成对的退还来源,正向流水不能算成退还。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", 30),
AdminDashboardWalletLedgerEffect::Ignore
);
}
/// 一条买断流水要真的累加进面板序列,而不只是分类正确。
#[test]
fn dashboard_wallet_stats_accumulate_game_purchase_consumption() {
let mut stats = AdminDashboardWalletStats {
consumed_mud_points: AdminDashboardSeries::default(),
refunded_mud_points: AdminDashboardSeries::default(),
recharged_mud_points: 0,
warnings: vec![],
};
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "game_purchase", -30, 100);
accumulate_admin_dashboard_wallet_ledger_row(
&mut stats,
"asset_operation_consume",
-40,
100,
);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "llm_router_consume", 7, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "asset_operation_refund", 40, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "points_recharge", 100, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "snapshot_sync", 500, 100);
assert_eq!(stats.consumed_mud_points.value(100), 70);
assert_eq!(stats.consumed_mud_points.total(), 70);
assert_eq!(stats.refunded_mud_points.value(100), 47);
assert_eq!(stats.recharged_mud_points, 100);
}
#[test]
@@ -1,5 +1,6 @@
use std::{
collections::{BTreeMap, HashMap, VecDeque},
future::Future,
io::Write,
sync::{Arc, Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
@@ -829,12 +830,49 @@ async fn serve_release_entry(
/// 公开发行网关。
///
/// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和
/// 未公开版本不会因为知道 ID 而可读。
/// 未公开版本不会因为知道 ID 而可读。付费作品的公开同源路径同样关闭(404),
/// 判定全部发生在读取包字节之前。
async fn serve_release_asset(
State(state): State<AppState>,
headers: HeaderMap,
Path((game_id, asset_path)): Path<(String, String)>,
) -> Result<Response, AppError> {
let public_game_state = state.clone();
serve_public_release_asset(
headers,
game_id,
asset_path,
move |game_id: String| async move {
public_game_state
.spacetime_client()
.get_public_game_distribution_game(game_id)
.await
},
move |game_id: String, version_id: String| async move {
release_package_bytes(&state, &game_id, &version_id).await
},
)
.await
}
/// 把「公开投影读取」与「包字节读取」都做成注入依赖的发行网关实现。
///
/// 发行网关是公开无鉴权端点,包字节要经 OSS 读取;「付费作品必须 404 且不读包字节」这条契约
/// 只有在判定顺序可观测时才守得住,因此准入判定全部排在 `load_package` 之前——单测用记录
/// 调用次数的 loader 断言拒绝路径确实没有读字节,而不是靠人读代码确认顺序。
async fn serve_public_release_asset<G, GFut, P, PFut>(
headers: HeaderMap,
game_id: String,
asset_path: String,
load_public_game: G,
load_package: P,
) -> Result<Response, AppError>
where
G: FnOnce(String) -> GFut,
GFut: Future<Output = Result<Option<GameDistributionPublicGameRecord>, SpacetimeClientError>>,
P: FnOnce(String, String) -> PFut,
PFut: Future<Output = Result<Arc<Vec<u8>>, AppError>>,
{
// 发行文件必须由独立来源提供。带上平台 Cookie 的请求说明它正落在主站来源上,
// 此时同源脚本可以读到平台会话,必须直接关闭而不是降级服务。
if headers.contains_key(header::COOKIE) {
@@ -858,9 +896,7 @@ async fn serve_release_asset(
);
AppError::from_status(StatusCode::NOT_FOUND)
})?;
let public_game = state
.spacetime_client()
.get_public_game_distribution_game(game_id.clone())
let public_game = load_public_game(game_id.clone())
.await
.map_err(map_spacetime_error)?
.ok_or_else(|| {
@@ -896,6 +932,7 @@ async fn serve_release_asset(
return Err(AppError::from_status(StatusCode::NOT_FOUND));
}
// 付费作品只经播放会话路径开放;公开同源发行路径对所有人关闭,避免猜测 gameId 绕过购买。
// 这条判定必须留在 `load_package` 之前:否则付费包字节已经读出来了。
if public_game.game.price_mud_points > 0 {
debug!(
operation = "release_rejected",
@@ -907,7 +944,7 @@ async fn serve_release_asset(
);
return Err(AppError::from_status(StatusCode::NOT_FOUND));
}
let package = release_package_bytes(&state, &game_id, &version.version_id).await?;
let package = load_package(game_id.clone(), version.version_id.clone()).await?;
let etag = release_asset_etag(&version.version_id, &asset_path);
release_package_asset_response(
&package,
@@ -1804,9 +1841,12 @@ async fn create_version(
///
/// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥,
/// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。
/// 现役登录链路下后台管理员令牌会先被 `/api/*` 用户路由的 `require_bearer_auth` 判为
/// 无效登录态(401),因此 403 分支只在「用户令牌带 admin 角色」时可达,保留为纵深防御。
///
/// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400;
/// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;管理员令牌 403;游戏不可见 / 不存在 404。
/// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;带 admin 角色的用户令牌 403
/// `GAME_PURCHASE_ADMIN_NOT_ALLOWED`;游戏不可见 / 不存在 404。
/// 免费游戏与已购买账号都不产生新扣费。
async fn purchase_game(
State(state): State<AppState>,
@@ -2950,6 +2990,39 @@ async fn serve_admin_version_preview_asset_inner(
/// 播放会话短时效:付费作品每次进入游戏都重新签发,过期后必须重新鉴权,不能长期留一个令牌。
const GAME_PLAY_SESSION_TTL_SECONDS: i64 = 2 * 60 * 60;
/// 付费作品播放会话的准入主体。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PaidPlaySessionViewer {
/// 后台管理员令牌:免购买试玩。
Admin,
/// 用户令牌:作者本人免购买,其余账号必须已有购买记录。
User { is_author: bool, has_purchase: bool },
}
/// 付费作品播放会话的准入判定(免费作品在更早的分支直接回公开入口,不进入这里)。
///
/// 管理员与作者本人免购买;其余账号必须已有购买记录,否则失败关闭——判定不通过时
/// 调用方直接返回,绝不会签发播放令牌。
fn resolve_paid_play_session_entitlement(viewer: PaidPlaySessionViewer) -> Result<(), AppError> {
match viewer {
PaidPlaySessionViewer::Admin => Ok(()),
PaidPlaySessionViewer::User {
is_author: true, ..
} => Ok(()),
PaidPlaySessionViewer::User {
is_author: false,
has_purchase: true,
} => Ok(()),
PaidPlaySessionViewer::User {
is_author: false,
has_purchase: false,
} => {
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("这款游戏需要先购买才能游玩"))
}
}
}
/// 为付费作品签发播放会话;免费作品直接回现有公开入口,不引入新的游玩路径。
///
/// 鉴权顺序:管理员令牌(按现有后台鉴权判定,免购买)→ 用户令牌(作者本人或已购买)。
@@ -2990,7 +3063,10 @@ async fn create_game_play_session(
let admin = try_authenticate_admin_from_headers(&state, &headers).await;
let identity = match admin {
// 管理员免购买试玩:会话仍绑定具体身份,避免把管理身份折叠成匿名令牌。
Some(admin) => admin.session().subject.clone(),
Some(admin) => {
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::Admin)?;
admin.session().subject.clone()
}
None => {
let authenticated = optional_access_token_from_headers(
&state,
@@ -3005,17 +3081,20 @@ async fn create_game_play_session(
})?;
let user_id = authenticated.claims().user_id().to_string();
let is_author = game.owner_user_id == user_id;
if !is_author {
let has_purchase = if is_author {
false
} else {
let (purchase, _) = state
.spacetime_client()
.get_game_distribution_purchase(game_id.clone(), user_id.clone())
.await
.map_err(map_spacetime_error)?;
if purchase.is_none() {
return Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("这款游戏需要先购买才能游玩"));
}
}
purchase.is_some()
};
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author,
has_purchase,
})?;
user_id
}
};
@@ -5537,6 +5616,200 @@ mod tests {
decoded
}
/// 付费作品的公开同源发行路径必须 404,并且**在读取包字节之前**就失败关闭:否则包字节
/// 已经经 OSS 读出,未购买者只差一个响应体。这里用记录调用次数的 loader 把顺序变成断言,
/// 而不是靠人工 E2E 或读代码确认。
#[tokio::test]
async fn paid_game_public_release_path_is_rejected_without_reading_package_bytes() {
let paid_game = || {
public_game_record(
paid_game_record("user_author", 30),
public_version_record(None, None),
)
};
let pending_version = || {
let mut version = public_version_record(None, None);
version.status = "pending_review".to_string();
public_game_record(paid_game_record("user_author", 30), version)
};
let platform_cookie = || {
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, HeaderValue::from_static("refresh=token"));
headers
};
let cases: [(
&str,
HeaderMap,
Option<GameDistributionPublicGameRecord>,
&str,
StatusCode,
); 7] = [
(
"付费作品入口",
HeaderMap::new(),
Some(paid_game()),
"index.html",
StatusCode::NOT_FOUND,
),
(
"付费作品包内资源",
HeaderMap::new(),
Some(paid_game()),
"assets/main.js",
StatusCode::NOT_FOUND,
),
(
"付费作品未知扩展名",
HeaderMap::new(),
Some(paid_game()),
"secret.bin",
StatusCode::NOT_FOUND,
),
(
"带平台 Cookie 的付费作品请求",
platform_cookie(),
Some(paid_game()),
"index.html",
StatusCode::FORBIDDEN,
),
(
"没有公开可玩版本",
HeaderMap::new(),
None,
"index.html",
StatusCode::NOT_FOUND,
),
(
"公开投影缺当前版本",
HeaderMap::new(),
Some(GameDistributionPublicGameRecord {
game: paid_game_record("user_author", 30),
current_version: None,
rating_summary: GameDistributionRatingSummaryRecord {
average_score: None,
rating_count: 0,
},
}),
"index.html",
StatusCode::NOT_FOUND,
),
(
"当前版本未公开",
HeaderMap::new(),
Some(pending_version()),
"index.html",
StatusCode::NOT_FOUND,
),
];
let package_reads = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
for (label, headers, public_game, asset_path, expected_status) in cases {
let package_reads = package_reads.clone();
let error = serve_public_release_asset(
headers,
"game_1".to_string(),
asset_path.to_string(),
move |_game_id| async move { Ok(public_game) },
move |game_id, version_id| {
let package_reads = package_reads.clone();
async move {
package_reads
.lock()
.expect("reads lock")
.push((game_id, version_id));
Ok(Arc::new(Vec::new()))
}
},
)
.await
.expect_err(label);
assert_eq!(error.status_code(), expected_status, "{label}");
}
assert_eq!(
package_reads.lock().expect("reads lock").len(),
0,
"被拒绝的发行请求不得读取包字节"
);
}
/// 免费作品照旧放行:包字节真的被读出并原样下发。
#[tokio::test]
async fn free_game_public_release_path_serves_the_package_asset() {
let marker = "genarrative-free-release-marker";
let package = release_package_fixture("assets/main.js", marker.as_bytes());
let reads = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let response = serve_public_release_asset(
HeaderMap::new(),
"game_1".to_string(),
"assets/main.js".to_string(),
move |_game_id| async move {
Ok(Some(public_game_record(
paid_game_record("user_author", 0),
public_version_record(Some("/games/game_1/"), None),
)))
},
{
let reads = reads.clone();
let package = package.clone();
move |game_id, version_id| {
let reads = reads.clone();
let package = package.clone();
async move {
reads
.lock()
.expect("reads lock")
.push((game_id, version_id));
Ok(Arc::new(package))
}
}
},
)
.await
.expect("免费作品的公开发行路径必须放行");
assert_eq!(
reads.lock().expect("reads lock").as_slice(),
[("game_1".to_string(), "version_1".to_string())]
);
assert_eq!(response.status(), StatusCode::OK);
let body = release_response_body(response).await;
assert!(
String::from_utf8_lossy(&body).contains(marker),
"响应正文必须包含包内资源字节"
);
}
/// 未购买账号的播放会话入口必须被拒;作者本人、已购买账号与管理员免购买放行。
#[test]
fn paid_play_session_entitlement_rejects_unpurchased_and_grants_owner_buyer_admin() {
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::Admin).is_ok(),
"管理员免购买"
);
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: true,
has_purchase: false,
})
.is_ok(),
"作者本人免购买"
);
assert!(
resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: false,
has_purchase: true,
})
.is_ok(),
"已购买账号放行"
);
let refused = resolve_paid_play_session_entitlement(PaidPlaySessionViewer::User {
is_author: false,
has_purchase: false,
})
.expect_err("未购买账号必须被拒");
assert_eq!(refused.status_code(), StatusCode::FORBIDDEN);
assert_eq!(refused.message(), "这款游戏需要先购买才能游玩");
}
#[tokio::test]
async fn release_asset_response_gzips_accepted_text_and_keeps_binary_untouched() {
let script = "console.log('genarrative-game');\n".repeat(64);
@@ -1,6 +1,6 @@
use std::{error::Error, fmt};
use crate::{GameVersionStatus, GameVisibility};
use crate::{GameVersionStatus, GameVisibility, domain::MAX_GAME_PRICE_MUD_POINTS};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameDistributionFieldError {
@@ -33,7 +33,13 @@ impl fmt::Display for GameDistributionFieldError {
Self::MissingReviewReason => "拒绝审核必须提供理由",
Self::InvalidVersionNumber => "版本号必须是大于 0 的整数",
Self::VersionNumberExhausted => "版本号已达上限,无法自动递增",
Self::InvalidGamePrice => "priceMudPoints 必须是 0 到 1000000 之间的整数",
// 上限只从常量取,避免改价格上限时漏改文案、前后端口径分叉。
Self::InvalidGamePrice => {
return write!(
formatter,
"priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数"
);
}
Self::MissingWalletLedgerId => "walletLedgerId 不能为空",
};
formatter.write_str(message)
@@ -122,3 +128,19 @@ fn _visibility_is_exhaustive(visibility: GameVisibility) -> bool {
GameVisibility::Unpublished | GameVisibility::Published | GameVisibility::Suspended
)
}
#[cfg(test)]
mod tests {
use super::*;
/// 价格上限只在 `domain::MAX_GAME_PRICE_MUD_POINTS` 定义一次;文案必须插值该常量,
/// 否则改上限时前端 / AGC 会继续按旧上限校验,两侧口径分叉。
#[test]
fn invalid_game_price_message_interpolates_the_price_limit_constant() {
let message = GameDistributionFieldError::InvalidGamePrice.to_string();
assert_eq!(
message,
format!("priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数")
);
}
}
+6 -4
View File
@@ -1705,8 +1705,10 @@ fn release_gateway_upstream_path(path: &str) -> Option<String> {
/// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions/<token>/…`。
///
/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:只认带尾斜杠的
/// 前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api` 路由。
/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:`/play-sessions/<token>`
/// 与 `/play-sessions/<token>/…` 都属于该前缀(前缀含 `<token>` 前的斜杠),只有裸前缀
/// `/api/game-distribution/play-sessions` 继续走通用 `/api`。该前缀下 api-server 只注册 GET 入口
/// 与包内资源;创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`,不在此前缀下。
fn is_play_session_proxy_path(path: &str) -> bool {
path.starts_with("/api/game-distribution/play-sessions/")
}
@@ -4124,8 +4126,8 @@ mod tests {
);
}
// 创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api`,
// 它需要账号凭证,绝不能跟着清空 Cookie。
// 裸前缀 `/api/game-distribution/play-sessions`(无 token)仍然走通用 `/api`:
// 它不是播放会话资源,绝不能跟着清空 Cookie。
for path in [
"/api/game-distribution/play-sessions",
"/api/game-distribution/games/game_1/play-session",
@@ -3794,6 +3794,40 @@ fn resolve_game_distribution_purchase_decision(
.map_err(|error| error.to_string())
}
/// 购买事务在扣费前的完整前置判定结果。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GamePurchasePreChargePlan {
/// 换幂等键重复购买:已有所有权时只补记收据并回传既有记录,绝不扣费。
ReplayExistingOwnership,
/// 首次购买:判定与期望价都已通过,允许进入扣费步骤。
Charge,
}
/// 扣费前的唯一放行口:`Free` 失败关闭;`AlreadyOwned` 只重放;只有 `Purchasable` 且期望价
/// 一致才允许扣费。
///
/// 事务里 `consume_profile_wallet_points_for_game_purchase` 只能出现在本函数的 `Charge`
/// 分支之后,所以「先判定后扣费」是这条纯函数上的可断言语义,不再依赖源码扫描。
fn resolve_game_purchase_pre_charge_plan(
decision: module_game_distribution::GamePurchaseDecision,
expected_price_mud_points: u64,
price_mud_points: u64,
) -> Result<GamePurchasePreChargePlan, String> {
match decision {
module_game_distribution::GamePurchaseDecision::Free => {
return Err("免费游戏不需要购买".to_string());
}
module_game_distribution::GamePurchaseDecision::AlreadyOwned => {
return Ok(GamePurchasePreChargePlan::ReplayExistingOwnership);
}
module_game_distribution::GamePurchaseDecision::Purchasable => {}
}
if expected_price_mud_points != price_mud_points {
return Err("价格已变化,请刷新后重试".to_string());
}
Ok(GamePurchasePreChargePlan::Charge)
}
/// 购买事务:先查幂等收据与现有所有权,再校验可见性/价格,最后扣费 + 落购买行 + 记收据。
fn purchase_game_distribution_game_tx(
ctx: &ReducerContext,
@@ -3839,12 +3873,13 @@ fn purchase_game_distribution_game_tx(
&user_id,
)?;
let price_mud_points = game.price_mud_points;
match decision {
module_game_distribution::GamePurchaseDecision::Free => {
return Err("免费游戏不需要购买".to_string());
}
match resolve_game_purchase_pre_charge_plan(
decision,
input.expected_price_mud_points,
price_mud_points,
)? {
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
module_game_distribution::GamePurchaseDecision::AlreadyOwned => {
GamePurchasePreChargePlan::ReplayExistingOwnership => {
let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录");
insert_game_distribution_receipt(
ctx,
@@ -3870,10 +3905,8 @@ fn purchase_game_distribution_game_tx(
true,
));
}
module_game_distribution::GamePurchaseDecision::Purchasable => {}
}
if input.expected_price_mud_points != price_mud_points {
return Err("价格已变化,请刷新后重试".to_string());
// 只有「可购买 + 期望价一致」才会走到这里,扣费前不再有其它分支。
GamePurchasePreChargePlan::Charge => {}
}
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
@@ -4383,35 +4416,33 @@ mod tests {
);
}
/// `ReducerContext` 无法在单测里构造,事务只能靠源扫描兜底:生产路径必须复用领域判定,
/// 且必须先判定后扣费,不得内联第二份可购买性判断。
/// 扣费前置:免费与价格已变化都失败关闭,已拥有只重放不扣费,只有可购买才允许进入扣费。
///
/// 这条断言直接打在生产事务唯一的扣费前放行口上(`consume_profile_wallet_points_for_game_purchase`
/// 只出现在 `Charge` 分支之后),因此重构不会误报,也不会因为注释里多写一句反例就变红。
#[test]
fn purchase_transaction_reuses_domain_decision_before_charging() {
let source = include_str!("game_distribution.rs");
let body = source
.split("fn purchase_game_distribution_game_tx(")
.nth(1)
.expect("购买事务应存在");
let body = &body[..body.find("\nfn ").unwrap_or(body.len())];
assert!(
body.contains("resolve_game_distribution_purchase_decision("),
"生产事务必须调用领域购买判定"
fn purchase_pre_charge_plan_gates_every_non_purchasable_decision() {
use module_game_distribution::GamePurchaseDecision;
assert_eq!(
resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Free, 100, 100)
.expect_err("免费游戏不能购买"),
"免费游戏不需要购买"
);
assert!(
body.contains("public_game_distribution_version(ctx, &game).is_some()"),
"可玩性口径必须与公开投影一致(活动版本存在且 published)"
// 已有所有权:即使客户端带的期望价与当前价不一致也只重放,绝不扣费。
assert_eq!(
resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::AlreadyOwned, 0, 100),
Ok(GamePurchasePreChargePlan::ReplayExistingOwnership)
);
assert!(
!body.contains("game.active_version_id.is_none()"),
"不得再用 active_version_id 直接判可购买"
assert_eq!(
resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Purchasable, 100, 999)
.expect_err("价格变化必须失败关闭"),
"价格已变化,请刷新后重试"
);
assert_eq!(
resolve_game_purchase_pre_charge_plan(GamePurchaseDecision::Purchasable, 100, 100),
Ok(GamePurchasePreChargePlan::Charge)
);
let decision = body
.find("resolve_game_distribution_purchase_decision(")
.expect("判定应存在");
let charge = body
.find("consume_profile_wallet_points_for_game_purchase")
.expect("扣费应存在");
assert!(decision < charge, "必须先判定后扣费");
}
/// M1 的事务侧:审核通过时把冻结资料里的价格整体生效到游戏行;历史版本缺 `priceMudPoints`
@@ -2926,7 +2926,7 @@ mod tests {
}
#[test]
fn historical_wallet_spend_counts_asset_and_llm_consumption() {
fn historical_wallet_spend_counts_every_consumption_source() {
assert_eq!(
sum_historical_profile_wallet_consumed_points([
(
@@ -2939,6 +2939,8 @@ mod tests {
),
(RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, -7),
(RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, 2),
(RuntimeProfileWalletLedgerSourceType::GamePurchase, -30),
(RuntimeProfileWalletLedgerSourceType::GamePurchase, 30),
(
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
40
@@ -2949,10 +2951,36 @@ mod tests {
),
(RuntimeProfileWalletLedgerSourceType::PointsRecharge, 100),
]),
55
85
);
}
/// 买断制购买必须与资产 / LLM 扣费同口径计入消耗:漏一处会让消费投影凭空少算整类扣费。
#[test]
fn wallet_consumption_source_predicate_includes_game_purchase() {
for source_type in [
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
RuntimeProfileWalletLedgerSourceType::LlmRouterConsume,
RuntimeProfileWalletLedgerSourceType::GamePurchase,
] {
assert!(
profile_wallet_ledger_source_counts_as_consumption(source_type),
"应计入消耗:{source_type:?}"
);
}
for source_type in [
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery,
RuntimeProfileWalletLedgerSourceType::SnapshotSync,
] {
assert!(
!profile_wallet_ledger_source_counts_as_consumption(source_type),
"不应计入消耗:{source_type:?}"
);
}
}
#[test]
fn historical_wallet_spend_saturates_instead_of_overflowing() {
assert_eq!(
@@ -5476,11 +5504,8 @@ fn initialize_profile_wallet_consumption_projections(
for row in ctx.db.profile_wallet_ledger().iter() {
scanned_ledger_count = scanned_ledger_count.saturating_add(1);
let total = totals.entry(row.user_id).or_default();
if matches!(
row.source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && row.amount_delta < 0
if profile_wallet_ledger_source_counts_as_consumption(row.source_type)
&& row.amount_delta < 0
{
*total = total.saturating_add(row.amount_delta.unsigned_abs());
}
@@ -5602,18 +5627,28 @@ fn rebuild_profile_wallet_consumption_total(ctx: &ReducerContext, user_id: &str)
)
}
/// 是否计入「消耗」口径:资产操作扣费、LLM Router 扣费与买断制购买扣费。
///
/// 消费投影增量、缺失行重建与历史花费重建共用这一判定,新增扣费来源只补一处,
/// 否则某个入口会静默漏算整类消耗。
fn profile_wallet_ledger_source_counts_as_consumption(
source_type: RuntimeProfileWalletLedgerSourceType,
) -> bool {
matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
| RuntimeProfileWalletLedgerSourceType::GamePurchase
)
}
fn sum_historical_profile_wallet_consumed_points(
entries: impl IntoIterator<Item = (RuntimeProfileWalletLedgerSourceType, i64)>,
) -> u64 {
entries
.into_iter()
.fold(0_u64, |total, (source_type, amount_delta)| {
if matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && amount_delta < 0
{
if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 {
total.saturating_add(amount_delta.unsigned_abs())
} else {
total
@@ -10646,12 +10681,7 @@ fn apply_profile_wallet_signed_delta(
metadata_json: Some(ledger_metadata_json),
});
if matches!(
source_type,
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
| RuntimeProfileWalletLedgerSourceType::LlmRouterConsume
) && amount_delta < 0
{
if profile_wallet_ledger_source_counts_as_consumption(source_type) && amount_delta < 0 {
record_profile_wallet_consumption(
ctx,
user_id,