合并 origin/master(52c83cc2a,248 个提交)到 feat/game-purchase
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m56s
Project CI / Backend tests (pull_request) Successful in 9m4s
Project CI / Repository checks (pull_request) Successful in 7m13s
Project CI / Native shell tests (pull_request) Successful in 10m16s
Project CI / Frontend tests (pull_request) Successful in 2m26s

- 同步 master 侧 248 个提交:会员与泥点计费(PR #588 / #603)、创作者主页与关注粉丝(PR #638)、运行页刷新真重载(PR #640)、AGC 模板 templateVersion 递增(PR #643)等;确认 master 未触碰发行网关与 `api-server/src/modules/game_distribution.rs`。
- 冲突 1(server-rs/crates/module-runtime/src/domain.rs:1157-1162)`RuntimeProfileWalletLedgerSourceType` 两侧各自在末尾追加变体:保留 master 的 `MembershipUpgradeGrant` 为索引 16(线上已落库口径),本分支 `GamePurchase` 顺延为索引 17,并保留其「每账号每游戏一次扣费」注释。
- 冲突 2(server-rs/crates/module-runtime/src/domain.rs:1184-1188)`as_str()` 同时保留 `membership_upgrade_grant` 与 `game_purchase` 两个分支。
- 冲突 3(server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs:95-101)bindings→领域枚举映射同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,按新索引顺序排列。
- 冲突 4(server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs:43-47)生成枚举按模块源码顺序合并为 …`MembershipUpgradeGrant`、`GamePurchase`;随后 `npm run spacetime:generate` 复核该文件与生成结果逐字节一致。
- 冲突 5(server-rs/crates/api-server/src/admin.rs:4849-4853)sats 索引映射改为 `16 => membership_upgrade_grant` 与 `17 => game_purchase` 两条并存。
- 冲突 6(server-rs/crates/api-server/src/admin.rs:6368-6461)索引断言并入 master 的 `[16] => membership_upgrade_grant`,并把 master 的「17 尚未映射」占位断言替换为 `[17] => game_purchase`;本分支的后台消耗统计两个用例原样保留。
- 冲突 7(server-rs/crates/api-server/src/runtime_profile.rs:219-226)来源文案格式化同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,注释随 `GamePurchase` 保留。
- 冲突 8(docs/project-memory/shared-memory/decision-log.md:3-24)两条决策记录并列保留:本分支 2026-10-05「播放会话前缀在三处入口清空 Cookie」在前,master 2026-10-03「每日免费发放额为 0 时前端隐藏该池」在后。
- 保住 master 侧:发行响应 ETag/304/gzip(`release_asset_response_with_cache` 五参形态与 `release_asset_etag` / `if_none_match_matches` / `accepts_gzip_encoding` / `gzip_release_asset` / `release_asset_not_modified_response`)、共享加载面 `PlatformGameLoadingSurface` 及其使用方、三池钱包与会员档位目录改造。
- 保住本分支侧:付费作品 404 守卫(`price_mud_points > 0` 且位于 `load_package` 之前)、购买与播放会话网关路由、nginx×3 / Pingora / Vite 的播放会话 Cookie 清空与 `nginx-route-parity.matrix.json` 登记、viewer 感知公开详情、后台审核价格、`PlatformGamePricingField` 共享定价组件与两端接入、AGC 项目版本只读 + 平台派发下一版本号、schema 与迁移白名单、钱包来源 `game_purchase`。
- 验证:`git merge-base --is-ancestor origin/master HEAD` 返回 0;`check:encoding`、`check:doc-index`、`check:spacetime-schema`、`check:server-rs-ddd`、`check:rustfmt`、`check:npm-workspaces`、`check:game-distribution-dto-parity`、`check:game-distribution-price-limit-parity`、`check:pingora-route-parity`、`check:nginx-spa-routes`、根/admin-web/AGC typecheck、`git diff --check` 全绿。
- 测试:`cargo test -p module-game-distribution` 30 passed,`cargo test -p api-server game_distribution` 61 passed,`cargo test -p spacetime-module game_distribution` 7 passed;定向 vitest 28 个文件 252 个用例全通过。
- `npm run spacetime:generate` 除 11 个 procedure 文件的纯空白格式差异外无任何语义改动(`git diff -w` 为空),已保留 master 的生成产物形态,提交内无 schema/绑定语义变化。
This commit is contained in:
2026-10-06 13:12:32 +08:00
201 changed files with 15890 additions and 13199 deletions
+20 -4
View File
@@ -2263,6 +2263,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
"/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]),
"/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]),
"/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]),
"/admin/api/profile/membership-plans" => AnyTab(&["membership-plans"]),
path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]),
"/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]),
"/admin/api/profile/users/reconcile-consumption" => {
@@ -4845,7 +4846,8 @@ 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",
16 => "membership_upgrade_grant",
17 => "game_purchase",
_ => return Some(Value::Array(items.to_vec()).to_string()),
}
.to_string(),
@@ -5171,8 +5173,9 @@ mod tests {
};
use serde_json::{Value, json};
use shared_contracts::admin::{
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery,
AdminDatabaseTableRowsQuery, AdminExternalApiKeyListQuery, AdminTrackingEventListQuery,
ADMIN_TAB_PERMISSIONS, AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminExternalApiKeyListQuery,
AdminTrackingEventListQuery,
};
use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery};
use spacetime_client::{
@@ -6359,9 +6362,13 @@ mod tests {
wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(),
Some("llm_router_consume")
);
// 买断制购买是第 17 个变体(索引 16);漏映射时后台会直接展示原始数组文本。
// 买断制购买是第 18 个变体(索引 17);漏映射时后台会直接展示原始数组文本。
assert_eq!(
wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(),
Some("membership_upgrade_grant")
);
assert_eq!(
wallet_ledger_source_type_to_string(&json!([17, []])).as_deref(),
Some("game_purchase")
);
assert_eq!(
@@ -7600,6 +7607,11 @@ mod tests {
Method::GET,
"/admin/api/profile/recharge-products",
),
(
"membership-plans",
Method::GET,
"/admin/api/profile/membership-plans",
),
(
"recharge-orders",
Method::GET,
@@ -7624,6 +7636,10 @@ mod tests {
];
for (permission, method, path) in cases {
assert!(
ADMIN_TAB_PERMISSIONS.contains(&permission),
"{permission} 未登记在 ADMIN_TAB_PERMISSIONS,非 owner 管理员无法被授予该 Tab"
);
assert!(
enforce_admin_request_permission(
"member",
@@ -11,6 +11,7 @@ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot,
RuntimeProfileMembershipOrderChangeSnapshot, RuntimeProfileMembershipRecord,
RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot,
RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource,
@@ -31,15 +32,16 @@ use platform_wechat::pay::{
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shared_contracts::admin::{
ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminProfileWalletPayload,
AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest,
AdminRechargeRefundHoldPayload, AdminRechargeRefundManualReviewResolveRequest,
AdminRechargeRefundPayload, AdminRechargeRefundPreviewRequest,
AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest,
AdminUserConsumptionProjectionInitializeResponse, AdminUserConsumptionReconcileRequest,
AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse,
AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest,
ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminMembershipOrderChangePayload,
AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminRechargeOrderEntryPayload,
AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse,
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload,
AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload,
AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse,
AdminRechargeRefundRegisterRequest, AdminUserConsumptionProjectionInitializeResponse,
AdminUserConsumptionReconcileRequest, AdminUserConsumptionReconcileResponse,
AdminUserDetailQuery, AdminUserDetailResponse, AdminUserSummaryPayload,
AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest,
AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload,
};
use spacetime_client::SpacetimeClientError;
@@ -158,6 +160,11 @@ pub async fn admin_get_user_detail(
.map_err(|error| spacetime_error_response(&request_context, error))?;
let cumulative_recharged_cents =
fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await;
let membership_record = state
.spacetime_client()
.get_profile_membership_snapshot(user.id.clone())
.await
.map_err(|error| spacetime_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
@@ -175,6 +182,7 @@ pub async fn admin_get_user_detail(
cumulative_recharged_cents,
can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE),
wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)),
membership: map_membership(membership_record),
recharge_orders: orders
.into_iter()
.map(|entry| map_order_entry(entry, Some(summary.clone())))
@@ -979,6 +987,44 @@ fn map_order_entry(
remaining_refundable_cents,
refund_eligible: block_reason.is_none(),
refund_block_reason_code: block_reason,
membership_change: entry.membership_change.map(map_membership_order_change),
}
}
fn map_membership_order_change(
change: RuntimeProfileMembershipOrderChangeSnapshot,
) -> AdminMembershipOrderChangePayload {
AdminMembershipOrderChangePayload {
change_kind: change.change_kind.into(),
plan_before: change.plan_before.map(Into::into),
plan_after: change.plan_after.map(Into::into),
cycle_kind_before: change.cycle_kind_before.map(Into::into),
cycle_kind_after: change.cycle_kind_after.map(Into::into),
period_points_before: change.period_points_before,
period_points_after: change.period_points_after,
granted_points_delta: change.granted_points_delta,
cycle_index: change.cycle_index,
expires_at_before_micros: change.expires_at_before_micros,
expires_at_after_micros: change.expires_at_after_micros,
cycle_resets_at_before_micros: change.cycle_resets_at_before_micros,
cycle_resets_at_after_micros: change.cycle_resets_at_after_micros,
price_breakdown_json: change.price_breakdown_json,
}
}
fn map_membership(record: RuntimeProfileMembershipRecord) -> AdminProfileMembershipPayload {
AdminProfileMembershipPayload {
plan: record.plan.into(),
status: record.status.into(),
cycle_kind: record.cycle_kind.into(),
cycle_index: record.cycle_index,
cycle_count: record.cycle_count,
cycle_granted_points: record.cycle_granted_points,
cycle_remaining_points: record.cycle_remaining_points,
started_at_micros: record.started_at_micros,
expires_at_micros: record.expires_at_micros,
cycle_resets_at_micros: record.cycle_resets_at_micros,
updated_at_micros: record.updated_at_micros,
}
}
@@ -991,7 +1037,7 @@ fn map_wallet(
total_balance: wallet.total_balance,
spendable_balance: wallet.spendable_balance,
daily_free_points: wallet.daily_free_points,
membership_limited_points: wallet.membership_limited_points,
monthly_points: wallet.membership_limited_points,
permanent_points: wallet.permanent_points,
held_points: wallet.held_points,
refund_debt_points: wallet.refund_debt_points,
+20 -2
View File
@@ -8,8 +8,8 @@ use axum::{
http::StatusCode,
};
use module_runtime::{
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelCatalog,
AgcModelProtocol,
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelAccess,
AgcModelCatalog, AgcModelProtocol,
};
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
use spacetime_client::SpacetimeClientError;
@@ -244,6 +244,7 @@ pub async fn admin_save_agc_models(
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
})?;
let access = AgcModelAccess::from(m.access);
Ok(module_runtime::AgcModel {
id: m.id,
alias: m.alias,
@@ -251,6 +252,7 @@ pub async fn admin_save_agc_models(
enabled: m.enabled,
agent_mode,
protocol,
access,
})
})
.collect::<Result<Vec<_>, AppError>>()?;
@@ -297,6 +299,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
enabled: m.enabled,
agent_mode: m.agent_mode.as_str().to_string(),
protocol: Some(protocol),
access: m.access.into(),
}
})
.collect(),
@@ -324,6 +327,21 @@ mod tests {
}))
.unwrap();
assert_eq!(legacy.agent_mode, "codex");
assert_eq!(
legacy.access,
shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic
);
// 权限档 token 在反序列化阶段就收口,未知值不再推迟到 `AgcModelAccess::parse`。
assert!(
serde_json::from_value::<AdminAgcModel>(json!({
"id": "model-a",
"alias": "model-a",
"modelId": "model-a",
"enabled": true,
"access": "premium"
}))
.is_err()
);
}
#[test]
@@ -695,6 +695,8 @@ fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> boo
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
// 业务拒绝是服务端已明确应答,不应触发退款应急 spool。
SpacetimeClientError::ProcedureRejected { .. } => false,
}
}
@@ -1149,6 +1149,7 @@ fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError)
|| message.contains("连接在返回结果前已断开")
}
SpacetimeClientError::Procedure(_) => false,
SpacetimeClientError::ProcedureRejected { .. } => false,
}
}
@@ -1160,6 +1161,7 @@ fn is_editor_generation_unknown_result_error(
&& !matches!(
error,
SpacetimeClientError::Procedure(_)
| SpacetimeClientError::ProcedureRejected { .. }
| SpacetimeClientError::Timeout(
SpacetimeClientStage::PoolAcquire
| SpacetimeClientStage::ConnectBuild
+183 -54
View File
@@ -9,11 +9,12 @@ use axum::{
},
};
use futures_util::StreamExt;
use module_runtime::AgcModelResolveError;
use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest};
use serde_json::{Value, json};
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
LlmModelSummary, LlmModelsResponse,
LlmModelSummary, LlmModelsResponse, LlmUnavailableModel,
};
use spacetime_client::SpacetimeClientError;
use std::convert::Infallible;
@@ -42,29 +43,106 @@ mod anthropic_bridge;
pub(crate) mod icon_specs;
mod model_access;
use model_access::{OwnerAgcModelCatalog, resolve_owner_agc_model_access};
#[cfg(test)]
mod model_catalog_tests {
use super::*;
#[test]
fn public_catalog_exposes_stable_id_and_upstream_alias() {
fn public_catalog_splits_available_and_disabled_models() {
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
7,
)
.expect("catalog should build");
catalog.models[1].enabled = false;
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
let payload = serde_json::to_value(
public_model_catalog(super::OwnerAgcModelCatalog {
catalog,
access: module_runtime::AgcModelAccess::Full,
})
.unwrap(),
)
.unwrap();
// 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。
assert_eq!(
payload["models"],
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}])
);
// 后台停用的模型仍下发到不可用桶,带 disabled 原因。
assert_eq!(
payload["unavailableModels"],
json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}])
);
assert_eq!(payload["defaultModelId"], "gpt-5-6-sol");
assert_eq!(payload["revision"], json!(7));
assert!(payload.get("defaultModel").is_none());
assert!(payload["models"][0].get("enabled").is_none());
assert!(payload["models"][0].get("modelId").is_none());
assert!(payload["unavailableModels"][0].get("modelId").is_none());
}
#[test]
fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() {
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
0,
)
.expect("catalog should build");
// 登记的默认项是 Full;Basic 档看不到它,默认项回退到目录顺序里第一个可用项。
catalog.models[0].access = module_runtime::AgcModelAccess::Full;
let payload = serde_json::to_value(
public_model_catalog(super::OwnerAgcModelCatalog {
catalog,
access: module_runtime::AgcModelAccess::Basic,
})
.unwrap(),
)
.unwrap();
assert_eq!(
payload["models"],
json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}])
);
assert_eq!(
payload["unavailableModels"],
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}])
);
assert_eq!(payload["defaultModelId"], "gpt-5-6-terra");
}
#[test]
fn plan_violation_maps_to_forbidden_with_dedicated_code() {
let violation =
agc_model_resolve_error(module_runtime::AgcModelResolveError::NotAvailableForPlan);
assert_eq!(violation.status_code(), StatusCode::FORBIDDEN);
assert_eq!(
violation.code(),
module_runtime::MODEL_NOT_AVAILABLE_FOR_PLAN
);
let unavailable =
agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable);
assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn tier_without_model_maps_to_service_unavailable_with_dedicated_code() {
// 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422;
// 码也不再与「单个模型不可用」共用 MODEL_UNAVAILABLE。
let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier);
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code(), module_runtime::MODEL_UNAVAILABLE_FOR_TIER);
}
#[test]
fn access_lookup_failure_maps_to_service_unavailable_with_dedicated_code() {
// 读链路失败必须是独立的 503 + 专用错误码,不能与 403 档位不足 / 422 模型不可用混用,
// 也不能把付费账号静默降级成 Basic。
let error = model_access::model_access_unavailable_error();
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code(), model_access::MODEL_ACCESS_UNAVAILABLE);
}
}
@@ -110,12 +188,7 @@ pub async fn proxy_llm_chat_completions(
let (llm_client, key_id) =
match resolve_llm_router_client(&state, authenticated.claims().user_id()).await {
Ok((client, key_id)) => (client, key_id),
Err(error) => {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error),
));
}
Err(error) => return Err(llm_error_response(&request_context, error)),
};
prepare_llm_router_billing(&state, authenticated.claims().user_id())
.await
@@ -195,32 +268,52 @@ pub async fn list_llm_models(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, Response> {
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let owner_catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), public_model_catalog(catalog)).into_response())
let catalog = public_model_catalog(owner_catalog)
.map_err(|error| llm_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), catalog).into_response())
}
fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse {
LlmModelsResponse {
default_model_id: catalog.default_model_id,
models: catalog
.models
.into_iter()
.filter(|model| model.enabled)
.map(|model| {
let protocol = model.resolved_protocol().as_str().to_string();
LlmModelSummary {
id: model.id,
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias,
agent_mode: model.agent_mode.as_str().to_string(),
protocol,
}
})
.collect(),
fn public_model_catalog(
owner_catalog: OwnerAgcModelCatalog,
) -> Result<LlmModelsResponse, AppError> {
let OwnerAgcModelCatalog { catalog, access } = owner_catalog;
// 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。
// 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是
// 503 + MODEL_UNAVAILABLE_FOR_TIER(与单个模型不可用的 422 + MODEL_UNAVAILABLE 区分开)。
let default_model_id = catalog
.default_model_id_for(access)
.ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))?
.to_string();
let models = catalog
.available_models_for(access)
.map(|model| LlmModelSummary {
id: model.id.clone(),
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias.clone(),
agent_mode: model.agent_mode,
protocol: model.resolved_protocol(),
})
.collect();
// 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。
let unavailable_models = catalog
.unavailable_models_for(access)
.map(|(model, reason)| LlmUnavailableModel {
id: model.id.clone(),
display_name: model.alias.clone(),
agent_mode: model.agent_mode,
protocol: model.resolved_protocol(),
reason,
})
.collect();
Ok(LlmModelsResponse {
default_model_id,
models,
unavailable_models,
revision: catalog.revision,
}
})
}
/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」,
@@ -262,6 +355,36 @@ async fn load_llm_catalog(
crate::agc_models::load_catalog(state).await
}
/// 全局目录 + 账号权限档;所有对外 LLM 路由都经它解析模型。
///
/// 两者相互独立,热路径上用 `join!` 并发发往 SpacetimeDB,避免串行叠加两次往返延迟。
async fn load_owner_llm_catalog(
state: &AppState,
owner: &str,
) -> Result<OwnerAgcModelCatalog, AppError> {
let (catalog, access) = tokio::join!(
load_llm_catalog(state, owner),
resolve_owner_agc_model_access(state, owner),
);
Ok(OwnerAgcModelCatalog {
catalog: catalog?,
access: access?,
})
}
/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用是 422;
/// 「该档没有任何可用模型」是目录 / 档位配置问题,按 503 处理,和 `/api/llm/models` 列表端点一致。
fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError {
let status = match error {
AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN,
AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY,
AgcModelResolveError::NoModelForTier => StatusCode::SERVICE_UNAVAILABLE,
};
AppError::from_status(status)
.with_code(error.code())
.with_message(error.message())
}
/// Proxies the OpenAI-compatible Responses protocol for the LLM Router.
///
/// The caller only presents the platform access token. The Router credential
@@ -334,7 +457,7 @@ pub async fn proxy_llm_responses(
.get("x-genarrative-client")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "agc");
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
let requested_model = if agc_client {
@@ -343,13 +466,9 @@ pub async fn proxy_llm_responses(
None
};
let selected_model = catalog
.resolve_requested(requested_model)
.map_err(|message| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
)
})?
.catalog
.resolve_requested_for(requested_model, catalog.access)
.map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))?
.to_string();
object.insert("model".to_string(), Value::String(selected_model.clone()));
@@ -550,21 +669,17 @@ pub async fn proxy_llm_messages(
] {
object.remove(field);
}
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
// 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补
// `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识;
// 目录外取值仍由 `resolve_requested` 拒绝。
// 目录外取值与档位不足都由 `resolve_requested_for` 拒绝。
let requested_model = requested_model.as_deref();
let selected_model = catalog
.resolve_requested(requested_model)
.map_err(|message| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
)
})?
.catalog
.resolve_requested_for(requested_model, catalog.access)
.map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))?
.to_string();
// 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。
object.insert("model".to_string(), Value::String(selected_model.clone()));
@@ -1400,12 +1515,20 @@ fn is_responses_terminal_sse_event(event: &str) -> bool {
async fn resolve_llm_router_client(
state: &AppState,
owner_user_id: &str,
) -> Result<(platform_llm::LlmClient, String), String> {
let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?;
let catalog = load_llm_catalog(state, owner_user_id)
) -> Result<(platform_llm::LlmClient, String), AppError> {
let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id)
.await
.map_err(|_| "模型目录暂不可用".to_string())?;
let model = catalog.resolve_requested(None)?;
.map_err(|message| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
})?;
// 目录读失败与会员权限读失败(503 + MODEL_ACCESS_UNAVAILABLE)都保留各自的类型化错误,
// 不再拍平成一句中文,与 `proxy_llm_responses` / `proxy_llm_messages` 的契约一致。
let catalog = load_owner_llm_catalog(state, owner_user_id).await?;
// Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。
let model = catalog
.catalog
.resolve_requested_for(None, catalog.access)
.map_err(agc_model_resolve_error)?;
let config = platform_llm::LlmConfig::new(
platform_llm::LlmProvider::OpenAiCompatible,
base_url.to_string(),
@@ -1415,10 +1538,16 @@ async fn resolve_llm_router_client(
state.config.llm_max_retries,
state.config.llm_retry_backoff_ms,
)
.map_err(|error| error.to_string())?;
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("LLM Router 客户端配置非法:{error}"))
})?;
platform_llm::LlmClient::new(config)
.map(|client| (client, key_id))
.map_err(|error| error.to_string())
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("创建 LLM Router 客户端失败:{error}"))
})
}
async fn resolve_llm_router_credentials(
@@ -0,0 +1,73 @@
//! 账号会员档位 → AGC 模型权限档的解析(BFF 侧,带 IO)。
//!
//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成
//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。
//!
//! LLM 路由对外错误码(四条互不重叠,客户端据此区分处理):
//! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。
//! - `MODEL_UNAVAILABLE`(422):模型不在目录或已停用。
//! - `MODEL_UNAVAILABLE_FOR_TIER`(503):该档在目录里没有任何可用模型,属目录 / 档位配置问题,
//! 列表端点与代理端点统一按 503 上报,与单个模型不可用(422)区分。
//! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。
//! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。
//! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。
use axum::http::StatusCode;
use module_runtime::{AgcModelAccess, AgcModelCatalog};
use crate::{http_error::AppError, state::AppState};
/// 读取账号权限档失败(基础设施)时的专用错误码,与档位不足 / 模型不可用区分。
pub(crate) const MODEL_ACCESS_UNAVAILABLE: &str = "MODEL_ACCESS_UNAVAILABLE";
/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。
pub(crate) struct OwnerAgcModelCatalog {
pub(crate) catalog: AgcModelCatalog,
pub(crate) access: AgcModelAccess,
}
/// 读链路失败时的统一错误:503 + [`MODEL_ACCESS_UNAVAILABLE`]。
///
/// 独立成函数是为了让「基础设施失败」这件事可被单测直接断言,而不是散落的构造。
pub(crate) fn model_access_unavailable_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_code(MODEL_ACCESS_UNAVAILABLE)
.with_message("会员权限档暂不可用,请稍后重试")
}
/// 解析账号的 AGC 模型权限档。
///
/// 走专用只读 procedure `get_profile_agc_model_access`:它只读会员账期投影与档位目录,
/// 不触发换期 / 免费点刷新,适合 LLM 代理热路径。
///
/// 成功返回档位;读链路失败返回 503 + [`MODEL_ACCESS_UNAVAILABLE`],由路由层透传,不再静默降级成 `Basic`。
/// 缺会员行 / 缺档位目录行仍由 module 内失败关闭到 `Basic` 并返回成功。
pub(crate) async fn resolve_owner_agc_model_access(
state: &AppState,
owner_user_id: &str,
) -> Result<AgcModelAccess, AppError> {
#[cfg(test)]
if super::test_provisioned_router_credentials()
.lock()
.expect("fixture lock")
.contains_key(owner_user_id)
{
// 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。
return Ok(AgcModelAccess::Full);
}
match state
.spacetime_client()
.get_profile_agc_model_access(owner_user_id.to_string())
.await
{
Ok(record) => Ok(AgcModelAccess::from(record.model_access)),
Err(error) => {
tracing::error!(
user_id = %owner_user_id,
error = %error,
"读取会员档位失败,AGC 模型权限按 503 上报"
);
Err(model_access_unavailable_error())
}
}
}
@@ -29,8 +29,9 @@ use crate::{
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_get_profile_wallet_config, admin_list_profile_invite_codes,
admin_list_profile_recharge_products, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_list_profile_membership_plans, admin_list_profile_recharge_products,
admin_list_profile_redeem_codes, admin_list_profile_task_configs,
admin_upsert_profile_invite_code, admin_upsert_profile_membership_plan,
admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code,
admin_upsert_profile_task_config, admin_upsert_profile_wallet_config,
},
@@ -172,6 +173,10 @@ pub fn router(state: AppState) -> Router<AppState> {
"/admin/api/profile/recharge-products",
get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product),
),
(
"/admin/api/profile/membership-plans",
get(admin_list_profile_membership_plans).post(admin_upsert_profile_membership_plan),
),
(
"/admin/api/profile/recharge-orders",
get(admin_list_recharge_orders),
@@ -279,6 +284,7 @@ mod route_contract_tests {
("/admin/api/profile/tasks/disable", &["POST"]),
("/admin/api/profile/wallet-config", &["GET", "POST"]),
("/admin/api/profile/recharge-products", &["GET", "POST"]),
("/admin/api/profile/membership-plans", &["GET", "POST"]),
("/admin/api/profile/recharge-orders", &["GET"]),
("/admin/api/profile/recharge-refunds/preview", &["POST"]),
("/admin/api/profile/recharge-refunds/execute", &["POST"]),
@@ -17,8 +17,9 @@ use crate::{
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center,
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
get_profile_membership_upgrade_quote, get_profile_recharge_center,
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code,
stream_wechat_profile_recharge_order_events, submit_profile_feedback,
},
state::AppState,
@@ -100,6 +101,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/membership/upgrade-quote",
post(get_profile_membership_upgrade_quote).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,401 @@
//! AGC 模型权限档与「按会员档位过滤模型目录」的判定(纯函数,无 IO)。
//!
//! 模型目录是全局的,但每个模型带一个权限档;会员档位(`profile_membership_plan.model_access`)
//! 决定账号能看到、能调用哪些模型。这里只做判定与解析,SpacetimeDB 读写留在 `spacetime-module`,
//! HTTP 错误映射留在 `api-server`。
//!
//! 失败开放:模型权限档缺省 [`AgcModelAccess::Basic`],因此存量目录 JSON 缺字段、后台未人工标注
//! `Full` 时都不会拦截任何请求;只有明确标了 `Full` 的模型才会对 `Basic` 档账号报越权。
//!
//! 本文件把「档位可见集合」「档位默认模型」「请求侧解析」收在一处,调用方只接触
//! [`AgcModelCatalog::resolve_requested_for`] 这类语义方法,不自己拼过滤条件。
use serde::{Deserialize, Serialize};
use crate::{
AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess,
};
use shared_contracts::llm_catalog::LlmModelUnavailableReason;
/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。
pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN";
/// 所选模型不在目录或已停用时对外返回的错误码。
pub const MODEL_UNAVAILABLE: &str = "MODEL_UNAVAILABLE";
/// 当前会员档位在目录里没有任何可用模型时对外返回的错误码。
///
/// 与 [`MODEL_UNAVAILABLE`] 区分:后者是「单个模型的问题」(422),本码是「目录 / 档位配置
/// 问题」(503),修法不同,客户端只按 `error.code` 也能分辨。
pub const MODEL_UNAVAILABLE_FOR_TIER: &str = "MODEL_UNAVAILABLE_FOR_TIER";
/// 单个模型要求的权限档。
///
/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理,
/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。未知取值同样
/// 失败开放到 `Basic`(`#[serde(other)]`),避免将来新增档位时旧服务端整份目录解析失败、
/// 静默回退到上游目录而丢掉人工标注。
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgcModelAccess {
Full,
#[default]
#[serde(other)]
Basic,
}
impl AgcModelAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Basic => "basic",
Self::Full => "full",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"basic" => Some(Self::Basic),
"full" => Some(Self::Full),
_ => None,
}
}
/// 当前账号档位是否覆盖模型要求的档位:`Full` 覆盖全部,`Basic` 只覆盖基础。
pub const fn allows(self, required: Self) -> bool {
matches!(
(self, required),
(Self::Full, _) | (Self::Basic, Self::Basic)
)
}
}
impl From<RuntimeProfileMembershipModelAccess> for AgcModelAccess {
fn from(value: RuntimeProfileMembershipModelAccess) -> Self {
match value {
RuntimeProfileMembershipModelAccess::Basic => Self::Basic,
RuntimeProfileMembershipModelAccess::Full => Self::Full,
}
}
}
impl From<shared_contracts::runtime::ProfileMembershipModelAccessToken> for AgcModelAccess {
fn from(value: shared_contracts::runtime::ProfileMembershipModelAccessToken) -> Self {
match value {
shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic => Self::Basic,
shared_contracts::runtime::ProfileMembershipModelAccessToken::Full => Self::Full,
}
}
}
impl From<AgcModelAccess> for shared_contracts::runtime::ProfileMembershipModelAccessToken {
fn from(value: AgcModelAccess) -> Self {
match value {
AgcModelAccess::Basic => Self::Basic,
AgcModelAccess::Full => Self::Full,
}
}
}
/// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgcModelResolveError {
/// 标识不在目录、模型已停用。
Unavailable,
/// 模型存在且启用,但当前会员档位不够(越权)。
NotAvailableForPlan,
/// 该会员档位在目录里没有任何可用模型:属于目录 / 档位配置问题,不是某个模型的问题。
NoModelForTier,
}
impl AgcModelResolveError {
pub const fn code(self) -> &'static str {
match self {
Self::Unavailable => MODEL_UNAVAILABLE,
Self::NoModelForTier => MODEL_UNAVAILABLE_FOR_TIER,
Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN,
}
}
pub const fn message(self) -> &'static str {
match self {
Self::Unavailable => "所选模型不可用,请刷新模型列表",
Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型",
Self::NoModelForTier => "当前会员档位没有可用模型",
}
}
}
impl AgcModel {
/// 该模型对给定账号档位是否可见 / 可调用(既要启用,也要档位足够)。
pub fn is_available_for(&self, access: AgcModelAccess) -> bool {
self.enabled && access.allows(self.access)
}
}
impl AgcModelCatalog {
/// 该档位可见的模型,保持目录顺序。
pub fn available_models_for(&self, access: AgcModelAccess) -> impl Iterator<Item = &AgcModel> {
self.models
.iter()
.filter(move |model| model.is_available_for(access))
}
/// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。
///
/// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且
/// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标
/// `plan_required` 误导用户。
pub fn unavailable_models_for(
&self,
access: AgcModelAccess,
) -> impl Iterator<Item = (&AgcModel, LlmModelUnavailableReason)> {
self.models.iter().filter_map(move |model| {
if !model.enabled {
Some((model, LlmModelUnavailableReason::Disabled))
} else if !access.allows(model.access) {
Some((model, LlmModelUnavailableReason::PlanRequired))
} else {
None
}
})
}
/// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时
/// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。
pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> {
let configured = self
.models
.iter()
.find(|model| model.id == self.default_model_id)
.filter(|model| model.is_available_for(access));
if configured.is_some() {
return Some(self.default_model_id.as_str());
}
self.available_models_for(access)
.map(|model| model.id.as_str())
.next()
}
/// 请求侧解析(带档位)。
///
/// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。
/// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`],
/// **不静默回退**成其它模型;该档没有任何可用模型 → [`AgcModelResolveError::NoModelForTier`]。
pub fn resolve_requested_for(
&self,
requested: Option<&str>,
access: AgcModelAccess,
) -> Result<&str, AgcModelResolveError> {
let requested = requested
.map(str::trim)
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
match requested {
Some(id) => match self.models.iter().find(|model| model.id == id) {
Some(model) if !model.enabled => Err(AgcModelResolveError::Unavailable),
Some(model) if access.allows(model.access) => Ok(model.model_id.as_str()),
Some(_) => Err(AgcModelResolveError::NotAvailableForPlan),
None => Err(AgcModelResolveError::Unavailable),
},
None => {
let id = self
.default_model_id_for(access)
.ok_or(AgcModelResolveError::NoModelForTier)?;
self.models
.iter()
.find(|model| model.id == id && model.is_available_for(access))
.map(|model| model.model_id.as_str())
.ok_or(AgcModelResolveError::NoModelForTier)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog_from_ids(ids: &[&str], default_index: usize) -> AgcModelCatalog {
let mut catalog =
AgcModelCatalog::from_upstream_models(ids.iter().map(|id| id.to_string()), 0)
.expect("目录应从上游模型名构建");
let default_id = catalog.models[default_index].id.clone();
catalog.default_model_id = default_id;
catalog
}
fn mark_full(catalog: &mut AgcModelCatalog, id: &str) {
let model = catalog
.models
.iter_mut()
.find(|model| model.id == id)
.expect("测试模型应存在");
model.access = AgcModelAccess::Full;
}
#[test]
fn access_defaults_to_basic_and_parses_strictly() {
assert_eq!(AgcModelAccess::default(), AgcModelAccess::Basic);
assert_eq!(AgcModelAccess::parse("basic"), Some(AgcModelAccess::Basic));
assert_eq!(AgcModelAccess::parse("full"), Some(AgcModelAccess::Full));
assert_eq!(AgcModelAccess::parse("Full"), None);
assert_eq!(AgcModelAccess::parse(""), None);
}
#[test]
fn full_plan_covers_every_model_and_basic_plan_only_basic() {
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Basic));
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Full));
assert!(AgcModelAccess::Basic.allows(AgcModelAccess::Basic));
assert!(!AgcModelAccess::Basic.allows(AgcModelAccess::Full));
}
#[test]
fn legacy_catalog_json_defaults_missing_access_to_basic() {
// 存量目录 JSON 没有 access 字段:按 Basic 解析,失败开放。
let catalog: AgcModelCatalog = serde_json::from_str(
r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex"}]}"#,
)
.expect("存量目录应可读");
assert_eq!(catalog.models[0].access, AgcModelAccess::Basic);
}
#[test]
fn unknown_catalog_json_access_fails_open_to_basic() {
// 中文注释:未知 access 取值(例如将来新增档位)不能让整份目录解析失败、
// 静默回退上游目录,应失败开放到 Basic。
let catalog: AgcModelCatalog = serde_json::from_str(
r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex","access":"premium"}]}"#,
)
.expect("未知 access 应失败开放到 Basic");
assert_eq!(catalog.models[0].access, AgcModelAccess::Basic);
}
#[test]
fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() {
let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1);
mark_full(&mut catalog, "model-b");
let visible = catalog
.available_models_for(AgcModelAccess::Basic)
.map(|model| model.id.as_str())
.collect::<Vec<_>>();
assert_eq!(visible, vec!["model-a", "model-c"]);
// 登记的默认项 model-b 是 Full,Basic 档回退到目录顺序里第一个可用项。
assert_eq!(
catalog.default_model_id_for(AgcModelAccess::Basic),
Some("model-a")
);
// Full 档保留登记的默认项。
assert_eq!(
catalog.default_model_id_for(AgcModelAccess::Full),
Some("model-b")
);
}
#[test]
fn resolve_requested_for_distinguishes_unknown_from_out_of_plan() {
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
mark_full(&mut catalog, "model-b");
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Basic),
Err(AgcModelResolveError::NotAvailableForPlan)
);
assert_eq!(
catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic),
Err(AgcModelResolveError::Unavailable)
);
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
Ok("model-b")
);
// 未选择时用该档默认模型。
assert_eq!(
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
Ok("model-a")
);
assert_eq!(
catalog.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Full),
Ok("model-a")
);
}
#[test]
fn disabled_model_is_unavailable_even_for_full_plan() {
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
catalog.models[1].enabled = false;
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
Err(AgcModelResolveError::Unavailable)
);
}
#[test]
fn plan_with_no_available_model_reports_no_model_for_tier() {
let mut catalog = catalog_from_ids(&["model-a"], 0);
mark_full(&mut catalog, "model-a");
assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None);
assert_eq!(
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
Err(AgcModelResolveError::NoModelForTier)
);
// 显式点了目录外的模型仍是「该模型不可用」,不能和「整个档位没有模型」混为一谈。
assert_eq!(
catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic),
Err(AgcModelResolveError::Unavailable)
);
}
#[test]
fn resolve_error_codes_are_stable() {
assert_eq!(
AgcModelResolveError::NotAvailableForPlan.code(),
MODEL_NOT_AVAILABLE_FOR_PLAN
);
assert_eq!(AgcModelResolveError::Unavailable.code(), MODEL_UNAVAILABLE);
// 「该档没有可用模型」是目录 / 档位配置问题(503),与单个模型不可用(422)用不同的码,
// 让只按 error.code 分派的客户端也能区分。
assert_eq!(
AgcModelResolveError::NoModelForTier.code(),
MODEL_UNAVAILABLE_FOR_TIER
);
assert_ne!(MODEL_UNAVAILABLE, MODEL_UNAVAILABLE_FOR_TIER);
}
#[test]
fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() {
let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0);
mark_full(&mut catalog, "model-b");
mark_full(&mut catalog, "model-c");
catalog.models[2].enabled = false;
let unavailable = catalog
.unavailable_models_for(AgcModelAccess::Basic)
.map(|(model, reason)| (model.id.as_str(), reason))
.collect::<Vec<_>>();
// model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。
assert_eq!(
unavailable,
vec![
("model-b", LlmModelUnavailableReason::PlanRequired),
("model-c", LlmModelUnavailableReason::Disabled),
]
);
// Full 档只剩停用的 model-c;可用集合与不可用集合互补。
let unavailable_full = catalog
.unavailable_models_for(AgcModelAccess::Full)
.map(|(model, reason)| (model.id.as_str(), reason))
.collect::<Vec<_>>();
assert_eq!(
unavailable_full,
vec![("model-c", LlmModelUnavailableReason::Disabled)]
);
let available = catalog
.available_models_for(AgcModelAccess::Basic)
.map(|model| model.id.as_str())
.collect::<Vec<_>>();
assert_eq!(available, vec!["model-a"]);
}
}
+34 -116
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::AgcModelAccess;
/// 目录 revision 乐观锁冲突。
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
@@ -12,99 +14,9 @@ pub const AGC_MODEL_ID_MAX_BYTES: usize = 64;
/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。
pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgcAgentMode {
Codex,
/// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`;
/// 协议本身由 `AgcModel.protocol` 表达。
#[serde(alias = "anthropic")]
Cc,
}
impl Default for AgcAgentMode {
fn default() -> Self {
Self::Codex
}
}
impl AgcAgentMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Cc => "cc",
}
}
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"codex" => Ok(Self::Codex),
"cc" => Ok(Self::Cc),
_ => Err("Agent 模式只允许 codex 或 cc".into()),
}
}
/// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的:
/// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走
/// Anthropic Messages。
pub const fn supports(self, protocol: AgcModelProtocol) -> bool {
match self {
Self::Codex => matches!(
protocol,
AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat
),
Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic),
}
}
/// 存量目录缺少协议字段时按执行器的默认线协议解释。
pub const fn default_protocol(self) -> AgcModelProtocol {
match self {
Self::Codex => AgcModelProtocol::OpenAiResponses,
Self::Cc => AgcModelProtocol::Anthropic,
}
}
}
/// 上游线协议,与执行器正交。
///
/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与
/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的
/// `/api/llm/anthropic/{*path}`。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgcModelProtocol {
#[serde(rename = "openai_responses")]
OpenAiResponses,
#[serde(rename = "openai_chat")]
OpenAiChat,
#[serde(rename = "anthropic")]
Anthropic,
}
impl Default for AgcModelProtocol {
fn default() -> Self {
Self::OpenAiResponses
}
}
impl AgcModelProtocol {
pub const fn as_str(self) -> &'static str {
match self {
Self::OpenAiResponses => "openai_responses",
Self::OpenAiChat => "openai_chat",
Self::Anthropic => "anthropic",
}
}
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"openai_responses" => Ok(Self::OpenAiResponses),
"openai_chat" => Ok(Self::OpenAiChat),
"anthropic" => Ok(Self::Anthropic),
_ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()),
}
}
}
// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源);
// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。
pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -120,6 +32,9 @@ pub struct AgcModel {
/// 保存时必须与 `agent_mode` 组成合法组合。
#[serde(default)]
pub protocol: Option<AgcModelProtocol>,
/// 该模型要求的访问级别;缺省 `Basic`(失败开放),由人工在后台标 `Full`。
#[serde(default)]
pub access: AgcModelAccess,
}
impl AgcModel {
@@ -169,6 +84,7 @@ impl AgcModelCatalog {
enabled: true,
agent_mode: AgcAgentMode::default(),
protocol: None,
access: AgcModelAccess::default(),
});
}
let default_model_id = entries
@@ -228,26 +144,15 @@ impl AgcModelCatalog {
));
}
}
self.resolve(&self.default_model_id).map(|_| ())
}
pub fn resolve(&self, id: &str) -> Result<&str, String> {
self.models
// 默认项必须存在且启用,否则目录无法给出可用默认模型。
if !self
.models
.iter()
.find(|m| m.enabled && m.id == id)
.map(|m| m.model_id.as_str())
.ok_or_else(|| "所选模型不可用,请刷新模型列表".into())
}
/// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。
pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> {
let requested = requested
.map(str::trim)
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
match requested {
Some(id) => self.resolve(id),
None => self.resolve(&self.default_model_id),
.any(|model| model.enabled && model.id == self.default_model_id)
{
return Err("所选模型不可用,请刷新模型列表".into());
}
Ok(())
}
}
@@ -316,6 +221,7 @@ mod tests {
enabled: true,
agent_mode: AgcAgentMode::default(),
protocol: None,
access: AgcModelAccess::default(),
}
}
@@ -399,18 +305,30 @@ mod tests {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
assert!(catalog.validate().is_ok());
assert_eq!(catalog.resolve("model-a").unwrap(), "model-a");
assert_eq!(
catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic),
Ok("model-a")
);
// 客户端不能直接指定实际模型名,只能回传目录标识。
assert!(catalog.resolve("model-c").is_err());
assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a");
assert_eq!(
catalog.resolve_requested_for(Some("model-c"), AgcModelAccess::Basic),
Err(crate::AgcModelResolveError::Unavailable)
);
assert_eq!(
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
Ok("model-a")
);
assert_eq!(
catalog
.resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT))
.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Basic)
.unwrap(),
"model-a"
);
catalog.models[0].enabled = false;
assert!(catalog.resolve("model-a").is_err());
assert_eq!(
catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic),
Err(crate::AgcModelResolveError::Unavailable)
);
assert!(catalog.validate().is_err());
}
@@ -8,6 +8,9 @@ use serde_json::Value;
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use std::collections::{BTreeMap, BTreeSet};
use crate::civil_calendar::{
civil_from_days, days_from_civil, days_in_month, is_leap_year, weekday_from_date_key,
};
use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
@@ -1049,16 +1052,7 @@ pub fn build_runtime_profile_recharge_center_record(
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
membership_products: snapshot
.membership_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
benefits: snapshot
.benefits
.into_iter()
.map(build_runtime_profile_membership_benefit_record)
.collect(),
membership_plans: snapshot.membership_plans,
latest_order: snapshot
.latest_order
.map(build_runtime_profile_recharge_order_record),
@@ -1082,11 +1076,6 @@ pub fn build_runtime_profile_recharge_product_record(
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
membership_period_points: snapshot.membership_period_points,
membership_period_days: snapshot.membership_period_days,
membership_queue_limit: snapshot.membership_queue_limit,
membership_discount_bps: snapshot.membership_discount_bps,
}
}
@@ -1103,11 +1092,6 @@ pub fn build_runtime_profile_recharge_product_config_record(
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
membership_period_points: snapshot.membership_period_points,
membership_period_days: snapshot.membership_period_days,
membership_queue_limit: snapshot.membership_queue_limit,
membership_discount_bps: snapshot.membership_discount_bps,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
@@ -1119,29 +1103,13 @@ pub fn build_runtime_profile_recharge_product_config_record(
}
}
pub fn build_runtime_profile_membership_benefit_record(
snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> RuntimeProfileMembershipBenefitRecord {
RuntimeProfileMembershipBenefitRecord {
benefit_name: snapshot.benefit_name,
normal_value: snapshot.normal_value,
month_value: snapshot.month_value,
season_value: snapshot.season_value,
year_value: snapshot.year_value,
starter_value: snapshot.starter_value,
basic_value: snapshot.basic_value,
pro_value: snapshot.pro_value,
ultimate_value: snapshot.ultimate_value,
}
}
pub fn build_runtime_profile_membership_record(
snapshot: RuntimeProfileMembershipSnapshot,
) -> RuntimeProfileMembershipRecord {
RuntimeProfileMembershipRecord {
user_id: snapshot.user_id,
status: snapshot.status,
tier: snapshot.tier,
plan: snapshot.plan,
started_at: snapshot.started_at_micros.map(format_utc_micros),
started_at_micros: snapshot.started_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
@@ -1154,7 +1122,29 @@ pub fn build_runtime_profile_membership_record(
cycle_resets_at_micros: snapshot.cycle_resets_at_micros,
cycle_granted_points: snapshot.cycle_granted_points,
cycle_remaining_points: snapshot.cycle_remaining_points,
cycle_period_days: snapshot.cycle_period_days,
cycle_index: snapshot.cycle_index,
cycle_count: snapshot.cycle_count,
cycle_kind: snapshot.cycle_kind,
}
}
pub fn build_runtime_profile_membership_upgrade_quote_record(
snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot,
) -> RuntimeProfileMembershipUpgradeQuoteRecord {
RuntimeProfileMembershipUpgradeQuoteRecord {
current_plan: snapshot.current_plan,
target_plan: snapshot.target_plan,
cycle_kind: snapshot.cycle_kind,
cycle_index: snapshot.cycle_index,
cycle_count: snapshot.cycle_count,
amount_cents: snapshot.amount_cents,
granted_points_delta: snapshot.granted_points_delta,
monthly_balance_after: snapshot.monthly_balance_after,
expires_at: format_utc_micros(snapshot.expires_at_micros),
expires_at_micros: snapshot.expires_at_micros,
cycle_resets_at: format_utc_micros(snapshot.cycle_resets_at_micros),
cycle_resets_at_micros: snapshot.cycle_resets_at_micros,
remaining_full_months: snapshot.remaining_full_months,
}
}
@@ -1375,25 +1365,6 @@ fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimePr
Ok(())
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn weekday_from_date_key(date_key: i64) -> u8 {
// 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。
(date_key + 3).rem_euclid(7) as u8 + 1
}
fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 {
let ordinal = ordinal_day(year, month, day);
let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7);
@@ -1430,36 +1401,6 @@ fn iso_weeks_in_year(year: i32) -> i32 {
}
}
fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
// 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。
let adjusted_year = year - if month <= 2 { 1 } else { 0 };
let era = adjusted_year.div_euclid(400);
let year_of_era = adjusted_year - era * 400;
let month = i32::from(month);
let day = i32::from(day);
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
i64::from(era * 146_097 + day_of_era - 719_468)
}
fn civil_from_days(date_key: i64) -> (i32, u8, u8) {
// 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。
let z = date_key + 719_468;
let era = z.div_euclid(146_097);
let day_of_era = z - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524
- day_of_era / 146_096)
.div_euclid(365);
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2).div_euclid(153);
let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year as i32, month as u8, day as u8)
}
pub fn build_default_runtime_profile_task_config(
updated_at_micros: i64,
updated_by: String,
@@ -0,0 +1,116 @@
//! 公历日期基元(不依赖运行环境时区)。
//!
//! 只做「公历年月日 ↔ 1970-01-01 起日序号」的纯换算,采用 Howard Hinnant 的 civil calendar
//! 算法。北京时间(UTC+8)与业务日边界由调用方叠加固定偏移,因此本模块永远不读系统时区。
/// 公历给定年月的天数,含闰年规则。
pub fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
/// 公历闰年判断。
pub fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// 公历年月日 → 1970-01-01 起的日序号。
///
/// 全程用 `i64` 运算:`i32` 年份区间内 `era * 146_097` 会超过 `i32::MAX`,在 `i32` 下
/// 会溢出(debug panic、release 静默回绕),因此这里的中间量一律提升到 `i64`。
pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
// 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。
let adjusted_year = i64::from(year) - if month <= 2 { 1 } else { 0 };
let era = adjusted_year.div_euclid(400);
let year_of_era = adjusted_year - era * 400;
let month = i64::from(month);
let day = i64::from(day);
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
/// 1970-01-01 起的日序号 → 公历年月日。
///
/// 只接受由 [`days_from_civil`] 产生、或落在公历可表示范围内的日序号(约 ±7.9e11 天,即
/// `i32` 年份区间)。内部用 `saturating_add` 防止中间结果溢出:传入远小于/远大于该范围的
/// 极端值时不会 panic,但会饱和并返回无意义的年月日,因此调用方需自行保证输入范围。
pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) {
// 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。
let z = date_key.saturating_add(719_468);
let era = z.div_euclid(146_097);
let day_of_era = z - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524
- day_of_era / 146_096)
.div_euclid(365);
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2).div_euclid(153);
let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year as i32, month as u8, day as u8)
}
/// ISO 星期:周一 = 1,周日 = 7。
pub fn weekday_from_date_key(date_key: i64) -> u8 {
// 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。
(date_key.rem_euclid(7) + 3).rem_euclid(7) as u8 + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_round_trip_holds_across_month_and_year_boundaries() {
for (year, month, day) in [
(2024, 2, 29),
(2026, 1, 1),
(2026, 12, 31),
(1970, 1, 1),
(2100, 12, 31),
] {
let date_key = days_from_civil(year, month, day);
assert_eq!(civil_from_days(date_key), (year, month, day));
}
}
#[test]
fn days_from_civil_handles_full_i32_year_range_without_overflow() {
// 中文注释:i32 年份区间内的 era 乘法会超过 i32::MAX,必须提升到 i64 才不溢出。
for (year, month, day) in [
(i32::MIN, 1, 1),
(i32::MIN, 2, 29),
(i32::MIN, 12, 31),
(i32::MAX, 1, 1),
(i32::MAX, 12, 31),
] {
let date_key = days_from_civil(year, month, day);
assert_eq!(civil_from_days(date_key), (year, month, day));
}
}
#[test]
fn days_in_month_applies_leap_year_rules() {
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2100, 2), 28);
assert_eq!(days_in_month(2000, 2), 29);
assert_eq!(days_in_month(2026, 4), 30);
assert_eq!(days_in_month(2026, 12), 31);
}
#[test]
fn weekday_from_date_key_matches_known_dates() {
// 中文注释:1970-01-01 是周四;2026-01-01 也是周四。
assert_eq!(weekday_from_date_key(days_from_civil(1970, 1, 1)), 4);
assert_eq!(weekday_from_date_key(days_from_civil(2026, 1, 1)), 4);
}
}
+90 -38
View File
@@ -99,8 +99,8 @@ pub fn build_runtime_profile_wallet_config_admin_upsert_input(
if initial_mud_points == 0 || initial_mud_points > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidInitialWalletPoints);
}
if daily_free_points_per_day == 0 || daily_free_points_per_day > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidDailyFreePointsPerDay);
if daily_free_points_per_day > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::DailyFreePointsPerDayOverflow);
}
Ok(RuntimeProfileWalletConfigAdminUpsertInput {
admin_user_id,
@@ -345,6 +345,13 @@ pub fn build_runtime_profile_recharge_center_get_input(
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
}
pub fn build_runtime_profile_membership_get_input(
user_id: String,
) -> Result<RuntimeProfileMembershipGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileMembershipGetInput { user_id })
}
pub fn build_runtime_profile_recharge_order_get_input(
order_id: String,
) -> Result<RuntimeProfileRechargeOrderGetInput, RuntimeProfileFieldError> {
@@ -357,7 +364,6 @@ pub fn build_runtime_profile_recharge_order_create_input(
user_id: String,
product_id: String,
payment_channel: String,
created_at_micros: i64,
) -> Result<RuntimeProfileRechargeOrderCreateInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let product_id =
@@ -369,7 +375,6 @@ pub fn build_runtime_profile_recharge_order_create_input(
user_id,
product_id,
payment_channel,
created_at_micros,
})
}
@@ -388,15 +393,8 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input(
price_cents: u64,
kind: RuntimeProfileRechargeProductKind,
points_amount: u64,
bonus_points: u64,
duration_days: u32,
badge_label: String,
description: String,
tier: RuntimeProfileMembershipTier,
membership_period_points: u64,
membership_period_days: u32,
membership_queue_limit: u32,
membership_discount_bps: u32,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
@@ -414,28 +412,10 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input(
if points_amount == 0 {
return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints);
}
if duration_days != 0
|| tier != RuntimeProfileMembershipTier::Normal
|| membership_period_points != 0
|| membership_period_days != 0
|| membership_queue_limit != 0
|| membership_discount_bps != 0
{
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
}
}
// 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。
RuntimeProfileRechargeProductKind::Membership => {
if duration_days == 0 {
return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration);
}
if points_amount != 0
|| bonus_points != 0
|| tier == RuntimeProfileMembershipTier::Normal
|| membership_period_points == 0
|| membership_period_days == 0
{
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
}
return Err(RuntimeProfileFieldError::InvalidRechargeProductKind);
}
}
@@ -446,21 +426,93 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input(
price_cents,
kind,
points_amount,
bonus_points,
duration_days,
badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(),
description: normalize_optional_string(Some(description)).unwrap_or_default(),
tier,
membership_period_points,
membership_period_days,
membership_queue_limit,
membership_discount_bps,
enabled,
sort_order,
updated_at_micros,
})
}
pub fn build_runtime_profile_membership_plan_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileMembershipPlanAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileMembershipPlanAdminListInput { admin_user_id })
}
/// 后台会员档位改价 / 改权益入参校验。
///
/// `normal` 是不可购买的非会员占位档位:价格与每期泥点一律按 0 落库;
/// 并发上限与模型权限固定为目录种子里的非会员基线,后台不能改。
#[allow(clippy::too_many_arguments)]
pub fn build_runtime_profile_membership_plan_admin_upsert_input(
admin_user_id: String,
plan: RuntimeProfileMembershipPlan,
title: String,
month_price_cents: u64,
year_price_cents: u64,
period_points: u64,
model_access: RuntimeProfileMembershipModelAccess,
concurrent_job_limit: u32,
enabled: bool,
updated_at_micros: i64,
) -> Result<RuntimeProfileMembershipPlanAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?;
if !plan.is_purchasable() {
// 中文注释:非会员基线固定取自目录种子,避免后台把「非会员」改成 0 并发或「不限并发」。
let baseline = crate::resolve_runtime_profile_membership_plan_row(plan)
.ok_or(RuntimeProfileFieldError::InvalidMembershipPlan)?;
return Ok(RuntimeProfileMembershipPlanAdminUpsertInput {
admin_user_id,
plan,
title,
month_price_cents: 0,
year_price_cents: 0,
period_points: 0,
model_access: baseline.model_access,
concurrent_job_limit: baseline.concurrent_job_limit,
enabled: false,
updated_at_micros,
});
}
if month_price_cents == 0
|| year_price_cents == 0
|| period_points == 0
|| concurrent_job_limit == 0
{
return Err(RuntimeProfileFieldError::InvalidMembershipPlan);
}
Ok(RuntimeProfileMembershipPlanAdminUpsertInput {
admin_user_id,
plan,
title,
month_price_cents,
year_price_cents,
period_points,
model_access,
concurrent_job_limit,
enabled,
updated_at_micros,
})
}
pub fn build_runtime_profile_membership_upgrade_quote_input(
user_id: String,
target_plan: RuntimeProfileMembershipPlan,
) -> Result<RuntimeProfileMembershipUpgradeQuoteGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileMembershipUpgradeQuoteGetInput {
user_id,
target_plan,
})
}
pub fn build_runtime_profile_recharge_order_paid_input(
order_id: String,
paid_at_micros: i64,
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -51,7 +51,7 @@ pub enum RuntimeProfileFieldError {
InvalidWalletAmount,
InvalidWalletMetadata,
InvalidInitialWalletPoints,
InvalidDailyFreePointsPerDay,
DailyFreePointsPerDayOverflow,
WalletAmountOverflow,
WalletBalanceOverflow,
InsufficientWalletBalance,
@@ -88,9 +88,9 @@ pub enum RuntimeProfileFieldError {
MissingProductTitle,
InvalidRechargeProductPrice,
InvalidRechargeProductPoints,
InvalidRechargeProductDuration,
InvalidRechargeProductKind,
InvalidRechargeProductTier,
InvalidMembershipPlan,
InvalidMembershipPlanOrder,
MissingPaymentChannel,
#[cfg(any())]
MissingWorldKey,
@@ -136,8 +136,8 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInitialWalletPoints => {
f.write_str("profile_wallet_config.initial_mud_points 必须大于 0")
}
Self::InvalidDailyFreePointsPerDay => {
f.write_str("profile_wallet_config.daily_free_points_per_day 必须大于 0")
Self::DailyFreePointsPerDayOverflow => {
f.write_str("profile_wallet_config.daily_free_points_per_day 超出上限")
}
Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
@@ -181,11 +181,15 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"),
Self::InvalidRechargeProductPrice => f.write_str("recharge.price_cents 必须大于 0"),
Self::InvalidRechargeProductPoints => f.write_str("泥点商品 points_amount 必须大于 0"),
Self::InvalidRechargeProductDuration => {
f.write_str("会员商品 duration_days 必须大于 0")
Self::InvalidRechargeProductKind => {
f.write_str("充值商品类型无效:会员商品已迁移到会员档位目录")
}
Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"),
Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"),
Self::InvalidMembershipPlan => {
f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效")
}
Self::InvalidMembershipPlanOrder => f.write_str(
"会员档位必须按 rank 严格递增:更高档位的月价、年价与每期泥点都必须更高",
),
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
#[cfg(any())]
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
+80 -261
View File
@@ -1,26 +1,45 @@
pub mod agc_analytics;
pub mod civil_calendar;
pub mod membership;
mod agc_model_access;
mod agc_models;
mod llm_billing;
pub use llm_billing::*;
mod payment;
pub use payment::*;
mod application;
pub use agc_models::*;
mod commands;
mod domain;
mod errors;
mod events;
mod llm_billing;
pub use agc_model_access::*;
pub use agc_models::*;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use llm_billing::*;
// 中文注释:`membership` 与 `civil_calendar` 不再用 `pub use ...::*` 摊平到 crate 根:
// 通配会把公历基元和目录内部件都变成公开 API,模块继续增长后容易命名冲突;
// 这里只重导出真正对外的会员契约符号,其余留在 `membership::` / `civil_calendar::` 命名空间下。
pub use membership::{
MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN,
RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot,
RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection,
beijing_local_micros, build_runtime_profile_membership_plan_snapshot,
check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window,
membership_expires_at, missing_runtime_profile_membership_plan_rows,
parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade,
resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog,
runtime_profile_membership_plan_rank, runtime_profile_membership_product_id,
validate_runtime_profile_membership_plan_row_against_catalog,
};
use shared_kernel::format_rfc3339 as format_shared_rfc3339;
use time::OffsetDateTime;
pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30;
pub fn format_utc_micros(micros: i64) -> String {
let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
@@ -29,166 +48,12 @@ pub fn format_utc_micros(micros: i64) -> String {
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
vec![
build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"),
build_points_recharge_product(
"points_180",
"180泥点",
1800,
180,
90,
"首充加赠",
"首充加赠90泥点",
),
build_points_recharge_product(
"points_300",
"300泥点",
3000,
300,
150,
"首充加赠",
"首充加赠150泥点",
),
build_points_recharge_product(
"points_680",
"680泥点",
6800,
680,
340,
"首充加赠",
"首充加赠340泥点",
),
]
}
/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。
pub fn resolve_runtime_profile_recharge_point_products(
_has_points_recharged: bool,
) -> Vec<RuntimeProfileRechargeProductSnapshot> {
runtime_profile_recharge_point_products()
}
pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot>
{
vec![
build_membership_recharge_product(
"member_starter",
"Starter",
1990,
30,
RuntimeProfileMembershipTier::Starter,
"每月200泥点 用于游戏创作",
),
build_membership_recharge_product(
"member_basic",
"Basic",
6990,
30,
RuntimeProfileMembershipTier::Basic,
"每月800泥点 用于游戏创作",
),
build_membership_recharge_product(
"member_pro",
"Pro",
19990,
30,
RuntimeProfileMembershipTier::Pro,
"每月2500泥点 用于游戏创作",
),
build_membership_recharge_product(
"member_ultimate",
"Ultimate",
41990,
30,
RuntimeProfileMembershipTier::Ultimate,
"每月6000泥点 用于游戏创作",
),
]
}
pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option<u8> {
match tier {
RuntimeProfileMembershipTier::Normal => None,
RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1),
RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2),
RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3),
RuntimeProfileMembershipTier::Ultimate => Some(4),
}
}
pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 {
match tier {
RuntimeProfileMembershipTier::Starter => 200,
RuntimeProfileMembershipTier::Basic => 800,
RuntimeProfileMembershipTier::Pro => 2500,
RuntimeProfileMembershipTier::Ultimate => 6000,
_ => 0,
}
}
pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 {
match tier {
RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2,
RuntimeProfileMembershipTier::Pro => 5,
RuntimeProfileMembershipTier::Ultimate => 10,
_ => 0,
}
}
pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 {
match tier {
RuntimeProfileMembershipTier::Basic => 8500,
RuntimeProfileMembershipTier::Pro => 8000,
RuntimeProfileMembershipTier::Ultimate => 7000,
_ => 0,
}
}
pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBenefitSnapshot> {
vec![
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "特权名称".to_string(),
normal_value: "普通".to_string(),
month_value: "月卡".to_string(),
season_value: "季卡".to_string(),
year_value: "年卡".to_string(),
starter_value: "Starter".to_string(),
basic_value: "Basic".to_string(),
pro_value: "Pro".to_string(),
ultimate_value: "Ultimate".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "每月泥点".to_string(),
normal_value: "0".to_string(),
month_value: "0".to_string(),
season_value: "0".to_string(),
year_value: "0".to_string(),
starter_value: "200".to_string(),
basic_value: "800".to_string(),
pro_value: "2500".to_string(),
ultimate_value: "6000".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "同时排队".to_string(),
normal_value: "1".to_string(),
month_value: "2".to_string(),
season_value: "2".to_string(),
year_value: "2".to_string(),
starter_value: "2".to_string(),
basic_value: "2".to_string(),
pro_value: "5".to_string(),
ultimate_value: "10".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "商业所有权".to_string(),
normal_value: "0%".to_string(),
month_value: "有".to_string(),
season_value: "有".to_string(),
year_value: "有".to_string(),
starter_value: "有".to_string(),
basic_value: "有".to_string(),
pro_value: "有".to_string(),
ultimate_value: "有".to_string(),
},
build_points_recharge_product("points_60", "60泥点", 600, 60),
build_points_recharge_product("points_180", "180泥点", 1800, 180),
build_points_recharge_product("points_300", "300泥点", 3000, 300),
build_points_recharge_product("points_680", "680泥点", 6800, 680),
build_points_recharge_product("points_1280", "1280泥点", 12800, 1280),
build_points_recharge_product("points_3280", "3280泥点", 32800, 3280),
]
}
@@ -197,7 +62,6 @@ pub fn runtime_profile_recharge_product_by_id(
) -> Option<RuntimeProfileRechargeProductSnapshot> {
runtime_profile_recharge_point_products()
.into_iter()
.chain(runtime_profile_recharge_membership_products())
.find(|product| product.product_id == product_id)
}
@@ -213,9 +77,6 @@ fn build_points_recharge_product(
title: &str,
price_cents: u64,
points_amount: u64,
bonus_points: u64,
badge_label: &str,
description: &str,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: product_id.to_string(),
@@ -223,41 +84,10 @@ fn build_points_recharge_product(
price_cents,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount,
bonus_points,
duration_days: 0,
badge_label: badge_label.to_string(),
description: description.to_string(),
tier: RuntimeProfileMembershipTier::Normal,
membership_period_points: 0,
membership_period_days: 0,
membership_queue_limit: 0,
membership_discount_bps: 0,
}
}
fn build_membership_recharge_product(
product_id: &str,
title: &str,
price_cents: u64,
duration_days: u32,
tier: RuntimeProfileMembershipTier,
description: &str,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: product_id.to_string(),
title: title.to_string(),
price_cents,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days,
duration_days: 0,
badge_label: String::new(),
description: description.to_string(),
tier,
membership_period_points: runtime_profile_membership_period_points(tier),
membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS,
membership_queue_limit: runtime_profile_membership_queue_limit(tier),
membership_discount_bps: runtime_profile_membership_discount_bps(tier),
description: title.to_string(),
}
}
@@ -863,7 +693,7 @@ mod tests {
}
#[test]
fn profile_wallet_config_requires_positive_daily_free_points() {
fn profile_wallet_config_allows_zero_daily_free_points() {
assert_eq!(
build_runtime_profile_wallet_config_admin_upsert_input(
"admin-1".to_string(),
@@ -871,19 +701,19 @@ mod tests {
1_713_680_000_000_000,
0,
)
.expect_err("zero daily free points should fail"),
RuntimeProfileFieldError::InvalidDailyFreePointsPerDay,
.expect("zero daily free points should pass")
.daily_free_points_per_day,
0,
);
assert_eq!(
build_runtime_profile_wallet_config_admin_upsert_input(
"admin-1".to_string(),
100,
1_713_680_000_000_000,
35,
i64::MAX as u64 + 1,
)
.expect("positive daily free points should pass")
.daily_free_points_per_day,
35,
.expect_err("overflowing daily free points should fail"),
RuntimeProfileFieldError::DailyFreePointsPerDayOverflow,
);
}
@@ -937,6 +767,10 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::LlmRouterConsume.as_str(),
"llm_router_consume"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant.as_str(),
"membership_upgrade_grant"
);
}
#[test]
@@ -1123,59 +957,46 @@ mod tests {
#[test]
fn recharge_product_catalog_matches_reference_prices() {
let point_products = runtime_profile_recharge_point_products();
let membership_products = runtime_profile_recharge_membership_products();
assert_eq!(point_products.len(), 4);
assert_eq!(point_products[0].product_id, "points_60");
assert_eq!(point_products[0].title, "60泥点");
assert_eq!(point_products[0].price_cents, 600);
assert_eq!(point_products[0].bonus_points, 0);
assert_eq!(point_products[0].badge_label, "");
assert_eq!(point_products[0].description, "60泥点");
assert_eq!(point_products[1].product_id, "points_180");
assert_eq!(point_products[1].bonus_points, 90);
assert_eq!(point_products[2].bonus_points, 150);
assert_eq!(point_products[3].product_id, "points_680");
assert_eq!(point_products[3].price_cents, 6800);
assert_eq!(point_products[3].bonus_points, 340);
assert_eq!(point_products[3].description, "首充加赠340泥点");
assert_eq!(membership_products.len(), 4);
assert_eq!(membership_products[0].product_id, "member_starter");
assert_eq!(membership_products[0].title, "Starter");
assert_eq!(membership_products[0].price_cents, 1990);
assert_eq!(membership_products[0].membership_period_points, 200);
assert_eq!(point_products.len(), 6);
for (product, product_id, price_cents, points_amount) in [
(&point_products[0], "points_60", 600, 60),
(&point_products[1], "points_180", 1800, 180),
(&point_products[2], "points_300", 3000, 300),
(&point_products[3], "points_680", 6800, 680),
(&point_products[4], "points_1280", 12800, 1280),
(&point_products[5], "points_3280", 32800, 3280),
] {
assert_eq!(product.product_id, product_id);
assert_eq!(product.price_cents, price_cents);
assert_eq!(product.points_amount, points_amount);
// 中文注释:新方案取消首充赠送,六档充点一律无赠送、无角标。
assert_eq!(product.bonus_points, 0);
assert_eq!(product.badge_label, "");
}
let plans = runtime_profile_membership_plan_catalog();
assert_eq!(plans.len(), 5);
assert_eq!(plans[0].plan, RuntimeProfileMembershipPlan::Normal);
assert!(!plans[0].enabled);
assert_eq!(plans[0].period_points, 0);
assert_eq!(plans[0].concurrent_job_limit, 1);
assert_eq!(plans[1].plan, RuntimeProfileMembershipPlan::Starter);
assert_eq!(plans[1].month_price_cents, 3_900);
assert_eq!(plans[1].year_price_cents, 39_000);
assert_eq!(plans[1].period_points, 400);
assert_eq!(plans[2].plan, RuntimeProfileMembershipPlan::Plus);
assert_eq!(plans[2].period_points, 1_150);
assert_eq!(plans[3].plan, RuntimeProfileMembershipPlan::Pro);
assert_eq!(plans[3].period_points, 3_650);
assert_eq!(plans[4].plan, RuntimeProfileMembershipPlan::Max);
assert_eq!(plans[4].month_price_cents, 69_900);
assert_eq!(plans[4].year_price_cents, 699_000);
assert_eq!(plans[4].period_points, 8_650);
assert_eq!(
membership_products[0].membership_period_days,
PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS
plans[4].concurrent_job_limit,
MEMBERSHIP_UNLIMITED_CONCURRENCY
);
assert_eq!(membership_products[3].product_id, "member_ultimate");
assert_eq!(membership_products[3].membership_period_points, 6000);
assert_eq!(membership_products[3].membership_queue_limit, 10);
let benefits = runtime_profile_membership_benefits();
assert!(
benefits
.iter()
.any(|benefit| benefit.benefit_name == "每月泥点")
);
}
#[test]
fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() {
let first_recharge_products = resolve_runtime_profile_recharge_point_products(false);
assert_eq!(first_recharge_products[0].bonus_points, 0);
assert_eq!(first_recharge_products[0].badge_label, "");
assert_eq!(first_recharge_products[0].description, "60泥点");
assert_eq!(first_recharge_products[1].bonus_points, 90);
assert_eq!(first_recharge_products[1].badge_label, "首充加赠");
assert_eq!(first_recharge_products[1].description, "首充加赠90泥点");
let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true);
assert_eq!(repeated_recharge_products[0].bonus_points, 0);
assert_eq!(repeated_recharge_products[1].bonus_points, 90);
assert_eq!(repeated_recharge_products[3].bonus_points, 340);
assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠");
assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点");
}
#[test]
@@ -1184,7 +1005,6 @@ mod tests {
"user-1".to_string(),
"custom-points-600".to_string(),
"mock".to_string(),
1,
)
.expect("product existence is validated against database config later");
@@ -1198,7 +1018,6 @@ mod tests {
"user-1".to_string(),
"points_60".to_string(),
" ".to_string(),
1,
)
.expect_err("missing payment channel should fail");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
//! 会员账期的自然月推进(纯函数,无 IO)。
//!
//! 账期锚点 = 北京时间(UTC+8)的「开通日时刻」。第 n 期一律从**原始锚点**重算,
//! 而不是从上一期递推,所以 1/31 开通得到的序列是 1/31 → 2/28 → 3/31,不会漂移成 3/28。
//! 单期长度固定为一个自然月,日期按目标月实际长度夹取(`min(开通日, 目标月天数)`)。
//! 中国不使用夏令时,因此这里用固定 UTC+8 偏移而不是时区库。
use crate::civil_calendar::{civil_from_days, days_from_civil, days_in_month};
use crate::{PROFILE_RUNTIME_DAY_MICROS, PROFILE_TASK_BEIJING_OFFSET_MICROS};
/// 北京时间(UTC+8)某个时刻的本地「年月日 + 当日已过微秒」。
///
/// 与 [`runtime_profile_beijing_day_key`](crate::runtime_profile_beijing_day_key) 使用同一套
/// 固定偏移与业务日边界,保证「同一个业务日」在所有余额投影里口径一致。
pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) {
let local_micros = micros.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS);
let day_key = local_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS);
let time_of_day_micros = local_micros.rem_euclid(PROFILE_RUNTIME_DAY_MICROS);
let (year, month, day) = civil_from_days(day_key);
(year, month, day, time_of_day_micros)
}
/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期或当日时刻越界时返回 `None`。
///
/// 返回 `Option` 而不是用 `0` 兼作非法哨兵:北京 1970-01-01 08:00 本身就是合法的 `0`,
/// 调用方必须能区分「非法日期」与「合法 epoch」。
pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> Option<i64> {
// 中文注释:先按 i64 微秒可表示范围(约 ±292_278 年)收口年份,避免 `days_from_civil`
// 内部的 `era * 146_097` 在 i32 上溢出(debug panic / release 回绕成看似合法的值)。
if !(-292_278..=292_278).contains(&year)
|| !(1..=12).contains(&month)
|| day == 0
|| day > days_in_month(year, month)
|| !(0..PROFILE_RUNTIME_DAY_MICROS).contains(&time_of_day_micros)
{
return None;
}
Some(
days_from_civil(year, month, day)
.checked_mul(PROFILE_RUNTIME_DAY_MICROS)?
.checked_add(time_of_day_micros)?
.checked_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS)?,
)
}
/// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。
///
/// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。
///
/// 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`:
/// 该输入在物理上不可表示,饱和既保留「更晚 / 更早」的方向,又避免历法乘法与
/// [`beijing_local_micros`] 的 `checked_*` 溢出让这个纯函数 panic。
pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 {
// 中文注释:`beijing_local_date_parts` 对极端 micros 会饱和后再反解,年份可能已被 i32 截断;
// 这里先按 i64 算目标年份,只在安全区间内才落回 i32 历法换算。
const MIN_SAFE_YEAR: i64 = -300_000;
const MAX_SAFE_YEAR: i64 = 300_000;
let (year, month, day, time_of_day_micros) = beijing_local_date_parts(anchor_micros);
let total_months = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(months);
let target_year = total_months.div_euclid(12);
let target_month = (total_months.rem_euclid(12) + 1) as u8;
if target_year < MIN_SAFE_YEAR {
return i64::MIN;
}
if target_year > MAX_SAFE_YEAR {
return i64::MAX;
}
let target_year = target_year as i32;
let target_day = day.min(days_in_month(target_year, target_month));
beijing_local_micros(target_year, target_month, target_day, time_of_day_micros).unwrap_or_else(
|| {
if anchor_micros < 0 {
i64::MIN
} else {
i64::MAX
}
},
)
}
/// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。
///
/// `index = 1` 是开通当期;`cycle_resets_at` 即下一期开始的时刻。窗口长度与周期类型无关,
/// 年付只是把同样的月度窗口重复 12 次;期数总数由
/// [`RuntimeProfileMembershipCycleKind::cycle_count`](crate::RuntimeProfileMembershipCycleKind::cycle_count)
/// 给出。
///
/// `index = 0` 会被 `max(1)` 收敛为第 1 期(开通当期),不报错:这是为兼容「期号水位尚未初始化」
/// 的存量行。它不同于 `quote_runtime_profile_membership_upgrade` 对 `cycle_index == 0` 的显式拒绝
/// (报价要求调用方给出明确期号),调用方若已能拿到真实期号,不应把 `0` 当合法期号传入。
pub fn membership_cycle_window(anchor_micros: i64, index: u32) -> (i64, i64) {
let index = index.max(1);
let start = if index == 1 {
anchor_micros
} else {
advance_beijing_months_clamped(anchor_micros, index - 1)
};
let resets_at = advance_beijing_months_clamped(anchor_micros, index);
(start, resets_at)
}
/// 本次购买的到期时刻:从锚点推进「总期数」个自然月(月付 1 期、年付 12 期)。
pub fn membership_expires_at(
anchor_micros: i64,
cycle_kind: crate::RuntimeProfileMembershipCycleKind,
) -> i64 {
advance_beijing_months_clamped(anchor_micros, cycle_kind.cycle_count())
}
/// 从当前时刻到本期刷新时刻的剩余微秒;已过期返回 0。
pub fn membership_cycle_remaining_micros(cycle_resets_at_micros: i64, now_micros: i64) -> i64 {
cycle_resets_at_micros.saturating_sub(now_micros).max(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RuntimeProfileMembershipCycleKind;
/// 北京时间某时刻 → UTC 微秒。
fn beijing(year: i32, month: u8, day: u8, hour: i64, minute: i64) -> i64 {
beijing_local_micros(year, month, day, hour * 3_600_000_000 + minute * 60_000_000)
.expect("测试用的北京本地时间应当合法")
}
#[test]
fn advance_saturates_instead_of_panicking_on_extreme_anchors() {
// 中文注释:极端锚点已无法精确表示目标月份,本用例只要求「不 panic 且方向不反转」。
assert_eq!(advance_beijing_months_clamped(i64::MAX, 1), i64::MAX);
assert!(advance_beijing_months_clamped(i64::MIN, 1) < 0);
assert_eq!(advance_beijing_months_clamped(i64::MAX, u32::MAX), i64::MAX);
}
#[test]
fn beijing_local_parts_round_trip() {
let micros = beijing(2026, 10, 2, 9, 20);
assert_eq!(
beijing_local_date_parts(micros),
(2026, 10, 2, 9 * 3_600_000_000 + 20 * 60_000_000)
);
}
#[test]
fn beijing_local_micros_distinguishes_epoch_from_invalid_input() {
// 中文注释:北京 1970-01-01 08:00 就是合法的 UTC 0,不能再被 0 哨兵吞掉。
assert_eq!(beijing(1970, 1, 1, 8, 0), 0);
assert_eq!(beijing_local_micros(2026, 13, 1, 0), None);
assert_eq!(beijing_local_micros(2026, 2, 30, 0), None);
assert_eq!(
beijing_local_micros(2026, 1, 1, PROFILE_RUNTIME_DAY_MICROS),
None
);
}
#[test]
fn beijing_local_micros_rejects_overflowing_year() {
// 中文注释:超出 i64 微秒范围的年份必须返回 None,而不是饱和成看似合法的值。
assert_eq!(beijing_local_micros(1_000_000, 1, 1, 0), None);
assert_eq!(beijing_local_micros(-1_000_000, 1, 1, 0), None);
// 这些年份会让 days_from_civil 内部的 i32 乘法溢出,必须在进入历法换算前收口。
assert_eq!(beijing_local_micros(10_000_000, 1, 1, 0), None);
assert_eq!(beijing_local_micros(-10_000_000, 1, 1, 0), None);
}
#[test]
fn advance_months_clamps_to_month_end() {
// 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。
let anchor = beijing(2026, 1, 31, 9, 20);
assert_eq!(advance_beijing_months_clamped(anchor, 0), anchor);
assert_eq!(
advance_beijing_months_clamped(anchor, 1),
beijing(2026, 2, 28, 9, 20)
);
assert_eq!(
advance_beijing_months_clamped(anchor, 2),
beijing(2026, 3, 31, 9, 20)
);
assert_eq!(
advance_beijing_months_clamped(anchor, 12),
beijing(2027, 1, 31, 9, 20)
);
}
#[test]
fn advance_months_handles_leap_year() {
let anchor = beijing(2024, 1, 31, 12, 0);
assert_eq!(
advance_beijing_months_clamped(anchor, 1),
beijing(2024, 2, 29, 12, 0)
);
assert_eq!(
advance_beijing_months_clamped(anchor, 2),
beijing(2024, 3, 31, 12, 0)
);
}
#[test]
fn advance_months_crosses_year_boundary() {
let anchor = beijing(2026, 12, 31, 23, 30);
assert_eq!(
advance_beijing_months_clamped(anchor, 1),
beijing(2027, 1, 31, 23, 30)
);
assert_eq!(
advance_beijing_months_clamped(anchor, 3),
beijing(2027, 3, 31, 23, 30)
);
}
#[test]
fn cycle_window_is_contiguous_from_the_anchor() {
let anchor = beijing(2026, 1, 31, 9, 20);
assert_eq!(
membership_cycle_window(anchor, 1),
(anchor, beijing(2026, 2, 28, 9, 20))
);
assert_eq!(
membership_cycle_window(anchor, 2),
(beijing(2026, 2, 28, 9, 20), beijing(2026, 3, 31, 9, 20))
);
assert_eq!(
membership_cycle_window(anchor, 3),
(beijing(2026, 3, 31, 9, 20), beijing(2026, 4, 30, 9, 20))
);
}
#[test]
fn cycle_window_treats_zero_index_as_first_period() {
let anchor = beijing(2026, 5, 20, 8, 0);
assert_eq!(
membership_cycle_window(anchor, 0),
membership_cycle_window(anchor, 1)
);
}
#[test]
fn expires_at_uses_total_period_count() {
let anchor = beijing(2026, 1, 31, 9, 20);
assert_eq!(
membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Monthly),
beijing(2026, 2, 28, 9, 20)
);
assert_eq!(
membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Yearly),
beijing(2027, 1, 31, 9, 20)
);
}
#[test]
fn remaining_micros_never_goes_negative() {
assert_eq!(membership_cycle_remaining_micros(2_000, 1_000), 1_000);
assert_eq!(membership_cycle_remaining_micros(1_000, 1_000), 0);
assert_eq!(membership_cycle_remaining_micros(1_000, 2_000), 0);
}
}
@@ -0,0 +1,31 @@
//! 会员并发上限的判定(纯函数)。
//!
//! 并发上限用 `u32` 落列与对外暴露,`>= MEMBERSHIP_UNLIMITED_CONCURRENCY`(128)视为「不设上限」。
//! 刻意不用 `Option<u32>`:SpacetimeDB 表列与 DTO 都少一层可空分支,哨兵值只在领域层解释一次。
/// 并发上限哨兵值:等于或超过它即视为「不设上限」(`Max` 档)。
pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128;
/// 该并发上限是否表示「不设上限」。
pub fn is_unlimited_concurrency(limit: u32) -> bool {
limit >= MEMBERSHIP_UNLIMITED_CONCURRENCY
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_limits_are_not_unlimited() {
for limit in [1, 3, 5, 127] {
assert!(!is_unlimited_concurrency(limit));
}
}
#[test]
fn sentinel_and_above_are_unlimited() {
for limit in [MEMBERSHIP_UNLIMITED_CONCURRENCY, 129, u32::MAX] {
assert!(is_unlimited_concurrency(limit));
}
}
}
@@ -0,0 +1,20 @@
//! 会员计费领域层(纯函数,无 IO)。
//!
//! 只放会员侧的规则计算:档位目录与解析、自然月账期推进、并发上限哨兵与补差升级报价。
//! SpacetimeDB 写入与事务编排留在 `spacetime-module`,HTTP 契约留在 `api-server`。
//!
//! 子模块按依赖顺序排列:
//! - [`catalog`]:档位枚举 → 可下单套餐(价格 / 每期泥点 / 模型权限 / 并发)。
//! - [`cycle`]:北京时间自然月账期(1/31 → 2/28 → 3/31,每期从原始锚点重算)。
//! - [`limits`]:并发上限哨兵,`>= 128` 视为「不设上限」。
//! - [`upgrade`]:补差升级报价与允许操作矩阵。
mod catalog;
mod cycle;
mod limits;
mod upgrade;
pub use catalog::*;
pub use cycle::*;
pub use limits::*;
pub use upgrade::*;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
use module_runtime::{
RuntimeProfileMembershipChangeKind, RuntimeProfileMembershipCycleKind,
RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan,
};
#[test]
fn membership_plan_tokens_round_trip_strictly() {
// 中文注释:对外 wire token 必须与枚举一一对应,未知 token 一律解析失败。
let cases = [
(RuntimeProfileMembershipPlan::Normal, "normal"),
(RuntimeProfileMembershipPlan::Starter, "starter"),
(RuntimeProfileMembershipPlan::Plus, "plus"),
(RuntimeProfileMembershipPlan::Pro, "pro"),
(RuntimeProfileMembershipPlan::Max, "max"),
];
for (plan, token) in cases {
assert_eq!(plan.as_str(), token);
assert_eq!(RuntimeProfileMembershipPlan::parse(token), Some(plan));
}
assert_eq!(RuntimeProfileMembershipPlan::parse("basic"), None);
assert_eq!(RuntimeProfileMembershipPlan::parse("ultimate"), None);
assert_eq!(RuntimeProfileMembershipPlan::parse("PLUS"), None);
assert_eq!(RuntimeProfileMembershipPlan::parse(""), None);
}
#[test]
fn membership_plan_normal_is_not_purchasable() {
// 中文注释:Normal 是非会员占位档位,不允许被购买。
assert!(!RuntimeProfileMembershipPlan::Normal.is_purchasable());
for plan in [
RuntimeProfileMembershipPlan::Starter,
RuntimeProfileMembershipPlan::Plus,
RuntimeProfileMembershipPlan::Pro,
RuntimeProfileMembershipPlan::Max,
] {
assert!(plan.is_purchasable());
}
}
#[test]
fn membership_cycle_kind_maps_to_period_count() {
// 中文注释:年付 12 期、月付 1 期,每期仍是一个自然月。
assert_eq!(RuntimeProfileMembershipCycleKind::Monthly.cycle_count(), 1);
assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.cycle_count(), 12);
assert_eq!(
RuntimeProfileMembershipCycleKind::Monthly.as_str(),
"monthly"
);
assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.as_str(), "yearly");
assert_eq!(
RuntimeProfileMembershipCycleKind::parse("yearly"),
Some(RuntimeProfileMembershipCycleKind::Yearly)
);
assert_eq!(RuntimeProfileMembershipCycleKind::parse("weekly"), None);
}
#[test]
fn membership_model_access_and_change_kind_tokens_round_trip() {
assert_eq!(RuntimeProfileMembershipModelAccess::Basic.as_str(), "basic");
assert_eq!(RuntimeProfileMembershipModelAccess::Full.as_str(), "full");
assert_eq!(
RuntimeProfileMembershipModelAccess::parse("full"),
Some(RuntimeProfileMembershipModelAccess::Full)
);
assert_eq!(RuntimeProfileMembershipModelAccess::parse("premium"), None);
assert_eq!(
RuntimeProfileMembershipChangeKind::Purchase.as_str(),
"purchase"
);
assert_eq!(
RuntimeProfileMembershipChangeKind::Upgrade.as_str(),
"upgrade"
);
assert_eq!(
RuntimeProfileMembershipChangeKind::parse("upgrade"),
Some(RuntimeProfileMembershipChangeKind::Upgrade)
);
assert_eq!(RuntimeProfileMembershipChangeKind::parse("downgrade"), None);
}
+62 -2
View File
@@ -3,6 +3,11 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::runtime::{
ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken,
ProfileMembershipModelAccessToken, ProfileMembershipPlanToken, ProfileMembershipStatusToken,
};
#[cfg(any())]
use crate::creation_entry_config::{
CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse,
@@ -10,7 +15,7 @@ use crate::creation_entry_config::{
};
/// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。
pub const ADMIN_TAB_PERMISSIONS: [&str; 21] = [
pub const ADMIN_TAB_PERMISSIONS: [&str; 22] = [
"dashboard",
"overview",
"tables",
@@ -23,6 +28,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 21] = [
"profile-wallet",
"tasks",
"recharge-products",
"membership-plans",
"recharge-orders",
"editor-generation-pricing",
"editor-showcase",
@@ -1185,7 +1191,8 @@ pub struct AdminProfileWalletPayload {
pub total_balance: u64,
pub spendable_balance: u64,
pub daily_free_points: u64,
pub membership_limited_points: u64,
/// 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。
pub monthly_points: u64,
pub permanent_points: u64,
pub held_points: u64,
pub refund_debt_points: u64,
@@ -1233,6 +1240,31 @@ pub struct AdminRechargeRefundHoldPayload {
pub updated_at_micros: i64,
}
/// 会员购买 / 升级的权益变更快照(只读)。
///
/// 与 `profile_recharge_order` 的末位快照列一一对应,供订单列表与退款人工复核还原变更
/// 前后的档位、账期、补点与有效期。非会员订单为 `None`。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminMembershipOrderChangePayload {
/// `purchase` 或 `upgrade`。
pub change_kind: ProfileMembershipChangeKindToken,
pub plan_before: Option<ProfileMembershipPlanToken>,
pub plan_after: Option<ProfileMembershipPlanToken>,
pub cycle_kind_before: Option<ProfileMembershipCycleKindToken>,
pub cycle_kind_after: Option<ProfileMembershipCycleKindToken>,
pub period_points_before: Option<u64>,
pub period_points_after: Option<u64>,
pub granted_points_delta: u64,
pub cycle_index: Option<u32>,
pub expires_at_before_micros: Option<i64>,
pub expires_at_after_micros: Option<i64>,
pub cycle_resets_at_before_micros: Option<i64>,
pub cycle_resets_at_after_micros: Option<i64>,
/// 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);字符串原样透出供人工核对。
pub price_breakdown_json: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminRechargeOrderEntryPayload {
@@ -1262,6 +1294,8 @@ pub struct AdminRechargeOrderEntryPayload {
pub remaining_refundable_cents: u64,
pub refund_eligible: bool,
pub refund_block_reason_code: Option<String>,
/// 会员购买 / 升级快照;非会员订单为 `None`。
pub membership_change: Option<AdminMembershipOrderChangePayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -1294,9 +1328,27 @@ pub struct AdminUserDetailResponse {
pub cumulative_recharged_cents: Option<u64>,
pub can_reconcile_consumption: bool,
pub wallet: AdminProfileWalletPayload,
pub membership: AdminProfileMembershipPayload,
pub recharge_orders: Vec<AdminRechargeOrderEntryPayload>,
}
/// 单用户当前会员状态与账期(只读),供后台用户详情判断档位、周期与到期可见性。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminProfileMembershipPayload {
pub plan: ProfileMembershipPlanToken,
pub status: ProfileMembershipStatusToken,
pub cycle_kind: ProfileMembershipCycleKindToken,
pub cycle_index: u32,
pub cycle_count: u32,
pub cycle_granted_points: u64,
pub cycle_remaining_points: u64,
pub started_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub cycle_resets_at_micros: Option<i64>,
pub updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserConsumptionReconcileRequest {
@@ -1653,6 +1705,11 @@ fn default_codex_agent_mode() -> String {
"codex".to_string()
}
/// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。
fn default_basic_agc_model_access() -> ProfileMembershipModelAccessToken {
ProfileMembershipModelAccessToken::Basic
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminAgcModel {
@@ -1667,6 +1724,9 @@ pub struct AdminAgcModel {
/// 服务端回读时始终给出解析后的具体值。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
/// 该模型要求的访问级别:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。
#[serde(default = "default_basic_agc_model_access")]
pub access: ProfileMembershipModelAccessToken,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]

Some files were not shown because too many files have changed in this diff Show More