Files
kdletters 458371a73d
Project CI / Repository checks (push) Successful in 4m11s
Project CI / Frontend tests (push) Successful in 8m38s
Project CI / Backend tests (push) Successful in 10m42s
Project CI / Native shell tests (push) Successful in 32m11s
将退款 outbox 主路径迁入 SpacetimeDB (#204)
## 变更内容

- 新增 `profile_wallet_refund_outbox` 表与 enqueue/process procedure,退款主路径进入 SpacetimeDB。
- 外部生成失败事务、inline 资产失败和跨节点 worker 统一使用库内 outbox,按 ledger 幂等并在事务内完成退款与删除。
- SpacetimeDB 完全不可达时才写本机 emergency spool,恢复时重新入库;兼容旧 spool 文件并保留 attempt 追踪。
- 更新 SpacetimeDB migration、生成 bindings、架构文档、运维恢复说明和项目决策记录。

## 验证

- `cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`
- api-server / spacetime-client / spacetime-module / module-runtime 定向测试
- `npm run check:spacetime-schema`
- `npm run check:spacetime-runtime-access`
- `npm run check:server-rs-ddd`
- `npm run check:encoding`
- `git diff --check`

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/204
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-27 22:04:50 +08:00

254 lines
11 KiB
Rust

//! 运行时领域错误。
//!
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
#[cfg(any())]
use crate::MAX_BROWSE_HISTORY_BATCH_SIZE;
use crate::{
PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS, PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS,
PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS, PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES,
PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT, PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeSettingsFieldError {
MissingUserId,
}
impl std::fmt::Display for RuntimeSettingsFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"),
}
}
}
#[cfg(any())]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeBrowseHistoryFieldError {
MissingUserId,
TooManyEntries,
}
#[cfg(any())]
impl std::fmt::Display for RuntimeBrowseHistoryFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"),
Self::TooManyEntries => write!(
f,
"browse_history.entries 单次最多只允许 {} 条",
MAX_BROWSE_HISTORY_BATCH_SIZE
),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeProfileFieldError {
MissingUserId,
MissingLedgerId,
InvalidWalletAmount,
InvalidWalletMetadata,
InvalidInitialWalletPoints,
InvalidDailyFreePointsPerDay,
WalletAmountOverflow,
WalletBalanceOverflow,
InsufficientWalletBalance,
MissingInviteCode,
MissingRedeemCode,
RedeemCodeDisabled,
RedeemCodeUsesExhausted,
RedeemCodeNotAllowedForUser,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
InvalidRedeemCodeValidityWindow,
RedeemCodeNotStarted,
RedeemCodeExpired,
InvalidInviteCodeMetadata,
InvalidUserTag,
InvalidInviteCodeValidityWindow,
MissingTaskId,
MissingTaskTitle,
MissingTaskEventKey,
MissingTrackingEventId,
MissingTrackingScopeId,
InvalidTaskCycle,
InvalidTaskScopeKind,
UnsupportedProfileTaskScopeKind,
InvalidTaskThreshold,
InvalidTaskReward,
TaskDisabled,
TaskNotClaimable,
TaskAlreadyClaimed,
MissingWorkerId,
InvalidExternalGenerationAttempt,
MissingOrderId,
MissingProductId,
MissingProductTitle,
InvalidRechargeProductPrice,
InvalidRechargeProductPoints,
InvalidRechargeProductDuration,
InvalidRechargeProductKind,
InvalidRechargeProductTier,
MissingPaymentChannel,
#[cfg(any())]
MissingWorldKey,
#[cfg(any())]
MissingBottomTab,
#[cfg(any())]
MissingCheckpointSessionId,
UnknownRechargeProduct,
#[cfg(any())]
InvalidGameStateJson,
#[cfg(any())]
InvalidCurrentStoryJson,
#[cfg(any())]
MissingRuntimeSessionId,
#[cfg(any())]
RuntimeSessionMismatch {
expected_session_id: String,
actual_session_id: String,
},
#[cfg(any())]
NonPersistentRuntimeSnapshot,
InvalidAnalyticsCalendarDate,
MissingFeedbackDescription,
FeedbackDescriptionTooShort,
FeedbackDescriptionTooLong,
FeedbackContactPhoneTooLong,
TooManyFeedbackEvidenceItems,
InvalidFeedbackEvidenceContentType,
InvalidFeedbackEvidenceDataUrl,
FeedbackEvidenceTooLarge,
FeedbackEvidenceTotalTooLarge,
}
impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::InvalidWalletMetadata => {
f.write_str("profile.wallet_metadata 必须是合法 JSON object")
}
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::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
Self::InsufficientWalletBalance => f.write_str("泥点余额不足"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::RedeemCodeDisabled => f.write_str("兑换码已停用"),
Self::RedeemCodeUsesExhausted => f.write_str("兑换次数已用完"),
Self::RedeemCodeNotAllowedForUser => f.write_str("该兑换码不适用于当前账号"),
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
Self::InvalidRedeemCodeValidityWindow => f.write_str("兑换码开始时间不能晚于截止时间"),
Self::RedeemCodeNotStarted => f.write_str("兑换码未生效"),
Self::RedeemCodeExpired => f.write_str("兑换码已过期"),
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是合法 JSON object")
}
Self::InvalidUserTag => f.write_str("用户标签格式无效"),
Self::InvalidInviteCodeValidityWindow => f.write_str("邀请码开始时间不能晚于截止时间"),
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),
Self::MissingTaskEventKey => f.write_str("profile_task.event_key 不能为空"),
Self::MissingTrackingEventId => f.write_str("tracking_event.event_id 不能为空"),
Self::MissingTrackingScopeId => f.write_str("tracking_event.scope_id 不能为空"),
Self::InvalidTaskCycle => f.write_str("profile_task.cycle 无效"),
Self::InvalidTaskScopeKind => f.write_str("profile_task.scope_kind 无效"),
Self::UnsupportedProfileTaskScopeKind => {
f.write_str("个人任务 scope_kind 首版仅支持 user")
}
Self::InvalidTaskThreshold => f.write_str("profile_task.threshold 必须大于 0"),
Self::InvalidTaskReward => f.write_str("profile_task.reward_points 必须大于 0"),
Self::TaskDisabled => f.write_str("任务已停用"),
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingWorkerId => f.write_str("worker_id 不能为空"),
Self::InvalidExternalGenerationAttempt => {
f.write_str("external_generation_job_id 与 claim_attempt 必须成对提供")
}
Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
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::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"),
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
#[cfg(any())]
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
#[cfg(any())]
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
#[cfg(any())]
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),
Self::UnknownRechargeProduct => f.write_str("recharge.product_id 不存在"),
#[cfg(any())]
Self::InvalidGameStateJson => {
f.write_str("runtime_snapshot.game_state 必须是合法 JSON")
}
#[cfg(any())]
Self::InvalidCurrentStoryJson => {
f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null")
}
#[cfg(any())]
Self::MissingRuntimeSessionId => {
f.write_str("服务端运行时快照缺少 runtimeSessionId,无法创建 checkpoint")
}
#[cfg(any())]
Self::RuntimeSessionMismatch { .. } => {
f.write_str("checkpoint sessionId 与服务端运行时快照不一致")
}
#[cfg(any())]
Self::NonPersistentRuntimeSnapshot => {
f.write_str("预览或测试运行态不能创建正式 checkpoint")
}
Self::InvalidAnalyticsCalendarDate => {
f.write_str("analytics_date_dimension.calendar_date 必须是合法 YYYY-MM-DD 日期")
}
Self::MissingFeedbackDescription => f.write_str("反馈问题描述不能为空"),
Self::FeedbackDescriptionTooShort => write!(
f,
"请填写{}个字以上的问题描述",
PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS
),
Self::FeedbackDescriptionTooLong => write!(
f,
"问题描述不能超过 {} 字",
PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS
),
Self::FeedbackContactPhoneTooLong => write!(
f,
"联系电话不能超过 {} 字",
PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS
),
Self::TooManyFeedbackEvidenceItems => {
write!(f, "最多上传{}张凭证", PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT)
}
Self::InvalidFeedbackEvidenceContentType => f.write_str("反馈凭证只支持图片类型"),
Self::InvalidFeedbackEvidenceDataUrl => f.write_str("反馈凭证图片数据无效"),
Self::FeedbackEvidenceTooLarge => write!(
f,
"单张反馈凭证不能超过 {} bytes",
PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES
),
Self::FeedbackEvidenceTotalTooLarge => write!(
f,
"反馈凭证总大小不能超过 {} bytes",
PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES
),
}
}
}