From 6035ac4d9a4da2f52f5dc05f4e7a9e3791f39b95 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 21 Aug 2026 16:33:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20MVP-1=20=E5=8F=AF?= =?UTF-8?q?=E9=80=86=E8=BF=81=E7=A7=BB=E4=B8=8E=20admin=20=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/000002_admin_baseline.down.sql | 45 +++ migrations/000002_admin_baseline.up.sql | 401 +++++++++++++++++++++ migrations/000003_mvp1_routing.down.sql | 63 ++++ migrations/000003_mvp1_routing.up.sql | 246 +++++++++++++ migrations/000004_mvp1_admin_seed.down.sql | 30 ++ migrations/000004_mvp1_admin_seed.up.sql | 89 +++++ migrations/migrations_test.go | 81 ++++- migrations/mysql_integration_test.go | 128 +++++++ 8 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 migrations/000002_admin_baseline.down.sql create mode 100644 migrations/000002_admin_baseline.up.sql create mode 100644 migrations/000003_mvp1_routing.down.sql create mode 100644 migrations/000003_mvp1_routing.up.sql create mode 100644 migrations/000004_mvp1_admin_seed.down.sql create mode 100644 migrations/000004_mvp1_admin_seed.up.sql create mode 100644 migrations/mysql_integration_test.go diff --git a/migrations/000002_admin_baseline.down.sql b/migrations/000002_admin_baseline.down.sql new file mode 100644 index 0000000..cd20781 --- /dev/null +++ b/migrations/000002_admin_baseline.down.sql @@ -0,0 +1,45 @@ +-- Keep this guard durable across a failed down attempt. It rejects a rollback before +-- any baseline table is dropped when an administrator has created business data. +CREATE TABLE IF NOT EXISTS chorus_admin_baseline_down_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_admin_baseline_down_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_admin_baseline_down_guard; +INSERT INTO chorus_admin_baseline_down_guard (id) +SELECT 1 +WHERE EXISTS (SELECT 1 FROM sys_user) + OR EXISTS (SELECT 1 FROM sys_role) + OR EXISTS (SELECT 1 FROM sys_dept) + OR EXISTS (SELECT 1 FROM sys_post) + OR EXISTS (SELECT 1 FROM sys_menu) + OR EXISTS (SELECT 1 FROM sys_api) + OR EXISTS (SELECT 1 FROM sys_config) + OR EXISTS (SELECT 1 FROM sys_dict_type) + OR EXISTS (SELECT 1 FROM sys_dict_data) + OR EXISTS (SELECT 1 FROM sys_job) + OR EXISTS (SELECT 1 FROM sys_login_log) + OR EXISTS (SELECT 1 FROM sys_opera_log) + OR EXISTS (SELECT 1 FROM sys_tables) + OR EXISTS (SELECT 1 FROM sys_columns) + OR EXISTS (SELECT 1 FROM sys_casbin_rule); + +DROP TABLE sys_columns; +DROP TABLE sys_tables; +DROP TABLE sys_opera_log; +DROP TABLE sys_login_log; +DROP TABLE sys_job; +DROP TABLE sys_dict_data; +DROP TABLE sys_dict_type; +DROP TABLE sys_config; +DROP TABLE sys_menu_api_rule; +DROP TABLE sys_role_dept; +DROP TABLE sys_role_menu; +DROP TABLE sys_casbin_rule; +DROP TABLE sys_api; +DROP TABLE sys_menu; +DROP TABLE sys_user; +DROP TABLE sys_post; +DROP TABLE sys_dept; +DROP TABLE sys_role; +DROP TABLE chorus_admin_baseline_down_guard; diff --git a/migrations/000002_admin_baseline.up.sql b/migrations/000002_admin_baseline.up.sql new file mode 100644 index 0000000..52bbcd9 --- /dev/null +++ b/migrations/000002_admin_baseline.up.sql @@ -0,0 +1,401 @@ +-- The audited minimum schema required by go-admin f06540883b41d03782bb6b2c4150f298f328c6b6. +-- It intentionally contains no administrator account or default system data; 000004 owns +-- Chorus-specific roles, menus, API entries, and permissions. + +CREATE TABLE sys_role ( + role_id BIGINT NOT NULL AUTO_INCREMENT, + role_name VARCHAR(128) NOT NULL, + status VARCHAR(4) NOT NULL, + role_key VARCHAR(128) NOT NULL, + role_sort INT NOT NULL DEFAULT 0, + flag VARCHAR(128) NOT NULL DEFAULT '', + remark VARCHAR(255) NOT NULL DEFAULT '', + admin BOOLEAN NOT NULL DEFAULT FALSE, + data_scope VARCHAR(128) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (role_id), + UNIQUE KEY uq_sys_role_role_key (role_key), + KEY idx_sys_role_deleted_at (deleted_at), + KEY idx_sys_role_create_by (create_by), + KEY idx_sys_role_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_dept ( + dept_id BIGINT NOT NULL AUTO_INCREMENT, + parent_id BIGINT NOT NULL DEFAULT 0, + dept_path VARCHAR(255) NOT NULL DEFAULT '', + dept_name VARCHAR(128) NOT NULL, + sort INT NOT NULL DEFAULT 0, + leader VARCHAR(128) NOT NULL DEFAULT '', + phone VARCHAR(11) NOT NULL DEFAULT '', + email VARCHAR(64) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (dept_id), + KEY idx_sys_dept_deleted_at (deleted_at), + KEY idx_sys_dept_create_by (create_by), + KEY idx_sys_dept_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_post ( + post_id BIGINT NOT NULL AUTO_INCREMENT, + post_name VARCHAR(128) NOT NULL, + post_code VARCHAR(128) NOT NULL, + sort INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + remark VARCHAR(255) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (post_id), + KEY idx_sys_post_deleted_at (deleted_at), + KEY idx_sys_post_create_by (create_by), + KEY idx_sys_post_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_user ( + user_id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(64) NOT NULL, + password VARCHAR(128) NOT NULL, + nick_name VARCHAR(128) NOT NULL DEFAULT '', + phone VARCHAR(11) NOT NULL DEFAULT '', + role_id BIGINT NOT NULL, + salt VARCHAR(255) NOT NULL DEFAULT '', + avatar VARCHAR(255) NOT NULL DEFAULT '', + sex VARCHAR(255) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + dept_id BIGINT NOT NULL DEFAULT 0, + post_id BIGINT NOT NULL DEFAULT 0, + remark VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(4) NOT NULL, + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (user_id), + UNIQUE KEY uq_sys_user_username (username), + KEY idx_sys_user_deleted_at (deleted_at), + KEY idx_sys_user_create_by (create_by), + KEY idx_sys_user_update_by (update_by), + CONSTRAINT fk_sys_user_role FOREIGN KEY (role_id) REFERENCES sys_role (role_id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_menu ( + menu_id BIGINT NOT NULL AUTO_INCREMENT, + menu_name VARCHAR(128) NOT NULL, + title VARCHAR(128) NOT NULL, + icon VARCHAR(128) NOT NULL DEFAULT '', + path VARCHAR(128) NOT NULL, + paths VARCHAR(128) NOT NULL DEFAULT '', + menu_type VARCHAR(1) NOT NULL, + action VARCHAR(16) NOT NULL DEFAULT '', + permission VARCHAR(255) NOT NULL DEFAULT '', + parent_id BIGINT NOT NULL DEFAULT 0, + no_cache BOOLEAN NOT NULL DEFAULT FALSE, + breadcrumb VARCHAR(255) NOT NULL DEFAULT '', + component VARCHAR(255) NOT NULL DEFAULT '', + sort INT NOT NULL DEFAULT 0, + visible VARCHAR(1) NOT NULL DEFAULT '0', + is_frame VARCHAR(1) NOT NULL DEFAULT '0', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (menu_id), + UNIQUE KEY uq_sys_menu_path (path), + KEY idx_sys_menu_deleted_at (deleted_at), + KEY idx_sys_menu_create_by (create_by), + KEY idx_sys_menu_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_api ( + id BIGINT NOT NULL AUTO_INCREMENT, + handle VARCHAR(128) NOT NULL, + title VARCHAR(128) NOT NULL, + path VARCHAR(128) NOT NULL, + type VARCHAR(16) NOT NULL, + action VARCHAR(16) NOT NULL, + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_sys_api_path_action (path, action), + UNIQUE KEY uq_sys_api_handle (handle), + KEY idx_sys_api_deleted_at (deleted_at), + KEY idx_sys_api_create_by (create_by), + KEY idx_sys_api_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_role_menu ( + role_id BIGINT NOT NULL, + menu_id BIGINT NOT NULL, + PRIMARY KEY (role_id, menu_id), + CONSTRAINT fk_sys_role_menu_role FOREIGN KEY (role_id) REFERENCES sys_role (role_id) ON DELETE CASCADE, + CONSTRAINT fk_sys_role_menu_menu FOREIGN KEY (menu_id) REFERENCES sys_menu (menu_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_role_dept ( + role_id BIGINT NOT NULL, + dept_id BIGINT NOT NULL, + PRIMARY KEY (role_id, dept_id), + CONSTRAINT fk_sys_role_dept_role FOREIGN KEY (role_id) REFERENCES sys_role (role_id) ON DELETE CASCADE, + CONSTRAINT fk_sys_role_dept_dept FOREIGN KEY (dept_id) REFERENCES sys_dept (dept_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_menu_api_rule ( + menu_id BIGINT NOT NULL, + sys_api_id BIGINT NOT NULL, + PRIMARY KEY (menu_id, sys_api_id), + CONSTRAINT fk_sys_menu_api_rule_menu FOREIGN KEY (menu_id) REFERENCES sys_menu (menu_id) ON DELETE CASCADE, + CONSTRAINT fk_sys_menu_api_rule_api FOREIGN KEY (sys_api_id) REFERENCES sys_api (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_casbin_rule ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ptype VARCHAR(512) NOT NULL, + v0 VARCHAR(512) NOT NULL DEFAULT '', + v1 VARCHAR(512) NOT NULL DEFAULT '', + v2 VARCHAR(512) NOT NULL DEFAULT '', + v3 VARCHAR(512) NOT NULL DEFAULT '', + v4 VARCHAR(512) NOT NULL DEFAULT '', + v5 VARCHAR(512) NOT NULL DEFAULT '', + rule_hash BINARY(32) GENERATED ALWAYS AS ( + UNHEX(SHA2(CONCAT_WS(CHAR(31), ptype, v0, v1, v2, v3, v4, v5), 256)) + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uq_sys_casbin_rule (rule_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + config_name VARCHAR(128) NOT NULL, + config_key VARCHAR(128) NOT NULL, + config_value VARCHAR(255) NOT NULL DEFAULT '', + config_type VARCHAR(64) NOT NULL DEFAULT '', + is_frontend INT NOT NULL DEFAULT 0, + remark VARCHAR(128) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_sys_config_deleted_at (deleted_at), + KEY idx_sys_config_create_by (create_by), + KEY idx_sys_config_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_dict_type ( + dict_id BIGINT NOT NULL AUTO_INCREMENT, + dict_name VARCHAR(128) NOT NULL, + dict_type VARCHAR(128) NOT NULL, + status INT NOT NULL DEFAULT 1, + remark VARCHAR(255) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (dict_id), + KEY idx_sys_dict_type_deleted_at (deleted_at), + KEY idx_sys_dict_type_create_by (create_by), + KEY idx_sys_dict_type_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_dict_data ( + dict_code BIGINT NOT NULL AUTO_INCREMENT, + dict_sort INT NOT NULL DEFAULT 0, + dict_label VARCHAR(128) NOT NULL, + dict_value VARCHAR(255) NOT NULL, + dict_type VARCHAR(64) NOT NULL, + css_class VARCHAR(128) NOT NULL DEFAULT '', + list_class VARCHAR(128) NOT NULL DEFAULT '', + is_default VARCHAR(8) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + `default` VARCHAR(8) NOT NULL DEFAULT '', + remark VARCHAR(255) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (dict_code), + KEY idx_sys_dict_data_deleted_at (deleted_at), + KEY idx_sys_dict_data_create_by (create_by), + KEY idx_sys_dict_data_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_job ( + job_id BIGINT NOT NULL AUTO_INCREMENT, + job_name VARCHAR(255) NOT NULL, + job_group VARCHAR(255) NOT NULL, + job_type INT NOT NULL DEFAULT 0, + cron_expression VARCHAR(255) NOT NULL DEFAULT '', + invoke_target VARCHAR(255) NOT NULL DEFAULT '', + args VARCHAR(255) NOT NULL DEFAULT '', + misfire_policy INT NOT NULL DEFAULT 0, + concurrent INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + entry_id INT NOT NULL DEFAULT 0, + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (job_id), + KEY idx_sys_job_deleted_at (deleted_at), + KEY idx_sys_job_create_by (create_by), + KEY idx_sys_job_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_login_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(4) NOT NULL DEFAULT '', + ipaddr VARCHAR(255) NOT NULL DEFAULT '', + login_location VARCHAR(255) NOT NULL DEFAULT '', + browser VARCHAR(255) NOT NULL DEFAULT '', + os VARCHAR(255) NOT NULL DEFAULT '', + platform VARCHAR(255) NOT NULL DEFAULT '', + login_time TIMESTAMP NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + msg VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_sys_login_log_create_by (create_by), + KEY idx_sys_login_log_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_opera_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NOT NULL DEFAULT '', + business_type VARCHAR(128) NOT NULL DEFAULT '', + business_types VARCHAR(128) NOT NULL DEFAULT '', + method VARCHAR(128) NOT NULL DEFAULT '', + request_method VARCHAR(128) NOT NULL DEFAULT '', + operator_type VARCHAR(128) NOT NULL DEFAULT '', + oper_name VARCHAR(128) NOT NULL DEFAULT '', + dept_name VARCHAR(128) NOT NULL DEFAULT '', + oper_url VARCHAR(255) NOT NULL DEFAULT '', + oper_ip VARCHAR(128) NOT NULL DEFAULT '', + oper_location VARCHAR(128) NOT NULL DEFAULT '', + oper_param TEXT NOT NULL, + status VARCHAR(4) NOT NULL DEFAULT '', + oper_time TIMESTAMP NULL, + json_result VARCHAR(255) NOT NULL DEFAULT '', + remark VARCHAR(255) NOT NULL DEFAULT '', + latency_time VARCHAR(128) NOT NULL DEFAULT '', + user_agent VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_sys_opera_log_create_by (create_by), + KEY idx_sys_opera_log_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_tables ( + table_id BIGINT NOT NULL AUTO_INCREMENT, + table_name VARCHAR(255) NOT NULL, + table_comment VARCHAR(255) NOT NULL DEFAULT '', + class_name VARCHAR(255) NOT NULL DEFAULT '', + tpl_category VARCHAR(255) NOT NULL DEFAULT '', + package_name VARCHAR(255) NOT NULL DEFAULT '', + module_name VARCHAR(255) NOT NULL DEFAULT '', + module_front_name VARCHAR(255) NOT NULL DEFAULT '', + business_name VARCHAR(255) NOT NULL DEFAULT '', + function_name VARCHAR(255) NOT NULL DEFAULT '', + function_author VARCHAR(255) NOT NULL DEFAULT '', + pk_column VARCHAR(255) NOT NULL DEFAULT '', + pk_go_field VARCHAR(255) NOT NULL DEFAULT '', + pk_json_field VARCHAR(255) NOT NULL DEFAULT '', + options VARCHAR(255) NOT NULL DEFAULT '', + tree_code VARCHAR(255) NOT NULL DEFAULT '', + tree_parent_code VARCHAR(255) NOT NULL DEFAULT '', + tree_name VARCHAR(255) NOT NULL DEFAULT '', + tree BOOLEAN NOT NULL DEFAULT FALSE, + crud BOOLEAN NOT NULL DEFAULT TRUE, + remark VARCHAR(255) NOT NULL DEFAULT '', + is_data_scope INT NOT NULL DEFAULT 0, + is_actions INT NOT NULL DEFAULT 0, + is_auth INT NOT NULL DEFAULT 0, + is_logical_delete VARCHAR(1) NOT NULL DEFAULT '0', + logical_delete BOOLEAN NOT NULL DEFAULT FALSE, + logical_delete_column VARCHAR(128) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (table_id), + UNIQUE KEY uq_sys_tables_table_name (table_name), + KEY idx_sys_tables_deleted_at (deleted_at), + KEY idx_sys_tables_create_by (create_by), + KEY idx_sys_tables_update_by (update_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE sys_columns ( + column_id BIGINT NOT NULL AUTO_INCREMENT, + table_id BIGINT NOT NULL, + column_name VARCHAR(128) NOT NULL, + column_comment VARCHAR(128) NOT NULL DEFAULT '', + column_type VARCHAR(128) NOT NULL DEFAULT '', + go_type VARCHAR(128) NOT NULL DEFAULT '', + go_field VARCHAR(128) NOT NULL DEFAULT '', + json_field VARCHAR(128) NOT NULL DEFAULT '', + is_pk VARCHAR(4) NOT NULL DEFAULT '', + is_increment VARCHAR(4) NOT NULL DEFAULT '', + is_required VARCHAR(4) NOT NULL DEFAULT '', + is_insert VARCHAR(4) NOT NULL DEFAULT '', + is_edit VARCHAR(4) NOT NULL DEFAULT '', + is_list VARCHAR(4) NOT NULL DEFAULT '', + is_query VARCHAR(4) NOT NULL DEFAULT '', + query_type VARCHAR(128) NOT NULL DEFAULT '', + html_type VARCHAR(128) NOT NULL DEFAULT '', + dict_type VARCHAR(128) NOT NULL DEFAULT '', + sort INT NOT NULL DEFAULT 0, + `list` VARCHAR(1) NOT NULL DEFAULT '0', + pk BOOLEAN NOT NULL DEFAULT FALSE, + required BOOLEAN NOT NULL DEFAULT FALSE, + super_column BOOLEAN NOT NULL DEFAULT FALSE, + usable_column BOOLEAN NOT NULL DEFAULT FALSE, + increment BOOLEAN NOT NULL DEFAULT FALSE, + `insert` BOOLEAN NOT NULL DEFAULT FALSE, + edit BOOLEAN NOT NULL DEFAULT FALSE, + query BOOLEAN NOT NULL DEFAULT FALSE, + remark VARCHAR(255) NOT NULL DEFAULT '', + fk_table_name VARCHAR(255) NOT NULL DEFAULT '', + fk_table_name_class VARCHAR(255) NOT NULL DEFAULT '', + fk_table_name_package VARCHAR(255) NOT NULL DEFAULT '', + fk_label_id VARCHAR(255) NOT NULL DEFAULT '', + fk_label_name VARCHAR(255) NOT NULL DEFAULT '', + create_by BIGINT NOT NULL DEFAULT 0, + update_by BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (column_id), + UNIQUE KEY uq_sys_columns_table_column (table_id, column_name), + KEY idx_sys_columns_deleted_at (deleted_at), + KEY idx_sys_columns_create_by (create_by), + KEY idx_sys_columns_update_by (update_by), + CONSTRAINT fk_sys_columns_table FOREIGN KEY (table_id) REFERENCES sys_tables (table_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/000003_mvp1_routing.down.sql b/migrations/000003_mvp1_routing.down.sql new file mode 100644 index 0000000..7d44e99 --- /dev/null +++ b/migrations/000003_mvp1_routing.down.sql @@ -0,0 +1,63 @@ +-- MVP-0 did not understand x-goog-api-key. Reject the rollback before changing any +-- application table if a post-MVP-0 provider would lose its original invariant. +CREATE TABLE IF NOT EXISTS chorus_mvp1_routing_down_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_mvp1_routing_down_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_mvp1_routing_down_guard; +INSERT INTO chorus_mvp1_routing_down_guard (id) +SELECT 1 WHERE EXISTS (SELECT 1 FROM providers WHERE auth_type = 'x-goog-api-key'); + +ALTER TABLE generations + DROP FOREIGN KEY fk_generations_route_pool, + DROP FOREIGN KEY fk_generations_prompt_template, + DROP CHECK chk_generations_route_snapshot, + DROP CHECK chk_generations_provider_attempt_count, + DROP INDEX idx_generations_route_pool, + DROP INDEX idx_generations_prompt_template, + DROP COLUMN provider_attempt_count, + DROP COLUMN role_rule, + DROP COLUMN route_snapshot, + DROP COLUMN prompt_template_id, + DROP COLUMN route_pool_version, + DROP COLUMN route_pool_id; + +ALTER TABLE generation_inputs + DROP CHECK chk_generation_inputs_note, + DROP COLUMN note; + +ALTER TABLE prompt_templates + DROP CHECK chk_prompt_templates_capability, + DROP COLUMN default_role_rule, + DROP COLUMN capability; + +ALTER TABLE providers + DROP CHECK chk_providers_auth_mvp1; + +UPDATE providers p +JOIN provider_credentials c ON c.id = p.active_credential_id +SET p.api_key_enc = c.api_key_enc; + +ALTER TABLE providers + DROP FOREIGN KEY fk_providers_active_credential, + DROP INDEX uq_providers_active_credential, + DROP COLUMN active_credential_id, + ADD CONSTRAINT chk_providers_auth CHECK ( + (auth_type = 'none' AND api_key_enc IS NULL) OR + (auth_type = 'bearer' AND api_key_enc IS NOT NULL) + ); + +ALTER TABLE provider_models + DROP FOREIGN KEY fk_provider_models_provider_mvp1, + ADD CONSTRAINT fk_provider_models_provider FOREIGN KEY (provider_id) REFERENCES providers (id) ON DELETE CASCADE; + +DROP TABLE admin_audit_events; +DROP TABLE provider_connectivity_checks; +DROP TABLE route_member_runtime; +DROP TABLE active_routes; +DROP TABLE route_pool_members; +DROP TABLE route_pools; +DROP TABLE provider_model_capabilities; +DROP TABLE provider_credentials; +DROP TABLE chorus_mvp1_routing_down_guard; diff --git a/migrations/000003_mvp1_routing.up.sql b/migrations/000003_mvp1_routing.up.sql new file mode 100644 index 0000000..a3f72a6 --- /dev/null +++ b/migrations/000003_mvp1_routing.up.sql @@ -0,0 +1,246 @@ +-- Validate that a pre-MVP-1 Gemini template or model has not been configured without +-- the explicit capability that MVP-1 requires. The guard remains only after a failed run. +CREATE TABLE IF NOT EXISTS chorus_mvp1_routing_up_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_mvp1_routing_up_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_mvp1_routing_up_guard; +INSERT INTO chorus_mvp1_routing_up_guard (id) +SELECT 1 +WHERE EXISTS (SELECT 1 FROM provider_models WHERE api_type = 'gemini') + OR EXISTS (SELECT 1 FROM prompt_templates WHERE api_type = 'gemini'); +DROP TABLE chorus_mvp1_routing_up_guard; + +CREATE TABLE provider_credentials ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider_id BIGINT UNSIGNED NOT NULL, + credential_version INT UNSIGNED NOT NULL, + api_key_enc JSON NOT NULL, + key_id VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL, + created_by BIGINT NULL, + updated_by BIGINT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + retired_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_provider_credentials_version (provider_id, credential_version), + KEY idx_provider_credentials_status (provider_id, status, created_at), + CONSTRAINT fk_provider_credentials_provider FOREIGN KEY (provider_id) REFERENCES providers (id) ON DELETE RESTRICT, + CONSTRAINT chk_provider_credentials_status CHECK (status IN ('active', 'retired')), + CONSTRAINT chk_provider_credentials_envelope CHECK ( + JSON_TYPE(api_key_enc) = 'OBJECT' AND + JSON_CONTAINS_PATH(api_key_enc, 'all', '$.version', '$.key_id', '$.nonce', '$.ciphertext') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE provider_model_capabilities ( + provider_model_id BIGINT UNSIGNED NOT NULL, + capability VARCHAR(32) NOT NULL, + PRIMARY KEY (provider_model_id, capability), + CONSTRAINT fk_provider_model_capabilities_model FOREIGN KEY (provider_model_id) REFERENCES provider_models (id) ON DELETE CASCADE, + CONSTRAINT chk_provider_model_capabilities_capability CHECK (capability IN ('text', 'image_generate', 'image_edit')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE route_pools ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + slug VARCHAR(80) NOT NULL, + name VARCHAR(120) NOT NULL, + capability VARCHAR(32) NOT NULL, + prompt_template_id BIGINT UNSIGNED NOT NULL, + max_failover SMALLINT UNSIGNED NOT NULL DEFAULT 0, + version INT UNSIGNED NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT NULL, + updated_by BIGINT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_route_pools_slug (slug), + KEY idx_route_pools_capability_enabled (capability, enabled), + KEY idx_route_pools_prompt_template (prompt_template_id), + CONSTRAINT fk_route_pools_template FOREIGN KEY (prompt_template_id) REFERENCES prompt_templates (id) ON DELETE RESTRICT, + CONSTRAINT chk_route_pools_capability CHECK (capability IN ('text', 'image_generate', 'image_edit')), + CONSTRAINT chk_route_pools_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE active_routes ( + capability VARCHAR(32) NOT NULL, + route_pool_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (capability), + KEY idx_active_routes_pool (route_pool_id), + CONSTRAINT fk_active_routes_pool FOREIGN KEY (route_pool_id) REFERENCES route_pools (id) ON DELETE RESTRICT, + CONSTRAINT chk_active_routes_capability CHECK (capability IN ('text', 'image_generate', 'image_edit')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE route_pool_members ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + route_pool_id BIGINT UNSIGNED NOT NULL, + provider_model_id BIGINT UNSIGNED NOT NULL, + weight SMALLINT UNSIGNED NOT NULL, + failure_threshold SMALLINT UNSIGNED NOT NULL DEFAULT 3, + open_seconds INT UNSIGNED NOT NULL DEFAULT 60, + half_open_max SMALLINT UNSIGNED NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + position SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT NULL, + updated_by BIGINT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_route_pool_members_model (route_pool_id, provider_model_id), + KEY idx_route_pool_members_list (route_pool_id, enabled, position), + CONSTRAINT fk_route_pool_members_pool FOREIGN KEY (route_pool_id) REFERENCES route_pools (id) ON DELETE CASCADE, + CONSTRAINT fk_route_pool_members_model FOREIGN KEY (provider_model_id) REFERENCES provider_models (id) ON DELETE RESTRICT, + CONSTRAINT chk_route_pool_members_weight CHECK (weight > 0), + CONSTRAINT chk_route_pool_members_failure_threshold CHECK (failure_threshold > 0), + CONSTRAINT chk_route_pool_members_open_seconds CHECK (open_seconds > 0), + CONSTRAINT chk_route_pool_members_half_open CHECK (half_open_max > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE route_member_runtime ( + route_pool_member_id BIGINT UNSIGNED NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'closed', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0, + open_until DATETIME(6) NULL, + probe_owner VARCHAR(128) NULL, + probe_token CHAR(36) NULL, + probe_until DATETIME(6) NULL, + last_error_code VARCHAR(64) NULL, + last_error_message VARCHAR(1024) NULL, + last_observed_at DATETIME(6) NULL, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (route_pool_member_id), + KEY idx_route_member_runtime_state (state, open_until), + CONSTRAINT fk_route_member_runtime_member FOREIGN KEY (route_pool_member_id) REFERENCES route_pool_members (id) ON DELETE CASCADE, + CONSTRAINT chk_route_member_runtime_state CHECK (state IN ('closed', 'open', 'half_open')), + CONSTRAINT chk_route_member_runtime_probe CHECK ( + (probe_owner IS NULL AND probe_token IS NULL AND probe_until IS NULL) OR + (probe_owner IS NOT NULL AND probe_token IS NOT NULL AND probe_until IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE provider_connectivity_checks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider_model_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT NULL, + status VARCHAR(16) NOT NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + latency_ms INT UNSIGNED NULL, + started_at DATETIME(6) NOT NULL, + completed_at DATETIME(6) NULL, + cooldown_until DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_provider_connectivity_checks_model (provider_model_id, started_at DESC), + KEY idx_provider_connectivity_checks_status (status, started_at), + CONSTRAINT fk_provider_connectivity_checks_model FOREIGN KEY (provider_model_id) REFERENCES provider_models (id) ON DELETE RESTRICT, + CONSTRAINT chk_provider_connectivity_checks_status CHECK (status IN ('running', 'succeeded', 'failed')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE admin_audit_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + operator_id BIGINT NULL, + action VARCHAR(128) NOT NULL, + target_type VARCHAR(64) NOT NULL, + target_id VARCHAR(128) NOT NULL, + result VARCHAR(16) NOT NULL, + request_id VARCHAR(128) NOT NULL, + summary JSON NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_admin_audit_events_request_action (request_id, action), + KEY idx_admin_audit_events_target (target_type, target_id, created_at), + KEY idx_admin_audit_events_operator (operator_id, created_at), + CONSTRAINT chk_admin_audit_events_result CHECK (result IN ('succeeded', 'failed')), + CONSTRAINT chk_admin_audit_events_summary CHECK (JSON_TYPE(summary) = 'OBJECT') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Backfill only envelopes that already passed the MVP-0 provider check constraint. +INSERT INTO provider_credentials (provider_id, credential_version, api_key_enc, key_id, status, created_at, updated_at) +SELECT id, + 1, + api_key_enc, + JSON_UNQUOTE(JSON_EXTRACT(api_key_enc, '$.key_id')), + 'active', + created_at, + updated_at +FROM providers +WHERE api_key_enc IS NOT NULL; + +INSERT INTO provider_model_capabilities (provider_model_id, capability) +SELECT id, + CASE api_type + WHEN 'chat' THEN 'text' + WHEN 'images' THEN 'image_generate' + WHEN 'images_edits' THEN 'image_edit' + END +FROM provider_models; + +ALTER TABLE providers + ADD COLUMN active_credential_id BIGINT UNSIGNED NULL, + ADD UNIQUE KEY uq_providers_active_credential (active_credential_id); + +UPDATE providers p +JOIN provider_credentials c ON c.provider_id = p.id AND c.status = 'active' +SET p.active_credential_id = c.id; + +ALTER TABLE providers + DROP CHECK chk_providers_auth, + ADD CONSTRAINT fk_providers_active_credential FOREIGN KEY (active_credential_id) REFERENCES provider_credentials (id) ON DELETE RESTRICT; + +UPDATE providers SET api_key_enc = NULL WHERE active_credential_id IS NOT NULL; + +ALTER TABLE providers + ADD CONSTRAINT chk_providers_auth_mvp1 CHECK ( + auth_type IN ('none', 'bearer', 'x-goog-api-key') AND + ((auth_type = 'none' AND active_credential_id IS NULL AND api_key_enc IS NULL) OR + (auth_type IN ('bearer', 'x-goog-api-key') AND active_credential_id IS NOT NULL AND api_key_enc IS NULL)) + ); + +ALTER TABLE provider_models + DROP FOREIGN KEY fk_provider_models_provider, + ADD CONSTRAINT fk_provider_models_provider_mvp1 FOREIGN KEY (provider_id) REFERENCES providers (id) ON DELETE RESTRICT; + +ALTER TABLE prompt_templates + ADD COLUMN capability VARCHAR(32) NULL, + ADD COLUMN default_role_rule TEXT NULL; + +UPDATE prompt_templates +SET capability = CASE api_type + WHEN 'chat' THEN 'text' + WHEN 'images' THEN 'image_generate' + WHEN 'images_edits' THEN 'image_edit' +END, + default_role_rule = ''; + +ALTER TABLE prompt_templates + MODIFY COLUMN capability VARCHAR(32) NOT NULL, + MODIFY COLUMN default_role_rule TEXT NOT NULL, + ADD CONSTRAINT chk_prompt_templates_capability CHECK (capability IN ('text', 'image_generate', 'image_edit')); + +ALTER TABLE generations + ADD COLUMN route_pool_id BIGINT UNSIGNED NULL, + ADD COLUMN route_pool_version INT UNSIGNED NULL, + ADD COLUMN prompt_template_id BIGINT UNSIGNED NULL, + ADD COLUMN route_snapshot JSON NULL, + ADD COLUMN role_rule TEXT NULL, + ADD COLUMN provider_attempt_count INT UNSIGNED NOT NULL DEFAULT 0, + ADD KEY idx_generations_route_pool (route_pool_id), + ADD KEY idx_generations_prompt_template (prompt_template_id), + ADD CONSTRAINT fk_generations_route_pool FOREIGN KEY (route_pool_id) REFERENCES route_pools (id) ON DELETE RESTRICT, + ADD CONSTRAINT fk_generations_prompt_template FOREIGN KEY (prompt_template_id) REFERENCES prompt_templates (id) ON DELETE RESTRICT, + ADD CONSTRAINT chk_generations_route_snapshot CHECK ( + (route_pool_id IS NULL AND route_pool_version IS NULL AND prompt_template_id IS NULL AND route_snapshot IS NULL) OR + (route_pool_id IS NOT NULL AND route_pool_version IS NOT NULL AND prompt_template_id IS NOT NULL AND route_snapshot IS NOT NULL AND JSON_TYPE(route_snapshot) = 'OBJECT') + ), + ADD CONSTRAINT chk_generations_provider_attempt_count CHECK (provider_attempt_count <= attempt_count); + +ALTER TABLE generation_inputs + ADD COLUMN note VARCHAR(500) NULL, + ADD CONSTRAINT chk_generation_inputs_note CHECK (note IS NULL OR CHAR_LENGTH(note) <= 500); diff --git a/migrations/000004_mvp1_admin_seed.down.sql b/migrations/000004_mvp1_admin_seed.down.sql new file mode 100644 index 0000000..e95a613 --- /dev/null +++ b/migrations/000004_mvp1_admin_seed.down.sql @@ -0,0 +1,30 @@ +DELETE c +FROM sys_casbin_rule c +JOIN sys_api a ON c.ptype = 'p' AND c.v0 = 'chorus_operator' AND c.v1 = a.path AND c.v2 = a.action +WHERE a.handle LIKE 'chorus.%'; + +DELETE r +FROM sys_menu_api_rule r +JOIN sys_menu m ON r.menu_id = m.menu_id +JOIN sys_api a ON r.sys_api_id = a.id +WHERE m.path IN ('/chorus/providers', '/chorus/models', '/chorus/routes', '/chorus/templates', '/chorus/generations', '/chorus/users') + AND a.handle LIKE 'chorus.%'; + +DELETE r +FROM sys_role_menu r +JOIN sys_role role_record ON r.role_id = role_record.role_id +JOIN sys_menu m ON r.menu_id = m.menu_id +WHERE role_record.role_key = 'chorus_operator' + AND m.path IN ('/chorus', '/chorus/providers', '/chorus/models', '/chorus/routes', '/chorus/templates', '/chorus/generations', '/chorus/users'); + +DELETE FROM sys_api WHERE handle IN ( + 'chorus.providers.list', 'chorus.providers.create', 'chorus.providers.get', 'chorus.providers.update', + 'chorus.providers.credential.rotate', 'chorus.providers.credential.activate', 'chorus.models.list', + 'chorus.models.create', 'chorus.models.update', 'chorus.models.connectivity', 'chorus.routes.list', + 'chorus.routes.create', 'chorus.routes.update', 'chorus.templates.list', 'chorus.templates.create', + 'chorus.templates.update', 'chorus.generations.list', 'chorus.users.list' +); + +DELETE FROM sys_menu WHERE path IN ('/chorus/providers', '/chorus/models', '/chorus/routes', '/chorus/templates', '/chorus/generations', '/chorus/users'); +DELETE FROM sys_menu WHERE path = '/chorus'; +DELETE FROM sys_role WHERE role_key = 'chorus_operator'; diff --git a/migrations/000004_mvp1_admin_seed.up.sql b/migrations/000004_mvp1_admin_seed.up.sql new file mode 100644 index 0000000..56c3cd4 --- /dev/null +++ b/migrations/000004_mvp1_admin_seed.up.sql @@ -0,0 +1,89 @@ +-- Chorus-owned records use stable role keys, menu paths, and API handles. The seed +-- deliberately creates no sys_user row and therefore never introduces a known password. +INSERT INTO sys_role (role_name, status, role_key, role_sort, flag, remark, admin, data_scope) +VALUES ('Chorus Operator', '2', 'chorus_operator', 100, '', 'Chorus operations role', FALSE, '1') +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), status = VALUES(status), role_sort = VALUES(role_sort), + remark = VALUES(remark), admin = VALUES(admin), data_scope = VALUES(data_scope), deleted_at = NULL; + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +VALUES + ('chorus', 'Chorus', 'ri:magic-line', '/chorus', '', 'M', '', 'chorus:menu:view', 0, FALSE, 'Chorus', 'Layout', 900, '0', '0') +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), no_cache = VALUES(no_cache), breadcrumb = VALUES(breadcrumb), + component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu SET paths = CONCAT('/0/', menu_id) WHERE path = '/chorus'; + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +SELECT menu_name, title, icon, path, '', menu_type, action, permission, + (SELECT menu_id FROM sys_menu WHERE path = '/chorus'), no_cache, breadcrumb, component, sort, visible, is_frame +FROM ( + SELECT 'chorus-providers' AS menu_name, 'Providers' AS title, 'ri:server-line' AS icon, + '/chorus/providers' AS path, 'C' AS menu_type, '' AS action, 'chorus:providers:view' AS permission, + FALSE AS no_cache, 'Chorus / Providers' AS breadcrumb, 'chorus/providers/index' AS component, 10 AS sort, '0' AS visible, '0' AS is_frame + UNION ALL SELECT 'chorus-models', 'Models', 'ri:cpu-line', '/chorus/models', 'C', '', 'chorus:models:view', FALSE, 'Chorus / Models', 'chorus/models/index', 20, '0', '0' + UNION ALL SELECT 'chorus-routes', 'Route Pools', 'ri:git-branch-line', '/chorus/routes', 'C', '', 'chorus:routes:view', FALSE, 'Chorus / Route Pools', 'chorus/routes/index', 30, '0', '0' + UNION ALL SELECT 'chorus-templates', 'Prompt Templates', 'ri:file-text-line', '/chorus/templates', 'C', '', 'chorus:templates:view', FALSE, 'Chorus / Prompt Templates', 'chorus/templates/index', 40, '0', '0' + UNION ALL SELECT 'chorus-generations', 'Generations', 'ri:history-line', '/chorus/generations', 'C', '', 'chorus:generations:view', FALSE, 'Chorus / Generations', 'chorus/generations/index', 50, '0', '0' + UNION ALL SELECT 'chorus-users', 'Users', 'ri:user-line', '/chorus/users', 'C', '', 'chorus:users:view', FALSE, 'Chorus / Users', 'chorus/users/index', 60, '0', '0' +) AS chorus_menus +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), parent_id = VALUES(parent_id), no_cache = VALUES(no_cache), + breadcrumb = VALUES(breadcrumb), component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), + is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu child +JOIN sys_menu parent ON parent.path = '/chorus' +SET child.paths = CONCAT(parent.paths, '/', child.menu_id) +WHERE child.path IN ('/chorus/providers', '/chorus/models', '/chorus/routes', '/chorus/templates', '/chorus/generations', '/chorus/users'); + +INSERT INTO sys_api (handle, title, path, type, action) +VALUES + ('chorus.providers.list', 'List providers', '/api/v1/chorus/providers', 'BUS', 'GET'), + ('chorus.providers.create', 'Create provider', '/api/v1/chorus/providers', 'BUS', 'POST'), + ('chorus.providers.get', 'Get provider', '/api/v1/chorus/providers/:id', 'BUS', 'GET'), + ('chorus.providers.update', 'Update provider', '/api/v1/chorus/providers/:id', 'BUS', 'PUT'), + ('chorus.providers.credential.rotate', 'Rotate provider credential', '/api/v1/chorus/providers/:id/credential', 'BUS', 'PUT'), + ('chorus.providers.credential.activate', 'Activate provider credential', '/api/v1/chorus/providers/:id/credential/:version/activate', 'BUS', 'POST'), + ('chorus.models.list', 'List provider models', '/api/v1/chorus/provider-models', 'BUS', 'GET'), + ('chorus.models.create', 'Create provider model', '/api/v1/chorus/provider-models', 'BUS', 'POST'), + ('chorus.models.update', 'Update provider model', '/api/v1/chorus/provider-models/:id', 'BUS', 'PUT'), + ('chorus.models.connectivity', 'Run connectivity check', '/api/v1/chorus/provider-models/:id/connectivity-checks', 'BUS', 'POST'), + ('chorus.routes.list', 'List route pools', '/api/v1/chorus/route-pools', 'BUS', 'GET'), + ('chorus.routes.create', 'Create route pool', '/api/v1/chorus/route-pools', 'BUS', 'POST'), + ('chorus.routes.update', 'Update route pool', '/api/v1/chorus/route-pools/:id', 'BUS', 'PUT'), + ('chorus.templates.list', 'List prompt templates', '/api/v1/chorus/prompt-templates', 'BUS', 'GET'), + ('chorus.templates.create', 'Create prompt template', '/api/v1/chorus/prompt-templates', 'BUS', 'POST'), + ('chorus.templates.update', 'Update prompt template', '/api/v1/chorus/prompt-templates/:id', 'BUS', 'PUT'), + ('chorus.generations.list', 'List generations', '/api/v1/chorus/generations', 'BUS', 'GET'), + ('chorus.users.list', 'List portal users', '/api/v1/chorus/users', 'BUS', 'GET') +ON DUPLICATE KEY UPDATE + title = VALUES(title), type = VALUES(type), deleted_at = NULL; + +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT r.role_id, m.menu_id +FROM sys_role r +JOIN sys_menu m ON m.path IN ('/chorus', '/chorus/providers', '/chorus/models', '/chorus/routes', '/chorus/templates', '/chorus/generations', '/chorus/users') +WHERE r.role_key = 'chorus_operator'; + +INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id) +SELECT m.menu_id, a.id +FROM sys_menu m +JOIN sys_api a ON ( + (m.path = '/chorus/providers' AND a.handle LIKE 'chorus.providers.%') OR + (m.path = '/chorus/models' AND a.handle LIKE 'chorus.models.%') OR + (m.path = '/chorus/routes' AND a.handle LIKE 'chorus.routes.%') OR + (m.path = '/chorus/templates' AND a.handle LIKE 'chorus.templates.%') OR + (m.path = '/chorus/generations' AND a.handle = 'chorus.generations.list') OR + (m.path = '/chorus/users' AND a.handle = 'chorus.users.list') +); + +INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', 'chorus_operator', path, action, '', '', '' +FROM sys_api +WHERE handle LIKE 'chorus.%'; diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 094b42d..1c2f2b4 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -20,8 +21,25 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { if len(upFiles) == 0 || len(upFiles) != len(downFiles) { t.Fatalf("migration pairs mismatch: up=%d down=%d", len(upFiles), len(downFiles)) } + wantFiles := []string{ + "000001_mvp0_base.up.sql", + "000002_admin_baseline.up.sql", + "000003_mvp1_routing.up.sql", + "000004_mvp1_admin_seed.up.sql", + } + slices.Sort(upFiles) + if !slices.Equal(upFiles, wantFiles) { + t.Fatalf("up migrations = %v, want %v", upFiles, wantFiles) + } - content, err := os.ReadFile(upFiles[0]) + for _, upFile := range upFiles { + downFile := strings.TrimSuffix(upFile, ".up.sql") + ".down.sql" + if _, err := os.Stat(downFile); err != nil { + t.Errorf("%s has no matching down migration: %v", upFile, err) + } + } + + content, err := os.ReadFile("000001_mvp0_base.up.sql") if err != nil { t.Fatal(err) } @@ -38,3 +56,64 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { } } } + +func TestMVP1MigrationContracts(t *testing.T) { + read := func(name string) string { + t.Helper() + content, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(content) + } + + adminBaseline := read("000002_admin_baseline.up.sql") + for _, table := range []string{ + "sys_user", "sys_role", "sys_menu", "sys_api", "sys_role_menu", "sys_menu_api_rule", + "sys_casbin_rule", "sys_tables", "sys_columns", + } { + if !regexp.MustCompile(`(?i)CREATE TABLE\s+` + regexp.QuoteMeta(table) + `\s*\(`).MatchString(adminBaseline) { + t.Errorf("admin baseline does not create %s", table) + } + } + if strings.Contains(strings.ToLower(adminBaseline), "automigrate") { + t.Error("admin baseline must not use AutoMigrate") + } + + routing := read("000003_mvp1_routing.up.sql") + for _, required := range []string{ + "provider_credentials", "provider_model_capabilities", "route_pools", "active_routes", + "route_pool_members", "route_member_runtime", "provider_connectivity_checks", "admin_audit_events", + "active_credential_id", "route_snapshot", "provider_attempt_count", "default_role_rule", "note", + "uq_provider_credentials_version", "uq_route_pools_slug", "uq_route_pool_members_model", + "idx_provider_connectivity_checks_model", "uq_admin_audit_events_request_action", + "DROP CHECK chk_providers_auth", "UPDATE providers SET api_key_enc = NULL", + } { + if !strings.Contains(routing, required) { + t.Errorf("MVP-1 routing migration is missing %s", required) + } + } + + seed := read("000004_mvp1_admin_seed.up.sql") + for _, required := range []string{"chorus_operator", "chorus.providers.credential.rotate", "sys_role_menu", "sys_menu_api_rule", "sys_casbin_rule"} { + if !strings.Contains(seed, required) { + t.Errorf("admin seed is missing %s", required) + } + } + if strings.Contains(seed, "INSERT INTO sys_user") { + t.Error("admin seed must not create a known administrator account") + } + adminDown := read("000002_admin_baseline.down.sql") + for _, required := range []string{"chorus_admin_baseline_down_guard", "INSERT INTO chorus_admin_baseline_down_guard", "EXISTS (SELECT 1 FROM sys_user)"} { + if !strings.Contains(adminDown, required) { + t.Errorf("admin baseline down migration is missing rollback guard %s", required) + } + } + + for _, name := range []string{"000002_admin_baseline.down.sql", "000003_mvp1_routing.down.sql", "000004_mvp1_admin_seed.down.sql"} { + down := read(name) + if strings.Contains(strings.ToLower(down), "delete from sys_menu where title") || strings.Contains(strings.ToLower(down), "delete from sys_api where title") { + t.Errorf("%s must delete Chorus seed data by stable keys, not titles", name) + } + } +} diff --git a/migrations/mysql_integration_test.go b/migrations/mysql_integration_test.go new file mode 100644 index 0000000..4ca879e --- /dev/null +++ b/migrations/mysql_integration_test.go @@ -0,0 +1,128 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// This test resets only the explicitly designated disposable migration database. +// It is opt-in so normal package tests cannot erase a developer's local data. +func TestMVP1MigrationsUpDownUpMySQL(t *testing.T) { + if os.Getenv("CHORUS_RUN_MIGRATION_TESTS") != "1" { + t.Skip("set CHORUS_RUN_MIGRATION_TESTS=1 for the disposable MySQL migration database") + } + migrationURL := strings.TrimSpace(os.Getenv("CHORUS_MIGRATE_URL")) + dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN")) + if migrationURL == "" || dsn == "" { + t.Fatal("CHORUS_MIGRATE_URL and CHORUS_DSN are required for migration integration tests") + } + if _, err := exec.LookPath("migrate"); err != nil { + t.Fatalf("migrate executable is required: %v", err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + t.Fatalf("connect disposable migration database: %v", err) + } + + runMigrate := func(args ...string) { + t.Helper() + commandArgs := append([]string{"-path", ".", "-database", migrationURL}, args...) + command := exec.CommandContext(ctx, "migrate", commandArgs...) + output, err := command.CombinedOutput() + if err != nil && !strings.Contains(string(output), "no change") { + t.Fatalf("migrate %s: %v\n%s", strings.Join(args, " "), err, output) + } + } + + runMigrate("down", "-all") + runMigrate("goto", "1") + seedMVP0MigrationFixture(t, ctx, db) + + runMigrate("up") + assertTableExists(t, ctx, db, "provider_credentials", true) + assertTableExists(t, ctx, db, "route_pools", true) + assertTableExists(t, ctx, db, "sys_user", true) + + var credentialCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM provider_credentials WHERE provider_id = 1 AND credential_version = 1 AND key_id = 'migration-test-key'`).Scan(&credentialCount); err != nil { + t.Fatal(err) + } + if credentialCount != 1 { + t.Fatalf("credential backfill count = %d, want 1", credentialCount) + } + var activeCredential sql.NullInt64 + var oldEnvelope sql.NullString + if err := db.QueryRowContext(ctx, `SELECT active_credential_id, api_key_enc FROM providers WHERE id = 1`).Scan(&activeCredential, &oldEnvelope); err != nil { + t.Fatal(err) + } + if !activeCredential.Valid || oldEnvelope.Valid { + t.Fatalf("provider credential migration did not activate the new credential safely") + } + assertCount(t, ctx, db, `SELECT COUNT(*) FROM provider_model_capabilities WHERE provider_model_id = 1 AND capability = 'text'`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_role WHERE role_key = 'chorus_operator'`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.providers.credential.rotate'`, 1) + + runMigrate("down", "3") + assertTableExists(t, ctx, db, "provider_credentials", false) + assertTableExists(t, ctx, db, "sys_user", false) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM providers WHERE id = 1 AND api_key_enc IS NOT NULL`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM generations WHERE id = 1`, 1) + + runMigrate("up") + assertTableExists(t, ctx, db, "provider_credentials", true) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM provider_credentials WHERE provider_id = 1 AND credential_version = 1`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_role WHERE role_key = 'chorus_operator'`, 1) +} + +func seedMVP0MigrationFixture(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + statements := []string{ + `INSERT INTO users (id, email, password_hash, display_name, status) VALUES (1, 'migration-user@chorus.invalid', 'synthetic', 'Migration User', 'active')`, + `INSERT INTO providers (id, slug, name, base_url, auth_type, api_key_enc, enabled) VALUES (1, 'migration-provider', 'Migration Provider', 'https://provider.invalid', 'bearer', JSON_OBJECT('version', 1, 'key_id', 'migration-test-key', 'nonce', 'AA==', 'ciphertext', 'AA=='), FALSE)`, + `INSERT INTO provider_models (id, provider_id, name, model_id, api_type, kind, extra_body, timeout_ms, weight, enabled) VALUES (1, 1, 'Migration Model', 'migration-model', 'chat', 'text', JSON_OBJECT(), 30000, 100, TRUE)`, + `INSERT INTO prompt_templates (id, template_key, kind, api_type, name, version, template_text, enabled) VALUES (1, 'migration-template', 'text', 'chat', 'Migration Template', 1, '{{.UserPrompt}}', TRUE)`, + `INSERT INTO generations (id, user_id, provider_model_id, kind, status, idempotency_key, user_prompt, rendered_prompt, attempts, attempt_count) VALUES (1, 1, 1, 'text', 'pending', 'migration-key', 'migration input', 'migration input', JSON_ARRAY(), 0)`, + } + for _, statement := range statements { + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatalf("seed MVP-0 fixture: %v", err) + } + } +} + +func assertTableExists(t *testing.T, ctx context.Context, db *sql.DB, table string, want bool) { + t.Helper() + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, table).Scan(&count); err != nil { + t.Fatal(err) + } + if (count == 1) != want { + t.Fatalf("table %s exists = %t, want %t", table, count == 1, want) + } +} + +func assertCount(t *testing.T, ctx context.Context, db *sql.DB, query string, want int) { + t.Helper() + var got int + if err := db.QueryRowContext(ctx, query).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatal(fmt.Sprintf("query count = %d, want %d", got, want)) + } +}