实现网站游戏评分与评价 (#561)
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s

公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #561.
This commit is contained in:
2026-10-01 19:19:58 +08:00
committed by 孔令弘
parent 44f415d057
commit b60aea4e71
81 changed files with 8594 additions and 60 deletions
+162 -1
View File
@@ -10,7 +10,7 @@ use crate::creation_entry_config::{
};
/// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。
pub const ADMIN_TAB_PERMISSIONS: [&str; 20] = [
pub const ADMIN_TAB_PERMISSIONS: [&str; 21] = [
"dashboard",
"overview",
"tables",
@@ -27,12 +27,124 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 20] = [
"editor-generation-pricing",
"editor-showcase",
"game-management",
"game-reviews",
"editor-assets",
"agc-templates",
"error-reports",
"project-snapshots",
];
/// 后台游戏评价的游戏选择查询,覆盖全部游戏可见状态。
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewGamesQuery {
pub query: Option<String>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewGame {
pub game_id: String,
pub title: String,
pub status: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewGamesResponse {
pub games: Vec<AdminGameReviewGame>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub total_pages: u64,
}
/// 用户 ID 精确匹配;关键词与其他条件由后端按 AND 组合筛选。
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewsQuery {
pub game_id: Option<String>,
pub user_id: Option<String>,
pub keyword: Option<String>,
pub status: Option<String>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewGameInfo {
pub title: String,
pub status: String,
}
/// 后台完整评价;管理原因只保存在后台操作记录,不进入普通用户投影。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReview {
pub id: String,
pub game_id: String,
pub game: AdminGameReviewGameInfo,
pub author: crate::game_distribution::GameDistributionAuthor,
pub score: i64,
pub comment: String,
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
/// 管理操作记录不复制评论正文;删除后依旧保留目标身份与原创建时间。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewOperation {
pub id: String,
pub review_id: String,
pub game_id: String,
pub user_id: String,
pub review_created_at: String,
pub action: String,
pub admin_user_id: String,
pub reason: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewsResponse {
pub reviews: Vec<AdminGameReview>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub total_pages: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewDetailResponse {
pub review: AdminGameReview,
pub operations: Vec<AdminGameReviewOperation>,
}
/// 原创建时间保护删除后重新评价的实例;管理员身份来自会话。
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewModerationRequest {
pub action: String,
pub expected_created_at: String,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminGameReviewModerationResponse {
pub review: Option<AdminGameReview>,
pub operation: AdminGameReviewOperation,
pub replayed: bool,
}
/// 私有工程快照的后台分页查询。
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1298,6 +1410,55 @@ mod tests {
AdminUpdateAgcTemplateRequest,
};
#[test]
fn game_review_contract_uses_camel_case_and_nullable_operation_result() {
use super::{
AdminGameReviewModerationRequest, AdminGameReviewModerationResponse,
AdminGameReviewOperation,
};
let request: AdminGameReviewModerationRequest = serde_json::from_value(json!({
"action":"restore", "expectedCreatedAt":"2026-10-01T00:00:00Z",
}))
.unwrap();
assert!(request.reason.is_none());
assert_eq!(
serde_json::to_value(request).unwrap()["expectedCreatedAt"],
"2026-10-01T00:00:00Z"
);
for value in [
json!({"action":1,"expectedCreatedAt":"2026-10-01T00:00:00Z"}),
json!({"action":"restore","expectedCreatedAt":1}),
json!({"action":"hide","expectedCreatedAt":"2026-10-01T00:00:00Z","reason":123}),
] {
assert!(serde_json::from_value::<AdminGameReviewModerationRequest>(value).is_err());
}
let response = serde_json::to_value(AdminGameReviewModerationResponse {
review: None,
replayed: true,
operation: AdminGameReviewOperation {
id: "operation-1".into(),
review_id: "review-1".into(),
game_id: "game-1".into(),
user_id: "user-1".into(),
review_created_at: "2026-10-01T00:00:00Z".into(),
action: "restore".into(),
admin_user_id: "admin-1".into(),
reason: None,
created_at: "2026-10-01T00:00:01Z".into(),
},
})
.unwrap();
assert!(response["review"].is_null());
assert!(response["operation"]["reason"].is_null());
assert_eq!(
response["operation"]["reviewCreatedAt"],
"2026-10-01T00:00:00Z"
);
assert_eq!(response["operation"]["adminUserId"], "admin-1");
assert!(response["operation"].get("comment").is_none());
assert!(ADMIN_TAB_PERMISSIONS.contains(&"game-reviews"));
}
#[test]
fn agc_template_snapshot_uses_camel_case_and_assignable_tab_permission() {
let value = serde_json::to_value(AdminAgcTemplateListResponse {
@@ -90,6 +90,59 @@ pub struct GameDistributionAuthor {
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionReview {
pub id: String,
pub game_id: String,
pub author: GameDistributionAuthor,
pub score: i64,
pub comment: String,
#[serde(default)]
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionRatingSummary {
pub average_score: Option<f64>,
pub rating_count: u64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionReviewsResponse {
pub reviews: Vec<GameDistributionReview>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub total_pages: u64,
pub rating_summary: GameDistributionRatingSummary,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionMyReviewResponse {
pub review: Option<GameDistributionReview>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSaveReviewRequest {
pub score: i64,
#[serde(default)]
pub comment: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSaveReviewResponse {
pub review: GameDistributionReview,
pub rating_summary: GameDistributionRatingSummary,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionVersionSummary {
@@ -102,7 +155,7 @@ pub struct GameDistributionVersionSummary {
pub controls: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionGameSummary {
pub id: String,
@@ -131,9 +184,12 @@ pub struct GameDistributionGameSummary {
pub current_version: Option<GameDistributionVersionSummary>,
pub play_count: u64,
pub created_at: String,
/// 公开列表/详情保证返回;作者侧及旧响应可省略。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_summary: Option<GameDistributionRatingSummary>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionListResponse {
pub games: Vec<GameDistributionGameSummary>,
@@ -196,6 +252,34 @@ pub struct GameDistributionPrivateVersion {
mod tests {
use super::*;
#[test]
fn save_review_request_requires_integer_score_and_defaults_comment() {
let request: GameDistributionSaveReviewRequest =
serde_json::from_value(serde_json::json!({"score": 10})).unwrap();
assert_eq!(request.comment, "");
for payload in [
serde_json::json!({}),
serde_json::json!({"score": 8.5}),
serde_json::json!({"score": "8"}),
serde_json::json!({"score": 8, "comment": null}),
serde_json::json!({"score": 8, "comment": 123}),
] {
assert!(serde_json::from_value::<GameDistributionSaveReviewRequest>(payload).is_err());
}
let response =
serde_json::to_value(GameDistributionMyReviewResponse { review: None }).unwrap();
assert_eq!(response, serde_json::json!({"review": null}));
let summary = serde_json::to_value(GameDistributionRatingSummary {
average_score: None,
rating_count: 0,
})
.unwrap();
assert_eq!(
summary,
serde_json::json!({"averageScore": null, "ratingCount": 0})
);
}
#[test]
fn create_game_request_round_trips_local_project_id() {
let payload: GameDistributionCreateGameRequest =