feat: add retention policy and stability verification

This commit is contained in:
2026-09-22 16:19:21 +08:00
parent c33c12c5e9
commit 2f95d9c45c
30 changed files with 1633 additions and 18 deletions

View File

@@ -7,9 +7,12 @@ import (
"os"
"strconv"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
)
type Config struct {
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
HTTPAddr string
MySQLDSN string
MaxOpen int
@@ -120,5 +123,10 @@ func FromEnv(get func(string) string) (Config, error) {
return Config{}, errors.New("invalid ORBIT_METRICS_ADDR")
}
}
var err error
c.RetentionDefaults, err = retention.FromEnv(get)
if err != nil {
return Config{}, err
}
return c, nil
}

View File

@@ -0,0 +1,43 @@
package retention
import (
"context"
"time"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// ArchiveInput 选择一类历史快照;每批最多100条根记录,游标绑定类别和策略版本。
type ArchiveInput struct {
Kind string `json:"kind" required:"true"` // Kind tasks、workflows、events、audit、deliveries、inbox 或 idempotency。
Limit int `json:"limit"` // Limit 根记录数量,默认20,最大100。
Cursor string `json:"cursor"` // Cursor 上一批返回的继续位置,空表示从最早记录开始。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"` // ExpectedRevision 防止使用页面未确认的保留窗口导出。
}
// ArchiveSnapshot 是一个只读归档批次;SourceRetained始终为true,禁止把导出当作数据库清理。
type ArchiveSnapshot struct {
FormatVersion int `json:"format_version"` // FormatVersion 归档信封格式版本。
SHA256 string `json:"sha256"` // SHA256 对Content原始UTF-8字节的摘要,校验传输和文件完整性。
Content string `json:"content"` // Content 是原始 JSON 文本;下载时按 UTF-8 原样保存再核验 SHA256,不重新序列化。
}
// CandidatePage 描述按策略筛选的一批根记录,不宣称它们可以删除。
type CandidatePage struct {
Kind string `json:"kind"` // Kind 历史类型。
Cutoff time.Time `json:"cutoff"` // Cutoff 当前策略的UTC排除边界。
Count int `json:"count"` // Count 本批候选数量,不是全库数量。
More bool `json:"more"` // More 后面还有候选根记录。
}
// Preview 返回按各类保留窗口读取的有限候选数量,不加载业务正文。
type Preview struct {
Policy Policy `json:"policy"` // Policy 实际读取的版本和窗口。
SampleLimit int `json:"sample_limit"` // SampleLimit 每类候选的取样上限。
Items []CandidatePage `json:"items"` // Items 固定类别的候选样本。
}
type ArchiveStore interface {
PreviewRetention(context.Context, auth.Token) (Preview, error)
ExportRetention(context.Context, auth.Token, ArchiveInput) (ArchiveSnapshot, error)
}

View File

@@ -0,0 +1,192 @@
// Package retention 定义 Orbit 历史数据保留策略的通用模型。
//
// 策略只描述候选数据的最短保留窗口,不直接删除任务、事件或审计记录。
// 归档执行器必须在独立流程中先完成核查、备份和归档证明,再按策略处理。
package retention
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const (
// DefaultIdempotencyDays 是请求幂等记录的默认保留天数。
DefaultIdempotencyDays uint32 = 90
// DefaultTaskHistoryDays 是终态任务历史的默认保留天数。
DefaultTaskHistoryDays uint32 = 365
// DefaultWorkflowHistoryDays 是终态流程历史的默认保留天数。
DefaultWorkflowHistoryDays uint32 = 365
// DefaultEventLogDays 是状态事件和结果事件的默认保留天数。
DefaultEventLogDays uint32 = 365
// DefaultDeliveryHistoryDays 是已确认交付历史的默认保留天数。
DefaultDeliveryHistoryDays uint32 = 90
// DefaultInboxHistoryDays 是已处理流程 Inbox 历史的默认保留天数。
DefaultInboxHistoryDays uint32 = 90
// DefaultAuditLogDays 是管理审计的默认保留天数,约七年。
DefaultAuditLogDays uint32 = 2555
minRetentionDays uint32 = 1
maxRetentionDays uint32 = 3650
)
// Policy 是按命名空间保存的历史数据保留策略。
// Enabled 默认关闭;即使打开,也允许只读归档导出使用满足窗口且已核验的数据。
// 当前版本不会因为配置变更自动删除任何数据。
type Policy struct {
// NamespaceID 是策略所属命名空间,不能由控制台请求替换。
NamespaceID core.ID `json:"namespace_id"`
// Revision 是乐观并发版本;默认策略未落库时为0,首次保存后从1开始。
Revision uint64 `json:"revision,string"`
// Enabled 表示是否允许归档执行器使用该策略;默认关闭以避免误清理。
Enabled bool `json:"enabled"`
// IdempotencyDays 是请求幂等记录至少保留的天数。
IdempotencyDays uint32 `json:"idempotency_days"`
// TaskHistoryDays 是终态任务及执行证据至少保留的天数。
TaskHistoryDays uint32 `json:"task_history_days"`
// WorkflowHistoryDays 是终态流程、步骤、等待和流程事件至少保留的天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days"`
// EventLogDays 是状态事件和结果事件至少保留的天数。
EventLogDays uint32 `json:"event_log_days"`
// DeliveryHistoryDays 是已确认结果交付至少保留的天数;未确认交付不得按窗口清理。
DeliveryHistoryDays uint32 `json:"delivery_history_days"`
// InboxHistoryDays 是已处理流程 Inbox 事件至少保留的天数;未处理记录不得清理。
InboxHistoryDays uint32 `json:"inbox_history_days"`
// AuditLogDays 是管理审计至少保留的天数;审计记录不参与普通清理。
AuditLogDays uint32 `json:"audit_log_days"`
// UpdatedAt 是策略最后修改时间,统一使用 UTC。
UpdatedAt time.Time `json:"updated_at"`
// Source 表示策略来自默认值还是数据库配置,便于控制台明确当前状态。
Source string `json:"source"`
}
// UpdateInput 是控制台保存策略所需的完整替换输入。
// 所有窗口必须显式提交,避免遗漏字段被解释成缩短保留期。
type UpdateInput struct {
// ExpectedRevision 是页面读取到的版本;首次保存必须为0。
ExpectedRevision uint64 `json:"expected_revision,string" required:"true"`
// Enabled 是否允许归档执行器读取本策略。
Enabled bool `json:"enabled" required:"true"`
// IdempotencyDays 请求幂等记录保留天数。
IdempotencyDays uint32 `json:"idempotency_days" required:"true"`
// TaskHistoryDays 终态任务历史保留天数。
TaskHistoryDays uint32 `json:"task_history_days" required:"true"`
// WorkflowHistoryDays 终态流程历史保留天数。
WorkflowHistoryDays uint32 `json:"workflow_history_days" required:"true"`
// EventLogDays 状态和结果事件保留天数。
EventLogDays uint32 `json:"event_log_days" required:"true"`
// DeliveryHistoryDays 已确认交付历史保留天数。
DeliveryHistoryDays uint32 `json:"delivery_history_days" required:"true"`
// InboxHistoryDays 已处理 Inbox 历史保留天数。
InboxHistoryDays uint32 `json:"inbox_history_days" required:"true"`
// AuditLogDays 管理审计保留天数。
AuditLogDays uint32 `json:"audit_log_days" required:"true"`
// Reason 是本次策略变更原因,会进入管理审计。
Reason string `json:"reason" required:"true"`
}
// Store 是控制台和归档执行器共享的最小策略存储边界。
// 实现必须保证 Update 的版本核对、幂等和审计与策略写入处于同一事务。
type Store interface {
GetRetentionPolicy(context.Context, core.ID) (Policy, error)
UpdateRetentionPolicy(context.Context, auth.Token, string, UpdateInput) (Policy, error)
}
// Defaults 返回不会触发任何归档执行的安全默认策略。
func Defaults(namespace core.ID) Policy {
return Policy{
NamespaceID: namespace,
Enabled: false,
IdempotencyDays: DefaultIdempotencyDays,
TaskHistoryDays: DefaultTaskHistoryDays,
WorkflowHistoryDays: DefaultWorkflowHistoryDays,
EventLogDays: DefaultEventLogDays,
DeliveryHistoryDays: DefaultDeliveryHistoryDays,
InboxHistoryDays: DefaultInboxHistoryDays,
AuditLogDays: DefaultAuditLogDays,
Source: "default",
}
}
// Validate 检查策略身份、窗口和审计原因。
func (p Policy) Validate() error {
if p.NamespaceID.Validate() != nil {
return errors.New("retention namespace is invalid")
}
for name, days := range map[string]uint32{
"idempotency_days": p.IdempotencyDays,
"task_history_days": p.TaskHistoryDays,
"workflow_history_days": p.WorkflowHistoryDays,
"event_log_days": p.EventLogDays,
"delivery_history_days": p.DeliveryHistoryDays,
"inbox_history_days": p.InboxHistoryDays,
"audit_log_days": p.AuditLogDays,
} {
if days < minRetentionDays || days > maxRetentionDays {
return fmt.Errorf("%s must be between %d and %d days", name, minRetentionDays, maxRetentionDays)
}
}
if p.IdempotencyDays < DefaultIdempotencyDays {
return errors.New("idempotency_days cannot be less than 90 days")
}
return nil
}
// ToPolicy 将完整输入转为待保存策略,版本由事务内分配。
func (in UpdateInput) ToPolicy(namespace core.ID) (Policy, error) {
if strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || len(in.Reason) > 1024 || strings.ContainsAny(in.Reason, "\r\n") {
return Policy{}, errors.New("retention reason must be 1-1024 UTF-8 bytes without newlines")
}
p := Policy{
NamespaceID: namespace,
Enabled: in.Enabled,
IdempotencyDays: in.IdempotencyDays,
TaskHistoryDays: in.TaskHistoryDays,
WorkflowHistoryDays: in.WorkflowHistoryDays,
EventLogDays: in.EventLogDays,
DeliveryHistoryDays: in.DeliveryHistoryDays,
InboxHistoryDays: in.InboxHistoryDays,
AuditLogDays: in.AuditLogDays,
}
if err := p.Validate(); err != nil {
return Policy{}, err
}
return p, nil
}
// FromEnv 读取部署默认值;数据库策略保存后优先于部署默认值。
// 所有节点应注入同一组默认值,保证尚未保存策略的展示一致。
func FromEnv(get func(string) string) (Policy, error) {
p := Defaults("")
for _, f := range []struct {
name string
dst *uint32
}{
{"ORBIT_RETENTION_IDEMPOTENCY_DAYS", &p.IdempotencyDays},
{"ORBIT_RETENTION_TASK_DAYS", &p.TaskHistoryDays},
{"ORBIT_RETENTION_WORKFLOW_DAYS", &p.WorkflowHistoryDays},
{"ORBIT_RETENTION_EVENT_DAYS", &p.EventLogDays},
{"ORBIT_RETENTION_DELIVERY_DAYS", &p.DeliveryHistoryDays},
{"ORBIT_RETENTION_INBOX_DAYS", &p.InboxHistoryDays},
{"ORBIT_RETENTION_AUDIT_DAYS", &p.AuditLogDays},
} {
if raw := get(f.name); raw != "" {
n, err := strconv.ParseUint(raw, 10, 32)
if err != nil || n < 1 || n > 3650 {
return Policy{}, fmt.Errorf("%s must be 1-3650 days", f.name)
}
*f.dst = uint32(n)
}
}
if p.IdempotencyDays < 90 {
return Policy{}, errors.New("ORBIT_RETENTION_IDEMPOTENCY_DAYS cannot be less than 90")
}
return p, nil
}

View File

@@ -0,0 +1,65 @@
package retention
import (
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func TestDefaultsAndValidation(t *testing.T) {
ns, err := core.NewID()
if err != nil {
t.Fatal(err)
}
p := Defaults(ns)
if p.Enabled || p.Source != "default" || p.IdempotencyDays != 90 || p.TaskHistoryDays != 365 || p.AuditLogDays != 2555 {
t.Fatalf("unexpected defaults: %+v", p)
}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
p.IdempotencyDays = 89
if err := p.Validate(); err == nil {
t.Fatal("accepted an idempotency window shorter than the safety minimum")
}
}
func TestUpdateInputRequiresReasonAndKeepsSafetyMinimum(t *testing.T) {
ns, _ := core.NewID()
in := UpdateInput{
IdempotencyDays: 90, TaskHistoryDays: 30, WorkflowHistoryDays: 30,
EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30,
AuditLogDays: 365, Reason: "quarterly retention review",
}
if _, err := in.ToPolicy(ns); err != nil {
t.Fatal(err)
}
in.Reason = ""
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an empty audit reason")
}
in.Reason = "shorten"
in.IdempotencyDays = 89
if _, err := in.ToPolicy(ns); err == nil {
t.Fatal("accepted an unsafe idempotency window")
}
}
func TestFromEnvOverridesOnlyDeploymentDefaults(t *testing.T) {
values := map[string]string{
"ORBIT_RETENTION_IDEMPOTENCY_DAYS": "120",
"ORBIT_RETENTION_TASK_DAYS": "180",
"ORBIT_RETENTION_AUDIT_DAYS": "730",
}
p, err := FromEnv(func(k string) string { return values[k] })
if err != nil {
t.Fatal(err)
}
if p.IdempotencyDays != 120 || p.TaskHistoryDays != 180 || p.AuditLogDays != 730 || p.EventLogDays != DefaultEventLogDays {
t.Fatalf("environment defaults not applied: %+v", p)
}
values["ORBIT_RETENTION_IDEMPOTENCY_DAYS"] = "89"
if _, err := FromEnv(func(k string) string { return values[k] }); err == nil {
t.Fatal("accepted unsafe environment idempotency window")
}
}

View File

@@ -92,6 +92,9 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
// 已经存在的 v29/v30 表误当成迁移重复执行。
f.exec(`DROP TABLE orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 28.
-- Orbit fresh-install baseline, generated from migration version 30.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -852,3 +852,29 @@ CREATE TABLE orbit_console_sessions (
KEY ix_console_session_expiry (expires_at, revoked_at),
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';
-- 命名空间级历史保留策略;只保存窗口和开关,不执行自动删除。
-- 归档执行器必须在独立事务中核对状态、备份和归档证明后再使用这些窗口。
CREATE TABLE orbit_retention_policies (
namespace_id BINARY(16) NOT NULL COMMENT '策略所属命名空间,服务端核对登录会话后使用',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '策略乐观并发版本,从1开始;首次默认策略未落库时版本为0',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许归档执行器使用策略;默认关闭,不触发删除',
idempotency_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '请求幂等记录至少保留天数,范围90至3650',
task_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态任务和执行证据至少保留天数,范围1至3650',
workflow_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态流程、步骤、等待和流程事件至少保留天数,范围1至3650',
event_log_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '状态事件和结果事件至少保留天数,范围1至3650',
delivery_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已确认结果交付至少保留天数,未确认交付不得按窗口清理',
inbox_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已处理流程Inbox事件至少保留天数,未处理记录不得清理',
audit_log_days INT UNSIGNED NOT NULL DEFAULT 2555 COMMENT '管理审计至少保留天数,普通清理不得处理审计记录',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略首次保存时间,统一UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略最后修改时间,统一UTC',
PRIMARY KEY (namespace_id),
CONSTRAINT ck_retention_policy_revision CHECK (revision > 0),
CONSTRAINT ck_retention_policy_idempotency CHECK (idempotency_days BETWEEN 90 AND 3650),
CONSTRAINT ck_retention_policy_task CHECK (task_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_workflow CHECK (workflow_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_event CHECK (event_log_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_delivery CHECK (delivery_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_inbox CHECK (inbox_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_audit CHECK (audit_log_days BETWEEN 1 AND 3650)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 29
const freshInstallBaselineVersion uint64 = 30
type Migration struct {
Version uint64

View File

@@ -0,0 +1,25 @@
-- 命名空间级历史保留策略;只保存窗口和开关,不执行自动删除。
-- 归档执行器必须在独立事务中核对状态、备份和归档证明后再使用这些窗口。
CREATE TABLE orbit_retention_policies (
namespace_id BINARY(16) NOT NULL COMMENT '策略所属命名空间,服务端核对登录会话后使用',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '策略乐观并发版本,从1开始;首次默认策略未落库时版本为0',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许归档执行器使用策略;默认关闭,不触发删除',
idempotency_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '请求幂等记录至少保留天数,范围90至3650',
task_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态任务和执行证据至少保留天数,范围1至3650',
workflow_history_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '终态流程、步骤、等待和流程事件至少保留天数,范围1至3650',
event_log_days INT UNSIGNED NOT NULL DEFAULT 365 COMMENT '状态事件和结果事件至少保留天数,范围1至3650',
delivery_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已确认结果交付至少保留天数,未确认交付不得按窗口清理',
inbox_history_days INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '已处理流程Inbox事件至少保留天数,未处理记录不得清理',
audit_log_days INT UNSIGNED NOT NULL DEFAULT 2555 COMMENT '管理审计至少保留天数,普通清理不得处理审计记录',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略首次保存时间,统一UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '策略最后修改时间,统一UTC',
PRIMARY KEY (namespace_id),
CONSTRAINT ck_retention_policy_revision CHECK (revision > 0),
CONSTRAINT ck_retention_policy_idempotency CHECK (idempotency_days BETWEEN 90 AND 3650),
CONSTRAINT ck_retention_policy_task CHECK (task_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_workflow CHECK (workflow_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_event CHECK (event_log_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_delivery CHECK (delivery_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_inbox CHECK (inbox_history_days BETWEEN 1 AND 3650),
CONSTRAINT ck_retention_policy_audit CHECK (audit_log_days BETWEEN 1 AND 3650)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间历史数据保留策略,只配置窗口和开关,不自动删除';

View File

@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
indexes := func() []string {
t.Helper()
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,377 @@
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const archiveMaxBytes = 8 << 20
const archiveMaxRows = 10000
// 查询定义只来自固定目录;浏览器不能提交表名、列名或SQL条件。
type archiveSource struct {
kind, table, clock, predicate string
days uint32
}
func archiveSources(p retention.Policy) []archiveSource {
return []archiveSource{
{"tasks", "pulse_tasks", "completed_at", `state IN ('SUCCEEDED','FAILED','CANCELLED') AND effect_uncertain=FALSE AND lease_until IS NULL AND owner_kind IS NULL`, p.TaskHistoryDays},
{"workflows", "weave_instances", "completed_at", `state IN ('SUCCEEDED','FAILED','CANCELLED') AND NOT EXISTS (SELECT 1 FROM pulse_tasks t WHERE t.namespace_id=r.namespace_id AND t.gate_id=r.gate_id AND (t.state NOT IN ('SUCCEEDED','FAILED','CANCELLED') OR t.effect_uncertain=TRUE)) AND NOT EXISTS (SELECT 1 FROM weave_inbox i WHERE i.namespace_id=r.namespace_id AND i.workflow_id=r.id AND i.state<>'PROCESSED')`, p.WorkflowHistoryDays},
{"events", "orbit_events", "occurred_at", `NOT EXISTS (SELECT 1 FROM orbit_deliveries d WHERE d.namespace_id=r.namespace_id AND d.event_id=r.id AND d.state<>'ACKED') AND NOT EXISTS (SELECT 1 FROM weave_inbox i WHERE i.namespace_id=r.namespace_id AND i.event_id=r.id AND i.state<>'PROCESSED')`, p.EventLogDays},
{"audit", "orbit_audit_events", "created_at", "TRUE", p.AuditLogDays},
{"deliveries", "orbit_deliveries", "acknowledged_at", "state='ACKED'", p.DeliveryHistoryDays},
{"inbox", "weave_inbox", "processed_at", `state='PROCESSED' AND EXISTS (SELECT 1 FROM weave_instances w WHERE w.namespace_id=r.namespace_id AND w.id=r.workflow_id AND w.state IN ('SUCCEEDED','FAILED','CANCELLED'))`, p.InboxHistoryDays},
{"idempotency", "orbit_idempotency_keys", "created_at", "expires_at<UTC_TIMESTAMP(6)", p.IdempotencyDays},
}
}
type archiveCursor struct {
Namespace core.ID
Kind string
Revision uint64
Cutoff time.Time
Time time.Time
ID core.ID
}
type archiveRoot struct {
id core.ID
at time.Time
}
func archiveCandidates(ctx context.Context, tx *sql.Tx, ns core.ID, source archiveSource, cutoff time.Time, limit int, cursor *archiveCursor) ([]archiveRoot, error) {
q := `SELECT /*+ MAX_EXECUTION_TIME(1500) */ r.id,r.` + source.clock + ` FROM ` + source.table + ` r WHERE r.namespace_id=? AND r.` + source.clock + `<? AND (` + source.predicate + `)`
args := []any{ns, cutoff}
if cursor != nil {
q += ` AND (r.` + source.clock + `>? OR (r.` + source.clock + `=? AND r.id>?))`
args = append(args, cursor.Time, cursor.Time, cursor.ID)
}
q += ` ORDER BY r.` + source.clock + `,r.id LIMIT ?`
args = append(args, limit)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []archiveRoot
for rows.Next() {
var r archiveRoot
if err := rows.Scan(&r.id, &r.at); err != nil {
return nil, err
}
items = append(items, r)
}
return items, rows.Err()
}
func (s *RetentionPolicyStore) PreviewRetention(ctx context.Context, t auth.Token) (retention.Preview, error) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
out := retention.Preview{SampleLimit: 100, Items: []retention.CandidatePage{}}
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := retentionActor(ctx, tx, t); err != nil {
return err
}
p, err := s.readPolicy(ctx, tx, t.NamespaceID, false)
if err != nil {
return err
}
out.Items = nil // WithTx 发生提交前重试时丢弃前次样本。
out.Policy = p
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
for _, source := range archiveSources(p) {
cutoff := now.Add(-time.Duration(source.days) * 24 * time.Hour)
roots, err := archiveCandidates(ctx, tx, t.NamespaceID, source, cutoff, 101, nil)
if err != nil {
return err
}
out.Items = append(out.Items, retention.CandidatePage{Kind: source.kind, Cutoff: cutoff, Count: min(len(roots), 100), More: len(roots) > 100})
}
return nil
})
return out, err
}
// archiveData 是独立历史快照,不是迁移/灾难恢复备份,也不是源数据可删除的证明。
type archiveData struct {
Policy retention.Policy `json:"policy"` // Policy 冻结的保留策略。
Kind string `json:"kind"` // Kind 当前批次根资源类别。
SnapshotAt time.Time `json:"snapshot_at"` // SnapshotAt 读取数据库时间。
Cutoff time.Time `json:"cutoff"` // Cutoff 当前批次截止时间。
SourceRetained bool `json:"source_retained"` // SourceRetained 表示源数据库全部记录仍然保留。
RootCount int `json:"root_count"` // RootCount 本批根记录数量。
NextCursor string `json:"next_cursor"` // NextCursor 后续批次位置,非提交水位。
Tables map[string][]map[string]any `json:"tables"` // Tables 固定表目录下的历史快照;二进制为十六进制,JSON及整数为文本以保留精度。
}
func (s *RetentionPolicyStore) ExportRetention(ctx context.Context, t auth.Token, in retention.ArchiveInput) (retention.ArchiveSnapshot, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if in.Limit == 0 {
in.Limit = 20
}
if in.Limit < 1 || in.Limit > 100 {
return retention.ArchiveSnapshot{}, fault.Invalid("limit must be 1-100")
}
// 导出不自动重放事务;失败没有成功包,源数据始终保留。
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
return retention.ArchiveSnapshot{}, err
}
defer tx.Rollback()
actor, err := retentionActor(ctx, tx, t)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
p, err := s.readPolicy(ctx, tx, t.NamespaceID, true)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if !p.Enabled || p.Revision != in.ExpectedRevision {
return retention.ArchiveSnapshot{}, fault.Conflict
}
var source archiveSource
for _, v := range archiveSources(p) {
if v.kind == in.Kind {
source = v
}
}
if source.kind == "" {
return retention.ArchiveSnapshot{}, fault.Invalid("unsupported archive kind")
}
now, err := dbNow(ctx, tx)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
cutoff := now.Add(-time.Duration(source.days) * 24 * time.Hour)
var cursor *archiveCursor
if in.Cursor != "" {
if len(in.Cursor) > 2048 {
return retention.ArchiveSnapshot{}, fault.Invalid("invalid archive cursor")
}
b, e := base64.RawURLEncoding.DecodeString(in.Cursor)
var c archiveCursor
if e != nil || json.Unmarshal(b, &c) != nil || c.Namespace != t.NamespaceID || c.Kind != in.Kind || c.Revision != p.Revision || c.ID.Validate() != nil || c.Cutoff.After(cutoff) || c.Time.IsZero() || !c.Time.Before(c.Cutoff) {
return retention.ArchiveSnapshot{}, fault.Invalid("invalid archive cursor")
}
cursor = &c
cutoff = c.Cutoff
}
roots, err := archiveCandidates(ctx, tx, t.NamespaceID, source, cutoff, in.Limit+1, cursor)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
data := archiveData{Policy: p, Kind: in.Kind, SnapshotAt: now, Cutoff: cutoff, SourceRetained: true, Tables: map[string][]map[string]any{}}
if len(roots) > in.Limit {
roots = roots[:in.Limit]
last := roots[len(roots)-1]
raw, _ := json.Marshal(archiveCursor{t.NamespaceID, in.Kind, p.Revision, cutoff, last.at, last.id})
data.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
}
data.RootCount = len(roots)
budget := archiveBudget{}
for _, root := range roots {
if err := archiveRows(ctx, tx, &data, &budget, source.table, "id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if source.kind == "tasks" {
if err := archiveTaskHistory(ctx, tx, &data, &budget, root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if source.kind == "events" {
if err := archiveRows(ctx, tx, &data, &budget, "orbit_result_events", "id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if source.kind == "workflows" {
for _, table := range []string{"weave_steps", "weave_activations", "weave_waits", "weave_signals", "weave_poll_states", "weave_poll_rounds", "weave_inbox"} {
if err := archiveRows(ctx, tx, &data, &budget, table, "workflow_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
// 流程固定版本与全部历史关联任务也一起导出,不能只保存实例头。
if err := archiveRows(ctx, tx, &data, &budget, "weave_definition_versions", "id IN (SELECT definition_version_id FROM weave_instances WHERE namespace_id=? AND id=?)", t.NamespaceID, root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
taskRows, err := tx.QueryContext(ctx, `SELECT id FROM pulse_tasks WHERE namespace_id=? AND gate_id=(SELECT gate_id FROM weave_instances WHERE namespace_id=? AND id=?) LIMIT 10001`, t.NamespaceID, t.NamespaceID, root.id)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
var taskIDs []core.ID
for taskRows.Next() {
var id core.ID
if err := taskRows.Scan(&id); err != nil {
taskRows.Close()
return retention.ArchiveSnapshot{}, err
}
taskIDs = append(taskIDs, id)
}
err = taskRows.Err()
taskRows.Close()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if len(taskIDs) > archiveMaxRows {
return retention.ArchiveSnapshot{}, fault.Invalid("archive too large; use a smaller batch")
}
for _, id := range taskIDs {
if err := archiveRows(ctx, tx, &data, &budget, "pulse_tasks", "id=?", id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := archiveTaskHistory(ctx, tx, &data, &budget, id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
if err := archiveRows(ctx, tx, &data, &budget, "orbit_events", "workflow_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := archiveRows(ctx, tx, &data, &budget, "orbit_audit_events", "resource_kind='workflow' AND resource_id=?", root.id); err != nil {
return retention.ArchiveSnapshot{}, err
}
}
}
content, err := json.Marshal(data)
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if len(content) > archiveMaxBytes {
return retention.ArchiveSnapshot{}, fault.Invalid("archive too large; use a smaller batch")
}
sum := sha256.Sum256(content)
digest := hex.EncodeToString(sum[:])
// 只记录包摘要和数量,不将归档正文或秘密写到审计/日志。
op, err := core.NewID()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
audit, err := core.NewID()
if err != nil {
return retention.ArchiveSnapshot{}, err
}
info, _ := json.Marshal(map[string]any{"kind": in.Kind, "policy_revision": p.Revision, "root_count": len(roots), "rows": budget.rows, "sha256": digest, "source_retained": true})
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`after`) VALUES (?,?,?,?,'retention.archive.export','retention_policy',?,'export retained history snapshot',?)", t.NamespaceID, audit, op, "console:"+string(actor), t.NamespaceID, string(info))
if err != nil {
return retention.ArchiveSnapshot{}, err
}
if err := tx.Commit(); err != nil {
return retention.ArchiveSnapshot{}, err
}
return retention.ArchiveSnapshot{FormatVersion: 1, SHA256: digest, Content: string(content)}, nil
}
type archiveBudget struct{ rows, bytes int }
func archiveTaskHistory(ctx context.Context, tx *sql.Tx, d *archiveData, b *archiveBudget, id core.ID) error {
if err := archiveRows(ctx, tx, d, b, "pulse_attempts", "task_id=?", id); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, "SELECT id FROM pulse_attempts WHERE namespace_id=? AND task_id=? ORDER BY id LIMIT ?", d.Policy.NamespaceID, id, archiveMaxRows+1)
if err != nil {
return err
}
defer rows.Close()
var attemptIDs []core.ID
for rows.Next() {
var attemptID core.ID
if err := rows.Scan(&attemptID); err != nil {
return err
}
attemptIDs = append(attemptIDs, attemptID)
}
if err := rows.Err(); err != nil {
return err
}
if len(attemptIDs) > archiveMaxRows {
return fault.Invalid("archive too large; use a smaller batch")
}
for _, attemptID := range attemptIDs {
for _, table := range []string{"pulse_attempt_receipts", "pulse_attempt_evidence"} {
if err := archiveRows(ctx, tx, d, b, table, "attempt_id=?", attemptID); err != nil {
return err
}
}
}
if err := archiveRows(ctx, tx, d, b, "orbit_result_events", "id IN (SELECT id FROM orbit_events WHERE namespace_id=? AND aggregate_kind='task' AND aggregate_id=?)", d.Policy.NamespaceID, id); err != nil {
return err
}
if err := archiveRows(ctx, tx, d, b, "orbit_events", "aggregate_kind='task' AND aggregate_id=?", id); err != nil {
return err
}
return archiveRows(ctx, tx, d, b, "orbit_audit_events", "resource_kind='task' AND resource_id=?", id)
}
// archiveRows 仅由上方固定目录调用。密钥摘要不导出,业务JSON文本及大整数不经过浮点转换。
func archiveRows(ctx context.Context, tx *sql.Tx, d *archiveData, b *archiveBudget, table, where string, args ...any) error {
q := "SELECT /*+ MAX_EXECUTION_TIME(1500) */ * FROM " + table + " WHERE namespace_id=? AND " + where + " ORDER BY id LIMIT ?"
params := append([]any{d.Policy.NamespaceID}, args...)
params = append(params, archiveMaxRows-b.rows+1)
rows, err := tx.QueryContext(ctx, q, params...)
if err != nil {
return err
}
defer rows.Close()
cols, err := rows.ColumnTypes()
if err != nil {
return err
}
for rows.Next() {
values := make([]any, len(cols))
dest := make([]any, len(cols))
for i := range values {
dest[i] = &values[i]
}
if err := rows.Scan(dest...); err != nil {
return err
}
record := map[string]any{}
for i, col := range cols {
name := col.Name()
if strings.Contains(name, "secret") || name == "token_digest" {
continue
}
v := values[i]
switch x := v.(type) {
case []byte:
if col.DatabaseTypeName() == "BINARY" || col.DatabaseTypeName() == "VARBINARY" {
v = hex.EncodeToString(x)
} else {
v = string(x)
}
case time.Time:
v = x.UTC().Format(time.RFC3339Nano)
case int64, uint64, float64:
v = fmt.Sprint(x)
}
record[name] = v
}
raw, err := json.Marshal(record)
if err != nil {
return err
}
b.rows++
b.bytes += len(raw)
if b.rows > archiveMaxRows || b.bytes > archiveMaxBytes {
return fault.Invalid("archive too large; use a smaller batch")
}
d.Tables[table] = append(d.Tables[table], record)
}
return rows.Err()
}
var _ retention.ArchiveStore = (*RetentionPolicyStore)(nil)

View File

@@ -0,0 +1,158 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
driver "github.com/go-sql-driver/mysql"
)
const retentionPolicyOperation = "retention.policy.update"
const retentionPolicyColumns = `revision,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days,updated_at`
// RetentionPolicyStore 保存命名空间级策略,数据库配置优先于部署默认值。
// 不缓存配置;节点重启和跨节点读取保持数据库中同一版本。
type RetentionPolicyStore struct {
db *sql.DB
defaults retention.Policy
}
func NewRetentionPolicyStore(db *sql.DB, defaults ...retention.Policy) *RetentionPolicyStore {
p := retention.Defaults("")
if len(defaults) > 0 {
p = defaults[0]
}
return &RetentionPolicyStore{db: db, defaults: p}
}
var _ retention.Store = (*RetentionPolicyStore)(nil)
func (s *RetentionPolicyStore) defaultPolicy(namespace core.ID) retention.Policy {
p := s.defaults
p.NamespaceID = namespace
p.Source = "default"
return p
}
type retentionQuery interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func (s *RetentionPolicyStore) readPolicy(ctx context.Context, db retentionQuery, namespace core.ID, lock bool) (retention.Policy, error) {
p := s.defaultPolicy(namespace)
q := `SELECT ` + retentionPolicyColumns + ` FROM orbit_retention_policies WHERE namespace_id=?`
if lock {
q += " FOR UPDATE"
}
err := db.QueryRowContext(ctx, q, namespace).Scan(&p.Revision, &p.Enabled, &p.IdempotencyDays, &p.TaskHistoryDays, &p.WorkflowHistoryDays, &p.EventLogDays, &p.DeliveryHistoryDays, &p.InboxHistoryDays, &p.AuditLogDays, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return p, p.Validate()
}
if err != nil {
return retention.Policy{}, err
}
p.Source = "database"
return p, p.Validate()
}
// GetRetentionPolicy 供经过会话认证的控制台和数据库管理工具读取配置。
func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace core.ID) (retention.Policy, error) {
if namespace.Validate() != nil {
return retention.Policy{}, fault.Invalid("invalid namespace")
}
return s.readPolicy(ctx, s.db, namespace, false)
}
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
p, err := authenticate(ctx, tx, token)
if err != nil {
return "", err
}
all := false
for _, q := range p.Queues {
if q == "*" {
all = true
}
}
if !all || !p.Allows("queues:manage") {
return "", fault.Forbidden
}
var user core.ID
err = tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL
AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&user)
if errors.Is(err, sql.ErrNoRows) {
return "", fault.Unauthorized
}
return user, err
}
// UpdateRetentionPolicy 在同一事务中核验版本、保存策略、前后快照、账号身份和幂等回执。
func (s *RetentionPolicyStore) UpdateRetentionPolicy(ctx context.Context, token auth.Token, key string, in retention.UpdateInput) (retention.Policy, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return retention.Policy{}, fault.Invalid("idempotency key must be 1-128 bytes")
}
want, err := in.ToPolicy(token.NamespaceID)
if err != nil {
return retention.Policy{}, fault.Invalid(err.Error())
}
var out retention.Policy
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, token)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, retentionPolicyOperation, "", key, "retention_policy", token.NamespaceID, in)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
current, err := s.readPolicy(ctx, tx, token.NamespaceID, true)
if err != nil {
return err
}
if current.Revision != in.ExpectedRevision {
return fault.Conflict
}
if current.Revision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_retention_policies(namespace_id,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days) VALUES (?,?,?,?,?,?,?,?,?)`, token.NamespaceID, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays)
var me *driver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return fault.Conflict
}
} else {
_, err = tx.ExecContext(ctx, `UPDATE orbit_retention_policies SET revision=revision+1,enabled=?,idempotency_days=?,task_history_days=?,workflow_history_days=?,event_log_days=?,delivery_history_days=?,inbox_history_days=?,audit_log_days=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND revision=?`, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays, token.NamespaceID, in.ExpectedRevision)
}
if err != nil {
return err
}
out, err = s.readPolicy(ctx, tx, token.NamespaceID, false)
if err != nil {
return err
}
auditID, err := core.NewID()
if err != nil {
return err
}
old, _ := json.Marshal(current)
updated, _ := json.Marshal(out)
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,'retention_policy',?,?,?,?)", token.NamespaceID, auditID, identity, "console:"+string(user), retentionPolicyOperation, token.NamespaceID, in.Reason, string(old), string(updated))
if err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}

