This commit is contained in:
2026-04-21 19:17:31 +08:00
parent d234d27cc0
commit 89129ef1f4
83 changed files with 13329 additions and 176 deletions

2262
server-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,20 @@
resolver = "2"
members = [
"crates/api-server",
"crates/module-assets",
"crates/module-auth",
"crates/platform-oss",
"crates/platform-auth",
"crates/shared-logging",
"crates/spacetime-client",
"crates/spacetime-module",
]
[workspace.package]
edition = "2024"
version = "0.1.0"
license = "UNLICENSED"
[workspace.dependencies]
log = "0.4"
spacetimedb = "2.1.0"

View File

@@ -51,6 +51,7 @@
33. 创建 `scripts/smoke.sh`,固定 Unix-like 本地冒烟验证入口。
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
后续任务会继续在本目录内按顺序补齐:
@@ -58,6 +59,16 @@
2. `module-auth` 的身份表、JWT 与 refresh cookie 主链
3. `platform-oss` 的浏览器直传签名、旧 `/generated-*` 前缀映射与对象 URL 解析能力
当前本地脚本补充说明:
1. `scripts/smoke.ps1` 用于验证 `api-server` 的本地 `/healthz` 基础 contract。
2. `scripts/oss-smoke.ps1` 用于验证真实阿里云 OSS
- 读取仓库根目录 `.env` / `.env.local`
- 启动临时 `api-server`
- 请求 `/api/assets/direct-upload-tickets`
- 实际执行 `PostObject` 上传
- 校验对象存在并默认自动删除
## 3. 已冻结边界
本目录后续落地时必须继续遵守 `M0` 已冻结的边界:

View File

