瀏覽代碼

feat(db): 组织工作区/数字讲师/积分账本/审计/生产队列 数据模型与 schema 迁移

bob 1 月之前
父節點
當前提交
eaf830a428
共有 3 個文件被更改,包括 260 次插入 和 3 次删除
  1. 8 2
      server/db.ts
  2. 216 0
      server/schema.sql
  3. 36 1
      src/types.ts

+ 8 - 2
server/db.ts

@@ -9,11 +9,12 @@ export const mapUser = (r: any) => ({
   id: r.id,
   phone: r.phone,
   role: r.role || 'USER',
-  organization: r.organization || '',
+  organization: '',
   wechat: r.wechat || '',
   contactName: r.contact_name || '',
   bio: r.bio || '',
   pointsBalance: Number(r.points_balance) || 0,
+  mustChangePassword: Boolean(r.must_change_password),
   createdAt: r.created_at,
   updatedAt: r.updated_at
 })
@@ -82,6 +83,8 @@ export const mapCourse = (
 
   return {
     id: r.id,
+    organizationId: r.organization_id || null,
+    organizationName: r.organization_name || null,
     userId: r.user_id,
     name: r.name,
     category: r.category || '',
@@ -102,17 +105,20 @@ export const mapCourse = (
       createdAt: event.created_at
     })),
     createdAt: r.created_at,
+    updatedAt: r.updated_at,
     submittedAt: r.submitted_at || null,
     completedAt: r.completed_at || null,
     assets: mappedAssets,
     episodes: mappedEpisodes,
     instructors: instructors.map(mapInstructor),