View File

@@ -0,0 +1,228 @@
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestIntegrationRetentionPolicyIsSharedAuditedAndIdempotent(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var boot BootstrapResult
app := testID(t)
if err := Bootstrap(ctx, db, BootstrapInput{
Application: string(app), Namespace: "default", Environment: "test", Queue: "admin",
ConsoleUsername: "admin-" + string(app), ConsolePassword: "a-strong-password-123",
}, func(v BootstrapResult) error {
boot = v
return nil
}); err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, "admin-"+string(app), "a-strong-password-123", "retention-test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
token, err := auth.Parse(login.Token)
if err != nil {
t.Fatal(err)
}
defaults := retention.Defaults(boot.NamespaceID)
store := NewRetentionPolicyStore(db, defaults)
got, err := store.GetRetentionPolicy(ctx, boot.NamespaceID)
if err != nil || got.Source != "default" || got.Revision != 0 || got.TaskHistoryDays != 365 {
t.Fatalf("default policy: %+v %v", got, err)
}
in := retention.UpdateInput{
ExpectedRevision: 0, Enabled: true, IdempotencyDays: 120,
TaskHistoryDays: 730, WorkflowHistoryDays: 730, EventLogDays: 365,
DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555,
Reason: "production retention policy review",
}
updated, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in)
if err != nil {
t.Fatal(err)
}
if updated.Source != "database" || updated.Revision != 1 || !updated.Enabled || updated.TaskHistoryDays != 730 {
t.Fatalf("updated policy: %+v", updated)
}
// A second store represents another Orbit node; it must read the same MySQL policy.
other := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID))
shared, err := other.GetRetentionPolicy(ctx, boot.NamespaceID)
if err != nil || shared.Revision != 1 || shared.TaskHistoryDays != 730 || !shared.Enabled {
t.Fatalf("cross-node policy read: %+v %v", shared, err)
}
replayed, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-1", in)
if err != nil || replayed.Revision != updated.Revision || replayed.TaskHistoryDays != updated.TaskHistoryDays {
t.Fatalf("idempotent replay: %+v %v", replayed, err)
}
conflict := in
conflict.ExpectedRevision = 0
conflict.TaskHistoryDays = 900
if _, err := store.UpdateRetentionPolicy(ctx, token, "retention-change-2", conflict); faultCode(err) != "STATE_CONFLICT" {
t.Fatalf("stale revision accepted: %v", err)
}
var audits int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update' AND resource_kind='retention_policy'`, boot.NamespaceID).Scan(&audits); err != nil {
t.Fatal(err)
}
if audits != 1 {
t.Fatalf("audit rows=%d want 1 after idempotent replay", audits)
}
var actor, reason string
if err := db.QueryRowContext(ctx, "SELECT actor_id,reason FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'", boot.NamespaceID).Scan(&actor, &reason); err != nil {
t.Fatal(err)
}
if actor == "" || reason != in.Reason || actor == "mysql:database" {
t.Fatalf("unexpected retention audit identity: actor=%q reason=%q", actor, reason)
}
t.Logf("retention policy revision %d is shared across stores, audited once, and safe on replay", updated.Revision)
}
func faultCode(err error) string {
if err == nil {
return ""
}
return fault.Code(err)
}
func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 12, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var boot BootstrapResult
app := testID(t)
if err := Bootstrap(ctx, db, BootstrapInput{
Application: string(app), Namespace: "growth", Environment: "test", Queue: "growth",
ConsoleUsername: "growth-admin-" + string(app), ConsolePassword: "a-strong-password-123",
}, func(v BootstrapResult) error { boot = v; return nil }); err != nil {
t.Fatal(err)
}
apiToken, err := auth.Parse(boot.Token)
if err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, "growth-admin-"+string(app), "a-strong-password-123", "retention-growth-test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
consoleToken, err := auth.Parse(login.Token)
if err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
t.Fatal(err)
}
before := retentionTableBytes(t, ctx, db)
serviceStore := service.New(NewTaskStore(db))
// 256 rows × 32 KiB is deliberately larger than one InnoDB allocation page,
// so the information_schema sample must observe physical table growth.
payload := json.RawMessage(`{"blob":"` + strings.Repeat("x", 32768) + `"}`)
ids := make([]string, 0, 256)
for i := 0; i < 256; i++ {
out, err := serviceStore.Submit(ctx, apiToken, "growth-"+string(app)+"-"+string(rune('a'+i%26))+"-"+string(testID(t)), service.SubmitInput{
QueueID: boot.QueueID, Handler: "retention.growth", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload,
TaskTimeoutMS: 60000, RecoverySafety: pulse.RecoveryReconcileOnly, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000},
})
if err != nil {
t.Fatal(err)
}
ids = append(ids, string(out.Task.ID))
}
for _, id := range ids {
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); err != nil {
t.Fatal(err)
}
}
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
t.Fatal(err)
}
after := retentionTableBytes(t, ctx, db)
if after <= before {
t.Fatalf("retention table bytes did not grow during bounded growth sample: before=%d after=%d", before, after)
}
store := NewRetentionPolicyStore(db, retention.Defaults(boot.NamespaceID))
_, err = store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-policy", retention.UpdateInput{
ExpectedRevision: 0, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 1,
WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30,
AuditLogDays: 365, Reason: "bounded disk growth rehearsal",
})
if err != nil {
t.Fatal(err)
}
preview, err := store.PreviewRetention(ctx, consoleToken)
if err != nil {
t.Fatal(err)
}
var taskSample retention.CandidatePage
for _, item := range preview.Items {
if item.Kind == "tasks" {
taskSample = item
}
}
if taskSample.Count == 0 {
t.Fatalf("backdated terminal tasks were not visible as retention candidates: %+v", preview.Items)
}
snapshot, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 1, ExpectedRevision: 1})
if err != nil {
t.Fatal(err)
}
if snapshot.FormatVersion != 1 || snapshot.Content == "" {
t.Fatalf("invalid retention snapshot envelope: %+v", snapshot)
}
sum := sha256.Sum256([]byte(snapshot.Content))
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
}
t.Logf("bounded retention growth sample inserted %d terminal tasks; bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
}
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) int64 {
t.Helper()
var bytes int64
if err := db.QueryRowContext(ctx, `SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('pulse_tasks','orbit_events','orbit_audit_events')`).Scan(&bytes); err != nil {
t.Fatal(err)
}
return bytes
}