@@ -7,18 +7,28 @@ license.workspace = true
[dependencies]
axum = "0.8"
dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
module-assets = { path = "../module-assets" }
module-auth = { path = "../module-auth" }
platform-auth = { path = "../platform-auth" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-logging = { path = "../shared-logging" }
spacetime-client = { path = "../spacetime-client" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
time = { version = "0.3", features = ["formatting"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
sha1 = "0.10"
tower = { version = "0.5", features = ["util"] }

View File

@@ -33,6 +33,15 @@
11. 接入 `GET /api/auth/me` 当前用户查询链路
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
13. 接入 `POST /api/auth/logout` 当前设备退出链路
14. 接入 `POST /api/assets/objects/confirm` 上传完成确认链路
15. 接入 `GET /api/auth/login-options` 登录方式探测链路
16. 接入 `POST /api/auth/phone/send-code` 手机验证码发送链路
17. 接入 `POST /api/auth/phone/login` 手机验证码登录链路
18. 接入 `GET /api/auth/wechat/start` 微信授权起跳链路
19. 接入 `GET /api/auth/wechat/callback` 微信回调换取系统登录态链路
20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路
21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
后续与本 crate 直接相关的任务包括:
@@ -46,6 +55,15 @@
8. [x] 接入 `/api/auth/me`
9. [x] 接入 `/api/auth/refresh`
10. [x] 接入 `/api/auth/logout`
11. [x] 接入 `/api/assets/objects/confirm`
12. [x] 接入 `/api/auth/login-options`
13. [x] 接入 `/api/auth/phone/send-code`
14. [x] 接入 `/api/auth/phone/login`
15. [x] 接入 `/api/auth/wechat/start`
16. [x] 接入 `/api/auth/wechat/callback`
17. [x] 接入 `/api/auth/wechat/bind-phone`
18. [x] 接入 `/api/assets/objects/bind`
19. [x] 接入 `/api/assets/sts-upload-credentials`
当前 tracing 约定:
@@ -108,3 +126,8 @@
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
8. 当前 `/api/auth/logout` 复用 `module-auth` 的当前会话吊销与用户版本递增能力,`api-server` 负责 Bearer JWT、refresh cookie 读取与清理 cookie 回写。
9. 当前 `/api/assets/objects/confirm` 先由 `platform-oss` 完成私有 `HEAD Object` 校验,再通过 `spacetime-client` 调用 `spacetime-module` 的对象确认持久化入口。
10. 当前 `/api/assets/objects/bind` 只绑定已确认对象到业务实体槽位,不访问 OSS不创建悬空 `asset_object_id`
11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB而是统一换成系统签发的 JWT。
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。

View File

@@ -10,7 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
use tracing::{Level, info_span};
use crate::{
assets::{create_direct_upload_ticket, get_asset_read_url},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
@@ -23,10 +26,12 @@ use crate::{
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
phone_auth::{phone_login, send_phone_code},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::AppState,
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
@@ -52,10 +57,7 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route(
"/api/auth/login-options",
get(auth_login_options),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
@@ -82,6 +84,17 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)
@@ -105,6 +118,15 @@ pub fn build_router(state: AppState) -> Router {
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials),
)
.route("/api/assets/objects/confirm", post(confirm_asset_object))
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/auth/entry", post(password_entry))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
@@ -479,6 +501,858 @@ mod tests {
);
}
#[tokio::test]
async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["cooldownSeconds"],
Value::Number(serde_json::Number::from(60))
);
assert_eq!(
payload["expiresInSeconds"],
Value::Number(serde_json::Number::from(300))
);
assert_eq!(payload["providerRequestId"], Value::Null);
}
#[tokio::test]
async fn send_phone_code_rejects_same_scene_during_cooldown() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("first request should build"),
)
.await
.expect("first request should succeed");
assert_eq!(first_response.status(), StatusCode::OK);
let cooldown_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("cooldown request should build"),
)
.await
.expect("cooldown request should succeed");
assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(
cooldown_response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.parse::<u64>().is_ok_and(|seconds| seconds > 0))
);
let body = cooldown_response
.into_body()
.collect()
.await
.expect("cooldown body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("cooldown body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("TOO_MANY_REQUESTS".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("验证码发送过于频繁,请稍后再试".to_string())
);
assert!(
payload["error"]["details"]["retryAfterSeconds"]
.as_u64()
.is_some()
);
}
#[tokio::test]
async fn phone_login_creates_user_and_sets_refresh_cookie() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
assert!(
login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let body = login_response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(
payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(
payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn phone_login_reuses_existing_user_for_same_phone_number() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login request should succeed");
assert_eq!(first_login_response.status(), StatusCode::OK);
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first login payload should be json");
let send_code_again_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_again_response.status(), StatusCode::OK);
let second_login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login request should succeed");
assert_eq!(second_login_response.status(), StatusCode::OK);
let second_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_payload: Value =
serde_json::from_slice(&second_body).expect("second login payload should be json");
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
}
#[tokio::test]
async fn phone_login_exhausts_code_after_too_many_wrong_attempts() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
for _ in 0..4 {
let wrong_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("wrong login request should build"),
)
.await
.expect("wrong login request should succeed");
assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST);
}
let exhausted_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("exhausted login request should build"),
)
.await
.expect("exhausted login request should succeed");
assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS);
let exhausted_body = exhausted_response
.into_body()
.collect()
.await
.expect("exhausted body should collect")
.to_bytes();
let exhausted_payload: Value =
serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json");
assert_eq!(
exhausted_payload["error"]["message"],
Value::String("验证码错误次数过多,请重新获取验证码".to_string())
);
let stale_right_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("stale login request should build"),
)
.await
.expect("stale login request should succeed");
assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST);
let resend_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("resend request should build"),
)
.await
.expect("resend request should succeed");
assert_eq!(resend_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn wechat_start_returns_mock_callback_url_with_state() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let authorization_url = payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
assert!(authorization_url.contains("/api/auth/wechat/callback"));
assert!(authorization_url.contains("mock_code=wx-mock-code"));
assert!(authorization_url.contains("state="));
}
#[tokio::test]
async fn wechat_callback_creates_pending_bind_phone_session_with_wechat_provider() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("wechat start should succeed");
let start_body = start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let start_payload: Value =
serde_json::from_slice(&start_body).expect("wechat start payload should be json");
let authorization_url = start_payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
let callback_url =
url::Url::parse(authorization_url).expect("authorization url should be valid");
let state = callback_url
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state query should exist");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("callback request should build"),
)
.await
.expect("callback request should succeed");
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
let location = callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("redirect location should exist");
let refresh_cookie = callback_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist");
assert!(location.starts_with("/play#"));
assert!(location.contains("auth_provider=wechat"));
assert!(location.contains("auth_binding_status=pending_bind_phone"));
assert!(location.contains("auth_token="));
assert!(refresh_cookie.contains("genarrative_refresh_session="));
let auth_hash = location
.split('#')
.nth(1)
.expect("hash fragment should exist");
let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes())
.into_owned()
.collect::<std::collections::HashMap<String, String>>();
let token = auth_params
.get("auth_token")
.expect("auth token should exist in hash");
let me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("auth me request should build"),
)
.await
.expect("auth me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_body = me_response
.into_body()
.collect()
.await
.expect("auth me body should collect")
.to_bytes();
let me_payload: Value =
serde_json::from_slice(&me_body).expect("auth me payload should be json");
assert_eq!(
me_payload["user"]["loginMethod"],
Value::String("wechat".to_string())
);
assert_eq!(
me_payload["user"]["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
let claims_response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/claims")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("claims request should build"),
)
.await
.expect("claims request should succeed");
let claims_body = claims_response
.into_body()
.collect()
.await
.expect("claims body should collect")
.to_bytes();
let claims_payload: Value =
serde_json::from_slice(&claims_body).expect("claims payload should be json");
assert_eq!(
claims_payload["claims"]["provider"],
Value::String("wechat".to_string())
);
assert_eq!(
claims_payload["claims"]["binding_status"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(
claims_payload["claims"]["phone_verified"],
Value::Bool(false)
);
}
#[tokio::test]
async fn wechat_bind_phone_merges_into_existing_phone_user() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let phone_send_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("phone send request should build"),
)
.await
.expect("phone send request should succeed");
assert_eq!(phone_send_response.status(), StatusCode::OK);
let phone_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("phone login request should build"),
)
.await
.expect("phone login request should succeed");
let phone_login_body = phone_login_response
.into_body()
.collect()
.await
.expect("phone login body should collect")
.to_bytes();
let phone_login_payload: Value =
serde_json::from_slice(&phone_login_body).expect("phone login payload should be json");
let phone_user_id = phone_login_payload["user"]["id"].clone();
let wechat_start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat start request should build"),
)
.await
.expect("wechat start request should succeed");
let wechat_start_body = wechat_start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let wechat_start_payload: Value = serde_json::from_slice(&wechat_start_body)
.expect("wechat start payload should be json");
let authorization_url = wechat_start_payload["authorizationUrl"]
.as_str()
.expect("wechat authorization url should exist");
let callback_state = url::Url::parse(authorization_url)
.expect("authorization url should be valid")
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state should exist");
let wechat_callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={callback_state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat callback request should build"),
)
.await
.expect("wechat callback request should succeed");
let wechat_location = wechat_callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("wechat callback location should exist");
let wechat_hash = wechat_location
.split('#')
.nth(1)
.expect("wechat callback hash should exist");
let wechat_auth_params = url::form_urlencoded::parse(wechat_hash.as_bytes())
.into_owned()
.collect::<std::collections::HashMap<String, String>>();
let wechat_token = wechat_auth_params
.get("auth_token")
.expect("wechat auth token should exist");
let bind_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "bind_phone"
})
.to_string(),
))
.expect("bind code request should build"),
)
.await
.expect("bind code request should succeed");
assert_eq!(bind_code_response.status(), StatusCode::OK);
let bind_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/bind-phone")
.header("authorization", format!("Bearer {wechat_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("bind request should build"),
)
.await
.expect("bind request should succeed");
assert_eq!(bind_response.status(), StatusCode::OK);
assert!(
bind_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let bind_body = bind_response
.into_body()
.collect()
.await
.expect("bind body should collect")
.to_bytes();
let bind_payload: Value =
serde_json::from_slice(&bind_body).expect("bind payload should be json");
assert_eq!(bind_payload["user"]["id"], phone_user_id);
assert_eq!(
bind_payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
bind_payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true));
assert_eq!(
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1178,8 +2052,8 @@ mod tests {
.await
.expect("logout-all body should collect")
.to_bytes();
let logout_all_payload: Value = serde_json::from_slice(&logout_all_body)
.expect("logout-all payload should be json");
let logout_all_payload: Value =
serde_json::from_slice(&logout_all_body).expect("logout-all payload should be json");
assert_eq!(logout_all_payload["ok"], Value::Bool(true));
let me_response = app
@@ -1279,4 +2153,4 @@ mod tests {
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,8 @@ use platform_auth::{
};
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{http_error::AppError, state::AppState};
use crate::{session_client::SessionClientContext};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
@@ -22,6 +22,15 @@ pub fn create_password_auth_session(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
) -> Result<SignedAuthSession, AppError> {
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
}
pub fn create_auth_session(
state: &AppState,
user: &AuthUser,
session_client: &SessionClientContext,
session_provider: AuthLoginMethod,
) -> Result<SignedAuthSession, AppError> {
let refresh_token = create_refresh_session_token();
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
@@ -31,13 +40,18 @@ pub fn create_password_auth_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash,
issued_by_provider: AuthLoginMethod::Password,
issued_by_provider: session_provider.clone(),
client_info: session_client.to_refresh_session_client_info(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_error)?;
let access_token = sign_access_token_for_user(state, user, &session.session.session_id)?;
let access_token = sign_access_token_for_user(
state,
user,
&session.session.session_id,
Some(&session_provider),
)?;
Ok(SignedAuthSession {
access_token,
@@ -49,12 +63,13 @@ pub fn sign_access_token_for_user(
state: &AppState,
user: &AuthUser,
session_id: &str,
session_provider_override: Option<&AuthLoginMethod>,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
provider: map_auth_provider(&user.login_method),
provider: map_auth_provider(session_provider_override.unwrap_or(&user.login_method)),
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: user.phone_number_masked.is_some(),

View File

@@ -70,9 +70,9 @@ pub async fn auth_sessions(
.sessions
.into_iter()
.map(|session| {
let is_current = current_refresh_token_hash.as_ref().is_some_and(|hash| {
session.refresh_token_hash == *hash
});
let is_current = current_refresh_token_hash
.as_ref()
.is_some_and(|hash| session.refresh_token_hash == *hash);
let client_label = session.client_info.device_display_name.clone();
AuthSessionSummaryPayload {
@@ -99,8 +99,10 @@ pub async fn auth_sessions(
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
match error {
module_auth::RefreshSessionError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
module_auth::RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录")
}
module_auth::RefreshSessionError::MissingToken
| module_auth::RefreshSessionError::SessionNotFound
| module_auth::RefreshSessionError::SessionExpired => {

View File

@@ -16,14 +16,30 @@ pub struct AppConfig {
pub refresh_session_ttl_days: u32,
pub sms_auth_enabled: bool,
pub wechat_auth_enabled: bool,
pub wechat_auth_provider: String,
pub wechat_app_id: Option<String>,
pub wechat_app_secret: Option<String>,
pub wechat_callback_path: String,
pub wechat_redirect_path: String,
pub wechat_authorize_endpoint: String,
pub wechat_access_token_endpoint: String,
pub wechat_user_info_endpoint: String,
pub wechat_state_ttl_minutes: u32,
pub wechat_mock_user_id: String,
pub wechat_mock_union_id: Option<String>,
pub wechat_mock_display_name: String,
pub wechat_mock_avatar_url: Option<String>,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
pub oss_access_key_secret: Option<String>,
pub oss_public_base_url: Option<String>,
pub oss_read_expire_seconds: u64,
pub oss_post_expire_seconds: u64,
pub oss_post_max_size_bytes: u64,
pub oss_success_action_status: u16,
pub spacetime_server_url: String,
pub spacetime_database: String,
pub spacetime_token: Option<String>,
}
impl Default for AppConfig {
@@ -42,14 +58,31 @@ impl Default for AppConfig {
refresh_session_ttl_days: 30,
sms_auth_enabled: false,
wechat_auth_enabled: false,
wechat_auth_provider: "mock".to_string(),
wechat_app_id: None,
wechat_app_secret: None,
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
wechat_redirect_path: "/".to_string(),
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token"
.to_string(),
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
wechat_state_ttl_minutes: 15,
wechat_mock_user_id: "wx-mock-user".to_string(),
wechat_mock_union_id: Some("wx-mock-union".to_string()),
wechat_mock_display_name: "微信旅人".to_string(),
wechat_mock_avatar_url: None,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
oss_access_key_secret: None,
oss_public_base_url: None,
oss_read_expire_seconds: 10 * 60,
oss_post_expire_seconds: 10 * 60,
oss_post_max_size_bytes: 20 * 1024 * 1024,
oss_success_action_status: 200,
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None,
}
}
}
@@ -126,12 +159,58 @@ impl AppConfig {
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
config.wechat_auth_enabled = wechat_auth_enabled;
}
if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) {
config.wechat_auth_provider = wechat_auth_provider;
}
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
config.wechat_callback_path = wechat_callback_path;
}
if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) {
config.wechat_redirect_path = wechat_redirect_path;
}
if let Some(wechat_authorize_endpoint) =
read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"])
{
config.wechat_authorize_endpoint = wechat_authorize_endpoint;
}
if let Some(wechat_access_token_endpoint) =
read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"])
{
config.wechat_access_token_endpoint = wechat_access_token_endpoint;
}
if let Some(wechat_user_info_endpoint) =
read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"])
{
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
}
if let Some(wechat_state_ttl_minutes) =
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
{
config.wechat_state_ttl_minutes = wechat_state_ttl_minutes;
}
if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) {
config.wechat_mock_user_id = wechat_mock_user_id;
}
config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]);
if let Some(wechat_mock_display_name) =
read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"])
{
config.wechat_mock_display_name = wechat_mock_display_name;
}
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]);
config.oss_public_base_url = read_first_non_empty_env(&["ALIYUN_OSS_PUBLIC_BASE_URL"]);
if let Some(oss_read_expire_seconds) =
read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"])
{
config.oss_read_expire_seconds = oss_read_expire_seconds;
}
if let Some(oss_post_expire_seconds) =
read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"])
@@ -151,6 +230,20 @@ impl AppConfig {
config.oss_success_action_status = oss_success_action_status;
}
if let Some(spacetime_server_url) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
{
config.spacetime_server_url = spacetime_server_url;
}
if let Some(spacetime_database) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"])
{
config.spacetime_database = spacetime_database;
}
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
config
}

View File

@@ -1,6 +1,6 @@
use axum::{
http::{HeaderMap, HeaderValue},
http::StatusCode,
http::{HeaderMap, HeaderValue},
response::{IntoResponse, Response},
};
use serde::Serialize;
@@ -42,6 +42,10 @@ impl AppError {
self.code
}
pub fn message(&self) -> &str {
&self.message
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
@@ -60,7 +64,8 @@ impl AppError {
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
let status_code = self.status_code;
let payload = self.to_payload();
let mut response = (status_code, json_error_body(request_context, &payload)).into_response();
let mut response =
(status_code, json_error_body(request_context, &payload)).into_response();
response.headers_mut().extend(self.headers);
response
}

View File

@@ -3,8 +3,8 @@ mod app;
mod assets;
mod auth;
mod auth_me;
mod auth_sessions;
mod auth_session;
mod auth_sessions;
mod config;
mod error_middleware;
mod health;
@@ -13,11 +13,14 @@ mod login_options;
mod logout;
mod logout_all;
mod password_entry;
mod phone_auth;
mod refresh_session;
mod request_context;
mod response_headers;
mod session_client;
mod state;
mod wechat_auth;
mod wechat_provider;
use shared_logging::init_tracing;
use tokio::net::TcpListener;
@@ -45,4 +48,4 @@ async fn main() -> Result<(), std::io::Error> {
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
axum::serve(listener, router).await
}
}

View File

@@ -0,0 +1,188 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::IntoResponse,
};
use module_auth::{
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
password_entry::PasswordEntryUserPayload,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeRequest {
pub phone: String,
pub scene: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeResponse {
pub ok: bool,
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginRequest {
pub phone: String,
pub code: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginResponse {
pub token: String,
pub user: PasswordEntryUserPayload,
}
pub async fn send_phone_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<PhoneSendCodeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
// 短信登录开关由服务端配置统一控制,避免前端误调用未开放能力。
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let scene = map_phone_auth_scene(payload.scene.as_deref())?;
let result = state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: payload.phone,
scene,
},
OffsetDateTime::now_utc(),
)
.map_err(map_phone_auth_error)?;
Ok(json_success_body(
Some(&request_context),
PhoneSendCodeResponse {
ok: true,
cooldown_seconds: result.cooldown_seconds,
expires_in_seconds: result.expires_in_seconds,
provider_request_id: result.provider_request_id,
},
))
}
pub async fn phone_login(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PhoneLoginRequest>,
) -> Result<impl IntoResponse, AppError> {
// 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。
if !state.config.sms_auth_enabled {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let result = state
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: payload.phone,
verify_code: payload.code,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: PasswordEntryUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str(),
binding_status: result.user.binding_status.as_str(),
wechat_bound: result.user.wechat_bound,
},
},
),
))
}
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
match raw_scene.unwrap_or("login").trim() {
"login" => Ok(PhoneAuthScene::Login),
"bind_phone" => Ok(PhoneAuthScene::BindPhone),
"change_phone" => Ok(PhoneAuthScene::ChangePhone),
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("短信验证码场景不合法")
.with_details(json!({ "field": "scene" }))),
}
}
fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
match error {
PhoneAuthError::InvalidPhoneNumber
| PhoneAuthError::InvalidVerifyCode
| PhoneAuthError::VerifyCodeNotFound
| PhoneAuthError::VerifyCodeExpired
| PhoneAuthError::UserStateMismatch => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
}
PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
}
PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -54,8 +54,12 @@ pub async fn refresh_session(
OffsetDateTime::now_utc(),
)
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
let access_token =
sign_access_token_for_user(&state, &rotated.user, &rotated.session.session_id)?;
let access_token = sign_access_token_for_user(
&state,
&rotated.user,
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
)?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -56,24 +56,30 @@ pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientConte
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
let explicit_client_platform =
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
let client_instance_id = normalize_optional_string(header_value(
headers,
X_CLIENT_INSTANCE_ID_HEADER,
));
let client_instance_id =
normalize_optional_string(header_value(headers, X_CLIENT_INSTANCE_ID_HEADER));
let mini_program_app_id =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
let mini_program_env =
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
let inferred_runtime =
infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower);
let inferred_runtime = infer_client_runtime(
explicit_client_runtime.as_deref(),
&inferred_client_type,
&ua_lower,
);
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
let ip = resolve_ip(headers);
let device_display_name =
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
let device_fingerprint =
build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref());
let device_fingerprint = build_device_fingerprint(
&inferred_client_type,
&inferred_runtime,
&inferred_platform,
client_instance_id.as_deref(),
user_agent.as_deref(),
);
SessionClientContext {
client_type: inferred_client_type,
@@ -160,7 +166,11 @@ fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
"web_browser".to_string()
}
fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String {
fn infer_client_runtime(
explicit_runtime: Option<&str>,
client_type: &str,
ua_lower: &str,
) -> String {
if client_type == "mini_program" {
if let Some(runtime) = explicit_runtime {
return runtime.to_string();
@@ -201,7 +211,8 @@ fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
return Some("chrome".to_string());
}
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") {
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/")
{
return Some("safari".to_string());
}
@@ -228,7 +239,11 @@ fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
None
}
fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String {
fn build_device_display_name(
client_type: &str,
client_runtime: &str,
client_platform: &str,
) -> String {
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
if client_type == "mini_program" {
return format!(
@@ -329,7 +344,10 @@ pub fn mask_ip(ip: Option<&str>) -> Option<String> {
}
if ip.contains(':') {
let parts = ip.split(':').filter(|part| !part.is_empty()).collect::<Vec<_>>();
let parts = ip
.split(':')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.len() <= 2 {
return Some(ip.to_string());
}

View File

@@ -1,12 +1,17 @@
use std::{error::Error, fmt};
use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
use platform_oss::{OssClient, OssConfig, OssError};
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
@@ -20,6 +25,11 @@ pub struct AppState {
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
auth_user_service: AuthUserService,
phone_auth_service: PhoneAuthService,
wechat_auth_state_service: WechatAuthStateService,
wechat_auth_service: WechatAuthService,
wechat_provider: WechatProvider,
spacetime_client: SpacetimeClient,
}
#[derive(Debug)]
@@ -51,8 +61,18 @@ impl AppState {
let auth_store = InMemoryAuthStore::default();
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
let phone_auth_service = PhoneAuthService::new(auth_store.clone());
let wechat_auth_state_service =
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 refresh_session_service =
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
});
Ok(Self {
config,
@@ -62,6 +82,11 @@ impl AppState {
password_entry_service,
refresh_session_service,
auth_user_service,
phone_auth_service,
wechat_auth_state_service,
wechat_auth_service,
wechat_provider,
spacetime_client,
})
}
@@ -88,6 +113,26 @@ impl AppState {
pub fn auth_user_service(&self) -> &AuthUserService {
&self.auth_user_service
}
pub fn phone_auth_service(&self) -> &PhoneAuthService {
&self.phone_auth_service
}
pub fn wechat_auth_state_service(&self) -> &WechatAuthStateService {
&self.wechat_auth_state_service
}
pub fn wechat_auth_service(&self) -> &WechatAuthService {
&self.wechat_auth_service
}
pub fn wechat_provider(&self) -> &WechatProvider {
&self.wechat_provider
}
pub fn spacetime_client(&self) -> &SpacetimeClient {
&self.spacetime_client
}
}
impl fmt::Display for AppStateInitError {

View File

@@ -0,0 +1,382 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
WechatAuthScene,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use url::Url;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
http_error::AppError,
password_entry::PasswordEntryUserPayload,
request_context::RequestContext,
session_client::resolve_session_client_context,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartQuery {
pub redirect_path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartResponse {
pub authorization_url: String,
}
#[derive(Debug, Deserialize)]
pub struct WechatCallbackQuery {
pub state: Option<String>,
pub code: Option<String>,
pub mock_code: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneRequest {
pub phone: String,
pub code: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneResponse {
pub token: String,
pub user: PasswordEntryUserPayload,
}
pub async fn start_wechat_login(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Query(query): Query<WechatStartQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let user_agent = headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let scene = resolve_wechat_scene(user_agent.as_deref())?;
let state_record = state
.wechat_auth_state_service()
.create_state(
CreateWechatAuthStateInput {
redirect_path: normalize_redirect_path(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: scene.clone(),
request_user_agent: user_agent.clone(),
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state.wechat_provider().build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)?;
Ok(json_success_body(
Some(&request_context),
WechatStartResponse { authorization_url },
))
}
pub async fn handle_wechat_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<WechatCallbackQuery>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let fallback_redirect = state.config.wechat_redirect_path.clone();
let state_token = query
.state
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
if state_token.is_empty() {
return Ok(Redirect::to(&build_auth_result_redirect_url(
&fallback_redirect,
&[
("auth_provider", "wechat"),
("auth_error", "微信登录状态已失效,请重新发起登录。"),
],
))
.into_response());
}
let consumed = match state
.wechat_auth_state_service()
.consume_state(&state_token, OffsetDateTime::now_utc())
{
Ok(value) => value,
Err(_) => {
return Ok(Redirect::to(&build_auth_result_redirect_url(
&fallback_redirect,
&[
("auth_provider", "wechat"),
("auth_error", "微信登录状态已失效,请重新发起登录。"),
],
))
.into_response());
}
};
let redirect_path = consumed.state.redirect_path.clone();
let session_client = resolve_session_client_context(&headers);
let result = match state
.wechat_provider()
.resolve_callback_profile(query.code.as_deref(), query.mock_code.as_deref())
.await
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.await
.map_err(map_wechat_auth_error),
Err(error) => Err(error),
};
match result {
Ok(result) => {
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
let mut response = Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[
("auth_provider", "wechat"),
("auth_token", signed_session.access_token.as_str()),
("auth_binding_status", result.user.binding_status.as_str()),
],
))
.into_response();
attach_set_cookie_header(
response.headers_mut(),
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok(response)
}
Err(error) => Ok(Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[("auth_provider", "wechat"), ("auth_error", error.message())],
))
.into_response()),
}
}
pub async fn bind_wechat_phone(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<WechatBindPhoneRequest>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let result = state
.phone_auth_service()
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: authenticated.claims().user_id().to_string(),
phone_number: payload.phone,
verify_code: payload.code,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_wechat_bind_phone_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(
&mut response_headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
response_headers,
json_success_body(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: PasswordEntryUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str(),
binding_status: result.user.binding_status.as_str(),
wechat_bound: result.user.wechat_bound,
},
},
),
))
}
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
let user_agent = user_agent.unwrap_or_default();
let is_wechat = user_agent.contains("MicroMessenger");
let is_mobile = user_agent.contains("Android")
|| user_agent.contains("iPhone")
|| user_agent.contains("iPad")
|| user_agent.contains("Mobile");
if is_wechat {
return Ok(WechatAuthScene::WechatInApp);
}
if is_mobile {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录"));
}
Ok(WechatAuthScene::Desktop)
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();
};
if raw_value.starts_with('/') {
return raw_value.to_string();
}
Url::parse(raw_value)
.map(|url| {
format!(
"{}{}{}",
url.path(),
url.query().map(|v| format!("?{v}")).unwrap_or_default(),
url.fragment().map(|v| format!("#{v}")).unwrap_or_default()
)
})
.unwrap_or_else(|_| fallback.to_string())
}
fn resolve_wechat_callback_url(state: &AppState, headers: &HeaderMap) -> Result<String, AppError> {
let proto = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http");
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1:3000");
Ok(format!(
"{proto}://{host}{}",
state.config.wechat_callback_path
))
}
fn build_auth_result_redirect_url(redirect_path: &str, params: &[(&str, &str)]) -> String {
let hash = params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
let path_without_hash = redirect_path.split('#').next().unwrap_or("/");
format!(
"{}#{}",
if path_without_hash.is_empty() {
"/"
} else {
path_without_hash
},
hash
)
}
#[allow(dead_code)]
fn _assert_response_type(_: Response) {}
fn map_wechat_auth_error(error: WechatAuthError) -> AppError {
match error {
WechatAuthError::MissingProfile
| WechatAuthError::StateNotFound
| WechatAuthError::StateExpired
| WechatAuthError::StateConsumed
| WechatAuthError::MissingWechatIdentity => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
WechatAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
WechatAuthError::Store(_) | WechatAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}
fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
match error {
module_auth::PhoneAuthError::InvalidPhoneNumber
| module_auth::PhoneAuthError::InvalidVerifyCode
| module_auth::PhoneAuthError::VerifyCodeNotFound
| module_auth::PhoneAuthError::VerifyCodeExpired
| module_auth::PhoneAuthError::UserStateMismatch => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
module_auth::PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => {
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message(error.to_string())
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
Ok(value) => app_error.with_header("retry-after", value),
Err(_) => app_error,
}
}
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
}
module_auth::PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -0,0 +1,280 @@
use module_auth::{WechatAuthScene, WechatIdentityProfile};
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
use url::Url;
use crate::{config::AppConfig, http_error::AppError};
use axum::http::StatusCode;
#[derive(Clone, Debug)]
pub enum WechatProvider {
Disabled,
Mock(MockWechatProvider),
Real(RealWechatProvider),
}
#[derive(Clone, Debug)]
pub struct MockWechatProvider {
mock_user_id: String,
mock_union_id: Option<String>,
mock_display_name: String,
mock_avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RealWechatProvider {
client: Client,
app_id: String,
app_secret: String,
authorize_endpoint: String,
access_token_endpoint: String,
user_info_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct WechatAccessTokenResponse {
access_token: Option<String>,
openid: Option<String>,
unionid: Option<String>,
errmsg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WechatUserInfoResponse {
openid: Option<String>,
unionid: Option<String>,
nickname: Option<String>,
headimgurl: Option<String>,
errmsg: Option<String>,
}
pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
if !config.wechat_auth_enabled {
return WechatProvider::Disabled;
}
if config
.wechat_auth_provider
.trim()
.eq_ignore_ascii_case("mock")
{
return WechatProvider::Mock(MockWechatProvider {
mock_user_id: config.wechat_mock_user_id.clone(),
mock_union_id: config.wechat_mock_union_id.clone(),
mock_display_name: config.wechat_mock_display_name.clone(),
mock_avatar_url: config.wechat_mock_avatar_url.clone(),
});
}
let Some(app_id) = config.wechat_app_id.clone() else {
return WechatProvider::Disabled;
};
let Some(app_secret) = config.wechat_app_secret.clone() else {
return WechatProvider::Disabled;
};
WechatProvider::Real(RealWechatProvider {
client: Client::new(),
app_id,
app_secret,
authorize_endpoint: config.wechat_authorize_endpoint.clone(),
access_token_endpoint: config.wechat_access_token_endpoint.clone(),
user_info_endpoint: config.wechat_user_info_endpoint.clone(),
})
}
impl WechatProvider {
pub fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(_) => {
let mut callback = Url::parse(callback_url).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信回调地址非法:{error}"))
})?;
callback
.query_pairs_mut()
.append_pair("mock_code", "wx-mock-code")
.append_pair("state", state);
Ok(callback.to_string())
}
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
}
}
pub async fn resolve_callback_profile(
&self,
code: Option<&str>,
mock_code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
match self {
Self::Disabled => {
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
}
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
Self::Real(provider) => provider.resolve_callback_profile(code).await,
}
}
}
impl MockWechatProvider {
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid,
provider_union_id: self.mock_union_id.clone(),
display_name: Some(self.mock_display_name.clone()),
avatar_url: self.mock_avatar_url.clone(),
}
}
}
impl RealWechatProvider {
fn build_authorization_url(
&self,
callback_url: &str,
state: &str,
scene: &WechatAuthScene,
) -> Result<String, AppError> {
let mut url = Url::parse(match scene {
WechatAuthScene::Desktop => &self.authorize_endpoint,
WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize",
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信授权地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("redirect_uri", callback_url)
.append_pair("response_type", "code")
.append_pair(
"scope",
match scene {
WechatAuthScene::Desktop => "snsapi_login",
WechatAuthScene::WechatInApp => "snsapi_userinfo",
},
)
.append_pair("state", state);
Ok(format!("{url}#wechat_redirect"))
}
async fn resolve_callback_profile(
&self,
code: Option<&str>,
) -> Result<WechatIdentityProfile, AppError> {
let code = code
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
})?;
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信 access_token 地址非法:{error}"))
})?;
access_token_url
.query_pairs_mut()
.append_pair("appid", &self.app_id)
.append_pair("secret", &self.app_secret)
.append_pair("code", code)
.append_pair("grant_type", "authorization_code");
let access_token_payload = self
.client
.get(access_token_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 请求失败")
})?
.json::<WechatAccessTokenResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信 access_token 响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败access_token 响应非法")
})?;
let access_token = access_token_payload
.access_token
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
access_token_payload
.errmsg
.unwrap_or_else(|| "缺少 access_token".to_string())
))
})?;
let openid = access_token_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:缺少 openid")
})?;
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("微信用户信息地址非法:{error}"))
})?;
user_info_url
.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("openid", &openid)
.append_pair("lang", "zh_CN");
let user_info_payload = self
.client
.get(user_info_url.as_str())
.send()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息请求失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息请求失败")
})?
.json::<WechatUserInfoResponse>()
.await
.map_err(|error| {
warn!(error = %error, "微信用户信息响应解析失败");
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信登录失败:用户信息响应非法")
})?;
let provider_uid = user_info_payload
.openid
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"微信登录失败:{}",
user_info_payload
.errmsg
.unwrap_or_else(|| "缺少 openid".to_string())
))
})?;
Ok(WechatIdentityProfile {
provider_uid,
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
display_name: user_info_payload.nickname,
avatar_url: user_info_payload.headimgurl,
})
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "module-assets"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = ["server-service"]
server-service = ["dep:platform-oss", "dep:reqwest"]
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
spacetimedb = { workspace = true, optional = true }
platform-oss = { path = "../platform-oss", optional = true }

