Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
DROP TABLE IF EXISTS device_simulations;
DROP TABLE IF EXISTS idempotency_keys;
DROP TABLE IF EXISTS audit_entries;
DROP TABLE IF EXISTS call_batch_tickets;
DROP TABLE IF EXISTS call_batches;
DROP TABLE IF EXISTS queue_tickets;
DROP TABLE IF EXISTS queue_sessions;
DROP TABLE IF EXISTS auth_sessions;
DROP TABLE IF EXISTS user_projects;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS users;

View File

@@ -0,0 +1,234 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username varchar(80) NOT NULL,
password_hash text NOT NULL,
role varchar(16) NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_username_format CHECK (username = lower(username) AND username ~ '^[a-z0-9][a-z0-9._-]{2,79}$'),
CONSTRAINT users_role_check CHECK (role IN ('ADMIN', 'STAFF')),
CONSTRAINT users_username_unique UNIQUE (username)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(24) NOT NULL,
name varchar(120) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'RUNNING',
timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai',
ticket_prefix varchar(8) NOT NULL DEFAULT 'A',
call_batch_size integer NOT NULL DEFAULT 5,
grace_period_minutes integer NOT NULL DEFAULT 5,
eta_mode varchar(24) NOT NULL DEFAULT 'FIXED_BATCH',
average_batch_interval_seconds integer NOT NULL DEFAULT 600,
continuous_rate_per_minute numeric(10,2) NOT NULL DEFAULT 2.0,
eta_buffer_minutes integer NOT NULL DEFAULT 5,
display_token_hash char(64),
device_simulation_mode varchar(16) NOT NULL DEFAULT 'SUCCESS',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT projects_code_format CHECK (code = upper(code) AND code ~ '^[A-Z0-9][A-Z0-9_-]{1,23}$'),
CONSTRAINT projects_code_unique UNIQUE (code),
CONSTRAINT projects_status_check CHECK (status IN ('NOT_OPEN', 'RUNNING', 'PAUSED', 'ENDED')),
CONSTRAINT projects_ticket_prefix_check CHECK (ticket_prefix ~ '^[A-Z0-9]{1,8}$'),
CONSTRAINT projects_batch_size_check CHECK (call_batch_size BETWEEN 1 AND 100),
CONSTRAINT projects_grace_check CHECK (grace_period_minutes BETWEEN 0 AND 120),
CONSTRAINT projects_eta_mode_check CHECK (eta_mode IN ('FIXED_BATCH', 'CONTINUOUS')),
CONSTRAINT projects_batch_interval_check CHECK (average_batch_interval_seconds BETWEEN 1 AND 86400),
CONSTRAINT projects_rate_check CHECK (continuous_rate_per_minute > 0 AND continuous_rate_per_minute <= 10000),
CONSTRAINT projects_eta_buffer_check CHECK (eta_buffer_minutes BETWEEN 0 AND 1440),
CONSTRAINT projects_display_token_hash_check CHECK (display_token_hash IS NULL OR display_token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT projects_display_token_hash_unique UNIQUE (display_token_hash),
CONSTRAINT projects_device_mode_check CHECK (device_simulation_mode IN ('DISABLED', 'SUCCESS', 'FAILURE'))
);
CREATE TABLE user_projects (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, project_id)
);
CREATE TABLE auth_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash char(64) NOT NULL,
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz,
created_ip inet,
user_agent varchar(512) NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT auth_sessions_token_hash_check CHECK (token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT auth_sessions_token_hash_unique UNIQUE (token_hash)
);
CREATE INDEX auth_sessions_user_active_idx
ON auth_sessions (user_id, expires_at)
WHERE revoked_at IS NULL;
CREATE TABLE queue_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
business_date date NOT NULL,
status varchar(16) NOT NULL DEFAULT 'RUNNING',
next_ticket_number integer NOT NULL DEFAULT 1,
revision bigint NOT NULL DEFAULT 0,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT queue_sessions_status_check CHECK (status IN ('RUNNING', 'PAUSED', 'ENDED')),
CONSTRAINT queue_sessions_next_ticket_check CHECK (next_ticket_number > 0),
CONSTRAINT queue_sessions_revision_check CHECK (revision >= 0),
CONSTRAINT queue_sessions_project_date_unique UNIQUE (project_id, business_date),
CONSTRAINT queue_sessions_id_project_unique UNIQUE (id, project_id)
);
CREATE TABLE queue_tickets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
queue_session_id uuid NOT NULL,
ticket_number integer NOT NULL,
display_number varchar(24) NOT NULL,
public_token_hash char(64) NOT NULL,
phone_ciphertext bytea NOT NULL,
phone_nonce bytea NOT NULL,
phone_hmac char(64) NOT NULL,
last_name_ciphertext bytea,
last_name_nonce bytea,
honorific varchar(16) NOT NULL DEFAULT '游客',
status varchar(16) NOT NULL DEFAULT 'WAITING',
joined_at timestamptz NOT NULL DEFAULT now(),
called_at timestamptz,
arrived_at timestamptz,
completed_at timestamptz,
missed_at timestamptz,
personal_data_purge_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
created_by uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT queue_tickets_session_project_fk FOREIGN KEY (queue_session_id, project_id)
REFERENCES queue_sessions(id, project_id) ON DELETE RESTRICT,
CONSTRAINT queue_tickets_number_check CHECK (ticket_number > 0),
CONSTRAINT queue_tickets_public_token_hash_check CHECK (public_token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT queue_tickets_phone_nonce_check CHECK (octet_length(phone_nonce) = 12),
CONSTRAINT queue_tickets_phone_hmac_check CHECK (phone_hmac ~ '^[0-9a-f]{64}$'),
CONSTRAINT queue_tickets_last_name_pair_check CHECK (
(last_name_ciphertext IS NULL AND last_name_nonce IS NULL)
OR (last_name_ciphertext IS NOT NULL AND octet_length(last_name_nonce) = 12)
),
CONSTRAINT queue_tickets_honorific_check CHECK (honorific IN ('游客', '先生', '女士')),
CONSTRAINT queue_tickets_status_check CHECK (status IN ('WAITING', 'CALLED', 'ARRIVED', 'COMPLETED', 'MISSED', 'CANCELED')),
CONSTRAINT queue_tickets_project_display_unique UNIQUE (project_id, queue_session_id, display_number),
CONSTRAINT queue_tickets_project_number_unique UNIQUE (project_id, queue_session_id, ticket_number),
CONSTRAINT queue_tickets_public_token_unique UNIQUE (public_token_hash),
CONSTRAINT queue_tickets_id_project_unique UNIQUE (id, project_id)
);
CREATE INDEX queue_tickets_fifo_idx
ON queue_tickets (project_id, queue_session_id, ticket_number)
WHERE status = 'WAITING';
CREATE INDEX queue_tickets_active_phone_idx
ON queue_tickets (project_id, phone_hmac, joined_at DESC)
WHERE status IN ('WAITING', 'CALLED', 'ARRIVED');
CREATE TABLE call_batches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
queue_session_id uuid NOT NULL,
batch_sequence integer NOT NULL,
revision bigint NOT NULL,
status varchar(16) NOT NULL DEFAULT 'CALLED',
requested_by uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
called_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT call_batches_session_project_fk FOREIGN KEY (queue_session_id, project_id)
REFERENCES queue_sessions(id, project_id) ON DELETE RESTRICT,
CONSTRAINT call_batches_sequence_check CHECK (batch_sequence > 0),
CONSTRAINT call_batches_revision_check CHECK (revision > 0),
CONSTRAINT call_batches_status_check CHECK (status IN ('CALLED', 'COMPLETED')),
CONSTRAINT call_batches_session_sequence_unique UNIQUE (queue_session_id, batch_sequence),
CONSTRAINT call_batches_session_revision_unique UNIQUE (queue_session_id, revision),
CONSTRAINT call_batches_id_project_unique UNIQUE (id, project_id)
);
CREATE TABLE call_batch_tickets (
call_batch_id uuid NOT NULL,
ticket_id uuid NOT NULL,
project_id uuid NOT NULL,
position integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (call_batch_id, ticket_id),
CONSTRAINT call_batch_tickets_batch_project_fk FOREIGN KEY (call_batch_id, project_id)
REFERENCES call_batches(id, project_id) ON DELETE CASCADE,
CONSTRAINT call_batch_tickets_ticket_project_fk FOREIGN KEY (ticket_id, project_id)
REFERENCES queue_tickets(id, project_id) ON DELETE RESTRICT,
CONSTRAINT call_batch_tickets_position_check CHECK (position > 0),
CONSTRAINT call_batch_tickets_ticket_once_unique UNIQUE (ticket_id),
CONSTRAINT call_batch_tickets_position_unique UNIQUE (call_batch_id, position)
);
CREATE TABLE audit_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid REFERENCES projects(id) ON DELETE RESTRICT,
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
action varchar(64) NOT NULL,
entity_type varchar(64) NOT NULL,
entity_id uuid,
details jsonb NOT NULL DEFAULT '{}'::jsonb,
request_id varchar(80) NOT NULL DEFAULT '',
ip_address inet,
user_agent varchar(512) NOT NULL DEFAULT '',
retain_until timestamptz NOT NULL DEFAULT (now() + interval '1 year'),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX audit_entries_project_created_idx
ON audit_entries (project_id, created_at DESC);
CREATE INDEX audit_entries_actor_created_idx
ON audit_entries (actor_user_id, created_at DESC);
CREATE TABLE idempotency_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope varchar(40) NOT NULL,
key varchar(128) NOT NULL,
request_hash char(64) NOT NULL,
response_code integer NOT NULL,
response_body jsonb NOT NULL,
resource_id uuid,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT idempotency_request_hash_check CHECK (request_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT idempotency_response_code_check CHECK (response_code BETWEEN 200 AND 599),
CONSTRAINT idempotency_scope_key_unique UNIQUE (project_id, user_id, scope, key)
);
CREATE INDEX idempotency_keys_expiry_idx ON idempotency_keys (expires_at);
CREATE TABLE device_simulations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
call_batch_id uuid NOT NULL,
adapter varchar(40) NOT NULL DEFAULT 'SIMULATOR',
outcome varchar(16) NOT NULL,
detail varchar(500) NOT NULL DEFAULT '',
attempted_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT device_simulations_batch_project_fk FOREIGN KEY (call_batch_id, project_id)
REFERENCES call_batches(id, project_id) ON DELETE CASCADE,
CONSTRAINT device_simulations_outcome_check CHECK (outcome IN ('SUCCESS', 'FAILURE', 'SKIPPED')),
CONSTRAINT device_simulations_batch_unique UNIQUE (call_batch_id)
);
CREATE INDEX device_simulations_project_created_idx
ON device_simulations (project_id, created_at DESC);

