diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql index 5bbe825..3e4c524 100644 --- a/backend/scripts/init_schema.sql +++ b/backend/scripts/init_schema.sql @@ -7,6 +7,15 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'record_status') THEN CREATE TYPE record_status AS ENUM ('active','inactive'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN + CREATE TYPE client_status AS ENUM ('active','inactive'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN + CREATE TYPE scope_type AS ENUM ('company','site'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN + CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage'); + END IF; END $$; @@ -22,6 +31,14 @@ CREATE TABLE IF NOT EXISTS users ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS auth_sync_state ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_synced_at TIMESTAMPTZ, + source_version TEXT, + last_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS companies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), company_key TEXT NOT NULL UNIQUE, @@ -31,6 +48,16 @@ CREATE TABLE IF NOT EXISTS companies ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS sites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + site_key TEXT NOT NULL UNIQUE, + company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS systems ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), system_key TEXT NOT NULL UNIQUE, @@ -49,27 +76,19 @@ CREATE TABLE IF NOT EXISTS modules ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS sites ( +-- legacy table: 保留相容舊流程 +CREATE TABLE IF NOT EXISTS permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - site_key TEXT NOT NULL UNIQUE, - company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, - name TEXT NOT NULL, - status record_status NOT NULL DEFAULT 'active', + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_id VARCHAR(128) NOT NULL, + module VARCHAR(128) NOT NULL, + action VARCHAR(32) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + CONSTRAINT uq_permissions_user_scope_module_action + UNIQUE (user_id, scope_type, scope_id, module, action) ); -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN - CREATE TYPE scope_type AS ENUM ('company','site'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN - CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage'); - END IF; -END -$$; - CREATE TABLE IF NOT EXISTS user_scope_permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -90,14 +109,6 @@ ALTER TABLE user_scope_permissions OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL)) ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company -ON user_scope_permissions(user_id, module_id, action, scope_type, company_id) -WHERE scope_type = 'company'; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site -ON user_scope_permissions(user_id, module_id, action, scope_type, site_id) -WHERE scope_type = 'site'; - CREATE TABLE IF NOT EXISTS permission_groups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), group_key TEXT NOT NULL UNIQUE, @@ -123,17 +134,46 @@ CREATE TABLE IF NOT EXISTS permission_group_permissions ( action TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_pgp_group_rule UNIQUE (group_id, system, module, action, scope_type, scope_id) ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule -ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id); +CREATE TABLE IF NOT EXISTS api_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status client_status NOT NULL DEFAULT 'active', + api_key_hash TEXT NOT NULL, + allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb, + allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb, + allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb, + rate_limit_per_min INTEGER, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO systems (system_key, name, status) +VALUES ('member', 'Member Center', 'active') +ON CONFLICT (system_key) DO NOTHING; CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); CREATE INDEX IF NOT EXISTS idx_sites_company_id ON sites(company_id); +CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); CREATE INDEX IF NOT EXISTS idx_usp_user_id ON user_scope_permissions(user_id); CREATE INDEX IF NOT EXISTS idx_usp_module_id ON user_scope_permissions(module_id); CREATE INDEX IF NOT EXISTS idx_usp_company_id ON user_scope_permissions(company_id); CREATE INDEX IF NOT EXISTS idx_usp_site_id ON user_scope_permissions(site_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company + ON user_scope_permissions(user_id, module_id, action, scope_type, company_id) + WHERE scope_type = 'company'; +CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site + ON user_scope_permissions(user_id, module_id, action, scope_type, site_id) + WHERE scope_type = 'site'; +CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status); +CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at); +CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key); +CREATE INDEX IF NOT EXISTS idx_modules_module_key ON modules(module_key); COMMIT; diff --git a/docs/API_CLIENTS_SQL.sql b/docs/API_CLIENTS_SQL.sql deleted file mode 100644 index e91281e..0000000 --- a/docs/API_CLIENTS_SQL.sql +++ /dev/null @@ -1,106 +0,0 @@ --- member_center: API 呼叫方白名單表 --- 位置: public schema - -BEGIN; - -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN - CREATE TYPE client_status AS ENUM ('active', 'inactive'); - END IF; -END $$; - -CREATE TABLE IF NOT EXISTS api_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status client_status NOT NULL DEFAULT 'active', - - -- 只存 hash,不存明文 key - api_key_hash TEXT NOT NULL, - - -- 可先留空,之後再嚴格化 - allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb, - allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb, - allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb, - - rate_limit_per_min INTEGER, - expires_at TIMESTAMPTZ, - last_used_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status); -CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at); - -CREATE OR REPLACE FUNCTION set_updated_at_api_clients() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_trigger WHERE tgname = 'trg_api_clients_set_updated_at' - ) THEN - CREATE TRIGGER trg_api_clients_set_updated_at - BEFORE UPDATE ON api_clients - FOR EACH ROW EXECUTE FUNCTION set_updated_at_api_clients(); - END IF; -END $$; - --- 建議初始化 2~3 個 client(api_key_hash 先放占位,後續再更新) -INSERT INTO api_clients ( - client_key, - name, - status, - api_key_hash, - allowed_origins, - allowed_ips, - allowed_paths, - rate_limit_per_min -) -VALUES - ( - 'mkt-backend', - 'MKT Backend Service', - 'active', - 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', - '[]'::jsonb, - '[]'::jsonb, - '["/internal/users/upsert-by-sub", "/internal/permissions"]'::jsonb, - 600 - ), - ( - 'admin-frontend', - 'Admin Frontend', - 'active', - 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', - '["https://admin.ose.tw", "https://member.ose.tw"]'::jsonb, - '[]'::jsonb, - '["/admin"]'::jsonb, - 300 - ), - ( - 'ops-local', - 'Ops Local Tooling', - 'inactive', - 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', - '[]'::jsonb, - '["127.0.0.1"]'::jsonb, - '["/internal", "/admin"]'::jsonb, - 120 - ) -ON CONFLICT (client_key) DO NOTHING; - -COMMIT; - --- 快速檢查 --- SELECT client_key, status, expires_at, created_at FROM api_clients ORDER BY client_key; diff --git a/docs/ARCHITECTURE_AND_CONFIG.md b/docs/ARCHITECTURE_AND_CONFIG.md index 56b05a9..8b33509 100644 --- a/docs/ARCHITECTURE_AND_CONFIG.md +++ b/docs/ARCHITECTURE_AND_CONFIG.md @@ -23,8 +23,8 @@ - 前端任務進度與驗收條件 - `docs/TASKPLAN_BACKEND.md` - 後端任務進度與驗收條件 -- `docs/API_CLIENTS_SQL.sql` - - `api_clients` 白名單表與初始資料 SQL +- `backend/scripts/init_schema.sql` + - 一次建立完整 schema(含 `api_clients`) - `docs/DB_SCHEMA_SNAPSHOT.md` - 目前資料庫 schema 快照(欄位/索引/約束) diff --git a/docs/BACKEND_BOOTSTRAP.md b/docs/BACKEND_BOOTSTRAP.md index 0863bc4..97c267d 100644 --- a/docs/BACKEND_BOOTSTRAP.md +++ b/docs/BACKEND_BOOTSTRAP.md @@ -10,9 +10,9 @@ cp .env.example .env ``` ## 2. 建立資料表 -1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql` -2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql` -3. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql` +1. 先執行 `member.ose.tw/backend/scripts/init_schema.sql`(已含 `api_clients`) +2. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_align_company_site_member_system.sql` +3. 若是更舊資料庫,再補 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql` ## 3. 啟動服務 ```bash diff --git a/docs/index.md b/docs/index.md index 78d97a9..f3355ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ - `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃) ## SQL 與配置 -- `docs/API_CLIENTS_SQL.sql` +- `backend/scripts/init_schema.sql` - `docs/DB_SCHEMA_SNAPSHOT.md` ## 給前端 AI 的一句話交接