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')), review_status TEXT NOT NULL DEFAULT 'draft' CHECK (review_status IN ('draft', 'reviewing', 'completed', 'archived')), review_completed_at TIMESTAMPTZ, 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, project_id BIGINT REFERENCES projects(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), active_round_id BIGINT, approved_version_number INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT; ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE; ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'draft'; ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_completed_at TIMESTAMPTZ; 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 '客户', 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')), closure_reason TEXT NOT NULL DEFAULT '', withdrawn_at TIMESTAMPTZ, 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, version_number INTEGER NOT NULL DEFAULT 1, 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')), closure_reason TEXT NOT NULL DEFAULT '', withdrawn_at TIMESTAMPTZ, 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', 'tags')), start_offset INTEGER NOT NULL DEFAULT 0, end_offset INTEGER NOT NULL DEFAULT 0, selected_text TEXT NOT NULL DEFAULT '', prefix_text TEXT NOT NULL DEFAULT '', suffix_text TEXT NOT NULL DEFAULT '', 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')), closure_reason TEXT NOT NULL DEFAULT '', withdrawn_at TIMESTAMPTZ, 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 feedback_replies ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, version_number INTEGER NOT NULL CHECK (version_number > 0), feedback_type TEXT NOT NULL CHECK (feedback_type IN ('image_annotation', 'text_annotation', 'comment')), feedback_id BIGINT NOT NULL, content TEXT NOT NULL, author_name TEXT NOT NULL, author_role TEXT NOT NULL CHECK (author_role IN ('client', 'operator')), withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id); 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')), review_round_id BIGINT, candidate_name TEXT NOT NULL DEFAULT '方案 A', candidate_status TEXT NOT NULL DEFAULT 'pending' CHECK (candidate_status IN ('draft', 'pending', 'changes_requested', 'selected', 'not_selected')), 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_rounds ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, round_number INTEGER NOT NULL CHECK (round_number > 0), status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed')), selected_version_number INTEGER, created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, completed_at TIMESTAMPTZ, completion_reason TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(note_id, round_number) ); ALTER TABLE notes ADD COLUMN IF NOT EXISTS active_round_id BIGINT; ALTER TABLE notes ADD COLUMN IF NOT EXISTS approved_version_number INTEGER; ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS review_round_id BIGINT; ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_name TEXT NOT NULL DEFAULT '方案 A'; ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE review_rounds ADD COLUMN IF NOT EXISTS completion_reason TEXT NOT NULL DEFAULT ''; ALTER TABLE annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client'; ALTER TABLE annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; ALTER TABLE annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS version_number INTEGER NOT NULL DEFAULT 1; ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS start_offset INTEGER NOT NULL DEFAULT 0; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS end_offset INTEGER NOT NULL DEFAULT 0; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS selected_text TEXT NOT NULL DEFAULT ''; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS prefix_text TEXT NOT NULL DEFAULT ''; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS suffix_text TEXT NOT NULL DEFAULT ''; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client'; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; -- TEXT_ANNOTATION_TARGET_REPAIR_START ALTER TABLE text_annotations DROP CONSTRAINT IF EXISTS text_annotations_target_check; ALTER TABLE text_annotations ADD CONSTRAINT text_annotations_target_check CHECK (target IN ('title', 'description', 'tags')); -- TEXT_ANNOTATION_TARGET_REPAIR_END 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 notes_project_id_idx ON notes(project_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_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 review_rounds_note_id_idx ON review_rounds(note_id, round_number); CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id); -- SINGLE_SCHEME_REPAIR_START DO $$ DECLARE duplicate RECORD; version_row RECORD; source_round RECORD; note_row RECORD; keeper_version INTEGER; next_round INTEGER; new_round_id BIGINT; remains_active BOOLEAN; BEGIN FOR duplicate IN SELECT review_round_id, note_id FROM work_versions WHERE review_round_id IS NOT NULL GROUP BY review_round_id, note_id HAVING COUNT(*) > 1 LOOP SELECT * INTO source_round FROM review_rounds WHERE id = duplicate.review_round_id; SELECT active_round_id, version_number INTO note_row FROM notes WHERE id = duplicate.note_id; SELECT COALESCE( (SELECT v.version_number FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id AND v.version_number=note_row.version_number LIMIT 1), (SELECT MIN(v.version_number) FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id) ) INTO keeper_version; FOR version_row IN SELECT version_number FROM work_versions WHERE review_round_id=duplicate.review_round_id AND version_number<>keeper_version ORDER BY version_number LOOP SELECT COALESCE(MAX(round_number),0)+1 INTO next_round FROM review_rounds WHERE note_id=duplicate.note_id; remains_active := note_row.active_round_id=duplicate.review_round_id AND note_row.version_number=version_row.version_number; INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason) VALUES ( duplicate.note_id, next_round, CASE WHEN remains_active THEN source_round.status ELSE 'completed' END, CASE WHEN source_round.selected_version_number=version_row.version_number THEN version_row.version_number ELSE NULL END, CASE WHEN remains_active THEN source_round.completed_at ELSE COALESCE(source_round.completed_at,NOW()) END, source_round.created_by, source_round.created_at, CASE WHEN remains_active THEN source_round.completion_reason ELSE COALESCE(NULLIF(source_round.completion_reason,''),'migrated_single_scheme') END ) RETURNING id INTO new_round_id; UPDATE work_versions SET review_round_id=new_round_id WHERE note_id=duplicate.note_id AND version_number=version_row.version_number; IF remains_active THEN UPDATE notes SET active_round_id=new_round_id WHERE id=duplicate.note_id; END IF; END LOOP; IF source_round.selected_version_number IS DISTINCT FROM keeper_version THEN UPDATE review_rounds SET selected_version_number=NULL WHERE id=duplicate.review_round_id; END IF; END LOOP; END $$; -- SINGLE_SCHEME_REPAIR_END CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL; -- 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 -- REVIEW_ROUND_REPAIR_START INSERT INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at) SELECT v.note_id, v.version_number, CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END, CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END, CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END, v.created_at FROM work_versions v JOIN notes n ON n.id = v.note_id ON CONFLICT (note_id, round_number) DO NOTHING; UPDATE work_versions v SET review_round_id = r.id, candidate_name = COALESCE(NULLIF(v.candidate_name, ''), '方案 A'), candidate_status = CASE WHEN v.review_status = 'approved' THEN 'selected' WHEN v.version_number = n.version_number AND v.review_status = 'changes_requested' THEN 'changes_requested' WHEN v.version_number = n.version_number AND v.review_status = 'pending' THEN 'pending' WHEN v.version_number = n.version_number AND v.review_status = 'draft' THEN 'draft' ELSE 'not_selected' END FROM review_rounds r, notes n WHERE r.note_id = v.note_id AND r.round_number = v.version_number AND n.id = v.note_id AND v.review_round_id IS NULL; UPDATE notes n SET active_round_id = r.id, approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = n.id AND v.review_status = 'approved') FROM review_rounds r WHERE r.note_id = n.id AND r.round_number = n.version_number AND n.active_round_id IS NULL; -- REVIEW_ROUND_REPAIR_END -- PROJECT_REVIEW_STATUS_REPAIR_START UPDATE notes n SET project_id = c.project_id FROM collections c WHERE c.id = n.collection_id AND n.project_id IS NULL; UPDATE work_comments wc SET version_number = n.version_number FROM notes n WHERE n.id = wc.note_id AND wc.version_number < 1; UPDATE projects p SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' WHEN s.work_count IS NULL OR s.work_count = 0 THEN 'draft' WHEN s.approved_count = s.work_count THEN 'completed' ELSE 'reviewing' END, review_completed_at = CASE WHEN p.status != 'archived' AND s.work_count > 0 AND s.approved_count = s.work_count THEN COALESCE(p.review_completed_at, NOW()) ELSE NULL END FROM ( SELECT project_id, COUNT(*) FILTER (WHERE review_status != 'draft') AS work_count, COUNT(*) FILTER (WHERE review_status = 'approved') AS approved_count FROM notes GROUP BY project_id ) s WHERE p.id = s.project_id; UPDATE projects p SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' ELSE 'draft' END, review_completed_at = NULL WHERE NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = p.id AND n.review_status != 'draft'); -- PROJECT_REVIEW_STATUS_REPAIR_END COMMIT;