diff --git a/docs/schema-postgres.sql b/docs/schema-postgres.sql index f81b44a..76fab83 100644 --- a/docs/schema-postgres.sql +++ b/docs/schema-postgres.sql @@ -1,8 +1,4 @@ --- PostgreSQL version of schema-mariadb.sql --- PostgreSQL 里通常先手动创建数据库,再连接进去执行下面脚本: --- CREATE DATABASE kepan; --- \c kepan -- ========================= -- Enum types @@ -30,11 +26,53 @@ BEGIN END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_part_status_enum') THEN - CREATE TYPE upload_part_status_enum AS ENUM ('pending', 'uploaded'); + CREATE TYPE upload_part_status_enum AS ENUM ('pending', 'uploaded', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status_enum') THEN + CREATE TYPE user_status_enum AS ENUM ('pending_verification', 'active', 'locked', 'disabled'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'folder_type_enum') THEN + CREATE TYPE folder_type_enum AS ENUM ('normal', 'root', 'system'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_status_enum') THEN + CREATE TYPE share_status_enum AS ENUM ('active', 'expired', 'revoked', 'deleted'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_member_status_enum') THEN + CREATE TYPE share_member_status_enum AS ENUM ('pending', 'accepted', 'rejected', 'revoked'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'favorite_item_type_enum') THEN + CREATE TYPE favorite_item_type_enum AS ENUM ('file', 'folder'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'view_mode_enum') THEN + CREATE TYPE view_mode_enum AS ENUM ('list', 'grid'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_by_enum') THEN + CREATE TYPE sort_by_enum AS ENUM ('name', 'size', 'created_at', 'updated_at', 'last_accessed_at'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_direction_enum') THEN + CREATE TYPE sort_direction_enum AS ENUM ('asc', 'desc'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'preview_status_enum') THEN + CREATE TYPE preview_status_enum AS ENUM ('pending', 'ready', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scan_result_enum') THEN + CREATE TYPE scan_result_enum AS ENUM ('pending', 'clean', 'infected', 'blocked', 'failed'); END IF; END $$; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + -- ========================= -- updated_at trigger -- ========================= @@ -55,14 +93,25 @@ CREATE TABLE IF NOT EXISTS "user" ( email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, role VARCHAR(50) NOT NULL DEFAULT 'USER', + status user_status_enum NOT NULL DEFAULT 'active', + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + email_verified_at TIMESTAMP NULL, storage_limit BIGINT NOT NULL DEFAULT 10737418240, storage_used BIGINT NOT NULL DEFAULT 0, + last_login_at TIMESTAMP NULL, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMP NULL, + password_changed_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_username UNIQUE (username), - CONSTRAINT uk_email UNIQUE (email) + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_username_ci ON "user" ((LOWER(username))); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_email_ci ON "user" ((LOWER(email))); +CREATE INDEX IF NOT EXISTS idx_user_status ON "user" (status); +CREATE INDEX IF NOT EXISTS idx_user_locked_until ON "user" (locked_until); + CREATE TRIGGER trg_user_updated_at BEFORE UPDATE ON "user" FOR EACH ROW @@ -92,7 +141,8 @@ CREATE TABLE IF NOT EXISTS user_group_member ( -- ========================= CREATE TABLE IF NOT EXISTS storage_object ( object_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - object_hash CHAR(64) NOT NULL, + object_hash CHAR(64), + hash_algorithm VARCHAR(32) NOT NULL DEFAULT 'sha256', bucket_name VARCHAR(100) NOT NULL, object_key VARCHAR(1024) NOT NULL, object_size BIGINT NOT NULL, @@ -101,14 +151,25 @@ CREATE TABLE IF NOT EXISTS storage_object ( content_type VARCHAR(255), storage_class VARCHAR(50), upload_status upload_status_enum NOT NULL DEFAULT 'active', + scan_status scan_result_enum NOT NULL DEFAULT 'pending', + moderation_status VARCHAR(32) NOT NULL DEFAULT 'pending', + quarantined_at TIMESTAMP NULL, + last_scanned_at TIMESTAMP NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, ref_count INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - CONSTRAINT uk_object_hash UNIQUE (object_hash), CONSTRAINT uk_bucket_object_key UNIQUE (bucket_name, object_key) ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_storage_object_hash_algo_size + ON storage_object (hash_algorithm, object_hash, object_size) + WHERE object_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_storage_object_scan_status ON storage_object (scan_status); +CREATE INDEX IF NOT EXISTS idx_storage_object_moderation_status ON storage_object (moderation_status); + CREATE INDEX IF NOT EXISTS idx_storage_object_upload_status ON storage_object(upload_status); @@ -125,16 +186,30 @@ CREATE TABLE IF NOT EXISTS folder ( owner_id BIGINT NOT NULL, parent_folder_id BIGINT NULL, folder_name VARCHAR(255) NOT NULL, - size BIGINT NOT NULL DEFAULT 0, + cached_size BIGINT NOT NULL DEFAULT 0, status folder_status_enum NOT NULL DEFAULT 'active', + folder_type folder_type_enum NOT NULL DEFAULT 'normal', + deleted_by BIGINT NULL, + restored_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, CONSTRAINT fk_folder_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE, CONSTRAINT fk_folder_parent FOREIGN KEY (parent_folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, - CONSTRAINT uk_folder_name_in_parent UNIQUE (parent_folder_id, folder_name, owner_id, status) + CONSTRAINT fk_folder_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_root_name_active + ON folder (owner_id, folder_name) + WHERE parent_folder_id IS NULL AND status = 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_child_name_active + ON folder (owner_id, parent_folder_id, folder_name) + WHERE parent_folder_id IS NOT NULL AND status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_folder_owner_parent_status ON folder (owner_id, parent_folder_id, status); +CREATE INDEX IF NOT EXISTS idx_folder_name_trgm ON folder USING gin ((LOWER(folder_name)) gin_trgm_ops); + CREATE TRIGGER trg_folder_updated_at BEFORE UPDATE ON folder FOR EACH ROW @@ -155,6 +230,9 @@ CREATE TABLE IF NOT EXISTS "file" ( file_size BIGINT NOT NULL, is_latest BOOLEAN NOT NULL DEFAULT TRUE, status file_status_enum NOT NULL DEFAULT 'active', + deleted_by BIGINT NULL, + restored_at TIMESTAMP NULL, + last_accessed_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, @@ -162,9 +240,16 @@ CREATE TABLE IF NOT EXISTS "file" ( CONSTRAINT fk_file_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE, CONSTRAINT fk_file_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, CONSTRAINT fk_file_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id), - CONSTRAINT uk_file_name_in_folder UNIQUE (folder_id, file_name, owner_id, status) + CONSTRAINT fk_file_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_file_name_active_in_folder + ON "file" (owner_id, folder_id, file_name) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_file_last_accessed_at ON "file" (last_accessed_at); +CREATE INDEX IF NOT EXISTS idx_file_name_trgm ON "file" USING gin ((LOWER(file_name)) gin_trgm_ops); + CREATE INDEX IF NOT EXISTS idx_file_storage_object_id ON "file"(storage_object_id); @@ -186,17 +271,45 @@ CREATE TABLE IF NOT EXISTS acl ( user_id BIGINT NULL, group_id BIGINT NULL, permission VARCHAR(100) NOT NULL, + permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer', + can_preview BOOLEAN NOT NULL DEFAULT TRUE, + can_download BOOLEAN NOT NULL DEFAULT TRUE, + can_save BOOLEAN NOT NULL DEFAULT FALSE, + can_share BOOLEAN NOT NULL DEFAULT FALSE, + expire_at TIMESTAMP NULL, + granted_by BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_acl_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, CONSTRAINT fk_acl_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, CONSTRAINT fk_acl_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, CONSTRAINT fk_acl_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE, - CONSTRAINT chk_acl_target CHECK ( - (user_id IS NOT NULL OR group_id IS NOT NULL) - AND - (file_id IS NOT NULL OR folder_id IS NOT NULL) - ) + CONSTRAINT fk_acl_granted_by FOREIGN KEY (granted_by) REFERENCES "user"(user_id) ON DELETE SET NULL, + CONSTRAINT chk_acl_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1), + CONSTRAINT chk_acl_resource_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1) ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_user + ON acl (file_id, user_id) + WHERE file_id IS NOT NULL AND user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_group + ON acl (file_id, group_id) + WHERE file_id IS NOT NULL AND group_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_user + ON acl (folder_id, user_id) + WHERE folder_id IS NOT NULL AND user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_group + ON acl (folder_id, group_id) + WHERE folder_id IS NOT NULL AND group_id IS NOT NULL; + +CREATE TRIGGER trg_acl_updated_at +BEFORE UPDATE ON acl +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + -- ========================= -- 分享表 -- ========================= @@ -207,47 +320,99 @@ CREATE TABLE IF NOT EXISTS share ( file_id BIGINT NULL, folder_id BIGINT NULL, share_link VARCHAR(255) NOT NULL UNIQUE, + share_code VARCHAR(64) NOT NULL, + status share_status_enum NOT NULL DEFAULT 'active', share_type VARCHAR(50) NOT NULL DEFAULT 'public', + permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer', + allow_preview BOOLEAN NOT NULL DEFAULT TRUE, + allow_download BOOLEAN NOT NULL DEFAULT TRUE, + allow_save BOOLEAN NOT NULL DEFAULT FALSE, + allow_reshare BOOLEAN NOT NULL DEFAULT FALSE, + require_login BOOLEAN NOT NULL DEFAULT FALSE, + max_visits INTEGER NULL, + max_downloads INTEGER NULL, password_hash VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TIMESTAMP NULL, expire_time TIMESTAMP NULL, visit_count INTEGER NOT NULL DEFAULT 0, download_count INTEGER NOT NULL DEFAULT 0, CONSTRAINT fk_share_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, CONSTRAINT fk_share_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, CONSTRAINT fk_share_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, - CONSTRAINT chk_share_target CHECK ( - (file_id IS NOT NULL AND folder_id IS NULL) - OR - (file_id IS NULL AND folder_id IS NOT NULL) + CONSTRAINT chk_share_target CHECK (num_nonnulls(file_id, folder_id) = 1), + CONSTRAINT chk_share_limits CHECK ( + (max_visits IS NULL OR max_visits >= 0) + AND + (max_downloads IS NULL OR max_downloads >= 0) ) ); +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_code ON share (share_code); +CREATE INDEX IF NOT EXISTS idx_share_user_status ON share (user_id, status); +CREATE INDEX IF NOT EXISTS idx_share_expire_time ON share (expire_time); + +CREATE TRIGGER trg_share_updated_at +BEFORE UPDATE ON share +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + -- ========================= -- 日志表 -- ========================= CREATE TABLE IF NOT EXISTS "log" ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - user_id BIGINT NOT NULL, + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NULL, + actor_type VARCHAR(50) NOT NULL DEFAULT 'user', operation VARCHAR(255) NOT NULL, + target_type VARCHAR(50) NULL, + target_id BIGINT NULL, + result VARCHAR(20) NOT NULL DEFAULT 'success', + request_id VARCHAR(128) NULL, + user_agent VARCHAR(255) NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, details TEXT, ip_address VARCHAR(45), performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE + CONSTRAINT fk_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL ); +CREATE INDEX IF NOT EXISTS idx_log_performed_at ON "log" (performed_at); +CREATE INDEX IF NOT EXISTS idx_log_user_operation ON "log" (user_id, operation); +CREATE INDEX IF NOT EXISTS idx_log_target ON "log" (target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_log_request_id ON "log" (request_id); + -- ========================= -- 通知表 -- ========================= CREATE TABLE IF NOT EXISTS notification ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL, - message VARCHAR(255) NOT NULL, + title VARCHAR(255) NULL, + type VARCHAR(50) NOT NULL DEFAULT 'system', + channel VARCHAR(50) NOT NULL DEFAULT 'in_app', + message TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + sent_at TIMESTAMP NULL, is_read BOOLEAN DEFAULT FALSE, + read_at TIMESTAMP NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + sender_user_id BIGINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_notification_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notification_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_notification_sender_user FOREIGN KEY (sender_user_id) REFERENCES "user"(user_id) ON DELETE SET NULL ); +CREATE INDEX IF NOT EXISTS idx_notification_user_status ON notification (user_id, status, is_read); +CREATE INDEX IF NOT EXISTS idx_notification_created_at ON notification (created_at); + +CREATE TRIGGER trg_notification_updated_at +BEFORE UPDATE ON notification +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + -- ========================= -- 视图 v_file_folder_details -- ========================= @@ -272,7 +437,7 @@ SELECT 'folder' AS item_type, fo.folder_id AS id, fo.folder_name AS name, - fo.size, + fo.cached_size AS size, 'inode/directory' AS mime_type, fo.parent_folder_id AS parent_id, fo.owner_id AS owner_id, @@ -403,7 +568,7 @@ SELECT 'folder' AS item_type, fo.folder_id AS id, fo.folder_name AS name, - fo.size, + fo.cached_size AS size, fo.parent_folder_id AS parent_id, fo.owner_id AS owner_id, u.username AS owner_name, @@ -418,23 +583,38 @@ WHERE fo.status = 'deleted' AND fo.deleted_at IS NOT NULL; CREATE TABLE IF NOT EXISTS upload_task ( task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL, + folder_id BIGINT NULL, + file_name VARCHAR(255) NULL, + mime_type VARCHAR(255) NULL, bucket_name VARCHAR(100) NOT NULL, object_key VARCHAR(1024) NOT NULL, object_hash CHAR(64), total_size BIGINT NOT NULL, + chunk_size BIGINT NULL, + uploaded_bytes BIGINT NOT NULL DEFAULT 0, + client_file_id VARCHAR(255) NULL, upload_id VARCHAR(255), upload_mode upload_mode_enum NOT NULL DEFAULT 'single', status upload_task_status_enum NOT NULL DEFAULT 'init', + last_error TEXT NULL, + completed_at TIMESTAMP NULL, expired_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_upload_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_upload_task_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL, CONSTRAINT uk_upload_target UNIQUE (bucket_name, object_key, upload_id) ); CREATE INDEX IF NOT EXISTS idx_upload_task_user_status ON upload_task(user_id, status); +CREATE INDEX IF NOT EXISTS idx_upload_task_expired_at ON upload_task (expired_at); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_upload_task_user_client_file_id + ON upload_task (user_id, client_file_id) + WHERE client_file_id IS NOT NULL; + CREATE TRIGGER trg_upload_task_updated_at BEFORE UPDATE ON upload_task FOR EACH ROW @@ -450,13 +630,995 @@ CREATE TABLE IF NOT EXISTS upload_task_part ( etag VARCHAR(128), part_size BIGINT NOT NULL, status upload_part_status_enum NOT NULL DEFAULT 'pending', + checksum CHAR(64) NULL, + uploaded_at TIMESTAMP NULL, + retry_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_upload_task_part_task FOREIGN KEY (task_id) REFERENCES upload_task(task_id) ON DELETE CASCADE, CONSTRAINT uk_task_part UNIQUE (task_id, part_number) ); +CREATE INDEX IF NOT EXISTS idx_upload_task_part_task_status ON upload_task_part (task_id, status); + CREATE TRIGGER trg_upload_task_part_updated_at BEFORE UPDATE ON upload_task_part FOR EACH ROW -EXECUTE FUNCTION set_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_updated_at(); + +-- ========================= +-- 2) 新增表 +-- ========================= + +-- 密码找回 token +CREATE TABLE IF NOT EXISTS password_reset_token ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expire_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + requester_ip VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_password_reset_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_token_user_expire + ON password_reset_token (user_id, expire_at); + +-- 邮箱验证 token +CREATE TABLE IF NOT EXISTS email_verification_token ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expire_at TIMESTAMP NOT NULL, + verified_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_email_verification_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_email_verification_token_user_expire + ON email_verification_token (user_id, expire_at); + +-- 会话表 / refresh token +CREATE TABLE IF NOT EXISTS user_session ( + session_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + refresh_token_hash CHAR(64) NOT NULL UNIQUE, + client_type VARCHAR(50) NOT NULL DEFAULT 'web', + device_id VARCHAR(255) NULL, + device_name VARCHAR(255) NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + CONSTRAINT fk_user_session_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_session_user_expire + ON user_session (user_id, expire_at); + +-- 分享成员:支撑“分享给别人”和“接受他人分享” +CREATE TABLE IF NOT EXISTS share_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + share_id BIGINT NOT NULL, + user_id BIGINT NULL, + group_id BIGINT NULL, + status share_member_status_enum NOT NULL DEFAULT 'pending', + target_folder_id BIGINT NULL, + accepted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_share_member_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_target_folder FOREIGN KEY (target_folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL, + CONSTRAINT chk_share_member_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_user + ON share_member (share_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_group + ON share_member (share_id, group_id) + WHERE group_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_share_member_share_status ON share_member (share_id, status); + +DROP TRIGGER IF EXISTS trg_share_member_updated_at ON share_member; +CREATE TRIGGER trg_share_member_updated_at +BEFORE UPDATE ON share_member +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 星标:文件、文件夹都可加星 +CREATE TABLE IF NOT EXISTS favorite_item ( + favorite_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + item_type favorite_item_type_enum NOT NULL, + file_id BIGINT NULL, + folder_id BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_favorite_item_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_item_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_item_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT chk_favorite_item_target_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1), + CONSTRAINT chk_favorite_item_type_match CHECK ( + (item_type = 'file' AND file_id IS NOT NULL AND folder_id IS NULL) + OR + (item_type = 'folder' AND folder_id IS NOT NULL AND file_id IS NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_file + ON favorite_item (user_id, file_id) + WHERE file_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_folder + ON favorite_item (user_id, folder_id) + WHERE folder_id IS NOT NULL; + +-- 目录视图偏好:全局默认 + 每个目录覆盖 +CREATE TABLE IF NOT EXISTS user_folder_preference ( + preference_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + folder_id BIGINT NULL, + view_mode view_mode_enum NOT NULL DEFAULT 'list', + sort_by sort_by_enum NOT NULL DEFAULT 'name', + sort_direction sort_direction_enum NOT NULL DEFAULT 'asc', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_folder_preference_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_user_folder_preference_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_default + ON user_folder_preference (user_id) + WHERE folder_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_folder + ON user_folder_preference (user_id, folder_id) + WHERE folder_id IS NOT NULL; + +DROP TRIGGER IF EXISTS trg_user_folder_preference_updated_at ON user_folder_preference; +CREATE TRIGGER trg_user_folder_preference_updated_at +BEFORE UPDATE ON user_folder_preference +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 预览产物:图片缩略图、PDF 页图、视频预览等 +CREATE TABLE IF NOT EXISTS file_preview_asset ( + preview_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + source_object_id BIGINT NOT NULL, + preview_object_id BIGINT NULL, + preview_type VARCHAR(50) NOT NULL, + page_no INTEGER NULL, + mime_type VARCHAR(255) NULL, + width INTEGER NULL, + height INTEGER NULL, + duration_ms BIGINT NULL, + status preview_status_enum NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_file_preview_asset_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE, + CONSTRAINT fk_file_preview_asset_preview_object FOREIGN KEY (preview_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_file_preview_asset_type_page + ON file_preview_asset (source_object_id, preview_type, (COALESCE(page_no, -1))); + +DROP TRIGGER IF EXISTS trg_file_preview_asset_updated_at ON file_preview_asset; +CREATE TRIGGER trg_file_preview_asset_updated_at +BEFORE UPDATE ON file_preview_asset +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 媒体元数据:预览 / 转码 / 播放所需 +CREATE TABLE IF NOT EXISTS file_media_metadata ( + metadata_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + source_object_id BIGINT NOT NULL UNIQUE, + width INTEGER NULL, + height INTEGER NULL, + duration_ms BIGINT NULL, + page_count INTEGER NULL, + bitrate INTEGER NULL, + sample_rate INTEGER NULL, + video_codec VARCHAR(64) NULL, + audio_codec VARCHAR(64) NULL, + extra_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + extracted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_file_media_metadata_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE +); + +-- 扫描结果:病毒 / 色情 / 其他违规检测 +CREATE TABLE IF NOT EXISTS object_scan_result ( + scan_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + object_id BIGINT NOT NULL, + scan_type VARCHAR(50) NOT NULL, + engine_name VARCHAR(100) NULL, + engine_version VARCHAR(100) NULL, + result scan_result_enum NOT NULL DEFAULT 'pending', + details JSONB NOT NULL DEFAULT '{}'::jsonb, + scanned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_object_scan_result_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_object_scan_result_object_scanned_at + ON object_scan_result (object_id, scanned_at DESC); + +-- 违规处理工单 +CREATE TABLE IF NOT EXISTS moderation_case ( + case_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + object_id BIGINT NOT NULL, + file_id BIGINT NULL, + reason_type VARCHAR(50) NOT NULL, + confidence NUMERIC(5,4) NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + resolution VARCHAR(50) NULL, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + handled_by BIGINT NULL, + handled_at TIMESTAMP NULL, + CONSTRAINT fk_moderation_case_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE, + CONSTRAINT fk_moderation_case_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE SET NULL, + CONSTRAINT fk_moderation_case_handled_by FOREIGN KEY (handled_by) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_moderation_case_status_created_at + ON moderation_case (status, created_at DESC); + +DROP TRIGGER IF EXISTS trg_moderation_case_updated_at ON moderation_case; +CREATE TRIGGER trg_moderation_case_updated_at +BEFORE UPDATE ON moderation_case +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 安全事件:限制滥用、审计风险行为 +CREATE TABLE IF NOT EXISTS security_event ( + event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NULL, + session_id BIGINT NULL, + event_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'info', + target_type VARCHAR(50) NULL, + target_id BIGINT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_security_event_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL, + CONSTRAINT fk_security_event_session FOREIGN KEY (session_id) REFERENCES user_session(session_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_security_event_user_occurred_at + ON security_event (user_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_security_event_type_occurred_at + ON security_event (event_type, occurred_at DESC); + +-- 批量下载任务:支持大目录异步打包下载 +CREATE TABLE IF NOT EXISTS batch_download_task ( + task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + archive_name VARCHAR(255) NOT NULL, + item_count INTEGER NOT NULL DEFAULT 0, + items JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + storage_object_id BIGINT NULL, + expire_at TIMESTAMP NULL, + error_message TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + CONSTRAINT fk_batch_download_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_batch_download_task_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_batch_download_task_user_status + ON batch_download_task (user_id, status); + +DROP TRIGGER IF EXISTS trg_batch_download_task_updated_at ON batch_download_task; +CREATE TRIGGER trg_batch_download_task_updated_at +BEFORE UPDATE ON batch_download_task +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 分享访问日志:访问 / 下载统计明细 +CREATE TABLE IF NOT EXISTS share_access_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + share_id BIGINT NOT NULL, + user_id BIGINT NULL, + event_type VARCHAR(20) NOT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + result VARCHAR(20) NOT NULL DEFAULT 'success', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_share_access_log_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE, + CONSTRAINT fk_share_access_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_share_access_log_share_created_at + ON share_access_log (share_id, created_at DESC); + +-- ========================= +-- 3) 触发器 / 维护函数 +-- ========================= + +-- 通知表:同步 is_read / read_at +CREATE OR REPLACE FUNCTION sync_notification_read_at() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.is_read AND NEW.read_at IS NULL THEN + NEW.read_at = CURRENT_TIMESTAMP; + ELSIF NOT NEW.is_read THEN + NEW.read_at = NULL; + END IF; + RETURN NEW; + END IF; + + IF NEW.is_read AND (OLD.is_read IS DISTINCT FROM NEW.is_read) AND NEW.read_at IS NULL THEN + NEW.read_at = CURRENT_TIMESTAMP; + ELSIF NOT NEW.is_read THEN + NEW.read_at = NULL; + ELSIF NEW.read_at IS NOT NULL THEN + NEW.is_read = TRUE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_notification_sync_read_at ON notification; +CREATE TRIGGER trg_notification_sync_read_at +BEFORE INSERT OR UPDATE ON notification +FOR EACH ROW +EXECUTE FUNCTION sync_notification_read_at(); + +-- 重建 / 维护用户已用空间 +CREATE OR REPLACE FUNCTION recalc_user_storage_used(p_user_id BIGINT) +RETURNS VOID AS $$ +BEGIN + IF p_user_id IS NULL THEN + RETURN; + END IF; + + UPDATE "user" u + SET storage_used = COALESCE(( + SELECT SUM(f.file_size) + FROM "file" f + WHERE f.owner_id = p_user_id + AND f.status = 'active' + ), 0) + WHERE u.user_id = p_user_id; +END; +$$ LANGUAGE plpgsql; + +-- 重建 / 维护物理对象引用数 +CREATE OR REPLACE FUNCTION recalc_storage_object_ref_count(p_object_id BIGINT) +RETURNS VOID AS $$ +BEGIN + IF p_object_id IS NULL THEN + RETURN; + END IF; + + UPDATE storage_object so + SET ref_count = COALESCE(( + SELECT COUNT(*) + FROM "file" f + WHERE f.storage_object_id = p_object_id + AND f.status = 'active' + ), 0) + WHERE so.object_id = p_object_id; +END; +$$ LANGUAGE plpgsql; + +-- 单个目录及其祖先目录大小回算(cached_size = 直属 active 文件大小 + 直属 active 子目录 cached_size) +CREATE OR REPLACE FUNCTION recalc_folder_cached_size(p_folder_id BIGINT) +RETURNS VOID AS $$ +DECLARE + v_folder_id BIGINT := p_folder_id; + v_parent_id BIGINT; +BEGIN + WHILE v_folder_id IS NOT NULL LOOP + UPDATE folder f + SET cached_size = + COALESCE(( + SELECT SUM(fi.file_size) + FROM "file" fi + WHERE fi.folder_id = f.folder_id + AND fi.status = 'active' + ), 0) + + + COALESCE(( + SELECT SUM(ch.cached_size) + FROM folder ch + WHERE ch.parent_folder_id = f.folder_id + AND ch.status = 'active' + ), 0) + WHERE f.folder_id = v_folder_id; + + SELECT parent_folder_id INTO v_parent_id + FROM folder + WHERE folder_id = v_folder_id; + + v_folder_id := v_parent_id; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 全量重建目录缓存大小 +CREATE OR REPLACE FUNCTION rebuild_all_folder_cached_size() +RETURNS VOID AS $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + WITH RECURSIVE tree AS ( + SELECT folder_id, parent_folder_id, 0 AS depth + FROM folder + WHERE parent_folder_id IS NULL + + UNION ALL + + SELECT f.folder_id, f.parent_folder_id, t.depth + 1 + FROM folder f + JOIN tree t ON f.parent_folder_id = t.folder_id + ) + SELECT folder_id + FROM tree + ORDER BY depth DESC, folder_id DESC + LOOP + PERFORM recalc_folder_cached_size(r.folder_id); + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 文件夹结构校验:同 owner、不能挂到自己的子孙目录下 +CREATE OR REPLACE FUNCTION check_folder_parent_valid() +RETURNS TRIGGER AS $$ +DECLARE + v_parent_owner BIGINT; + v_parent_status folder_status_enum; + v_has_cycle BOOLEAN := FALSE; +BEGIN + IF NEW.parent_folder_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT owner_id, status + INTO v_parent_owner, v_parent_status + FROM folder + WHERE folder_id = NEW.parent_folder_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Parent folder % does not exist', NEW.parent_folder_id; + END IF; + + IF v_parent_owner <> NEW.owner_id THEN + RAISE EXCEPTION 'Parent folder owner must match folder owner'; + END IF; + + IF NEW.status = 'active' AND v_parent_status <> 'active' THEN + RAISE EXCEPTION 'Active folder cannot be placed under a non-active parent folder'; + END IF; + + IF NEW.folder_id IS NOT NULL THEN + IF NEW.parent_folder_id = NEW.folder_id THEN + RAISE EXCEPTION 'Folder cannot be its own parent'; + END IF; + + WITH RECURSIVE descendants AS ( + SELECT folder_id + FROM folder + WHERE parent_folder_id = NEW.folder_id + + UNION ALL + + SELECT f.folder_id + FROM folder f + JOIN descendants d ON f.parent_folder_id = d.folder_id + ) + SELECT EXISTS( + SELECT 1 + FROM descendants + WHERE folder_id = NEW.parent_folder_id + ) + INTO v_has_cycle; + + IF v_has_cycle THEN + RAISE EXCEPTION 'Folder cannot be moved into its own descendant'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_folder_validate ON folder; +CREATE TRIGGER trg_folder_validate +BEFORE INSERT OR UPDATE OF parent_folder_id, owner_id, status ON folder +FOR EACH ROW +EXECUTE FUNCTION check_folder_parent_valid(); + +-- 文件落目录校验:active 文件必须落在 active 且同 owner 的目录下 +CREATE OR REPLACE FUNCTION check_file_folder_valid() +RETURNS TRIGGER AS $$ +DECLARE + v_folder_owner BIGINT; + v_folder_status folder_status_enum; +BEGIN + SELECT owner_id, status + INTO v_folder_owner, v_folder_status + FROM folder + WHERE folder_id = NEW.folder_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Folder % does not exist', NEW.folder_id; + END IF; + + IF NEW.status = 'active' THEN + IF v_folder_owner <> NEW.owner_id THEN + RAISE EXCEPTION 'File owner must match folder owner'; + END IF; + + IF v_folder_status <> 'active' THEN + RAISE EXCEPTION 'Active file cannot be placed under a non-active folder'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_file_validate ON "file"; +CREATE TRIGGER trg_file_validate +BEFORE INSERT OR UPDATE OF folder_id, owner_id, status ON "file" +FOR EACH ROW +EXECUTE FUNCTION check_file_folder_valid(); + +-- 文件变更后:回算 storage_object.ref_count、user.storage_used、folder.cached_size +CREATE OR REPLACE FUNCTION sync_file_derived_fields() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM recalc_storage_object_ref_count(NEW.storage_object_id); + PERFORM recalc_user_storage_used(NEW.owner_id); + PERFORM recalc_folder_cached_size(NEW.folder_id); + RETURN NULL; + ELSIF TG_OP = 'UPDATE' THEN + PERFORM recalc_storage_object_ref_count(OLD.storage_object_id); + IF NEW.storage_object_id IS DISTINCT FROM OLD.storage_object_id THEN + PERFORM recalc_storage_object_ref_count(NEW.storage_object_id); + END IF; + + PERFORM recalc_user_storage_used(OLD.owner_id); + IF NEW.owner_id IS DISTINCT FROM OLD.owner_id THEN + PERFORM recalc_user_storage_used(NEW.owner_id); + END IF; + + PERFORM recalc_folder_cached_size(OLD.folder_id); + IF NEW.folder_id IS DISTINCT FROM OLD.folder_id THEN + PERFORM recalc_folder_cached_size(NEW.folder_id); + END IF; + RETURN NULL; + ELSE + PERFORM recalc_storage_object_ref_count(OLD.storage_object_id); + PERFORM recalc_user_storage_used(OLD.owner_id); + PERFORM recalc_folder_cached_size(OLD.folder_id); + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_file_sync_derived_fields ON "file"; +CREATE TRIGGER trg_file_sync_derived_fields +AFTER INSERT OR UPDATE OR DELETE ON "file" +FOR EACH ROW +EXECUTE FUNCTION sync_file_derived_fields(); + +-- 目录结构变化后:回算目录大小 +CREATE OR REPLACE FUNCTION sync_folder_cached_size() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM recalc_folder_cached_size(NEW.folder_id); + PERFORM recalc_folder_cached_size(NEW.parent_folder_id); + RETURN NULL; + ELSIF TG_OP = 'UPDATE' THEN + PERFORM recalc_folder_cached_size(OLD.parent_folder_id); + PERFORM recalc_folder_cached_size(NEW.folder_id); + IF NEW.parent_folder_id IS DISTINCT FROM OLD.parent_folder_id THEN + PERFORM recalc_folder_cached_size(NEW.parent_folder_id); + END IF; + RETURN NULL; + ELSE + PERFORM recalc_folder_cached_size(OLD.parent_folder_id); + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_folder_sync_cached_size ON folder; +CREATE TRIGGER trg_folder_sync_cached_size +AFTER INSERT OR DELETE OR UPDATE OF parent_folder_id, status ON folder +FOR EACH ROW +EXECUTE FUNCTION sync_folder_cached_size(); + +-- ========================= +-- 4) 初始化缓存字段 +-- ========================= + +UPDATE storage_object so +SET ref_count = x.ref_count +FROM ( + SELECT storage_object_id, COUNT(*) AS ref_count + FROM "file" + WHERE status = 'active' + GROUP BY storage_object_id +) x +WHERE so.object_id = x.storage_object_id; + +UPDATE storage_object so +SET ref_count = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM "file" f + WHERE f.storage_object_id = so.object_id + AND f.status = 'active' +); + +UPDATE "user" u +SET storage_used = x.total_size +FROM ( + SELECT owner_id AS user_id, COALESCE(SUM(file_size), 0) AS total_size + FROM "file" + WHERE status = 'active' + GROUP BY owner_id +) x +WHERE u.user_id = x.user_id; + +UPDATE "user" u +SET storage_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM "file" f + WHERE f.owner_id = u.user_id + AND f.status = 'active' +); + +SELECT rebuild_all_folder_cached_size(); + +-- ========================= +-- 5) 视图重建 +-- ========================= +DROP VIEW IF EXISTS v_shared_with_me; +DROP VIEW IF EXISTS v_user_permissions; +DROP VIEW IF EXISTS v_user_recycle_bin; +DROP VIEW IF EXISTS v_full_path; +DROP VIEW IF EXISTS v_file_folder_details; +DROP VIEW IF EXISTS v_user_storage_summary; +DROP VIEW IF EXISTS v_admin_share_overview; +DROP VIEW IF EXISTS v_user_dashboard; + +-- 文件 / 文件夹统一明细视图 +CREATE VIEW v_file_folder_details AS +SELECT + 'file' AS item_type, + f.file_id AS id, + f.file_name AS name, + f.file_size AS size, + f.mime_type, + f.folder_id AS parent_id, + f.owner_id, + u.username AS owner_name, + f.created_at, + f.updated_at +FROM "file" f +JOIN "user" u ON f.owner_id = u.user_id +JOIN storage_object so ON f.storage_object_id = so.object_id +WHERE f.status = 'active' + AND so.upload_status = 'active' + +UNION ALL + +SELECT + 'folder' AS item_type, + fo.folder_id AS id, + fo.folder_name AS name, + fo.cached_size AS size, + 'inode/directory' AS mime_type, + fo.parent_folder_id AS parent_id, + fo.owner_id, + u.username AS owner_name, + fo.created_at, + fo.updated_at +FROM folder fo +JOIN "user" u ON fo.owner_id = u.user_id +WHERE fo.status = 'active'; + +-- 用户权限视图:同时展开 direct user 授权和 group 授权 +CREATE VIEW v_user_permissions AS +SELECT + acl.id AS acl_id, + u.user_id, + u.username AS user_name, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + acl.permission_role, + acl.can_preview, + acl.can_download, + acl.can_save, + acl.can_share, + acl.expire_at, + 'direct'::TEXT AS grant_source +FROM acl +JOIN "user" u ON acl.user_id = u.user_id +LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active' +WHERE acl.user_id IS NOT NULL + AND ( + (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ) + +UNION ALL + +SELECT + acl.id AS acl_id, + u.user_id, + u.username AS user_name, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + acl.permission_role, + acl.can_preview, + acl.can_download, + acl.can_save, + acl.can_share, + acl.expire_at, + 'group'::TEXT AS grant_source +FROM acl +JOIN user_group_member ugm ON acl.group_id = ugm.group_id +JOIN "user" u ON ugm.user_id = u.user_id +LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active' +WHERE acl.group_id IS NOT NULL + AND ( + (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ); + +-- 用户空间汇总 +CREATE VIEW v_user_storage_summary AS +SELECT + user_id, + username, + status, + storage_limit, + storage_used, + CASE + WHEN storage_limit <= 0 THEN 0::NUMERIC(10,2) + ELSE ROUND((storage_used::NUMERIC / storage_limit::NUMERIC) * 100, 2) + END AS storage_used_pct +FROM "user"; + +-- “共享给我”:基于 share + share_member,包含待接受 / 已接受两种状态 +CREATE VIEW v_shared_with_me AS +SELECT + s.share_id, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + owner.user_id AS shared_by_user_id, + owner.username AS shared_by, + sm.user_id AS shared_to_user_id, + sm.status AS member_status, + s.share_type, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + sm.target_folder_id, + sm.accepted_at, + s.expire_time, + s.created_at AS shared_at +FROM share s +JOIN share_member sm ON s.share_id = sm.share_id +JOIN "user" owner ON s.user_id = owner.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active' +WHERE sm.user_id IS NOT NULL + AND sm.status IN ('pending', 'accepted') + AND s.status = 'active' + AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP) + AND ( + (s.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ) + +UNION ALL + +SELECT + s.share_id, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + owner.user_id AS shared_by_user_id, + owner.username AS shared_by, + ugm.user_id AS shared_to_user_id, + sm.status AS member_status, + s.share_type, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + sm.target_folder_id, + sm.accepted_at, + s.expire_time, + s.created_at AS shared_at +FROM share s +JOIN share_member sm ON s.share_id = sm.share_id +JOIN user_group_member ugm ON sm.group_id = ugm.group_id +JOIN "user" owner ON s.user_id = owner.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active' +WHERE sm.group_id IS NOT NULL + AND sm.status IN ('pending', 'accepted') + AND s.status = 'active' + AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP) + AND ( + (s.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ); + +-- 目录全路径:按 owner 隔离,且只看 active 目录 +CREATE VIEW v_full_path AS +WITH RECURSIVE folder_path AS ( + SELECT + folder_id, + owner_id, + parent_folder_id, + folder_name, + CAST(folder_name AS VARCHAR(2048)) AS path + FROM folder + WHERE parent_folder_id IS NULL + AND status = 'active' + + UNION ALL + + SELECT + f.folder_id, + f.owner_id, + f.parent_folder_id, + f.folder_name, + fp.path || '/' || f.folder_name AS path + FROM folder f + JOIN folder_path fp + ON f.parent_folder_id = fp.folder_id + AND f.owner_id = fp.owner_id + WHERE f.status = 'active' +) +SELECT + folder_id AS id, + owner_id, + parent_folder_id, + path +FROM folder_path; + +-- 回收站视图:修正 file.owner_id,并改用 cached_size +CREATE VIEW v_user_recycle_bin AS +SELECT + 'file' AS item_type, + f.file_id AS id, + f.file_name AS name, + f.file_size AS size, + f.folder_id AS parent_id, + f.owner_id, + u.username AS owner_name, + f.deleted_at +FROM "file" f +JOIN "user" u ON f.owner_id = u.user_id +WHERE f.status = 'deleted' + AND f.deleted_at IS NOT NULL + +UNION ALL + +SELECT + 'folder' AS item_type, + fo.folder_id AS id, + fo.folder_name AS name, + fo.cached_size AS size, + fo.parent_folder_id AS parent_id, + fo.owner_id, + u.username AS owner_name, + fo.deleted_at +FROM folder fo +JOIN "user" u ON fo.owner_id = u.user_id +WHERE fo.status = 'deleted' + AND fo.deleted_at IS NOT NULL; + +-- 管理员查看分享链接总览 +CREATE VIEW v_admin_share_overview AS +SELECT + s.share_id, + s.share_code, + s.status, + s.share_type, + s.user_id AS created_by_user_id, + u.username AS created_by, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + s.require_login, + s.password_hash IS NOT NULL AS has_password, + s.expire_time, + s.visit_count, + s.download_count, + s.created_at, + s.updated_at +FROM share s +JOIN "user" u ON s.user_id = u.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id +LEFT JOIN folder fo ON s.folder_id = fo.folder_id; + +-- 管理员用户仪表盘 +CREATE VIEW v_user_dashboard AS +SELECT + u.user_id, + u.username, + u.email, + u.status, + u.storage_limit, + u.storage_used, + CASE + WHEN u.storage_limit <= 0 THEN 0::NUMERIC(10,2) + ELSE ROUND((u.storage_used::NUMERIC / u.storage_limit::NUMERIC) * 100, 2) + END AS storage_used_pct, + COALESCE(f.file_count, 0) AS active_file_count, + COALESCE(fo.folder_count, 0) AS active_folder_count, + COALESCE(s.active_share_count, 0) AS active_share_count, + COALESCE(fv.favorite_count, 0) AS favorite_count, + u.last_login_at, + u.created_at +FROM "user" u +LEFT JOIN ( + SELECT owner_id, COUNT(*) AS file_count + FROM "file" + WHERE status = 'active' + GROUP BY owner_id +) f ON u.user_id = f.owner_id +LEFT JOIN ( + SELECT owner_id, COUNT(*) AS folder_count + FROM folder + WHERE status = 'active' + GROUP BY owner_id +) fo ON u.user_id = fo.owner_id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS active_share_count + FROM share + WHERE status = 'active' + AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP) + GROUP BY user_id +) s ON u.user_id = s.user_id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS favorite_count + FROM favorite_item + GROUP BY user_id +) fv ON u.user_id = fv.user_id; diff --git a/docs/schema-summary.sql b/docs/schema-summary.sql new file mode 100644 index 0000000..c889a71 --- /dev/null +++ b/docs/schema-summary.sql @@ -0,0 +1,11 @@ +-- PostgreSQL schema entrypoint (domain-splitted) +-- Usage (from repository root): +-- psql -U -d -f docs/schema-postgres.sql + +\ir sql/postgres/00_base.sql +\ir sql/postgres/10_identity.sql +\ir sql/postgres/20_storage.sql +\ir sql/postgres/30_access_share.sql +\ir sql/postgres/40_audit_security.sql +\ir sql/postgres/50_maintenance_views.sql +\ir sql/postgres/90_comments.sql diff --git a/docs/sql/postgres/00_base.sql b/docs/sql/postgres/00_base.sql new file mode 100644 index 0000000..c8eccef --- /dev/null +++ b/docs/sql/postgres/00_base.sql @@ -0,0 +1,81 @@ +-- ========================= +-- Base: extensions, enum types, common trigger functions +-- ========================= + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_status_enum') THEN + CREATE TYPE upload_status_enum AS ENUM ('uploading', 'active', 'deleted', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'folder_status_enum') THEN + CREATE TYPE folder_status_enum AS ENUM ('active', 'deleted'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'file_status_enum') THEN + CREATE TYPE file_status_enum AS ENUM ('active', 'deleted'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_mode_enum') THEN + CREATE TYPE upload_mode_enum AS ENUM ('single', 'multipart'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_task_status_enum') THEN + CREATE TYPE upload_task_status_enum AS ENUM ('init', 'uploading', 'completed', 'aborted', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'upload_part_status_enum') THEN + CREATE TYPE upload_part_status_enum AS ENUM ('pending', 'uploaded', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status_enum') THEN + CREATE TYPE user_status_enum AS ENUM ('pending_verification', 'active', 'locked', 'disabled'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'folder_type_enum') THEN + CREATE TYPE folder_type_enum AS ENUM ('normal', 'root', 'system'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_status_enum') THEN + CREATE TYPE share_status_enum AS ENUM ('active', 'expired', 'revoked', 'deleted'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'share_member_status_enum') THEN + CREATE TYPE share_member_status_enum AS ENUM ('pending', 'accepted', 'rejected', 'revoked'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'favorite_item_type_enum') THEN + CREATE TYPE favorite_item_type_enum AS ENUM ('file', 'folder'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'view_mode_enum') THEN + CREATE TYPE view_mode_enum AS ENUM ('list', 'grid'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_by_enum') THEN + CREATE TYPE sort_by_enum AS ENUM ('name', 'size', 'created_at', 'updated_at', 'last_accessed_at'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sort_direction_enum') THEN + CREATE TYPE sort_direction_enum AS ENUM ('asc', 'desc'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'preview_status_enum') THEN + CREATE TYPE preview_status_enum AS ENUM ('pending', 'ready', 'failed'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scan_result_enum') THEN + CREATE TYPE scan_result_enum AS ENUM ('pending', 'clean', 'infected', 'blocked', 'failed'); + END IF; +END +$$; + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/docs/sql/postgres/10_identity.sql b/docs/sql/postgres/10_identity.sql new file mode 100644 index 0000000..9e96660 --- /dev/null +++ b/docs/sql/postgres/10_identity.sql @@ -0,0 +1,96 @@ +-- ========================= +-- Domain: identity +-- ========================= + +CREATE TABLE IF NOT EXISTS "user" ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'USER', + status user_status_enum NOT NULL DEFAULT 'active', + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + email_verified_at TIMESTAMP NULL, + storage_limit BIGINT NOT NULL DEFAULT 10737418240, + storage_used BIGINT NOT NULL DEFAULT 0, + last_login_at TIMESTAMP NULL, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMP NULL, + password_changed_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_username_ci ON "user" ((LOWER(username))); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_email_ci ON "user" ((LOWER(email))); +CREATE INDEX IF NOT EXISTS idx_user_status ON "user" (status); +CREATE INDEX IF NOT EXISTS idx_user_locked_until ON "user" (locked_until); + +CREATE TRIGGER trg_user_updated_at +BEFORE UPDATE ON "user" +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS user_group ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255) +); + +CREATE TABLE IF NOT EXISTS user_group_member ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'member', + CONSTRAINT fk_ugm_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_ugm_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE, + CONSTRAINT uk_user_group UNIQUE (user_id, group_id) +); + +CREATE TABLE IF NOT EXISTS password_reset_token ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expire_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + requester_ip VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_password_reset_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_token_user_expire + ON password_reset_token (user_id, expire_at); + +CREATE TABLE IF NOT EXISTS email_verification_token ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expire_at TIMESTAMP NOT NULL, + verified_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_email_verification_token_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_email_verification_token_user_expire + ON email_verification_token (user_id, expire_at); + +CREATE TABLE IF NOT EXISTS user_session ( + session_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + refresh_token_hash CHAR(64) NOT NULL UNIQUE, + client_type VARCHAR(50) NOT NULL DEFAULT 'web', + device_id VARCHAR(255) NULL, + device_name VARCHAR(255) NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + CONSTRAINT fk_user_session_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_session_user_expire + ON user_session (user_id, expire_at); diff --git a/docs/sql/postgres/20_storage.sql b/docs/sql/postgres/20_storage.sql new file mode 100644 index 0000000..5fee641 --- /dev/null +++ b/docs/sql/postgres/20_storage.sql @@ -0,0 +1,241 @@ +-- ========================= +-- Domain: storage and upload +-- ========================= + +CREATE TABLE IF NOT EXISTS storage_object ( + object_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + object_hash CHAR(64), + hash_algorithm VARCHAR(32) NOT NULL DEFAULT 'sha256', + bucket_name VARCHAR(100) NOT NULL, + object_key VARCHAR(1024) NOT NULL, + object_size BIGINT NOT NULL, + etag VARCHAR(128), + version_id VARCHAR(255), + content_type VARCHAR(255), + storage_class VARCHAR(50), + upload_status upload_status_enum NOT NULL DEFAULT 'active', + scan_status scan_result_enum NOT NULL DEFAULT 'pending', + moderation_status VARCHAR(32) NOT NULL DEFAULT 'pending', + quarantined_at TIMESTAMP NULL, + last_scanned_at TIMESTAMP NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + ref_count INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + CONSTRAINT uk_bucket_object_key UNIQUE (bucket_name, object_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_storage_object_hash_algo_size + ON storage_object (hash_algorithm, object_hash, object_size) + WHERE object_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_storage_object_scan_status ON storage_object (scan_status); +CREATE INDEX IF NOT EXISTS idx_storage_object_moderation_status ON storage_object (moderation_status); +CREATE INDEX IF NOT EXISTS idx_storage_object_upload_status ON storage_object(upload_status); + +CREATE TRIGGER trg_storage_object_updated_at +BEFORE UPDATE ON storage_object +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS folder ( + folder_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner_id BIGINT NOT NULL, + parent_folder_id BIGINT NULL, + folder_name VARCHAR(255) NOT NULL, + cached_size BIGINT NOT NULL DEFAULT 0, + status folder_status_enum NOT NULL DEFAULT 'active', + folder_type folder_type_enum NOT NULL DEFAULT 'normal', + deleted_by BIGINT NULL, + restored_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + CONSTRAINT fk_folder_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_folder_parent FOREIGN KEY (parent_folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT fk_folder_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_root_name_active + ON folder (owner_id, folder_name) + WHERE parent_folder_id IS NULL AND status = 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_folder_child_name_active + ON folder (owner_id, parent_folder_id, folder_name) + WHERE parent_folder_id IS NOT NULL AND status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_folder_owner_parent_status ON folder (owner_id, parent_folder_id, status); +CREATE INDEX IF NOT EXISTS idx_folder_name_trgm ON folder USING gin ((LOWER(folder_name)) gin_trgm_ops); + +CREATE TRIGGER trg_folder_updated_at +BEFORE UPDATE ON folder +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS "file" ( + file_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + uploader_id BIGINT NOT NULL, + owner_id BIGINT NOT NULL, + folder_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_ext VARCHAR(50), + mime_type VARCHAR(255), + storage_object_id BIGINT NOT NULL, + file_size BIGINT NOT NULL, + is_latest BOOLEAN NOT NULL DEFAULT TRUE, + status file_status_enum NOT NULL DEFAULT 'active', + deleted_by BIGINT NULL, + restored_at TIMESTAMP NULL, + last_accessed_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + CONSTRAINT fk_file_uploader FOREIGN KEY (uploader_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_file_owner FOREIGN KEY (owner_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_file_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT fk_file_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id), + CONSTRAINT fk_file_deleted_by FOREIGN KEY (deleted_by) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_file_name_active_in_folder + ON "file" (owner_id, folder_id, file_name) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_file_last_accessed_at ON "file" (last_accessed_at); +CREATE INDEX IF NOT EXISTS idx_file_name_trgm ON "file" USING gin ((LOWER(file_name)) gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_file_storage_object_id ON "file"(storage_object_id); +CREATE INDEX IF NOT EXISTS idx_file_owner_folder ON "file"(owner_id, folder_id, status); + +CREATE TRIGGER trg_file_updated_at +BEFORE UPDATE ON "file" +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS upload_task ( + task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + folder_id BIGINT NULL, + file_name VARCHAR(255) NULL, + mime_type VARCHAR(255) NULL, + bucket_name VARCHAR(100) NOT NULL, + object_key VARCHAR(1024) NOT NULL, + object_hash CHAR(64), + total_size BIGINT NOT NULL, + chunk_size BIGINT NULL, + uploaded_bytes BIGINT NOT NULL DEFAULT 0, + client_file_id VARCHAR(255) NULL, + upload_id VARCHAR(255), + upload_mode upload_mode_enum NOT NULL DEFAULT 'single', + status upload_task_status_enum NOT NULL DEFAULT 'init', + last_error TEXT NULL, + completed_at TIMESTAMP NULL, + expired_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_upload_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_upload_task_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL, + CONSTRAINT uk_upload_target UNIQUE (bucket_name, object_key, upload_id) +); + +CREATE INDEX IF NOT EXISTS idx_upload_task_user_status ON upload_task(user_id, status); +CREATE INDEX IF NOT EXISTS idx_upload_task_expired_at ON upload_task (expired_at); +CREATE UNIQUE INDEX IF NOT EXISTS uk_upload_task_user_client_file_id + ON upload_task (user_id, client_file_id) + WHERE client_file_id IS NOT NULL; + +CREATE TRIGGER trg_upload_task_updated_at +BEFORE UPDATE ON upload_task +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS upload_task_part ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL, + part_number INTEGER NOT NULL, + etag VARCHAR(128), + part_size BIGINT NOT NULL, + status upload_part_status_enum NOT NULL DEFAULT 'pending', + checksum CHAR(64) NULL, + uploaded_at TIMESTAMP NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_upload_task_part_task FOREIGN KEY (task_id) REFERENCES upload_task(task_id) ON DELETE CASCADE, + CONSTRAINT uk_task_part UNIQUE (task_id, part_number) +); + +CREATE INDEX IF NOT EXISTS idx_upload_task_part_task_status ON upload_task_part (task_id, status); + +CREATE TRIGGER trg_upload_task_part_updated_at +BEFORE UPDATE ON upload_task_part +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS file_preview_asset ( + preview_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + source_object_id BIGINT NOT NULL, + preview_object_id BIGINT NULL, + preview_type VARCHAR(50) NOT NULL, + page_no INTEGER NULL, + mime_type VARCHAR(255) NULL, + width INTEGER NULL, + height INTEGER NULL, + duration_ms BIGINT NULL, + status preview_status_enum NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_file_preview_asset_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE, + CONSTRAINT fk_file_preview_asset_preview_object FOREIGN KEY (preview_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_file_preview_asset_type_page + ON file_preview_asset (source_object_id, preview_type, (COALESCE(page_no, -1))); + +DROP TRIGGER IF EXISTS trg_file_preview_asset_updated_at ON file_preview_asset; +CREATE TRIGGER trg_file_preview_asset_updated_at +BEFORE UPDATE ON file_preview_asset +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS file_media_metadata ( + metadata_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + source_object_id BIGINT NOT NULL UNIQUE, + width INTEGER NULL, + height INTEGER NULL, + duration_ms BIGINT NULL, + page_count INTEGER NULL, + bitrate INTEGER NULL, + sample_rate INTEGER NULL, + video_codec VARCHAR(64) NULL, + audio_codec VARCHAR(64) NULL, + extra_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + extracted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_file_media_metadata_source_object FOREIGN KEY (source_object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS batch_download_task ( + task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + archive_name VARCHAR(255) NOT NULL, + item_count INTEGER NOT NULL DEFAULT 0, + items JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + storage_object_id BIGINT NULL, + expire_at TIMESTAMP NULL, + error_message TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + CONSTRAINT fk_batch_download_task_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_batch_download_task_storage_object FOREIGN KEY (storage_object_id) REFERENCES storage_object(object_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_batch_download_task_user_status + ON batch_download_task (user_id, status); + +DROP TRIGGER IF EXISTS trg_batch_download_task_updated_at ON batch_download_task; +CREATE TRIGGER trg_batch_download_task_updated_at +BEFORE UPDATE ON batch_download_task +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); diff --git a/docs/sql/postgres/30_access_share.sql b/docs/sql/postgres/30_access_share.sql new file mode 100644 index 0000000..e2fe2e5 --- /dev/null +++ b/docs/sql/postgres/30_access_share.sql @@ -0,0 +1,196 @@ +-- ========================= +-- Domain: access control and sharing +-- ========================= + +CREATE TABLE IF NOT EXISTS acl ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + file_id BIGINT NULL, + folder_id BIGINT NULL, + user_id BIGINT NULL, + group_id BIGINT NULL, + permission VARCHAR(100) NOT NULL, + permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer', + can_preview BOOLEAN NOT NULL DEFAULT TRUE, + can_download BOOLEAN NOT NULL DEFAULT TRUE, + can_save BOOLEAN NOT NULL DEFAULT FALSE, + can_share BOOLEAN NOT NULL DEFAULT FALSE, + expire_at TIMESTAMP NULL, + granted_by BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_acl_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, + CONSTRAINT fk_acl_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT fk_acl_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_acl_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE, + CONSTRAINT fk_acl_granted_by FOREIGN KEY (granted_by) REFERENCES "user"(user_id) ON DELETE SET NULL, + CONSTRAINT chk_acl_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1), + CONSTRAINT chk_acl_resource_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_user + ON acl (file_id, user_id) + WHERE file_id IS NOT NULL AND user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_file_group + ON acl (file_id, group_id) + WHERE file_id IS NOT NULL AND group_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_user + ON acl (folder_id, user_id) + WHERE folder_id IS NOT NULL AND user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_acl_folder_group + ON acl (folder_id, group_id) + WHERE folder_id IS NOT NULL AND group_id IS NOT NULL; + +CREATE TRIGGER trg_acl_updated_at +BEFORE UPDATE ON acl +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS share ( + share_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + resource_type VARCHAR(50) NOT NULL, + file_id BIGINT NULL, + folder_id BIGINT NULL, + share_link VARCHAR(255) NOT NULL UNIQUE, + share_code VARCHAR(64) NOT NULL, + status share_status_enum NOT NULL DEFAULT 'active', + share_type VARCHAR(50) NOT NULL DEFAULT 'public', + permission_role VARCHAR(50) NOT NULL DEFAULT 'viewer', + allow_preview BOOLEAN NOT NULL DEFAULT TRUE, + allow_download BOOLEAN NOT NULL DEFAULT TRUE, + allow_save BOOLEAN NOT NULL DEFAULT FALSE, + allow_reshare BOOLEAN NOT NULL DEFAULT FALSE, + require_login BOOLEAN NOT NULL DEFAULT FALSE, + max_visits INTEGER NULL, + max_downloads INTEGER NULL, + password_hash VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TIMESTAMP NULL, + expire_time TIMESTAMP NULL, + visit_count INTEGER NOT NULL DEFAULT 0, + download_count INTEGER NOT NULL DEFAULT 0, + CONSTRAINT fk_share_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_share_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, + CONSTRAINT fk_share_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT chk_share_target CHECK (num_nonnulls(file_id, folder_id) = 1), + CONSTRAINT chk_share_limits CHECK ( + (max_visits IS NULL OR max_visits >= 0) + AND + (max_downloads IS NULL OR max_downloads >= 0) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_code ON share (share_code); +CREATE INDEX IF NOT EXISTS idx_share_user_status ON share (user_id, status); +CREATE INDEX IF NOT EXISTS idx_share_expire_time ON share (expire_time); + +CREATE TRIGGER trg_share_updated_at +BEFORE UPDATE ON share +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS share_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + share_id BIGINT NOT NULL, + user_id BIGINT NULL, + group_id BIGINT NULL, + status share_member_status_enum NOT NULL DEFAULT 'pending', + target_folder_id BIGINT NULL, + accepted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_share_member_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_group FOREIGN KEY (group_id) REFERENCES user_group(id) ON DELETE CASCADE, + CONSTRAINT fk_share_member_target_folder FOREIGN KEY (target_folder_id) REFERENCES folder(folder_id) ON DELETE SET NULL, + CONSTRAINT chk_share_member_subject_exactly_one CHECK (num_nonnulls(user_id, group_id) = 1) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_user + ON share_member (share_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_share_member_group + ON share_member (share_id, group_id) + WHERE group_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_share_member_share_status ON share_member (share_id, status); + +DROP TRIGGER IF EXISTS trg_share_member_updated_at ON share_member; +CREATE TRIGGER trg_share_member_updated_at +BEFORE UPDATE ON share_member +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS favorite_item ( + favorite_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + item_type favorite_item_type_enum NOT NULL, + file_id BIGINT NULL, + folder_id BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_favorite_item_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_item_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_item_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE, + CONSTRAINT chk_favorite_item_target_exactly_one CHECK (num_nonnulls(file_id, folder_id) = 1), + CONSTRAINT chk_favorite_item_type_match CHECK ( + (item_type = 'file' AND file_id IS NOT NULL AND folder_id IS NULL) + OR + (item_type = 'folder' AND folder_id IS NOT NULL AND file_id IS NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_file + ON favorite_item (user_id, file_id) + WHERE file_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_favorite_item_folder + ON favorite_item (user_id, folder_id) + WHERE folder_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS user_folder_preference ( + preference_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + folder_id BIGINT NULL, + view_mode view_mode_enum NOT NULL DEFAULT 'list', + sort_by sort_by_enum NOT NULL DEFAULT 'name', + sort_direction sort_direction_enum NOT NULL DEFAULT 'asc', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_folder_preference_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_user_folder_preference_folder FOREIGN KEY (folder_id) REFERENCES folder(folder_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_default + ON user_folder_preference (user_id) + WHERE folder_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_folder_preference_folder + ON user_folder_preference (user_id, folder_id) + WHERE folder_id IS NOT NULL; + +DROP TRIGGER IF EXISTS trg_user_folder_preference_updated_at ON user_folder_preference; +CREATE TRIGGER trg_user_folder_preference_updated_at +BEFORE UPDATE ON user_folder_preference +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS share_access_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + share_id BIGINT NOT NULL, + user_id BIGINT NULL, + event_type VARCHAR(20) NOT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + result VARCHAR(20) NOT NULL DEFAULT 'success', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_share_access_log_share FOREIGN KEY (share_id) REFERENCES share(share_id) ON DELETE CASCADE, + CONSTRAINT fk_share_access_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_share_access_log_share_created_at + ON share_access_log (share_id, created_at DESC); diff --git a/docs/sql/postgres/40_audit_security.sql b/docs/sql/postgres/40_audit_security.sql new file mode 100644 index 0000000..165ff2e --- /dev/null +++ b/docs/sql/postgres/40_audit_security.sql @@ -0,0 +1,146 @@ +-- ========================= +-- Domain: audit, notification and risk control +-- ========================= + +CREATE TABLE IF NOT EXISTS "log" ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NULL, + actor_type VARCHAR(50) NOT NULL DEFAULT 'user', + operation VARCHAR(255) NOT NULL, + target_type VARCHAR(50) NULL, + target_id BIGINT NULL, + result VARCHAR(20) NOT NULL DEFAULT 'success', + request_id VARCHAR(128) NULL, + user_agent VARCHAR(255) NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + details TEXT, + ip_address VARCHAR(45), + performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_log_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_log_performed_at ON "log" (performed_at); +CREATE INDEX IF NOT EXISTS idx_log_user_operation ON "log" (user_id, operation); +CREATE INDEX IF NOT EXISTS idx_log_target ON "log" (target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_log_request_id ON "log" (request_id); + +CREATE TABLE IF NOT EXISTS notification ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(255) NULL, + type VARCHAR(50) NOT NULL DEFAULT 'system', + channel VARCHAR(50) NOT NULL DEFAULT 'in_app', + message TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + sent_at TIMESTAMP NULL, + is_read BOOLEAN DEFAULT FALSE, + read_at TIMESTAMP NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + sender_user_id BIGINT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notification_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE CASCADE, + CONSTRAINT fk_notification_sender_user FOREIGN KEY (sender_user_id) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_notification_user_status ON notification (user_id, status, is_read); +CREATE INDEX IF NOT EXISTS idx_notification_created_at ON notification (created_at); + +CREATE TRIGGER trg_notification_updated_at +BEFORE UPDATE ON notification +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE OR REPLACE FUNCTION sync_notification_read_at() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.is_read AND NEW.read_at IS NULL THEN + NEW.read_at = CURRENT_TIMESTAMP; + ELSIF NOT NEW.is_read THEN + NEW.read_at = NULL; + END IF; + RETURN NEW; + END IF; + + IF NEW.is_read AND (OLD.is_read IS DISTINCT FROM NEW.is_read) AND NEW.read_at IS NULL THEN + NEW.read_at = CURRENT_TIMESTAMP; + ELSIF NOT NEW.is_read THEN + NEW.read_at = NULL; + ELSIF NEW.read_at IS NOT NULL THEN + NEW.is_read = TRUE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_notification_sync_read_at ON notification; +CREATE TRIGGER trg_notification_sync_read_at +BEFORE INSERT OR UPDATE ON notification +FOR EACH ROW +EXECUTE FUNCTION sync_notification_read_at(); + +CREATE TABLE IF NOT EXISTS object_scan_result ( + scan_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + object_id BIGINT NOT NULL, + scan_type VARCHAR(50) NOT NULL, + engine_name VARCHAR(100) NULL, + engine_version VARCHAR(100) NULL, + result scan_result_enum NOT NULL DEFAULT 'pending', + details JSONB NOT NULL DEFAULT '{}'::jsonb, + scanned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_object_scan_result_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_object_scan_result_object_scanned_at + ON object_scan_result (object_id, scanned_at DESC); + +CREATE TABLE IF NOT EXISTS moderation_case ( + case_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + object_id BIGINT NOT NULL, + file_id BIGINT NULL, + reason_type VARCHAR(50) NOT NULL, + confidence NUMERIC(5,4) NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + resolution VARCHAR(50) NULL, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + handled_by BIGINT NULL, + handled_at TIMESTAMP NULL, + CONSTRAINT fk_moderation_case_object FOREIGN KEY (object_id) REFERENCES storage_object(object_id) ON DELETE CASCADE, + CONSTRAINT fk_moderation_case_file FOREIGN KEY (file_id) REFERENCES "file"(file_id) ON DELETE SET NULL, + CONSTRAINT fk_moderation_case_handled_by FOREIGN KEY (handled_by) REFERENCES "user"(user_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_moderation_case_status_created_at + ON moderation_case (status, created_at DESC); + +DROP TRIGGER IF EXISTS trg_moderation_case_updated_at ON moderation_case; +CREATE TRIGGER trg_moderation_case_updated_at +BEFORE UPDATE ON moderation_case +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS security_event ( + event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NULL, + session_id BIGINT NULL, + event_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'info', + target_type VARCHAR(50) NULL, + target_id BIGINT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_security_event_user FOREIGN KEY (user_id) REFERENCES "user"(user_id) ON DELETE SET NULL, + CONSTRAINT fk_security_event_session FOREIGN KEY (session_id) REFERENCES user_session(session_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_security_event_user_occurred_at + ON security_event (user_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_security_event_type_occurred_at + ON security_event (event_type, occurred_at DESC); diff --git a/docs/sql/postgres/50_maintenance_views.sql b/docs/sql/postgres/50_maintenance_views.sql new file mode 100644 index 0000000..978edea --- /dev/null +++ b/docs/sql/postgres/50_maintenance_views.sql @@ -0,0 +1,624 @@ +-- ========================= +-- Domain: maintenance functions and views +-- ========================= + +CREATE OR REPLACE FUNCTION recalc_user_storage_used(p_user_id BIGINT) +RETURNS VOID AS $$ +BEGIN + IF p_user_id IS NULL THEN + RETURN; + END IF; + + UPDATE "user" u + SET storage_used = COALESCE(( + SELECT SUM(f.file_size) + FROM "file" f + WHERE f.owner_id = p_user_id + AND f.status = 'active' + ), 0) + WHERE u.user_id = p_user_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION recalc_storage_object_ref_count(p_object_id BIGINT) +RETURNS VOID AS $$ +BEGIN + IF p_object_id IS NULL THEN + RETURN; + END IF; + + UPDATE storage_object so + SET ref_count = COALESCE(( + SELECT COUNT(*) + FROM "file" f + WHERE f.storage_object_id = p_object_id + AND f.status = 'active' + ), 0) + WHERE so.object_id = p_object_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION recalc_folder_cached_size(p_folder_id BIGINT) +RETURNS VOID AS $$ +DECLARE + v_folder_id BIGINT := p_folder_id; + v_parent_id BIGINT; +BEGIN + WHILE v_folder_id IS NOT NULL LOOP + UPDATE folder f + SET cached_size = + COALESCE(( + SELECT SUM(fi.file_size) + FROM "file" fi + WHERE fi.folder_id = f.folder_id + AND fi.status = 'active' + ), 0) + + + COALESCE(( + SELECT SUM(ch.cached_size) + FROM folder ch + WHERE ch.parent_folder_id = f.folder_id + AND ch.status = 'active' + ), 0) + WHERE f.folder_id = v_folder_id; + + SELECT parent_folder_id INTO v_parent_id + FROM folder + WHERE folder_id = v_folder_id; + + v_folder_id := v_parent_id; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION rebuild_all_folder_cached_size() +RETURNS VOID AS $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + WITH RECURSIVE tree AS ( + SELECT folder_id, parent_folder_id, 0 AS depth + FROM folder + WHERE parent_folder_id IS NULL + + UNION ALL + + SELECT f.folder_id, f.parent_folder_id, t.depth + 1 + FROM folder f + JOIN tree t ON f.parent_folder_id = t.folder_id + ) + SELECT folder_id + FROM tree + ORDER BY depth DESC, folder_id DESC + LOOP + PERFORM recalc_folder_cached_size(r.folder_id); + END LOOP; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION check_folder_parent_valid() +RETURNS TRIGGER AS $$ +DECLARE + v_parent_owner BIGINT; + v_parent_status folder_status_enum; + v_has_cycle BOOLEAN := FALSE; +BEGIN + IF NEW.parent_folder_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT owner_id, status + INTO v_parent_owner, v_parent_status + FROM folder + WHERE folder_id = NEW.parent_folder_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Parent folder % does not exist', NEW.parent_folder_id; + END IF; + + IF v_parent_owner <> NEW.owner_id THEN + RAISE EXCEPTION 'Parent folder owner must match folder owner'; + END IF; + + IF NEW.status = 'active' AND v_parent_status <> 'active' THEN + RAISE EXCEPTION 'Active folder cannot be placed under a non-active parent folder'; + END IF; + + IF NEW.folder_id IS NOT NULL THEN + IF NEW.parent_folder_id = NEW.folder_id THEN + RAISE EXCEPTION 'Folder cannot be its own parent'; + END IF; + + WITH RECURSIVE descendants AS ( + SELECT folder_id + FROM folder + WHERE parent_folder_id = NEW.folder_id + + UNION ALL + + SELECT f.folder_id + FROM folder f + JOIN descendants d ON f.parent_folder_id = d.folder_id + ) + SELECT EXISTS( + SELECT 1 + FROM descendants + WHERE folder_id = NEW.parent_folder_id + ) + INTO v_has_cycle; + + IF v_has_cycle THEN + RAISE EXCEPTION 'Folder cannot be moved into its own descendant'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_folder_validate ON folder; +CREATE TRIGGER trg_folder_validate +BEFORE INSERT OR UPDATE OF parent_folder_id, owner_id, status ON folder +FOR EACH ROW +EXECUTE FUNCTION check_folder_parent_valid(); + +CREATE OR REPLACE FUNCTION check_file_folder_valid() +RETURNS TRIGGER AS $$ +DECLARE + v_folder_owner BIGINT; + v_folder_status folder_status_enum; +BEGIN + SELECT owner_id, status + INTO v_folder_owner, v_folder_status + FROM folder + WHERE folder_id = NEW.folder_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Folder % does not exist', NEW.folder_id; + END IF; + + IF NEW.status = 'active' THEN + IF v_folder_owner <> NEW.owner_id THEN + RAISE EXCEPTION 'File owner must match folder owner'; + END IF; + + IF v_folder_status <> 'active' THEN + RAISE EXCEPTION 'Active file cannot be placed under a non-active folder'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_file_validate ON "file"; +CREATE TRIGGER trg_file_validate +BEFORE INSERT OR UPDATE OF folder_id, owner_id, status ON "file" +FOR EACH ROW +EXECUTE FUNCTION check_file_folder_valid(); + +CREATE OR REPLACE FUNCTION sync_file_derived_fields() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM recalc_storage_object_ref_count(NEW.storage_object_id); + PERFORM recalc_user_storage_used(NEW.owner_id); + PERFORM recalc_folder_cached_size(NEW.folder_id); + RETURN NULL; + ELSIF TG_OP = 'UPDATE' THEN + PERFORM recalc_storage_object_ref_count(OLD.storage_object_id); + IF NEW.storage_object_id IS DISTINCT FROM OLD.storage_object_id THEN + PERFORM recalc_storage_object_ref_count(NEW.storage_object_id); + END IF; + + PERFORM recalc_user_storage_used(OLD.owner_id); + IF NEW.owner_id IS DISTINCT FROM OLD.owner_id THEN + PERFORM recalc_user_storage_used(NEW.owner_id); + END IF; + + PERFORM recalc_folder_cached_size(OLD.folder_id); + IF NEW.folder_id IS DISTINCT FROM OLD.folder_id THEN + PERFORM recalc_folder_cached_size(NEW.folder_id); + END IF; + RETURN NULL; + ELSE + PERFORM recalc_storage_object_ref_count(OLD.storage_object_id); + PERFORM recalc_user_storage_used(OLD.owner_id); + PERFORM recalc_folder_cached_size(OLD.folder_id); + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_file_sync_derived_fields ON "file"; +CREATE TRIGGER trg_file_sync_derived_fields +AFTER INSERT OR UPDATE OR DELETE ON "file" +FOR EACH ROW +EXECUTE FUNCTION sync_file_derived_fields(); + +CREATE OR REPLACE FUNCTION sync_folder_cached_size() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM recalc_folder_cached_size(NEW.folder_id); + PERFORM recalc_folder_cached_size(NEW.parent_folder_id); + RETURN NULL; + ELSIF TG_OP = 'UPDATE' THEN + PERFORM recalc_folder_cached_size(OLD.parent_folder_id); + PERFORM recalc_folder_cached_size(NEW.folder_id); + IF NEW.parent_folder_id IS DISTINCT FROM OLD.parent_folder_id THEN + PERFORM recalc_folder_cached_size(NEW.parent_folder_id); + END IF; + RETURN NULL; + ELSE + PERFORM recalc_folder_cached_size(OLD.parent_folder_id); + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_folder_sync_cached_size ON folder; +CREATE TRIGGER trg_folder_sync_cached_size +AFTER INSERT OR DELETE OR UPDATE OF parent_folder_id, status ON folder +FOR EACH ROW +EXECUTE FUNCTION sync_folder_cached_size(); + +UPDATE storage_object so +SET ref_count = x.ref_count +FROM ( + SELECT storage_object_id, COUNT(*) AS ref_count + FROM "file" + WHERE status = 'active' + GROUP BY storage_object_id +) x +WHERE so.object_id = x.storage_object_id; + +UPDATE storage_object so +SET ref_count = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM "file" f + WHERE f.storage_object_id = so.object_id + AND f.status = 'active' +); + +UPDATE "user" u +SET storage_used = x.total_size +FROM ( + SELECT owner_id AS user_id, COALESCE(SUM(file_size), 0) AS total_size + FROM "file" + WHERE status = 'active' + GROUP BY owner_id +) x +WHERE u.user_id = x.user_id; + +UPDATE "user" u +SET storage_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM "file" f + WHERE f.owner_id = u.user_id + AND f.status = 'active' +); + +SELECT rebuild_all_folder_cached_size(); + +DROP VIEW IF EXISTS v_shared_with_me; +DROP VIEW IF EXISTS v_user_permissions; +DROP VIEW IF EXISTS v_user_recycle_bin; +DROP VIEW IF EXISTS v_full_path; +DROP VIEW IF EXISTS v_file_folder_details; +DROP VIEW IF EXISTS v_user_storage_summary; +DROP VIEW IF EXISTS v_admin_share_overview; +DROP VIEW IF EXISTS v_user_dashboard; + +CREATE VIEW v_file_folder_details AS +SELECT + 'file' AS item_type, + f.file_id AS id, + f.file_name AS name, + f.file_size AS size, + f.mime_type, + f.folder_id AS parent_id, + f.owner_id, + u.username AS owner_name, + f.created_at, + f.updated_at +FROM "file" f +JOIN "user" u ON f.owner_id = u.user_id +JOIN storage_object so ON f.storage_object_id = so.object_id +WHERE f.status = 'active' + AND so.upload_status = 'active' + +UNION ALL + +SELECT + 'folder' AS item_type, + fo.folder_id AS id, + fo.folder_name AS name, + fo.cached_size AS size, + 'inode/directory' AS mime_type, + fo.parent_folder_id AS parent_id, + fo.owner_id, + u.username AS owner_name, + fo.created_at, + fo.updated_at +FROM folder fo +JOIN "user" u ON fo.owner_id = u.user_id +WHERE fo.status = 'active'; + +CREATE VIEW v_user_permissions AS +SELECT + acl.id AS acl_id, + u.user_id, + u.username AS user_name, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + acl.permission_role, + acl.can_preview, + acl.can_download, + acl.can_save, + acl.can_share, + acl.expire_at, + 'direct'::TEXT AS grant_source +FROM acl +JOIN "user" u ON acl.user_id = u.user_id +LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active' +WHERE acl.user_id IS NOT NULL + AND ( + (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ) + +UNION ALL + +SELECT + acl.id AS acl_id, + u.user_id, + u.username AS user_name, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + CASE WHEN acl.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + acl.permission_role, + acl.can_preview, + acl.can_download, + acl.can_save, + acl.can_share, + acl.expire_at, + 'group'::TEXT AS grant_source +FROM acl +JOIN user_group_member ugm ON acl.group_id = ugm.group_id +JOIN "user" u ON ugm.user_id = u.user_id +LEFT JOIN "file" f ON acl.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON acl.folder_id = fo.folder_id AND fo.status = 'active' +WHERE acl.group_id IS NOT NULL + AND ( + (acl.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (acl.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ); + +CREATE VIEW v_user_storage_summary AS +SELECT + user_id, + username, + status, + storage_limit, + storage_used, + CASE + WHEN storage_limit <= 0 THEN 0::NUMERIC(10,2) + ELSE ROUND((storage_used::NUMERIC / storage_limit::NUMERIC) * 100, 2) + END AS storage_used_pct +FROM "user"; + +CREATE VIEW v_shared_with_me AS +SELECT + s.share_id, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + owner.user_id AS shared_by_user_id, + owner.username AS shared_by, + sm.user_id AS shared_to_user_id, + sm.status AS member_status, + s.share_type, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + sm.target_folder_id, + sm.accepted_at, + s.expire_time, + s.created_at AS shared_at +FROM share s +JOIN share_member sm ON s.share_id = sm.share_id +JOIN "user" owner ON s.user_id = owner.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active' +WHERE sm.user_id IS NOT NULL + AND sm.status IN ('pending', 'accepted') + AND s.status = 'active' + AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP) + AND ( + (s.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ) + +UNION ALL + +SELECT + s.share_id, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + owner.user_id AS shared_by_user_id, + owner.username AS shared_by, + ugm.user_id AS shared_to_user_id, + sm.status AS member_status, + s.share_type, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + sm.target_folder_id, + sm.accepted_at, + s.expire_time, + s.created_at AS shared_at +FROM share s +JOIN share_member sm ON s.share_id = sm.share_id +JOIN user_group_member ugm ON sm.group_id = ugm.group_id +JOIN "user" owner ON s.user_id = owner.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id AND f.status = 'active' +LEFT JOIN folder fo ON s.folder_id = fo.folder_id AND fo.status = 'active' +WHERE sm.group_id IS NOT NULL + AND sm.status IN ('pending', 'accepted') + AND s.status = 'active' + AND (s.expire_time IS NULL OR s.expire_time > CURRENT_TIMESTAMP) + AND ( + (s.file_id IS NOT NULL AND f.file_id IS NOT NULL) + OR + (s.folder_id IS NOT NULL AND fo.folder_id IS NOT NULL) + ); + +CREATE VIEW v_full_path AS +WITH RECURSIVE folder_path AS ( + SELECT + folder_id, + owner_id, + parent_folder_id, + folder_name, + CAST(folder_name AS VARCHAR(2048)) AS path + FROM folder + WHERE parent_folder_id IS NULL + AND status = 'active' + + UNION ALL + + SELECT + f.folder_id, + f.owner_id, + f.parent_folder_id, + f.folder_name, + fp.path || '/' || f.folder_name AS path + FROM folder f + JOIN folder_path fp + ON f.parent_folder_id = fp.folder_id + AND f.owner_id = fp.owner_id + WHERE f.status = 'active' +) +SELECT + folder_id AS id, + owner_id, + parent_folder_id, + path +FROM folder_path; + +CREATE VIEW v_user_recycle_bin AS +SELECT + 'file' AS item_type, + f.file_id AS id, + f.file_name AS name, + f.file_size AS size, + f.folder_id AS parent_id, + f.owner_id, + u.username AS owner_name, + f.deleted_at +FROM "file" f +JOIN "user" u ON f.owner_id = u.user_id +WHERE f.status = 'deleted' + AND f.deleted_at IS NOT NULL + +UNION ALL + +SELECT + 'folder' AS item_type, + fo.folder_id AS id, + fo.folder_name AS name, + fo.cached_size AS size, + fo.parent_folder_id AS parent_id, + fo.owner_id, + u.username AS owner_name, + fo.deleted_at +FROM folder fo +JOIN "user" u ON fo.owner_id = u.user_id +WHERE fo.status = 'deleted' + AND fo.deleted_at IS NOT NULL; + +CREATE VIEW v_admin_share_overview AS +SELECT + s.share_id, + s.share_code, + s.status, + s.share_type, + s.user_id AS created_by_user_id, + u.username AS created_by, + CASE WHEN s.file_id IS NOT NULL THEN 'file' ELSE 'folder' END AS item_type, + COALESCE(f.file_id, fo.folder_id) AS item_id, + COALESCE(f.file_name, fo.folder_name) AS item_name, + s.permission_role, + s.allow_preview, + s.allow_download, + s.allow_save, + s.allow_reshare, + s.require_login, + s.password_hash IS NOT NULL AS has_password, + s.expire_time, + s.visit_count, + s.download_count, + s.created_at, + s.updated_at +FROM share s +JOIN "user" u ON s.user_id = u.user_id +LEFT JOIN "file" f ON s.file_id = f.file_id +LEFT JOIN folder fo ON s.folder_id = fo.folder_id; + +CREATE VIEW v_user_dashboard AS +SELECT + u.user_id, + u.username, + u.email, + u.status, + u.storage_limit, + u.storage_used, + CASE + WHEN u.storage_limit <= 0 THEN 0::NUMERIC(10,2) + ELSE ROUND((u.storage_used::NUMERIC / u.storage_limit::NUMERIC) * 100, 2) + END AS storage_used_pct, + COALESCE(f.file_count, 0) AS active_file_count, + COALESCE(fo.folder_count, 0) AS active_folder_count, + COALESCE(s.active_share_count, 0) AS active_share_count, + COALESCE(fv.favorite_count, 0) AS favorite_count, + u.last_login_at, + u.created_at +FROM "user" u +LEFT JOIN ( + SELECT owner_id, COUNT(*) AS file_count + FROM "file" + WHERE status = 'active' + GROUP BY owner_id +) f ON u.user_id = f.owner_id +LEFT JOIN ( + SELECT owner_id, COUNT(*) AS folder_count + FROM folder + WHERE status = 'active' + GROUP BY owner_id +) fo ON u.user_id = fo.owner_id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS active_share_count + FROM share + WHERE status = 'active' + AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP) + GROUP BY user_id +) s ON u.user_id = s.user_id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS favorite_count + FROM favorite_item + GROUP BY user_id +) fv ON u.user_id = fv.user_id; diff --git a/docs/sql/postgres/90_comments.sql b/docs/sql/postgres/90_comments.sql new file mode 100644 index 0000000..b28721e --- /dev/null +++ b/docs/sql/postgres/90_comments.sql @@ -0,0 +1,248 @@ +-- ========================= +-- Comments: table and column descriptions +-- ========================= + +COMMENT ON TABLE "user" IS '用户账户主表'; +COMMENT ON COLUMN "user".user_id IS '用户主键'; +COMMENT ON COLUMN "user".username IS '用户名(全局大小写不敏感唯一)'; +COMMENT ON COLUMN "user".email IS '邮箱(全局大小写不敏感唯一)'; +COMMENT ON COLUMN "user".password_hash IS '密码哈希'; +COMMENT ON COLUMN "user".role IS '平台角色,例如 USER/ADMIN'; +COMMENT ON COLUMN "user".status IS '账号状态'; +COMMENT ON COLUMN "user".email_verified IS '邮箱是否已验证'; +COMMENT ON COLUMN "user".storage_limit IS '存储配额(字节)'; +COMMENT ON COLUMN "user".storage_used IS '已用空间(字节)'; +COMMENT ON COLUMN "user".failed_login_count IS '连续登录失败次数'; +COMMENT ON COLUMN "user".locked_until IS '账号锁定截止时间'; + +COMMENT ON TABLE user_group IS '用户组定义'; +COMMENT ON COLUMN user_group.id IS '用户组主键'; +COMMENT ON COLUMN user_group.name IS '用户组名称'; +COMMENT ON COLUMN user_group.description IS '用户组说明'; + +COMMENT ON TABLE user_group_member IS '用户组成员关系'; +COMMENT ON COLUMN user_group_member.id IS '成员关系主键'; +COMMENT ON COLUMN user_group_member.user_id IS '用户ID'; +COMMENT ON COLUMN user_group_member.group_id IS '用户组ID'; +COMMENT ON COLUMN user_group_member.role IS '组内角色'; + +COMMENT ON TABLE storage_object IS '物理存储对象(对象存储层)'; +COMMENT ON COLUMN storage_object.object_id IS '对象主键'; +COMMENT ON COLUMN storage_object.object_hash IS '对象内容哈希'; +COMMENT ON COLUMN storage_object.hash_algorithm IS '哈希算法'; +COMMENT ON COLUMN storage_object.bucket_name IS '对象存储桶名'; +COMMENT ON COLUMN storage_object.object_key IS '对象存储键'; +COMMENT ON COLUMN storage_object.object_size IS '对象大小(字节)'; +COMMENT ON COLUMN storage_object.upload_status IS '上传与可用状态'; +COMMENT ON COLUMN storage_object.scan_status IS '安全扫描状态'; +COMMENT ON COLUMN storage_object.moderation_status IS '内容审核状态'; +COMMENT ON COLUMN storage_object.ref_count IS '被逻辑文件引用计数'; + +COMMENT ON TABLE folder IS '文件夹目录节点'; +COMMENT ON COLUMN folder.folder_id IS '目录主键'; +COMMENT ON COLUMN folder.owner_id IS '目录所有者用户ID'; +COMMENT ON COLUMN folder.parent_folder_id IS '父目录ID,根目录为NULL'; +COMMENT ON COLUMN folder.folder_name IS '目录名'; +COMMENT ON COLUMN folder.cached_size IS '缓存目录大小(字节)'; +COMMENT ON COLUMN folder.status IS '目录状态'; +COMMENT ON COLUMN folder.folder_type IS '目录类型(normal/root/system)'; +COMMENT ON COLUMN folder.deleted_by IS '删除操作者用户ID'; + +COMMENT ON TABLE "file" IS '逻辑文件记录'; +COMMENT ON COLUMN "file".file_id IS '文件主键'; +COMMENT ON COLUMN "file".uploader_id IS '上传者用户ID'; +COMMENT ON COLUMN "file".owner_id IS '文件所有者用户ID'; +COMMENT ON COLUMN "file".folder_id IS '所在目录ID'; +COMMENT ON COLUMN "file".file_name IS '文件名'; +COMMENT ON COLUMN "file".mime_type IS 'MIME 类型'; +COMMENT ON COLUMN "file".storage_object_id IS '关联物理对象ID'; +COMMENT ON COLUMN "file".file_size IS '文件大小(字节)'; +COMMENT ON COLUMN "file".status IS '文件状态'; +COMMENT ON COLUMN "file".last_accessed_at IS '最后访问时间'; + +COMMENT ON TABLE acl IS '访问控制列表(文件/目录授权)'; +COMMENT ON COLUMN acl.id IS 'ACL主键'; +COMMENT ON COLUMN acl.file_id IS '授权文件ID'; +COMMENT ON COLUMN acl.folder_id IS '授权目录ID'; +COMMENT ON COLUMN acl.user_id IS '被授权用户ID'; +COMMENT ON COLUMN acl.group_id IS '被授权用户组ID'; +COMMENT ON COLUMN acl.permission_role IS '权限角色 viewer/editor/manager'; +COMMENT ON COLUMN acl.can_preview IS '是否允许预览'; +COMMENT ON COLUMN acl.can_download IS '是否允许下载'; +COMMENT ON COLUMN acl.can_save IS '是否允许保存到我的网盘'; +COMMENT ON COLUMN acl.can_share IS '是否允许继续分享'; +COMMENT ON COLUMN acl.expire_at IS '授权过期时间'; + +COMMENT ON TABLE share IS '分享链接与分享策略'; +COMMENT ON COLUMN share.share_id IS '分享主键'; +COMMENT ON COLUMN share.user_id IS '分享发起人用户ID'; +COMMENT ON COLUMN share.file_id IS '被分享文件ID'; +COMMENT ON COLUMN share.folder_id IS '被分享目录ID'; +COMMENT ON COLUMN share.share_link IS '兼容旧版的分享链接'; +COMMENT ON COLUMN share.share_code IS '短分享码'; +COMMENT ON COLUMN share.status IS '分享状态'; +COMMENT ON COLUMN share.permission_role IS '分享默认权限角色'; +COMMENT ON COLUMN share.allow_download IS '是否允许下载'; +COMMENT ON COLUMN share.allow_save IS '是否允许保存到我的网盘'; +COMMENT ON COLUMN share.require_login IS '访问是否要求登录'; +COMMENT ON COLUMN share.expire_time IS '分享过期时间'; +COMMENT ON COLUMN share.visit_count IS '访问次数'; +COMMENT ON COLUMN share.download_count IS '下载次数'; + +COMMENT ON TABLE share_member IS '定向分享成员(用户或用户组)'; +COMMENT ON COLUMN share_member.id IS '分享成员主键'; +COMMENT ON COLUMN share_member.share_id IS '关联分享ID'; +COMMENT ON COLUMN share_member.user_id IS '被分享用户ID'; +COMMENT ON COLUMN share_member.group_id IS '被分享用户组ID'; +COMMENT ON COLUMN share_member.status IS '接受状态'; +COMMENT ON COLUMN share_member.target_folder_id IS '接受后落地目录ID'; +COMMENT ON COLUMN share_member.accepted_at IS '接受时间'; + +COMMENT ON TABLE favorite_item IS '用户星标项'; +COMMENT ON COLUMN favorite_item.favorite_id IS '星标主键'; +COMMENT ON COLUMN favorite_item.user_id IS '用户ID'; +COMMENT ON COLUMN favorite_item.item_type IS '星标项类型 file/folder'; +COMMENT ON COLUMN favorite_item.file_id IS '星标文件ID'; +COMMENT ON COLUMN favorite_item.folder_id IS '星标目录ID'; + +COMMENT ON TABLE user_folder_preference IS '用户目录视图偏好'; +COMMENT ON COLUMN user_folder_preference.preference_id IS '偏好主键'; +COMMENT ON COLUMN user_folder_preference.user_id IS '用户ID'; +COMMENT ON COLUMN user_folder_preference.folder_id IS '目录ID,NULL表示全局默认'; +COMMENT ON COLUMN user_folder_preference.view_mode IS '展示模式 list/grid'; +COMMENT ON COLUMN user_folder_preference.sort_by IS '排序字段'; +COMMENT ON COLUMN user_folder_preference.sort_direction IS '排序方向'; + +COMMENT ON TABLE "log" IS '审计日志'; +COMMENT ON COLUMN "log".id IS '日志主键'; +COMMENT ON COLUMN "log".user_id IS '操作用户ID(可为空)'; +COMMENT ON COLUMN "log".actor_type IS '行为主体类型'; +COMMENT ON COLUMN "log".operation IS '操作类型'; +COMMENT ON COLUMN "log".target_type IS '目标资源类型'; +COMMENT ON COLUMN "log".target_id IS '目标资源ID'; +COMMENT ON COLUMN "log".result IS '操作结果'; +COMMENT ON COLUMN "log".request_id IS '请求链路ID'; + +COMMENT ON TABLE notification IS '站内通知'; +COMMENT ON COLUMN notification.id IS '通知主键'; +COMMENT ON COLUMN notification.user_id IS '接收用户ID'; +COMMENT ON COLUMN notification.type IS '通知业务类型'; +COMMENT ON COLUMN notification.channel IS '发送渠道'; +COMMENT ON COLUMN notification.message IS '通知正文'; +COMMENT ON COLUMN notification.payload IS '扩展载荷(JSON)'; +COMMENT ON COLUMN notification.is_read IS '是否已读'; +COMMENT ON COLUMN notification.read_at IS '已读时间'; +COMMENT ON COLUMN notification.sender_user_id IS '发送方用户ID'; + +COMMENT ON TABLE upload_task IS '上传任务'; +COMMENT ON COLUMN upload_task.task_id IS '上传任务主键'; +COMMENT ON COLUMN upload_task.user_id IS '任务所属用户ID'; +COMMENT ON COLUMN upload_task.folder_id IS '目标目录ID'; +COMMENT ON COLUMN upload_task.file_name IS '客户端文件名'; +COMMENT ON COLUMN upload_task.total_size IS '文件总大小(字节)'; +COMMENT ON COLUMN upload_task.chunk_size IS '分片大小(字节)'; +COMMENT ON COLUMN upload_task.uploaded_bytes IS '已上传字节数'; +COMMENT ON COLUMN upload_task.client_file_id IS '客户端幂等ID'; +COMMENT ON COLUMN upload_task.status IS '上传任务状态'; +COMMENT ON COLUMN upload_task.upload_id IS '多段上传会话ID'; + +COMMENT ON TABLE upload_task_part IS '上传任务分片'; +COMMENT ON COLUMN upload_task_part.id IS '分片主键'; +COMMENT ON COLUMN upload_task_part.task_id IS '上传任务ID'; +COMMENT ON COLUMN upload_task_part.part_number IS '分片序号'; +COMMENT ON COLUMN upload_task_part.part_size IS '分片大小(字节)'; +COMMENT ON COLUMN upload_task_part.status IS '分片状态'; +COMMENT ON COLUMN upload_task_part.checksum IS '分片校验值'; +COMMENT ON COLUMN upload_task_part.retry_count IS '分片重试次数'; + +COMMENT ON TABLE password_reset_token IS '密码重置令牌'; +COMMENT ON COLUMN password_reset_token.token_id IS '令牌主键'; +COMMENT ON COLUMN password_reset_token.user_id IS '用户ID'; +COMMENT ON COLUMN password_reset_token.token_hash IS '令牌哈希'; +COMMENT ON COLUMN password_reset_token.expire_at IS '过期时间'; +COMMENT ON COLUMN password_reset_token.used_at IS '使用时间'; + +COMMENT ON TABLE email_verification_token IS '邮箱验证令牌'; +COMMENT ON COLUMN email_verification_token.token_id IS '令牌主键'; +COMMENT ON COLUMN email_verification_token.user_id IS '用户ID'; +COMMENT ON COLUMN email_verification_token.token_hash IS '令牌哈希'; +COMMENT ON COLUMN email_verification_token.expire_at IS '过期时间'; +COMMENT ON COLUMN email_verification_token.verified_at IS '验证完成时间'; + +COMMENT ON TABLE user_session IS '用户会话与刷新令牌'; +COMMENT ON COLUMN user_session.session_id IS '会话主键'; +COMMENT ON COLUMN user_session.user_id IS '用户ID'; +COMMENT ON COLUMN user_session.refresh_token_hash IS '刷新令牌哈希'; +COMMENT ON COLUMN user_session.client_type IS '客户端类型'; +COMMENT ON COLUMN user_session.device_id IS '设备ID'; +COMMENT ON COLUMN user_session.last_seen_at IS '最近活跃时间'; +COMMENT ON COLUMN user_session.expire_at IS '会话过期时间'; +COMMENT ON COLUMN user_session.revoked_at IS '会话吊销时间'; + +COMMENT ON TABLE file_preview_asset IS '文件预览产物'; +COMMENT ON COLUMN file_preview_asset.preview_id IS '预览产物主键'; +COMMENT ON COLUMN file_preview_asset.source_object_id IS '源对象ID'; +COMMENT ON COLUMN file_preview_asset.preview_object_id IS '预览对象ID'; +COMMENT ON COLUMN file_preview_asset.preview_type IS '预览类型'; +COMMENT ON COLUMN file_preview_asset.page_no IS '页码(文档类)'; +COMMENT ON COLUMN file_preview_asset.status IS '预览生成状态'; + +COMMENT ON TABLE file_media_metadata IS '媒体元数据'; +COMMENT ON COLUMN file_media_metadata.metadata_id IS '媒体元数据主键'; +COMMENT ON COLUMN file_media_metadata.source_object_id IS '源对象ID'; +COMMENT ON COLUMN file_media_metadata.width IS '媒体宽度'; +COMMENT ON COLUMN file_media_metadata.height IS '媒体高度'; +COMMENT ON COLUMN file_media_metadata.duration_ms IS '时长(毫秒)'; +COMMENT ON COLUMN file_media_metadata.extra_metadata IS '扩展元数据(JSON)'; + +COMMENT ON TABLE object_scan_result IS '对象扫描结果'; +COMMENT ON COLUMN object_scan_result.scan_id IS '扫描记录主键'; +COMMENT ON COLUMN object_scan_result.object_id IS '对象ID'; +COMMENT ON COLUMN object_scan_result.scan_type IS '扫描类型'; +COMMENT ON COLUMN object_scan_result.engine_name IS '扫描引擎名称'; +COMMENT ON COLUMN object_scan_result.result IS '扫描结果'; +COMMENT ON COLUMN object_scan_result.details IS '扫描明细(JSON)'; + +COMMENT ON TABLE moderation_case IS '内容审核工单'; +COMMENT ON COLUMN moderation_case.case_id IS '工单主键'; +COMMENT ON COLUMN moderation_case.object_id IS '对象ID'; +COMMENT ON COLUMN moderation_case.file_id IS '关联文件ID'; +COMMENT ON COLUMN moderation_case.reason_type IS '触发原因类型'; +COMMENT ON COLUMN moderation_case.confidence IS '置信度'; +COMMENT ON COLUMN moderation_case.status IS '工单状态'; +COMMENT ON COLUMN moderation_case.resolution IS '处置结果'; + +COMMENT ON TABLE security_event IS '安全事件'; +COMMENT ON COLUMN security_event.event_id IS '安全事件主键'; +COMMENT ON COLUMN security_event.user_id IS '关联用户ID'; +COMMENT ON COLUMN security_event.session_id IS '关联会话ID'; +COMMENT ON COLUMN security_event.event_type IS '事件类型'; +COMMENT ON COLUMN security_event.severity IS '风险级别'; +COMMENT ON COLUMN security_event.occurred_at IS '事件发生时间'; + +COMMENT ON TABLE batch_download_task IS '批量下载打包任务'; +COMMENT ON COLUMN batch_download_task.task_id IS '任务主键'; +COMMENT ON COLUMN batch_download_task.user_id IS '用户ID'; +COMMENT ON COLUMN batch_download_task.archive_name IS '压缩包文件名'; +COMMENT ON COLUMN batch_download_task.item_count IS '打包条目数'; +COMMENT ON COLUMN batch_download_task.items IS '打包条目清单(JSON)'; +COMMENT ON COLUMN batch_download_task.status IS '任务状态'; +COMMENT ON COLUMN batch_download_task.storage_object_id IS '生成压缩包对象ID'; +COMMENT ON COLUMN batch_download_task.completed_at IS '完成时间'; + +COMMENT ON TABLE share_access_log IS '分享访问日志'; +COMMENT ON COLUMN share_access_log.id IS '访问日志主键'; +COMMENT ON COLUMN share_access_log.share_id IS '分享ID'; +COMMENT ON COLUMN share_access_log.user_id IS '访问用户ID'; +COMMENT ON COLUMN share_access_log.event_type IS '事件类型(visit/download)'; +COMMENT ON COLUMN share_access_log.result IS '事件结果'; +COMMENT ON COLUMN share_access_log.created_at IS '事件发生时间'; + +COMMENT ON VIEW v_file_folder_details IS '文件与目录统一列表视图'; +COMMENT ON VIEW v_user_permissions IS '用户有效权限视图(直授+组授)'; +COMMENT ON VIEW v_user_storage_summary IS '用户空间使用汇总视图'; +COMMENT ON VIEW v_shared_with_me IS '共享给我的资源视图'; +COMMENT ON VIEW v_full_path IS '目录全路径视图'; +COMMENT ON VIEW v_user_recycle_bin IS '用户回收站视图'; +COMMENT ON VIEW v_admin_share_overview IS '管理员分享总览视图'; +COMMENT ON VIEW v_user_dashboard IS '管理员用户仪表盘视图';