重写
This commit is contained in:
13
server-rs/crates/spacetime-module/Cargo.toml
Normal file
13
server-rs/crates/spacetime-module/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "spacetime-module"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
module-assets = { path = "../module-assets", default-features = false, features = ["spacetime-types"] }
|
||||
spacetimedb = { workspace = true, features = ["unstable"] }
|
||||
@@ -14,14 +14,29 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前阶段仍未进入具体 schema 与 reducer 实现,但已经补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口固定下来。
|
||||
当前阶段已落下第一批真实 schema 骨架,并已补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口与首版资产对象表固定下来。
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
1. 建立模块聚合入口
|
||||
2. 设计表、reducer、view 的聚合方式
|
||||
1. 继续扩充模块聚合入口
|
||||
2. 继续设计表、reducer、view 的聚合方式
|
||||
3. 接入身份 claims 透传
|
||||
4. 在实体 module scaffold 落地后接入 publish / dev 循环
|
||||
4. 在当前 scaffold 基础上接入 publish / dev 循环
|
||||
|
||||
当前已落地:
|
||||
|
||||
1. `spacetime-module` 真实 `cdylib` crate scaffold
|
||||
2. `asset_object` 首版表骨架
|
||||
3. `bucket + object_key` 双列对象定位索引
|
||||
4. `module-assets` 的访问策略与字段校验类型接入
|
||||
5. 面向 Axum 的 `asset_object` 确认持久化入口
|
||||
6. `asset_entity_binding` 通用绑定表
|
||||
7. 面向 Axum 的 `bind_asset_object_to_entity_and_return` 绑定 procedure
|
||||
|
||||
`asset_object` 的详细设计见:
|
||||
|
||||
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
|
||||
|
||||
当前身份透传设计依据:
|
||||
|
||||
@@ -30,7 +45,7 @@
|
||||
当前本地开发脚本约定:
|
||||
|
||||
1. `../../scripts/spacetime-dev.ps1` 与 `../../scripts/spacetime-dev.sh` 当前固定执行 `spacetime start` 的 standalone 模式。
|
||||
2. 默认监听 `127.0.0.1:3001`,避免与 `api-server` 默认 `3000` 端口冲突。
|
||||
2. 默认监听 `127.0.0.1:3000`,与 `spacetime` CLI 的 `local` server 默认口径保持一致。
|
||||
3. 本地数据目录固定到 `server-rs/.spacetimedb/local`,避免污染全局 SpacetimeDB 根目录。
|
||||
4. 当前阶段暂不自动 publish `crates/spacetime-module`,待 module 实体 scaffold 与聚合入口落地后再扩展。
|
||||
|
||||
|
||||
327
server-rs/crates/spacetime-module/src/lib.rs
Normal file
327
server-rs/crates/spacetime-module/src/lib.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use module_assets::{
|
||||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
|
||||
AssetEntityBindingProcedureResult, AssetEntityBindingSnapshot, AssetObjectAccessPolicy,
|
||||
AssetObjectProcedureResult, AssetObjectUpsertInput, AssetObjectUpsertSnapshot,
|
||||
INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields,
|
||||
validate_asset_object_fields,
|
||||
};
|
||||
use spacetimedb::{ProcedureContext, ReducerContext, Table, Timestamp};
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_object,
|
||||
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
|
||||
)]
|
||||
pub struct AssetObject {
|
||||
#[primary_key]
|
||||
asset_object_id: String,
|
||||
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_policy: AssetObjectAccessPolicy,
|
||||
content_type: Option<String>,
|
||||
content_length: u64,
|
||||
content_hash: Option<String>,
|
||||
version: u32,
|
||||
source_job_id: Option<String>,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
#[index(btree)]
|
||||
asset_kind: String,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_entity_binding,
|
||||
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
|
||||
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
|
||||
)]
|
||||
pub struct AssetEntityBinding {
|
||||
#[primary_key]
|
||||
binding_id: String,
|
||||
asset_object_id: String,
|
||||
entity_kind: String,
|
||||
entity_id: String,
|
||||
slot: String,
|
||||
asset_kind: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。
|
||||
#[spacetimedb::reducer(init)]
|
||||
pub fn init(_ctx: &ReducerContext) {
|
||||
log::info!(
|
||||
"spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,默认对象 ID 前缀={},默认绑定 ID 前缀={},初始版本={}",
|
||||
ASSET_OBJECT_ID_PREFIX,
|
||||
ASSET_BINDING_ID_PREFIX,
|
||||
INITIAL_ASSET_OBJECT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn confirm_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_object(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn confirm_asset_object_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> AssetObjectProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
|
||||
Ok(record) => AssetObjectProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetObjectProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn bind_asset_object_to_entity(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_entity_binding(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn bind_asset_object_to_entity_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> AssetEntityBindingProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
|
||||
Ok(record) => AssetEntityBindingProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetEntityBindingProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<AssetObjectUpsertSnapshot, String> {
|
||||
validate_asset_object_fields(
|
||||
&input.bucket,
|
||||
&input.object_key,
|
||||
&input.asset_kind,
|
||||
input.version,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
|
||||
let current = ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.iter()
|
||||
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.delete(&existing.asset_object_id);
|
||||
let row = AssetObject {
|
||||
asset_object_id: existing.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: existing.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetObject {
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: input.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn upsert_asset_entity_binding(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<AssetEntityBindingSnapshot, String> {
|
||||
validate_asset_entity_binding_fields(
|
||||
&input.binding_id,
|
||||
&input.asset_object_id,
|
||||
&input.entity_kind,
|
||||
&input.entity_id,
|
||||
&input.slot,
|
||||
&input.asset_kind,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.find(&input.asset_object_id)
|
||||
.is_none()
|
||||
{
|
||||
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
|
||||
}
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
|
||||
let current = ctx.db.asset_entity_binding().iter().find(|row| {
|
||||
row.entity_kind == input.entity_kind
|
||||
&& row.entity_id == input.entity_id
|
||||
&& row.slot == input.slot
|
||||
});
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_entity_binding()
|
||||
.binding_id()
|
||||
.delete(&existing.binding_id);
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: existing.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: existing.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: input.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: input.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
Reference in New Issue
Block a user