Files
Genarrative/server-rs/crates/spacetime-module/src/admin_account_storage.rs
T
kdletters 271d5d7bd3
Project CI / Repository checks (push) Successful in 59s
Project CI / Frontend tests (push) Successful in 1m58s
Project CI / Native shell tests (push) Successful in 2m28s
Project CI / Backend tests (push) Successful in 3m5s
持久化用户历史消费泥点投影
后台用户详情展示历史花费并保留手动对账
消费落账原子更新投影并提供存量初始化
为手动对账增加独立管理员操作权限
更新SpacetimeDB绑定、文档和测试
2026-07-28 16:20:43 +08:00

687 lines
22 KiB
Rust

use std::collections::HashSet;
use crate::*;
use shared_contracts::admin::{ADMIN_ACTION_PERMISSIONS, ADMIN_TAB_PERMISSIONS};
const ADMIN_ACCOUNT_MAX_ID_CHARS: usize = 128;
const ADMIN_ACCOUNT_MAX_USERNAME_CHARS: usize = 64;
const ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS: usize = 64;
const ADMIN_ACCOUNT_MAX_ACTOR_CHARS: usize = 128;
const ADMIN_ACCOUNT_MAX_PASSWORD_HASH_CHARS: usize = 512;
#[spacetimedb::table(accessor = admin_account)]
#[derive(Clone)]
pub struct AdminAccount {
#[primary_key]
pub account_id: String,
#[unique]
pub username: String,
pub display_name: String,
pub password_hash: String,
pub tab_permissions_json: String,
pub enabled: bool,
pub token_version: u64,
pub created_by: String,
pub updated_by: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
#[default(None::<String>)]
pub action_permissions_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountGetByUsernameInput {
pub username: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountGetByIdInput {
pub account_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountCreateInput {
pub account_id: String,
pub username: String,
pub display_name: String,
pub password_hash: String,
pub tab_permissions_json: String,
pub enabled: bool,
pub created_by: String,
pub action_permissions_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountUpdateInput {
pub account_id: String,
pub display_name: String,
pub password_hash: Option<String>,
pub tab_permissions_json: String,
pub enabled: bool,
pub updated_by: String,
pub action_permissions_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountSnapshot {
pub account_id: String,
pub username: String,
pub display_name: String,
pub tab_permissions_json: String,
pub enabled: bool,
pub token_version: u64,
pub created_by: String,
pub updated_by: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub action_permissions_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountCredentialSnapshot {
pub account: AdminAccountSnapshot,
pub password_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountProcedureResult {
pub ok: bool,
pub account: Option<AdminAccountSnapshot>,
pub accounts: Vec<AdminAccountSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AdminAccountCredentialProcedureResult {
pub ok: bool,
pub account: Option<AdminAccountCredentialSnapshot>,
pub error_message: Option<String>,
}
#[spacetimedb::procedure]
pub fn get_admin_account_by_username_and_return(
ctx: &mut ProcedureContext,
input: AdminAccountGetByUsernameInput,
) -> AdminAccountCredentialProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_admin_account_service_identity(tx, caller)?;
get_admin_account_by_username(tx, input.clone())
}) {
Ok(account) => admin_account_credential_ok(account),
Err(message) => admin_account_credential_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_admin_account_by_id_and_return(
ctx: &mut ProcedureContext,
input: AdminAccountGetByIdInput,
) -> AdminAccountProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_admin_account_service_identity(tx, caller)?;
get_admin_account_by_id(tx, input.clone())
}) {
Ok(account) => admin_account_single_ok(account),
Err(message) => admin_account_error(message),
}
}
#[spacetimedb::procedure]
pub fn list_admin_accounts_and_return(ctx: &mut ProcedureContext) -> AdminAccountProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_admin_account_service_identity(tx, caller)?;
Ok(list_admin_accounts(tx))
}) {
Ok(accounts) => admin_account_list_ok(accounts),
Err(message) => admin_account_error(message),
}
}
#[spacetimedb::procedure]
pub fn create_admin_account_and_return(
ctx: &mut ProcedureContext,
input: AdminAccountCreateInput,
) -> AdminAccountProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_admin_account_service_identity(tx, caller)?;
create_admin_account(tx, input.clone())
}) {
Ok(account) => admin_account_single_ok(account),
Err(message) => admin_account_error(message),
}
}
#[spacetimedb::procedure]
pub fn update_admin_account_and_return(
ctx: &mut ProcedureContext,
input: AdminAccountUpdateInput,
) -> AdminAccountProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_admin_account_service_identity(tx, caller)?;
update_admin_account(tx, input.clone())
}) {
Ok(account) => admin_account_single_ok(account),
Err(message) => admin_account_error(message),
}
}
fn require_admin_account_service_identity(
ctx: &ReducerContext,
caller: Identity,
) -> Result<(), String> {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(ctx, caller)
}
fn get_admin_account_by_username(
ctx: &ReducerContext,
input: AdminAccountGetByUsernameInput,
) -> Result<AdminAccountCredentialSnapshot, String> {
let username = normalize_username(&input.username)?;
ctx.db
.admin_account()
.username()
.find(&username)
.map(admin_account_credential_snapshot_from_row)
.ok_or_else(|| "后台账号不存在".to_string())
}
fn get_admin_account_by_id(
ctx: &ReducerContext,
input: AdminAccountGetByIdInput,
) -> Result<AdminAccountSnapshot, String> {
let account_id = normalize_required(
&input.account_id,
"admin_account.account_id",
ADMIN_ACCOUNT_MAX_ID_CHARS,
)?;
ctx.db
.admin_account()
.account_id()
.find(&account_id)
.map(admin_account_snapshot_from_row)
.ok_or_else(|| "后台账号不存在".to_string())
}
fn list_admin_accounts(ctx: &ReducerContext) -> Vec<AdminAccountSnapshot> {
let mut accounts = ctx
.db
.admin_account()
.iter()
.map(admin_account_snapshot_from_row)
.collect::<Vec<_>>();
accounts.sort_by(|left, right| {
left.username
.cmp(&right.username)
.then_with(|| left.account_id.cmp(&right.account_id))
});
accounts
}
fn create_admin_account(
ctx: &ReducerContext,
input: AdminAccountCreateInput,
) -> Result<AdminAccountSnapshot, String> {
let account_id = normalize_required(
&input.account_id,
"admin_account.account_id",
ADMIN_ACCOUNT_MAX_ID_CHARS,
)?;
let username = normalize_username(&input.username)?;
ensure_admin_account_available(
ctx.db
.admin_account()
.account_id()
.find(&account_id)
.is_some(),
ctx.db.admin_account().username().find(&username).is_some(),
)?;
let display_name = normalize_required(
&input.display_name,
"admin_account.display_name",
ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS,
)?;
let password_hash = normalize_password_hash(&input.password_hash)?;
let tab_permissions_json = normalize_tab_permissions_json(&input.tab_permissions_json)?;
let action_permissions_json =
normalize_action_permissions_json(&input.action_permissions_json)?;
let created_by = normalize_required(
&input.created_by,
"admin_account.created_by",
ADMIN_ACCOUNT_MAX_ACTOR_CHARS,
)?;
ctx.db.admin_account().try_insert(AdminAccount {
account_id: account_id.clone(),
username,
display_name,
password_hash,
tab_permissions_json,
enabled: input.enabled,
token_version: 1,
created_by: created_by.clone(),
updated_by: created_by,
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
action_permissions_json: Some(action_permissions_json),
})?;
ctx.db
.admin_account()
.account_id()
.find(&account_id)
.map(admin_account_snapshot_from_row)
.ok_or_else(|| "后台账号创建失败".to_string())
}
fn update_admin_account(
ctx: &ReducerContext,
input: AdminAccountUpdateInput,
) -> Result<AdminAccountSnapshot, String> {
let account_id = normalize_required(
&input.account_id,
"admin_account.account_id",
ADMIN_ACCOUNT_MAX_ID_CHARS,
)?;
let row = ctx
.db
.admin_account()
.account_id()
.find(&account_id)
.ok_or_else(|| "后台账号不存在".to_string())?;
let display_name = normalize_required(
&input.display_name,
"admin_account.display_name",
ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS,
)?;
let password_hash = input
.password_hash
.as_deref()
.map(normalize_password_hash)
.transpose()?;
let tab_permissions_json = normalize_tab_permissions_json(&input.tab_permissions_json)?;
let action_permissions_json =
normalize_action_permissions_json(&input.action_permissions_json)?;
let updated_by = normalize_required(
&input.updated_by,
"admin_account.updated_by",
ADMIN_ACCOUNT_MAX_ACTOR_CHARS,
)?;
let updated = build_updated_admin_account(
row,
display_name,
password_hash,
tab_permissions_json,
action_permissions_json,
input.enabled,
updated_by,
ctx.timestamp,
)?;
ctx.db.admin_account().account_id().update(updated);
ctx.db
.admin_account()
.account_id()
.find(&account_id)
.map(admin_account_snapshot_from_row)
.ok_or_else(|| "后台账号更新失败".to_string())
}
fn build_updated_admin_account(
row: AdminAccount,
display_name: String,
password_hash: Option<String>,
tab_permissions_json: String,
action_permissions_json: String,
enabled: bool,
updated_by: String,
updated_at: Timestamp,
) -> Result<AdminAccount, String> {
let invalidates_session = password_hash.is_some()
|| row.tab_permissions_json != tab_permissions_json
|| row.action_permissions_json.as_deref().unwrap_or("[]") != action_permissions_json
|| row.enabled != enabled;
let token_version = if invalidates_session {
row.token_version
.checked_add(1)
.ok_or_else(|| "后台账号 token_version 已达上限".to_string())?
} else {
row.token_version
};
Ok(AdminAccount {
display_name,
password_hash: password_hash.unwrap_or(row.password_hash),
tab_permissions_json,
action_permissions_json: Some(action_permissions_json),
enabled,
token_version,
updated_by,
updated_at,
..row
})
}
fn admin_account_snapshot_from_row(row: AdminAccount) -> AdminAccountSnapshot {
AdminAccountSnapshot {
account_id: row.account_id,
username: row.username,
display_name: row.display_name,
tab_permissions_json: row.tab_permissions_json,
enabled: row.enabled,
token_version: row.token_version,
created_by: row.created_by,
updated_by: row.updated_by,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
action_permissions_json: row
.action_permissions_json
.unwrap_or_else(|| "[]".to_string()),
}
}
fn admin_account_credential_snapshot_from_row(row: AdminAccount) -> AdminAccountCredentialSnapshot {
AdminAccountCredentialSnapshot {
password_hash: row.password_hash.clone(),
account: admin_account_snapshot_from_row(row),
}
}
fn normalize_username(value: &str) -> Result<String, String> {
normalize_required(
value,
"admin_account.username",
ADMIN_ACCOUNT_MAX_USERNAME_CHARS,
)
.map(|value| value.to_ascii_lowercase())
}
fn normalize_password_hash(value: &str) -> Result<String, String> {
normalize_required(
value,
"admin_account.password_hash",
ADMIN_ACCOUNT_MAX_PASSWORD_HASH_CHARS,
)
}
fn normalize_required(value: &str, field: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("{field} 不能为空"));
}
if normalized.chars().count() > max_chars {
return Err(format!("{field} 长度不能超过 {max_chars} 个字符"));
}
Ok(normalized.to_string())
}
fn normalize_tab_permissions_json(value: &str) -> Result<String, String> {
normalize_permissions_json(
value,
"admin_account.tab_permissions_json",
"后台 Tab",
&ADMIN_TAB_PERMISSIONS,
)
}
fn normalize_action_permissions_json(value: &str) -> Result<String, String> {
normalize_permissions_json(
value,
"admin_account.action_permissions_json",
"后台操作",
&ADMIN_ACTION_PERMISSIONS,
)
}
fn normalize_permissions_json(
value: &str,
field_name: &str,
permission_kind: &str,
allowed_permissions: &[&str],
) -> Result<String, String> {
let permissions = serde_json::from_str::<Vec<String>>(value.trim())
.map_err(|_| format!("{field_name} 必须是字符串数组 JSON"))?;
let requested = permissions
.into_iter()
.map(|permission| permission.trim().to_string())
.collect::<HashSet<_>>();
if let Some(permission) = requested
.iter()
.find(|permission| !allowed_permissions.contains(&permission.as_str()))
{
return Err(format!("未知{permission_kind}权限: {permission}"));
}
let normalized = allowed_permissions
.iter()
.filter(|permission| requested.contains(**permission))
.copied()
.collect::<Vec<_>>();
serde_json::to_string(&normalized).map_err(|_| format!("{permission_kind}权限序列化失败"))
}
fn ensure_admin_account_available(
account_id_exists: bool,
username_exists: bool,
) -> Result<(), String> {
if account_id_exists {
return Err("后台账号 ID 已存在".to_string());
}
if username_exists {
return Err("后台账号用户名已存在".to_string());
}
Ok(())
}
fn admin_account_single_ok(account: AdminAccountSnapshot) -> AdminAccountProcedureResult {
AdminAccountProcedureResult {
ok: true,
account: Some(account),
accounts: Vec::new(),
error_message: None,
}
}
fn admin_account_credential_ok(
account: AdminAccountCredentialSnapshot,
) -> AdminAccountCredentialProcedureResult {
AdminAccountCredentialProcedureResult {
ok: true,
account: Some(account),
error_message: None,
}
}
fn admin_account_list_ok(accounts: Vec<AdminAccountSnapshot>) -> AdminAccountProcedureResult {
AdminAccountProcedureResult {
ok: true,
account: None,
accounts,
error_message: None,
}
}
fn admin_account_error(message: String) -> AdminAccountProcedureResult {
AdminAccountProcedureResult {
ok: false,
account: None,
accounts: Vec::new(),
error_message: Some(message),
}
}
fn admin_account_credential_error(message: String) -> AdminAccountCredentialProcedureResult {
AdminAccountCredentialProcedureResult {
ok: false,
account: None,
error_message: Some(message),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_permissions_are_deduplicated_in_stable_route_order() {
let normalized = normalize_tab_permissions_json(
r#"["editor-assets","dashboard","dashboard","tracking"]"#,
)
.expect("permissions should normalize");
assert_eq!(normalized, r#"["dashboard","tracking","editor-assets"]"#);
}
#[test]
fn unknown_tab_permission_is_rejected() {
let error = normalize_tab_permissions_json(r#"["dashboard","accounts"]"#)
.expect_err("owner-only accounts permission must be rejected");
assert!(error.contains("accounts"));
}
#[test]
fn action_permissions_are_independent_from_tab_permissions() {
assert_eq!(
normalize_action_permissions_json(
r#"["profile-wallet-consumption-reconcile","profile-wallet-consumption-reconcile"]"#,
)
.expect("action permission should normalize"),
r#"["profile-wallet-consumption-reconcile"]"#
);
assert!(normalize_action_permissions_json(r#"["recharge-orders"]"#).is_err());
}
#[test]
fn duplicate_username_is_rejected() {
let error = ensure_admin_account_available(false, true)
.expect_err("duplicate username must be rejected");
assert_eq!(error, "后台账号用户名已存在");
}
#[test]
fn update_state_increments_token_version_and_preserves_username() {
let row = AdminAccount {
account_id: "member-1".to_string(),
username: "operator".to_string(),
display_name: "运营".to_string(),
password_hash: "$argon2id$old".to_string(),
tab_permissions_json: r#"["dashboard"]"#.to_string(),
enabled: true,
token_version: 7,
created_by: "owner".to_string(),
updated_by: "owner".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(10),
updated_at: Timestamp::from_micros_since_unix_epoch(10),
action_permissions_json: None,
};
let updated = build_updated_admin_account(
row,
"禁用运营".to_string(),
None,
r#"["dashboard"]"#.to_string(),
"[]".to_string(),
false,
"owner".to_string(),
Timestamp::from_micros_since_unix_epoch(20),
)
.expect("account should update");
assert_eq!(updated.username, "operator");
assert_eq!(updated.token_version, 8);
assert!(!updated.enabled);
}
#[test]
fn display_name_only_update_keeps_token_version() {
let row = AdminAccount {
account_id: "member-1".to_string(),
username: "operator".to_string(),
display_name: "运营".to_string(),
password_hash: "$argon2id$old".to_string(),
tab_permissions_json: r#"["dashboard"]"#.to_string(),
enabled: true,
token_version: 7,
created_by: "owner".to_string(),
updated_by: "owner".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(10),
updated_at: Timestamp::from_micros_since_unix_epoch(10),
action_permissions_json: None,
};
let updated = build_updated_admin_account(
row,
"运营二组".to_string(),
None,
r#"["dashboard"]"#.to_string(),
"[]".to_string(),
true,
"owner".to_string(),
Timestamp::from_micros_since_unix_epoch(20),
)
.expect("display name should update");
assert_eq!(updated.display_name, "运营二组");
assert_eq!(updated.token_version, 7);
}
#[test]
fn action_permission_update_invalidates_existing_session() {
let row = AdminAccount {
account_id: "member-1".to_string(),
username: "operator".to_string(),
display_name: "运营".to_string(),
password_hash: "$argon2id$old".to_string(),
tab_permissions_json: r#"["dashboard"]"#.to_string(),
enabled: true,
token_version: 7,
created_by: "owner".to_string(),
updated_by: "owner".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(10),
updated_at: Timestamp::from_micros_since_unix_epoch(10),
action_permissions_json: None,
};
let updated = build_updated_admin_account(
row,
"运营".to_string(),
None,
r#"["dashboard"]"#.to_string(),
r#"["profile-wallet-consumption-reconcile"]"#.to_string(),
true,
"owner".to_string(),
Timestamp::from_micros_since_unix_epoch(20),
)
.expect("action permission should update");
assert_eq!(updated.token_version, 8);
}
#[test]
fn public_and_credential_account_snapshots_are_type_separated() {
let row = AdminAccount {
account_id: "member-1".to_string(),
username: "operator".to_string(),
display_name: "运营".to_string(),
password_hash: "$argon2id$secret".to_string(),
tab_permissions_json: "[]".to_string(),
enabled: true,
token_version: 1,
created_by: "owner".to_string(),
updated_by: "owner".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(10),
updated_at: Timestamp::from_micros_since_unix_epoch(10),
action_permissions_json: Some(
r#"["profile-wallet-consumption-reconcile"]"#.to_string(),
),
};
let public_snapshot = admin_account_snapshot_from_row(row.clone());
let credential_snapshot = admin_account_credential_snapshot_from_row(row);
assert_eq!(public_snapshot.account_id, "member-1");
assert_eq!(credential_snapshot.account, public_snapshot);
assert_eq!(credential_snapshot.password_hash, "$argon2id$secret");
}
}