Compare commits

...
3 Commits
Author SHA1 Message Date
ZyyandCodex 31d6ef9585 chore: bump version to 3.1.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-02 04:29:44 +08:00
ZyyandCodex fdfb77c4a2 ci(release): reuse caches across versions
Dispatch releases from the long-lived publishing branch so Cargo caches are shared across version tags. Store prebuilt image caches in per-architecture GHCR refs and include toolchain and workspace manifests in Cargo cache invalidation.

Co-authored-by: Codex <noreply@openai.com>
2026-08-02 04:27:49 +08:00
ZyyandCodex a93e3853f7 chore: consolidate migrations and tune settings colors
Fold the completed provider snapshot, S3 backup, and redundant-index changes into the initial PostgreSQL baseline. Keep the settings section tabs aligned with the input surface color.

Co-authored-by: Codex <noreply@openai.com>
2026-08-02 04:07:37 +08:00
14 changed files with 243 additions and 460 deletions
+9 -12
View File
@@ -1,9 +1,6 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
@@ -15,7 +12,7 @@ permissions:
contents: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
group: release-${{ inputs.tag }}
cancel-in-progress: false
env:
@@ -43,14 +40,14 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Read release metadata
id: version
shell: bash
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
require "json"
@@ -188,9 +185,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-${{ runner.os }}-${{ matrix.target }}-
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -253,9 +250,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-${{ runner.os }}-${{ matrix.target }}-
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -471,8 +468,8 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
cache-from: type=gha,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
sbom: true
labels: |
+4 -3
View File
@@ -182,9 +182,10 @@ upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
release/publish <version>
```
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
不要手工修改版本后单独打 tag。该脚本只发布仓库版本和触发 Release 构建,不会登录服务器,也不会
改变任何运行实例;源码提交、仓库发版和实例升级是三个独立状态。
脚本依赖已登录的 GitHub CLI(`gh auth login`)。它会更新 `release/version.yaml`、创建约定提交与带
注释的 `v<version>` tag,原子推送分支和 tag,再从发布分支触发 Release 构建,使构建缓存可以跨版本
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
仓库发版和实例升级是三个独立状态。
## 文档
@@ -1,4 +1,4 @@
//! 八张终态业务表的 PostgreSQL adapters。
//! 九张终态业务表的 PostgreSQL adapters。
use async_trait::async_trait;
use sqlx::{PgPool, postgres::PgPoolOptions};
@@ -21,6 +21,8 @@ mod runtime_settings;
mod snapshot;
mod snapshots;
static TEST_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
pub(super) struct TestDatabase {
admin: PgPool,
pub(super) pool: PgPool,
@@ -56,28 +58,10 @@ impl TestDatabase {
.connect(&database_url)
.await
.expect("connect isolated test schema");
let mut migration = pool.begin().await.expect("begin terminal migration");
sqlx::raw_sql(include_str!("../../../../migrations/0001_initial.sql"))
.execute(&mut *migration)
TEST_MIGRATOR
.run(&pool)
.await
.expect("apply terminal migration");
sqlx::raw_sql(include_str!(
"../../../../migrations/0002_snapshot_provider_account_identity.sql"
))
.execute(&mut *migration)
.await
.expect("apply snapshot migration");
sqlx::raw_sql(include_str!("../../../../migrations/0003_s3_backup.sql"))
.execute(&mut *migration)
.await
.expect("apply backup migration");
sqlx::raw_sql(include_str!(
"../../../../migrations/0004_drop_provider_accounts_id_kind_uq.sql"
))
.execute(&mut *migration)
.await
.expect("apply index drop migration");
migration.commit().await.expect("commit terminal migration");
.expect("apply test migrations");
Some(Self {
admin,
pool,
@@ -172,24 +156,19 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
.expect("drop migration test database");
admin.close().await;
assert_eq!((first_table_count, migration_count), (10, 4));
assert_eq!((first_table_count, migration_count), (10, 1));
assert_eq!(response_id_types, ["bytea", "bytea"]);
assert!(!raw_response_id_index_exists);
}
#[test]
fn migrations_should_leave_transaction_ownership_to_sqlx() {
let transaction_statements = [
include_str!("../../../../migrations/0001_initial.sql"),
include_str!("../../../../migrations/0002_snapshot_provider_account_identity.sql"),
include_str!("../../../../migrations/0003_s3_backup.sql"),
include_str!("../../../../migrations/0004_drop_provider_accounts_id_kind_uq.sql"),
]
.into_iter()
.flat_map(str::lines)
.map(str::trim)
.filter(|line| matches!(*line, "begin;" | "commit;"))
.count();
let transaction_statements = TEST_MIGRATOR
.iter()
.flat_map(|migration| migration.sql.as_str().lines())
.map(str::trim)
.filter(|line| matches!(*line, "begin;" | "commit;"))
.count();
assert_eq!(transaction_statements, 0);
}
@@ -6,19 +6,9 @@ use gateway_store::postgres::{
PgObservabilityRepository, PgOpsEventRepository, UsageRecordFilter, UsageRecordQuery,
};
use sqlx::PgPool;
use uuid::Uuid;
use super::TestDatabase;
type ProviderAccountSnapshotRow = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
#[tokio::test]
async fn request_snapshots_should_survive_account_deletion() {
let Some(database) = TestDatabase::create("snapshot_request_delete").await else {
@@ -664,175 +654,6 @@ async fn renaming_account_should_not_rewrite_historical_snapshots() {
database.close().await;
}
#[tokio::test]
async fn migration_should_backfill_snapshots_for_existing_accounts_and_survive_deletion() {
let Some(database_url) = crate::support::test_env("CPR_TEST_DATABASE_URL") else {
return;
};
let schema = format!("cpr_store_snapshot_backfill_{}", Uuid::new_v4().simple());
let admin = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect test PostgreSQL");
sqlx::raw_sql(sqlx::AssertSqlSafe(format!("create schema \"{schema}\"")))
.execute(&admin)
.await
.expect("create backfill schema");
let search_path = schema.clone();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.after_connect(move |connection, _metadata| {
let search_path = search_path.clone();
Box::pin(async move {
sqlx::query("select set_config('search_path', $1, false)")
.bind(search_path)
.execute(connection)
.await?;
Ok(())
})
})
.connect(&database_url)
.await
.expect("connect backfill pool");
let started_at = Utc::now();
{
let mut migration = pool.begin().await.expect("begin terminal migration");
sqlx::raw_sql(include_str!("../../../../migrations/0001_initial.sql"))
.execute(&mut *migration)
.await
.expect("apply initial migration");
migration.commit().await.expect("commit initial migration");
}
seed_account(
&pool,
"acct_backfill",
"Backfill",
Some("backfill@example.invalid"),
"oauth",
started_at,
)
.await;
sqlx::query(
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id,
provider_kind, provider_account_id, provider_account_ref,
upstream_model_id, upstream_transport, attempt_count, upstream_send_state,
started_at, deadline_at
) values (
'req_backfill', 'key_backfill', 1, 'openai', 'responses', '/v1/responses',
'http_sse', 'coding', 'openai', 'acct_backfill', 'acct_backfill',
'upstream-backfill', 'http_sse', 1, 'sent', $1, $1 + interval '30 seconds'
)",
)
.bind(started_at)
.execute(&pool)
.await
.expect("insert pre-migration request");
sqlx::query(
"insert into ops_events (
id, level, component, operation,
provider_kind, provider_account_id, provider_account_ref,
failure_kind, message, occurrence_count, created_at
) values (
'ops_backfill', 'warning', 'account_probe', 'connection_test',
'openai', 'acct_backfill', 'acct_backfill',
'auth_failed', 'backfill probe', 1, $1
)",
)
.bind(started_at)
.execute(&pool)
.await
.expect("insert pre-migration ops event");
{
let mut migration = pool.begin().await.expect("begin snapshot migration");
sqlx::raw_sql(include_str!(
"../../../../migrations/0002_snapshot_provider_account_identity.sql"
))
.execute(&mut *migration)
.await
.expect("apply snapshot migration");
migration.commit().await.expect("commit snapshot migration");
}
let (
request_name,
request_email,
request_authentication_kind,
event_name,
event_email,
event_authentication_kind,
): ProviderAccountSnapshotRow = sqlx::query_as(
"select
(select provider_account_name_snapshot from model_requests where id = 'req_backfill'),
(select provider_account_email_snapshot from model_requests where id = 'req_backfill'),
(select provider_account_authentication_kind_snapshot from model_requests where id = 'req_backfill'),
(select provider_account_name_snapshot from ops_events where id = 'ops_backfill'),
(select provider_account_email_snapshot from ops_events where id = 'ops_backfill'),
(select provider_account_authentication_kind_snapshot from ops_events where id = 'ops_backfill')",
)
.fetch_one(&pool)
.await
.expect("load backfilled snapshots");
assert_eq!(
(
request_name.as_deref(),
request_email.as_deref(),
request_authentication_kind.as_deref(),
),
(
Some("Backfill"),
Some("backfill@example.invalid"),
Some("oauth"),
)
);
assert_eq!(
(
event_name.as_deref(),
event_email.as_deref(),
event_authentication_kind.as_deref(),
),
(
Some("Backfill"),
Some("backfill@example.invalid"),
Some("oauth"),
)
);
sqlx::query("delete from provider_accounts where id = 'acct_backfill'")
.execute(&pool)
.await
.expect("delete backfilled account");
let request_snapshot: (Option<String>, Option<String>, Option<String>) = sqlx::query_as(
"select provider_account_name_snapshot, provider_account_email_snapshot,
provider_account_authentication_kind_snapshot
from model_requests where id = 'req_backfill'",
)
.fetch_one(&pool)
.await
.expect("load request snapshot after deletion");
assert_eq!(
(
request_snapshot.0.as_deref(),
request_snapshot.1.as_deref(),
request_snapshot.2.as_deref(),
),
(
Some("Backfill"),
Some("backfill@example.invalid"),
Some("oauth"),
)
);
drop(pool);
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
"drop schema \"{schema}\" cascade"
)))
.execute(&admin)
.await
.expect("drop backfill schema");
admin.close().await;
}
fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
NewModelRequest {
id: id.to_owned(),
+1 -4
View File
@@ -1,4 +1 @@
a4681a5c040e80c5234e879293fc34972080e5251dfbc7c40e1374f8e4170a4c 0001_initial.sql
7dc3b626e7a958281f82c0e646b8284f8b373e0c7d3dbdaf750811ce0c4a7f83 0002_snapshot_provider_account_identity.sql
ada40b11728ba67cfe44d8492bc77a4754b36640e575b25daa5dfe90e92e43aa 0003_s3_backup.sql
52087c48b289eb5d8d9b00e338bb4d9861e81290e0be6bd0e311862ba2d4b59b 0004_drop_provider_accounts_id_kind_uq.sql
54be07512951a561391b6e611355a1bfc119d82363253fb9bbdb225c9e3e39bb 0001_initial.sql
+195 -2
View File
@@ -1,3 +1,4 @@
-- 管理员身份与密码摘要。
create table admin_users (
id text primary key,
password_hash text not null,
@@ -6,6 +7,7 @@ create table admin_users (
constraint admin_users_time_ck check (created_at <= updated_at)
);
-- 管理操作审计事件。
create table admin_audit_events (
id text primary key,
actor_kind text not null,
@@ -44,6 +46,7 @@ create index admin_audit_events_entity_idx
create index admin_audit_events_actor_ref_idx
on admin_audit_events (actor_ref, created_at desc, id desc);
-- 客户端 API 密钥、鉴权范围与限额。
create table client_api_keys (
id text primary key,
name text not null,
@@ -74,6 +77,7 @@ create index client_api_keys_created_idx
create index client_api_keys_last_used_idx
on client_api_keys (last_used_at desc, id desc);
-- 全局运行参数与配置版本。
create table runtime_settings (
id bigint primary key,
config_revision bigint not null default 1,
@@ -110,6 +114,7 @@ create table runtime_settings (
)
);
-- 上游 Provider 账号、凭据与可用性状态。
create table provider_accounts (
id text primary key,
provider_kind text not null,
@@ -182,8 +187,6 @@ create table provider_accounts (
)
);
create unique index provider_accounts_id_kind_uq
on provider_accounts (id, provider_kind);
create unique index provider_accounts_upstream_identity_uq
on provider_accounts (
provider_kind,
@@ -203,6 +206,7 @@ create index provider_accounts_email_idx
on provider_accounts (provider_kind, lower(email))
where email is not null;
-- 模型请求、上游尝试、用量、计费与结果。
create table model_requests (
id text primary key,
client_api_key_id text,
@@ -218,6 +222,9 @@ create table model_requests (
service_tier text,
provider_account_id text,
provider_account_ref text,
provider_account_name_snapshot text,
provider_account_email_snapshot text,
provider_account_authentication_kind_snapshot text,
upstream_transport text,
http_version text,
websocket_pool text,
@@ -431,6 +438,7 @@ create index model_requests_running_deadline_idx
create index model_requests_retention_idx
on model_requests (completed_at)
where outcome <> 'running';
-- 运行期警告与错误事件。
create table ops_events (
id text primary key,
model_request_id text,
@@ -441,6 +449,9 @@ create table ops_events (
provider_kind text,
provider_account_id text,
provider_account_ref text,
provider_account_name_snapshot text,
provider_account_email_snapshot text,
provider_account_authentication_kind_snapshot text,
upstream_model_id text,
failure_kind text not null,
status_code integer,
@@ -506,3 +517,185 @@ create index ops_events_retention_idx
insert into runtime_settings (id, updated_at)
values (1, now());
-- S3 兼容对象存储、备份计划与保留策略。
create table backup_settings (
id bigint primary key,
storage_revision bigint not null default 1,
endpoint text,
region text,
bucket text,
access_key_id text,
secret_access_key text,
prefix text,
force_path_style boolean not null default false,
schedule_enabled boolean not null default false,
cron_expression text,
schedule_timezone text default 'Asia/Shanghai',
retention_days bigint not null default 0,
retention_count bigint not null default 0,
next_run_at timestamptz,
last_verified_at timestamptz,
updated_at timestamptz not null,
constraint backup_settings_singleton_ck check (id = 1),
constraint backup_settings_revision_ck check (storage_revision > 0),
constraint backup_settings_endpoint_ck check (
endpoint is null
or (
endpoint ~ '^https?://'
and endpoint !~ '[[:space:][:cntrl:]]'
and endpoint !~ '://[^/]*@'
)
),
constraint backup_settings_lengths_ck check (
(bucket is null or (octet_length(bucket) between 1 and 255 and bucket !~ '[[:cntrl:]]'))
and (region is null or (octet_length(region) between 1 and 64 and region !~ '[[:cntrl:]]'))
and (prefix is null or (octet_length(prefix) <= 1024 and prefix !~ '[[:cntrl:]]'))
and (
access_key_id is null
or (octet_length(access_key_id) between 1 and 255 and access_key_id !~ '[[:cntrl:]]')
)
and (
secret_access_key is null
or (
octet_length(secret_access_key) between 1 and 512
and secret_access_key !~ '[[:cntrl:]]'
)
)
),
constraint backup_settings_schedule_ck check (
(cron_expression is null or cron_expression ~ '^[^[:space:]]+([[:space:]]+[^[:space:]]+){4}$')
and (
schedule_timezone is null
or (octet_length(schedule_timezone) between 1 and 64 and schedule_timezone !~ '[[:cntrl:]]')
)
and retention_days >= 0
and retention_count >= 0
and (
not schedule_enabled
or (
endpoint is not null
and bucket is not null
and region is not null
and access_key_id is not null
and secret_access_key is not null
and prefix is not null
and cron_expression is not null
and schedule_timezone is not null
and last_verified_at is not null
)
)
),
-- 计划启停与游标一致性:启用必有 next_run_at,关闭必清空。
constraint backup_settings_cursor_ck check (
(not schedule_enabled and next_run_at is null)
or (schedule_enabled and next_run_at is not null)
)
);
insert into backup_settings (
id,
storage_revision,
schedule_enabled,
retention_days,
retention_count,
force_path_style,
schedule_timezone,
updated_at
)
values (1, 1, false, 0, 0, false, 'Asia/Shanghai', now());
-- 备份任务状态与归档信息。
create table backup_records (
id text primary key,
trigger_kind text not null,
status text not null,
scheduled_at timestamptz,
object_key text not null,
size_bytes bigint,
sha256 text,
expires_at timestamptz,
attempt_count integer not null default 0,
error_code text,
error_message text,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null,
updated_at timestamptz not null,
constraint backup_records_id_ck check (id ~ '^backup_[0-9a-f]{32}$'),
constraint backup_records_trigger_kind_ck check (
trigger_kind in ('manual', 'scheduled')
and (
(trigger_kind = 'manual' and scheduled_at is null)
or (trigger_kind = 'scheduled' and scheduled_at is not null)
)
),
constraint backup_records_status_ck check (
status in ('queued', 'dumping', 'uploading', 'completed', 'failed', 'deleting')
),
constraint backup_records_attempt_ck check (attempt_count >= 0),
constraint backup_records_lifecycle_ck check (
created_at <= updated_at
and (started_at is null or started_at >= created_at)
and (completed_at is null or completed_at >= created_at)
and (
(
status = 'queued'
and started_at is null
and completed_at is null
)
or (
status in ('dumping', 'uploading')
and started_at is not null
and completed_at is null
)
or (
status in ('completed', 'failed', 'deleting')
and started_at is not null
and completed_at is not null
)
)
),
constraint backup_records_artifact_ck check (
(
status not in ('uploading', 'completed')
or (size_bytes is not null and sha256 is not null)
)
and (size_bytes is null or size_bytes >= 0)
and (sha256 is null or sha256 ~ '^[0-9a-f]{64}$')
),
constraint backup_records_error_ck check (
(error_code is null) = (error_message is null)
and (status <> 'failed' or (error_code is not null and error_message is not null))
and (
status not in ('queued', 'dumping', 'uploading', 'completed')
or error_code is null
)
),
-- expires_at 为空,或不早于创建时间(创建时 now + 天数 恒晚于 created_at)。
constraint backup_records_expiry_ck check (
expires_at is null or expires_at >= created_at
)
);
create index backup_records_created_idx
on backup_records (created_at desc, id desc);
create index backup_records_status_idx
on backup_records (status, created_at, id);
create unique index backup_records_scheduled_uq
on backup_records (scheduled_at)
where trigger_kind = 'scheduled' and scheduled_at is not null;
create unique index backup_records_active_uq
on backup_records ((1))
where status in ('queued', 'dumping', 'uploading');
create unique index backup_records_object_key_uq
on backup_records (object_key)
where object_key is not null;
-- 同时服务 retentionDays 与 retentionCount。
create index backup_records_scheduled_completed_idx
on backup_records (completed_at desc, id desc)
where trigger_kind = 'scheduled' and status = 'completed';
-- 可清理记录(expires_at 已到期且非活跃)的扫描索引。
create index backup_records_retention_idx
on backup_records (expires_at)
where expires_at is not null and status in ('completed', 'failed');
@@ -1,29 +0,0 @@
-- Provider 账号观测快照:请求/事件发生时账号的名称、邮箱与认证类型。
-- 账号删除后 provider_account_id 外键置空,历史显示仍依赖这些快照列;
-- provider_account_ref 继续承担稳定筛选与关联事实。
alter table model_requests
add column provider_account_name_snapshot text,
add column provider_account_email_snapshot text,
add column provider_account_authentication_kind_snapshot text;
alter table ops_events
add column provider_account_name_snapshot text,
add column provider_account_email_snapshot text,
add column provider_account_authentication_kind_snapshot text;
-- 迁移时仍存在账号的历史行回填快照;已删除账号的历史行保持为空,
-- 读取侧回退 provider_account_ref。迁移前已删除账号的资料无法恢复。
update model_requests mr
set provider_account_name_snapshot = pa.name,
provider_account_email_snapshot = pa.email,
provider_account_authentication_kind_snapshot = pa.authentication_kind
from provider_accounts pa
where pa.id = mr.provider_account_ref;
update ops_events oe
set provider_account_name_snapshot = pa.name,
provider_account_email_snapshot = pa.email,
provider_account_authentication_kind_snapshot = pa.authentication_kind
from provider_accounts pa
where pa.id = oe.provider_account_ref;
-189
View File
@@ -1,189 +0,0 @@
-- S3 兼容对象存储(含 Cloudflare R2)与 PostgreSQL 备份。
-- 按 docs/s3-backup-design-audit.md 最终状态实现。
--
-- backup_settings 是单例配置行(id = 1);backup_records 记录每次备份任务。
-- 当前部署边界是单副本:任务执行由单个可取消 Daemon 承担,记录表不需要
-- fencing token、heartbeat 或墓碑状态。skipped 只是日志/指标,删除成功后记录
-- 硬删除,操作历史进入 admin_audit_events。
-- 手动过期 TTL:每份备份独立的 expires_at,创建时确定,到期自动清理
-- (审计 A-06 曾移除该字段,按参考实现 sub2api 的语义加回)。
create table backup_settings (
id bigint primary key,
storage_revision bigint not null default 1,
endpoint text,
region text,
bucket text,
access_key_id text,
secret_access_key text,
prefix text,
force_path_style boolean not null default false,
schedule_enabled boolean not null default false,
cron_expression text,
schedule_timezone text default 'Asia/Shanghai',
retention_days bigint not null default 0,
retention_count bigint not null default 0,
next_run_at timestamptz,
last_verified_at timestamptz,
updated_at timestamptz not null,
constraint backup_settings_singleton_ck check (id = 1),
constraint backup_settings_revision_ck check (storage_revision > 0),
constraint backup_settings_endpoint_ck check (
endpoint is null
or (
endpoint ~ '^https?://'
and endpoint !~ '[[:space:][:cntrl:]]'
and endpoint !~ '://[^/]*@'
)
),
constraint backup_settings_lengths_ck check (
(bucket is null or (octet_length(bucket) between 1 and 255 and bucket !~ '[[:cntrl:]]'))
and (region is null or (octet_length(region) between 1 and 64 and region !~ '[[:cntrl:]]'))
and (prefix is null or (octet_length(prefix) <= 1024 and prefix !~ '[[:cntrl:]]'))
and (
access_key_id is null
or (octet_length(access_key_id) between 1 and 255 and access_key_id !~ '[[:cntrl:]]')
)
and (
secret_access_key is null
or (
octet_length(secret_access_key) between 1 and 512
and secret_access_key !~ '[[:cntrl:]]'
)
)
),
constraint backup_settings_schedule_ck check (
(cron_expression is null or cron_expression ~ '^[^[:space:]]+([[:space:]]+[^[:space:]]+){4}$')
and (
schedule_timezone is null
or (octet_length(schedule_timezone) between 1 and 64 and schedule_timezone !~ '[[:cntrl:]]')
)
and retention_days >= 0
and retention_count >= 0
and (
not schedule_enabled
or (
endpoint is not null
and bucket is not null
and region is not null
and access_key_id is not null
and secret_access_key is not null
and prefix is not null
and cron_expression is not null
and schedule_timezone is not null
and last_verified_at is not null
)
)
),
-- 计划启停与游标一致性:启用必有 next_run_at,关闭必清空。
constraint backup_settings_cursor_ck check (
(not schedule_enabled and next_run_at is null)
or (schedule_enabled and next_run_at is not null)
)
);
insert into backup_settings (
id,
storage_revision,
schedule_enabled,
retention_days,
retention_count,
force_path_style,
schedule_timezone,
updated_at
)
values (1, 1, false, 0, 0, false, 'Asia/Shanghai', now());
create table backup_records (
id text primary key,
trigger_kind text not null,
status text not null,
scheduled_at timestamptz,
object_key text not null,
size_bytes bigint,
sha256 text,
expires_at timestamptz,
attempt_count integer not null default 0,
error_code text,
error_message text,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null,
updated_at timestamptz not null,
constraint backup_records_id_ck check (id ~ '^backup_[0-9a-f]{32}$'),
constraint backup_records_trigger_kind_ck check (
trigger_kind in ('manual', 'scheduled')
and (
(trigger_kind = 'manual' and scheduled_at is null)
or (trigger_kind = 'scheduled' and scheduled_at is not null)
)
),
constraint backup_records_status_ck check (
status in ('queued', 'dumping', 'uploading', 'completed', 'failed', 'deleting')
),
constraint backup_records_attempt_ck check (attempt_count >= 0),
constraint backup_records_lifecycle_ck check (
created_at <= updated_at
and (started_at is null or started_at >= created_at)
and (completed_at is null or completed_at >= created_at)
and (
(
status = 'queued'
and started_at is null
and completed_at is null
)
or (
status in ('dumping', 'uploading')
and started_at is not null
and completed_at is null
)
or (
status in ('completed', 'failed', 'deleting')
and started_at is not null
and completed_at is not null
)
)
),
constraint backup_records_artifact_ck check (
(
status not in ('uploading', 'completed')
or (size_bytes is not null and sha256 is not null)
)
and (size_bytes is null or size_bytes >= 0)
and (sha256 is null or sha256 ~ '^[0-9a-f]{64}$')
),
constraint backup_records_error_ck check (
(error_code is null) = (error_message is null)
and (status <> 'failed' or (error_code is not null and error_message is not null))
and (
status not in ('queued', 'dumping', 'uploading', 'completed')
or error_code is null
)
),
-- expires_at 为空,或不早于创建时间(创建时 now + 天数 恒晚于 created_at)。
constraint backup_records_expiry_ck check (
expires_at is null or expires_at >= created_at
)
);
create index backup_records_created_idx
on backup_records (created_at desc, id desc);
create index backup_records_status_idx
on backup_records (status, created_at, id);
create unique index backup_records_scheduled_uq
on backup_records (scheduled_at)
where trigger_kind = 'scheduled' and scheduled_at is not null;
create unique index backup_records_active_uq
on backup_records ((1))
where status in ('queued', 'dumping', 'uploading');
create unique index backup_records_object_key_uq
on backup_records (object_key)
where object_key is not null;
-- 同时服务 retentionDays 与 retentionCount。
create index backup_records_scheduled_completed_idx
on backup_records (completed_at desc, id desc)
where trigger_kind = 'scheduled' and status = 'completed';
-- 可清理记录(expires_at 已到期且非活跃)的扫描索引。
create index backup_records_retention_idx
on backup_records (expires_at)
where expires_at is not null and status in ('completed', 'failed');
@@ -1,5 +0,0 @@
-- 删除被主键覆盖的冗余唯一索引(docs/project-redundancy-boundary-audit.md DB-01)。
-- provider_accounts.id 已是主键,(id, provider_kind) 第二列不增加唯一性,
-- 工程内也没有外键依赖该复合索引。
drop index provider_accounts_id_kind_uq;
+1 -1
View File
@@ -147,7 +147,7 @@ Admin API 不要求客户端提交配置 revision,也不向客户端暴露配
## 8. PostgreSQL 终态
`backend/migrations/0001_initial.sql` 创建且只创建七张业务表;`0003_s3_backup.sql` 增加备份配置与备份记录两张表:
`backend/migrations/0001_initial.sql` 直接定义当前大版本完整的新库基线,共创建九张业务表:
| 表 | 权威事实 |
| --- | --- |
+1 -1
View File
@@ -91,7 +91,7 @@ watch(
<div class="mt-4">
<BaseSegmented
:model-value="section"
class="bg-(--cp-default-bg-active)!"
class="bg-(--cp-input-soft-bg)!"
:options="[
{ label: '运行设置', value: 'runtime' },
{ label: '备份', value: 'backup' },
+18
View File
@@ -67,8 +67,11 @@ fi
}
require_command git
require_command gh
require_command ruby
gh auth status >/dev/null 2>&1 || die "gh authentication is required; run gh auth login first"
version="${1#v}"
[[ "$version" =~ $SEMVER ]] || die "version must be semver, for example 0.1.4"
@@ -83,6 +86,9 @@ merge_ref="$(git config --get "branch.$branch.merge" || true)"
[[ -n "$remote" && -n "$merge_ref" ]] || die "current branch has no upstream"
remote_branch="${merge_ref#refs/heads/}"
[[ "$remote_branch" != "$merge_ref" && -n "$remote_branch" ]] || die "unsupported upstream ref: $merge_ref"
remote_url="$(git remote get-url "$remote")" || die "failed to resolve remote URL for $remote"
repository="$(gh repo view "$remote_url" --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)" ||
die "failed to resolve GitHub repository for $remote_url"
if ! git diff --quiet --ignore-submodules --; then
die "tracked files have unstaged changes; commit or stash them first"
@@ -129,8 +135,20 @@ git commit \
git tag -a "$tag" -m "$tag"
git push --atomic "$remote" "HEAD:$remote_branch" "refs/tags/$tag"
# Dispatch from the long-lived release branch instead of the tag so GitHub
# Actions caches remain reusable across releases.
if ! gh workflow run release.yml \
--repo "$repository" \
--ref "$remote_branch" \
--raw-field "tag=$tag"; then
echo "error: commit and tag were pushed, but the Release workflow was not dispatched" >&2
echo "retry: gh workflow run release.yml --repo $repository --ref $remote_branch --raw-field tag=$tag" >&2
exit 1
fi
cat <<DONE
Published $APP_NAME $version
Commit: $(git rev-parse --short HEAD)
Tag: $tag
Release workflow dispatched from: $remote_branch
DONE
+1 -1
View File
@@ -1 +1 @@
version: "3.1.0"
version: "3.1.1"