View File

@@ -1,8 +1,8 @@
(() => {
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略' };
function showToast(message) {
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
@@ -28,6 +28,7 @@
text('page-title', pageNames[next]);
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
if (next === 'retention' && state.authenticated) loadRetention();
}
function redirectToLogin() {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
@@ -62,6 +63,80 @@
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
}
}
function renderRetention(policy) {
if (!policy) return;
state.retention = policy;
$('retention-enabled').checked = Boolean(policy.enabled);
$('retention-idempotency').value = policy.idempotency_days ?? '';
$('retention-task').value = policy.task_history_days ?? '';
$('retention-workflow').value = policy.workflow_history_days ?? '';
$('retention-event').value = policy.event_log_days ?? '';
$('retention-delivery').value = policy.delivery_history_days ?? '';
$('retention-inbox').value = policy.inbox_history_days ?? '';
$('retention-audit').value = policy.audit_log_days ?? '';
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
text('retention-revision', policy.revision ? String(policy.revision) : '默认');
text('retention-updated', policy.updated_at ? formatTime(policy.updated_at) : '尚未保存');
}
async function loadRetention() {
try {
const response = await request('/console/retention');
renderRetention(extractData(response));
} catch (error) {
$('retention-error').textContent = error.message || '保留策略读取失败';
}
}
function retentionNumber(id) {
const value = Number($(id).value);
return Number.isSafeInteger(value) && value >= 1 && value <= 3650 ? value : 0;
}
async function submitRetention(event) {
event.preventDefault();
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
if (Object.values(values).some(value => !value)) { $('retention-error').textContent = '所有保留天数必须是 1 到 3650 的整数。'; return; }
const reason = $('retention-reason').value.trim();
if (!reason) { $('retention-error').textContent = '请填写变更原因。'; return; }
const button = $('retention-submit'); button.disabled = true; $('retention-error').textContent = '';
const current = state.retention || {};
try {
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('retention-policy') }, body: JSON.stringify({ expected_revision: String(current.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason }) });
renderRetention(extractData(response)); $('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
} catch (error) { $('retention-error').textContent = error.message || '保留策略保存失败'; }
finally { button.disabled = false; }
}
function renderRetentionPreview(preview) {
const el = $('retention-preview');
const items = Array.isArray(preview?.items) ? preview.items : [];
if (!items.length) { el.innerHTML = '<span>当前策略没有可预览的候选数据。</span>'; return; }
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
el.innerHTML = `<div class="retention-preview-head"><b>候选预览</b><small>仅展示样本,不会删除数据</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))} 条</b><small>截止 ${escapeHTML(formatTime(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
}
async function previewRetention() {
const button = $('retention-preview-button'); button.disabled = true; $('retention-error').textContent = '';
try {
const response = await request('/console/retention/preview');
renderRetentionPreview(extractData(response));
} catch (error) { $('retention-error').textContent = error.message || '候选预览失败'; }
finally { button.disabled = false; }
}
async function exportRetention() {
const button = $('retention-export-button'); button.disabled = true; $('retention-error').textContent = '';
try {
const kind = $('retention-kind').value;
const current = state.retention || {};
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, expected_revision: String(current.revision || 0) }) });
const snapshot = extractData(response);
const content = String(snapshot.content || '');
if (!content) throw new Error('服务端返回了空的归档快照');
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = url; anchor.download = `orbit-retention-${kind}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
showToast(`快照已下载 · SHA256 ${String(snapshot.sha256 || '').slice(0, 12)}…`);
} catch (error) { $('retention-error').textContent = error.message || '归档快照导出失败'; }
finally { button.disabled = false; }
}
function renderConnectionSummary() {
text('summary-api', state.base || location.origin);
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
@@ -473,13 +548,13 @@
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
}
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh);
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
window.addEventListener('hashchange', () => setView(currentView(), false));
setView(currentView(), false);
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => refresh()); }).catch(() => redirectToLogin());
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => Promise.all([loadRetention(), refresh()])); }).catch(() => redirectToLogin());
state.timer = setInterval(() => { if (state.authenticated) refresh(); }, 10000);
})();

