Repository navigation
Expand file tree
/
Copy pathschema.sql
More file actions
289 lines (252 loc) · 10.2 KB
/
Copy pathschema.sql
File metadata and controls
289 lines (252 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
-- ============================================================
-- Forumlify 数据库表结构
-- ============================================================
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
username VARCHAR(20) UNIQUE NOT NULL,
avatar_url TEXT,
bio TEXT DEFAULT '',
role VARCHAR(10) DEFAULT 'user' CHECK (role IN ('user', 'admin')),
token_version INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Keep case-insensitive lookups index-backed for legacy mixed-case emails.
CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users (LOWER(email));
-- 帖子表
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
images TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 回复表
CREATE TABLE IF NOT EXISTS replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 友情链接表
CREATE TABLE IF NOT EXISTS friendly_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(100) NOT NULL,
url TEXT NOT NULL,
sort_order INT DEFAULT 0
);
-- 举报表
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason VARCHAR(100) NOT NULL,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ DEFAULT now(),
handled_at TIMESTAMPTZ,
handler_id UUID REFERENCES users(id) ON DELETE SET NULL,
handler_note TEXT
);
-- Preserve moderation history when a reported post is deleted. This also
-- upgrades databases created with the previous ON DELETE CASCADE constraint.
DO $$
DECLARE
constraint_name TEXT;
BEGIN
SELECT con.conname INTO constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute attr ON attr.attrelid = rel.oid AND attr.attnum = ANY(con.conkey)
WHERE rel.relname = 'reports'
AND con.contype = 'f'
AND attr.attname = 'post_id'
LIMIT 1;
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE reports DROP CONSTRAINT %I', constraint_name);
END IF;
ALTER TABLE reports ALTER COLUMN post_id DROP NOT NULL;
ALTER TABLE reports
ADD CONSTRAINT reports_post_id_fkey
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE SET NULL;
END $$;
-- 事件日志表
CREATE TABLE IF NOT EXISTS event_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
ip VARCHAR(45),
method VARCHAR(10),
path TEXT,
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE event_logs ADD COLUMN IF NOT EXISTS method VARCHAR(10);
ALTER TABLE event_logs ADD COLUMN IF NOT EXISTS path TEXT;
ALTER TABLE event_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
ALTER TABLE event_logs ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS idx_event_logs_created_at ON event_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_logs_user_id ON event_logs(user_id);
-- ============================================================
-- 论坛设置表
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(50) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO settings (key, value) VALUES ('forum_name', 'Forumlify')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- 私信系统
-- ============================================================
-- 私信会话表
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user1_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user2_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_message_at TIMESTAMPTZ DEFAULT now(),
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (user1_id < user2_id),
UNIQUE(user1_id, user2_id)
);
-- 私信消息表
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user1_id ON conversations(user1_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user2_id ON conversations(user2_id);
-- ============================================================
-- 自定义页面表
-- ============================================================
CREATE TABLE IF NOT EXISTS custom_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL UNIQUE,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_custom_pages_name ON custom_pages(name);
-- ============================================================
-- 通知系统
-- ============================================================
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(30) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
link TEXT,
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read);
-- ============================================================
-- 自动更新 updated_at 的触发器
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_posts_updated_at ON posts;
CREATE TRIGGER update_posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
ALTER TABLE posts ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
-- ============================================================
-- 恢复码表(密码重置用)
-- ============================================================
CREATE TABLE IF NOT EXISTS recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
is_used BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user_id ON recovery_codes(user_id);
-- 帖子表添加置顶字段
ALTER TABLE posts ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT false;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_posts_is_pinned ON posts(is_pinned);
ALTER TABLE users ADD COLUMN IF NOT EXISTS signature TEXT DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0;
-- Canonicalize conversation pairs and merge any pre-existing reverse/duplicate
-- conversations before enforcing one row per unordered user pair.
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_user1_id_user2_id_key;
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_canonical_pair;
WITH ranked AS (
SELECT
id,
FIRST_VALUE(id) OVER (
PARTITION BY LEAST(user1_id, user2_id), GREATEST(user1_id, user2_id)
ORDER BY created_at, id
) AS keep_id
FROM conversations
)
UPDATE messages AS m
SET conversation_id = ranked.keep_id
FROM ranked
WHERE m.conversation_id = ranked.id
AND ranked.id <> ranked.keep_id;
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY LEAST(user1_id, user2_id), GREATEST(user1_id, user2_id)
ORDER BY created_at, id
) AS row_number
FROM conversations
)
DELETE FROM conversations AS c
USING ranked
WHERE c.id = ranked.id AND ranked.row_number > 1;
UPDATE conversations
SET user1_id = LEAST(user1_id, user2_id),
user2_id = GREATEST(user1_id, user2_id);
ALTER TABLE conversations
ADD CONSTRAINT conversations_canonical_pair CHECK (user1_id < user2_id);
ALTER TABLE conversations
ADD CONSTRAINT conversations_user1_id_user2_id_key UNIQUE (user1_id, user2_id);
-- Keep one pending report per reporter/post at the database layer. Historical
-- duplicates are rejected before creating the partial unique index.
WITH duplicates AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY post_id, reporter_id
ORDER BY created_at, id
) AS row_number
FROM reports
WHERE status = 'pending' AND post_id IS NOT NULL
)
UPDATE reports AS r
SET status = 'rejected',
handled_at = COALESCE(r.handled_at, NOW()),
handler_note = COALESCE(r.handler_note, '重复举报已自动合并')
FROM duplicates
WHERE r.id = duplicates.id AND duplicates.row_number > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_reports_unique_pending
ON reports(post_id, reporter_id)
WHERE status = 'pending' AND post_id IS NOT NULL;
-- ============================================================
-- 论坛默认设置
-- ============================================================
INSERT INTO settings (key, value) VALUES ('forum_name', 'Forumlify')
ON CONFLICT (key) DO NOTHING;