BEGIN; CREATE TABLE IF NOT EXISTS operation_groups ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, group_id BIGINT REFERENCES operation_groups(id) ON DELETE RESTRICT, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('platform_admin', 'group_admin', 'operator')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), must_change_password BOOLEAN NOT NULL DEFAULT TRUE, last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users(group_id) WHERE role = 'group_admin'; CREATE TABLE IF NOT EXISTS projects ( id BIGSERIAL PRIMARY KEY, group_id BIGINT NOT NULL REFERENCES operation_groups(id) ON DELETE RESTRICT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')), access_password_hash TEXT NOT NULL DEFAULT '', customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE, access_expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS collections ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')), completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (project_id, name) ); ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS notes ( id BIGSERIAL PRIMARY KEY, collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, external_id TEXT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')), version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT; CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''; CREATE TABLE IF NOT EXISTS images ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, url TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 0, height INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0, storage_provider TEXT NOT NULL DEFAULT 'local' CHECK (storage_provider IN ('local', 'tencent_cos', 'external')), storage_key TEXT NOT NULL DEFAULT '', version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0) ); CREATE TABLE IF NOT EXISTS annotations ( id BIGSERIAL PRIMARY KEY, image_id BIGINT NOT NULL REFERENCES images(id) ON DELETE CASCADE, x DOUBLE PRECISION NOT NULL CHECK (x BETWEEN 0 AND 1), y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1), content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS work_comments ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS text_annotations ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, version_number INTEGER NOT NULL CHECK (version_number > 0), target TEXT NOT NULL CHECK (target IN ('title', 'description')), content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number); CREATE TABLE IF NOT EXISTS work_versions ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, version_number INTEGER NOT NULL CHECK (version_number > 0), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')), created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (note_id, version_number) ); CREATE TABLE IF NOT EXISTS review_events ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, version_number INTEGER NOT NULL, event_type TEXT NOT NULL CHECK (event_type IN ('submitted', 'approved', 'changes_requested', 'reopened')), from_status TEXT, to_status TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', actor_name TEXT NOT NULL, actor_role TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS sessions ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS customer_sessions ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, reviewer_name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS audit_logs ( id BIGSERIAL PRIMARY KEY, group_id BIGINT REFERENCES operation_groups(id) ON DELETE SET NULL, user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id BIGINT, detail TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS api_keys ( id BIGSERIAL PRIMARY KEY, group_id BIGINT REFERENCES operation_groups(id) ON DELETE RESTRICT, project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, key_prefix TEXT NOT NULL UNIQUE, key_hash TEXT NOT NULL UNIQUE, scope TEXT NOT NULL CHECK (scope IN ('platform', 'project')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS storage_configs ( id BIGSERIAL PRIMARY KEY, provider TEXT NOT NULL DEFAULT 'tencent_cos', region TEXT NOT NULL, bucket TEXT NOT NULL, public_base_url TEXT NOT NULL DEFAULT '', cdn_domain TEXT NOT NULL DEFAULT '', path_prefix TEXT NOT NULL DEFAULT 'delivery-desk', secret_id_encrypted TEXT NOT NULL, secret_key_encrypted TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), test_status TEXT NOT NULL DEFAULT 'untested' CHECK (test_status IN ('untested', 'passed', 'failed')), test_message TEXT NOT NULL DEFAULT '', last_tested_at TIMESTAMPTZ, created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), activated_at TIMESTAMPTZ ); CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id); CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id); CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index); CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id); CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id); CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions(project_id); CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number); CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id); -- COLLECTION_STATUS_REPAIR_START UPDATE collections SET status = 'draft', completed_at = NULL WHERE status != 'archived' AND id NOT IN (SELECT collection_id FROM notes WHERE review_status != 'draft'); UPDATE collections AS c SET status = CASE WHEN s.approved_count = s.work_count THEN 'completed' ELSE 'reviewing' END, completed_at = CASE WHEN s.approved_count = s.work_count THEN COALESCE(c.completed_at, NOW()) ELSE NULL END FROM ( SELECT collection_id, COUNT(*) AS work_count, SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status != 'draft' GROUP BY collection_id ) AS s WHERE c.id = s.collection_id AND c.status != 'archived'; -- COLLECTION_STATUS_REPAIR_END COMMIT;