Merge branch 'master' into gptimage2-param-limit-in-backend

This commit is contained in:
2026-07-14 17:59:31 +08:00
39 changed files with 2668 additions and 745 deletions
@@ -16,6 +16,7 @@ use module_runtime::{
build_runtime_profile_recharge_order_admin_list_input,
build_runtime_profile_recharge_refund_hold_prepare_input,
build_runtime_profile_recharge_refund_hold_preview_input,
build_runtime_profile_recharge_refund_manual_review_resolve_input,
build_runtime_profile_recharge_refund_settlement_plan,
build_runtime_profile_wallet_manual_restriction_upsert_input,
};
@@ -27,7 +28,8 @@ use sha2::{Digest, Sha256};
use shared_contracts::admin::{
AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse,
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, AdminRechargeRefundPayload,
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload,
AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload,
AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse,
AdminRechargeRefundRegisterRequest, AdminUserDetailQuery, AdminUserDetailResponse,
AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest,
@@ -448,6 +450,49 @@ pub async fn admin_register_recharge_refund(
))
}
pub async fn admin_resolve_recharge_refund_manual_review(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminRechargeRefundManualReviewResolveRequest>,
) -> Result<Json<Value>, Response> {
let input = build_runtime_profile_recharge_refund_manual_review_resolve_input(
payload.out_refund_no,
admin.session().subject.clone(),
payload.reason,
)
.map_err(|message| {
error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message),
)
})?;
let (refund, _, _, resolution_code) = state
.spacetime_client()
.resolve_profile_recharge_refund_manual_review(input)
.await
.map_err(|error| {
error_response(
&request_context,
AppError::from_status(StatusCode::CONFLICT)
.with_message("人工确认退款归属失败")
.with_details(json!({"message": error.to_string()})),
)
})?;
let entry = load_order(&state, &request_context, &refund.order_id).await?;
let user = load_user_summary(&state, &entry.order.user_id);
Ok(json_success_body(
Some(&request_context),
AdminRechargeRefundActionResponse {
out_refund_no: refund.out_refund_no,
provider_status: refund.provider_status.as_str().to_string(),
result_code: resolution_code,
provider_status_unknown: false,
order: map_order_entry(entry, user),
},
))
}
pub async fn admin_update_wallet_restriction(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -872,6 +917,9 @@ fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefu
unrecovered_points: refund.unrecovered_points,
recovery_status: refund.recovery_status.as_str().to_string(),
last_error_code: refund.last_error_code,
manual_review_resolved_by_admin_user_id: refund.manual_review_resolved_by_admin_user_id,
manual_review_resolution_reason: refund.manual_review_resolution_reason,
manual_review_resolved_at_micros: refund.manual_review_resolved_at_micros,
}
}
@@ -21,7 +21,7 @@ use crate::{
admin_recharge::{
admin_execute_recharge_refund, admin_get_user_detail, admin_list_recharge_orders,
admin_preview_recharge_refund, admin_register_recharge_refund,
admin_update_wallet_restriction,
admin_resolve_recharge_refund_manual_review, admin_update_wallet_restriction,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -275,6 +275,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/profile/recharge-refunds/manual-review/resolve",
post(admin_resolve_recharge_refund_manual_review).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/users/detail",
get(admin_get_user_detail).route_layer(middleware::from_fn_with_state(
@@ -54,7 +54,11 @@ pub(crate) struct PersistedWechatRefundObservation {
pub fn spawn_profile_recharge_refund_reconciliation_worker(state: AppState) {
if !state.wechat_pay_refund_reconciliation_enabled() {
debug!("wechat pay refund reconciliation worker is disabled");
if state.wechat_pay_client().supports_refund_reconciliation() {
warn!("real wechat pay is active but refund reconciliation worker is disabled");
} else {
debug!("wechat pay refund reconciliation worker is disabled");
}
return;
}
if !state.wechat_pay_client().supports_refund_reconciliation() {
@@ -62,6 +66,8 @@ pub fn spawn_profile_recharge_refund_reconciliation_worker(state: AppState) {
return;
}
info!("wechat pay refund reconciliation worker is enabled");
tokio::spawn(async move {
let mut trade_bill_scan_slot = 0;
let mut hold_not_found_counts = BTreeMap::<String, u8>::new();
@@ -940,6 +946,9 @@ mod tests {
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable,
last_recovery_ledger_id: None,
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at_micros: None,
}
}
@@ -611,6 +611,24 @@ pub fn build_runtime_profile_recharge_refund_hold_list_input(
}
}
pub fn build_runtime_profile_recharge_refund_manual_review_resolve_input(
out_refund_no: String,
admin_user_id: String,
reason: String,
) -> Result<RuntimeProfileRechargeRefundManualReviewResolveInput, String> {
Ok(RuntimeProfileRechargeRefundManualReviewResolveInput {
out_refund_no: normalize_profile_recharge_refund_identifier(
out_refund_no,
"refund_manual_review.out_refund_no",
)?,
admin_user_id: normalize_profile_recharge_refund_identifier(
admin_user_id,
"refund_manual_review.admin_user_id",
)?,
reason: normalize_profile_recharge_refund_reason(reason, "refund_manual_review.reason")?,
})
}
pub fn build_runtime_profile_admin_wallet_get_input(
user_id: String,
) -> Result<RuntimeProfileAdminWalletGetInput, String> {
@@ -1432,6 +1450,28 @@ fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFiel
mod tests {
use super::*;
#[test]
fn refund_manual_review_resolution_requires_auditable_fields() {
let input = build_runtime_profile_recharge_refund_manual_review_resolve_input(
" refund-1 ".to_string(),
" admin-1 ".to_string(),
" 已核对微信商户平台原始账单 ".to_string(),
)
.expect("manual review input should normalize");
assert_eq!(input.out_refund_no, "refund-1");
assert_eq!(input.admin_user_id, "admin-1");
assert_eq!(input.reason, "已核对微信商户平台原始账单");
assert!(
build_runtime_profile_recharge_refund_manual_review_resolve_input(
"refund-1".to_string(),
"admin-1".to_string(),
" ".to_string(),
)
.is_err()
);
}
#[test]
fn invite_code_metadata_tags_alias_is_saved_as_user_tags() {
let normalized = normalize_invite_code_metadata_json(
@@ -1435,6 +1435,9 @@ pub struct RuntimeProfileRechargeRefundSnapshot {
pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub last_recovery_ledger_id: Option<String>,
pub last_error_code: Option<String>,
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1722,6 +1725,14 @@ pub struct RuntimeProfileRechargeRefundHoldListInput {
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeRefundManualReviewResolveInput {
pub out_refund_no: String,
pub admin_user_id: String,
pub reason: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileAdminWalletGetInput {
+25 -1
View File
@@ -709,6 +709,9 @@ pub struct AdminRechargeRefundPayload {
pub unrecovered_points: u64,
pub recovery_status: String,
pub last_error_code: Option<String>,
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -804,6 +807,13 @@ pub struct AdminRechargeRefundRegisterRequest {
pub out_refund_no: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminRechargeRefundManualReviewResolveRequest {
pub out_refund_no: String,
pub reason: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminWalletRestrictionRequest {
@@ -854,7 +864,21 @@ pub struct AdminWalletRestrictionResponse {
mod tests {
use serde_json::json;
use super::AdminEditorShowcaseAssetPayload;
use super::{
AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest,
};
#[test]
fn refund_manual_review_resolution_request_uses_camel_case_contract() {
let value = serde_json::to_value(AdminRechargeRefundManualReviewResolveRequest {
out_refund_no: "refund-1".to_string(),
reason: "已核对微信商户平台原始账单".to_string(),
})
.expect("request should serialize");
assert_eq!(value["outRefundNo"], json!("refund-1"));
assert!(value.get("out_refund_no").is_none());
}
#[test]
fn editor_showcase_asset_payload_serializes_author_fields_as_camel_case() {
@@ -161,6 +161,18 @@ impl From<module_runtime::RuntimeProfileRechargeRefundHoldListInput>
}
}
impl From<module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput>
for RuntimeProfileRechargeRefundManualReviewResolveInput
{
fn from(input: module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput) -> Self {
Self {
out_refund_no: input.out_refund_no,
admin_user_id: input.admin_user_id,
reason: input.reason,
}
}
}
impl From<module_runtime::RuntimeProfileAdminWalletGetInput> for RuntimeProfileAdminWalletGetInput {
fn from(input: module_runtime::RuntimeProfileAdminWalletGetInput) -> Self {
Self {
@@ -1417,6 +1429,9 @@ pub(crate) fn map_runtime_profile_recharge_refund_snapshot(
),
last_recovery_ledger_id: snapshot.last_recovery_ledger_id,
last_error_code: snapshot.last_error_code,
manual_review_resolved_by_admin_user_id: snapshot.manual_review_resolved_by_admin_user_id,
manual_review_resolution_reason: snapshot.manual_review_resolution_reason,
manual_review_resolved_at_micros: snapshot.manual_review_resolved_at_micros,
}
}
@@ -1012,6 +1012,7 @@ pub mod resolve_npc_interaction_reducer;
pub mod resolve_npc_social_action_and_return_procedure;
pub mod resolve_npc_social_action_input_type;
pub mod resolve_npc_social_action_reducer;
pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure;
pub mod resolve_treasure_interaction_and_return_procedure;
pub mod resolve_treasure_interaction_reducer;
pub mod restart_jump_hop_run_procedure;
@@ -1112,6 +1113,7 @@ pub mod runtime_profile_recharge_refund_hold_release_input_type;
pub mod runtime_profile_recharge_refund_hold_snapshot_type;
pub mod runtime_profile_recharge_refund_hold_status_type;
pub mod runtime_profile_recharge_refund_list_procedure_result_type;
pub mod runtime_profile_recharge_refund_manual_review_resolve_input_type;
pub mod runtime_profile_recharge_refund_observation_input_type;
pub mod runtime_profile_recharge_refund_observation_source_type;
pub mod runtime_profile_recharge_refund_procedure_result_type;
@@ -2411,6 +2413,7 @@ pub use resolve_npc_interaction_reducer::resolve_npc_interaction;
pub use resolve_npc_social_action_and_return_procedure::resolve_npc_social_action_and_return;
pub use resolve_npc_social_action_input_type::ResolveNpcSocialActionInput;
pub use resolve_npc_social_action_reducer::resolve_npc_social_action;
pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return;
pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_interaction_and_return;
pub use resolve_treasure_interaction_reducer::resolve_treasure_interaction;
pub use restart_jump_hop_run_procedure::restart_jump_hop_run;
@@ -2511,6 +2514,7 @@ pub use runtime_profile_recharge_refund_hold_release_input_type::RuntimeProfileR
pub use runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot;
pub use runtime_profile_recharge_refund_hold_status_type::RuntimeProfileRechargeRefundHoldStatus;
pub use runtime_profile_recharge_refund_list_procedure_result_type::RuntimeProfileRechargeRefundListProcedureResult;
pub use runtime_profile_recharge_refund_manual_review_resolve_input_type::RuntimeProfileRechargeRefundManualReviewResolveInput;
pub use runtime_profile_recharge_refund_observation_input_type::RuntimeProfileRechargeRefundObservationInput;
pub use runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource;
pub use runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult;
@@ -33,6 +33,9 @@ pub struct ProfileRechargeRefund {
pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub last_recovery_ledger_id: Option<String>,
pub last_error_code: Option<String>,
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at: Option<__sdk::Timestamp>,
}
impl __sdk::InModule for ProfileRechargeRefund {
@@ -73,6 +76,12 @@ pub struct ProfileRechargeRefundCols {
>,
pub last_recovery_ledger_id: __sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
pub last_error_code: __sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
pub manual_review_resolved_by_admin_user_id:
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
pub manual_review_resolution_reason:
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
pub manual_review_resolved_at:
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<__sdk::Timestamp>>,
}
impl __sdk::__query_builder::HasCols for ProfileRechargeRefund {
@@ -116,6 +125,18 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeRefund {
"last_recovery_ledger_id",
),
last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"),
manual_review_resolved_by_admin_user_id: __sdk::__query_builder::Col::new(
table_name,
"manual_review_resolved_by_admin_user_id",
),
manual_review_resolution_reason: __sdk::__query_builder::Col::new(
table_name,
"manual_review_resolution_reason",
),
manual_review_resolved_at: __sdk::__query_builder::Col::new(
table_name,
"manual_review_resolved_at",
),
}
}
}
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_recharge_refund_manual_review_resolve_input_type::RuntimeProfileRechargeRefundManualReviewResolveInput;
use super::runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ResolveProfileRechargeRefundManualReviewAndReturnArgs {
pub input: RuntimeProfileRechargeRefundManualReviewResolveInput,
}
impl __sdk::InModule for ResolveProfileRechargeRefundManualReviewAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `resolve_profile_recharge_refund_manual_review_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait resolve_profile_recharge_refund_manual_review_and_return {
fn resolve_profile_recharge_refund_manual_review_and_return(
&self,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
) {
self.resolve_profile_recharge_refund_manual_review_and_return_then(input, |_, _| {});
}
fn resolve_profile_recharge_refund_manual_review_and_return_then(
&self,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeRefundProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl resolve_profile_recharge_refund_manual_review_and_return for super::RemoteProcedures {
fn resolve_profile_recharge_refund_manual_review_and_return_then(
&self,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeRefundProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>(
"resolve_profile_recharge_refund_manual_review_and_return",
ResolveProfileRechargeRefundManualReviewAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeRefundManualReviewResolveInput {
pub out_refund_no: String,
pub admin_user_id: String,
pub reason: String,
}
impl __sdk::InModule for RuntimeProfileRechargeRefundManualReviewResolveInput {
type Module = super::RemoteModule;
}
@@ -33,6 +33,9 @@ pub struct RuntimeProfileRechargeRefundSnapshot {
pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub last_recovery_ledger_id: Option<String>,
pub last_error_code: Option<String>,
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
}
impl __sdk::InModule for RuntimeProfileRechargeRefundSnapshot {
@@ -963,6 +963,38 @@ impl SpacetimeClient {
.await
}
pub async fn resolve_profile_recharge_refund_manual_review(
&self,
input: module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput,
) -> Result<
(
module_runtime::RuntimeProfileRechargeRefundSnapshot,
Option<module_runtime::RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
bool,
String,
),
SpacetimeClientError,
> {
let procedure_input: RuntimeProfileRechargeRefundManualReviewResolveInput = input.into();
self.call_after_connect(
"resolve_profile_recharge_refund_manual_review_and_return",
move |connection, sender| {
connection
.procedures()
.resolve_profile_recharge_refund_manual_review_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_recharge_refund_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn admin_get_profile_wallet(
&self,
input: module_runtime::RuntimeProfileAdminWalletGetInput,
@@ -1345,6 +1345,20 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "profile_recharge_refund" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:退款人工复核审计字段晚于退款事实表加入,旧迁移包按尚未处理兼容。
object
.entry("manual_review_resolved_by_admin_user_id".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("manual_review_resolution_reason".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("manual_review_resolved_at".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "external_generation_job_summary" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:非阻断完成告警晚于轻量任务摘要表加入,旧迁移包按无告警兼容。
@@ -1662,6 +1676,56 @@ mod migration_bootstrap_secret_tests {
assert_eq!(normalized["warning_message"], serde_json::Value::Null);
}
#[test]
fn old_profile_recharge_refund_rows_import_with_empty_manual_review_audit() {
let row = ProfileRechargeRefund {
out_refund_no: "refund-legacy".to_string(),
provider_refund_id: "wx-refund-legacy".to_string(),
order_id: "order-legacy".to_string(),
provider_transaction_id: "wx-transaction-legacy".to_string(),
user_id: Some("user-legacy".to_string()),
provider_status: RuntimeProfileRechargeRefundStatus::Success,
total_cents: 600,
refund_cents: 300,
payer_total_cents: 600,
payer_refund_cents: 300,
success_at: Some(Timestamp::from_micros_since_unix_epoch(200)),
first_observed_at: Timestamp::from_micros_since_unix_epoch(100),
updated_at: Timestamp::from_micros_since_unix_epoch(200),
last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query,
last_observation_id: "observation-legacy".to_string(),
order_settled_at: None,
target_recovery_points: 30,
recovered_points: 30,
unrecovered_points: 0,
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Applied,
last_recovery_ledger_id: Some("ledger-legacy".to_string()),
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at: None,
};
let mut legacy_value = row_to_json(&row).expect("退款旧行应可序列化");
let object = legacy_value
.as_object_mut()
.expect("退款迁移行应为 JSON object");
object.remove("manual_review_resolved_by_admin_user_id");
object.remove("manual_review_resolution_reason");
object.remove("manual_review_resolved_at");
let normalized = normalize_migration_row("profile_recharge_refund", &legacy_value);
let restored = row_from_json::<ProfileRechargeRefund>(
"profile_recharge_refund",
&normalized,
&mut Vec::new(),
)
.expect("缺少人工复核字段的旧退款行应可导入");
assert!(restored.manual_review_resolved_by_admin_user_id.is_none());
assert!(restored.manual_review_resolution_reason.is_none());
assert!(restored.manual_review_resolved_at.is_none());
}
#[test]
fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() {
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
File diff suppressed because it is too large Load Diff
@@ -506,6 +506,12 @@ pub struct ProfileRechargeRefund {
pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
pub(crate) last_recovery_ledger_id: Option<String>,
pub(crate) last_error_code: Option<String>,
#[default(None::<String>)]
pub(crate) manual_review_resolved_by_admin_user_id: Option<String>,
#[default(None::<String>)]
pub(crate) manual_review_resolution_reason: Option<String>,
#[default(None::<Timestamp>)]
pub(crate) manual_review_resolved_at: Option<Timestamp>,
}
#[spacetimedb::table(
@@ -1473,6 +1479,39 @@ pub fn release_profile_recharge_refund_hold_and_return(
}
}
#[spacetimedb::procedure]
pub fn resolve_profile_recharge_refund_manual_review_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
) -> RuntimeProfileRechargeRefundProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
resolve_profile_recharge_refund_manual_review(tx, input.clone())
}) {
Ok((record, settlement, duplicate, resolution_code)) => {
RuntimeProfileRechargeRefundProcedureResult {
ok: true,
record: Some(record),
settlement,
duplicate,
resolution_code,
error_message: None,
}
}
Err(message) => RuntimeProfileRechargeRefundProcedureResult {
ok: false,
record: None,
settlement: None,
duplicate: false,
resolution_code: "manual_review_resolution_failed".to_string(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_profile_recharge_refund_holds_for_reconciliation(
ctx: &mut ProcedureContext,
@@ -2685,6 +2724,9 @@ mod tests {
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending,
last_recovery_ledger_id: None,
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at: None,
}
}
@@ -2708,6 +2750,27 @@ mod tests {
assert!(validate_profile_recharge_order_paid_replay_transaction_id(&order, &None).is_err());
}
#[test]
fn confirmed_manual_review_suppresses_only_the_order_match_conflict() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let mut refund = recharge_refund_for_order(&order);
refund.provider_transaction_id = "wx-transaction-other".to_string();
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
Some("provider_transaction_id_mismatch".to_string())
);
refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300));
assert_eq!(
unresolved_profile_recharge_refund_order_match_error(&order, &refund),
None
);
assert_eq!(
validate_profile_recharge_refund_order_match(&order, &refund),
Err("provider_transaction_id_mismatch".to_string())
);
}
#[test]
fn refunded_order_keeps_first_recharge_qualification_consumed() {
let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded);
@@ -4510,6 +4573,94 @@ fn release_profile_recharge_refund_hold(
))
}
fn resolve_profile_recharge_refund_manual_review(
ctx: &ReducerContext,
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
) -> Result<
(
RuntimeProfileRechargeRefundSnapshot,
Option<RuntimeProfileRechargeOrderRefundSettlementSnapshot>,
bool,
String,
),
String,
> {
let validated = build_runtime_profile_recharge_refund_manual_review_resolve_input(
input.out_refund_no,
input.admin_user_id,
input.reason,
)?;
let mut refund = ctx
.db
.profile_recharge_refund()
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "退款记录不存在".to_string())?;
if refund.manual_review_resolved_at.is_some() {
let settlement = ctx
.db
.profile_recharge_order_refund_settlement()
.order_id()
.find(&refund.order_id)
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row));
return Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement,
true,
"manual_review_already_resolved".to_string(),
));
}
if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success
|| refund.recovery_status != RuntimeProfileRechargeRefundRecoveryStatus::ManualReview
{
return Err("只有已成功且待人工复核的退款可以确认归属".to_string());
}
let review_code = refund
.last_error_code
.as_deref()
.ok_or_else(|| "退款缺少人工复核原因".to_string())?;
if !matches!(
review_code,
"provider_transaction_id_mismatch" | "order_total_mismatch"
) {
return Err(format!(
"当前人工复核原因不能通过确认订单归属收口: {review_code}"
));
}
let order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&refund.order_id)
.ok_or_else(|| "退款对应充值订单不存在".to_string())?;
if order.kind != RuntimeProfileRechargeProductKind::Points {
return Err("会员退款不能使用泥点钱包人工复核入口".to_string());
}
refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id);
refund.manual_review_resolution_reason = Some(validated.reason);
refund.manual_review_resolved_at = Some(ctx.timestamp);
refund.updated_at = ctx.timestamp;
let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund);
if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview {
return Err(format!(
"确认订单归属后权益结算仍需人工复核: {}",
refund
.last_error_code
.as_deref()
.unwrap_or(resolution_code.as_str())
));
}
upsert_profile_recharge_refund_row(ctx, refund.clone());
Ok((
build_profile_recharge_refund_snapshot_from_row(&refund),
settlement
.map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)),
false,
format!("manual_review_resolved:{resolution_code}"),
))
}
fn build_profile_admin_wallet_snapshot(
ctx: &ReducerContext,
user_id: &str,
@@ -4887,6 +5038,9 @@ fn record_profile_recharge_refund_observation(
},
last_recovery_ledger_id: None,
last_error_code: None,
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at: None,
},
};
@@ -4932,7 +5086,7 @@ fn reconcile_profile_recharge_refund_success(
};
refund.user_id = Some(order.user_id.clone());
let mismatch_code = validate_profile_recharge_refund_order_match(&order, refund).err();
let mismatch_code = unresolved_profile_recharge_refund_order_match_error(&order, refund);
if let Some(code) = mismatch_code {
refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview;
refund.last_error_code = Some(code.clone());
@@ -5087,6 +5241,15 @@ fn validate_profile_recharge_refund_order_match(
Ok(())
}
fn unresolved_profile_recharge_refund_order_match_error(
order: &ProfileRechargeOrder,
refund: &ProfileRechargeRefund,
) -> Option<String> {
validate_profile_recharge_refund_order_match(order, refund)
.err()
.filter(|_| refund.manual_review_resolved_at.is_none())
}
fn mark_profile_recharge_refund_manual_review(
ctx: &ReducerContext,
refund: &mut ProfileRechargeRefund,
@@ -9653,6 +9816,13 @@ fn build_profile_recharge_refund_snapshot_from_row(
recovery_status: row.recovery_status,
last_recovery_ledger_id: row.last_recovery_ledger_id.clone(),
last_error_code: row.last_error_code.clone(),
manual_review_resolved_by_admin_user_id: row
.manual_review_resolved_by_admin_user_id
.clone(),
manual_review_resolution_reason: row.manual_review_resolution_reason.clone(),
manual_review_resolved_at_micros: row
.manual_review_resolved_at
.map(|value| value.to_micros_since_unix_epoch()),
}
}