Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use module_puzzle::PuzzleGeneratedImageCandidate;
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -36,11 +36,10 @@ use shared_contracts::{
},
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -52,14 +51,13 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -1100,36 +1098,6 @@ pub async fn advance_puzzle_next_level(
))
}
pub async fn advance_local_puzzle_next_level(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<AdvanceLocalPuzzleNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str())
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn submit_puzzle_leaderboard(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
@@ -1385,98 +1353,6 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
}
}
fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord {
PuzzleRunRecord {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_level_request_record(
level: PuzzleRuntimeLevelSnapshotResponse,
) -> PuzzleRuntimeLevelRecord {
PuzzleRuntimeLevelRecord {
run_id: level.run_id,
level_index: level.level_index,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
board: map_puzzle_board_request_record(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_leaderboard_request_record(
entry: PuzzleLeaderboardEntryResponse,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
@@ -1842,343 +1718,6 @@ async fn generate_puzzle_image_candidates(
.collect())
}
async fn build_local_next_puzzle_run(
state: &AppState,
payload: AdvanceLocalPuzzleNextLevelRequest,
owner_user_id: &str,
) -> Result<PuzzleRunRecord, AppError> {
let run = map_puzzle_run_request_record(payload.run);
let current_level = run.current_level.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "currentLevel is required",
}))
})?;
if current_level.status != "cleared" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})),
);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
return Ok(build_next_run_from_puzzle_work(run, gallery_item));
}
let source_session_id = payload.source_session_id.unwrap_or_default();
if source_session_id.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
}
let session = state
.spacetime_client()
.get_puzzle_agent_session(source_session_id, owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
if let Some(candidate) = session
.draft
.as_ref()
.and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids))
{
return Ok(build_next_run_from_candidate(run, &session, candidate));
}
let draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "puzzle draft is required when gallery has no next puzzle work",
}))
})?;
let candidates = generate_puzzle_image_candidates(
state,
owner_user_id,
&session.session_id,
&draft.level_name,
&draft.summary,
None,
1,
draft.candidates.len(),
)
.await
.map_err(|message| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let updated_session = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.to_string(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)?;
let candidate = updated_session
.draft
.as_ref()
.and_then(|draft| {
draft
.candidates
.iter()
.find(|candidate| !candidate.image_src.is_empty())
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "现场生成后没有可用候选图",
}))
})?;
Ok(build_next_run_from_candidate(
run,
&updated_session,
candidate,
))
}
async fn resolve_gallery_next_puzzle_work(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
let items = state
.spacetime_client()
.list_puzzle_gallery()
.await
.map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| {
item.publication_status == "published"
&& item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id)
}))
}
fn pick_unused_puzzle_candidate<'a>(
candidates: &'a [PuzzleGeneratedImageCandidateRecord],
played_profile_ids: &[String],
) -> Option<&'a PuzzleGeneratedImageCandidateRecord> {
candidates.iter().find(|candidate| {
!candidate.image_src.is_empty()
&& !played_profile_ids
.iter()
.any(|profile_id| profile_id.contains(&candidate.candidate_id))
})
}
fn build_next_run_from_puzzle_work(
run: PuzzleRunRecord,
item: PuzzleWorkProfileRecord,
) -> PuzzleRunRecord {
build_next_run_from_parts(
run,
item.profile_id,
item.level_name,
item.author_display_name,
item.theme_tags,
item.cover_image_src,
)
}
fn build_next_run_from_candidate(
run: PuzzleRunRecord,
session: &PuzzleAgentSessionRecord,
candidate: &PuzzleGeneratedImageCandidateRecord,
) -> PuzzleRunRecord {
let draft = session.draft.as_ref();
let level_index = run.current_level_index + 1;
build_next_run_from_parts(
run,
format!(
"{}-{}-level-{}",
session.session_id, candidate.candidate_id, level_index
),
draft
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".to_string(),
draft
.map(|draft| draft.theme_tags.clone())
.unwrap_or_default(),
Some(candidate.image_src.clone()),
)
}
fn build_next_run_from_parts(
run: PuzzleRunRecord,
profile_id: String,
level_name: String,
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
PuzzleRunRecord {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: next_level_index,
current_grid_size: grid_size,
played_profile_ids,
previous_level_tags: theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelRecord {
run_id: run.run_id,
level_index: next_level_index,
grid_size,
profile_id,
level_name,
author_display_name,
theme_tags,
cover_image_src,
board,
status: "playing".to_string(),
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
}
}
fn build_local_puzzle_board(
grid_size: u32,
run_id: &str,
profile_id: &str,
level_index: u32,
) -> PuzzleBoardRecord {
let board = module_puzzle::build_initial_board_with_seed(
grid_size,
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
)
.unwrap_or_else(|_| {
module_puzzle::build_initial_board_with_seed(3, 1)
.expect("fallback puzzle board should use supported grid size")
});
map_puzzle_board_snapshot_record(board)
}
fn build_local_puzzle_shuffle_seed(
run_id: &str,
profile_id: &str,
level_index: u32,
grid_size: u32,
) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
board
.pieces
.iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect()
}
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
board.pieces.iter().any(|piece| {
board.pieces.iter().any(|candidate| {
piece.piece_id != candidate.piece_id
&& piece.current_row.abs_diff(candidate.current_row)
+ piece.current_col.abs_diff(candidate.current_col)
== 1
&& piece.correct_row.abs_diff(candidate.correct_row)
+ piece.correct_col.abs_diff(candidate.correct_col)
== 1
})
})
}
#[test]
fn local_next_level_board_shuffle_changes_by_level() {
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
assert_ne!(board_positions(&second), board_positions(&third));
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
}
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,