+    digitalInstructorId: r.digital_instructor_id || null,
+    digitalInstructorSnapshot: r.digital_instructor_snapshot || null,
     deliverables: mappedDeliverables,
     creator: creatorUser ? mapUser(creatorUser) : (r.user_phone ? {
       id: r.user_id,
       phone: r.user_phone,
       role: r.user_role || 'USER',
-      organization: r.user_organization || '',
+      organization: '',
       wechat: r.user_wechat || '',
       contactName: r.user_contact_name || '',
       bio: r.user_bio || '',

+ 216 - 0
server/schema.sql

@@ -14,8 +14,117 @@ CREATE TABLE IF NOT EXISTS users (
 
 ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
 ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INT NOT NULL DEFAULT 10000;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
 ALTER TABLE users ALTER COLUMN points_balance SET DEFAULT 10000;
 
+CREATE TABLE IF NOT EXISTS platform_admins (
+  id UUID PRIMARY KEY,
+  phone VARCHAR(20) UNIQUE NOT NULL,
+  password_hash TEXT NOT NULL,
+  contact_name VARCHAR(80) NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE TABLE IF NOT EXISTS platform_admin_sessions (
+  token_hash CHAR(64) PRIMARY KEY,
+  admin_id UUID NOT NULL REFERENCES platform_admins(id) ON DELETE CASCADE,
+  expires_at TIMESTAMPTZ NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_platform_admin_sessions_admin ON platform_admin_sessions(admin_id);
+CREATE OR REPLACE FUNCTION reject_platform_admin_user() RETURNS trigger AS $$
+BEGIN
+  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
+  IF NEW.role <> 'USER' THEN RAISE EXCEPTION 'users table only accepts USER role'; END IF;
+  IF EXISTS (SELECT 1 FROM platform_admins WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to platform administrator'; END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_reject_platform_admin_user ON users;
+CREATE TRIGGER trg_reject_platform_admin_user BEFORE INSERT OR UPDATE OF phone, role ON users
+FOR EACH ROW EXECUTE FUNCTION reject_platform_admin_user();
+CREATE OR REPLACE FUNCTION reject_existing_user_admin() RETURNS trigger AS $$
+BEGIN
+  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
+  IF EXISTS (SELECT 1 FROM users WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to ordinary user'; END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_reject_existing_user_admin ON platform_admins;
+CREATE TRIGGER trg_reject_existing_user_admin BEFORE INSERT OR UPDATE OF phone ON platform_admins
+FOR EACH ROW EXECUTE FUNCTION reject_existing_user_admin();
+
+CREATE TABLE IF NOT EXISTS organizations (
+  id UUID PRIMARY KEY,
+  slug VARCHAR(80) UNIQUE NOT NULL,
+  name VARCHAR(120) NOT NULL,
+  is_active BOOLEAN NOT NULL DEFAULT TRUE,
+  points_balance INT NOT NULL DEFAULT 0 CHECK (points_balance >= 0),
+  invite_token_hash CHAR(64),
+  invite_token TEXT,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE organizations ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE organizations ADD COLUMN IF NOT EXISTS invite_token TEXT;
+CREATE TABLE IF NOT EXISTS organization_members (
+  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  role VARCHAR(20) NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('MEMBER', 'MANAGER')),
+  joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  PRIMARY KEY (organization_id, user_id)
+);
+CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
+CREATE TABLE IF NOT EXISTS organization_applications (
+  id UUID PRIMARY KEY,
+  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')),
+  reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  reviewed_at TIMESTAMPTZ,
+  UNIQUE (organization_id, user_id)
+);
+ALTER TABLE organization_applications ADD COLUMN IF NOT EXISTS reviewed_by_admin UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_organization_applications_org ON organization_applications(organization_id, status);
+CREATE TABLE IF NOT EXISTS audit_events (
+  id UUID PRIMARY KEY,
+  organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL,
+  course_id UUID,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  subject_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  action VARCHAR(100) NOT NULL,
+  details JSONB NOT NULL DEFAULT '{}'::jsonb,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_audit_events_org ON audit_events(organization_id, created_at DESC);
+CREATE TABLE IF NOT EXISTS points_ledger (
+  id UUID PRIMARY KEY,
+  owner_type VARCHAR(20) NOT NULL CHECK (owner_type IN ('PERSONAL', 'ORGANIZATION')),
+  owner_id UUID NOT NULL,
+  course_id UUID,
+  course_name_snapshot VARCHAR(100),
+  beneficiary_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  amount INT NOT NULL,
+  reason VARCHAR(200) NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_points_ledger_owner ON points_ledger(owner_type, owner_id, created_at DESC);
+ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS course_name_snapshot VARCHAR(100);
+CREATE OR REPLACE FUNCTION initialize_personal_points_ledger() RETURNS trigger AS $$
+BEGIN
+  INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
+  VALUES(gen_random_uuid(),'PERSONAL',NEW.id,NEW.id,NEW.points_balance,'账户初始积分');
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_personal_points_opening ON users;
+CREATE TRIGGER trg_personal_points_opening AFTER INSERT ON users
+FOR EACH ROW EXECUTE FUNCTION initialize_personal_points_ledger();
+
 CREATE TABLE IF NOT EXISTS sessions (
   token_hash CHAR(64) PRIMARY KEY,
   user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -47,6 +156,8 @@ CREATE TABLE IF NOT EXISTS courses (
 );
 
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT;
+CREATE INDEX IF NOT EXISTS idx_courses_organization_created ON courses(organization_id, created_at DESC);
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_notes TEXT NOT NULL DEFAULT '';
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_minutes INT NOT NULL DEFAULT 1;
@@ -117,6 +228,93 @@ CREATE TABLE IF NOT EXISTS instructor_images (
 );
 CREATE INDEX IF NOT EXISTS idx_instructor_images_instructor ON instructor_images(instructor_id, sort_order, created_at);
 
+-- 数字讲师属于一个工作区;旧课程讲师保留原表作历史资料。
+CREATE TABLE IF NOT EXISTS digital_instructors (
+  id UUID PRIMARY KEY,
+  organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT,
+  personal_user_id UUID REFERENCES users(id) ON DELETE RESTRICT,
+  owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+  creator_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+  source_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL,
+  legacy_instructor_id UUID UNIQUE REFERENCES instructors(id) ON DELETE SET NULL,
+  name VARCHAR(80) NOT NULL,
+  organization VARCHAR(120) NOT NULL DEFAULT '',
+  introduction VARCHAR(1000) NOT NULL DEFAULT '',
+  note VARCHAR(1000),
+  quality_status VARCHAR(30) NOT NULL DEFAULT 'NOT_CHECKED',
+  deleted_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  CHECK ((organization_id IS NULL) <> (personal_user_id IS NULL))
+);
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS note VARCHAR(1000);
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS admin_creator_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+ALTER TABLE digital_instructors ALTER COLUMN creator_id DROP NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_digital_instructors_workspace ON digital_instructors(organization_id, personal_user_id, created_at DESC);
+CREATE TABLE IF NOT EXISTS digital_instructor_files (
+  id UUID PRIMARY KEY,
+  instructor_id UUID NOT NULL REFERENCES digital_instructors(id) ON DELETE CASCADE,
+  kind VARCHAR(10) NOT NULL CHECK (kind IN ('IMAGE', 'AUDIO')),
+  original_name VARCHAR(255) NOT NULL,
+  stored_name VARCHAR(255) NOT NULL UNIQUE,
+  size BIGINT NOT NULL DEFAULT 0,
+  mime_type VARCHAR(150) NOT NULL,
+  sort_order INT NOT NULL DEFAULT 0,
+  is_active BOOLEAN NOT NULL DEFAULT TRUE,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE digital_instructor_files ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
+CREATE INDEX IF NOT EXISTS idx_digital_instructor_files_parent ON digital_instructor_files(instructor_id, kind, sort_order);
+CREATE TABLE IF NOT EXISTS digital_instructor_events (
+  id UUID PRIMARY KEY,
+  instructor_id UUID,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  action VARCHAR(50) NOT NULL,
+  details JSONB NOT NULL DEFAULT '{}',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_snapshot JSONB;
+
+-- 幂等迁移:保留旧讲师及文件;缺录音的历史记录不能供新课程选择。
+INSERT INTO digital_instructors(id, organization_id, personal_user_id, owner_id, creator_id, legacy_instructor_id, name, organization, introduction, created_at)
+SELECT i.id, c.organization_id, CASE WHEN c.organization_id IS NULL THEN c.user_id ELSE NULL END,
+       c.user_id, c.user_id, i.id, i.name, i.organization, i.introduction, i.created_at
+FROM instructors i JOIN courses c ON c.id=i.course_id WHERE c.user_id IS NOT NULL
+ON CONFLICT DO NOTHING;
+INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
+SELECT ii.id,ii.instructor_id,'IMAGE',ii.original_name,ii.stored_name,0,ii.mime_type,ii.sort_order,ii.created_at
+FROM instructor_images ii JOIN digital_instructors di ON di.legacy_instructor_id=ii.instructor_id
+ON CONFLICT DO NOTHING;
+INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
+SELECT gen_random_uuid(),i.id,'IMAGE',COALESCE(i.image_original_name,'历史照片'),i.image_stored_name,0,
+       COALESCE(i.image_mime_type,'image/jpeg'),0,i.created_at
+FROM instructors i JOIN digital_instructors di ON di.legacy_instructor_id=i.id
+WHERE i.image_stored_name IS NOT NULL
+ON CONFLICT DO NOTHING;
+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)
+WHERE c.digital_instructor_id IS NULL AND EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id);
+UPDATE digital_instructors SET note=introduction WHERE note IS NULL;
+ALTER TABLE digital_instructors ALTER COLUMN note SET DEFAULT '';
+ALTER TABLE digital_instructors ALTER COLUMN note SET NOT NULL;
+
+-- 保留历史重名记录;新建、改名和复制必须在组织内唯一,行锁防止并发写入重名。
+CREATE OR REPLACE FUNCTION check_digital_instructor_name() RETURNS trigger AS $$
+BEGIN
+  IF NEW.organization_id IS NOT NULL AND NEW.deleted_at IS NULL THEN
+    PERFORM 1 FROM organizations WHERE id=NEW.organization_id FOR UPDATE;
+    IF EXISTS (SELECT 1 FROM digital_instructors d WHERE d.organization_id=NEW.organization_id
+      AND d.deleted_at IS NULL AND d.id<>NEW.id AND lower(btrim(d.name))=lower(btrim(NEW.name))) THEN
+      RAISE EXCEPTION 'digital instructor name already exists' USING ERRCODE='23505', CONSTRAINT='digital_instructors_org_name_unique';
+    END IF;
+  END IF;
+  RETURN NEW;
+END $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_digital_instructor_name ON digital_instructors;
+CREATE TRIGGER trg_digital_instructor_name BEFORE INSERT OR UPDATE OF name, organization_id, deleted_at
+ON digital_instructors FOR EACH ROW EXECUTE FUNCTION check_digital_instructor_name();
+
 CREATE TABLE IF NOT EXISTS course_deliverables (
   id UUID PRIMARY KEY,
   course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
@@ -146,3 +344,21 @@ CREATE INDEX IF NOT EXISTS idx_course_progress_events ON course_progress_events(
 -- 默认按每集 1000 积分计费,并同步刷新已有课程的默认值。
 UPDATE courses c
 SET estimated_points = GREATEST(1, (SELECT COUNT(*) FROM episodes e WHERE e.course_id = c.id)) * 1000;
+
+-- 旧账户的既有余额与已完成课程,折算为可追溯的个人流水。
+INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
+SELECT gen_random_uuid(),'PERSONAL',u.id,u.id,
+  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,
+  '历史账户期初余额'
+FROM users u LEFT JOIN courses c ON c.user_id=u.id
+WHERE NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.owner_id=u.id)
+GROUP BY u.id;
+INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,amount,reason,created_at)
+SELECT gen_random_uuid(),'PERSONAL',c.user_id,c.id,c.name,c.user_id,
+  -COALESCE(c.actual_points,c.estimated_points),'历史课程完成扣费',COALESCE(c.completed_at,c.updated_at)
+FROM courses c WHERE c.points_charged AND c.organization_id IS NULL
+  AND NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.course_id=c.id);
+UPDATE points_ledger l SET course_name_snapshot=c.name FROM courses c
+WHERE l.course_id=c.id AND l.course_name_snapshot IS NULL;
+
+ALTER TABLE digital_instructor_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;

+ 36 - 1
src/types.ts

@@ -15,11 +15,21 @@ export interface User {
   inProductionCoursesCount?: number
   completedCoursesCount?: number
   pointsBalance: number
+  mustChangePassword?: boolean
+}
+
+export interface Organization {
+  id: string
+  slug: string
+  name: string
+  pointsBalance: number
+  role: 'MEMBER' | 'MANAGER'
+  isActive: boolean
 }
 
 export interface PointsTransaction {
   id: string
-  courseId: string
+  courseId: string | null
   courseName: string
   points: number
   createdAt: string
@@ -55,6 +65,26 @@ export interface Instructor {
   imageUrls: string[]
 }
 
+export interface DigitalInstructor {
+  id: string
+  name: string
+  note: string
+  organizationId: string | null
+  personalUserId: string | null
+  ownerId: string
+  ownerName: string
+  creatorId: string
+  creatorName: string
+  createdAt: string
+  updatedAt: string
+  sourceId: string | null
+  qualityStatus: string
+  ready: boolean
+  usageCount: number
+  images: { id: string; name: string; url: string }[]
+  audio: { id: string; name: string; url: string }[]
+}
+
 export interface CourseDeliverable {
   id: string
   courseId?: string
@@ -82,6 +112,8 @@ export interface Episode {
 export interface Course {
   id: string
   userId?: string
+  organizationId?: string | null
+  organizationName?: string | null
   name: string
   category: string
   description: string
@@ -95,9 +127,12 @@ export interface Course {
   assets: CourseAsset[]
   episodes: Episode[]
   instructors: Instructor[]
+  digitalInstructorId?: string | null
+  digitalInstructorSnapshot?: DigitalInstructor | null
   deliverables?: CourseDeliverable[]
   creator?: User
   createdAt: string
+  updatedAt?: string
   submittedAt: string | null
   completedAt?: string | null
 }