View File

@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS queue_tickets_reissued_from_idx;
ALTER TABLE queue_tickets
DROP CONSTRAINT IF EXISTS queue_tickets_reissued_from_once_unique,
DROP CONSTRAINT IF EXISTS queue_tickets_reissued_from_not_self_check,
DROP CONSTRAINT IF EXISTS queue_tickets_reissued_from_project_fk,
DROP COLUMN IF EXISTS reissued_from_ticket_id;

View File

@@ -0,0 +1,16 @@
ALTER TABLE queue_tickets
ADD COLUMN reissued_from_ticket_id uuid;
ALTER TABLE queue_tickets
ADD CONSTRAINT queue_tickets_reissued_from_project_fk
FOREIGN KEY (reissued_from_ticket_id, project_id)
REFERENCES queue_tickets(id, project_id)
ON DELETE RESTRICT,
ADD CONSTRAINT queue_tickets_reissued_from_not_self_check
CHECK (reissued_from_ticket_id IS NULL OR reissued_from_ticket_id <> id),
ADD CONSTRAINT queue_tickets_reissued_from_once_unique
UNIQUE (reissued_from_ticket_id);
CREATE INDEX queue_tickets_reissued_from_idx
ON queue_tickets (project_id, reissued_from_ticket_id)
WHERE reissued_from_ticket_id IS NOT NULL;