View File

@@ -14,10 +14,29 @@
## 2. 当前阶段说明
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施:
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施与首版 schema 骨架
1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets`
2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力
3. 资产对象引用口径已冻结为 `bucket + object_key` 双列
4. `module-assets` 已落地:
- `AssetObjectAccessPolicy`
- `asset_object` 字段校验 helper
- `assetobj_` ID 前缀与初始版本常量
- `asset_entity_binding` 输入、快照、返回记录与字段校验 helper
- `assetbind_` ID 前缀
当前 `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_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
当前还已补齐:
1. `AssetObjectService`
2. 私有 bucket `HEAD Object` 后的对象确认写入
3. 当前阶段的进程内 `asset_object` 去重存储
后续与本 package 直接相关的任务包括:
@@ -31,3 +50,4 @@
1. `module-assets` 负责资产任务状态、对象引用关系与模块级编排,不把二进制对象本身放回本地持久化目录真相中。
2. OSS 上传、签名、对象读写等副作用通过平台适配完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。
3. 前端兼容接口由 `apps/api-server` 暴露,但资产任务状态与对象绑定关系不能再次散落到本地文件判断逻辑里。
4. 后续 `SpacetimeDB` 中的对象引用统一按 `bucket + object_key` 两列建模,不存完整 URL 作为真相字段。

View File

@@ -0,0 +1,515 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetObjectFieldError {
MissingBucket,
MissingObjectKey,
MissingAssetKind,
MissingAssetObjectId,
MissingBindingId,
MissingEntityKind,
MissingEntityId,
MissingSlot,
InvalidVersion,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectInput {
pub bucket: Option<String>,
pub object_key: String,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_hash: Option<String>,
pub asset_kind: String,
pub access_policy: Option<AssetObjectAccessPolicy>,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetObjectRecord {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetEntityBindingRecord {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
// bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。
pub fn validate_asset_object_fields(
bucket: &str,
object_key: &str,
asset_kind: &str,
version: u32,
) -> Result<(), AssetObjectFieldError> {
if bucket.trim().is_empty() {
return Err(AssetObjectFieldError::MissingBucket);
}
if object_key.trim().trim_start_matches('/').is_empty() {
return Err(AssetObjectFieldError::MissingObjectKey);
}
if asset_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetKind);
}
if version == 0 {
return Err(AssetObjectFieldError::InvalidVersion);
}
Ok(())
}
// 业务绑定首版只校验稳定定位字段;授权关系后续由 SpacetimeDB 身份透传接入后再收紧。
pub fn validate_asset_entity_binding_fields(
binding_id: &str,
asset_object_id: &str,
entity_kind: &str,
entity_id: &str,
slot: &str,
asset_kind: &str,
) -> Result<(), AssetObjectFieldError> {
if binding_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingBindingId);
}
if asset_object_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetObjectId);
}
if entity_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingEntityKind);
}
if entity_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingEntityId);
}
if slot.trim().is_empty() {
return Err(AssetObjectFieldError::MissingSlot);
}
if asset_kind.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetKind);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn build_asset_object_upsert_input(
asset_object_id: String,
bucket: String,
object_key: String,
access_policy: AssetObjectAccessPolicy,
content_type: Option<String>,
content_length: u64,
content_hash: Option<String>,
asset_kind: String,
source_job_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
entity_id: Option<String>,
updated_at_micros: i64,
) -> Result<AssetObjectUpsertInput, AssetObjectFieldError> {
if asset_object_id.trim().is_empty() {
return Err(AssetObjectFieldError::MissingAssetObjectId);
}
validate_asset_object_fields(
&bucket,
&object_key,
&asset_kind,
INITIAL_ASSET_OBJECT_VERSION,
)?;
Ok(AssetObjectUpsertInput {
asset_object_id: asset_object_id.trim().to_string(),
bucket: bucket.trim().to_string(),
object_key: object_key.trim().trim_start_matches('/').to_string(),
access_policy,
content_type: normalize_optional_value(content_type),
content_length,
content_hash: normalize_optional_value(content_hash),
version: INITIAL_ASSET_OBJECT_VERSION,
source_job_id: normalize_optional_value(source_job_id),
owner_user_id: normalize_optional_value(owner_user_id),
profile_id: normalize_optional_value(profile_id),
entity_id: normalize_optional_value(entity_id),
asset_kind: asset_kind.trim().to_string(),
updated_at_micros,
})
}
pub fn build_asset_object_record(snapshot: AssetObjectUpsertSnapshot) -> AssetObjectRecord {
AssetObjectRecord {
asset_object_id: snapshot.asset_object_id,
bucket: snapshot.bucket,
object_key: snapshot.object_key,
access_policy: snapshot.access_policy,
content_type: snapshot.content_type,
content_length: snapshot.content_length,
content_hash: snapshot.content_hash,
version: snapshot.version,
source_job_id: snapshot.source_job_id,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
entity_id: snapshot.entity_id,
asset_kind: snapshot.asset_kind,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_asset_entity_binding_input(
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>,
updated_at_micros: i64,
) -> Result<AssetEntityBindingInput, AssetObjectFieldError> {
validate_asset_entity_binding_fields(
&binding_id,
&asset_object_id,
&entity_kind,
&entity_id,
&slot,
&asset_kind,
)?;
Ok(AssetEntityBindingInput {
binding_id: binding_id.trim().to_string(),
asset_object_id: asset_object_id.trim().to_string(),
entity_kind: entity_kind.trim().to_string(),
entity_id: entity_id.trim().to_string(),
slot: slot.trim().to_string(),
asset_kind: asset_kind.trim().to_string(),
owner_user_id: normalize_optional_value(owner_user_id),
profile_id: normalize_optional_value(profile_id),
updated_at_micros,
})
}
pub fn build_asset_entity_binding_record(
snapshot: AssetEntityBindingSnapshot,
) -> AssetEntityBindingRecord {
AssetEntityBindingRecord {
binding_id: snapshot.binding_id,
asset_object_id: snapshot.asset_object_id,
entity_kind: snapshot.entity_kind,
entity_id: snapshot.entity_id,
slot: snapshot.slot,
asset_kind: snapshot.asset_kind,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
pub fn generate_asset_object_id(seed_micros: i64) -> String {
format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros)
}
pub fn generate_asset_binding_id(seed_micros: i64) -> String {
format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros)
}
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn format_timestamp_micros(micros: i64) -> String {
let seconds = micros.div_euclid(1_000_000);
let subsec_micros = micros.rem_euclid(1_000_000);
format!("{seconds}.{subsec_micros:06}Z")
}
impl fmt::Display for AssetObjectFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
}
}
}
impl Error for AssetObjectFieldError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_asset_object_fields_accepts_minimal_private_object_contract() {
let result = validate_asset_object_fields(
"xushi-dev",
"generated-characters/hero_001/visual/master.png",
"character_visual",
INITIAL_ASSET_OBJECT_VERSION,
);
assert!(result.is_ok());
}
#[test]
fn validate_asset_object_fields_rejects_missing_storage_truth() {
let error = validate_asset_object_fields("", " ", "character_visual", 0)
.expect_err("missing bucket/object_key/version should fail");
assert_eq!(error, AssetObjectFieldError::MissingBucket);
}
#[test]
fn access_policy_string_matches_private_bucket_first_contract() {
assert_eq!(AssetObjectAccessPolicy::Private.as_str(), "private");
assert_eq!(AssetObjectAccessPolicy::PublicRead.as_str(), "public_read");
}
#[test]
fn build_asset_object_upsert_input_normalizes_optional_fields() {
let input = build_asset_object_upsert_input(
"assetobj_001".to_string(),
"xushi-dev".to_string(),
"/generated-characters/hero/master.png".to_string(),
AssetObjectAccessPolicy::Private,
Some(" image/png ".to_string()),
128,
Some(" ".to_string()),
" character_visual ".to_string(),
Some(" job-001 ".to_string()),
None,
Some(" profile_001 ".to_string()),
None,
1_713_686_400_000_000,
)
.expect("input should build");
assert_eq!(input.object_key, "generated-characters/hero/master.png");
assert_eq!(input.content_type.as_deref(), Some("image/png"));
assert_eq!(input.content_hash, None);
assert_eq!(input.asset_kind, "character_visual");
assert_eq!(input.source_job_id.as_deref(), Some("job-001"));
assert_eq!(input.profile_id.as_deref(), Some("profile_001"));
}
#[test]
fn build_asset_object_record_formats_timestamp_micros_stably() {
let record = build_asset_object_record(AssetObjectUpsertSnapshot {
asset_object_id: "assetobj_001".to_string(),
bucket: "xushi-dev".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 128,
content_hash: None,
version: INITIAL_ASSET_OBJECT_VERSION,
source_job_id: None,
owner_user_id: None,
profile_id: None,
entity_id: None,
asset_kind: "character_visual".to_string(),
created_at_micros: 1_713_686_400_000_000,
updated_at_micros: 1_713_686_401_234_567,
});
assert_eq!(record.created_at, "1713686400.000000Z");
assert_eq!(record.updated_at, "1713686401.234567Z");
}
#[test]
fn build_asset_entity_binding_input_normalizes_binding_fields() {
let input = build_asset_entity_binding_input(
" assetbind_001 ".to_string(),
" assetobj_001 ".to_string(),
" character ".to_string(),
" hero_001 ".to_string(),
" primary_visual ".to_string(),
" character_visual ".to_string(),
Some(" user_001 ".to_string()),
Some(" ".to_string()),
1_713_686_400_000_000,
)
.expect("binding input should build");
assert_eq!(input.binding_id, "assetbind_001");
assert_eq!(input.asset_object_id, "assetobj_001");
assert_eq!(input.entity_kind, "character");
assert_eq!(input.entity_id, "hero_001");
assert_eq!(input.slot, "primary_visual");
assert_eq!(input.asset_kind, "character_visual");
assert_eq!(input.owner_user_id.as_deref(), Some("user_001"));
assert_eq!(input.profile_id, None);
}
#[test]
fn validate_asset_entity_binding_fields_rejects_missing_slot() {
let error = validate_asset_entity_binding_fields(
"assetbind_001",
"assetobj_001",
"character",
"hero_001",
" ",
"character_visual",
)
.expect_err("missing slot should fail");
assert_eq!(error, AssetObjectFieldError::MissingSlot);
}
}

