Files
delivery-desk/db/postgres/schema.sql
yuzhe 6091d61612 feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收
- 保留历史轮次只读并兼容单候选稿版本接口
- 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
2026-07-21 20:25:52 +08:00

285 lines
12 KiB
PL/PgSQL

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),
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;
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')),
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,
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';
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 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);
-- 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
COMMIT;