View File

@@ -23,6 +23,7 @@
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span><em>03</em></button>
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
</nav>
<div class="sidebar-bottom">
<div class="rail-card">
@@ -129,6 +130,37 @@
</section>
</div>
<div class="page-view" id="view-retention" data-page="retention">
<section class="content-grid single-column">
<article class="panel wide-panel retention-panel">
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;归档执行器还会核对终态、未确认结果、备份和审计要求。</p>
<form id="retention-form" class="retention-form">
<div class="retention-switch"><div><b>允许归档执行器使用策略</b><small>默认关闭。打开后仍需独立归档任务执行,未确认任务、交付和 Inbox 不会按窗口清理。</small></div><label class="switch"><input id="retention-enabled" type="checkbox"><span></span></label></div>
<div class="retention-grid">
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="1" max="3650" required></label>
<label class="retention-field"><span>任务历史</span><small>天</small><input id="retention-task" type="number" min="1" max="3650" required></label>
<label class="retention-field"><span>流程历史</span><small>天</small><input id="retention-workflow" type="number" min="1" max="3650" required></label>
<label class="retention-field"><span>事件日志</span><small>天</small><input id="retention-event" type="number" min="1" max="3650" required></label>
<label class="retention-field"><span>交付历史</span><small>天</small><input id="retention-delivery" type="number" min="1" max="3650" required></label>
<label class="retention-field"><span>Inbox 历史</span><small>天</small><input id="retention-inbox" type="number" min="1" max="3650" required></label>
<label class="retention-field retention-field-wide"><span>管理审计</span><small>天</small><input id="retention-audit" type="number" min="1" max="3650" required></label>
</div>
<div class="retention-meta"><span>当前版本 <b id="retention-revision">默认</b></span><span>最后修改 <b id="retention-updated">—</b></span></div>
<label class="field-label" for="retention-reason">变更原因</label>
<textarea id="retention-reason" rows="3" maxlength="1024" placeholder="说明为什么调整历史保留窗口"></textarea>
<div class="action-form-buttons"><button class="primary-button compact" id="retention-submit" type="submit">保存保留策略 <span>→</span></button></div>
<div class="retention-tools">
<label class="retention-select"><span>历史类型</span><select id="retention-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select></label>
<div class="retention-tool-buttons"><button class="secondary-button compact" id="retention-preview-button" type="button">预览候选</button><button class="secondary-button compact" id="retention-export-button" type="button">导出只读快照 <span>↓</span></button></div>
</div>
<div class="retention-preview" id="retention-preview" aria-live="polite"><span>保存策略后可预览当前窗口下的候选数据;预览不会删除任何记录。</span></div>
<div class="action-error" id="retention-error" role="alert"></div>
</form>
</article>
</section>
</div>
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
</main>
</div>

