feat(api): update response types to use pagination for various endpoints
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified app/src/main.rs app/src/main.rs +10 −6 View app/src/main.rs
@@ -99,7 +99,7 @@
9999 ],
100100 );
101101
102 spawn_dependency_health_sampler(state.clone());
102 spawn_dependency_health_sampler(state.clone(), conn.clone());
103103
104104 let mut workers = tokio::task::JoinSet::new();
105105 workers.spawn(chat::worker::run_background_tasks(
@@ -448,19 +448,23 @@
448448const HEALTH_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
449449
450450/// 周期性采样 RocketMQ/S3 健康:`/readyz` 只读缓存,避免每次探活都新建连接。
451fn spawn_dependency_health_sampler(state: bootstrap::AppState) {
451fn spawn_dependency_health_sampler(state: bootstrap::AppState, conn: queue::RocketMqConn) {
452452 tokio::spawn(async move {
453453 let mut ticker = tokio::time::interval(HEALTH_SAMPLE_INTERVAL);
454454 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
455 let auth_topic = state
456 .config
457 .rocketmq
458 .topic(events::EventKind::AuthLoginCreate.topic());
455459 loop {
456460 ticker.tick().await;
457461
458 let rocketmq_up = queue::probe_nameservers_with(
459 &state.config.rocketmq,
460 std::time::Duration::from_secs(1),
462 let rocketmq_up = tokio::time::timeout(
463 std::time::Duration::from_secs(6),
464 conn.health(&[&auth_topic, "TBW102"]),
461465 )
462466 .await
463 .is_ok();
467 .is_ok_and(|result| result.is_ok());
464468 state.health.mark_rocketmq(rocketmq_up);
465469 oltp::metrics::health_dependency("rocketmq", rocketmq_up);
466470
Modified lib/infra/actor/src/actor/host/builder.rs lib/infra/actor/src/actor/host/builder.rs +2 −0 View lib/infra/actor/src/actor/host/builder.rs
@@ -97,6 +97,8 @@
9797 needed: true,
9898 generation: 0,
9999 last_pass: None,
100 next_attempt: None,
101 attempts: HashMap::new(),
100102 }),
101103 });
102104 let host = ActorHost { inner };
Modified lib/infra/actor/src/actor/host/core.rs lib/infra/actor/src/actor/host/core.rs +18 −0 View lib/infra/actor/src/actor/host/core.rs
@@ -37,6 +37,8 @@
3737 pub(super) needed: bool,
3838 pub(super) generation: u64,
3939 pub(super) last_pass: Option<Instant>,
40 pub(super) next_attempt: Option<Instant>,
41 pub(super) attempts: HashMap<String, (u32, Instant)>,
4042}
4143
4244pub struct ActorEvent {
@@ -60,6 +62,11 @@
6062 Heartbeat,
6163}
6264
65pub(super) fn backoff(failures: u32) -> Duration {
66 let shift = failures.saturating_sub(1).min(5);
67 (Duration::from_millis(250) * 2u32.pow(shift)).min(Duration::from_secs(8))
68}
69
6370pub(super) enum ReplyTarget {
6471 Local(oneshot::Sender<Result<Value, ActorError>>),
6572 Remote { stream: String, id: String },
@@ -120,9 +127,20 @@
120127
121128 pub(super) async fn release(&self, entry: &str) {
122129 self.inner.transport.inflight.lock().await.remove(entry);
130 let now = Instant::now();
123131 let mut recovery = self.inner.recovery.lock().await;
132 let attempts = {
133 let attempts = recovery
134 .attempts
135 .entry(entry.to_string())
136 .or_insert((0, now));
137 attempts.0 = attempts.0.saturating_add(1);
138 attempts.1 = now;
139 attempts.0
140 };
124141 recovery.needed = true;
125142 recovery.generation = recovery.generation.wrapping_add(1);
143 recovery.next_attempt = Some(now + backoff(attempts));
126144 let start = rewind_point(entry);
127145 if id_before(&start, &recovery.cursor) {
128146 recovery.cursor = start;
Modified lib/infra/actor/src/actor/host/reader.rs lib/infra/actor/src/actor/host/reader.rs +43 −9 View lib/infra/actor/src/actor/host/reader.rs
@@ -2,11 +2,11 @@
22
33use super::super::error::ActorError;
44use super::super::transport::WireMessage;
5use super::core::{ActorEvent, ActorHost, LocalRequest, ReplyTarget};
5use super::core::{ActorEvent, ActorHost, LocalRequest, ReplyTarget, backoff};
66
7const ERROR_BACKOFF: Duration = Duration::from_millis(250);
8const MAX_ERROR_BACKOFF: Duration = Duration::from_secs(8);
97const RECOVERY_INTERVAL: Duration = Duration::from_secs(1);
8const MAX_ENTRY_ATTEMPTS: u32 = 8;
9const ATTEMPT_TTL: Duration = Duration::from_secs(3600);
1010
1111impl ActorHost {
1212 pub(super) async fn run_reader(self) {
@@ -64,11 +64,15 @@
6464 && recovery
6565 .last_pass
6666 .is_none_or(|at| at.elapsed() >= RECOVERY_INTERVAL)
67 && recovery.next_attempt.is_none_or(|at| at <= Instant::now())
6768 }
6869
6970 async fn recover_pending(&self) -> Result<(), ActorError> {
7071 let (mut after, generation) = {
71 let recovery = self.inner.recovery.lock().await;
72 let mut recovery = self.inner.recovery.lock().await;
73 recovery
74 .attempts
75 .retain(|_, (_, at)| at.elapsed() < ATTEMPT_TTL);
7276 (recovery.cursor.clone(), recovery.generation)
7377 };
7478
@@ -86,6 +90,41 @@
8690 after = last;
8791
8892 for (entry, wire) in entries {
93 let attempts = self
94 .inner
95 .recovery
96 .lock()
97 .await
98 .attempts
99 .get(&entry)
100 .map(|(count, _)| *count)
101 .unwrap_or(0);
102 if attempts >= MAX_ENTRY_ATTEMPTS {
103 if let Err(error) = self
104 .inner
105 .transport
106 .dead_letter(&entry, wire.as_ref())
107 .await
108 {
109 tracing::error!(%entry, %error, "actor entry could not be dead-lettered");
110 }
111 if let Err(error) = self.inner.transport.ack(&entry).await {
112 tracing::error!(
113 %entry,
114 %error,
115 "actor dead-lettered entry could not be acknowledged"
116 );
117 self.release(&entry).await;
118 continue;
119 }
120 self.inner.recovery.lock().await.attempts.remove(&entry);
121 tracing::error!(
122 %entry,
123 attempts,
124 "actor entry exceeded the retry limit; dead-lettered"
125 );
126 continue;
127 }
89128 self.handle_entry(entry, wire).await;
90129 }
91130
@@ -169,9 +208,4 @@
169208 }
170209 }
171210 }
172}
173
174fn backoff(failures: u32) -> Duration {
175 let shift = failures.saturating_sub(1).min(5);
176 (ERROR_BACKOFF * 2u32.pow(shift)).min(MAX_ERROR_BACKOFF)
177211}
Modified lib/infra/actor/src/actor/transport/stream.rs lib/infra/actor/src/actor/transport/stream.rs +23 −0 View lib/infra/actor/src/actor/transport/stream.rs
@@ -63,6 +63,29 @@
6363 result.map(|_| ()).map_err(Into::into)
6464 }
6565
66 pub(crate) async fn dead_letter(
67 &self,
68 entry: &str,
69 wire: Option<&WireMessage>,
70 ) -> Result<(), ActorError> {
71 let stream = format!("{}:dead:{}", self.prefix, self.consumer);
72 let payload = match wire {
73 Some(wire) => serde_json::to_string(wire).map_err(ActorError::Encode)?,
74 None => String::new(),
75 };
76 let mut cmd = redis::cmd("EVAL");
77 cmd.arg(
78 "redis.call('XADD',KEYS[1],'MAXLEN','~',ARGV[1],'*','entry',ARGV[2],'payload',ARGV[3]); return 1",
79 )
80 .arg(1)
81 .arg(&stream)
82 .arg(self.max_len)
83 .arg(entry)
84 .arg(payload);
85 let _: i64 = self.commands.query(&cmd).await?;
86 Ok(())
87 }
88
6689 pub(crate) async fn receive(&self) -> Result<Vec<(String, Option<WireMessage>)>, ActorError> {
6790 self.pending
6891 .lock()
Modified lib/infra/db/src/migrate.rs lib/infra/db/src/migrate.rs +5 −4 View lib/infra/db/src/migrate.rs
@@ -1,6 +1,5 @@
1use models::migrate::MIGRATION_LOCK_KEY;
12use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, TransactionTrait};
2
3const LOCK_KEY: i64 = 5_241_915_330_551;
43
54/// 实体优先迁移:实体定义是表结构的唯一真相。
65///
@@ -42,8 +41,10 @@
4241
4342 pub async fn run(&self, db: &DatabaseConnection) -> Result<(), DbErr> {
4443 let txn = db.begin().await?;
45 txn.execute_unprepared(&format!("SELECT pg_advisory_xact_lock({LOCK_KEY})"))
46 .await?;
44 txn.execute_unprepared(&format!(
45 "SELECT pg_advisory_xact_lock({MIGRATION_LOCK_KEY})"
46 ))
47 .await?;
4748 for statement in &self.before {
4849 txn.execute_unprepared(statement).await?;
4950 }
Modified lib/infra/oltp/src/telemetry.rs lib/infra/oltp/src/telemetry.rs +29 −20 View lib/infra/oltp/src/telemetry.rs
@@ -33,29 +33,31 @@
3333 }
3434
3535 pub fn force_flush(&self) -> Result<(), TelemetryError> {
36 self.tracer_provider
37 .force_flush()
38 .map_err(TelemetryError::exporter)?;
39 self.meter_provider
40 .force_flush()
41 .map_err(TelemetryError::exporter)?;
42 self.logger_provider
43 .force_flush()
44 .map_err(TelemetryError::exporter)?;
45 Ok(())
36 first_error([
37 self.tracer_provider
38 .force_flush()
39 .map_err(TelemetryError::exporter),
40 self.meter_provider
41 .force_flush()
42 .map_err(TelemetryError::exporter),
43 self.logger_provider
44 .force_flush()
45 .map_err(TelemetryError::exporter),
46 ])
4647 }
4748
4849 pub fn shutdown(&self) -> Result<(), TelemetryError> {
49 self.tracer_provider
50 .shutdown()
51 .map_err(TelemetryError::shutdown)?;
52 self.meter_provider
53 .shutdown()
54 .map_err(TelemetryError::shutdown)?;
55 self.logger_provider
56 .shutdown()
57 .map_err(TelemetryError::shutdown)?;
58 Ok(())
50 first_error([
51 self.tracer_provider
52 .shutdown()
53 .map_err(TelemetryError::shutdown),
54 self.meter_provider
55 .shutdown()
56 .map_err(TelemetryError::shutdown),
57 self.logger_provider
58 .shutdown()
59 .map_err(TelemetryError::shutdown),
60 ])
5961 }
6062
6163 pub fn guard(&self) -> TelemetryGuard<'_> {
@@ -75,6 +77,13 @@
7577 }
7678}
7779
80fn first_error(errors: [Result<(), TelemetryError>; 3]) -> Result<(), TelemetryError> {
81 match errors.into_iter().find(Result::is_err) {
82 Some(error) => error,
83 None => Ok(()),
84 }
85}
86
7887pub fn install(config: &TelemetryConfig) -> Telemetry {
7988 let resource = resource::build(config);
8089 let mut warnings = Vec::new();
Modified lib/infra/queue/src/conn.rs lib/infra/queue/src/conn.rs +18 −1 View lib/infra/queue/src/conn.rs
@@ -59,6 +59,23 @@
5959 self.config.max_message_bytes
6060 }
6161
62 pub async fn health(&self, topics: &[&str]) -> QueueResult<()> {
63 let mut producer = self.producer.clone();
64 let mut last_error = None;
65 for topic in topics {
66 match producer.fetch_publish_message_queues(topic).await {
67 Ok(_) => return Ok(()),
68 Err(error) => last_error = Some((*topic, error)),
69 }
70 }
71 let detail = last_error
72 .map(|(topic, error)| format!("`{topic}`: {error}"))
73 .unwrap_or_default();
74 Err(QueueError::connect(format!(
75 "rocketmq route lookup failed ({detail})"
76 )))
77 }
78
6279 pub async fn send(&self, topic: &str, message: Message) -> QueueResult<()> {
6380 let result = self
6481 .producer
@@ -89,7 +106,7 @@
89106 probe_nameservers_with(config, std::time::Duration::from_secs(3)).await
90107}
91108
92pub async fn probe_nameservers_with(
109async fn probe_nameservers_with(
93110 config: &RocketMqConfig,
94111 timeout: std::time::Duration,
95112) -> QueueResult<()> {
Modified lib/infra/queue/src/lib.rs lib/infra/queue/src/lib.rs +1 −3 View lib/infra/queue/src/lib.rs
@@ -8,9 +8,7 @@
88pub mod runtime;
99pub mod subject;
1010
11pub use conn::{
12 DEFAULT_MAX_MESSAGE_BYTES, MAX_ENVELOPE_BYTES, RocketMqConn, probe_nameservers_with,
13};
11pub use conn::{DEFAULT_MAX_MESSAGE_BYTES, MAX_ENVELOPE_BYTES, RocketMqConn};
1412pub use consumer::{Consumer, ConsumerConfig, MessageHandler};
1513pub use envelope::{DeliveryClass, EVENT_PREFIX, Envelope, Labels, SCHEMA_VERSION};
1614pub use error::{QueueError, QueueResult};
Modified lib/service/auth/src/migrate.rs lib/service/auth/src/migrate.rs +2 −0 View lib/service/auth/src/migrate.rs
@@ -11,10 +11,12 @@
1111 "DROP TABLE IF EXISTS oauth_clients",
1212 "DROP TABLE IF EXISTS user_access_tokens",
1313 "DROP TABLE IF EXISTS service_token_revocations",
14 "DROP INDEX IF EXISTS idx_space_join_requests_pending",
1415 ])
1516 .after([
1617 // 邮箱查询一律走 lower(email),表达式唯一索引把大小写变体挡在数据库层。
1718 "CREATE UNIQUE INDEX IF NOT EXISTS auth_users_lower_email_idx ON auth_users (lower(email))",
19 "CREATE UNIQUE INDEX IF NOT EXISTS idx_space_join_requests_pending ON space_join_requests (organization_id, user_id) WHERE status = 'pending'",
1820 ])
1921 .run(db.write())
2022 .await?;
Modified lib/service/chat/src/handlers/emojis.rs lib/service/chat/src/handlers/emojis.rs +7 −5 View lib/service/chat/src/handlers/emojis.rs
@@ -1,5 +1,6 @@
11use ::session::AuthedUser;
2use api::http::{Json, Path, Response, State};
2use api::http::{Json, Path, Query, Response, State};
3use api::pagination::{Page, PageQuery};
34use api::rest::{AppResponseData, NoData};
45use uuid::Uuid;
56
@@ -13,9 +14,9 @@
1314 path = "/chat/spaces/{space_name}/emojis",
1415 tag = "chat",
1516 description = "返回指定空间可用的自定义表情列表。",
16 params(("space_name" = String, Path, description = "space name")),
17 params(("space_name" = String, Path, description = "space name"), PageQuery),
1718 responses(
18 (status = 200, description = "emoji list", body = AppResponseData<Vec<EmojiDto>>),
19 (status = 200, description = "emoji list", body = AppResponseData<Page<EmojiDto>>),
1920 (status = 404, description = "not found", body = AppResponseData<NoData>),
2021 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
2122 )
@@ -24,8 +25,9 @@
2425 State(service): State<ChatService>,
2526 AuthedUser(uid): AuthedUser,
2627 Path(space_name): Path<String>,
27) -> Result<AppResponseData<Vec<EmojiDto>>, ApiError> {
28 let emojis = service.list_emojis(uid, &space_name).await?;
28 Query(page): Query<PageQuery>,
29) -> Result<AppResponseData<Page<EmojiDto>>, ApiError> {
30 let emojis = service.list_emojis(uid, &space_name, page).await?;
2931 Ok(AppResponseData::ok(emojis))
3032}
3133
Modified lib/service/chat/src/handlers/messages.rs lib/service/chat/src/handlers/messages.rs +2 −2 View lib/service/chat/src/handlers/messages.rs
@@ -39,10 +39,10 @@
3939 get,
4040 path = "/chat/rooms/{room_id}/messages",
4141 tag = "chat",
42 description = "按房间序号游标读取消息页,并附带作者、附件和回应状态。",
42 description = "按房间序号过滤并分页读取消息;has_more 可用 offset + items.len() < total 判断。",
4343 params(("room_id" = Uuid, Path, description = "room id"), ListMessageQuery),
4444 responses(
45 (status = 200, description = "message page", body = AppResponseData<MessagePage>),
45 (status = 200, description = "message page", body = AppResponseData<api::pagination::Page<MessageDto>>),
4646 (status = 404, description = "not found", body = AppResponseData<NoData>),
4747 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
4848 )
Modified lib/service/chat/src/handlers/threads.rs lib/service/chat/src/handlers/threads.rs +1 −1 View lib/service/chat/src/handlers/threads.rs
@@ -44,7 +44,7 @@
4444 description = "分页列出房间话题,可按归档状态过滤。",
4545 params(("room_id" = Uuid, Path, description = "room id"), ListThreadQuery),
4646 responses(
47 (status = 200, description = "thread page", body = AppResponseData<ThreadPage>),
47 (status = 200, description = "thread page", body = AppResponseData<api::pagination::Page<ThreadDto>>),
4848 (status = 404, description = "not found", body = AppResponseData<NoData>),
4949 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
5050 )
Modified lib/service/chat/src/services/emojis.rs lib/service/chat/src/services/emojis.rs +21 −7 View lib/service/chat/src/services/emojis.rs
@@ -1,5 +1,9 @@
1use api::pagination::{Page, PageQuery};
12use models::chat::emojis;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
6};
37use serde::{Deserialize, Serialize};
48use space::Space;
59use time::OffsetDateTime;
@@ -62,15 +66,24 @@
6266 &self,
6367 caller: i64,
6468 space_name: &str,
65 ) -> Result<Vec<EmojiDto>, ChatError> {
69 page: PageQuery,
70 ) -> Result<Page<EmojiDto>, ChatError> {
6671 let space = lookup_space_by_name(self, space_name).await?;
6772 require_space_member(self, &space, caller).await?;
68 let rows = emojis::Entity::find()
69 .filter(emojis::Column::SpaceId.eq(space.id()))
73 let (limit, offset) = page.resolve(
74 self.settings.chat.default_pagination,
75 self.settings.chat.max_pagination,
76 )?;
77 let db = self.database.read();
78 let select = emojis::Entity::find().filter(emojis::Column::SpaceId.eq(space.id()));
79 let total = select.clone().count(db).await?;
80 let rows = select
7081 .order_by_asc(emojis::Column::LowerName)
71 .all(self.database.read())
82 .limit(limit)
83 .offset(offset)
84 .all(db)
7285 .await?;
73 Ok(rows
86 let items = rows
7487 .into_iter()
7588 .map(|e| EmojiDto {
7689 id: e.id,
@@ -80,7 +93,8 @@
8093 disabled: e.disabled,
8194 created_at: e.created_at,
8295 })
83 .collect())
96 .collect();
97 Ok(Page::new(items, total, limit, offset))
8498 }
8599
86100 pub async fn upload_emoji(
Modified lib/service/chat/src/openapi.rs lib/service/chat/src/openapi.rs +1 −1 View lib/service/chat/src/openapi.rs
@@ -99,6 +99,7 @@
9999 api::pagination::Page<crate::services::bookmarks::BookmarkDto>,
100100 api::pagination::Page<crate::services::messages::MessageDto>,
101101 api::pagination::Page<crate::services::rooms::RoomBriefDto>,
102 api::pagination::Page<crate::services::emojis::EmojiDto>,
102103 AppResponseData<api::rest::NoData>,
103104 api::rest::NoData,
104105 api::users::UserDto,
@@ -122,7 +123,6 @@
122123 crate::services::rooms::RoomBriefDto,
123124 crate::services::rooms::UpdateRoomCmd,
124125 crate::services::threads::ThreadDto,
125 crate::services::threads::ThreadPage,
126126 crate::services::threads::CreateThreadCmd,
127127 crate::services::threads::UpdateThreadCmd,
128128 crate::services::threads::ThreadMemberCmd,
Modified lib/service/deletion/src/lib.rs lib/service/deletion/src/lib.rs +45 −10 View lib/service/deletion/src/lib.rs
@@ -19,8 +19,8 @@
1919use proto::KenShellClient;
2020use sea_orm::sea_query::Expr;
2121use sea_orm::{
22 ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
23 QueryFilter, QuerySelect, Set, Statement,
22 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DbBackend, EntityTrait,
23 PaginatorTrait, QueryFilter, QuerySelect, Set, Statement,
2424};
2525use serde::Serialize;
2626use time::OffsetDateTime;
@@ -30,6 +30,7 @@
3030
3131const RETRY_BASE_SECS: u64 = 30;
3232const RETRY_MAX_SECS: u64 = 3600;
33const MAX_PURGE_ATTEMPTS: i32 = 20;
3334/// 一次只领取一行:purge 会跨 KenShell / KenAuth 多步执行,逐行领取能让关停
3435/// 在行与行之间生效,而不是把已领取的其余行晾到租约过期。
3536const CLAIM_BATCH: i64 = 1;
@@ -234,14 +235,32 @@
234235 accounts: &auth::Accounts,
235236 user_id: i64,
236237) -> Result<(), DeletionError> {
238 let db = state.database.write();
239 let deleted = account_deletion::Entity::delete_many()
240 .filter(account_deletion::Column::UserId.eq(user_id))
241 .filter(
242 Condition::any()
243 .add(account_deletion::Column::LeaseId.is_null())
244 .add(account_deletion::Column::LeasedUntil.lte(now())),
245 )
246 .exec(db)
247 .await?;
248 if deleted.rows_affected == 0 {
249 let exists = account_deletion::Entity::find()
250 .filter(account_deletion::Column::UserId.eq(user_id))
251 .one(db)
252 .await?
253 .is_some();
254 if exists {
255 return Err(DeletionError::Conflict(
256 "account deletion is currently being purged".into(),
257 ));
258 }
259 }
237260 accounts
238261 .cancel_account_deletion(user_id)
239262 .await
240263 .map_err(ken_auth)?;
241 account_deletion::Entity::delete_many()
242 .filter(account_deletion::Column::UserId.eq(user_id))
243 .exec(state.database.write())
244 .await?;
245264 Ok(())
246265}
247266
@@ -683,6 +702,7 @@
683702 error: &DeletionError,
684703) -> Result<(), DeletionError> {
685704 let attempts = row.attempts.max(1);
705 let retryable = error.is_retryable() && attempts < MAX_PURGE_ATTEMPTS;
686706 let updated = space_deletion::Entity::update_many()
687707 .col_expr(space_deletion::Column::Attempts, Expr::value(attempts))
688708 .col_expr(
@@ -693,8 +713,11 @@
693713 space_deletion::Column::LastError,
694714 Expr::value(Some(error.to_string())),
695715 )
696 .col_expr(space_deletion::Column::Status, Expr::value("pending"))
697716 .col_expr(
717 space_deletion::Column::Status,
718 Expr::value(if retryable { "pending" } else { "failed" }),
719 )
720 .col_expr(
698721 space_deletion::Column::LeaseId,
699722 Expr::value(Option::<Uuid>::None),
700723 )
@@ -715,7 +738,11 @@
715738 );
716739 return Ok(());
717740 }
718 tracing::warn!(space_id = %row.space_id, %error, attempts, "space purge failed; will retry");
741 if retryable {
742 tracing::warn!(space_id = %row.space_id, %error, attempts, "space purge failed; will retry");
743 } else {
744 tracing::warn!(space_id = %row.space_id, %error, attempts, "space purge failed permanently");
745 }
719746 Ok(())
720747}
721748
@@ -725,6 +752,7 @@
725752 error: &DeletionError,
726753) -> Result<(), DeletionError> {
727754 let attempts = row.attempts.max(1);
755 let retryable = error.is_retryable() && attempts < MAX_PURGE_ATTEMPTS;
728756 let updated = account_deletion::Entity::update_many()
729757 .col_expr(account_deletion::Column::Attempts, Expr::value(attempts))
730758 .col_expr(
@@ -735,8 +763,11 @@
735763 account_deletion::Column::LastError,
736764 Expr::value(Some(error.to_string())),
737765 )
738 .col_expr(account_deletion::Column::Status, Expr::value("pending"))
739766 .col_expr(
767 account_deletion::Column::Status,
768 Expr::value(if retryable { "pending" } else { "failed" }),
769 )
770 .col_expr(
740771 account_deletion::Column::LeaseId,
741772 Expr::value(Option::<Uuid>::None),
742773 )
@@ -757,7 +788,11 @@
757788 );
758789 return Ok(());
759790 }
760 tracing::warn!(user_id = %row.user_id, %error, attempts, "account purge failed; will retry");
791 if retryable {
792 tracing::warn!(user_id = %row.user_id, %error, attempts, "account purge failed; will retry");
793 } else {
794 tracing::warn!(user_id = %row.user_id, %error, attempts, "account purge failed permanently");
795 }
761796 Ok(())
762797}
763798
Modified lib/service/git/src/handler/code_comments/list_code_comments.rs lib/service/git/src/handler/code_comments/list_code_comments.rs +1 −1 View lib/service/git/src/handler/code_comments/list_code_comments.rs
@@ -14,7 +14,7 @@
1414 description = "列出代码行级评论;可按 path 与 revision 过滤,锚定提交非当前分支提示时标记 outdated。",
1515 params(crate::handler::RepoPath, ListCodeCommentsQuery),
1616 responses(
17 (status = 200, description = "code comment page", body = AppResponseData<CodeCommentPage>)
17 (status = 200, description = "code comment page", body = AppResponseData<api::pagination::Page<crate::service::code_comments::CodeCommentDto>>)
1818 )
1919)]
2020pub async fn list_code_comments(
Modified lib/service/git/src/handler/releases/list_releases.rs lib/service/git/src/handler/releases/list_releases.rs +1 −1 View lib/service/git/src/handler/releases/list_releases.rs
@@ -14,7 +14,7 @@
1414 description = "列出仓库发布记录(新→旧),可按草稿状态过滤。",
1515 params(crate::handler::RepoPath, ListReleasesQuery),
1616 responses(
17 (status = 200, description = "release page", body = AppResponseData<ReleasePage>)
17 (status = 200, description = "release page", body = AppResponseData<api::pagination::Page<crate::service::releases::ReleaseDto>>)
1818 )
1919)]
2020pub async fn list_releases(
Modified lib/service/issues/src/handlers/boards/list_boards.rs lib/service/issues/src/handlers/boards/list_boards.rs +1 −1 View lib/service/issues/src/handlers/boards/list_boards.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出调用者可见的看板,并统计开放和已关闭议题数量。",
1515 params(SpacePath, ListBoardQuery),
1616 responses(
17 (status = 200, description = "list boards", body = AppResponseData<BoardPage>),
17 (status = 200, description = "list boards", body = AppResponseData<api::pagination::Page<crate::services::boards::BoardDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/comments/list_comments.rs lib/service/issues/src/handlers/comments/list_comments.rs +1 −1 View lib/service/issues/src/handlers/comments/list_comments.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出议题评论,可按父评论过滤并调整排序。",
1515 params(IssuePath, ListCommentQuery),
1616 responses(
17 (status = 200, description = "list comments", body = AppResponseData<CommentPage>),
17 (status = 200, description = "list comments", body = AppResponseData<api::pagination::Page<crate::services::comments::CommentDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/content_history/list_content_history.rs lib/service/issues/src/handlers/content_history/list_content_history.rs +1 −1 View lib/service/issues/src/handlers/content_history/list_content_history.rs
@@ -15,7 +15,7 @@
1515 description = "分页读取议题正文或评论的修改前后历史记录。",
1616 params(ContentHistoryPath, ListContentHistoryQuery),
1717 responses(
18 (status = 200, description = "list content history", body = AppResponseData<ContentHistoryPage>),
18 (status = 200, description = "list content history", body = AppResponseData<api::pagination::Page<crate::services::content_history::ContentHistoryDto>>),
1919 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2020 (status = 404, description = "not found", body = AppResponseData<NoData>),
2121 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/events/list_events.rs lib/service/issues/src/handlers/events/list_events.rs +1 −1 View lib/service/issues/src/handlers/events/list_events.rs
@@ -13,7 +13,7 @@
1313 description = "按时间倒序读取议题的创建、编辑、状态和关联活动事件。",
1414 params(EventPath, ListEventQuery),
1515 responses(
16 (status = 200, description = "list issue events", body = AppResponseData<EventPage>),
16 (status = 200, description = "list issue events", body = AppResponseData<api::pagination::Page<crate::services::events::EventDto>>),
1717 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1818 (status = 404, description = "not found", body = AppResponseData<NoData>),
1919 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/issue_users/list_issue_users.rs lib/service/issues/src/handlers/issue_users/list_issue_users.rs +1 −1 View lib/service/issues/src/handlers/issue_users/list_issue_users.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出议题的已读用户及对应阅读状态。",
1515 params(IssuePath, ListIssueUserQuery),
1616 responses(
17 (status = 200, description = "list issue user states", body = AppResponseData<IssueUserPage>),
17 (status = 200, description = "list issue user states", body = AppResponseData<api::pagination::Page<crate::services::issue_users::IssueUserDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/issues/list_issues.rs lib/service/issues/src/handlers/issues/list_issues.rs +1 −1 View lib/service/issues/src/handlers/issues/list_issues.rs
@@ -14,7 +14,7 @@
1414 description = "按状态、作者、里程碑、标签、指派人、优先级或关键字分页查询议题。",
1515 params(BoardPath, ListIssueQuery),
1616 responses(
17 (status = 200, description = "list issues", body = AppResponseData<IssuePage>),
17 (status = 200, description = "list issues", body = AppResponseData<api::pagination::Page<crate::services::issues::IssueListItemDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/labels/list_labels.rs lib/service/issues/src/handlers/labels/list_labels.rs +1 −1 View lib/service/issues/src/handlers/labels/list_labels.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出看板标签,可按名称与归档状态过滤。",
1515 params(BoardPath, ListLabelQuery),
1616 responses(
17 (status = 200, description = "list labels", body = AppResponseData<LabelPage>),
17 (status = 200, description = "list labels", body = AppResponseData<api::pagination::Page<crate::services::labels::LabelDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/members/list_members.rs lib/service/issues/src/handlers/members/list_members.rs +1 −1 View lib/service/issues/src/handlers/members/list_members.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出议题成员,可按指派、提及或参与角色过滤。",
1515 params(IssuePath, ListIssueMemberQuery),
1616 responses(
17 (status = 200, description = "list issue members", body = AppResponseData<IssueMemberPage>),
17 (status = 200, description = "list issue members", body = AppResponseData<api::pagination::Page<crate::services::members::IssueMemberDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/milestones/list_milestones.rs lib/service/issues/src/handlers/milestones/list_milestones.rs +1 −1 View lib/service/issues/src/handlers/milestones/list_milestones.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出看板里程碑及其议题完成统计。",
1515 params(BoardPath, ListMilestoneQuery),
1616 responses(
17 (status = 200, description = "list milestones", body = AppResponseData<MilestonePage>),
17 (status = 200, description = "list milestones", body = AppResponseData<api::pagination::Page<crate::services::milestones::MilestoneDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/handlers/tracked_time/list_tracked_time.rs lib/service/issues/src/handlers/tracked_time/list_tracked_time.rs +1 −1 View lib/service/issues/src/handlers/tracked_time/list_tracked_time.rs
@@ -13,7 +13,7 @@
1313 description = "分页读取议题工时记录,可按登记用户过滤。",
1414 params(TrackedTimePath, ListTrackedTimeQuery),
1515 responses(
16 (status = 200, description = "list tracked times", body = AppResponseData<TrackedTimePage>),
16 (status = 200, description = "list tracked times", body = AppResponseData<api::pagination::Page<crate::services::tracked_time::TrackedTimeDto>>),
1717 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1818 (status = 404, description = "not found", body = AppResponseData<NoData>),
1919 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/issues/src/openapi.rs lib/service/issues/src/openapi.rs +10 −19 View lib/service/issues/src/openapi.rs
@@ -77,39 +77,38 @@
7777 components(schemas(
7878 AppResponseData<api::rest::NoData>,
7979 AppResponseData<crate::services::boards::BoardDto>,
80 AppResponseData<crate::services::boards::BoardPage>,
80 AppResponseData<api::pagination::Page<crate::services::boards::BoardDto>>,
8181 AppResponseData<crate::services::issues::IssueDto>,
82 AppResponseData<crate::services::issues::IssuePage>,
82 AppResponseData<api::pagination::Page<crate::services::issues::IssueListItemDto>>,
8383 AppResponseData<crate::services::comments::CommentDto>,
84 AppResponseData<crate::services::comments::CommentPage>,
84 AppResponseData<api::pagination::Page<crate::services::comments::CommentDto>>,
8585 AppResponseData<crate::services::labels::LabelDto>,
86 AppResponseData<crate::services::labels::LabelPage>,
86 AppResponseData<api::pagination::Page<crate::services::labels::LabelDto>>,
8787 AppResponseData<Vec<crate::services::labels::LabelDto>>,
8888 AppResponseData<crate::services::milestones::MilestoneDto>,
89 AppResponseData<crate::services::milestones::MilestonePage>,
89 AppResponseData<api::pagination::Page<crate::services::milestones::MilestoneDto>>,
9090 AppResponseData<crate::services::reactions::ReactionDto>,
9191 AppResponseData<Vec<crate::services::reactions::ReactionAggDto>>,
9292 AppResponseData<crate::services::watches::WatchDto>,
9393 AppResponseData<crate::services::members::IssueMemberDto>,
94 AppResponseData<crate::services::members::IssueMemberPage>,
94 AppResponseData<api::pagination::Page<crate::services::members::IssueMemberDto>>,
9595 AppResponseData<crate::services::pins::PinDto>,
9696 AppResponseData<Vec<crate::services::pins::PinDto>>,
97 AppResponseData<crate::services::events::EventPage>,
97 AppResponseData<api::pagination::Page<crate::services::events::EventDto>>,
9898 AppResponseData<crate::services::dependencies::DependencyDto>,
9999 AppResponseData<Vec<crate::services::dependencies::DependencyDto>>,
100 AppResponseData<crate::services::content_history::ContentHistoryPage>,
100 AppResponseData<api::pagination::Page<crate::services::content_history::ContentHistoryDto>>,
101101 AppResponseData<Option<crate::services::stopwatch::StopwatchDto>>,
102102 AppResponseData<crate::services::stopwatch::StopwatchDto>,
103 AppResponseData<crate::services::tracked_time::TrackedTimePage>,
103 AppResponseData<api::pagination::Page<crate::services::tracked_time::TrackedTimeDto>>,
104104 AppResponseData<crate::services::issue_users::IssueUserDto>,
105 AppResponseData<crate::services::issue_users::IssueUserPage>,
105 AppResponseData<api::pagination::Page<crate::services::issue_users::IssueUserDto>>,
106106 AppResponseData<crate::services::repo_relations::RepoRelDto>,
107107 AppResponseData<Vec<crate::services::repo_relations::RepoRelDto>>,
108108 api::rest::NoData,
109109 api::users::UserDto,
110110 space::services::dto::SpacePath,
111111 crate::services::boards::BoardDto,
112 crate::services::boards::BoardPage,
113112 crate::services::boards::CreateBoardCmd,
114113 crate::services::boards::UpdateBoardCmd,
115114 crate::services::boards::ListBoardQuery,
@@ -124,20 +123,17 @@
124123 crate::services::issues::IssuePath,
125124 crate::services::comments::CommentDto,
126125 crate::services::comments::CommentTreeNode,
127 crate::services::comments::CommentPage,
128126 crate::services::comments::CreateCommentCmd,
129127 crate::services::comments::UpdateCommentCmd,
130128 crate::services::comments::ListCommentQuery,
131129 crate::services::comments::CommentPath,
132130 crate::services::labels::LabelDto,
133 crate::services::labels::LabelPage,
134131 crate::services::labels::CreateLabelCmd,
135132 crate::services::labels::UpdateLabelCmd,
136133 crate::services::labels::ListLabelQuery,
137134 crate::services::labels::LabelPath,
138135 crate::services::labels::IssueLabelPath,
139136 crate::services::milestones::MilestoneDto,
140 crate::services::milestones::MilestonePage,
141137 crate::services::milestones::CreateMilestoneCmd,
142138 crate::services::milestones::UpdateMilestoneCmd,
143139 crate::services::milestones::ListMilestoneQuery,
@@ -153,7 +149,6 @@
153149 crate::services::watches::WatchPath,
154150 models::common::watch_mode::WatchMode,
155151 crate::services::members::IssueMemberDto,
156 crate::services::members::IssueMemberPage,
157152 crate::services::members::CreateIssueMemberCmd,
158153 crate::services::members::ListIssueMemberQuery,
159154 crate::services::members::IssueMemberPath,
@@ -163,20 +158,17 @@
163158 crate::services::pins::UpdatePinCmd,
164159 crate::services::pins::PinPath,
165160 crate::services::events::EventDto,
166 crate::services::events::EventPage,
167161 crate::services::events::EventPath,
168162 crate::services::events::ListEventQuery,
169163 crate::services::dependencies::DependencyDto,
170164 crate::services::dependencies::CreateDependencyCmd,
171165 crate::services::dependencies::DependencyPath,
172166 crate::services::content_history::ContentHistoryDto,
173 crate::services::content_history::ContentHistoryPage,
174167 crate::services::content_history::ContentHistoryPath,
175168 crate::services::content_history::ListContentHistoryQuery,
176169 crate::services::stopwatch::StopwatchDto,
177170 crate::services::stopwatch::StopwatchPath,
178171 crate::services::tracked_time::TrackedTimeDto,
179 crate::services::tracked_time::TrackedTimePage,
180172 crate::services::tracked_time::TrackedTimePath,
181173 crate::services::tracked_time::TrackedTimeIdPath,
182174 crate::services::tracked_time::CreateTrackedTimeCmd,
@@ -187,7 +179,6 @@
187179 crate::services::tracked_time::TrackedTimePath,
188180 crate::services::tracked_time::ListTrackedTimeQuery,
189181 crate::services::issue_users::IssueUserDto,
190 crate::services::issue_users::IssueUserPage,
191182 crate::services::issue_users::UpdateIssueUserCmd,
192183 crate::services::issue_users::IssueUserPath,
193184 crate::services::issue_users::ListIssueUserQuery,
Modified lib/service/pulls/src/handlers/comments/list_pull_comments.rs lib/service/pulls/src/handlers/comments/list_pull_comments.rs +1 −1 View lib/service/pulls/src/handlers/comments/list_pull_comments.rs
@@ -16,7 +16,7 @@
1616 description = "分页读取合并请求的全部评论(含行内评论)。",
1717 params(PullPath, PageQuery),
1818 responses(
19 (status = 200, description = "comments", body = AppResponseData<CommentPage>)
19 (status = 200, description = "comments", body = AppResponseData<api::pagination::Page<crate::services::comments::PullCommentDto>>)
2020 )
2121)]
2222pub async fn list_pull_comments(
Modified lib/service/pulls/src/handlers/pulls/list_pull_events.rs lib/service/pulls/src/handlers/pulls/list_pull_events.rs +1 −1 View lib/service/pulls/src/handlers/pulls/list_pull_events.rs
@@ -16,7 +16,7 @@
1616 description = "分页读取合并请求时间线(创建、评审、合并、推送更新等)。",
1717 params(PullPath, PageQuery),
1818 responses(
19 (status = 200, description = "pull request timeline", body = AppResponseData<EventPage>)
19 (status = 200, description = "pull request timeline", body = AppResponseData<api::pagination::Page<crate::services::events::PullEventDto>>)
2020 )
2121)]
2222pub async fn list_pull_events(
Modified lib/service/pulls/src/handlers/pulls/list_pulls.rs lib/service/pulls/src/handlers/pulls/list_pulls.rs +1 −1 View lib/service/pulls/src/handlers/pulls/list_pulls.rs
@@ -15,7 +15,7 @@
1515 description = "按状态、作者、分支、草稿标记或关键字分页查询合并请求。",
1616 params(RepoPath, ListPullQuery),
1717 responses(
18 (status = 200, description = "list pull requests", body = AppResponseData<PullPage>)
18 (status = 200, description = "list pull requests", body = AppResponseData<api::pagination::Page<crate::services::pulls::PullDto>>)
1919 )
2020)]
2121pub async fn list_pulls(
Modified lib/service/pulls/src/handlers/inbox.rs lib/service/pulls/src/handlers/inbox.rs +1 −1 View lib/service/pulls/src/handlers/inbox.rs
@@ -19,7 +19,7 @@
1919 description = "跨仓库的个人合并请求收件箱:我创建的、待我评审的、我评审过的。",
2020 params(InboxPath, InboxQuery),
2121 responses(
22 (status = 200, description = "inbox", body = AppResponseData<InboxPage>)
22 (status = 200, description = "inbox", body = AppResponseData<api::pagination::Page<crate::services::pulls::PullDto>>)
2323 )
2424)]
2525pub async fn list_my_pulls(
Modified lib/service/pulls/src/openapi.rs lib/service/pulls/src/openapi.rs +4 −4 View lib/service/pulls/src/openapi.rs
@@ -56,9 +56,9 @@
5656 api::pagination::SortOrder,
5757 crate::services::pulls::PullSort,
5858 AppResponseData<crate::services::pulls::PullDto>,
59 AppResponseData<crate::services::pulls::PullPage>,
60 AppResponseData<crate::services::comments::CommentPage>,
61 AppResponseData<crate::services::events::EventPage>,
59 AppResponseData<api::pagination::Page<crate::services::pulls::PullDto>>,
60 AppResponseData<api::pagination::Page<crate::services::comments::PullCommentDto>>,
61 AppResponseData<api::pagination::Page<crate::services::events::PullEventDto>>,
6262 AppResponseData<Vec<git::facade::MergeCommitDto>>,
6363 AppResponseData<Vec<git::facade::ChangedPathDiffDto>>,
6464 AppResponseData<crate::services::changes::PullSummaryDto>,
@@ -74,7 +74,7 @@
7474 AppResponseData<Vec<crate::services::relations::IssuePullLinkDto>>,
7575 AppResponseData<crate::services::checks::PullChecksDto>,
7676 AppResponseData<crate::services::checks::PullGateDto>,
77 AppResponseData<crate::services::inbox::InboxPage>,
77 AppResponseData<api::pagination::Page<crate::services::pulls::PullDto>>,
7878 NoData,
7979 api::users::UserDto,
8080 issues::services::labels::LabelDto,
Modified lib/service/space/src/handlers/explore_spaces.rs lib/service/space/src/handlers/explore_spaces.rs +2 −2 View lib/service/space/src/handlers/explore_spaces.rs
@@ -17,7 +17,7 @@
1717 description = "分页列出公开的活跃空间;已登录时返回调用者的成员与申请状态。",
1818 params(ExploreQuery),
1919 responses(
20 (status = 200, description = "explore public spaces", body = AppResponseData<ExploreSpacePage>),
20 (status = 200, description = "explore public spaces", body = AppResponseData<api::pagination::Page<crate::services::join_requests::ExploreSpaceDto>>),
2121 (status = 400, description = "bad request", body = AppResponseData<NoData>)
2222 )
2323)]
@@ -37,7 +37,7 @@
3737 description = "分页列出空间的加入申请,需要成员管理权限。",
3838 params(SpacePath, ListJoinRequestsQuery),
3939 responses(
40 (status = 200, description = "list join requests", body = AppResponseData<JoinRequestPage>),
40 (status = 200, description = "list join requests", body = AppResponseData<api::pagination::Page<crate::services::join_requests::JoinRequestDto>>),
4141 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
4242 (status = 404, description = "not found", body = AppResponseData<NoData>),
4343 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/space/src/handlers/list_groups.rs lib/service/space/src/handlers/list_groups.rs +1 −1 View lib/service/space/src/handlers/list_groups.rs
@@ -14,7 +14,7 @@
1414 description = "分页列出空间群组及权限;需要角色管理权限。",
1515 params(SpacePath, PageQuery),
1616 responses(
17 (status = 200, description = "list space groups", body = AppResponseData<GroupPage>),
17 (status = 200, description = "list space groups", body = AppResponseData<api::pagination::Page<crate::services::groups::GroupDto>>),
1818 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1919 (status = 404, description = "space not found", body = AppResponseData<NoData>),
2020 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
Modified lib/service/space/src/services/join.rs lib/service/space/src/services/join.rs +1 −1 View lib/service/space/src/services/join.rs
@@ -210,7 +210,7 @@
210210 .filter(space_join_requests::Column::OrganizationId.eq(organization_id))
211211 .filter(space_join_requests::Column::UserId.eq(actor_user_id))
212212 .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Pending))
213 .one(self.access.database.read())
213 .one(self.access.database.write())
214214 .await?
215215 {
216216 let mut active: space_join_requests::ActiveModel = pending.into();
Modified lib/service/space/src/services/join_requests.rs lib/service/space/src/services/join_requests.rs +1 −1 View lib/service/space/src/services/join_requests.rs
@@ -355,7 +355,7 @@
355355 .filter(space_join_requests::Column::OrganizationId.eq(space.id))
356356 .filter(space_join_requests::Column::UserId.eq(caller))
357357 .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Pending))
358 .one(self.access.database.read())
358 .one(self.access.database.write())
359359 .await?;
360360 if pending.is_some() {
361361 return Err(SpaceError::Conflict(
Modified lib/service/space/src/openapi.rs lib/service/space/src/openapi.rs +3 −6 View lib/service/space/src/openapi.rs
@@ -82,11 +82,11 @@
8282 api::pagination::SortOrder,
8383 crate::services::join_requests::ExploreSort,
8484 AppResponseData<crate::services::dto::SpaceDto>,
85 AppResponseData<crate::services::join_requests::ExploreSpacePage>,
85 AppResponseData<api::pagination::Page<crate::services::join_requests::ExploreSpaceDto>>,
8686 AppResponseData<crate::services::join_requests::JoinRequestDto>,
87 AppResponseData<crate::services::join_requests::JoinRequestPage>,
87 AppResponseData<api::pagination::Page<crate::services::join_requests::JoinRequestDto>>,
8888 AppResponseData<crate::services::groups::GroupDto>,
89 AppResponseData<crate::services::groups::GroupPage>,
89 AppResponseData<api::pagination::Page<crate::services::groups::GroupDto>>,
9090 AppResponseData<crate::services::dto::MemberDto>,
9191 AppResponseData<crate::services::images::SpaceImageDto>,
9292 AppResponseData<Option<crate::services::images::SpaceImageDto>>,
@@ -99,17 +99,14 @@
9999 AppResponseData<Vec<crate::services::dir::UserOverwriteDto>>,
100100 crate::services::dto::SpaceDto,
101101 crate::services::join_requests::ExploreSpaceDto,
102 crate::services::join_requests::ExploreSpacePage,
103102 crate::services::join_requests::ExploreViewerDto,
104103 crate::services::join_requests::ExploreViewerRequestDto,
105104 crate::services::join_requests::JoinRequestDto,
106 crate::services::join_requests::JoinRequestPage,
107105 crate::services::join_requests::CreateJoinRequestCmd,
108106 models::space::SpaceJoinRequestState,
109107 api::pagination::Page<crate::services::dto::SpaceDto>,
110108 crate::services::dto::UserDto,
111109 crate::services::groups::GroupDto,
112 crate::services::groups::GroupPage,
113110 crate::services::groups::CreateGroupCmd,
114111 crate::services::groups::UpdateGroupCmd,
115112 crate::services::groups::AssignGroupCmd,
Modified lib/service/webapi/src/account_deletion.rs lib/service/webapi/src/account_deletion.rs +1 −0 View lib/service/webapi/src/account_deletion.rs
@@ -82,6 +82,7 @@
8282 responses(
8383 (status = 200, description = "deletion cancelled", body = AppResponseData<NoData>),
8484 (status = 404, description = "no deletion is pending", body = AppResponseData<NoData>),
85 (status = 409, description = "account deletion is currently being purged", body = AppResponseData<NoData>),
8586 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
8687 )
8788)]
Modified lib/share/models/src/migrate/mod.rs lib/share/models/src/migrate/mod.rs +84 −14 View lib/share/models/src/migrate/mod.rs
@@ -1,23 +1,62 @@
11use std::hash::{DefaultHasher, Hash, Hasher};
22
3use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement};
3use sea_orm::{
4 ConnectionTrait, DatabaseBackend, DatabaseConnection, DbErr, Statement, TransactionTrait,
5};
46
57const SCHEMA_PRE_SYNC: &str = include_str!("../../schema_pre_sync.sql");
68const SCHEMA_EXTRAS: &str = include_str!("../../schema_extras.sql");
9/// Bump whenever `sync_schema` gains a Rust-side step that is not captured by
10/// the entity registry or by `schema_pre_sync.sql` / `schema_extras.sql`.
711const SYNC_FORMAT_VERSION: u32 = 1;
12pub const MIGRATION_LOCK_KEY: i64 = 5_241_915_330_551;
813const SYNC_STATE_DDL: &str = "CREATE TABLE IF NOT EXISTS schema_sync_state (
914 id smallint PRIMARY KEY CHECK (id = 1),
1015 fingerprint text NOT NULL,
16 live_fingerprint text,
1117 synced_at timestamptz NOT NULL DEFAULT now()
1218)";
19const SYNC_STATE_LIVE_COLUMN: &str =
20 "ALTER TABLE schema_sync_state ADD COLUMN IF NOT EXISTS live_fingerprint text";
21const LIVE_FINGERPRINT_SQL: &str = r#"
22SELECT COALESCE(md5(string_agg(entry, E'\n' ORDER BY entry)), '') AS fingerprint
23FROM (
24 SELECT format('table:%s', table_name) AS entry
25 FROM information_schema.tables
26 WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
27 UNION ALL
28 SELECT format('column:%s.%s:%s:%s:%s',
29 table_name, column_name, data_type, is_nullable,
30 COALESCE(column_default, ''))
31 FROM information_schema.columns
32 WHERE table_schema = 'public'
33 AND table_name IN (
34 SELECT table_name
35 FROM information_schema.tables
36 WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
37 )
38 UNION ALL
39 SELECT format('index:%s:%s', tablename, indexdef)
40 FROM pg_indexes
41 WHERE schemaname = 'public'
42 UNION ALL
43 SELECT format('constraint:%s:%s', conrelid::regclass, pg_get_constraintdef(oid))
44 FROM pg_constraint
45 WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
46 UNION ALL
47 SELECT format('enum:%s:%s', t.typname, e.enumlabel)
48 FROM pg_type t
49 JOIN pg_enum e ON e.enumtypid = t.oid
50 JOIN pg_namespace n ON n.oid = t.typnamespace
51 WHERE n.nspname = 'public'
52) AS entries
53"#;
1354
14pub async fn sync_schema(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> {
55pub async fn sync_schema(db: &DatabaseConnection) -> Result<(), DbErr> {
1556 let builder = db.get_schema_registry("models::");
1657 if db.get_database_backend() != DatabaseBackend::Postgres {
1758 return builder.sync(db).await;
1859 }
19
20 db.execute_unprepared(SYNC_STATE_DDL).await?;
2160
2261 let mut hasher = DefaultHasher::new();
2362 SYNC_FORMAT_VERSION.hash(&mut hasher);
@@ -26,26 +65,57 @@
2665 format!("{builder:?}").hash(&mut hasher);
2766 let fingerprint = format!("{:016x}", hasher.finish());
2867
29 let stored = db
68 let txn = db.begin().await?;
69 txn.execute_unprepared(&format!(
70 "SELECT pg_advisory_xact_lock({MIGRATION_LOCK_KEY})"
71 ))
72 .await?;
73 txn.execute_unprepared(SYNC_STATE_DDL).await?;
74 txn.execute_unprepared(SYNC_STATE_LIVE_COLUMN).await?;
75
76 let live = live_fingerprint(&txn).await?;
77 let stored = txn
3078 .query_one_raw(Statement::from_string(
3179 DatabaseBackend::Postgres,
32 "SELECT fingerprint FROM schema_sync_state WHERE id = 1",
80 "SELECT fingerprint, live_fingerprint FROM schema_sync_state WHERE id = 1",
3381 ))
3482 .await?;
3583 if let Some(row) = stored {
36 if row.try_get::<String>("", "fingerprint")? == fingerprint {
84 let stored_fingerprint = row.try_get::<String>("", "fingerprint")?;
85 let stored_live = row.try_get::<Option<String>>("", "live_fingerprint")?;
86 if stored_fingerprint == fingerprint && stored_live.as_deref() == Some(live.as_str()) {
87 txn.commit().await?;
3788 return Ok(());
3889 }
3990 }
4091
41 db.execute_unprepared(SCHEMA_PRE_SYNC).await?;
42 builder.sync(db).await?;
43 db.execute_unprepared(SCHEMA_EXTRAS).await?;
92 txn.execute_unprepared(SCHEMA_PRE_SYNC).await?;
93 builder.sync(&txn).await?;
94 txn.execute_unprepared(SCHEMA_EXTRAS).await?;
4495
96 let live = live_fingerprint(&txn).await?;
4597 let update = format!(
46 "INSERT INTO schema_sync_state (id, fingerprint) VALUES (1, '{fingerprint}') \
47 ON CONFLICT (id) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, synced_at = now()"
98 "INSERT INTO schema_sync_state (id, fingerprint, live_fingerprint) \
99 VALUES (1, '{fingerprint}', '{live}') \
100 ON CONFLICT (id) DO UPDATE SET fingerprint = EXCLUDED.fingerprint, \
101 live_fingerprint = EXCLUDED.live_fingerprint, synced_at = now()"
48102 );
49 db.execute_unprepared(&update).await?;
50 Ok(())
103 txn.execute_unprepared(&update).await?;
104 txn.commit().await
105}
106
107async fn live_fingerprint<C>(db: &C) -> Result<String, DbErr>
108where
109 C: ConnectionTrait,
110{
111 let row = db
112 .query_one_raw(Statement::from_string(
113 DatabaseBackend::Postgres,
114 LIVE_FINGERPRINT_SQL,
115 ))
116 .await?;
117 match row {
118 Some(row) => row.try_get::<String>("", "fingerprint"),
119 None => Ok(String::new()),
120 }
51121}
Modified lib/share/models/schema_extras.sql lib/share/models/schema_extras.sql +14 −1 View lib/share/models/schema_extras.sql
@@ -2,7 +2,7 @@
22-- Idempotent PostgreSQL DDL for constraints that SeaORM entity-first `sync`
33-- cannot express via entity attributes. Applied by models::migrate::sync_schema
44-- after each successful entity sync (fingerprint-gated), so it only runs when
5-- the entity schema or one of the two SQL files changed.
5-- the entity schema, one of the two SQL files, or the live database changed.
66-- Safe to re-run on every schema change; every statement is guarded with
77-- IF NOT EXISTS / DO-block checks.
88
@@ -29,6 +29,19 @@
2929CREATE UNIQUE INDEX IF NOT EXISTS idx_branch_protection_whitelist_group
3030 ON repo_branch_protection_whitelist (policy_id, kind, group_id) WHERE group_id IS NOT NULL;
3131
32-- One pending join request per (organization, user). Existing duplicates are
33-- collapsed to the newest row before the unique index is (re)built.
34DELETE FROM space_join_requests AS stale
35USING space_join_requests AS keep
36WHERE stale.organization_id = keep.organization_id
37 AND stale.user_id = keep.user_id
38 AND stale.status = 'pending'
39 AND keep.status = 'pending'
40 AND (stale.created_at, stale.id) < (keep.created_at, keep.id);
41
42CREATE UNIQUE INDEX IF NOT EXISTS idx_space_join_requests_pending
43 ON space_join_requests (organization_id, user_id) WHERE status = 'pending';
44
3245-- ---------------------------------------------------------------------------
3346-- Composite non-unique indexes (entity attributes only express single-column
3447-- indexed/unique; lookups below span two columns).
Modified lib/share/models/schema_pre_sync.sql lib/share/models/schema_pre_sync.sql +10 −8 View lib/share/models/schema_pre_sync.sql
@@ -6,21 +6,22 @@
66-- stale unique key, and Postgres then issues DROP CONSTRAINT on a plain index,
77-- which fails. These indexes are recreated by schema_extras.sql after sync.
88--
9-- Only indexes whose column list differs from an entity unique key belong
10-- here. Indexes that do match (idx_issue_watch_issue_user, ..._project_user,
11-- idx_issue_reaction_issue, idx_issue_reaction_comment) must NOT be dropped:
12-- sync keeps them as-is and the drop would only rebuild them needlessly.
13--
14-- The migrate command only runs this file when the entity schema fingerprint
15-- changed, so the rebuild cost is paid on schema changes, not on every start.
9-- Partial indexes are dropped even when their column list matches an entity
10-- unique key: sync compares only column lists and then issues DROP CONSTRAINT,
11-- which fails because these are plain indexes. Recreating them is cheap enough
12-- because the gate below runs on schema changes or drift, not on every start.
1613
1714-- Legacy plain indexes left behind by older unique_key definitions. They no
1815-- longer match the entity column list, so sync would try to drop them and
1916-- abort because they are plain indexes.
2017DROP INDEX IF EXISTS "idx-issue_watch-issue_user";
21DROP INDEX IF EXISTS "idx-issue-reaction-issue_user_emoji";
18DROP INDEX IF EXISTS "idx-issue_reaction-issue_user_emoji";
2219
2320-- Hand-managed per-target unique keys (predicates not expressible in entities).
21DROP INDEX IF EXISTS idx_issue_watch_issue_user;
22DROP INDEX IF EXISTS idx_issue_watch_project_user;
23DROP INDEX IF EXISTS idx_issue_reaction_issue;
24DROP INDEX IF EXISTS idx_issue_reaction_comment;
2425DROP INDEX IF EXISTS idx_issue_reaction_project;
2526DROP INDEX IF EXISTS idx_branch_protection_whitelist_user;
2627DROP INDEX IF EXISTS idx_branch_protection_whitelist_group;
@@ -28,6 +29,7 @@
2829DROP INDEX IF EXISTS idx_chat_dm_pair;
2930DROP INDEX IF EXISTS idx_chat_gdm_key;
3031DROP INDEX IF EXISTS idx_chat_draft_unique;
32DROP INDEX IF EXISTS idx_space_join_requests_pending;
3133-- scheduled messages feature removed; drop the legacy table before sync
3234DROP TABLE IF EXISTS user_scheduled_message;
3335
Modified openapi.json openapi.json View openapi.json
Diff too large to display.