View File

@@ -0,0 +1,4 @@
UPDATE queue_tickets AS ticket
SET display_number = project.ticket_prefix || lpad(ticket.ticket_number::text, 4, '0')
FROM projects AS project
WHERE project.id = ticket.project_id;

View File

@@ -0,0 +1,3 @@
UPDATE queue_tickets
SET display_number = lpad(ticket_number::text, 5, '0')
WHERE display_number <> lpad(ticket_number::text, 5, '0');

View File

@@ -0,0 +1,5 @@
ALTER TABLE projects
DROP CONSTRAINT IF EXISTS projects_visitor_notice_length_check;
ALTER TABLE projects
DROP COLUMN IF EXISTS visitor_notice;

View File

@@ -0,0 +1,6 @@
ALTER TABLE projects
ADD COLUMN visitor_notice varchar(240) NOT NULL DEFAULT '请您在景区附近等候,注意听从工作人员指引。';
ALTER TABLE projects
ADD CONSTRAINT projects_visitor_notice_length_check
CHECK (char_length(visitor_notice) <= 240);

View File

@@ -0,0 +1,2 @@
ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_eta_interval_check;
ALTER TABLE projects DROP COLUMN IF EXISTS eta_interval_seconds;

View File

@@ -0,0 +1,13 @@
ALTER TABLE projects
ADD COLUMN eta_interval_seconds integer;
UPDATE projects
SET eta_interval_seconds = CASE
WHEN eta_mode = 'CONTINUOUS' THEN GREATEST(1, ROUND(60 / continuous_rate_per_minute)::integer)
ELSE GREATEST(1, ROUND(average_batch_interval_seconds::numeric / call_batch_size)::integer)
END;
ALTER TABLE projects
ALTER COLUMN eta_interval_seconds SET DEFAULT 60,
ALTER COLUMN eta_interval_seconds SET NOT NULL,
ADD CONSTRAINT projects_eta_interval_check CHECK (eta_interval_seconds BETWEEN 1 AND 86400);