View File

@@ -0,0 +1,254 @@
use std::{
collections::HashMap,
error::Error,
fmt,
sync::{Arc, Mutex},
};
use platform_oss::{OssClient, OssError, OssHeadObjectRequest};
use reqwest::Client;
use crate::{
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectRecord, AssetObjectUpsertSnapshot,
ConfirmAssetObjectInput, ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION,
build_asset_object_record, build_asset_object_upsert_input, generate_asset_object_id,
normalize_optional_value, validate_asset_object_fields,
};
#[derive(Clone, Debug)]
pub struct InMemoryAssetObjectStore {
inner: Arc<Mutex<HashMap<(String, String), AssetObjectUpsertSnapshot>>>,
}
#[derive(Clone, Debug)]
pub struct AssetObjectService {
store: InMemoryAssetObjectStore,
http_client: Client,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfirmAssetObjectError {
BucketMismatch,
ContentLengthMismatch,
Field(AssetObjectFieldError),
Oss(OssError),
Store(String),
}
impl Default for InMemoryAssetObjectStore {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl InMemoryAssetObjectStore {
fn upsert_by_location(
&self,
record: AssetObjectUpsertSnapshot,
) -> Result<AssetObjectRecord, ConfirmAssetObjectError> {
let mut state = self
.inner
.lock()
.map_err(|_| ConfirmAssetObjectError::Store("资产对象仓储锁已中毒".to_string()))?;
let key = (record.bucket.clone(), record.object_key.clone());
let next_record = match state.get(&key) {
Some(existing) => AssetObjectUpsertSnapshot {
asset_object_id: existing.asset_object_id.clone(),
created_at_micros: existing.created_at_micros,
..record
},
None => record,
};
state.insert(key, next_record.clone());
Ok(build_asset_object_record(next_record))
}
}
impl AssetObjectService {
pub fn new(store: InMemoryAssetObjectStore) -> Self {
Self {
store,
http_client: Client::new(),
}
}
pub async fn confirm_object(
&self,
oss_client: &OssClient,
input: ConfirmAssetObjectInput,
) -> Result<ConfirmAssetObjectResult, ConfirmAssetObjectError> {
let configured_bucket = oss_client.config_bucket().to_string();
let resolved_bucket = input
.bucket
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(configured_bucket.as_str())
.to_string();
if resolved_bucket != configured_bucket {
return Err(ConfirmAssetObjectError::BucketMismatch);
}
validate_asset_object_fields(
&resolved_bucket,
&input.object_key,
&input.asset_kind,
INITIAL_ASSET_OBJECT_VERSION,
)
.map_err(ConfirmAssetObjectError::Field)?;
let head = oss_client
.head_object(
&self.http_client,
OssHeadObjectRequest {
object_key: input.object_key.clone(),
},
)
.await
.map_err(ConfirmAssetObjectError::Oss)?;
if let Some(expected_length) = input.content_length
&& expected_length != head.content_length
{
return Err(ConfirmAssetObjectError::ContentLengthMismatch);
}
// 进程内 store 仅保留给无 SpacetimeDB 配置场景的最小 fallback因此这里继续使用稳定微秒值表达时间。
let now_micros = chrono_like_utc_now_micros();
let upsert_input = build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
resolved_bucket,
head.object_key,
input
.access_policy
.unwrap_or(AssetObjectAccessPolicy::Private),
head.content_type
.or_else(|| normalize_optional_value(input.content_type)),
head.content_length,
normalize_optional_value(input.content_hash),
input.asset_kind,
input.source_job_id,
input.owner_user_id,
input.profile_id,
input.entity_id,
now_micros,
)
.map_err(ConfirmAssetObjectError::Field)?;
let record = self.store.upsert_by_location(AssetObjectUpsertSnapshot {
asset_object_id: upsert_input.asset_object_id,
bucket: upsert_input.bucket,
object_key: upsert_input.object_key,
access_policy: upsert_input.access_policy,
content_type: upsert_input.content_type,
content_length: upsert_input.content_length,
content_hash: upsert_input.content_hash,
version: upsert_input.version,
source_job_id: upsert_input.source_job_id,
owner_user_id: upsert_input.owner_user_id,
profile_id: upsert_input.profile_id,
entity_id: upsert_input.entity_id,
asset_kind: upsert_input.asset_kind,
created_at_micros: now_micros,
updated_at_micros: now_micros,
})?;
Ok(ConfirmAssetObjectResult { record })
}
}
impl fmt::Display for ConfirmAssetObjectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BucketMismatch => f.write_str("bucket 与当前服务端 OSS bucket 不一致"),
Self::ContentLengthMismatch => {
f.write_str("客户端声明的 contentLength 与 OSS 实际对象大小不一致")
}
Self::Field(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for ConfirmAssetObjectError {}
impl From<AssetObjectFieldError> for ConfirmAssetObjectError {
fn from(value: AssetObjectFieldError) -> Self {
Self::Field(value)
}
}
impl From<OssError> for ConfirmAssetObjectError {
fn from(value: OssError) -> Self {
Self::Oss(value)
}
}
fn chrono_like_utc_now_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_store_upsert_keeps_same_primary_id_for_same_bucket_and_object_key() {
let store = InMemoryAssetObjectStore::default();
let first = store
.upsert_by_location(AssetObjectUpsertSnapshot {
asset_object_id: "assetobj_first".to_string(),
bucket: "xushi-dev".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 100,
content_hash: None,
version: INITIAL_ASSET_OBJECT_VERSION,
source_job_id: None,
owner_user_id: None,
profile_id: None,
entity_id: None,
asset_kind: "character_visual".to_string(),
created_at_micros: 1_000_000,
updated_at_micros: 1_000_000,
})
.expect("first upsert should succeed");
let second = store
.upsert_by_location(AssetObjectUpsertSnapshot {
asset_object_id: "assetobj_second".to_string(),
bucket: "xushi-dev".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 100,
content_hash: None,
version: INITIAL_ASSET_OBJECT_VERSION,
source_job_id: None,
owner_user_id: None,
profile_id: None,
entity_id: None,
asset_kind: "character_visual".to_string(),
created_at_micros: 2_000_000,
updated_at_micros: 2_000_000,
})
.expect("second upsert should succeed");
assert_eq!(first.asset_object_id, "assetobj_first");
assert_eq!(second.asset_object_id, "assetobj_first");
assert_eq!(second.created_at, "1.000000Z");
assert_eq!(second.updated_at, "2.000000Z");
}
}

View File

@@ -0,0 +1,18 @@
mod asset_object_core;
#[cfg(feature = "server-service")]
mod asset_object_service;
pub use asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectProcedureResult, AssetObjectRecord,
AssetObjectUpsertInput, AssetObjectUpsertSnapshot, ConfirmAssetObjectInput,
ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
build_asset_entity_binding_record, build_asset_object_record, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
};
#[cfg(feature = "server-service")]
pub use asset_object_service::{
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
};

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,12 @@ license.workspace = true
[dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
time = { version = "0.3", features = ["formatting"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@@ -17,16 +17,23 @@
1. `PostObject` 浏览器直传签名
2.`/generated-*` 公开前缀到 OSS `object_key` 的兼容映射
3. `object_key -> publicUrl` 解析
4. `x-oss-meta-*` 元数据归一化与大小限制校验
5. `content-type``content-length-range``success_action_status` policy 条件生成
3. 私有对象短期签名读 URL
4. 私有对象 `HEAD Object` 探测
5. 服务端 `PutObject` 上传 helper
6. `x-oss-meta-*` 元数据归一化与大小限制校验
7. `content-type``content-length-range``success_action_status` policy 条件生成
当前仍未落地的内容:
1. `STS` 临时授权
2. 服务端上传 helper
3. 私有对象签名 URL
4. 对象确认与业务绑定
1. `STS` 真实临时授权下发
2. multipart 分片上传
3. 内容 hash 自动计算与标签写入
补充说明:
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract避免浏览器拿到 OSS 写权限。
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`
## 3. 边界约束

View File

@@ -1,7 +1,9 @@
use std::{collections::BTreeMap, error::Error, fmt};
use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use httpdate::fmt_http_date;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha1::Sha1;
@@ -10,6 +12,7 @@ use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha1 = Hmac<Sha1>;
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
@@ -46,7 +49,7 @@ pub struct OssConfig {
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
@@ -65,6 +68,28 @@ pub struct OssPostObjectRequest {
pub success_action_status: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssSignedGetObjectUrlRequest {
pub object_key: String,
pub expire_seconds: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectRequest {
pub object_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPutObjectRequest {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub metadata: BTreeMap<String, String>,
pub body: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectResponse {
#[serde(rename = "signatureVersion")]
@@ -77,8 +102,6 @@ pub struct OssPostObjectResponse {
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "publicUrl", skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub access: OssObjectAccess,
@@ -94,6 +117,51 @@ pub struct OssPostObjectResponse {
pub form_fields: OssPostObjectFormFields,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssSignedGetObjectUrlResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
#[serde(rename = "signedUrl")]
pub signed_url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectResponse {
pub bucket: String,
pub object_key: String,
pub content_length: u64,
pub content_type: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPutObjectResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(rename = "contentLength")]
pub content_length: u64,
pub access: OssObjectAccess,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectFormFields {
pub key: String,
@@ -119,6 +187,8 @@ pub struct OssClient {
pub enum OssError {
InvalidConfig(String),
InvalidRequest(String),
ObjectNotFound(String),
Request(String),
SerializePolicy(String),
Sign(String),
}
@@ -157,6 +227,12 @@ impl LegacyAssetPrefix {
pub fn as_public_path_prefix(&self) -> String {
format!("/{}", self.as_str())
}
pub fn from_object_key(raw: &str) -> Option<Self> {
let normalized = raw.trim().trim_start_matches('/').trim();
let prefix = normalized.split('/').next()?;
Self::parse(prefix)
}
}
impl OssConfig {
@@ -166,7 +242,7 @@ impl OssConfig {
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
@@ -176,7 +252,12 @@ impl OssConfig {
let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?;
let access_key_secret =
normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?;
let public_base_url = normalize_optional_base_url(public_base_url);
if default_read_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS 私有读签名有效期必须大于 0".to_string(),
));
}
if default_post_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
@@ -201,7 +282,7 @@ impl OssConfig {
endpoint,
access_key_id,
access_key_secret,
public_base_url,
default_read_expire_seconds,
default_post_expire_seconds,
default_post_max_size_bytes,
default_success_action_status,
@@ -219,6 +300,14 @@ impl OssConfig {
pub fn bucket(&self) -> &str {
&self.bucket
}
pub fn access_key_id(&self) -> &str {
&self.access_key_id
}
pub fn access_key_secret(&self) -> &str {
&self.access_key_secret
}
}
impl OssClient {
@@ -226,6 +315,10 @@ impl OssClient {
Self { config }
}
pub fn config_bucket(&self) -> &str {
self.config.bucket()
}
pub fn sign_post_object(
&self,
request: OssPostObjectRequest,
@@ -293,11 +386,6 @@ impl OssClient {
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?;
let public_url = match request.access {
OssObjectAccess::Public => Some(self.public_url_for(&object_key)),
OssObjectAccess::Private => None,
};
Ok(OssPostObjectResponse {
signature_version: "v1",
provider: "aliyun-oss",
@@ -306,7 +394,6 @@ impl OssClient {
host: self.config.upload_host(),
object_key: object_key.clone(),
legacy_public_path,
public_url,
content_type: content_type.clone(),
access: request.access,
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
@@ -325,14 +412,189 @@ impl OssClient {
})
}
fn public_url_for(&self, object_key: &str) -> String {
let base_url = self
.config
.public_base_url
.clone()
.unwrap_or_else(|| self.config.upload_host());
// 私有 bucket 的对象读取统一走短期签名 URL避免把长期主凭证下发给浏览器。
pub fn sign_get_object_url(
&self,
request: OssSignedGetObjectUrlRequest,
) -> Result<OssSignedGetObjectUrlResponse, OssError> {
let expire_seconds = request
.expire_seconds
.unwrap_or(self.config.default_read_expire_seconds);
format!("{}/{}", base_url.trim_end_matches('/'), object_key)
if expire_seconds == 0 {
return Err(OssError::InvalidRequest(
"expireSeconds 必须大于 0".to_string(),
));
}
let object_key = normalize_object_key(&request.object_key)?;
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
)?))
.ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
let expires_at_text = expires_at
.format(&Rfc3339)
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
let expires_epoch_seconds = expires_at.unix_timestamp();
let canonical_resource = build_canonical_object_resource(&self.config.bucket, &object_key);
let string_to_sign = format!("GET\n\n\n{expires_epoch_seconds}\n{canonical_resource}");
let signature = sign_policy(&self.config.access_key_secret, &string_to_sign)?;
let signed_url = format!(
"{}/{}?OSSAccessKeyId={}&Expires={}&Signature={}",
self.config.upload_host(),
encode_url_path(&object_key),
encode_url_query_value(&self.config.access_key_id),
expires_epoch_seconds,
encode_url_query_value(&signature)
);
Ok(OssSignedGetObjectUrlResponse {
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key,
expires_at: expires_at_text,
signed_url,
})
}
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
pub async fn head_object(
&self,
client: &reqwest::Client,
request: OssHeadObjectRequest,
) -> Result<OssHeadObjectResponse, OssError> {
let object_key = normalize_object_key(&request.object_key)?;
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
let response = send_signed_request(
client,
&self.config,
Method::HEAD,
Some(&object_key),
target_url,
)
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(OssError::ObjectNotFound(format!(
"OSS 对象不存在:{}",
request.object_key
)));
}
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS HEAD Object 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let content_length = headers
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssHeadObjectResponse {
bucket: self.config.bucket.clone(),
object_key,
content_length,
content_type,
etag,
last_modified,
})
}
// AI 生成资源默认由服务端上传 OSSWeb 端只拿签名读地址,不直接持有写权限。
pub async fn put_object(
&self,
client: &reqwest::Client,
request: OssPutObjectRequest,
) -> Result<OssPutObjectResponse, OssError> {
if request.body.is_empty() {
return Err(OssError::InvalidRequest(
"服务端上传对象内容不能为空".to_string(),
));
}
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name)?;
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
let content_length = u64::try_from(request.body.len())
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
let builder = signed_request_builder(
client,
&self.config,
Method::PUT,
Some(&object_key),
target_url,
content_type.as_deref(),
&metadata,
)?
.header(reqwest::header::CONTENT_LENGTH, content_length)
.body(request.body);
let response = builder
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS PutObject 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssPutObjectResponse {
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
legacy_public_path: format!("/{object_key}"),
object_key,
content_type,
content_length,
access: request.access,
etag,
last_modified,
})
}
}
@@ -341,6 +603,8 @@ impl fmt::Display for OssError {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::ObjectNotFound(message)
| Self::Request(message)
| Self::SerializePolicy(message)
| Self::Sign(message) => f.write_str(message),
}
@@ -383,6 +647,23 @@ fn build_policy_json(
})
}
fn build_object_url(
bucket: &str,
endpoint: &str,
object_key: &str,
) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))
.map_err(|error| error.to_string())?;
url = url
.join(object_key.trim_start_matches('/'))
.map_err(|error| error.to_string())?;
Ok(url)
}
fn build_canonical_object_resource(bucket: &str, object_key: &str) -> String {
format!("/{bucket}/{object_key}")
}
fn build_object_key(
prefix: LegacyAssetPrefix,
path_segments: &[String],
@@ -395,6 +676,42 @@ fn build_object_key(
parts.join("/")
}
fn normalize_object_key(raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
if normalized.is_empty() {
return Err(OssError::InvalidRequest("objectKey 不能为空".to_string()));
}
if LegacyAssetPrefix::from_object_key(&normalized).is_none() {
return Err(OssError::InvalidRequest(
"objectKey 必须落在受支持的 generated-* 前缀下".to_string(),
));
}
let segments = normalized.split('/').collect::<Vec<_>>();
if segments.len() < 2 {
return Err(OssError::InvalidRequest(
"objectKey 至少需要包含前缀和文件名".to_string(),
));
}
for segment in &segments {
if segment.is_empty() || *segment == "." || *segment == ".." {
return Err(OssError::InvalidRequest(
"objectKey 包含非法路径片段".to_string(),
));
}
if segment.contains('\\') {
return Err(OssError::InvalidRequest(
"objectKey 不能包含反斜杠".to_string(),
));
}
}
Ok(normalized)
}
fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String {
let mut parts = Vec::with_capacity(path_segments.len() + 1);
parts.push(prefix.as_str().to_string());
@@ -541,10 +858,6 @@ fn normalize_optional_value(value: Option<String>) -> Option<String> {
})
}
fn normalize_optional_base_url(value: Option<String>) -> Option<String> {
normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string())
}
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
let endpoint = raw
.trim()
@@ -588,6 +901,105 @@ fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String,
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
}
async fn send_signed_request(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
) -> Result<reqwest::Response, OssError> {
signed_request_builder(
client,
config,
method,
object_key,
target_url,
None,
&BTreeMap::new(),
)?
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))
}
fn signed_request_builder(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
content_type: Option<&str>,
oss_headers: &BTreeMap<String, String>,
) -> Result<reqwest::RequestBuilder, OssError> {
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => {
build_canonical_object_resource(config.bucket(), object_key.trim_start_matches('/'))
}
None => format!("/{}/", config.bucket()),
};
let canonicalized_oss_headers = build_canonicalized_oss_headers(oss_headers);
let string_to_sign = format!(
"{}\n\n{}\n{}\n{}{}",
method.as_str(),
content_type.unwrap_or_default(),
date,
canonicalized_oss_headers,
canonical_resource
);
let signature = sign_policy(config.access_key_secret(), &string_to_sign)?;
let mut builder = client
.request(method, target_url)
.header("Date", date)
.header(
"Authorization",
format!("OSS {}:{}", config.access_key_id(), signature),
);
if let Some(content_type) = content_type {
builder = builder.header(reqwest::header::CONTENT_TYPE, content_type);
}
for (key, value) in oss_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
fn build_canonicalized_oss_headers(headers: &BTreeMap<String, String>) -> String {
headers
.iter()
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
.collect::<String>()
}
fn encode_url_path(path: &str) -> String {
path.split('/')
.map(encode_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_url_query_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
_ => {
use std::fmt::Write as _;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
@@ -599,7 +1011,7 @@ mod tests {
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
Some("https://cdn.genarrative.local".to_string()),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
@@ -618,7 +1030,7 @@ mod tests {
}
#[test]
fn sign_post_object_returns_legacy_compatible_key_and_urls() {
fn sign_post_object_returns_bucket_and_object_key_for_private_storage_truth() {
let client = build_client();
let mut metadata = BTreeMap::new();
metadata.insert("asset-kind".to_string(), "character-visual".to_string());
@@ -650,12 +1062,7 @@ mod tests {
response.legacy_public_path,
"/generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(
response.public_url.as_deref(),
Some(
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
)
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.form_fields.oss_access_key_id,
"test-access-key-id".to_string()
@@ -713,7 +1120,7 @@ mod tests {
policy["conditions"][4],
json!(["eq", "$content-type", "image/png"])
);
assert!(response.public_url.is_none());
assert_eq!(response.bucket, "genarrative-assets".to_string());
}
#[test]
@@ -725,4 +1132,145 @@ mod tests {
OssError::InvalidRequest("fileName 不能为空".to_string())
);
}
#[test]
fn sign_get_object_url_returns_signed_private_read_url() {
let client = build_client();
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "generated-characters/hero_001/visual/asset_01/master.png".to_string(),
expire_seconds: Some(300),
})
.expect("signed get url should build");
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.object_key,
"generated-characters/hero_001/visual/asset_01/master.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/generated-characters/hero_001/visual/asset_01/master.png?"));
assert!(
response
.signed_url
.contains("OSSAccessKeyId=test-access-key-id")
);
assert!(response.signed_url.contains("&Expires="));
assert!(response.signed_url.contains("&Signature="));
}
#[test]
fn sign_get_object_url_rejects_unsupported_prefix() {
let client = build_client();
let error = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "workflow-cache/task-1.json".to_string(),
expire_seconds: Some(300),
})
.expect_err("unsupported prefix should fail");
assert_eq!(
error,
OssError::InvalidRequest("objectKey 必须落在受支持的 generated-* 前缀下".to_string())
);
}
#[test]
fn legacy_prefix_can_be_resolved_from_object_key() {
assert_eq!(
LegacyAssetPrefix::from_object_key(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png"
),
Some(LegacyAssetPrefix::CustomWorldScenes)
);
assert_eq!(
LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"),
None
);
}
#[test]
fn put_object_request_reuses_generated_object_key_contract() {
let request = OssPutObjectRequest {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec!["Profile 001".to_string(), "asset_01".to_string()],
file_name: "Cover.PNG".to_string(),
content_type: Some(" image/png ".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::from([
("asset_kind".to_string(), "custom_world_cover".to_string()),
("source job id".to_string(), "job_001".to_string()),
]),
body: b"cover-bytes".to_vec(),
};
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name).expect("file name should sanitize");
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let metadata = normalize_metadata(request.metadata).expect("metadata should normalize");
assert_eq!(
object_key,
"generated-custom-world-covers/profile-001/asset_01/cover.png"
);
assert_eq!(
metadata.get("x-oss-meta-asset-kind"),
Some(&"custom_world_cover".to_string())
);
assert_eq!(
metadata.get("x-oss-meta-source-job-id"),
Some(&"job_001".to_string())
);
}
#[test]
fn canonicalized_oss_headers_matches_oss_v1_upload_signature_shape() {
let headers = BTreeMap::from([
(
"x-oss-meta-source-job-id".to_string(),
" job_001 ".to_string(),
),
(
"x-oss-meta-asset-kind".to_string(),
"character_visual".to_string(),
),
]);
assert_eq!(
build_canonicalized_oss_headers(&headers),
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}
#[tokio::test]
async fn put_object_rejects_empty_body_before_calling_oss() {
let client = build_client();
let error = client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Characters,
path_segments: vec!["hero".to_string()],
file_name: "master.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
body: Vec::new(),
},
)
.await
.expect_err("empty server upload should fail before network");
assert_eq!(
error,
OssError::InvalidRequest("服务端上传对象内容不能为空".to_string())
);
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "spacetime-client"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
module-assets = { path = "../module-assets" }
spacetimedb-sdk = "2.1.0"
tokio = { version = "1", features = ["rt", "sync", "time"] }

View File

@@ -12,12 +12,20 @@
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入 bindings 生成、调用封装与订阅实现。
当前目录已不再只是占位,当前阶段已经落下:
1. 通过 `spacetime generate --lang rust --include-private` 生成的最小 Rust bindings
2. `DbConnection` 连接封装
3. `confirm_asset_object_and_return` procedure 的最小调用适配
4. `bind_asset_object_to_entity_and_return` procedure 的最小调用适配
5. `api-server` 所需的 `asset_object` 确认与 `asset_entity_binding` 绑定返回值转换
`confirm_asset_object_and_return``bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure本地联调会表现为连接建立但请求长期没有回调最终等到 idle timeout。
后续与本 package 直接相关的任务包括:
1. 设计 bindings 生成与更新流程
2. 设计 reducer、view、订阅的统一调用接口
1. 固化 bindings 生成与更新脚本
2. 设计 reducer、procedure、view、订阅的统一调用接口
3. 设计身份透传与连接配置策略
4. 设计 Axum / worker / 测试环境下的客户端复用方式

View File

@@ -0,0 +1,306 @@
pub mod module_bindings;
use std::{
error::Error,
fmt,
sync::{Arc, Mutex},
time::Duration,
};
use module_assets::{
AssetEntityBindingRecord, AssetObjectAccessPolicy, AssetObjectRecord,
build_asset_entity_binding_record, build_asset_object_record,
};
use spacetimedb_sdk::DbContext;
use tokio::{sync::oneshot, time::timeout};
use crate::module_bindings::{
AssetEntityBindingInput as BindingAssetEntityBindingInput,
AssetEntityBindingProcedureResult as BindingAssetEntityBindingProcedureResult,
AssetEntityBindingSnapshot as BindingAssetEntityBindingSnapshot,
AssetObjectProcedureResult as BindingAssetObjectProcedureResult,
AssetObjectUpsertInput as BindingAssetObjectUpsertInput,
AssetObjectUpsertSnapshot as BindingAssetObjectUpsertSnapshot, DbConnection,
bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return as _,
confirm_asset_object_and_return_procedure::confirm_asset_object_and_return as _,
};
#[derive(Clone, Debug)]
pub struct SpacetimeClientConfig {
pub server_url: String,
pub database: String,
pub token: Option<String>,
}
#[derive(Clone, Debug)]
pub struct SpacetimeClient {
config: SpacetimeClientConfig,
}
#[derive(Debug)]
pub enum SpacetimeClientError {
Build(String),
ConnectDropped,
Procedure(String),
Runtime(String),
Timeout,
}
const CONFIRM_ASSET_OBJECT_TIMEOUT: Duration = Duration::from_secs(10);
type ProcedureResultSender<T> =
Arc<Mutex<Option<oneshot::Sender<Result<T, SpacetimeClientError>>>>>;
impl SpacetimeClient {
pub fn new(config: SpacetimeClientConfig) -> Self {
Self { config }
}
pub async fn confirm_asset_object(
&self,
input: module_assets::AssetObjectUpsertInput,
) -> Result<AssetObjectRecord, SpacetimeClientError> {
let procedure_input = map_upsert_input(input);
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.confirm_asset_object_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn bind_asset_object_to_entity(
&self,
input: module_assets::AssetEntityBindingInput,
) -> Result<AssetEntityBindingRecord, SpacetimeClientError> {
let procedure_input = map_entity_binding_input(input);
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_entity_binding_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
async fn call_after_connect<T>(
&self,
call: impl FnOnce(&DbConnection, ProcedureResultSender<T>) + Send + 'static,
) -> Result<T, SpacetimeClientError>
where
T: Send + 'static,
{
let config = self.config.clone();
let (sender, receiver) = oneshot::channel();
let result_sender = Arc::new(Mutex::new(Some(sender)));
let connect_sender = result_sender.clone();
let disconnect_sender = result_sender.clone();
let connection = tokio::task::spawn_blocking(move || {
DbConnection::builder()
.with_uri(config.server_url)
.with_database_name(config.database)
.with_token(config.token)
.on_connect(move |connection, _, _| {
// SDK 收到 IdentityToken 后才调用 procedure避免 WebSocket 已建好但身份握手未完成时丢请求。
call(connection, connect_sender);
})
.on_disconnect(move |_, error| {
let message = error
.map(|error| error.to_string())
.unwrap_or_else(|| "SpacetimeDB 连接在 procedure 返回前断开".to_string());
send_once(
&disconnect_sender,
Err(SpacetimeClientError::Procedure(message)),
);
})
.build()
.map_err(|error| SpacetimeClientError::Build(error.to_string()))
})
.await
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??;
let runner = connection.run_threaded();
let result = timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await;
let _ = connection.disconnect();
// SDK 线程会在断开消息被处理后自行退出HTTP 请求不能同步等待该线程,否则 Windows 本地联调可能卡在收尾阶段。
drop(runner);
result
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|_| SpacetimeClientError::ConnectDropped)?
}
}
fn send_once<T>(sender: &ProcedureResultSender<T>, result: Result<T, SpacetimeClientError>) {
if let Some(sender) = sender
.lock()
.expect("spacetime result sender should not poison")
.take()
{
let _ = sender.send(result);
}
}
fn map_entity_binding_input(
input: module_assets::AssetEntityBindingInput,
) -> BindingAssetEntityBindingInput {
BindingAssetEntityBindingInput {
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,
updated_at_micros: input.updated_at_micros,
}
}
fn map_upsert_input(input: module_assets::AssetObjectUpsertInput) -> BindingAssetObjectUpsertInput {
BindingAssetObjectUpsertInput {
asset_object_id: input.asset_object_id,
bucket: input.bucket,
object_key: input.object_key,
access_policy: map_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,
updated_at_micros: input.updated_at_micros,
}
}
fn map_procedure_result(
result: BindingAssetObjectProcedureResult,
) -> Result<AssetObjectRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回对象快照".to_string())
})?;
Ok(build_asset_object_record(map_snapshot(snapshot)))
}
fn map_entity_binding_procedure_result(
result: BindingAssetEntityBindingProcedureResult,
) -> Result<AssetEntityBindingRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回绑定快照".to_string())
})?;
Ok(build_asset_entity_binding_record(
map_entity_binding_snapshot(snapshot),
))
}
fn map_entity_binding_snapshot(
snapshot: BindingAssetEntityBindingSnapshot,
) -> module_assets::AssetEntityBindingSnapshot {
module_assets::AssetEntityBindingSnapshot {
binding_id: snapshot.binding_id,
asset_object_id: snapshot.asset_object_id,
entity_kind: snapshot.entity_kind,
entity_id: snapshot.entity_id,
slot: snapshot.slot,
asset_kind: snapshot.asset_kind,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
fn map_snapshot(
snapshot: BindingAssetObjectUpsertSnapshot,
) -> module_assets::AssetObjectUpsertSnapshot {
module_assets::AssetObjectUpsertSnapshot {
asset_object_id: snapshot.asset_object_id,
bucket: snapshot.bucket,
object_key: snapshot.object_key,
access_policy: map_access_policy_back(snapshot.access_policy),
content_type: snapshot.content_type,
content_length: snapshot.content_length,
content_hash: snapshot.content_hash,
version: snapshot.version,
source_job_id: snapshot.source_job_id,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
entity_id: snapshot.entity_id,
asset_kind: snapshot.asset_kind,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
fn map_access_policy(
value: AssetObjectAccessPolicy,
) -> crate::module_bindings::AssetObjectAccessPolicy {
match value {
AssetObjectAccessPolicy::Private => {
crate::module_bindings::AssetObjectAccessPolicy::Private
}
AssetObjectAccessPolicy::PublicRead => {
crate::module_bindings::AssetObjectAccessPolicy::PublicRead
}
}
}
fn map_access_policy_back(
value: crate::module_bindings::AssetObjectAccessPolicy,
) -> AssetObjectAccessPolicy {
match value {
crate::module_bindings::AssetObjectAccessPolicy::Private => {
AssetObjectAccessPolicy::Private
}
crate::module_bindings::AssetObjectAccessPolicy::PublicRead => {
AssetObjectAccessPolicy::PublicRead
}
}
}
impl fmt::Display for SpacetimeClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => {
f.write_str(message)
}
Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"),
Self::Timeout => f.write_str("SpacetimeDB procedure 调用超时"),
}
}
}
impl Error for SpacetimeClientError {}

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetEntityBindingInput {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetEntityBindingInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AssetEntityBindingProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetEntityBindingSnapshot {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetEntityBindingSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,78 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetEntityBinding {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for AssetEntityBinding {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AssetEntityBinding`.
///
/// Provides typed access to columns for query building.
pub struct AssetEntityBindingCols {
pub binding_id: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub asset_object_id: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub entity_kind: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub entity_id: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub slot: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub asset_kind: __sdk::__query_builder::Col<AssetEntityBinding, String>,
pub owner_user_id: __sdk::__query_builder::Col<AssetEntityBinding, Option<String>>,
pub profile_id: __sdk::__query_builder::Col<AssetEntityBinding, Option<String>>,
pub created_at: __sdk::__query_builder::Col<AssetEntityBinding, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<AssetEntityBinding, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for AssetEntityBinding {
type Cols = AssetEntityBindingCols;
fn cols(table_name: &'static str) -> Self::Cols {
AssetEntityBindingCols {
binding_id: __sdk::__query_builder::Col::new(table_name, "binding_id"),
asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"),
entity_kind: __sdk::__query_builder::Col::new(table_name, "entity_kind"),
entity_id: __sdk::__query_builder::Col::new(table_name, "entity_id"),
slot: __sdk::__query_builder::Col::new(table_name, "slot"),
asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `AssetEntityBinding`.
///
/// Provides typed access to indexed columns for query building.
pub struct AssetEntityBindingIxCols {
pub asset_object_id: __sdk::__query_builder::IxCol<AssetEntityBinding, String>,
pub binding_id: __sdk::__query_builder::IxCol<AssetEntityBinding, String>,
}
impl __sdk::__query_builder::HasIxCols for AssetEntityBinding {
type IxCols = AssetEntityBindingIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
AssetEntityBindingIxCols {
asset_object_id: __sdk::__query_builder::IxCol::new(table_name, "asset_object_id"),
binding_id: __sdk::__query_builder::IxCol::new(table_name, "binding_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AssetEntityBinding {}

View File

@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum AssetObjectAccessPolicy {
Private,
PublicRead,
}
impl __sdk::InModule for AssetObjectAccessPolicy {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetObjectProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AssetObjectProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,95 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_access_policy_type::AssetObjectAccessPolicy;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetObject {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for AssetObject {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AssetObject`.
///
/// Provides typed access to columns for query building.
pub struct AssetObjectCols {
pub asset_object_id: __sdk::__query_builder::Col<AssetObject, String>,
pub bucket: __sdk::__query_builder::Col<AssetObject, String>,
pub object_key: __sdk::__query_builder::Col<AssetObject, String>,
pub access_policy: __sdk::__query_builder::Col<AssetObject, AssetObjectAccessPolicy>,
pub content_type: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub content_length: __sdk::__query_builder::Col<AssetObject, u64>,
pub content_hash: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub version: __sdk::__query_builder::Col<AssetObject, u32>,
pub source_job_id: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub owner_user_id: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub profile_id: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub entity_id: __sdk::__query_builder::Col<AssetObject, Option<String>>,
pub asset_kind: __sdk::__query_builder::Col<AssetObject, String>,
pub created_at: __sdk::__query_builder::Col<AssetObject, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<AssetObject, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for AssetObject {
type Cols = AssetObjectCols;
fn cols(table_name: &'static str) -> Self::Cols {
AssetObjectCols {
asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"),
bucket: __sdk::__query_builder::Col::new(table_name, "bucket"),
object_key: __sdk::__query_builder::Col::new(table_name, "object_key"),
access_policy: __sdk::__query_builder::Col::new(table_name, "access_policy"),
content_type: __sdk::__query_builder::Col::new(table_name, "content_type"),
content_length: __sdk::__query_builder::Col::new(table_name, "content_length"),
content_hash: __sdk::__query_builder::Col::new(table_name, "content_hash"),
version: __sdk::__query_builder::Col::new(table_name, "version"),
source_job_id: __sdk::__query_builder::Col::new(table_name, "source_job_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
entity_id: __sdk::__query_builder::Col::new(table_name, "entity_id"),
asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `AssetObject`.
///
/// Provides typed access to indexed columns for query building.
pub struct AssetObjectIxCols {
pub asset_kind: __sdk::__query_builder::IxCol<AssetObject, String>,
pub asset_object_id: __sdk::__query_builder::IxCol<AssetObject, String>,
}
impl __sdk::__query_builder::HasIxCols for AssetObject {
type IxCols = AssetObjectIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
AssetObjectIxCols {
asset_kind: __sdk::__query_builder::IxCol::new(table_name, "asset_kind"),
asset_object_id: __sdk::__query_builder::IxCol::new(table_name, "asset_object_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AssetObject {}

View File

@@ -0,0 +1,30 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_access_policy_type::AssetObjectAccessPolicy;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetObjectUpsertInput {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetObjectUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,31 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_access_policy_type::AssetObjectAccessPolicy;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetObjectUpsertSnapshot {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: AssetObjectAccessPolicy,
pub content_type: Option<String>,
pub content_length: u64,
pub content_hash: Option<String>,
pub version: u32,
pub source_job_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetObjectUpsertSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_entity_binding_input_type::AssetEntityBindingInput;
use super::asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct BindAssetObjectToEntityAndReturnArgs {
pub input: AssetEntityBindingInput,
}
impl __sdk::InModule for BindAssetObjectToEntityAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `bind_asset_object_to_entity_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait bind_asset_object_to_entity_and_return {
fn bind_asset_object_to_entity_and_return(&self, input: AssetEntityBindingInput) {
self.bind_asset_object_to_entity_and_return_then(input, |_, _| {});
}
fn bind_asset_object_to_entity_and_return_then(
&self,
input: AssetEntityBindingInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl bind_asset_object_to_entity_and_return for super::RemoteProcedures {
fn bind_asset_object_to_entity_and_return_then(
&self,
input: AssetEntityBindingInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetEntityBindingProcedureResult>(
"bind_asset_object_to_entity_and_return",
BindAssetObjectToEntityAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,68 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_entity_binding_input_type::AssetEntityBindingInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub(super) struct BindAssetObjectToEntityArgs {
pub input: AssetEntityBindingInput,
}
impl From<BindAssetObjectToEntityArgs> for super::Reducer {
fn from(args: BindAssetObjectToEntityArgs) -> Self {
Self::BindAssetObjectToEntity { input: args.input }
}
}
impl __sdk::InModule for BindAssetObjectToEntityArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the reducer `bind_asset_object_to_entity`.
///
/// Implemented for [`super::RemoteReducers`].
pub trait bind_asset_object_to_entity {
/// Request that the remote module invoke the reducer `bind_asset_object_to_entity` to run as soon as possible.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`bind_asset_object_to_entity:bind_asset_object_to_entity_then`] to run a callback after the reducer completes.
fn bind_asset_object_to_entity(&self, input: AssetEntityBindingInput) -> __sdk::Result<()> {
self.bind_asset_object_to_entity_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `bind_asset_object_to_entity` to run as soon as possible,
/// registering `callback` to run when we are notified that the reducer completed.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and its status can be observed with the `callback`.
fn bind_asset_object_to_entity_then(
&self,
input: AssetEntityBindingInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()>;
}
impl bind_asset_object_to_entity for super::RemoteReducers {
fn bind_asset_object_to_entity_then(
&self,
input: AssetEntityBindingInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(BindAssetObjectToEntityArgs { input }, callback)
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_procedure_result_type::AssetObjectProcedureResult;
use super::asset_object_upsert_input_type::AssetObjectUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ConfirmAssetObjectAndReturnArgs {
pub input: AssetObjectUpsertInput,
}
impl __sdk::InModule for ConfirmAssetObjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `confirm_asset_object_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait confirm_asset_object_and_return {
fn confirm_asset_object_and_return(&self, input: AssetObjectUpsertInput) {
self.confirm_asset_object_and_return_then(input, |_, _| {});
}
fn confirm_asset_object_and_return_then(
&self,
input: AssetObjectUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl confirm_asset_object_and_return for super::RemoteProcedures {
fn confirm_asset_object_and_return_then(
&self,
input: AssetObjectUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetObjectProcedureResult>(
"confirm_asset_object_and_return",
ConfirmAssetObjectAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,68 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_upsert_input_type::AssetObjectUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub(super) struct ConfirmAssetObjectArgs {
pub input: AssetObjectUpsertInput,
}
impl From<ConfirmAssetObjectArgs> for super::Reducer {
fn from(args: ConfirmAssetObjectArgs) -> Self {
Self::ConfirmAssetObject { input: args.input }
}
}
impl __sdk::InModule for ConfirmAssetObjectArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the reducer `confirm_asset_object`.
///
/// Implemented for [`super::RemoteReducers`].
pub trait confirm_asset_object {
/// Request that the remote module invoke the reducer `confirm_asset_object` to run as soon as possible.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`confirm_asset_object:confirm_asset_object_then`] to run a callback after the reducer completes.
fn confirm_asset_object(&self, input: AssetObjectUpsertInput) -> __sdk::Result<()> {
self.confirm_asset_object_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `confirm_asset_object` to run as soon as possible,
/// registering `callback` to run when we are notified that the reducer completed.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and its status can be observed with the `callback`.
fn confirm_asset_object_then(
&self,
input: AssetObjectUpsertInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()>;
}
impl confirm_asset_object for super::RemoteReducers {
fn confirm_asset_object_then(
&self,
input: AssetObjectUpsertInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(ConfirmAssetObjectArgs { input }, callback)
}
}

View File

@@ -0,0 +1,823 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod asset_entity_binding_input_type;
pub mod asset_entity_binding_procedure_result_type;
pub mod asset_entity_binding_snapshot_type;
pub mod asset_entity_binding_type;
pub mod asset_object_access_policy_type;
pub mod asset_object_procedure_result_type;
pub mod asset_object_type;
pub mod asset_object_upsert_input_type;
pub mod asset_object_upsert_snapshot_type;
pub mod bind_asset_object_to_entity_and_return_procedure;
pub mod bind_asset_object_to_entity_reducer;
pub mod confirm_asset_object_and_return_procedure;
pub mod confirm_asset_object_reducer;
pub use asset_entity_binding_input_type::AssetEntityBindingInput;
pub use asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult;
pub use asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot;
pub use asset_entity_binding_type::AssetEntityBinding;
pub use asset_object_access_policy_type::AssetObjectAccessPolicy;
pub use asset_object_procedure_result_type::AssetObjectProcedureResult;
pub use asset_object_type::AssetObject;
pub use asset_object_upsert_input_type::AssetObjectUpsertInput;
pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return;
pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity;
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
pub use confirm_asset_object_reducer::confirm_asset_object;
#[derive(Clone, PartialEq, Debug)]
/// One of the reducers defined by this module.
///
/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events
/// to indicate which reducer caused the event.
pub enum Reducer {
BindAssetObjectToEntity { input: AssetEntityBindingInput },
ConfirmAssetObject { input: AssetObjectUpsertInput },
}
impl __sdk::InModule for Reducer {
type Module = RemoteModule;
}
impl __sdk::Reducer for Reducer {
fn reducer_name(&self) -> &'static str {
match self {
Reducer::BindAssetObjectToEntity { .. } => "bind_asset_object_to_entity",
Reducer::ConfirmAssetObject { .. } => "confirm_asset_object",
_ => unreachable!(),
}
}
#[allow(clippy::clone_on_copy)]
fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
match self {
Reducer::BindAssetObjectToEntity { input } => __sats::bsatn::to_vec(
&bind_asset_object_to_entity_reducer::BindAssetObjectToEntityArgs {
input: input.clone(),
},
),
Reducer::ConfirmAssetObject { input } => {
__sats::bsatn::to_vec(&confirm_asset_object_reducer::ConfirmAssetObjectArgs {
input: input.clone(),
})
}
_ => unreachable!(),
}
}
}
#[derive(Default, Debug)]
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct DbUpdate {}
impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
type Error = __sdk::Error;
fn try_from(raw: __ws::v2::TransactionUpdate) -> Result<Self, Self::Error> {
let mut db_update = DbUpdate::default();
for table_update in __sdk::transaction_update_iter_table_updates(raw) {
match &table_update.table_name[..] {
unknown => {
return Err(__sdk::InternalError::unknown_name(
"table",
unknown,
"DatabaseUpdate",
)
.into());
}
}
}
Ok(db_update)
}
}
impl __sdk::InModule for DbUpdate {
type Module = RemoteModule;
}
impl __sdk::DbUpdate for DbUpdate {
fn apply_to_client_cache(
&self,
cache: &mut __sdk::ClientCache<RemoteModule>,
) -> AppliedDiff<'_> {
let mut diff = AppliedDiff::default();
diff
}
fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
unknown => {
return Err(
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
);
}
}
}
Ok(db_update)
}
fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
unknown => {
return Err(
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
);
}
}
}
Ok(db_update)
}
}
#[derive(Default)]
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct AppliedDiff<'r> {
__unused: std::marker::PhantomData<&'r ()>,
}
impl __sdk::InModule for AppliedDiff<'_> {
type Module = RemoteModule;
}
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
fn invoke_row_callbacks(
&self,
event: &EventContext,
callbacks: &mut __sdk::DbCallbacks<RemoteModule>,
) {
}
}
#[doc(hidden)]
#[derive(Debug)]
pub struct RemoteModule;
impl __sdk::InModule for RemoteModule {
type Module = Self;
}
/// The `reducers` field of [`EventContext`] and [`DbConnection`],
/// with methods provided by extension traits for each reducer defined by the module.
pub struct RemoteReducers {
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::InModule for RemoteReducers {
type Module = RemoteModule;
}
/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types,
/// with methods provided by extension traits for each procedure defined by the module.
pub struct RemoteProcedures {
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::InModule for RemoteProcedures {
type Module = RemoteModule;
}
/// The `db` field of [`EventContext`] and [`DbConnection`],
/// with methods provided by extension traits for each table defined by the module.
pub struct RemoteTables {
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::InModule for RemoteTables {
type Module = RemoteModule;
}
/// A connection to a remote module, including a materialized view of a subset of the database.
///
/// Connect to a remote module by calling [`DbConnection::builder`]
/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor.
///
/// You must explicitly advance the connection by calling any one of:
///
/// - [`DbConnection::frame_tick`].
#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")]
#[cfg_attr(
target_arch = "wasm32",
doc = "- [`DbConnection::run_background_task`]."
)]
/// - [`DbConnection::run_async`].
/// - [`DbConnection::advance_one_message`].
#[cfg_attr(
not(target_arch = "wasm32"),
doc = "- [`DbConnection::advance_one_message_blocking`]."
)]
/// - [`DbConnection::advance_one_message_async`].
///
/// Which of these methods you should call depends on the specific needs of your application,
/// but you must call one of them, or else the connection will never progress.
pub struct DbConnection {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
#[doc(hidden)]
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::InModule for DbConnection {
type Module = RemoteModule;
}
impl __sdk::DbContext for DbConnection {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl DbConnection {
/// Builder-pattern constructor for a connection to a remote module.
///
/// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection.
pub fn builder() -> __sdk::DbConnectionBuilder<RemoteModule> {
__sdk::DbConnectionBuilder::new()
}
/// If any WebSocket messages are waiting, process one of them.
///
/// Returns `true` if a message was processed, or `false` if the queue is empty.
/// Callers should invoke this message in a loop until it returns `false`
/// or for as much time is available to process messages.
///
/// Returns an error if the connection is disconnected.
/// If the disconnection in question was normal,
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
///
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
/// Most applications should call [`Self::frame_tick`] each frame
/// to fully exhaust the queue whenever time is available.
pub fn advance_one_message(&self) -> __sdk::Result<bool> {
self.imp.advance_one_message()
}
/// Process one WebSocket message, potentially blocking the current thread until one is received.
///
/// Returns an error if the connection is disconnected.
/// If the disconnection in question was normal,
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
///
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
/// Most applications should call [`Self::run_threaded`] to spawn a thread
/// which advances the connection automatically.
#[cfg(not(target_arch = "wasm32"))]
pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> {
self.imp.advance_one_message_blocking()
}
/// Process one WebSocket message, `await`ing until one is received.
///
/// Returns an error if the connection is disconnected.
/// If the disconnection in question was normal,
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
///
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
/// Most applications should call [`Self::run_async`] to run an `async` loop
/// which advances the connection when polled.
pub async fn advance_one_message_async(&self) -> __sdk::Result<()> {
self.imp.advance_one_message_async().await
}
/// Process all WebSocket messages waiting in the queue,
/// then return without `await`ing or blocking the current thread.
pub fn frame_tick(&self) -> __sdk::Result<()> {
self.imp.frame_tick()
}
/// Spawn a thread which processes WebSocket messages as they are received.
#[cfg(not(target_arch = "wasm32"))]
pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {
self.imp.run_threaded()
}
/// Spawn a background task which processes WebSocket messages as they are received.
#[cfg(target_arch = "wasm32")]
pub fn run_background_task(&self) {
self.imp.run_background_task()
}
/// Run an `async` loop which processes WebSocket messages when polled.
pub async fn run_async(&self) -> __sdk::Result<()> {
self.imp.run_async().await
}
}
impl __sdk::DbConnection for DbConnection {
fn new(imp: __sdk::DbContextImpl<RemoteModule>) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
imp,
}
}
}
/// A handle on a subscribed query.
// TODO: Document this better after implementing the new subscription API.
#[derive(Clone)]
pub struct SubscriptionHandle {
imp: __sdk::SubscriptionHandleImpl<RemoteModule>,
}
impl __sdk::InModule for SubscriptionHandle {
type Module = RemoteModule;
}
impl __sdk::SubscriptionHandle for SubscriptionHandle {
fn new(imp: __sdk::SubscriptionHandleImpl<RemoteModule>) -> Self {
Self { imp }
}
/// Returns true if this subscription has been terminated due to an unsubscribe call or an error.
fn is_ended(&self) -> bool {
self.imp.is_ended()
}
/// Returns true if this subscription has been applied and has not yet been unsubscribed.
fn is_active(&self) -> bool {
self.imp.is_active()
}
/// Unsubscribe from the query controlled by this `SubscriptionHandle`,
/// then run `on_end` when its rows are removed from the client cache.
fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback<RemoteModule>) -> __sdk::Result<()> {
self.imp.unsubscribe_then(Some(on_end))
}
fn unsubscribe(self) -> __sdk::Result<()> {
self.imp.unsubscribe_then(None)
}
}
/// Alias trait for a [`__sdk::DbContext`] connected to this module,
/// with that trait's associated types bounded to this module's concrete types.
///
/// Users can use this trait as a boundary on definitions which should accept
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
pub trait RemoteDbContext:
__sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
{
}
impl<
Ctx: __sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>,
> RemoteDbContext for Ctx
{
}
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`],
/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks.
pub struct EventContext {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
/// The event which caused these callbacks to run.
pub event: __sdk::Event<Reducer>,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::AbstractEventContext for EventContext {
type Event = __sdk::Event<Reducer>;
fn event(&self) -> &Self::Event {
&self.event
}
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
event,
imp,
}
}
}
impl __sdk::InModule for EventContext {
type Module = RemoteModule;
}
impl __sdk::DbContext for EventContext {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl __sdk::EventContext for EventContext {}
/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`],
/// passed to on-reducer callbacks.
pub struct ReducerEventContext {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
/// The event which caused these callbacks to run.
pub event: __sdk::ReducerEvent<Reducer>,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::AbstractEventContext for ReducerEventContext {
type Event = __sdk::ReducerEvent<Reducer>;
fn event(&self) -> &Self::Event {
&self.event
}
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
event,
imp,
}
}
}
impl __sdk::InModule for ReducerEventContext {
type Module = RemoteModule;
}
impl __sdk::DbContext for ReducerEventContext {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl __sdk::ReducerEventContext for ReducerEventContext {}
/// An [`__sdk::DbContext`] passed to procedure callbacks.
pub struct ProcedureEventContext {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::AbstractEventContext for ProcedureEventContext {
type Event = ();
fn event(&self) -> &Self::Event {
&()
}
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
imp,
}
}
}
impl __sdk::InModule for ProcedureEventContext {
type Module = RemoteModule;
}
impl __sdk::DbContext for ProcedureEventContext {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl __sdk::ProcedureEventContext for ProcedureEventContext {}
/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks.
pub struct SubscriptionEventContext {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::AbstractEventContext for SubscriptionEventContext {
type Event = ();
fn event(&self) -> &Self::Event {
&()
}
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
imp,
}
}
}
impl __sdk::InModule for SubscriptionEventContext {
type Module = RemoteModule;
}
impl __sdk::DbContext for SubscriptionEventContext {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl __sdk::SubscriptionEventContext for SubscriptionEventContext {}
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`],
/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks.
pub struct ErrorContext {
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
pub db: RemoteTables,
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
pub reducers: RemoteReducers,
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
pub procedures: RemoteProcedures,
/// The event which caused these callbacks to run.
pub event: Option<__sdk::Error>,
imp: __sdk::DbContextImpl<RemoteModule>,
}
impl __sdk::AbstractEventContext for ErrorContext {
type Event = Option<__sdk::Error>;
fn event(&self) -> &Self::Event {
&self.event
}
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
Self {
db: RemoteTables { imp: imp.clone() },
reducers: RemoteReducers { imp: imp.clone() },
procedures: RemoteProcedures { imp: imp.clone() },
event,
imp,
}
}
}
impl __sdk::InModule for ErrorContext {
type Module = RemoteModule;
}
impl __sdk::DbContext for ErrorContext {
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
fn db(&self) -> &Self::DbView {
&self.db
}
fn reducers(&self) -> &Self::Reducers {
&self.reducers
}
fn procedures(&self) -> &Self::Procedures {
&self.procedures
}
fn is_active(&self) -> bool {
self.imp.is_active()
}
fn disconnect(&self) -> __sdk::Result<()> {
self.imp.disconnect()
}
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
__sdk::SubscriptionBuilder::new(&self.imp)
}
fn try_identity(&self) -> Option<__sdk::Identity> {
self.imp.try_identity()
}
fn connection_id(&self) -> __sdk::ConnectionId {
self.imp.connection_id()
}
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
self.imp.try_connection_id()
}
}
impl __sdk::ErrorContext for ErrorContext {}
impl __sdk::SpacetimeModule for RemoteModule {
type DbConnection = DbConnection;
type EventContext = EventContext;
type ReducerEventContext = ReducerEventContext;
type ProcedureEventContext = ProcedureEventContext;
type SubscriptionEventContext = SubscriptionEventContext;
type ErrorContext = ErrorContext;
type Reducer = Reducer;
type DbView = RemoteTables;
type Reducers = RemoteReducers;
type Procedures = RemoteProcedures;
type DbUpdate = DbUpdate;
type AppliedDiff<'r> = AppliedDiff<'r>;
type SubscriptionHandle = SubscriptionHandle;
type QueryBuilder = __sdk::QueryBuilder;
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {}
const ALL_TABLE_NAMES: &'static [&'static str] = &[];
}

View 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"] }

View File

@@ -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 与聚合入口落地后再扩展。

View 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)
}

View File

@@ -0,0 +1,519 @@
[CmdletBinding()]
param(
[Alias("h")]
[switch]$Help,
[string]$ApiHost = "127.0.0.1",
[int]$Port = 3310,
[string]$Log = "warn,tower_http=warn",
[int]$StartupTimeoutSeconds = 30,
[string]$LegacyPrefix = "/generated-character-drafts/*",
[string[]]$PathSegments = @("oss-smoke"),
[string]$FileName = "tmp_oss_upload_test.txt",
[string]$FileContent = "Genarrative OSS smoke test",
[switch]$KeepObject,
[switch]$JsonOnly
)
$ErrorActionPreference = "Stop"
function Write-Usage {
@(
'Usage:'
' ./server-rs/scripts/oss-smoke.ps1'
' ./server-rs/scripts/oss-smoke.ps1 -LegacyPrefix "/generated-characters/*" -PathSegments hero_001,visual,asset_01'
' ./server-rs/scripts/oss-smoke.ps1 -KeepObject'
''
'Notes:'
' 1. Load OSS config from repository root .env and .env.local'
' 2. Start a temporary local api-server process'
' 3. Request /api/assets/direct-upload-tickets and perform a real PostObject upload'
' 4. Verify bucket access, uploaded object visibility, and delete the test object by default'
) -join [Environment]::NewLine
}
function Assert-Condition {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
throw $Message
}
}
function Read-EnvFile {
param(
[string]$Path,
[hashtable]$Target
)
if (-not (Test-Path $Path)) {
return
}
foreach ($line in Get-Content -Encoding UTF8 $Path) {
$trimmed = $line.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) {
continue
}
$separatorIndex = $trimmed.IndexOf('=')
if ($separatorIndex -lt 1) {
continue
}
$key = $trimmed.Substring(0, $separatorIndex).Trim()
$value = $trimmed.Substring($separatorIndex + 1).Trim()
if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) {
$value = $value.Substring(1, $value.Length - 2)
}
$Target[$key] = $value
}
}
function Set-InheritedEnvVar {
param(
[string]$Name,
[string]$Value,
[hashtable]$Snapshot
)
if (-not $Snapshot.ContainsKey($Name)) {
$Snapshot[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
}
[Environment]::SetEnvironmentVariable($Name, $Value, "Process")
}
function Restore-InheritedEnvVars {
param([hashtable]$Snapshot)
foreach ($entry in $Snapshot.GetEnumerator()) {
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process")
}
}
function ConvertTo-Rfc1123Date {
return [DateTime]::UtcNow.ToString("r", [System.Globalization.CultureInfo]::InvariantCulture)
}
function New-HmacSha1Signature {
param(
[string]$Secret,
[string]$Content
)
$hmac = New-Object System.Security.Cryptography.HMACSHA1
try {
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
$hashBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Content))
return [Convert]::ToBase64String($hashBytes)
}
finally {
$hmac.Dispose()
}
}
function Invoke-SignedOssRequest {
param(
[string]$Method,
[string]$Bucket,
[string]$Endpoint,
[string]$AccessKeyId,
[string]$AccessKeySecret,
[string]$ObjectKey = ""
)
$dateValue = ConvertTo-Rfc1123Date
$canonicalResource = if ([string]::IsNullOrWhiteSpace($ObjectKey)) {
"/$Bucket/"
}
else {
"/$Bucket/$ObjectKey"
}
$stringToSign = "$Method`n`n`n$dateValue`n$canonicalResource"
$signature = New-HmacSha1Signature -Secret $AccessKeySecret -Content $stringToSign
$uri = if ([string]::IsNullOrWhiteSpace($ObjectKey)) {
"https://$Bucket.$Endpoint/"
}
else {
"https://$Bucket.$Endpoint/$ObjectKey"
}
try {
$response = Invoke-WebRequest `
-Uri $uri `
-Method $Method `
-Headers @{
"Date" = $dateValue
"Authorization" = "OSS $AccessKeyId`:$signature"
} `
-UseBasicParsing `
-TimeoutSec 30
return @{
ok = $true
status = [int]$response.StatusCode
url = $uri
}
}
catch {
$statusCode = $null
$body = $_.Exception.Message
if ($_.Exception.Response) {
try {
$statusCode = [int]$_.Exception.Response.StatusCode
}
catch {
$statusCode = $null
}
}
return @{
ok = $false
status = $statusCode
url = $uri
body = $body
}
}
}
function Wait-ForHealthz {
param(
[string]$Uri,
[int]$TimeoutSeconds,
$Process
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastError = $null
while ((Get-Date) -lt $deadline) {
if ($Process.HasExited) {
throw "api-server exited before /healthz became ready."
}
try {
$response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
return
}
$lastError = "Unexpected status code $($response.StatusCode)"
}
catch {
$lastError = $_.Exception.Message
}
Start-Sleep -Milliseconds 300
}
throw "Timed out waiting for /healthz readiness. Last error: $lastError"
}
function Invoke-JsonPost {
param(
[string]$Uri,
[string]$Body
)
$response = Invoke-WebRequest `
-Uri $Uri `
-Method Post `
-ContentType "application/json" `
-Headers @{ "x-request-id" = "oss-smoke-$([guid]::NewGuid().ToString('N').Substring(0, 8))" } `
-Body $Body `
-UseBasicParsing `
-TimeoutSec 30
return $response.Content | ConvertFrom-Json
}
function New-MultipartFormData {
param(
[hashtable]$Fields,
[string]$FilePath,
[string]$FileFieldName = "file"
)
$boundary = "----CodexBoundary$([Guid]::NewGuid().ToString('N'))"
$encoding = [System.Text.Encoding]::UTF8
$memory = New-Object System.IO.MemoryStream
try {
foreach ($entry in $Fields.GetEnumerator()) {
$prefix = "--$boundary`r`nContent-Disposition: form-data; name=""$($entry.Key)""`r`n`r`n$($entry.Value)`r`n"
$bytes = $encoding.GetBytes($prefix)
$memory.Write($bytes, 0, $bytes.Length)
}
$fileInfo = Get-Item -LiteralPath $FilePath
$mimeType = "application/octet-stream"
if ($fileInfo.Extension -ieq ".txt") {
$mimeType = "text/plain"
}
$fileHeader = "--$boundary`r`nContent-Disposition: form-data; name=""$FileFieldName""; filename=""$($fileInfo.Name)""`r`nContent-Type: $mimeType`r`n`r`n"
$fileHeaderBytes = $encoding.GetBytes($fileHeader)
$memory.Write($fileHeaderBytes, 0, $fileHeaderBytes.Length)
$fileBytes = [System.IO.File]::ReadAllBytes($FilePath)
$memory.Write($fileBytes, 0, $fileBytes.Length)
$fileFooterBytes = $encoding.GetBytes("`r`n--$boundary--`r`n")
$memory.Write($fileFooterBytes, 0, $fileFooterBytes.Length)
return @{
Boundary = $boundary
Body = $memory.ToArray()
}
}
finally {
$memory.Dispose()
}
}
function Remove-IfExists {
param([string]$Path)
if (Test-Path $Path) {
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
}
}
if ($Help) {
Write-Usage
exit 0
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$serverRsDir = Split-Path -Parent $scriptDir
$repoRoot = Split-Path -Parent $serverRsDir
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
$binaryPath = Join-Path $serverRsDir "target\debug\api-server.exe"
$baseUrl = "http://$ApiHost`:$Port"
$healthzUrl = "$baseUrl/healthz"
$runId = [Guid]::NewGuid().ToString("N")
$stdoutLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stdout.log"
$stderrLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stderr.log"
$uploadFilePath = Join-Path $env:TEMP "$runId-$FileName"
$envSnapshot = @{}
$mergedEnv = @{}
$serverProcess = $null
$deleteResult = $null
$signedObjectHead = $null
if (-not (Test-Path $manifestPath)) {
throw "Missing server-rs/Cargo.toml, cannot start OSS smoke script."
}
Read-EnvFile -Path (Join-Path $repoRoot ".env") -Target $mergedEnv
Read-EnvFile -Path (Join-Path $repoRoot ".env.local") -Target $mergedEnv
$bucket = [string]($mergedEnv["ALIYUN_OSS_BUCKET"])
$endpoint = [string]($mergedEnv["ALIYUN_OSS_ENDPOINT"])
$accessKeyId = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_ID"])
$accessKeySecret = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_SECRET"])
Assert-Condition (-not [string]::IsNullOrWhiteSpace($bucket)) "Missing ALIYUN_OSS_BUCKET in .env/.env.local."
Assert-Condition (-not [string]::IsNullOrWhiteSpace($endpoint)) "Missing ALIYUN_OSS_ENDPOINT in .env/.env.local."
Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeyId)) "Missing ALIYUN_OSS_ACCESS_KEY_ID in .env/.env.local."
Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeySecret)) "Missing ALIYUN_OSS_ACCESS_KEY_SECRET in .env/.env.local."
$bucketHead = Invoke-SignedOssRequest `
-Method "HEAD" `
-Bucket $bucket `
-Endpoint $endpoint `
-AccessKeyId $accessKeyId `
-AccessKeySecret $accessKeySecret
[System.IO.File]::WriteAllText($uploadFilePath, "$FileContent`n", [System.Text.Encoding]::UTF8)
Push-Location $serverRsDir
try {
Write-Host "[server-rs:oss-smoke] step: cargo build -p api-server"
cargo build -p api-server --manifest-path $manifestPath
Assert-Condition (Test-Path $binaryPath) "Missing api-server binary at $binaryPath after cargo build."
foreach ($entry in $mergedEnv.GetEnumerator()) {
Set-InheritedEnvVar -Name $entry.Key -Value ([string]$entry.Value) -Snapshot $envSnapshot
}
Set-InheritedEnvVar -Name "GENARRATIVE_API_HOST" -Value $ApiHost -Snapshot $envSnapshot
Set-InheritedEnvVar -Name "GENARRATIVE_API_PORT" -Value "$Port" -Snapshot $envSnapshot
Set-InheritedEnvVar -Name "GENARRATIVE_API_LOG" -Value $Log -Snapshot $envSnapshot
Write-Host "[server-rs:oss-smoke] step: start api-server binary"
$serverProcess = Start-Process `
-FilePath $binaryPath `
-WorkingDirectory $repoRoot `
-PassThru `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog
Restore-InheritedEnvVars -Snapshot $envSnapshot
Write-Host "[server-rs:oss-smoke] step: wait for /healthz readiness"
Wait-ForHealthz -Uri $healthzUrl -TimeoutSeconds $StartupTimeoutSeconds -Process $serverProcess
$timestampSegment = Get-Date -Format "yyyyMMdd-HHmmss"
$resolvedPathSegments = @($PathSegments + $timestampSegment)
$ticketRequestBody = @{
legacyPrefix = $LegacyPrefix
pathSegments = $resolvedPathSegments
fileName = $FileName
contentType = "text/plain"
metadata = @{
origin = "server-rs-oss-smoke"
"asset-kind" = "manual-test"
}
} | ConvertTo-Json -Depth 5
Write-Host "[server-rs:oss-smoke] step: request direct upload ticket"
$ticketEnvelope = Invoke-JsonPost -Uri "$baseUrl/api/assets/direct-upload-tickets" -Body $ticketRequestBody
$upload = $ticketEnvelope.upload
if ($null -eq $upload) {
$upload = $ticketEnvelope.data.upload
}
Assert-Condition ($null -ne $upload) "OSS direct upload ticket response is missing upload payload."
$formFields = @{}
$upload.formFields.psobject.Properties | ForEach-Object {
$formFields[$_.Name] = [string]$_.Value
}
$multipart = New-MultipartFormData -Fields $formFields -FilePath $uploadFilePath
Write-Host "[server-rs:oss-smoke] step: upload test object to OSS"
$uploadResponse = $null
try {
$uploadResponse = Invoke-WebRequest `
-Uri $upload.host `
-Method Post `
-ContentType "multipart/form-data; boundary=$($multipart.Boundary)" `
-Body $multipart.Body `
-UseBasicParsing `
-TimeoutSec 60
$uploadResult = @{
ok = $true
status = [int]$uploadResponse.StatusCode
body = $uploadResponse.Content
}
}
catch {
$statusCode = $null
if ($_.Exception.Response) {
try {
$statusCode = [int]$_.Exception.Response.StatusCode
}
catch {
$statusCode = $null
}
}
$uploadResult = @{
ok = $false
status = $statusCode
body = $_.Exception.Message
}
}
$publicHead = $null
if (-not [string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) {
try {
$publicResponse = Invoke-WebRequest `
-Uri ([string]$upload.publicUrl) `
-Method Head `
-UseBasicParsing `
-TimeoutSec 30
$publicHead = @{
ok = $true
status = [int]$publicResponse.StatusCode
url = [string]$upload.publicUrl
}
}
catch {
$statusCode = $null
if ($_.Exception.Response) {
try {
$statusCode = [int]$_.Exception.Response.StatusCode
}
catch {
$statusCode = $null
}
}
$publicHead = @{
ok = $false
status = $statusCode
url = [string]$upload.publicUrl
body = $_.Exception.Message
}
}
}
$signedObjectHead = Invoke-SignedOssRequest `
-Method "HEAD" `
-Bucket $bucket `
-Endpoint $endpoint `
-AccessKeyId $accessKeyId `
-AccessKeySecret $accessKeySecret `
-ObjectKey ([string]$upload.objectKey)
if (-not $KeepObject) {
$deleteResult = Invoke-SignedOssRequest `
-Method "DELETE" `
-Bucket $bucket `
-Endpoint $endpoint `
-AccessKeyId $accessKeyId `
-AccessKeySecret $accessKeySecret `
-ObjectKey ([string]$upload.objectKey)
}
$result = [ordered]@{
bucketHead = $bucketHead
ticket = [ordered]@{
host = [string]$upload.host
objectKey = [string]$upload.objectKey
legacyPublicPath = [string]$upload.legacyPublicPath
publicUrl = if ([string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) {
$null
}
else {
[string]$upload.publicUrl
}
}
upload = $uploadResult
publicHead = $publicHead
signedObjectHead = $signedObjectHead
delete = $deleteResult
}
if ($JsonOnly) {
$result | ConvertTo-Json -Depth 8
}
else {
Write-Host "[server-rs:oss-smoke] result:"
$result | ConvertTo-Json -Depth 8
}
}
finally {
Restore-InheritedEnvVars -Snapshot $envSnapshot
if ($null -ne $serverProcess -and -not $serverProcess.HasExited) {
Stop-Process -Id $serverProcess.Id -Force
$serverProcess.WaitForExit()
}
Pop-Location
Remove-IfExists -Path $uploadFilePath
Remove-IfExists -Path $stdoutLog
Remove-IfExists -Path $stderrLog
}

View File

@@ -3,7 +3,7 @@ param(
[Alias("h")]
[switch]$Help,
[string]$ListenHost = "127.0.0.1",
[int]$Port = 3001,
[int]$Port = 3000,
[string]$RootDir = ""
)
@@ -18,7 +18,8 @@ function Write-Usage {
'Notes:'
' 1. Start local standalone SpacetimeDB for the Genarrative Rust backend'
' 2. Store local SpacetimeDB state in server-rs/.spacetimedb/local by default'
' 3. Current stage only boots the standalone server and does not publish a module yet'
' 3. Default port is 3000 to align with the spacetime CLI local server alias'
' 4. Current stage already has crates/spacetime-module scaffold, but still does not auto-publish the module'
) -join [Environment]::NewLine
}
@@ -56,7 +57,7 @@ Write-Host "[server-rs:spacetime-dev] working dir: $serverRsDir"
Write-Host "[server-rs:spacetime-dev] root dir: $RootDir"
Write-Host "[server-rs:spacetime-dev] listen addr: $listenAddress"
Write-Host "[server-rs:spacetime-dev] mode: standalone"
Write-Host "[server-rs:spacetime-dev] note: module publish is deferred until crates/spacetime-module scaffold lands"
Write-Host "[server-rs:spacetime-dev] note: module scaffold already exists; publish remains manual in this stage"
Push-Location $serverRsDir
try {

View File

@@ -13,7 +13,8 @@ usage() {
说明:
1. 启动 Genarrative Rust 后端使用的本地 standalone SpacetimeDB
2. 默认把本地数据目录放到 `server-rs/.spacetimedb/local`
3. 当前阶段只负责启动 standalone server暂不自动 publish `crates/spacetime-module`
3. 默认端口使用 `3000`,与 `spacetime` CLI 的 local server 昵称保持一致
4. 当前阶段已具备 `crates/spacetime-module` scaffold但暂不自动 publish
EOF
}
@@ -25,7 +26,7 @@ fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SERVER_RS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
LISTEN_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
PORT="${GENARRATIVE_SPACETIME_PORT:-3001}"
PORT="${GENARRATIVE_SPACETIME_PORT:-3000}"
ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SERVER_RS_DIR}/.spacetimedb/local}"
if [[ ! -f "${SERVER_RS_DIR}/crates/spacetime-module/README.md" ]]; then
@@ -46,7 +47,7 @@ echo "[server-rs:spacetime-dev] 工作目录: ${SERVER_RS_DIR}"
echo "[server-rs:spacetime-dev] 数据目录: ${ROOT_DIR}"
echo "[server-rs:spacetime-dev] 监听地址: ${LISTEN_HOST}:${PORT}"
echo "[server-rs:spacetime-dev] 模式: standalone"
echo "[server-rs:spacetime-dev] 说明: 当前阶段不自动 publish crates/spacetime-module"
echo "[server-rs:spacetime-dev] 说明: 当前阶段已落 crate scaffold但仍不自动 publish crates/spacetime-module"
cd "${SERVER_RS_DIR}"
spacetime --root-dir "${ROOT_DIR}" start --edition standalone --listen-addr "${LISTEN_HOST}:${PORT}"