mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 16:05:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d6ef9585 | ||
|
|
fdfb77c4a2 | ||
|
|
a93e3853f7 |
@@ -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: |
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -147,7 +147,7 @@ Admin API 不要求客户端提交配置 revision,也不向客户端暴露配
|
||||
|
||||
## 8. PostgreSQL 终态
|
||||
|
||||
`backend/migrations/0001_initial.sql` 创建且只创建七张业务表;`0003_s3_backup.sql` 增加备份配置与备份记录两张表:
|
||||
`backend/migrations/0001_initial.sql` 直接定义当前大版本完整的新库基线,共创建九张业务表:
|
||||
|
||||
| 表 | 权威事实 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.1.0"
|
||||
version: "3.1.1"
|
||||
|
||||
Reference in New Issue
Block a user