View File

@@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS users_protect_super_admin ON users;
DROP FUNCTION IF EXISTS protect_super_admin();
DELETE FROM users WHERE username = 'xqkwljtadmin';

View File

@@ -0,0 +1,23 @@
INSERT INTO users (username, password_hash, role, active)
VALUES ('xqkwljtadmin', '$2y$12$6ETuQSdI3Z3os3LaCc0RXemu6awiXkNglHdMX.cTM82/tnBXIAdp6', 'ADMIN', true)
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
role = 'ADMIN',
active = true,
updated_at = now();
CREATE OR REPLACE FUNCTION protect_super_admin() RETURNS trigger AS $$
BEGIN
IF OLD.username = 'xqkwljtadmin' THEN
RAISE EXCEPTION 'protected super administrator cannot be updated or deleted';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_protect_super_admin
BEFORE UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION protect_super_admin();

View File

@@ -0,0 +1,12 @@
DROP INDEX IF EXISTS queue_tickets_personal_purge_idx;
ALTER TABLE queue_tickets
DROP CONSTRAINT IF EXISTS queue_tickets_phone_pair_check,
ADD CONSTRAINT queue_tickets_phone_nonce_check CHECK (octet_length(phone_nonce) = 12),
ADD CONSTRAINT queue_tickets_phone_hmac_check CHECK (phone_hmac ~ '^[0-9a-f]{64}$');
ALTER TABLE queue_tickets
ALTER COLUMN phone_ciphertext SET NOT NULL,
ALTER COLUMN phone_nonce SET NOT NULL,
ALTER COLUMN phone_hmac SET NOT NULL,
DROP COLUMN IF EXISTS personal_data_purged_at;

View File

@@ -0,0 +1,24 @@
ALTER TABLE queue_tickets
ADD COLUMN personal_data_purged_at timestamptz,
ALTER COLUMN phone_ciphertext DROP NOT NULL,
ALTER COLUMN phone_nonce DROP NOT NULL,
ALTER COLUMN phone_hmac DROP NOT NULL;
ALTER TABLE queue_tickets
DROP CONSTRAINT queue_tickets_phone_nonce_check,
DROP CONSTRAINT queue_tickets_phone_hmac_check,
ADD CONSTRAINT queue_tickets_phone_pair_check CHECK (
(phone_ciphertext IS NULL AND phone_nonce IS NULL AND phone_hmac IS NULL)
OR (
phone_ciphertext IS NOT NULL
AND phone_nonce IS NOT NULL
AND octet_length(phone_nonce) = 12
AND phone_hmac IS NOT NULL
AND phone_hmac ~ '^[0-9a-f]{64}$'
)
);
CREATE INDEX queue_tickets_personal_purge_idx
ON queue_tickets (personal_data_purge_at, id)
WHERE status IN ('COMPLETED', 'MISSED', 'CANCELED')
AND personal_data_purged_at IS NULL;

View File

@@ -0,0 +1,8 @@
CREATE OR REPLACE FUNCTION protect_super_admin() RETURNS trigger AS $$
BEGIN
IF OLD.username = 'xqkwljtadmin' THEN
RAISE EXCEPTION 'protected super administrator cannot be updated or deleted';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,17 @@
CREATE OR REPLACE FUNCTION protect_super_admin() RETURNS trigger AS $$
BEGIN
IF OLD.username = 'xqkwljtadmin' THEN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'protected super administrator cannot be deleted';
END IF;
IF NEW.username <> OLD.username
OR NEW.role <> OLD.role
OR NEW.active <> OLD.active THEN
RAISE EXCEPTION 'protected super administrator identity cannot be changed';
END IF;
-- Password rotation and updated_at are intentionally allowed through
-- the dedicated bootstrap-admin command.
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,9 @@
package migrations
import "embed"
// Files contains the immutable, versioned database migrations shipped with the
// API binary.
//
//go:embed *.sql
var Files embed.FS