4177 lines
158 KiB
Rust
4177 lines
158 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, Query, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{
|
|
IntoResponse, Response,
|
|
sse::{Event, Sse},
|
|
},
|
|
};
|
|
use hmac::{Hmac, Mac};
|
|
use module_runtime::{
|
|
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord,
|
|
RuntimeProfileDailyFreePointsRecord, RuntimeProfileFeedbackEvidenceRecord,
|
|
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
|
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
|
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
|
|
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
|
|
RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind,
|
|
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
|
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
|
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
|
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
|
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
|
RuntimeTrackingScopeKind,
|
|
};
|
|
use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use sha2::Sha256;
|
|
use shared_contracts::runtime::{
|
|
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
|
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
|
|
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
|
AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest,
|
|
AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest,
|
|
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
|
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
|
|
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN,
|
|
PROFILE_MEMBERSHIP_TIER_BASIC, PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL,
|
|
PROFILE_MEMBERSHIP_TIER_PRO, PROFILE_MEMBERSHIP_TIER_SEASON, PROFILE_MEMBERSHIP_TIER_STARTER,
|
|
PROFILE_MEMBERSHIP_TIER_ULTIMATE, PROFILE_MEMBERSHIP_TIER_YEAR,
|
|
PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, PROFILE_RECHARGE_PRODUCT_KIND_POINTS,
|
|
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
|
|
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileCodeOperationAdminResponse,
|
|
ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse,
|
|
ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse,
|
|
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
|
|
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse,
|
|
ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
|
ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse,
|
|
ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
|
|
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
|
|
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
|
|
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
|
|
ProfileWalletConfigAdminResponse, ProfileWalletLedgerEntryResponse,
|
|
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
|
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
|
RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse,
|
|
TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER,
|
|
TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
|
|
WechatMiniProgramVirtualPayParamsResponse, WechatProfileRechargeOrderDoneEvent,
|
|
WechatProfileRechargeOrderErrorEvent,
|
|
};
|
|
#[cfg(any())]
|
|
use shared_contracts::runtime::{ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse};
|
|
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
|
use spacetime_client::SpacetimeClientError;
|
|
use std::{convert::Infallible, time::Duration};
|
|
use time::OffsetDateTime;
|
|
|
|
use crate::{
|
|
admin::{AdminDisplayNameDirectory, AuthenticatedAdmin, load_admin_display_name_directory},
|
|
api_response::json_success_body,
|
|
auth::AuthenticatedAccessToken,
|
|
http_error::AppError,
|
|
platform_errors::map_wechat_error,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
wechat::pay::{
|
|
build_wechat_payment_request, build_wechat_virtual_payment_notify_provide_goods_request,
|
|
build_wechat_virtual_payment_query_order_request, build_wechat_web_payment_request,
|
|
current_unix_micros, is_wechat_virtual_payment_order_paid, map_wechat_pay_error,
|
|
paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_virtual_payment_order,
|
|
},
|
|
};
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
pub async fn get_profile_dashboard(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.get_profile_dashboard(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileDashboardSummaryResponse {
|
|
wallet_balance: record.wallet_balance,
|
|
total_play_time_ms: record.total_play_time_ms,
|
|
played_world_count: record.played_world_count,
|
|
updated_at: record.updated_at,
|
|
daily_free_points: build_profile_daily_free_points_response(record.daily_free_points),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_profile_wallet_ledger(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let entries = state
|
|
.spacetime_client()
|
|
.list_profile_wallet_ledger(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileWalletLedgerResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(build_profile_wallet_ledger_entry_response)
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
fn format_profile_wallet_ledger_source_type(
|
|
source_type: RuntimeProfileWalletLedgerSourceType,
|
|
) -> &'static str {
|
|
match source_type {
|
|
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeGrant => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeReset => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET
|
|
}
|
|
RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => {
|
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_profile_recharge_center(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.get_profile_recharge_center(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_recharge_center_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn create_profile_recharge_order(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<CreateProfileRechargeOrderRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
validate_recharge_payment_channel(&state, &payment_channel)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let created_at_micros = current_unix_micros();
|
|
let (center, order) = state
|
|
.spacetime_client()
|
|
.create_profile_recharge_order(
|
|
user_id,
|
|
payload.product_id,
|
|
payment_channel.clone(),
|
|
created_at_micros,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
let wechat_mini_program_pay_params = if payment_channel
|
|
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
|
|| payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
|
|
{
|
|
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
|
.await
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
|
|
state
|
|
.wechat_pay_client()
|
|
.create_mini_program_order(build_wechat_payment_request(
|
|
order.order_id.clone(),
|
|
order.product_title.clone(),
|
|
order.amount_cents,
|
|
identity,
|
|
))
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
|
})?,
|
|
))
|
|
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
|
|
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
|
.await
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
Some(
|
|
build_wechat_virtual_pay_params(&state, ¢er, &order, &openid)
|
|
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 {
|
|
Some(
|
|
state
|
|
.wechat_pay_client()
|
|
.create_h5_order(build_wechat_web_payment_request(
|
|
order.order_id.clone(),
|
|
order.product_title.clone(),
|
|
order.amount_cents,
|
|
resolve_wechat_pay_client_ip(&headers),
|
|
))
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
|
})?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
|
{
|
|
Some(
|
|
state
|
|
.wechat_pay_client()
|
|
.create_native_order(build_wechat_web_payment_request(
|
|
order.order_id.clone(),
|
|
order.product_title.clone(),
|
|
order.amount_cents,
|
|
resolve_wechat_pay_client_ip(&headers),
|
|
))
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
|
})?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CreateProfileRechargeOrderResponse {
|
|
order: build_profile_recharge_order_response(order),
|
|
center: build_profile_recharge_center_response(center),
|
|
wechat_mini_program_pay_params,
|
|
wechat_h5_payment,
|
|
wechat_native_payment,
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn confirm_wechat_profile_recharge_order(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(order_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let (center, order) = state
|
|
.spacetime_client()
|
|
.get_profile_recharge_order(order_id.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
if order.user_id != user_id {
|
|
return Err(runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
|
|
));
|
|
}
|
|
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
|
|
return Err(runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("该充值订单不是微信支付订单"),
|
|
));
|
|
}
|
|
let is_virtual_payment =
|
|
order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL;
|
|
if order.status == RuntimeProfileRechargeOrderStatus::Paid && !is_virtual_payment {
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
if !matches!(
|
|
order.status,
|
|
RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired
|
|
) && !(is_virtual_payment && order.status == RuntimeProfileRechargeOrderStatus::Paid)
|
|
{
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
if is_virtual_payment {
|
|
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
|
.await
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let query_request = build_wechat_virtual_payment_query_order_request(
|
|
&state.config,
|
|
openid,
|
|
order.order_id.clone(),
|
|
)
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
let wechat_order = state
|
|
.wechat_client()
|
|
.query_virtual_payment_order(query_request)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order)
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
if !is_wechat_virtual_payment_order_paid(wechat_order.status) {
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
|
|
let wechat_status = wechat_order.status;
|
|
let (center, order) = if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
|
(center, order)
|
|
} else {
|
|
let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order)
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
let paid = state
|
|
.spacetime_client()
|
|
.mark_profile_recharge_order_paid(
|
|
wechat_order.order_id,
|
|
paid_at_micros,
|
|
wechat_order.wxpay_order_id.or(wechat_order.wx_order_id),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
state.publish_profile_recharge_order_update(paid.1.order_id.clone());
|
|
paid
|
|
};
|
|
if should_notify_virtual_payment_goods_delivery(order.kind, wechat_status) {
|
|
let notify_request = build_wechat_virtual_payment_notify_provide_goods_request(
|
|
&state.config,
|
|
order.order_id.clone(),
|
|
)
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
state
|
|
.wechat_client()
|
|
.notify_virtual_payment_goods_delivered(notify_request)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(&request_context, map_wechat_error(error))
|
|
})?;
|
|
}
|
|
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
|
|
let order_was_expired = order.status == RuntimeProfileRechargeOrderStatus::Expired;
|
|
let wechat_order = match state
|
|
.wechat_pay_client()
|
|
.query_order_by_out_trade_no(&order.order_id)
|
|
.await
|
|
{
|
|
Ok(wechat_order) => wechat_order,
|
|
Err(WechatPayError::OrderNotExist(_)) if order_was_expired => {
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
Err(error) => {
|
|
return Err(runtime_profile_error_response(
|
|
&request_context,
|
|
map_wechat_pay_error(error),
|
|
));
|
|
}
|
|
};
|
|
if wechat_order.out_trade_no != order.order_id {
|
|
return Err(runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY)
|
|
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
|
|
.with_details(json!({ "provider": "wechat_pay" })),
|
|
));
|
|
}
|
|
if wechat_order.trade_state != "SUCCESS" {
|
|
return Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
));
|
|
}
|
|
|
|
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
|
|
let (center, order) = state
|
|
.spacetime_client()
|
|
.mark_profile_recharge_order_paid(
|
|
wechat_order.out_trade_no,
|
|
paid_at_micros,
|
|
wechat_order.transaction_id,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
state.publish_profile_recharge_order_update(order.order_id.clone());
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_wechat_profile_recharge_order_confirmation(center, order),
|
|
))
|
|
}
|
|
|
|
fn should_notify_virtual_payment_goods_delivery(
|
|
kind: RuntimeProfileRechargeProductKind,
|
|
wechat_status: i64,
|
|
) -> bool {
|
|
kind == RuntimeProfileRechargeProductKind::Membership && wechat_status == 2
|
|
}
|
|
|
|
pub async fn stream_wechat_profile_recharge_order_events(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(order_id): Path<String>,
|
|
) -> Result<Response, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let (center, order) = load_user_wechat_profile_recharge_order(
|
|
&state,
|
|
&request_context,
|
|
user_id.clone(),
|
|
order_id.clone(),
|
|
)
|
|
.await?;
|
|
let stream_state = state.clone();
|
|
let stream_context = request_context.clone();
|
|
let stream = async_stream::stream! {
|
|
let initial_response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
|
|
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
|
"order",
|
|
&initial_response,
|
|
));
|
|
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
|
|
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
|
"done",
|
|
&WechatProfileRechargeOrderDoneEvent {
|
|
order_id: order.order_id.clone(),
|
|
status: build_profile_recharge_order_status(order.status),
|
|
},
|
|
));
|
|
return;
|
|
}
|
|
|
|
let mut updates = stream_state.subscribe_profile_recharge_order_updates();
|
|
let mut poll_interval = tokio::time::interval(Duration::from_millis(1200));
|
|
for _ in 0..25 {
|
|
tokio::select! {
|
|
maybe_order_id = updates.recv() => {
|
|
if !matches!(maybe_order_id, Ok(ref value) if value == &order_id) {
|
|
continue;
|
|
}
|
|
}
|
|
_ = poll_interval.tick() => {}
|
|
}
|
|
|
|
match load_user_wechat_profile_recharge_order(
|
|
&stream_state,
|
|
&stream_context,
|
|
user_id.clone(),
|
|
order_id.clone(),
|
|
).await {
|
|
Ok((center, order)) => {
|
|
let response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
|
|
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
|
"order",
|
|
&response,
|
|
));
|
|
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
|
|
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
|
"done",
|
|
&WechatProfileRechargeOrderDoneEvent {
|
|
order_id: order.order_id.clone(),
|
|
status: build_profile_recharge_order_status(order.status),
|
|
},
|
|
));
|
|
return;
|
|
}
|
|
}
|
|
Err(_) => {
|
|
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
|
"error",
|
|
&WechatProfileRechargeOrderErrorEvent {
|
|
message: "读取充值订单状态失败".to_string(),
|
|
},
|
|
));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Sse::new(stream).into_response())
|
|
}
|
|
|
|
pub async fn submit_profile_feedback(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<SubmitProfileFeedbackRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let evidence_items = payload
|
|
.evidence_items
|
|
.into_iter()
|
|
.map(|item| RuntimeProfileFeedbackEvidenceSnapshot {
|
|
evidence_id: String::new(),
|
|
file_name: item.file_name,
|
|
content_type: item.content_type,
|
|
size_bytes: item.size_bytes,
|
|
data_url: item.data_url,
|
|
})
|
|
.collect();
|
|
|
|
let record = state
|
|
.spacetime_client()
|
|
.submit_profile_feedback(
|
|
user_id,
|
|
payload.description,
|
|
payload.contact_phone,
|
|
evidence_items,
|
|
created_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
SubmitProfileFeedbackResponse {
|
|
feedback: build_profile_feedback_submission_response(record),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_profile_referral_invite_center(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.get_profile_referral_invite_center(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_referral_invite_center_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn redeem_profile_referral_invite_code(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_redeem_profile_referral_invite_code_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn redeem_profile_reward_code(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<RedeemProfileRewardCodeRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.redeem_profile_reward_code(user_id.clone(), payload.code, redeemed_at_micros as i64)
|
|
.await
|
|
.map_err(|error| {
|
|
tracing::warn!(
|
|
request_id = request_context.request_id(),
|
|
user_id = %user_id,
|
|
error = %error,
|
|
"个人中心兑换码兑换失败"
|
|
);
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_redeem_profile_reward_code_response(record),
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AnalyticsMetricQueryParams {
|
|
pub event_key: String,
|
|
pub scope_kind: String,
|
|
pub scope_id: String,
|
|
pub granularity: String,
|
|
}
|
|
|
|
pub async fn get_profile_analytics_metric(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
Query(query): Query<AnalyticsMetricQueryParams>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
|
|
let record = state
|
|
.spacetime_client()
|
|
.query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_analytics_metric_query_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn get_profile_task_center(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.get_profile_task_center(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_task_center_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn claim_profile_task_reward(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(task_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.claim_profile_task_reward(user_id, task_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_claim_profile_task_reward_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_list_profile_task_configs(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let entries = state
|
|
.spacetime_client()
|
|
.admin_list_profile_task_configs(admin.session().subject.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileTaskConfigAdminListResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(|entry| build_profile_task_config_admin_response(entry, &admin_display_names))
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn admin_upsert_profile_task_config(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpsertProfileTaskConfigRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
// 中文注释:个人任务配置首版只开放 User scope,HTTP 层先返回清晰错误,领域层再兜底。
|
|
if scope_kind != RuntimeTrackingScopeKind::User {
|
|
return Err(runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("个人任务 scopeKind 首版仅支持 user"),
|
|
));
|
|
}
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_upsert_profile_task_config(
|
|
admin.session().subject.clone(),
|
|
payload.task_id,
|
|
payload.title,
|
|
payload.description.unwrap_or_default(),
|
|
payload.event_key,
|
|
cycle,
|
|
scope_kind,
|
|
payload.threshold,
|
|
payload.reward_points,
|
|
payload.enabled,
|
|
payload.sort_order.unwrap_or(10),
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_task_config_admin_response(record, &admin_display_names),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_disable_profile_task_config(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_disable_profile_task_config(
|
|
admin.session().subject.clone(),
|
|
payload.task_id,
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_task_config_admin_response(record, &admin_display_names),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_get_profile_wallet_config(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_get_profile_wallet_config(admin.session().subject.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_wallet_config_admin_response(record, &admin_display_names),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_upsert_profile_wallet_config(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpsertProfileWalletConfigRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_upsert_profile_wallet_config(
|
|
admin.session().subject.clone(),
|
|
payload.initial_mud_points,
|
|
updated_at_micros as i64,
|
|
payload.daily_free_points_per_day,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_wallet_config_admin_response(record, &admin_display_names),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_list_profile_recharge_products(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let entries = state
|
|
.spacetime_client()
|
|
.admin_list_profile_recharge_products(admin.session().subject.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileRechargeProductConfigAdminListResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(build_profile_recharge_product_config_admin_response)
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn admin_upsert_profile_recharge_product(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpsertProfileRechargeProductRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let kind = parse_profile_recharge_product_kind(&payload.kind).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_upsert_profile_recharge_product(
|
|
admin.session().subject.clone(),
|
|
payload.product_id,
|
|
payload.title,
|
|
payload.price_cents,
|
|
kind,
|
|
payload.points_amount,
|
|
payload.bonus_points,
|
|
payload.duration_days,
|
|
payload.badge_label.unwrap_or_default(),
|
|
payload.description.unwrap_or_default(),
|
|
tier,
|
|
payload.membership_period_points,
|
|
payload.membership_period_days,
|
|
payload.membership_queue_limit,
|
|
payload.membership_discount_bps,
|
|
payload.enabled,
|
|
payload.sort_order.unwrap_or(10),
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_recharge_product_config_admin_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_list_profile_redeem_codes(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_list_profile_redeem_codes(admin.session().subject.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileRedeemCodeAdminListResponse {
|
|
entries: record
|
|
.entries
|
|
.into_iter()
|
|
.map(build_profile_redeem_code_admin_response)
|
|
.collect(),
|
|
operations: record
|
|
.operations
|
|
.into_iter()
|
|
.map(|operation| {
|
|
build_profile_code_operation_admin_response(operation, &admin_display_names)
|
|
})
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn admin_upsert_profile_redeem_code(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
|
)
|
|
})?;
|
|
let starts_at_micros =
|
|
parse_admin_profile_code_time_field("兑换码", "startsAt", payload.starts_at)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let expires_at_micros =
|
|
parse_admin_profile_code_time_field("兑换码", "expiresAt", payload.expires_at)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_upsert_profile_redeem_code(
|
|
admin.session().subject.clone(),
|
|
payload.code,
|
|
mode,
|
|
payload.reward_points,
|
|
payload.max_uses,
|
|
payload.enabled,
|
|
payload.allowed_user_ids,
|
|
payload.allowed_public_user_codes,
|
|
starts_at_micros,
|
|
expires_at_micros,
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_redeem_code_admin_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_disable_profile_redeem_code(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_disable_profile_redeem_code(
|
|
admin.session().subject.clone(),
|
|
payload.code,
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_redeem_code_admin_response(record),
|
|
))
|
|
}
|
|
|
|
pub async fn admin_list_profile_invite_codes(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_list_profile_invite_codes(admin.session().subject.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
let admin_display_names = load_admin_display_name_directory(&state)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfileInviteCodeAdminListResponse {
|
|
entries: record
|
|
.entries
|
|
.into_iter()
|
|
.map(build_profile_invite_code_admin_response)
|
|
.collect(),
|
|
operations: record
|
|
.operations
|
|
.into_iter()
|
|
.map(|operation| {
|
|
build_profile_code_operation_admin_response(operation, &admin_display_names)
|
|
})
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn admin_upsert_profile_invite_code(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpsertProfileInviteCodeRequest>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let starts_at_micros =
|
|
parse_admin_profile_code_time_field("邀请码", "startsAt", payload.starts_at)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let expires_at_micros =
|
|
parse_admin_profile_code_time_field("邀请码", "expiresAt", payload.expires_at)
|
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
|
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let record = state
|
|
.spacetime_client()
|
|
.admin_upsert_profile_invite_code(
|
|
admin.session().username.clone(),
|
|
payload.invite_code,
|
|
metadata_json,
|
|
starts_at_micros,
|
|
expires_at_micros,
|
|
updated_at_micros as i64,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
build_profile_invite_code_admin_response(record),
|
|
))
|
|
}
|
|
|
|
#[cfg(any())]
|
|
pub async fn get_profile_play_stats(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let user_id = authenticated.claims().user_id().to_string();
|
|
let record = state
|
|
.spacetime_client()
|
|
.get_profile_play_stats(user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(
|
|
&request_context,
|
|
map_runtime_profile_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ProfilePlayStatsResponse {
|
|
total_play_time_ms: record.total_play_time_ms,
|
|
played_works: record
|
|
.played_works
|
|
.into_iter()
|
|
.map(|entry| ProfilePlayedWorkSummaryResponse {
|
|
world_key: entry.world_key,
|
|
owner_user_id: entry.owner_user_id,
|
|
profile_id: entry.profile_id,
|
|
world_type: entry.world_type,
|
|
world_title: entry.world_title,
|
|
world_subtitle: entry.world_subtitle,
|
|
first_played_at: entry.first_played_at,
|
|
last_played_at: entry.last_played_at,
|
|
last_observed_play_time_ms: entry.last_observed_play_time_ms,
|
|
})
|
|
.collect(),
|
|
updated_at: record.updated_at,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError {
|
|
let is_redeem_code_domain_error = matches!(
|
|
&error,
|
|
SpacetimeClientError::Procedure(message)
|
|
if is_runtime_profile_redeem_code_domain_error(message)
|
|
);
|
|
let (status, provider) = match &error {
|
|
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"),
|
|
SpacetimeClientError::Procedure(_) if is_redeem_code_domain_error => {
|
|
(StatusCode::BAD_REQUEST, "runtime-profile")
|
|
}
|
|
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
|
};
|
|
|
|
let app_error = AppError::from_status(status).with_details(json!({
|
|
"provider": provider,
|
|
"message": error.to_string(),
|
|
}));
|
|
if is_redeem_code_domain_error {
|
|
return app_error.with_message(error.to_string());
|
|
}
|
|
app_error
|
|
}
|
|
|
|
fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool {
|
|
matches!(
|
|
message,
|
|
"兑换码不存在"
|
|
| "兑换码已停用"
|
|
| "兑换码未生效"
|
|
| "兑换码已过期"
|
|
| "兑换次数已用完"
|
|
| "该兑换码不适用于当前账号"
|
|
| "兑换码奖励无效"
|
|
)
|
|
}
|
|
|
|
fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
|
error.into_response_with_context(Some(request_context))
|
|
}
|
|
|
|
async fn load_user_wechat_profile_recharge_order(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
user_id: String,
|
|
order_id: String,
|
|
) -> Result<
|
|
(
|
|
RuntimeProfileRechargeCenterRecord,
|
|
RuntimeProfileRechargeOrderRecord,
|
|
),
|
|
Response,
|
|
> {
|
|
let (center, order) = state
|
|
.spacetime_client()
|
|
.get_profile_recharge_order(order_id)
|
|
.await
|
|
.map_err(|error| {
|
|
runtime_profile_error_response(request_context, map_runtime_profile_client_error(error))
|
|
})?;
|
|
|
|
if order.user_id != user_id {
|
|
return Err(runtime_profile_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
|
|
));
|
|
}
|
|
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
|
|
return Err(runtime_profile_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("该充值订单不是微信支付订单"),
|
|
));
|
|
}
|
|
|
|
Ok((center, order))
|
|
}
|
|
|
|
fn build_wechat_profile_recharge_order_confirmation(
|
|
center: RuntimeProfileRechargeCenterRecord,
|
|
order: RuntimeProfileRechargeOrderRecord,
|
|
) -> ConfirmWechatProfileRechargeOrderResponse {
|
|
ConfirmWechatProfileRechargeOrderResponse {
|
|
order: build_profile_recharge_order_response(order),
|
|
center: build_profile_recharge_center_response(center),
|
|
}
|
|
}
|
|
|
|
fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus) -> String {
|
|
match status {
|
|
RuntimeProfileRechargeOrderStatus::Pending => "pending",
|
|
RuntimeProfileRechargeOrderStatus::Paid => "paid",
|
|
RuntimeProfileRechargeOrderStatus::Failed => "failed",
|
|
RuntimeProfileRechargeOrderStatus::Closed => "closed",
|
|
RuntimeProfileRechargeOrderStatus::Refunded => "refunded",
|
|
RuntimeProfileRechargeOrderStatus::Expired => "expired",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
fn is_wechat_profile_recharge_order_terminal_for_confirmation(
|
|
order: &RuntimeProfileRechargeOrderRecord,
|
|
) -> bool {
|
|
if order.status == RuntimeProfileRechargeOrderStatus::Pending {
|
|
return false;
|
|
}
|
|
|
|
if order.status == RuntimeProfileRechargeOrderStatus::Expired
|
|
&& order.expiration_checked_at_micros.is_none()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
|
|
where
|
|
T: Serialize,
|
|
{
|
|
Event::default()
|
|
.event(event_name)
|
|
.json_data(payload)
|
|
.unwrap_or_else(|_| {
|
|
Event::default()
|
|
.event("error")
|
|
.json_data(&WechatProfileRechargeOrderErrorEvent {
|
|
message: "充值订单状态事件序列化失败".to_string(),
|
|
})
|
|
.unwrap_or_else(|_| Event::default().event("error").data("{}"))
|
|
})
|
|
}
|
|
|
|
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
|
|
raw.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空")
|
|
})
|
|
}
|
|
|
|
fn validate_recharge_payment_channel(
|
|
state: &AppState,
|
|
payment_channel: &str,
|
|
) -> Result<(), AppError> {
|
|
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
|
|
if is_recharge_mock_channel_allowed(state) {
|
|
return Ok(());
|
|
}
|
|
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("生产充值不允许使用 mock 支付渠道"));
|
|
}
|
|
|
|
if is_wechat_recharge_payment_channel(payment_channel) {
|
|
validate_real_wechat_recharge_payment_provider(state)?;
|
|
return Ok(());
|
|
}
|
|
|
|
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效"))
|
|
}
|
|
|
|
fn validate_recharge_device_for_payment_channel(
|
|
claims: &platform_auth::AccessTokenClaims,
|
|
payment_channel: &str,
|
|
) -> Result<(), AppError> {
|
|
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
|
|
return Ok(());
|
|
}
|
|
|
|
if !is_wechat_recharge_payment_channel(payment_channel) {
|
|
return Ok(());
|
|
}
|
|
|
|
let is_supported_device = match payment_channel {
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
|
|
claims.is_wechat_mini_program_device()
|
|
}
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
|
|
claims.is_wechat_mini_program_device()
|
|
}
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI => is_web_recharge_device(claims),
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => is_web_recharge_device(claims),
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => is_web_recharge_device(claims),
|
|
_ => false,
|
|
};
|
|
if is_supported_device {
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::warn!(
|
|
payment_channel = payment_channel,
|
|
client_type = claims.client_type().unwrap_or("<missing>"),
|
|
client_platform = claims.client_platform().unwrap_or("<missing>"),
|
|
is_mobile_device = claims.is_mobile_device(),
|
|
is_desktop_device = claims.is_desktop_device(),
|
|
is_wechat_mini_program_device = claims.is_wechat_mini_program_device(),
|
|
"profile recharge payment channel rejected for current login device"
|
|
);
|
|
|
|
Err(AppError::from_status(StatusCode::FORBIDDEN)
|
|
.with_message("当前登录设备不支持充值,请在微信环境内登录后重试"))
|
|
}
|
|
|
|
fn is_web_recharge_device(claims: &platform_auth::AccessTokenClaims) -> bool {
|
|
is_web_recharge_client_type(claims) || is_legacy_web_recharge_device(claims)
|
|
}
|
|
|
|
fn is_web_recharge_client_type(claims: &platform_auth::AccessTokenClaims) -> bool {
|
|
matches!(claims.client_type(), Some("wechat_h5" | "web_browser"))
|
|
}
|
|
|
|
fn is_legacy_web_recharge_device(claims: &platform_auth::AccessTokenClaims) -> bool {
|
|
let legacy_or_unknown_platform =
|
|
matches!(claims.client_platform(), None | Some("unknown" | "test"));
|
|
legacy_or_unknown_platform && matches!(claims.client_type(), None | Some("web_browser"))
|
|
}
|
|
|
|
fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> {
|
|
if !state.config.wechat_pay_enabled {
|
|
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
|
.with_message("微信支付真实渠道暂未启用"));
|
|
}
|
|
|
|
if state
|
|
.config
|
|
.wechat_pay_provider
|
|
.trim()
|
|
.eq_ignore_ascii_case("real")
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
|
.with_message("真实微信支付渠道不能使用 mock 支付配置"))
|
|
}
|
|
|
|
fn is_recharge_mock_channel_allowed(state: &AppState) -> bool {
|
|
if cfg!(test) {
|
|
return state
|
|
.config
|
|
.wechat_pay_provider
|
|
.trim()
|
|
.eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK);
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
|
|
matches!(
|
|
payment_channel,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
|
)
|
|
}
|
|
|
|
fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String {
|
|
headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(',').next())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| {
|
|
headers
|
|
.get("x-real-ip")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
})
|
|
.unwrap_or("127.0.0.1")
|
|
.to_string()
|
|
}
|
|
|
|
async fn resolve_wechat_identity_for_payment(
|
|
state: &AppState,
|
|
user_id: &str,
|
|
) -> Result<String, AppError> {
|
|
if let Some(identity) = state
|
|
.wechat_auth_service()
|
|
.get_identity_by_user_id(user_id)
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_message(format!("读取微信身份失败:{error}"))
|
|
})?
|
|
{
|
|
return Ok(identity.provider_uid);
|
|
}
|
|
|
|
Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
|
}
|
|
|
|
fn build_wechat_virtual_pay_params(
|
|
state: &AppState,
|
|
center: &RuntimeProfileRechargeCenterRecord,
|
|
order: &RuntimeProfileRechargeOrderRecord,
|
|
openid: &str,
|
|
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
|
|
let product = match order.kind {
|
|
RuntimeProfileRechargeProductKind::Points => center
|
|
.point_products
|
|
.iter()
|
|
.find(|item| item.product_id == order.product_id)
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("当前充值商品不存在,请刷新后再试")
|
|
})?,
|
|
RuntimeProfileRechargeProductKind::Membership => center
|
|
.membership_products
|
|
.iter()
|
|
.find(|item| item.product_id == order.product_id)
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("当前充值商品不存在,请刷新后再试")
|
|
})?,
|
|
};
|
|
let identity = state
|
|
.wechat_auth_service()
|
|
.get_identity_by_user_id(&order.user_id)
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_message(format!("读取微信身份失败:{error}"))
|
|
})?
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
|
|
})?;
|
|
let session_key = identity.session_key.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("当前微信登录态缺少 session_key,请重新登录后再试")
|
|
})?;
|
|
let offer_id = required_wechat_virtual_payment_config(
|
|
state
|
|
.config
|
|
.wechat_mini_program_virtual_payment_offer_id
|
|
.as_deref(),
|
|
"微信虚拟支付 OfferId 未配置",
|
|
)?;
|
|
let mode = match order.kind {
|
|
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
|
|
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
|
|
};
|
|
let buy_quantity = match product.kind {
|
|
RuntimeProfileRechargeProductKind::Points => product.points_amount,
|
|
RuntimeProfileRechargeProductKind::Membership => 1,
|
|
};
|
|
let mut sign_data = serde_json::json!({
|
|
"offerId": offer_id,
|
|
"buyQuantity": buy_quantity,
|
|
"env": state.config.wechat_mini_program_virtual_payment_env,
|
|
"currencyType": "CNY",
|
|
"outTradeNo": order.order_id,
|
|
"attach": serde_json::json!({
|
|
"userId": order.user_id,
|
|
"productId": order.product_id,
|
|
"paymentChannel": order.payment_channel,
|
|
"openId": openid,
|
|
}).to_string(),
|
|
});
|
|
if order.kind == RuntimeProfileRechargeProductKind::Membership {
|
|
sign_data["productId"] = json!(order.product_id);
|
|
sign_data["goodsPrice"] = json!(order.amount_cents);
|
|
}
|
|
let sign_data = sign_data.to_string();
|
|
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
|
|
let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
|
|
|
|
Ok(WechatMiniProgramVirtualPayParamsResponse {
|
|
mode: mode.to_string(),
|
|
sign_data,
|
|
pay_sig,
|
|
signature,
|
|
})
|
|
}
|
|
|
|
fn calc_wechat_virtual_payment_signature(
|
|
state: &AppState,
|
|
sign_data: &str,
|
|
use_sandbox_key: bool,
|
|
) -> Result<String, AppError> {
|
|
let env = state.config.wechat_mini_program_virtual_payment_env;
|
|
let app_key = if use_sandbox_key || env == 1 {
|
|
required_wechat_virtual_payment_config(
|
|
state
|
|
.config
|
|
.wechat_mini_program_virtual_payment_sandbox_app_key
|
|
.as_deref(),
|
|
"微信虚拟支付沙箱 AppKey 未配置",
|
|
)?
|
|
} else {
|
|
required_wechat_virtual_payment_config(
|
|
state
|
|
.config
|
|
.wechat_mini_program_virtual_payment_app_key
|
|
.as_deref(),
|
|
"微信虚拟支付 AppKey 未配置",
|
|
)?
|
|
};
|
|
calc_wechat_virtual_payment_pay_signature_with_key(app_key, sign_data)
|
|
}
|
|
|
|
fn required_wechat_virtual_payment_config<'a>(
|
|
value: Option<&'a str>,
|
|
message: &str,
|
|
) -> Result<&'a str, AppError> {
|
|
value
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
|
|
}
|
|
|
|
fn calc_wechat_virtual_payment_pay_signature_with_key(
|
|
key: &str,
|
|
sign_data: &str,
|
|
) -> Result<String, AppError> {
|
|
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_message("微信虚拟支付签名密钥初始化失败")
|
|
})?;
|
|
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
|
|
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
|
|
}
|
|
|
|
fn calc_wechat_virtual_payment_user_signature_with_key(
|
|
session_key: &str,
|
|
sign_data: &str,
|
|
) -> Result<String, AppError> {
|
|
let mut mac = HmacSha256::new_from_slice(session_key.as_bytes()).map_err(|_| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_message("微信虚拟支付用户态签名密钥初始化失败")
|
|
})?;
|
|
mac.update(sign_data.as_bytes());
|
|
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
|
|
}
|
|
|
|
fn to_lower_hex(bytes: &[u8]) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut output = String::with_capacity(bytes.len() * 2);
|
|
for &byte in bytes {
|
|
output.push(char::from(HEX[(byte >> 4) as usize]));
|
|
output.push(char::from(HEX[(byte & 0x0f) as usize]));
|
|
}
|
|
output
|
|
}
|
|
|
|
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
|
order
|
|
.success_time
|
|
.as_deref()
|
|
.and_then(|value| parse_rfc3339(value).ok())
|
|
.map(offset_datetime_to_unix_micros)
|
|
.unwrap_or_else(current_unix_micros)
|
|
}
|
|
|
|
fn build_profile_recharge_center_response(
|
|
record: RuntimeProfileRechargeCenterRecord,
|
|
) -> ProfileRechargeCenterResponse {
|
|
let mud_point_balance = build_profile_mud_point_balance_response(&record);
|
|
ProfileRechargeCenterResponse {
|
|
wallet_balance: record.wallet_balance,
|
|
mud_point_balance,
|
|
membership: ProfileMembershipResponse {
|
|
status: record.membership.status.as_str().to_string(),
|
|
tier: record.membership.tier.as_str().to_string(),
|
|
started_at: record.membership.started_at,
|
|
expires_at: record.membership.expires_at,
|
|
updated_at: record.membership.updated_at,
|
|
cycle_started_at: record.membership.cycle_started_at,
|
|
cycle_resets_at: record.membership.cycle_resets_at,
|
|
cycle_granted_points: record.membership.cycle_granted_points,
|
|
cycle_remaining_points: record.membership.cycle_remaining_points,
|
|
cycle_period_days: record.membership.cycle_period_days,
|
|
},
|
|
point_products: record
|
|
.point_products
|
|
.into_iter()
|
|
.map(build_profile_recharge_product_response)
|
|
.collect(),
|
|
membership_products: record
|
|
.membership_products
|
|
.into_iter()
|
|
.map(build_profile_recharge_product_response)
|
|
.collect(),
|
|
benefits: record
|
|
.benefits
|
|
.into_iter()
|
|
.map(build_profile_membership_benefit_response)
|
|
.collect(),
|
|
latest_order: record
|
|
.latest_order
|
|
.map(build_profile_recharge_order_response),
|
|
has_points_recharged: record.has_points_recharged,
|
|
daily_free_points: build_profile_daily_free_points_response(record.daily_free_points),
|
|
}
|
|
}
|
|
|
|
fn build_profile_mud_point_balance_response(
|
|
record: &RuntimeProfileRechargeCenterRecord,
|
|
) -> ProfileMudPointBalanceResponse {
|
|
let daily_free_points = record.daily_free_points.remaining_points;
|
|
let limited_points = record.membership.cycle_remaining_points;
|
|
let permanent_points = record
|
|
.wallet_balance
|
|
.saturating_sub(daily_free_points)
|
|
.saturating_sub(limited_points);
|
|
let limited_expires_at = (limited_points > 0)
|
|
.then(|| {
|
|
record
|
|
.membership
|
|
.cycle_resets_at
|
|
.clone()
|
|
.or_else(|| record.membership.expires_at.clone())
|
|
})
|
|
.flatten();
|
|
|
|
ProfileMudPointBalanceResponse {
|
|
total_points: record.wallet_balance,
|
|
permanent_points,
|
|
limited_points,
|
|
limited_expires_at,
|
|
daily_free_points,
|
|
daily_free_reset_points: record.daily_free_points.reset_points,
|
|
daily_free_resets_at: record.daily_free_points.resets_at.clone(),
|
|
}
|
|
}
|
|
|
|
fn build_profile_daily_free_points_response(
|
|
record: RuntimeProfileDailyFreePointsRecord,
|
|
) -> ProfileDailyFreePointsResponse {
|
|
ProfileDailyFreePointsResponse {
|
|
day_key: record.day_key,
|
|
granted_points: record.granted_points,
|
|
remaining_points: record.remaining_points,
|
|
resets_at: record.resets_at,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_recharge_product_response(
|
|
record: RuntimeProfileRechargeProductRecord,
|
|
) -> ProfileRechargeProductResponse {
|
|
ProfileRechargeProductResponse {
|
|
product_id: record.product_id,
|
|
title: record.title,
|
|
price_cents: record.price_cents,
|
|
kind: record.kind.as_str().to_string(),
|
|
points_amount: record.points_amount,
|
|
bonus_points: record.bonus_points,
|
|
duration_days: record.duration_days,
|
|
badge_label: record.badge_label,
|
|
description: record.description,
|
|
tier: record.tier.as_str().to_string(),
|
|
membership_period_points: record.membership_period_points,
|
|
membership_period_days: record.membership_period_days,
|
|
membership_queue_limit: record.membership_queue_limit,
|
|
membership_discount_bps: record.membership_discount_bps,
|
|
}
|
|
}
|
|
|
|
fn build_profile_membership_benefit_response(
|
|
record: RuntimeProfileMembershipBenefitRecord,
|
|
) -> ProfileMembershipBenefitResponse {
|
|
ProfileMembershipBenefitResponse {
|
|
benefit_name: record.benefit_name,
|
|
normal_value: record.normal_value,
|
|
month_value: record.month_value,
|
|
season_value: record.season_value,
|
|
year_value: record.year_value,
|
|
starter_value: record.starter_value,
|
|
basic_value: record.basic_value,
|
|
pro_value: record.pro_value,
|
|
ultimate_value: record.ultimate_value,
|
|
}
|
|
}
|
|
|
|
fn build_profile_recharge_order_response(
|
|
record: RuntimeProfileRechargeOrderRecord,
|
|
) -> ProfileRechargeOrderResponse {
|
|
ProfileRechargeOrderResponse {
|
|
order_id: record.order_id,
|
|
product_id: record.product_id,
|
|
product_title: record.product_title,
|
|
kind: record.kind.as_str().to_string(),
|
|
amount_cents: record.amount_cents,
|
|
status: record.status.as_str().to_string(),
|
|
payment_channel: record.payment_channel,
|
|
paid_at: record.paid_at,
|
|
provider_transaction_id: record.provider_transaction_id,
|
|
created_at: record.created_at,
|
|
points_delta: record.points_delta,
|
|
membership_expires_at: record.membership_expires_at,
|
|
expired_at: record.expired_at,
|
|
expiration_checked_at: record.expiration_checked_at,
|
|
expiration_provider_state: record.expiration_provider_state,
|
|
expiration_last_error: record.expiration_last_error,
|
|
}
|
|
}
|
|
|
|
fn build_profile_feedback_submission_response(
|
|
record: RuntimeProfileFeedbackSubmissionRecord,
|
|
) -> ProfileFeedbackSubmissionResponse {
|
|
ProfileFeedbackSubmissionResponse {
|
|
feedback_id: record.feedback_id,
|
|
status: match record.status {
|
|
module_runtime::RuntimeProfileFeedbackStatus::Open => {
|
|
PROFILE_FEEDBACK_STATUS_OPEN.to_string()
|
|
}
|
|
},
|
|
created_at: record.created_at,
|
|
evidence_items: record
|
|
.evidence_items
|
|
.into_iter()
|
|
.map(build_profile_feedback_evidence_response)
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn build_profile_feedback_evidence_response(
|
|
record: RuntimeProfileFeedbackEvidenceRecord,
|
|
) -> ProfileFeedbackEvidenceItemResponse {
|
|
ProfileFeedbackEvidenceItemResponse {
|
|
evidence_id: record.evidence_id,
|
|
file_name: record.file_name,
|
|
content_type: record.content_type,
|
|
size_bytes: record.size_bytes,
|
|
}
|
|
}
|
|
|
|
fn build_profile_referral_invite_center_response(
|
|
record: RuntimeReferralInviteCenterRecord,
|
|
) -> ProfileReferralInviteCenterResponse {
|
|
ProfileReferralInviteCenterResponse {
|
|
invite_code: record.invite_code,
|
|
invite_link_path: record.invite_link_path,
|
|
invited_count: record.invited_count,
|
|
rewarded_invite_count: record.rewarded_invite_count,
|
|
today_inviter_reward_count: record.today_inviter_reward_count,
|
|
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
|
|
reward_points: record.reward_points,
|
|
invited_users: record
|
|
.invited_users
|
|
.into_iter()
|
|
.map(|user| ProfileReferralInvitedUserResponse {
|
|
user_id: user.user_id,
|
|
display_name: user.display_name,
|
|
avatar_url: user.avatar_url,
|
|
bound_at: user.bound_at,
|
|
})
|
|
.collect(),
|
|
has_redeemed_code: record.has_redeemed_code,
|
|
bound_inviter_user_id: record.bound_inviter_user_id,
|
|
bound_at: record.bound_at,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_redeem_profile_referral_invite_code_response(
|
|
record: module_runtime::RuntimeReferralRedeemRecord,
|
|
) -> RedeemProfileReferralInviteCodeResponse {
|
|
RedeemProfileReferralInviteCodeResponse {
|
|
center: build_profile_referral_invite_center_response(record.center),
|
|
invitee_reward_granted: record.invitee_reward_granted,
|
|
inviter_reward_granted: record.inviter_reward_granted,
|
|
invitee_balance_after: record.invitee_balance_after,
|
|
inviter_balance_after: record.inviter_balance_after,
|
|
}
|
|
}
|
|
|
|
fn build_redeem_profile_reward_code_response(
|
|
record: RuntimeProfileRewardCodeRedeemRecord,
|
|
) -> RedeemProfileRewardCodeResponse {
|
|
RedeemProfileRewardCodeResponse {
|
|
wallet_balance: record.wallet_balance,
|
|
amount_granted: record.amount_granted,
|
|
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
|
|
}
|
|
}
|
|
|
|
fn build_profile_wallet_ledger_entry_response(
|
|
record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
|
|
) -> ProfileWalletLedgerEntryResponse {
|
|
ProfileWalletLedgerEntryResponse {
|
|
id: record.wallet_ledger_id,
|
|
amount_delta: record.amount_delta,
|
|
balance_after: record.balance_after,
|
|
source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
|
|
created_at: record.created_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_task_center_response(
|
|
record: RuntimeProfileTaskCenterRecord,
|
|
) -> ProfileTaskCenterResponse {
|
|
ProfileTaskCenterResponse {
|
|
day_key: record.day_key,
|
|
wallet_balance: record.wallet_balance,
|
|
tasks: record
|
|
.tasks
|
|
.into_iter()
|
|
.map(build_profile_task_item_response)
|
|
.collect(),
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_analytics_metric_query_response(
|
|
record: module_runtime::AnalyticsMetricQueryResponse,
|
|
) -> AnalyticsMetricQueryResponse {
|
|
AnalyticsMetricQueryResponse {
|
|
buckets: record
|
|
.buckets
|
|
.into_iter()
|
|
.map(|bucket| AnalyticsBucketMetricResponse {
|
|
bucket_key: bucket.bucket_key,
|
|
bucket_start_date_key: bucket.bucket_start_date_key,
|
|
bucket_end_date_key: bucket.bucket_end_date_key,
|
|
value: bucket.value,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn build_profile_task_item_response(
|
|
record: RuntimeProfileTaskItemRecord,
|
|
) -> ProfileTaskItemResponse {
|
|
ProfileTaskItemResponse {
|
|
task_id: record.task_id,
|
|
title: record.title,
|
|
description: record.description,
|
|
event_key: record.event_key,
|
|
cycle: format_profile_task_cycle(record.cycle).to_string(),
|
|
threshold: record.threshold,
|
|
progress_count: record.progress_count,
|
|
reward_points: record.reward_points,
|
|
status: format_profile_task_status(record.status).to_string(),
|
|
day_key: record.day_key,
|
|
claimed_at: record.claimed_at,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_claim_profile_task_reward_response(
|
|
record: RuntimeProfileTaskClaimRecord,
|
|
) -> ClaimProfileTaskRewardResponse {
|
|
ClaimProfileTaskRewardResponse {
|
|
task_id: record.task_id,
|
|
day_key: record.day_key,
|
|
reward_points: record.reward_points,
|
|
wallet_balance: record.wallet_balance,
|
|
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
|
|
center: build_profile_task_center_response(record.center),
|
|
}
|
|
}
|
|
|
|
fn build_profile_task_config_admin_response(
|
|
record: RuntimeProfileTaskConfigRecord,
|
|
admin_display_names: &AdminDisplayNameDirectory,
|
|
) -> ProfileTaskConfigAdminResponse {
|
|
let created_by_display_name = admin_display_names.resolve(&record.created_by);
|
|
let updated_by_display_name = admin_display_names.resolve(&record.updated_by);
|
|
ProfileTaskConfigAdminResponse {
|
|
task_id: record.task_id,
|
|
title: record.title,
|
|
description: record.description,
|
|
event_key: record.event_key,
|
|
cycle: format_profile_task_cycle(record.cycle).to_string(),
|
|
scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(),
|
|
threshold: record.threshold,
|
|
reward_points: record.reward_points,
|
|
enabled: record.enabled,
|
|
sort_order: record.sort_order,
|
|
created_by: record.created_by,
|
|
created_by_display_name,
|
|
created_at: record.created_at,
|
|
updated_by: record.updated_by,
|
|
updated_by_display_name,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_recharge_product_config_admin_response(
|
|
record: RuntimeProfileRechargeProductConfigRecord,
|
|
) -> ProfileRechargeProductConfigAdminResponse {
|
|
ProfileRechargeProductConfigAdminResponse {
|
|
product_id: record.product_id,
|
|
title: record.title,
|
|
price_cents: record.price_cents,
|
|
kind: format_profile_recharge_product_kind(record.kind).to_string(),
|
|
points_amount: record.points_amount,
|
|
bonus_points: record.bonus_points,
|
|
duration_days: record.duration_days,
|
|
badge_label: record.badge_label,
|
|
description: record.description,
|
|
tier: format_profile_membership_tier(record.tier).to_string(),
|
|
membership_period_points: record.membership_period_points,
|
|
membership_period_days: record.membership_period_days,
|
|
membership_queue_limit: record.membership_queue_limit,
|
|
membership_discount_bps: record.membership_discount_bps,
|
|
enabled: record.enabled,
|
|
sort_order: record.sort_order,
|
|
created_by: record.created_by,
|
|
created_at: record.created_at,
|
|
updated_by: record.updated_by,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_wallet_config_admin_response(
|
|
record: module_runtime::RuntimeProfileWalletConfigRecord,
|
|
admin_display_names: &AdminDisplayNameDirectory,
|
|
) -> ProfileWalletConfigAdminResponse {
|
|
let created_by_display_name = admin_display_names.resolve(&record.created_by);
|
|
let updated_by_display_name = admin_display_names.resolve(&record.updated_by);
|
|
ProfileWalletConfigAdminResponse {
|
|
config_id: record.config_id,
|
|
initial_mud_points: record.initial_mud_points,
|
|
created_by: record.created_by,
|
|
created_by_display_name,
|
|
created_at: record.created_at,
|
|
updated_by: record.updated_by,
|
|
updated_by_display_name,
|
|
updated_at: record.updated_at,
|
|
daily_free_points_per_day: record.daily_free_points_per_day,
|
|
}
|
|
}
|
|
|
|
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
|
|
let metadata = match metadata {
|
|
Some(Value::Null) | None => json!({}),
|
|
Some(value) if value.is_object() => value,
|
|
Some(_) => {
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("邀请码 metadata 必须是 JSON 对象")
|
|
.with_details(json!({ "field": "metadata" })));
|
|
}
|
|
};
|
|
let metadata_json = serde_json::to_string(&metadata).map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message(format!("邀请码 metadata 序列化失败:{error}"))
|
|
.with_details(json!({ "field": "metadata" }))
|
|
})?;
|
|
if metadata_json.len() > 4096 {
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("邀请码 metadata 不能超过 4096 bytes")
|
|
.with_details(json!({ "field": "metadata" })));
|
|
}
|
|
|
|
Ok(metadata_json)
|
|
}
|
|
|
|
fn parse_admin_profile_code_time_field(
|
|
code_label: &'static str,
|
|
field: &'static str,
|
|
value: Option<String>,
|
|
) -> Result<Option<i64>, AppError> {
|
|
let Some(value) = value else {
|
|
return Ok(None);
|
|
};
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let parsed = parse_rfc3339(value).map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message(format!("{code_label} {field} 必须是 RFC3339 时间字符串"))
|
|
.with_details(json!({ "field": field, "message": error }))
|
|
})?;
|
|
|
|
Ok(Some(offset_datetime_to_unix_micros(parsed)))
|
|
}
|
|
|
|
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
|
|
"unique" => Ok(RuntimeProfileRedeemCodeMode::Unique),
|
|
"private" => Ok(RuntimeProfileRedeemCodeMode::Private),
|
|
_ => Err("兑换码类型无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily),
|
|
_ => Err("任务周期无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_profile_recharge_product_kind(
|
|
raw: &str,
|
|
) -> Result<RuntimeProfileRechargeProductKind, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
PROFILE_RECHARGE_PRODUCT_KIND_POINTS => Ok(RuntimeProfileRechargeProductKind::Points),
|
|
PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP => {
|
|
Ok(RuntimeProfileRechargeProductKind::Membership)
|
|
}
|
|
_ => Err("充值商品类型无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_profile_membership_tier(raw: &str) -> Result<RuntimeProfileMembershipTier, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal),
|
|
PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month),
|
|
PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season),
|
|
PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year),
|
|
PROFILE_MEMBERSHIP_TIER_STARTER => Ok(RuntimeProfileMembershipTier::Starter),
|
|
PROFILE_MEMBERSHIP_TIER_BASIC => Ok(RuntimeProfileMembershipTier::Basic),
|
|
PROFILE_MEMBERSHIP_TIER_PRO => Ok(RuntimeProfileMembershipTier::Pro),
|
|
PROFILE_MEMBERSHIP_TIER_ULTIMATE => Ok(RuntimeProfileMembershipTier::Ultimate),
|
|
_ => Err("会员档位无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
|
|
TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work),
|
|
TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module),
|
|
TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User),
|
|
_ => Err("埋点范围无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_analytics_granularity(raw: &str) -> Result<AnalyticsGranularity, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day),
|
|
ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week),
|
|
ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month),
|
|
ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter),
|
|
ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year),
|
|
_ => Err("统计粒度无效".to_string()),
|
|
}
|
|
}
|
|
|
|
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
|
|
match cycle {
|
|
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
|
|
}
|
|
}
|
|
|
|
fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str {
|
|
match status {
|
|
RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE,
|
|
RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE,
|
|
RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED,
|
|
RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED,
|
|
}
|
|
}
|
|
|
|
fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) -> &'static str {
|
|
match kind {
|
|
RuntimeProfileRechargeProductKind::Points => PROFILE_RECHARGE_PRODUCT_KIND_POINTS,
|
|
RuntimeProfileRechargeProductKind::Membership => PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP,
|
|
}
|
|
}
|
|
|
|
fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str {
|
|
match tier {
|
|
RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL,
|
|
RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH,
|
|
RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON,
|
|
RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR,
|
|
RuntimeProfileMembershipTier::Starter => PROFILE_MEMBERSHIP_TIER_STARTER,
|
|
RuntimeProfileMembershipTier::Basic => PROFILE_MEMBERSHIP_TIER_BASIC,
|
|
RuntimeProfileMembershipTier::Pro => PROFILE_MEMBERSHIP_TIER_PRO,
|
|
RuntimeProfileMembershipTier::Ultimate => PROFILE_MEMBERSHIP_TIER_ULTIMATE,
|
|
}
|
|
}
|
|
|
|
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
|
|
match scope_kind {
|
|
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
|
|
RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK,
|
|
RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE,
|
|
RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER,
|
|
}
|
|
}
|
|
|
|
fn build_profile_invite_code_admin_response(
|
|
record: RuntimeProfileInviteCodeRecord,
|
|
) -> ProfileInviteCodeAdminResponse {
|
|
let metadata =
|
|
serde_json::from_str::<Value>(&record.metadata_json).unwrap_or_else(|_| json!({}));
|
|
ProfileInviteCodeAdminResponse {
|
|
user_id: record.user_id,
|
|
invite_code: record.invite_code,
|
|
metadata,
|
|
starts_at: record.starts_at,
|
|
expires_at: record.expires_at,
|
|
status: record.status.as_str().to_string(),
|
|
created_at: record.created_at,
|
|
updated_at: record.updated_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_redeem_code_admin_response(
|
|
record: RuntimeProfileRedeemCodeRecord,
|
|
) -> ProfileRedeemCodeAdminResponse {
|
|
ProfileRedeemCodeAdminResponse {
|
|
code: record.code,
|
|
mode: record.mode.as_str().to_string(),
|
|
reward_points: record.reward_points,
|
|
max_uses: record.max_uses,
|
|
global_used_count: record.global_used_count,
|
|
enabled: record.enabled,
|
|
allowed_user_ids: record.allowed_user_ids,
|
|
created_by: record.created_by,
|
|
created_at: record.created_at,
|
|
updated_at: record.updated_at,
|
|
starts_at: record.starts_at,
|
|
expires_at: record.expires_at,
|
|
}
|
|
}
|
|
|
|
fn build_profile_code_operation_admin_response(
|
|
record: RuntimeProfileCodeOperationRecord,
|
|
admin_display_names: &AdminDisplayNameDirectory,
|
|
) -> ProfileCodeOperationAdminResponse {
|
|
let operator_display_name = admin_display_names.resolve(&record.operator_user_id);
|
|
ProfileCodeOperationAdminResponse {
|
|
operation_id: record.operation_id,
|
|
code_kind: record.code_kind,
|
|
code: record.code,
|
|
action: record.action,
|
|
operator_user_id: record.operator_user_id,
|
|
operator_display_name,
|
|
created_at: record.created_at,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
|
|
use module_runtime::{
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
|
RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipRecord,
|
|
RuntimeProfileMembershipStatus, RuntimeProfileMembershipTier,
|
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
|
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind,
|
|
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
|
|
};
|
|
|
|
use super::{
|
|
build_profile_mud_point_balance_response, build_wechat_virtual_pay_params,
|
|
calc_wechat_virtual_payment_pay_signature_with_key,
|
|
calc_wechat_virtual_payment_user_signature_with_key,
|
|
format_profile_wallet_ledger_source_type,
|
|
is_wechat_profile_recharge_order_terminal_for_confirmation,
|
|
map_runtime_profile_client_error, normalize_admin_invite_code_metadata,
|
|
parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery,
|
|
};
|
|
|
|
#[test]
|
|
fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() {
|
|
for message in ["兑换码未生效", "兑换码已过期"] {
|
|
let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure(
|
|
message.to_string(),
|
|
));
|
|
|
|
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(error.code(), "BAD_REQUEST");
|
|
assert_eq!(error.message(), message);
|
|
assert_eq!(
|
|
error
|
|
.details()
|
|
.and_then(|details| details.get("provider"))
|
|
.and_then(Value::as_str),
|
|
Some("runtime-profile")
|
|
);
|
|
}
|
|
|
|
let infrastructure_error = map_runtime_profile_client_error(
|
|
SpacetimeClientError::Procedure("No such procedure: redeem_profile_reward_code".into()),
|
|
);
|
|
assert_eq!(infrastructure_error.status_code(), StatusCode::BAD_GATEWAY);
|
|
assert_eq!(
|
|
infrastructure_error
|
|
.details()
|
|
.and_then(|details| details.get("provider"))
|
|
.and_then(Value::as_str),
|
|
Some("spacetimedb")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_profile_code_time_parser_accepts_optional_rfc3339_values() {
|
|
assert_eq!(
|
|
parse_admin_profile_code_time_field("兑换码", "startsAt", None)
|
|
.expect("missing boundary should pass"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_admin_profile_code_time_field(
|
|
"兑换码",
|
|
"startsAt",
|
|
Some("1970-01-01T00:00:01Z".to_string()),
|
|
)
|
|
.expect("RFC3339 boundary should pass"),
|
|
Some(1_000_000)
|
|
);
|
|
let error = parse_admin_profile_code_time_field(
|
|
"兑换码",
|
|
"startsAt",
|
|
Some("2026-07-13 00:00:00".to_string()),
|
|
)
|
|
.expect_err("non-RFC3339 boundary should fail");
|
|
assert!(error.message().contains("兑换码 startsAt"));
|
|
}
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use platform_auth::{
|
|
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
|
};
|
|
use serde_json::Value;
|
|
use spacetime_client::SpacetimeClientError;
|
|
use std::time::Duration;
|
|
use time::OffsetDateTime;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::{app::build_router, config::AppConfig, state::AppState};
|
|
|
|
fn daily_free_points_record() -> RuntimeProfileDailyFreePointsRecord {
|
|
RuntimeProfileDailyFreePointsRecord {
|
|
day_key: 20_646,
|
|
granted_points: 20,
|
|
remaining_points: 20,
|
|
resets_at: "2026-07-12T16:00:00Z".to_string(),
|
|
resets_at_micros: 1_783_872_000_000_000,
|
|
updated_at: "2026-07-12T08:00:00Z".to_string(),
|
|
updated_at_micros: 1_783_843_200_000_000,
|
|
reset_points: 35,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mud_point_balance_is_split_by_backend_wallet_buckets() {
|
|
let record = RuntimeProfileRechargeCenterRecord {
|
|
user_id: "user-1".to_string(),
|
|
wallet_balance: 200,
|
|
membership: RuntimeProfileMembershipRecord {
|
|
user_id: "user-1".to_string(),
|
|
status: RuntimeProfileMembershipStatus::Active,
|
|
tier: RuntimeProfileMembershipTier::Starter,
|
|
started_at: Some("2026-07-01T00:00:00Z".to_string()),
|
|
started_at_micros: Some(1_783_036_800_000_000),
|
|
expires_at: Some("2026-08-01T00:00:00Z".to_string()),
|
|
expires_at_micros: Some(1_785_542_400_000_000),
|
|
updated_at: Some("2026-07-12T08:00:00Z".to_string()),
|
|
updated_at_micros: Some(1_783_843_200_000_000),
|
|
cycle_started_at: Some("2026-07-08T00:00:00Z".to_string()),
|
|
cycle_started_at_micros: Some(1_783_468_800_000_000),
|
|
cycle_resets_at: Some("2026-07-15T00:00:00Z".to_string()),
|
|
cycle_resets_at_micros: Some(1_784_073_600_000_000),
|
|
cycle_granted_points: 80,
|
|
cycle_remaining_points: 80,
|
|
cycle_period_days: 7,
|
|
},
|
|
point_products: vec![],
|
|
membership_products: vec![],
|
|
benefits: vec![],
|
|
latest_order: None,
|
|
has_points_recharged: false,
|
|
daily_free_points: daily_free_points_record(),
|
|
};
|
|
|
|
let balance = build_profile_mud_point_balance_response(&record);
|
|
|
|
assert_eq!(balance.total_points, 200);
|
|
assert_eq!(balance.permanent_points, 100);
|
|
assert_eq!(balance.limited_points, 80);
|
|
assert_eq!(balance.daily_free_points, 20);
|
|
assert_eq!(balance.daily_free_reset_points, 35);
|
|
assert_eq!(
|
|
balance.limited_expires_at.as_deref(),
|
|
Some("2026-07-15T00:00:00Z"),
|
|
);
|
|
assert_eq!(balance.daily_free_resets_at, "2026-07-12T16:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn profile_wallet_ledger_source_type_formats_backend_values() {
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::DailyTaskReward
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeGrant
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT
|
|
);
|
|
assert_eq!(
|
|
format_profile_wallet_ledger_source_type(
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeReset
|
|
),
|
|
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_dashboard_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/dashboard")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let confirm_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_wallet_ledger_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/wallet-ledger")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_tasks_require_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let list_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/tasks")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
let claim_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/tasks/daily_login/claim")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retired_profile_play_stats_route_is_not_mounted() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/play-stats")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_center_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/recharge-center")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"productId":"points_60"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() {
|
|
let state = seed_authenticated_state().await;
|
|
let token = issue_access_token(&state);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"productId":"points_60"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(payload["error"]["message"], "充值支付渠道不能为空");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_unknown_payment_channel_before_spacetime() {
|
|
let state = seed_authenticated_state().await;
|
|
let token = issue_access_token(&state);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"card"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(payload["error"]["message"], "充值支付渠道无效");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_mock_when_pay_provider_is_real() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_provider: "real".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_access_token(&state);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"mock"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"生产充值不允许使用 mock 支付渠道"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_non_web_device_before_spacetime() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_device_access_token(
|
|
&state,
|
|
"native_app",
|
|
"native_app",
|
|
"windows",
|
|
"sess_runtime_profile_native_app",
|
|
);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"当前登录设备不支持充值,请在微信环境内登录后重试"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_legacy_web_session_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_access_token(&state);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_legacy_web_unknown_platform_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token =
|
|
issue_web_browser_access_token(&state, "test", "sess_runtime_profile_legacy_web");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_web_device_using_mini_program_channel_before_spacetime()
|
|
{
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_mp_virtual"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"当前登录设备不支持充值,请在微信环境内登录后重试"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token =
|
|
issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_mobile_web_h5_channel_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token =
|
|
issue_web_browser_access_token(&state, "ios", "sess_runtime_profile_mobile_web");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_desktop_web_native_channel_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token =
|
|
issue_web_browser_access_token(&state, "windows", "sess_runtime_profile_desktop_web");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_mobile_web_native_channel_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token =
|
|
issue_web_browser_access_token(&state, "ios", "sess_runtime_profile_mobile_web_native");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_allows_desktop_web_h5_channel_before_provider_check() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_web_browser_access_token(
|
|
&state,
|
|
"windows",
|
|
"sess_runtime_profile_desktop_web_h5",
|
|
);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_pay_enabled: true,
|
|
wechat_pay_provider: "mock".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
})
|
|
.await;
|
|
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5");
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/recharge/orders")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"真实微信支付渠道不能使用 mock 支付配置"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
|
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
|
wechat_mini_program_virtual_payment_env: 0,
|
|
..fast_spacetime_timeout_config()
|
|
})
|
|
.await;
|
|
let wechat_login = state
|
|
.wechat_auth_service()
|
|
.resolve_login(ResolveWechatLoginInput {
|
|
profile: WechatIdentityProfile {
|
|
provider_uid: "openid-user-00000001".to_string(),
|
|
provider_union_id: Some("union-user-00000001".to_string()),
|
|
display_name: Some("资料页用户".to_string()),
|
|
avatar_url: None,
|
|
session_key: Some("session-key-1".to_string()),
|
|
},
|
|
})
|
|
.await
|
|
.expect("wechat identity should seed");
|
|
let user_id = wechat_login.user.id;
|
|
let order = RuntimeProfileRechargeOrderRecord {
|
|
order_id: "memberorder01".to_string(),
|
|
user_id: user_id.clone(),
|
|
product_id: "member_month".to_string(),
|
|
product_title: "月卡".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
amount_cents: 2800,
|
|
status: RuntimeProfileRechargeOrderStatus::Pending,
|
|
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
|
.to_string(),
|
|
paid_at: None,
|
|
paid_at_micros: None,
|
|
provider_transaction_id: None,
|
|
created_at: "2026-05-26T10:00:00Z".to_string(),
|
|
created_at_micros: 1_779_756_000_000_000,
|
|
points_delta: 0,
|
|
membership_expires_at: None,
|
|
membership_expires_at_micros: None,
|
|
expired_at: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at: None,
|
|
expiration_checked_at_micros: None,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
};
|
|
|
|
let center = RuntimeProfileRechargeCenterRecord {
|
|
user_id: user_id.clone(),
|
|
wallet_balance: 0,
|
|
membership: RuntimeProfileMembershipRecord {
|
|
user_id: user_id.clone(),
|
|
status: RuntimeProfileMembershipStatus::Normal,
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
started_at: None,
|
|
started_at_micros: None,
|
|
expires_at: None,
|
|
expires_at_micros: None,
|
|
updated_at: None,
|
|
updated_at_micros: None,
|
|
cycle_started_at: None,
|
|
cycle_started_at_micros: None,
|
|
cycle_resets_at: None,
|
|
cycle_resets_at_micros: None,
|
|
cycle_granted_points: 0,
|
|
cycle_remaining_points: 0,
|
|
cycle_period_days: 30,
|
|
},
|
|
point_products: vec![],
|
|
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
|
product_id: "member_month".to_string(),
|
|
title: "月卡".to_string(),
|
|
price_cents: 2800,
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
points_amount: 0,
|
|
bonus_points: 0,
|
|
duration_days: 30,
|
|
badge_label: String::new(),
|
|
description: "30天会员".to_string(),
|
|
tier: RuntimeProfileMembershipTier::Month,
|
|
membership_period_points: 0,
|
|
membership_period_days: 30,
|
|
membership_queue_limit: 0,
|
|
membership_discount_bps: 0,
|
|
}],
|
|
benefits: vec![],
|
|
latest_order: None,
|
|
has_points_recharged: false,
|
|
daily_free_points: daily_free_points_record(),
|
|
};
|
|
|
|
let params =
|
|
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001")
|
|
.expect("membership virtual pay params should build");
|
|
let sign_data: Value =
|
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
|
let attach: Value = serde_json::from_str(
|
|
sign_data["attach"]
|
|
.as_str()
|
|
.expect("attach should be string json"),
|
|
)
|
|
.expect("attach should decode");
|
|
|
|
assert_eq!(params.mode, "short_series_goods");
|
|
assert_eq!(sign_data["buyQuantity"], 1);
|
|
assert_eq!(sign_data["offerId"], "offer-1");
|
|
assert_eq!(sign_data["productId"], "member_month");
|
|
assert_eq!(sign_data["goodsPrice"], 2800);
|
|
assert_eq!(sign_data["outTradeNo"], "memberorder01");
|
|
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
|
assert!(!params.pay_sig.is_empty());
|
|
assert!(!params.signature.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wechat_virtual_pay_params_use_coin_quantity_for_points_products() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
|
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
|
wechat_mini_program_virtual_payment_env: 0,
|
|
..fast_spacetime_timeout_config()
|
|
})
|
|
.await;
|
|
let wechat_login = state
|
|
.wechat_auth_service()
|
|
.resolve_login(ResolveWechatLoginInput {
|
|
profile: WechatIdentityProfile {
|
|
provider_uid: "openid-user-points-60".to_string(),
|
|
provider_union_id: Some("union-user-points-60".to_string()),
|
|
display_name: Some("资料页用户".to_string()),
|
|
avatar_url: None,
|
|
session_key: Some("session-key-points-60".to_string()),
|
|
},
|
|
})
|
|
.await
|
|
.expect("wechat identity should seed");
|
|
let user_id = wechat_login.user.id.clone();
|
|
let order = RuntimeProfileRechargeOrderRecord {
|
|
order_id: "pointsorder60".to_string(),
|
|
user_id: user_id.clone(),
|
|
product_id: "points_60".to_string(),
|
|
product_title: "60泥点".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
amount_cents: 600,
|
|
status: RuntimeProfileRechargeOrderStatus::Pending,
|
|
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
|
.to_string(),
|
|
paid_at: None,
|
|
paid_at_micros: None,
|
|
provider_transaction_id: None,
|
|
created_at: "2026-05-30T10:00:00Z".to_string(),
|
|
created_at_micros: 1_780_000_000_000_000,
|
|
points_delta: 0,
|
|
membership_expires_at: None,
|
|
membership_expires_at_micros: None,
|
|
expired_at: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at: None,
|
|
expiration_checked_at_micros: None,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
};
|
|
|
|
let center = RuntimeProfileRechargeCenterRecord {
|
|
user_id: user_id.clone(),
|
|
wallet_balance: 0,
|
|
membership: RuntimeProfileMembershipRecord {
|
|
user_id: user_id.clone(),
|
|
status: RuntimeProfileMembershipStatus::Normal,
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
started_at: None,
|
|
started_at_micros: None,
|
|
expires_at: None,
|
|
expires_at_micros: None,
|
|
updated_at: None,
|
|
updated_at_micros: None,
|
|
cycle_started_at: None,
|
|
cycle_started_at_micros: None,
|
|
cycle_resets_at: None,
|
|
cycle_resets_at_micros: None,
|
|
cycle_granted_points: 0,
|
|
cycle_remaining_points: 0,
|
|
cycle_period_days: 30,
|
|
},
|
|
point_products: vec![RuntimeProfileRechargeProductRecord {
|
|
product_id: "points_60".to_string(),
|
|
title: "60泥点".to_string(),
|
|
price_cents: 600,
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
points_amount: 60,
|
|
bonus_points: 60,
|
|
duration_days: 0,
|
|
badge_label: "首充双倍".to_string(),
|
|
description: "60+60泥点".to_string(),
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
membership_period_points: 0,
|
|
membership_period_days: 0,
|
|
membership_queue_limit: 0,
|
|
membership_discount_bps: 0,
|
|
}],
|
|
membership_products: vec![],
|
|
benefits: vec![],
|
|
latest_order: None,
|
|
has_points_recharged: true,
|
|
daily_free_points: daily_free_points_record(),
|
|
};
|
|
|
|
let params =
|
|
build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-points-60")
|
|
.expect("points virtual pay params should build");
|
|
let sign_data: Value =
|
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
|
let attach: Value = serde_json::from_str(
|
|
sign_data["attach"]
|
|
.as_str()
|
|
.expect("attach should be string json"),
|
|
)
|
|
.expect("attach should decode");
|
|
|
|
assert_eq!(params.mode, "short_series_coin");
|
|
assert_eq!(sign_data["buyQuantity"], 60);
|
|
assert_eq!(sign_data["offerId"], "offer-1");
|
|
assert_eq!(sign_data["outTradeNo"], "pointsorder60");
|
|
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
|
assert!(!params.pay_sig.is_empty());
|
|
assert!(!params.signature.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
|
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
|
wechat_mini_program_virtual_payment_env: 0,
|
|
..fast_spacetime_timeout_config()
|
|
})
|
|
.await;
|
|
let wechat_login = state
|
|
.wechat_auth_service()
|
|
.resolve_login(ResolveWechatLoginInput {
|
|
profile: WechatIdentityProfile {
|
|
provider_uid: "openid-user-item01".to_string(),
|
|
provider_union_id: Some("union-user-item01".to_string()),
|
|
display_name: Some("资料页用户".to_string()),
|
|
avatar_url: None,
|
|
session_key: Some("session-key-item01".to_string()),
|
|
},
|
|
})
|
|
.await
|
|
.expect("wechat identity should seed");
|
|
let user_id = wechat_login.user.id.clone();
|
|
let order = RuntimeProfileRechargeOrderRecord {
|
|
order_id: "item01order01".to_string(),
|
|
user_id: user_id.clone(),
|
|
product_id: "item01".to_string(),
|
|
product_title: "测试道具".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
amount_cents: 100,
|
|
status: RuntimeProfileRechargeOrderStatus::Pending,
|
|
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
|
.to_string(),
|
|
paid_at: None,
|
|
paid_at_micros: None,
|
|
provider_transaction_id: None,
|
|
created_at: "2026-05-27T10:00:00Z".to_string(),
|
|
created_at_micros: 1_779_842_400_000_000,
|
|
points_delta: 0,
|
|
membership_expires_at: None,
|
|
membership_expires_at_micros: None,
|
|
expired_at: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at: None,
|
|
expiration_checked_at_micros: None,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
};
|
|
|
|
let center = RuntimeProfileRechargeCenterRecord {
|
|
user_id: user_id.clone(),
|
|
wallet_balance: 0,
|
|
membership: RuntimeProfileMembershipRecord {
|
|
user_id: user_id.clone(),
|
|
status: RuntimeProfileMembershipStatus::Normal,
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
started_at: None,
|
|
started_at_micros: None,
|
|
expires_at: None,
|
|
expires_at_micros: None,
|
|
updated_at: None,
|
|
updated_at_micros: None,
|
|
cycle_started_at: None,
|
|
cycle_started_at_micros: None,
|
|
cycle_resets_at: None,
|
|
cycle_resets_at_micros: None,
|
|
cycle_granted_points: 0,
|
|
cycle_remaining_points: 0,
|
|
cycle_period_days: 30,
|
|
},
|
|
point_products: vec![],
|
|
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
|
product_id: "item01".to_string(),
|
|
title: "测试道具".to_string(),
|
|
price_cents: 100,
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
points_amount: 0,
|
|
bonus_points: 0,
|
|
duration_days: 30,
|
|
badge_label: String::new(),
|
|
description: "30天会员".to_string(),
|
|
tier: RuntimeProfileMembershipTier::Month,
|
|
membership_period_points: 0,
|
|
membership_period_days: 30,
|
|
membership_queue_limit: 0,
|
|
membership_discount_bps: 0,
|
|
}],
|
|
benefits: vec![],
|
|
latest_order: None,
|
|
has_points_recharged: false,
|
|
daily_free_points: daily_free_points_record(),
|
|
};
|
|
|
|
let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-item01")
|
|
.expect("custom membership virtual pay params should build");
|
|
let sign_data: Value =
|
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
|
|
|
assert_eq!(params.mode, "short_series_goods");
|
|
assert_eq!(sign_data["productId"], "item01");
|
|
assert_eq!(sign_data["goodsPrice"], 100);
|
|
assert_eq!(sign_data["outTradeNo"], "item01order01");
|
|
}
|
|
|
|
#[test]
|
|
fn wechat_virtual_payment_signatures_match_official_examples() {
|
|
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
|
|
|
|
let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
|
|
.expect("pay signature should build");
|
|
let signature = calc_wechat_virtual_payment_user_signature_with_key(
|
|
"9hAb/NEYUlkaMBEsmFgzig==",
|
|
post_body,
|
|
)
|
|
.expect("user signature should build");
|
|
|
|
assert_eq!(
|
|
pay_sig,
|
|
"a1ab2651b927b6a766152cf864033417b85c1448fc3c6e1bedbbd7f49416e92f"
|
|
);
|
|
assert_eq!(
|
|
signature,
|
|
"089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_paid_pending_membership_goods_require_delivery_confirmation() {
|
|
assert!(should_notify_virtual_payment_goods_delivery(
|
|
RuntimeProfileRechargeProductKind::Membership,
|
|
2
|
|
));
|
|
assert!(!should_notify_virtual_payment_goods_delivery(
|
|
RuntimeProfileRechargeProductKind::Membership,
|
|
3
|
|
));
|
|
assert!(!should_notify_virtual_payment_goods_delivery(
|
|
RuntimeProfileRechargeProductKind::Points,
|
|
2
|
|
));
|
|
}
|
|
|
|
fn build_test_recharge_order(
|
|
status: RuntimeProfileRechargeOrderStatus,
|
|
expiration_checked_at_micros: Option<i64>,
|
|
) -> RuntimeProfileRechargeOrderRecord {
|
|
RuntimeProfileRechargeOrderRecord {
|
|
order_id: "confirm-terminal-order".to_string(),
|
|
user_id: "user-terminal".to_string(),
|
|
product_id: "points_60".to_string(),
|
|
product_title: "60娉ョ偣".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
amount_cents: 600,
|
|
status,
|
|
payment_channel: "wechat_native".to_string(),
|
|
paid_at: None,
|
|
paid_at_micros: None,
|
|
provider_transaction_id: None,
|
|
created_at: "2026-04-25T10:00:00Z".to_string(),
|
|
created_at_micros: 1_777_111_200_000_000,
|
|
points_delta: 0,
|
|
membership_expires_at: None,
|
|
membership_expires_at_micros: None,
|
|
expired_at: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at: expiration_checked_at_micros
|
|
.map(|_| "2026-04-25T10:05:02Z".to_string()),
|
|
expiration_checked_at_micros,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expired_recharge_order_waits_for_expiration_compensation_check() {
|
|
let order = build_test_recharge_order(RuntimeProfileRechargeOrderStatus::Expired, None);
|
|
|
|
assert!(!is_wechat_profile_recharge_order_terminal_for_confirmation(
|
|
&order
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn checked_expired_recharge_order_is_terminal_for_confirmation() {
|
|
let order = build_test_recharge_order(
|
|
RuntimeProfileRechargeOrderStatus::Expired,
|
|
Some(1_777_111_502_000_000),
|
|
);
|
|
|
|
assert!(is_wechat_profile_recharge_order_terminal_for_confirmation(
|
|
&order
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
|
|
let state = seed_authenticated_state_with_config(AppConfig {
|
|
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
|
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
|
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
|
wechat_mini_program_virtual_payment_env: 1,
|
|
..fast_spacetime_timeout_config()
|
|
})
|
|
.await;
|
|
let wechat_login = state
|
|
.wechat_auth_service()
|
|
.resolve_login(ResolveWechatLoginInput {
|
|
profile: WechatIdentityProfile {
|
|
provider_uid: "openid-sandbox-1".to_string(),
|
|
provider_union_id: Some("union-sandbox-1".to_string()),
|
|
display_name: Some("资料页用户".to_string()),
|
|
avatar_url: None,
|
|
session_key: Some("session-key-sandbox-1".to_string()),
|
|
},
|
|
})
|
|
.await
|
|
.expect("wechat identity should seed");
|
|
let user_id = wechat_login.user.id.clone();
|
|
let order = RuntimeProfileRechargeOrderRecord {
|
|
order_id: "sandboxorder01".to_string(),
|
|
user_id: user_id.clone(),
|
|
product_id: "points_60".to_string(),
|
|
product_title: "60泥点".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
amount_cents: 600,
|
|
status: RuntimeProfileRechargeOrderStatus::Pending,
|
|
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
|
.to_string(),
|
|
paid_at: None,
|
|
paid_at_micros: None,
|
|
provider_transaction_id: None,
|
|
created_at: "2026-05-30T10:00:00Z".to_string(),
|
|
created_at_micros: 1_780_000_000_000_000,
|
|
points_delta: 0,
|
|
membership_expires_at: None,
|
|
membership_expires_at_micros: None,
|
|
expired_at: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at: None,
|
|
expiration_checked_at_micros: None,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
};
|
|
|
|
let center = RuntimeProfileRechargeCenterRecord {
|
|
user_id: user_id.clone(),
|
|
wallet_balance: 0,
|
|
membership: RuntimeProfileMembershipRecord {
|
|
user_id: user_id.clone(),
|
|
status: RuntimeProfileMembershipStatus::Normal,
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
started_at: None,
|
|
started_at_micros: None,
|
|
expires_at: None,
|
|
expires_at_micros: None,
|
|
updated_at: None,
|
|
updated_at_micros: None,
|
|
cycle_started_at: None,
|
|
cycle_started_at_micros: None,
|
|
cycle_resets_at: None,
|
|
cycle_resets_at_micros: None,
|
|
cycle_granted_points: 0,
|
|
cycle_remaining_points: 0,
|
|
cycle_period_days: 30,
|
|
},
|
|
point_products: vec![RuntimeProfileRechargeProductRecord {
|
|
product_id: "points_60".to_string(),
|
|
title: "60泥点".to_string(),
|
|
price_cents: 600,
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
points_amount: 60,
|
|
bonus_points: 60,
|
|
duration_days: 0,
|
|
badge_label: "首充双倍".to_string(),
|
|
description: "60+60泥点".to_string(),
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
membership_period_points: 0,
|
|
membership_period_days: 0,
|
|
membership_queue_limit: 0,
|
|
membership_discount_bps: 0,
|
|
}],
|
|
membership_products: vec![],
|
|
benefits: vec![],
|
|
latest_order: None,
|
|
has_points_recharged: false,
|
|
daily_free_points: daily_free_points_record(),
|
|
};
|
|
|
|
let error = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-sandbox-1")
|
|
.expect_err("sandbox pay params should reject missing sandbox app key");
|
|
assert!(
|
|
error.to_string().contains("沙箱 AppKey 未配置"),
|
|
"unexpected error: {error}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_feedback_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/feedback")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"description":"反馈页面上传图片后没有显示预览"}"#,
|
|
))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_referral_invite_center_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/profile/referrals/invite-center")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_referral_redeem_code_requires_authentication() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/referrals/redeem-code")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() {
|
|
let state = seed_authenticated_state().await;
|
|
let token = issue_access_token(&state);
|
|
let app = build_router(state);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/profile/referrals/redeem-code")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let payload: Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
assert_eq!(
|
|
payload["error"]["details"]["provider"],
|
|
Value::String("spacetimedb".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_invite_code_metadata_accepts_only_json_object() {
|
|
assert_eq!(
|
|
normalize_admin_invite_code_metadata(None).expect("empty metadata should default"),
|
|
"{}"
|
|
);
|
|
assert_eq!(
|
|
normalize_admin_invite_code_metadata(Some(serde_json::json!({
|
|
"channel": "spring",
|
|
"source": "banner"
|
|
})))
|
|
.expect("object metadata should serialize"),
|
|
r#"{"channel":"spring","source":"banner"}"#
|
|
);
|
|
|
|
let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring")))
|
|
.expect_err("non-object metadata should reject");
|
|
assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_profile_legacy_routes_are_not_mounted() {
|
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
|
|
for uri in [
|
|
"/api/runtime/profile/dashboard",
|
|
"/api/runtime/profile/wallet-ledger",
|
|
"/api/runtime/profile/recharge-center",
|
|
"/api/runtime/profile/recharge/orders",
|
|
"/api/runtime/profile/feedback",
|
|
"/api/runtime/profile/referrals/invite-center",
|
|
"/api/runtime/profile/referrals/redeem-code",
|
|
"/api/runtime/profile/redeem-codes/redeem",
|
|
"/api/runtime/profile/play-stats",
|
|
"/api/runtime/profile/save-archives",
|
|
"/api/runtime/profile/save-archives/world-1",
|
|
"/api/runtime/profile/browse-history",
|
|
] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(uri)
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_profile_task_routes_require_admin_authentication() {
|
|
let app =
|
|
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
|
|
|
|
let list_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/admin/api/profile/tasks")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
let upsert_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/admin/api/profile/tasks")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"taskId":"daily_login"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
let disable_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/admin/api/profile/tasks/disable")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"taskId":"daily_login"}"#))
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_profile_code_list_routes_require_admin_authentication() {
|
|
let app =
|
|
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
|
|
|
|
for uri in [
|
|
"/admin/api/profile/redeem-codes",
|
|
"/admin/api/profile/invite-codes",
|
|
"/admin/api/profile/recharge-products",
|
|
] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(uri)
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
|
|
}
|
|
}
|
|
|
|
async fn seed_authenticated_state() -> AppState {
|
|
seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await
|
|
}
|
|
|
|
async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState {
|
|
let state = AppState::new(config).expect("state should build");
|
|
state
|
|
.seed_test_phone_user_with_password("13800138104", "secret123")
|
|
.await
|
|
.id;
|
|
state
|
|
}
|
|
|
|
fn fast_spacetime_timeout_config() -> AppConfig {
|
|
AppConfig {
|
|
spacetime_procedure_timeout: Duration::from_secs(1),
|
|
..AppConfig::default()
|
|
}
|
|
}
|
|
|
|
fn admin_enabled_test_config() -> AppConfig {
|
|
AppConfig {
|
|
admin_username: Some("root".to_string()),
|
|
admin_password: Some("secret123".to_string()),
|
|
..AppConfig::default()
|
|
}
|
|
}
|
|
|
|
fn issue_access_token(state: &AppState) -> String {
|
|
let user_id = test_authenticated_user_id(state);
|
|
let claims = AccessTokenClaims::from_input(
|
|
AccessTokenClaimsInput {
|
|
user_id: user_id.clone(),
|
|
session_id: state
|
|
.seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"),
|
|
provider: AuthProvider::Password,
|
|
roles: vec!["user".to_string()],
|
|
token_version: 2,
|
|
phone_verified: true,
|
|
binding_status: BindingStatus::Active,
|
|
display_name: Some("资料页用户".to_string()),
|
|
},
|
|
state.auth_jwt_config(),
|
|
OffsetDateTime::now_utc(),
|
|
)
|
|
.expect("claims should build");
|
|
|
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
|
}
|
|
|
|
fn issue_wechat_h5_access_token(
|
|
state: &AppState,
|
|
client_platform: &str,
|
|
session_id: &str,
|
|
) -> String {
|
|
let user_id = test_authenticated_user_id(state);
|
|
let claims = AccessTokenClaims::from_input_with_device(
|
|
AccessTokenClaimsInput {
|
|
user_id: user_id.clone(),
|
|
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
|
|
provider: AuthProvider::Wechat,
|
|
roles: vec!["user".to_string()],
|
|
token_version: 2,
|
|
phone_verified: true,
|
|
binding_status: BindingStatus::Active,
|
|
display_name: Some("微信资料页用户".to_string()),
|
|
},
|
|
Some(platform_auth::AccessTokenDeviceInfo {
|
|
client_type: "wechat_h5".to_string(),
|
|
client_runtime: "wechat_embedded_browser".to_string(),
|
|
client_platform: client_platform.to_string(),
|
|
}),
|
|
state.auth_jwt_config(),
|
|
OffsetDateTime::now_utc(),
|
|
)
|
|
.expect("claims should build");
|
|
|
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
|
}
|
|
|
|
fn issue_device_access_token(
|
|
state: &AppState,
|
|
client_type: &str,
|
|
client_runtime: &str,
|
|
client_platform: &str,
|
|
session_id: &str,
|
|
) -> String {
|
|
let user_id = test_authenticated_user_id(state);
|
|
let claims = AccessTokenClaims::from_input_with_device(
|
|
AccessTokenClaimsInput {
|
|
user_id: user_id.clone(),
|
|
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
|
|
provider: AuthProvider::Password,
|
|
roles: vec!["user".to_string()],
|
|
token_version: 2,
|
|
phone_verified: true,
|
|
binding_status: BindingStatus::Active,
|
|
display_name: Some("设备资料页用户".to_string()),
|
|
},
|
|
Some(platform_auth::AccessTokenDeviceInfo {
|
|
client_type: client_type.to_string(),
|
|
client_runtime: client_runtime.to_string(),
|
|
client_platform: client_platform.to_string(),
|
|
}),
|
|
state.auth_jwt_config(),
|
|
OffsetDateTime::now_utc(),
|
|
)
|
|
.expect("claims should build");
|
|
|
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
|
}
|
|
|
|
fn issue_web_browser_access_token(
|
|
state: &AppState,
|
|
client_platform: &str,
|
|
session_id: &str,
|
|
) -> String {
|
|
let user_id = test_authenticated_user_id(state);
|
|
let claims = AccessTokenClaims::from_input_with_device(
|
|
AccessTokenClaimsInput {
|
|
user_id: user_id.clone(),
|
|
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
|
|
provider: AuthProvider::Password,
|
|
roles: vec!["user".to_string()],
|
|
token_version: 2,
|
|
phone_verified: true,
|
|
binding_status: BindingStatus::Active,
|
|
display_name: Some("网页资料页用户".to_string()),
|
|
},
|
|
Some(platform_auth::AccessTokenDeviceInfo {
|
|
client_type: "web_browser".to_string(),
|
|
client_runtime: "web".to_string(),
|
|
client_platform: client_platform.to_string(),
|
|
}),
|
|
state.auth_jwt_config(),
|
|
OffsetDateTime::now_utc(),
|
|
)
|
|
.expect("claims should build");
|
|
|
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
|
}
|
|
|
|
fn test_authenticated_user_id(state: &AppState) -> String {
|
|
state
|
|
.auth_user_service()
|
|
.get_user_by_public_user_code("SY-00000001")
|
|
.expect("test user lookup should succeed")
|
|
.expect("seeded test user should exist")
|
|
.id
|
|
}
|
|
}
|