schema.sql 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. CREATE TABLE IF NOT EXISTS users (
  2. id UUID PRIMARY KEY,
  3. phone VARCHAR(20) UNIQUE NOT NULL,
  4. password_hash TEXT NOT NULL,
  5. role VARCHAR(20) NOT NULL DEFAULT 'USER',
  6. organization VARCHAR(120) NOT NULL DEFAULT '',
  7. wechat VARCHAR(80) NOT NULL DEFAULT '',
  8. contact_name VARCHAR(80) NOT NULL DEFAULT '',
  9. bio VARCHAR(500) NOT NULL DEFAULT '',
  10. points_balance INT NOT NULL DEFAULT 10000 CHECK (points_balance >= 0),
  11. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  12. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  13. );
  14. ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
  15. ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INT NOT NULL DEFAULT 10000;
  16. ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
  17. UPDATE users SET must_change_password = FALSE WHERE must_change_password = TRUE;
  18. ALTER TABLE users ALTER COLUMN points_balance SET DEFAULT 10000;
  19. CREATE TABLE IF NOT EXISTS platform_admins (
  20. id UUID PRIMARY KEY,
  21. phone VARCHAR(20) UNIQUE NOT NULL,
  22. password_hash TEXT NOT NULL,
  23. contact_name VARCHAR(80) NOT NULL DEFAULT '',
  24. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  25. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  26. );
  27. CREATE TABLE IF NOT EXISTS platform_admin_sessions (
  28. token_hash CHAR(64) PRIMARY KEY,
  29. admin_id UUID NOT NULL REFERENCES platform_admins(id) ON DELETE CASCADE,
  30. expires_at TIMESTAMPTZ NOT NULL,
  31. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  32. );
  33. CREATE INDEX IF NOT EXISTS idx_platform_admin_sessions_admin ON platform_admin_sessions(admin_id);
  34. CREATE OR REPLACE FUNCTION reject_platform_admin_user() RETURNS trigger AS $$
  35. BEGIN
  36. PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
  37. IF NEW.role <> 'USER' THEN RAISE EXCEPTION 'users table only accepts USER role'; END IF;
  38. IF EXISTS (SELECT 1 FROM platform_admins WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to platform administrator'; END IF;
  39. RETURN NEW;
  40. END;
  41. $$ LANGUAGE plpgsql;
  42. DROP TRIGGER IF EXISTS trg_reject_platform_admin_user ON users;
  43. CREATE TRIGGER trg_reject_platform_admin_user BEFORE INSERT OR UPDATE OF phone, role ON users
  44. FOR EACH ROW EXECUTE FUNCTION reject_platform_admin_user();
  45. CREATE OR REPLACE FUNCTION reject_existing_user_admin() RETURNS trigger AS $$
  46. BEGIN
  47. PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
  48. IF EXISTS (SELECT 1 FROM users WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to ordinary user'; END IF;
  49. RETURN NEW;
  50. END;
  51. $$ LANGUAGE plpgsql;
  52. DROP TRIGGER IF EXISTS trg_reject_existing_user_admin ON platform_admins;
  53. CREATE TRIGGER trg_reject_existing_user_admin BEFORE INSERT OR UPDATE OF phone ON platform_admins
  54. FOR EACH ROW EXECUTE FUNCTION reject_existing_user_admin();
  55. CREATE TABLE IF NOT EXISTS organizations (
  56. id UUID PRIMARY KEY,
  57. slug VARCHAR(80) UNIQUE NOT NULL,
  58. name VARCHAR(120) NOT NULL,
  59. is_active BOOLEAN NOT NULL DEFAULT TRUE,
  60. points_balance INT NOT NULL DEFAULT 0 CHECK (points_balance >= 0),
  61. invite_token_hash CHAR(64),
  62. invite_token TEXT,
  63. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  64. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  65. );
  66. ALTER TABLE organizations ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
  67. ALTER TABLE organizations ADD COLUMN IF NOT EXISTS invite_token TEXT;
  68. CREATE TABLE IF NOT EXISTS organization_members (
  69. organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  70. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  71. role VARCHAR(20) NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('MEMBER', 'MANAGER')),
  72. joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  73. PRIMARY KEY (organization_id, user_id)
  74. );
  75. CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
  76. CREATE TABLE IF NOT EXISTS organization_applications (
  77. id UUID PRIMARY KEY,
  78. organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  79. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  80. status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')),
  81. reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
  82. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  83. reviewed_at TIMESTAMPTZ,
  84. UNIQUE (organization_id, user_id)
  85. );
  86. ALTER TABLE organization_applications ADD COLUMN IF NOT EXISTS reviewed_by_admin UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
  87. CREATE INDEX IF NOT EXISTS idx_organization_applications_org ON organization_applications(organization_id, status);
  88. CREATE TABLE IF NOT EXISTS audit_events (
  89. id UUID PRIMARY KEY,
  90. organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL,
  91. course_id UUID,
  92. actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
  93. subject_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
  94. action VARCHAR(100) NOT NULL,
  95. details JSONB NOT NULL DEFAULT '{}'::jsonb,
  96. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  97. );
  98. ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
  99. CREATE INDEX IF NOT EXISTS idx_audit_events_org ON audit_events(organization_id, created_at DESC);
  100. CREATE TABLE IF NOT EXISTS points_ledger (
  101. id UUID PRIMARY KEY,
  102. owner_type VARCHAR(20) NOT NULL CHECK (owner_type IN ('PERSONAL', 'ORGANIZATION')),
  103. owner_id UUID NOT NULL,
  104. course_id UUID,
  105. course_name_snapshot VARCHAR(100),
  106. beneficiary_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
  107. actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
  108. amount INT NOT NULL,
  109. reason VARCHAR(200) NOT NULL,
  110. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  111. );
  112. ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
  113. CREATE INDEX IF NOT EXISTS idx_points_ledger_owner ON points_ledger(owner_type, owner_id, created_at DESC);
  114. ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS course_name_snapshot VARCHAR(100);
  115. CREATE OR REPLACE FUNCTION initialize_personal_points_ledger() RETURNS trigger AS $$
  116. BEGIN
  117. INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
  118. VALUES(gen_random_uuid(),'PERSONAL',NEW.id,NEW.id,NEW.points_balance,'账户初始积分');
  119. RETURN NEW;
  120. END;
  121. $$ LANGUAGE plpgsql;
  122. DROP TRIGGER IF EXISTS trg_personal_points_opening ON users;
  123. CREATE TRIGGER trg_personal_points_opening AFTER INSERT ON users
  124. FOR EACH ROW EXECUTE FUNCTION initialize_personal_points_ledger();
  125. CREATE TABLE IF NOT EXISTS sessions (
  126. token_hash CHAR(64) PRIMARY KEY,
  127. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  128. expires_at TIMESTAMPTZ NOT NULL,
  129. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  130. );
  131. CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
  132. CREATE TABLE IF NOT EXISTS courses (
  133. id UUID PRIMARY KEY,
  134. user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  135. name VARCHAR(100) NOT NULL,
  136. category VARCHAR(50) NOT NULL DEFAULT '',
  137. description VARCHAR(500) NOT NULL DEFAULT '',
  138. status VARCHAR(30) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED')),
  139. production_notes TEXT NOT NULL DEFAULT '',
  140. asset_notes TEXT NOT NULL DEFAULT '',
  141. estimated_minutes INT NOT NULL DEFAULT 1,
  142. estimated_points INT NOT NULL DEFAULT 1000,
  143. actual_points INT,
  144. points_charged BOOLEAN NOT NULL DEFAULT FALSE,
  145. ppt_original_name VARCHAR(255),
  146. ppt_stored_name VARCHAR(255),
  147. ppt_size BIGINT,
  148. ppt_mime_type VARCHAR(150),
  149. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  150. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  151. submitted_at TIMESTAMPTZ,
  152. completed_at TIMESTAMPTZ
  153. );
  154. ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
  155. ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT;
  156. CREATE INDEX IF NOT EXISTS idx_courses_organization_created ON courses(organization_id, created_at DESC);
  157. ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_notes TEXT NOT NULL DEFAULT '';
  158. ALTER TABLE courses ADD COLUMN IF NOT EXISTS asset_notes TEXT NOT NULL DEFAULT '';
  159. ALTER TABLE courses ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
  160. ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_minutes INT NOT NULL DEFAULT 1;
  161. ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_points INT NOT NULL DEFAULT 1000;
  162. ALTER TABLE courses ALTER COLUMN estimated_points SET DEFAULT 1000;
  163. ALTER TABLE courses ADD COLUMN IF NOT EXISTS actual_points INT;
  164. ALTER TABLE courses ADD COLUMN IF NOT EXISTS points_charged BOOLEAN NOT NULL DEFAULT FALSE;
  165. DO $$
  166. BEGIN
  167. ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_status_check;
  168. ALTER TABLE courses ADD CONSTRAINT courses_status_check CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'));
  169. EXCEPTION
  170. WHEN OTHERS THEN NULL;
  171. END $$;
  172. CREATE INDEX IF NOT EXISTS idx_courses_user_created ON courses(user_id, created_at DESC);
  173. CREATE INDEX IF NOT EXISTS idx_courses_status_created ON courses(status, created_at DESC);
  174. -- 课程分集表(Episodes)
  175. CREATE TABLE IF NOT EXISTS episodes (
  176. id UUID PRIMARY KEY,
  177. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  178. episode_number INT NOT NULL DEFAULT 1,
  179. title VARCHAR(200) NOT NULL,
  180. summary VARCHAR(500) NOT NULL DEFAULT '',
  181. lecture_notes TEXT NOT NULL DEFAULT '',
  182. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  183. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  184. );
  185. CREATE INDEX IF NOT EXISTS idx_episodes_course ON episodes(course_id, episode_number ASC);
  186. CREATE TABLE IF NOT EXISTS course_assets (
  187. id UUID PRIMARY KEY,
  188. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  189. episode_id UUID REFERENCES episodes(id) ON DELETE CASCADE,
  190. original_name VARCHAR(255) NOT NULL,
  191. stored_name VARCHAR(255) NOT NULL,
  192. size BIGINT NOT NULL,
  193. mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
  194. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  195. );
  196. -- 平台制作阶段对单门课程的讲师快照补充的照片与声音,不影响讲师资产库。
  197. CREATE TABLE IF NOT EXISTS course_instructor_files (
  198. id UUID PRIMARY KEY,
  199. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  200. kind TEXT NOT NULL CHECK (kind IN ('IMAGE', 'AUDIO')),
  201. original_name TEXT NOT NULL,
  202. stored_name TEXT NOT NULL UNIQUE,
  203. size BIGINT NOT NULL,
  204. mime_type TEXT NOT NULL,
  205. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  206. );
  207. CREATE INDEX IF NOT EXISTS idx_course_instructor_files_course ON course_instructor_files(course_id);
  208. ALTER TABLE course_assets ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE CASCADE;
  209. CREATE INDEX IF NOT EXISTS idx_assets_course ON course_assets(course_id, created_at);
  210. CREATE INDEX IF NOT EXISTS idx_assets_episode ON course_assets(episode_id, created_at);
  211. CREATE TABLE IF NOT EXISTS instructors (
  212. id UUID PRIMARY KEY,
  213. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  214. name VARCHAR(80) NOT NULL,
  215. organization VARCHAR(120) NOT NULL DEFAULT '',
  216. introduction VARCHAR(1000) NOT NULL DEFAULT '',
  217. image_original_name VARCHAR(255),
  218. image_stored_name VARCHAR(255),
  219. image_mime_type VARCHAR(100),
  220. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  221. );
  222. CREATE INDEX IF NOT EXISTS idx_instructors_course ON instructors(course_id, created_at);
  223. CREATE TABLE IF NOT EXISTS instructor_images (
  224. id UUID PRIMARY KEY,
  225. instructor_id UUID NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
  226. original_name VARCHAR(255) NOT NULL,
  227. stored_name VARCHAR(255) NOT NULL,
  228. mime_type VARCHAR(100) NOT NULL,
  229. sort_order INTEGER NOT NULL DEFAULT 0,
  230. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  231. );
  232. CREATE INDEX IF NOT EXISTS idx_instructor_images_instructor ON instructor_images(instructor_id, sort_order, created_at);
  233. -- 数字讲师属于一个工作区;旧课程讲师保留原表作历史资料。
  234. CREATE TABLE IF NOT EXISTS digital_instructors (
  235. id UUID PRIMARY KEY,
  236. organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT,
  237. personal_user_id UUID REFERENCES users(id) ON DELETE RESTRICT,
  238. owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  239. creator_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  240. source_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL,
  241. legacy_instructor_id UUID UNIQUE REFERENCES instructors(id) ON DELETE SET NULL,
  242. name VARCHAR(80) NOT NULL,
  243. organization VARCHAR(120) NOT NULL DEFAULT '',
  244. introduction VARCHAR(1000) NOT NULL DEFAULT '',
  245. note VARCHAR(1000),
  246. quality_status VARCHAR(30) NOT NULL DEFAULT 'NOT_CHECKED',
  247. deleted_at TIMESTAMPTZ,
  248. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  249. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  250. CHECK ((organization_id IS NULL) <> (personal_user_id IS NULL))
  251. );
  252. ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
  253. ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS note VARCHAR(1000);
  254. ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS admin_creator_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
  255. ALTER TABLE digital_instructors ALTER COLUMN creator_id DROP NOT NULL;
  256. CREATE INDEX IF NOT EXISTS idx_digital_instructors_workspace ON digital_instructors(organization_id, personal_user_id, created_at DESC);
  257. CREATE TABLE IF NOT EXISTS digital_instructor_files (
  258. id UUID PRIMARY KEY,
  259. instructor_id UUID NOT NULL REFERENCES digital_instructors(id) ON DELETE CASCADE,
  260. kind VARCHAR(10) NOT NULL CHECK (kind IN ('IMAGE', 'AUDIO')),
  261. original_name VARCHAR(255) NOT NULL,
  262. stored_name VARCHAR(255) NOT NULL UNIQUE,
  263. size BIGINT NOT NULL DEFAULT 0,
  264. mime_type VARCHAR(150) NOT NULL,
  265. sort_order INT NOT NULL DEFAULT 0,
  266. is_active BOOLEAN NOT NULL DEFAULT TRUE,
  267. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  268. );
  269. ALTER TABLE digital_instructor_files ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
  270. CREATE INDEX IF NOT EXISTS idx_digital_instructor_files_parent ON digital_instructor_files(instructor_id, kind, sort_order);
  271. CREATE TABLE IF NOT EXISTS digital_instructor_events (
  272. id UUID PRIMARY KEY,
  273. instructor_id UUID,
  274. actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
  275. action VARCHAR(50) NOT NULL,
  276. details JSONB NOT NULL DEFAULT '{}',
  277. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  278. );
  279. ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL;
  280. ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_snapshot JSONB;
  281. -- 幂等迁移:保留旧讲师及文件;缺录音的历史记录不能供新课程选择。
  282. INSERT INTO digital_instructors(id, organization_id, personal_user_id, owner_id, creator_id, legacy_instructor_id, name, organization, introduction, created_at)
  283. SELECT i.id, c.organization_id, CASE WHEN c.organization_id IS NULL THEN c.user_id ELSE NULL END,
  284. c.user_id, c.user_id, i.id, i.name, i.organization, i.introduction, i.created_at
  285. FROM instructors i JOIN courses c ON c.id=i.course_id WHERE c.user_id IS NOT NULL
  286. ON CONFLICT DO NOTHING;
  287. INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
  288. SELECT ii.id,ii.instructor_id,'IMAGE',ii.original_name,ii.stored_name,0,ii.mime_type,ii.sort_order,ii.created_at
  289. FROM instructor_images ii JOIN digital_instructors di ON di.legacy_instructor_id=ii.instructor_id
  290. ON CONFLICT DO NOTHING;
  291. INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
  292. SELECT gen_random_uuid(),i.id,'IMAGE',COALESCE(i.image_original_name,'历史照片'),i.image_stored_name,0,
  293. COALESCE(i.image_mime_type,'image/jpeg'),0,i.created_at
  294. FROM instructors i JOIN digital_instructors di ON di.legacy_instructor_id=i.id
  295. WHERE i.image_stored_name IS NOT NULL
  296. ON CONFLICT DO NOTHING;
  297. UPDATE courses c SET digital_instructor_id=(SELECT di.id FROM digital_instructors di JOIN instructors i ON i.id=di.legacy_instructor_id WHERE i.course_id=c.id ORDER BY i.created_at LIMIT 1)
  298. WHERE c.digital_instructor_id IS NULL AND EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id);
  299. UPDATE digital_instructors SET note=introduction WHERE note IS NULL;
  300. ALTER TABLE digital_instructors ALTER COLUMN note SET DEFAULT '';
  301. ALTER TABLE digital_instructors ALTER COLUMN note SET NOT NULL;
  302. -- 保留历史重名记录;新建、改名和复制必须在组织内唯一,行锁防止并发写入重名。
  303. CREATE OR REPLACE FUNCTION check_digital_instructor_name() RETURNS trigger AS $$
  304. BEGIN
  305. IF NEW.organization_id IS NOT NULL AND NEW.deleted_at IS NULL THEN
  306. PERFORM 1 FROM organizations WHERE id=NEW.organization_id FOR UPDATE;
  307. IF EXISTS (SELECT 1 FROM digital_instructors d WHERE d.organization_id=NEW.organization_id
  308. AND d.deleted_at IS NULL AND d.id<>NEW.id AND lower(btrim(d.name))=lower(btrim(NEW.name))) THEN
  309. RAISE EXCEPTION 'digital instructor name already exists' USING ERRCODE='23505', CONSTRAINT='digital_instructors_org_name_unique';
  310. END IF;
  311. END IF;
  312. RETURN NEW;
  313. END $$ LANGUAGE plpgsql;
  314. DROP TRIGGER IF EXISTS trg_digital_instructor_name ON digital_instructors;
  315. CREATE TRIGGER trg_digital_instructor_name BEFORE INSERT OR UPDATE OF name, organization_id, deleted_at
  316. ON digital_instructors FOR EACH ROW EXECUTE FUNCTION check_digital_instructor_name();
  317. CREATE TABLE IF NOT EXISTS course_deliverables (
  318. id UUID PRIMARY KEY,
  319. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  320. episode_id UUID REFERENCES episodes(id) ON DELETE SET NULL,
  321. original_name VARCHAR(255) NOT NULL,
  322. stored_name VARCHAR(255) NOT NULL,
  323. size BIGINT NOT NULL,
  324. mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
  325. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  326. );
  327. ALTER TABLE course_deliverables ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE SET NULL;
  328. CREATE INDEX IF NOT EXISTS idx_deliverables_course ON course_deliverables(course_id, created_at);
  329. CREATE INDEX IF NOT EXISTS idx_deliverables_episode ON course_deliverables(episode_id, created_at);
  330. CREATE TABLE IF NOT EXISTS course_progress_events (
  331. id UUID PRIMARY KEY,
  332. course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  333. actor_type VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
  334. event_type VARCHAR(40) NOT NULL,
  335. title VARCHAR(120) NOT NULL,
  336. description TEXT NOT NULL DEFAULT '',
  337. metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
  338. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  339. );
  340. CREATE INDEX IF NOT EXISTS idx_course_progress_events ON course_progress_events(course_id, created_at DESC);
  341. -- 默认按每集 1000 积分计费,并同步刷新已有课程的默认值。
  342. UPDATE courses c
  343. SET estimated_points = GREATEST(1, (SELECT COUNT(*) FROM episodes e WHERE e.course_id = c.id)) * 1000;
  344. -- 旧账户的既有余额与已完成课程,折算为可追溯的个人流水。
  345. INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
  346. SELECT gen_random_uuid(),'PERSONAL',u.id,u.id,
  347. u.points_balance + COALESCE(SUM(COALESCE(c.actual_points,c.estimated_points)) FILTER (WHERE c.points_charged AND c.organization_id IS NULL),0)::int,
  348. '历史账户期初余额'
  349. FROM users u LEFT JOIN courses c ON c.user_id=u.id
  350. WHERE NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.owner_id=u.id)
  351. GROUP BY u.id;
  352. INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,amount,reason,created_at)
  353. SELECT gen_random_uuid(),'PERSONAL',c.user_id,c.id,c.name,c.user_id,
  354. -COALESCE(c.actual_points,c.estimated_points),'历史课程完成扣费',COALESCE(c.completed_at,c.updated_at)
  355. FROM courses c WHERE c.points_charged AND c.organization_id IS NULL
  356. AND NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.course_id=c.id);
  357. UPDATE points_ledger l SET course_name_snapshot=c.name FROM courses c
  358. WHERE l.course_id=c.id AND l.course_name_snapshot IS NULL;
  359. ALTER TABLE digital_instructor_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
  360. -- 广告落地页(/lp)收集的课程咨询线索。无需登录即可写入,由管理员在后台跟进。
  361. CREATE TABLE IF NOT EXISTS lead_requests (
  362. id UUID PRIMARY KEY,
  363. name VARCHAR(80) NOT NULL,
  364. phone VARCHAR(20) NOT NULL,
  365. wechat VARCHAR(80) NOT NULL DEFAULT '',
  366. need_type VARCHAR(40) NOT NULL DEFAULT 'OTHER',
  367. course_scale VARCHAR(40) NOT NULL DEFAULT '',
  368. message TEXT NOT NULL DEFAULT '',
  369. source VARCHAR(120) NOT NULL DEFAULT '',
  370. user_id UUID REFERENCES users(id) ON DELETE SET NULL,
  371. status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
  372. note TEXT NOT NULL DEFAULT '',
  373. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  374. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  375. );
  376. ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PENDING';
  377. ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '';
  378. ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS source VARCHAR(120) NOT NULL DEFAULT '';
  379. CREATE INDEX IF NOT EXISTS idx_lead_requests_created ON lead_requests(created_at DESC);
  380. CREATE INDEX IF NOT EXISTS idx_lead_requests_phone ON lead_requests(phone);