再次合并 master

合入 origin/master 最新订阅消息与计费相关更新

保留作品架 actions 收口并接入统一分享弹窗

修复创作生成泥点预检与本地余额扣减回归
This commit is contained in:
2026-06-08 17:18:38 +08:00
342 changed files with 4153 additions and 2483 deletions

View File

@@ -71,6 +71,10 @@ async fn consume_asset_operation_points(
asset_id: &str,
points_cost: u64,
) -> Result<bool, AppError> {
if points_cost == 0 {
return Ok(false);
}
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id

View File

@@ -36,7 +36,7 @@ use time::{Duration as TimeDuration, OffsetDateTime};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -62,6 +62,8 @@ const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-";
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE: &str = "initial_draft_generation";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT: u64 = 3;
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024";
const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792";
@@ -303,11 +305,13 @@ pub async fn generate_bark_battle_image_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let result = execute_billable_asset_operation(
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
let result = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
bark_battle_slot_asset_kind(&slot),
asset_id.as_str(),
points_cost,
async {
generate_and_persist_bark_battle_image_asset(
&state,
@@ -328,6 +332,40 @@ pub async fn generate_bark_battle_image_asset(
Ok(json_success_body(Some(&request_context), result))
}
async fn resolve_bark_battle_image_asset_points_cost(
state: &AppState,
payload: &BarkBattleImageAssetGenerateRequest,
) -> u64 {
if payload.billing_purpose.as_deref()
!= Some(BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE)
{
return crate::asset_billing::ASSET_OPERATION_POINTS_COST;
}
let total_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
BARK_BATTLE_PLAY_TYPE_ID,
BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT
* crate::asset_billing::ASSET_OPERATION_POINTS_COST,
)
.await;
resolve_bark_battle_initial_generation_slot_points_cost(&payload.slot, total_cost)
}
fn resolve_bark_battle_initial_generation_slot_points_cost(
slot: &BarkBattleAssetSlot,
total_cost: u64,
) -> u64 {
let base_cost = total_cost / BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let remainder = total_cost % BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let slot_index = match slot {
BarkBattleAssetSlot::PlayerCharacter => 0,
BarkBattleAssetSlot::OpponentCharacter => 1,
BarkBattleAssetSlot::UiBackground => 2,
};
base_cost + u64::from(slot_index < remainder)
}
pub async fn publish_bark_battle_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1661,6 +1699,94 @@ mod tests {
);
}
#[test]
fn initial_generation_slot_cost_splits_creation_entry_total_cost() {
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
1,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
2,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
8,
),
2,
);
}
#[test]
fn draft_config_mapping_includes_stable_work_identity() {
let request_context = RequestContext::new(

View File

@@ -100,6 +100,10 @@ pub struct AppConfig {
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
pub wechat_mini_program_message_token: Option<String>,
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
pub wechat_mini_program_subscribe_message_enabled: bool,
pub wechat_mini_program_generation_result_template_id: Option<String>,
pub wechat_mini_program_subscribe_message_endpoint: String,
pub wechat_mini_program_subscribe_message_state: String,
pub wechat_mini_program_virtual_payment_env: u8,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
@@ -250,6 +254,13 @@ impl Default for AppConfig {
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_message_token: None,
wechat_mini_program_message_encoding_aes_key: None,
wechat_mini_program_subscribe_message_enabled: true,
wechat_mini_program_generation_result_template_id: Some(
"m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(),
),
wechat_mini_program_subscribe_message_endpoint:
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(),
wechat_mini_program_subscribe_message_state: "formal".to_string(),
wechat_mini_program_virtual_payment_env: 0,
oss_bucket: None,
oss_endpoint: None,
@@ -613,6 +624,26 @@ impl AppConfig {
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
config.wechat_mini_program_message_encoding_aes_key =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]);
if let Some(enabled) =
read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"])
{
config.wechat_mini_program_subscribe_message_enabled = enabled;
}
if let Some(template_id) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"])
{
config.wechat_mini_program_generation_result_template_id = Some(template_id);
}
if let Some(endpoint) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"])
{
config.wechat_mini_program_subscribe_message_endpoint = endpoint;
}
if let Some(state) =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"])
{
config.wechat_mini_program_subscribe_message_state = state;
}
if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
&& env <= 1
{
@@ -1419,6 +1450,9 @@ mod tests {
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
std::env::set_var("WECHAT_PAY_ENABLED", "true");
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
@@ -1446,6 +1480,12 @@ mod tests {
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
);
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true");
std::env::set_var(
"WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID",
"tmpl-generation-result",
);
std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial");
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
}
@@ -1497,6 +1537,14 @@ mod tests {
.as_deref(),
Some("sandbox-app-key-001")
);
assert!(config.wechat_mini_program_subscribe_message_enabled);
assert_eq!(
config
.wechat_mini_program_generation_result_template_id
.as_deref(),
Some("tmpl-generation-result")
);
assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial");
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
unsafe {
@@ -1514,6 +1562,9 @@ mod tests {
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
}
}

View File

@@ -126,6 +126,44 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.and_then(|item| item.unified_creation_spec.as_ref())
.map(|spec| u64::from(spec.mud_point_cost))
.filter(|cost| *cost > 0)
.unwrap_or(fallback_cost)
}
pub(crate) async fn resolve_creation_entry_mud_point_cost(
state: &AppState,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
match state.get_creation_entry_config().await {
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
&config,
creation_type_id,
fallback_cost,
),
Err(error) => {
tracing::warn!(
creation_type_id,
fallback_cost,
error = %error,
"读取创作入口泥点成本失败,回退到代码默认值"
);
fallback_cost
}
}
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -170,6 +208,7 @@ pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigRespon
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
#[test]
fn resolves_new_creation_paths_to_creation_type_ids() {
@@ -258,6 +297,50 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
let spec = puzzle
.unified_creation_spec
.as_mut()
.expect("puzzle unified spec should exist");
spec.mud_point_cost = 8;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
8,
);
}
#[test]
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
puzzle.unified_creation_spec = None;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
2,
);
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(
&config,
"missing-play",
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
),
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();

View File

@@ -92,6 +92,7 @@ mod volcengine_speech;
mod wechat_auth;
mod wechat_pay;
mod wechat_provider;
mod wechat_subscribe_message;
mod wooden_fish;
mod work_author;
mod work_play_tracking;

View File

@@ -163,6 +163,12 @@ pub(super) async fn compile_match3d_draft_for_session(
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
"match3d",
MATCH3D_DRAFT_GENERATION_POINTS_COST,
)
.await;
let compile_session_id = session_id.clone();
let compile_owner_user_id = owner_user_id.clone();
let compile_profile_id = profile_id.clone();
@@ -175,6 +181,7 @@ pub(super) async fn compile_match3d_draft_for_session(
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
points_cost,
async {
let mut session = upsert_match3d_draft_snapshot(
state,
@@ -418,12 +425,13 @@ fn match3d_response_failure_message(response: &Response) -> String {
.unwrap_or_else(|| format!("抓大鹅草稿生成失败HTTP {}", response.status()))
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, Response>
where
@@ -434,6 +442,7 @@ where
request_context,
owner_user_id,
billing_asset_id,
points_cost,
)
.await?;
@@ -441,8 +450,13 @@ where
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
refund_match3d_draft_generation_points(
state,
owner_user_id,
billing_asset_id,
points_cost,
)
.await;
}
Err(response)
}
@@ -454,6 +468,7 @@ async fn consume_match3d_draft_generation_points(
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
@@ -463,7 +478,7 @@ async fn consume_match3d_draft_generation_points(
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
@@ -491,6 +506,7 @@ async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
@@ -500,7 +516,7 @@ async fn refund_match3d_draft_generation_points(
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)

View File

@@ -2,6 +2,7 @@ use axum::http::{HeaderValue, StatusCode};
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
use platform_llm::{LlmError, LlmErrorKind};
use platform_oss::{OssError, OssErrorKind};
use platform_wechat::{WechatError, WechatErrorKind};
use serde_json::json;
use crate::http_error::AppError;
@@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError {
AppError::from_status(status).with_message(error.to_string())
}
pub fn map_wechat_error(error: WechatError) -> AppError {
let status = match error.kind() {
WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE,
WechatErrorKind::RequestFailed
| WechatErrorKind::DeserializeFailed
| WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_message(error.to_string())
}
pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError {
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => error.with_header("retry-after", value),

View File

@@ -58,16 +58,15 @@ use spacetime_client::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
@@ -106,6 +105,10 @@ use crate::{
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
wechat_subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
};

View File

@@ -589,6 +589,7 @@ pub async fn execute_puzzle_agent_action(
let now = current_utc_micros();
let action = payload.action.trim().to_string();
let billing_asset_id = format!("{session_id}:{now}");
let mut operation_consumed_points = 0;
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
@@ -617,13 +618,14 @@ pub async fn execute_puzzle_agent_action(
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let failed_at_micros = current_utc_micros();
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
owner_user_id: owner_user_id.clone(),
error_message,
failed_at_micros: current_utc_micros(),
failed_at_micros,
})
.await;
if let Err(error) = result {
@@ -634,6 +636,19 @@ pub async fn execute_puzzle_agent_action(
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
} else {
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id,
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
}
};
@@ -641,6 +656,17 @@ pub async fn execute_puzzle_agent_action(
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let puzzle_draft_generation_points_cost = if ai_redraw {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state.root_state(),
"puzzle",
PUZZLE_IMAGE_GENERATION_POINTS_COST,
)
.await
} else {
0
};
operation_consumed_points = puzzle_draft_generation_points_cost;
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
@@ -677,10 +703,7 @@ pub async fn execute_puzzle_agent_action(
);
state
.spacetime_client()
.get_puzzle_agent_session(
compile_session_id.clone(),
owner_user_id.clone(),
)
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
.await
.map(mark_puzzle_initial_generation_started_snapshot)
.map_err(map_puzzle_client_error)
@@ -696,10 +719,9 @@ pub async fn execute_puzzle_agent_action(
.map_err(map_puzzle_compile_error);
match compiled_session {
Ok(compiled_session) => {
let response_session =
mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let response_session = mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let background_state = state.clone();
let background_request_context = request_context.clone();
let background_session_id = compile_session_id.clone();
@@ -708,20 +730,23 @@ pub async fn execute_puzzle_agent_action(
let background_reference_image_src =
primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone();
let background_points_cost = puzzle_draft_generation_points_cost;
let background_work_name = compiled_session
.draft
.as_ref()
.map(|draft| draft.work_title.clone());
let background_billing_asset_id =
format!("{background_session_id}:compile_puzzle_draft");
tokio::spawn(async move {
let operation_owner_user_id =
background_owner_user_id.clone();
let background_root_state =
background_state.root_state().clone();
let operation_owner_user_id = background_owner_user_id.clone();
let background_root_state = background_state.root_state().clone();
let operation_state = background_state.clone();
let result = execute_billable_asset_operation_with_cost(
&background_root_state,
&background_owner_user_id,
"puzzle_initial_image",
&background_billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
background_points_cost,
async move {
generate_puzzle_initial_cover_from_compiled_session(
&operation_state,
@@ -739,6 +764,22 @@ pub async fn execute_puzzle_agent_action(
.await;
match result {
Ok(session) => {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
work_name: session
.draft
.as_ref()
.map(|draft| draft.work_title.clone()),
status:
GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: background_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
@@ -748,15 +789,15 @@ pub async fn execute_puzzle_agent_action(
}
Err(error) => {
let error_message = error.body_text();
let failed_at_micros = current_utc_micros();
let failure_result = background_state
.spacetime_client()
.mark_puzzle_draft_generation_failed(
PuzzleDraftCompileFailureRecordInput {
session_id: background_session_id.clone(),
owner_user_id: background_owner_user_id
.clone(),
owner_user_id: background_owner_user_id.clone(),
error_message: error_message.clone(),
failed_at_micros: current_utc_micros(),
failed_at_micros,
},
)
.await;
@@ -768,6 +809,20 @@ pub async fn execute_puzzle_agent_action(
message = %mark_error,
"拼图首图后台生成失败态回写失败"
);
} else {
send_generation_result_subscribe_message_after_completion(
&background_root_state,
GenerationResultSubscribeMessage {
owner_user_id: background_owner_user_id.clone(),
work_name: background_work_name.clone(),
status:
GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: failed_at_micros,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -778,9 +833,7 @@ pub async fn execute_puzzle_agent_action(
);
}
}
unregister_puzzle_background_compile_task(
&background_session_id,
);
unregister_puzzle_background_compile_task(&background_session_id);
});
Ok(response_session)
}
@@ -1428,6 +1481,25 @@ pub async fn execute_puzzle_agent_action(
};
let session = session?;
if operation_type == "compile_puzzle_draft"
&& session
.draft
.as_ref()
.is_some_and(|draft| draft.generation_status == "ready")
{
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: owner_user_id.clone(),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: operation_consumed_points,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(json_success_body(
Some(&request_context),

View File

@@ -10,12 +10,12 @@ use std::{
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
@@ -27,6 +27,7 @@ use platform_auth::{
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
@@ -251,6 +252,7 @@ pub struct AppStateInner {
wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider,
wechat_client: WechatClient,
wechat_pay_client: WechatPayClient,
#[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService,
@@ -385,6 +387,7 @@ impl AppState {
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
let wechat_provider = build_wechat_provider(&config);
let wechat_client = build_wechat_client(&config);
let wechat_pay_client =
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
let refresh_session_service =
@@ -424,6 +427,7 @@ impl AppState {
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
wechat_client,
wechat_pay_client,
ai_task_service,
spacetime_client,
@@ -776,6 +780,10 @@ impl AppState {
&self.wechat_provider
}
pub fn wechat_client(&self) -> &WechatClient {
&self.wechat_client
}
pub fn wechat_pay_client(&self) -> &WechatPayClient {
&self.wechat_pay_client
}
@@ -1333,6 +1341,17 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
Ok(Some(OssClient::new(oss_config)))
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
app_secret: config.wechat_mini_program_app_secret.clone(),
stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(),
subscribe_message_endpoint: config
.wechat_mini_program_subscribe_message_endpoint
.clone(),
})
}
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config
.llm_api_key

View File

@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use axum::http::StatusCode;
use platform_wechat::WechatSubscribeMessageRequest;
use time::{OffsetDateTime, UtcOffset};
use tracing::{info, warn};
use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState};
const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成";
const DEFAULT_WORK_NAME: &str = "AI创作作品";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GenerationResultSubscribeMessageStatus {
Succeeded,
Failed,
}
#[derive(Clone, Debug)]
pub struct GenerationResultSubscribeMessage {
pub owner_user_id: String,
pub work_name: Option<String>,
pub status: GenerationResultSubscribeMessageStatus,
pub consumed_points: u64,
pub completed_at_micros: i64,
pub page: Option<String>,
}
pub async fn send_generation_result_subscribe_message_after_completion(
state: &AppState,
message: GenerationResultSubscribeMessage,
) {
if let Err(error) = send_generation_result_subscribe_message(state, message).await {
warn!(
error = %error,
"微信小程序生成结果订阅消息发送失败,已忽略"
);
}
}
async fn send_generation_result_subscribe_message(
state: &AppState,
message: GenerationResultSubscribeMessage,
) -> Result<(), AppError> {
if !state.config.wechat_mini_program_subscribe_message_enabled {
return Ok(());
}
let template_id = state
.config
.wechat_mini_program_generation_result_template_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信订阅消息模板 ID 未配置")
})?;
let user = state
.auth_user_service()
.get_user_by_id(&message.owner_user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信订阅消息用户失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在")
})?;
let openid = user
.wechat_account
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("用户未绑定微信小程序 openid")
})?;
state
.wechat_client()
.send_subscribe_message(WechatSubscribeMessageRequest {
touser: openid.to_string(),
template_id: template_id.to_string(),
page: message
.page
.clone()
.or_else(|| Some("/pages/web-view/index".to_string())),
miniprogram_state: Some(
normalize_miniprogram_state(
&state.config.wechat_mini_program_subscribe_message_state,
)
.to_string(),
),
lang: Some("zh_CN".to_string()),
data: build_generation_result_template_data(&message),
})
.await
.map_err(map_wechat_error)?;
info!(
owner_user_id = %message.owner_user_id,
template_id,
"微信小程序生成结果订阅消息已发送"
);
Ok(())
}
fn build_generation_result_template_data(
message: &GenerationResultSubscribeMessage,
) -> BTreeMap<String, String> {
BTreeMap::from([
(
"thing1".to_string(),
truncate_template_value(GENERATION_RESULT_TASK_NAME, 20),
),
(
"phrase2".to_string(),
truncate_template_value(message.status.template_status_label(), 5),
),
(
"time4".to_string(),
truncate_template_value(
&format_generation_completed_time(message.completed_at_micros),
20,
),
),
(
"thing5".to_string(),
truncate_template_value(
message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME),
20,
),
),
(
"number6".to_string(),
truncate_template_value(&message.consumed_points.to_string(), 32),
),
])
}
impl GenerationResultSubscribeMessageStatus {
fn template_status_label(self) -> &'static str {
match self {
Self::Succeeded => "已完成",
Self::Failed => "生成失败",
}
}
}
fn truncate_template_value(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
let mut result = String::new();
for character in trimmed.chars().take(max_chars) {
result.push(character);
}
if result.is_empty() {
DEFAULT_WORK_NAME.to_string()
} else {
result
}
}
fn format_generation_completed_time(completed_at_micros: i64) -> String {
let seconds = completed_at_micros.div_euclid(1_000_000);
let Ok(utc_time) = OffsetDateTime::from_unix_timestamp(seconds) else {
return "1970-01-01 08:00".to_string();
};
let beijing_offset = UtcOffset::from_hms(8, 0, 0).unwrap_or(UtcOffset::UTC);
let local_time = utc_time.to_offset(beijing_offset);
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
local_time.year(),
u8::from(local_time.month()),
local_time.day(),
local_time.hour(),
local_time.minute()
)
}
fn normalize_miniprogram_state(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"developer" | "develop" | "dev" => "developer",
"trial" => "trial",
_ => "formal",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_generation_result_template_uses_failed_status_and_zero_points() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
work_name: Some("首关拼图".to_string()),
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: 1_762_000_000_000_000,
page: None,
});
assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败"));
assert_eq!(data.get("number6").map(String::as_str), Some("0"));
}
#[test]
fn generation_result_template_time_uses_wechat_time_format() {
let data = build_generation_result_template_data(&GenerationResultSubscribeMessage {
owner_user_id: "user-1".to_string(),
work_name: Some("首关拼图".to_string()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: 15,
completed_at_micros: 0,
page: None,
});
assert_eq!(
data.get("time4").map(String::as_str),
Some("1970-01-01 08:00")
);
}
}