View File

@@ -6,3 +6,5 @@
.detail-row-action{border:1px solid rgba(255,179,107,.28);border-radius:8px;padding:6px 9px;color:var(--orange);background:rgba(255,179,107,.07);font:10px inherit;cursor:pointer;white-space:nowrap}.detail-row-action:hover{border-color:rgba(255,179,107,.65);color:#ffe0bb;background:rgba(255,179,107,.13)}
.recovery-block{border-color:rgba(255,179,107,.2);background:linear-gradient(135deg,rgba(255,179,107,.045),rgba(98,230,212,.025))}.recovery-intro{margin:0 0 12px;color:#8399b0;font-size:10px;line-height:1.6}.recovery-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.recovery-action-button{display:flex;flex-direction:column;align-items:flex-start;gap:4px;border:1px solid rgba(255,179,107,.2);border-radius:10px;padding:10px 11px;color:#e6f0ff;background:rgba(4,12,23,.4);text-align:left;cursor:pointer;transition:border-color .2s,background .2s,transform .2s}.recovery-action-button:hover,.recovery-action-button:focus{outline:0;border-color:rgba(98,230,212,.6);background:rgba(98,230,212,.08);transform:translateY(-1px)}.recovery-action-button b{font-size:10px;font-weight:650}.recovery-action-button small{color:#7890a8;font-size:9px;line-height:1.4}.recovery-form{border-color:rgba(255,179,107,.22)}.recovery-snapshot{margin:0 0 14px;padding:8px 10px;border-radius:8px;background:rgba(98,230,212,.06);color:#8db4b6;font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.recovery-inline-fields{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px}.recovery-inline-fields select{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px}.recovery-inline-fields select:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}@media(max-width:780px){.recovery-action-grid{grid-template-columns:1fr}.recovery-inline-fields{grid-template-columns:1fr}}
.overview-grid{grid-template-columns:minmax(0,1.45fr) minmax(300px,.7fr)}.stats-panel,.connection-summary-panel{min-height:326px}.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.stat-tile{padding:14px 13px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.stat-tile span{display:block;color:#7188a3;font-size:9px}.stat-tile strong{display:block;margin-top:10px;color:#e4f1fc;font-size:23px;letter-spacing:-.04em}.stat-tile small{display:block;margin-top:5px;color:#607791;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.distribution-block{margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.distribution-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;color:#afc2d6;font-size:11px}.distribution-heading small{color:#687f99;font-size:9px}.distribution-list{display:grid;gap:10px}.distribution-row{display:grid;grid-template-columns:42px minmax(0,1fr) 28px;gap:10px;align-items:center;color:#7187a1;font-size:9px}.distribution-row b{text-align:right;color:#c1d2e2;font-size:10px;font-weight:500}.status-track{height:5px;border-radius:5px;background:#1c3048;overflow:hidden}.status-track i{display:block;width:0;height:100%;border-radius:5px;transition:width .45s ease}.distribution-row:nth-child(1) .status-track i{background:var(--cyan)}.distribution-row:nth-child(2) .status-track i{background:var(--blue)}.distribution-row:nth-child(3) .status-track i{background:var(--purple)}.distribution-row:nth-child(4) .status-track i{background:var(--orange)}.connection-summary-copy{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid rgba(98,230,212,.12);border-radius:14px;background:rgba(4,15,26,.3)}.connection-summary-icon{display:grid;place-items:center;width:33px;height:33px;border-radius:11px;background:rgba(98,230,212,.1);color:var(--cyan);font-size:17px}.connection-summary-copy b{display:block;color:#d5e5f4;font-size:12px;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-summary-copy small{display:block;margin-top:5px;color:#68809a;font-size:9px}.connection-summary-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;margin-top:12px;border:1px solid rgba(156,183,218,.1);border-radius:13px;overflow:hidden}.connection-summary-grid>div{padding:12px;background:rgba(4,13,24,.25)}.connection-summary-grid span{display:block;color:#647b96;font-size:9px;margin-bottom:5px}.connection-summary-grid b{display:block;color:#b9cde0;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.connection-summary-note{min-height:31px;margin:15px 0 14px;color:#6f86a0;font-size:10px;line-height:1.6}.connection-summary-panel>.quiet-button{width:100%;justify-content:center}.connection-settings-intro{margin:0 0 18px}.connection-drawer{width:min(510px,100%)}@media(max-width:1200px){.stat-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:780px){.overview-grid{grid-template-columns:1fr}.stat-grid{grid-template-columns:repeat(2,1fr)}.connection-drawer{width:100%}}
.retention-panel{max-width:980px}.retention-panel .panel-description{max-width:760px}.retention-form{max-width:820px}.retention-switch{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 17px;border:1px solid rgba(98,230,212,.16);border-radius:14px;background:rgba(4,15,26,.34)}.retention-switch b{display:block;color:#dceafb;font-size:11px;font-weight:550}.retention-switch small{display:block;margin-top:6px;color:#7086a0;font-size:9px;line-height:1.5}.switch{position:relative;display:block;width:43px;height:24px;flex:0 0 43px}.switch input{position:absolute;opacity:0;width:0;height:0}.switch span{position:absolute;inset:0;border-radius:999px;background:#243852;cursor:pointer;transition:.2s}.switch span:before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#8ca3ba;transition:.2s}.switch input:checked+span{background:rgba(98,230,212,.55);box-shadow:0 0 18px rgba(98,230,212,.18)}.switch input:checked+span:before{transform:translateX(19px);background:#ecfffb}.retention-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin-top:15px}.retention-field{display:block;padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.retention-field span{color:#afc2d6;font-size:10px}.retention-field small{float:right;color:#637b96;font-size:9px}.retention-field input{display:block;width:100%;margin-top:12px;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:13px;padding:10px}.retention-field input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-field-wide{grid-column:span 3}.retention-meta{display:flex;gap:25px;margin-top:16px;color:#637b96;font-size:9px}.retention-meta b{margin-left:5px;color:#afc2d6;font-weight:500}.retention-form>.field-label{margin-top:22px}.retention-form>textarea{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.retention-form>textarea:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-form .action-form-buttons{justify-content:flex-start}.retention-form .action-error{min-height:18px}@media(max-width:780px){.retention-grid{grid-template-columns:1fr 1fr}.retention-field-wide{grid-column:span 2}.retention-meta{flex-direction:column;gap:7px}}
.retention-tools{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.retention-select{display:flex;align-items:center;gap:10px;color:#9eb4cb;font-size:10px}.retention-select select{min-width:150px;border:1px solid rgba(156,183,218,.15);border-radius:9px;background:#0a1a2e;color:#dceafb;font:inherit;padding:9px 11px;outline:0}.retention-select select:focus{border-color:rgba(98,230,212,.58)}.retention-tool-buttons{display:flex;gap:9px}.secondary-button.compact{min-height:37px;padding:0 14px}.retention-preview{margin-top:13px;padding:13px 14px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,13,24,.28);color:#7189a3;font-size:10px;line-height:1.5}.retention-preview-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:8px;color:#c9d8e8}.retention-preview-head small{color:#7189a3}.retention-preview-row{display:grid;grid-template-columns:1fr auto 1.6fr;gap:12px;padding:7px 0;border-top:1px solid rgba(156,183,218,.08)}.retention-preview-row b{color:#9beade;font-weight:600}.retention-preview-row small{color:#7189a3;text-align:right}@media(max-width:780px){.retention-tools{align-items:stretch;flex-direction:column}.retention-tool-buttons{justify-content:flex-start}.retention-preview-row{grid-template-columns:1fr auto}.retention-preview-row small{grid-column:span 2;text-align:left}}

View File

@@ -0,0 +1,106 @@
package httpserver
import (
"context"
"net/http"
"strings"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// RegisterConsoleRetention 注册保留策略配置、候选预览和只读归档导出。
// 所有接口只接受控制台 HttpOnly 会话,不接受业务 Bearer token。
func RegisterConsoleRetention(server *http.Server, consoleAuth RetentionConsoleAuth, store retention.Store, archive retention.ArchiveStore) {
if server == nil || consoleAuth == nil || store == nil {
return
}
mux, ok := server.Handler.(*http.ServeMux)
if !ok {
return
}
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
t, ok := consoleCookieToken(r)
if !ok || consoleAuth.ValidateSession(r.Context(), t) != nil {
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
}
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
p, err := store.GetRetentionPolicy(r.Context(), t.NamespaceID)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": p})
})
mux.HandleFunc("PUT /console/retention", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
var in retention.UpdateInput
if err := decodeBody(r, &in); err != nil {
writeError(w, "", err)
return
}
keys := r.Header.Values("Idempotency-Key")
if len(keys) != 1 {
writeError(w, "", fault.Invalid("exactly one Idempotency-Key is required"))
return
}
p, err := store.UpdateRetentionPolicy(r.Context(), t, keys[0], in)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": p})
})
if archive == nil {
return
}
mux.HandleFunc("GET /console/retention/preview", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
p, err := archive.PreviewRetention(r.Context(), t)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": p})
})
mux.HandleFunc("POST /console/retention/export", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
var in retention.ArchiveInput
if err := decodeBody(r, &in); err != nil {
writeError(w, "", err)
return
}
if strings.TrimSpace(in.Kind) == "" {
writeError(w, "", fault.Invalid("archive kind is required"))
return
}
snapshot, err := archive.ExportRetention(r.Context(), t, in)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": snapshot})
})
}
// RetentionConsoleAuth keeps route registration independent from the concrete DB auth type.
type RetentionConsoleAuth interface {
ValidateSession(context.Context, auth.Token) error
}

View File

@@ -0,0 +1,78 @@
package httpserver
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type fakeRetentionStore struct {
policy retention.Policy
gotKey string
updates int
}
func (f *fakeRetentionStore) GetRetentionPolicy(_ context.Context, namespace core.ID) (retention.Policy, error) {
p := f.policy
p.NamespaceID = namespace
return p, nil
}
func (f *fakeRetentionStore) UpdateRetentionPolicy(_ context.Context, token auth.Token, key string, in retention.UpdateInput) (retention.Policy, error) {
f.gotKey = key
f.updates++
p, err := in.ToPolicy(token.NamespaceID)
if err != nil {
return retention.Policy{}, err
}
p.Revision = 1
p.Source = "database"
f.policy = p
return p, nil
}
func TestConsoleRetentionRequiresSessionAndIdempotencyKey(t *testing.T) {
ns, _ := core.NewID()
cred, _ := core.NewID()
raw, _, err := auth.Issue(ns, cred)
if err != nil {
t.Fatal(err)
}
authStore := &fakeConsoleAuth{valid: true}
retentionStore := &fakeRetentionStore{policy: retention.Defaults(ns)}
server := NewWithConsoleAuth("127.0.0.1:0", nil, authStore)
RegisterConsoleRetention(server, authStore, retentionStore, nil)
unauthenticated := httptest.NewRequest(http.MethodGet, "/console/retention", nil)
unauthenticatedRes := httptest.NewRecorder()
server.Handler.ServeHTTP(unauthenticatedRes, unauthenticated)
if unauthenticatedRes.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", unauthenticatedRes.Code)
}
body := `{"expected_revision":"0","enabled":true,"idempotency_days":90,"task_history_days":365,"workflow_history_days":365,"event_log_days":365,"delivery_history_days":90,"inbox_history_days":90,"audit_log_days":2555,"reason":"test"}`
missingKey := httptest.NewRequest(http.MethodPut, "/console/retention", strings.NewReader(body))
missingKey.Header.Set("Content-Type", "application/json")
missingKey.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
missingKeyRes := httptest.NewRecorder()
server.Handler.ServeHTTP(missingKeyRes, missingKey)
if missingKeyRes.Code != http.StatusBadRequest {
t.Fatalf("missing key status=%d", missingKeyRes.Code)
}
req := httptest.NewRequest(http.MethodPut, "/console/retention", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "retention-http-1")
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
res := httptest.NewRecorder()
server.Handler.ServeHTTP(res, req)
if res.Code != http.StatusOK || retentionStore.updates != 1 || retentionStore.gotKey != "retention-http-1" {
t.Fatalf("update status=%d updates=%d key=%q body=%s", res.Code, retentionStore.updates, retentionStore.gotKey, res.Body.String())
}
}