lesson-records.sql 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. BEGIN;
  2. ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "assignmentVersion" double precision DEFAULT 0;
  3. CREATE TABLE IF NOT EXISTS "ReviewTransferCommand" (
  4. "objectId" text PRIMARY KEY,"company" text NOT NULL,"actorId" text NOT NULL,"key" text NOT NULL,
  5. "fingerprint" text NOT NULL,"reason" text NOT NULL,"result" jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),
  6. UNIQUE ("company","key")
  7. );
  8. CREATE TABLE IF NOT EXISTS "ReviewTransferAudit" (
  9. "objectId" text PRIMARY KEY,"company" text NOT NULL,"commandId" text NOT NULL,"taskId" text NOT NULL,
  10. "studentId" text NOT NULL,"appointmentId" text,"oldCoachId" text,"newCoachId" text NOT NULL,
  11. "oldVersion" double precision,"newVersion" double precision,"createdAt" timestamptz NOT NULL DEFAULT now()
  12. );
  13. CREATE TABLE IF NOT EXISTS "ReviewAssignmentConflict" (
  14. "objectId" text PRIMARY KEY,"company" text NOT NULL,"taskId" text NOT NULL,"incoming" jsonb NOT NULL,
  15. "preserved" jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now()
  16. );
  17. -- A source learning record is authoritative; conflicting appointment links are never guessed.
  18. CREATE OR REPLACE VIEW xs_review_task_link AS
  19. SELECT m."company",m."objectId" AS "taskId",m."yhid"::text AS "studentId",
  20. CASE WHEN source.n=1 AND (COALESCE(NULLIF(m."orderId"::text,'0'),'')='' OR m."orderId"::text=source.appointment) THEN source.appointment END AS "appointmentId"
  21. FROM "MemoryPracticeRecord" m
  22. LEFT JOIN LATERAL (
  23. SELECT count(DISTINCT NULLIF(NULLIF(d.dsid,'0'),'')) n,min(NULLIF(NULLIF(d.dsid,'0'),'')) appointment
  24. FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d.company=c.company AND d.id=c."itemId" AND d."userId"::text=m.yhid::text
  25. WHERE c.company=m.company AND c."modelId"=56 AND c."generalId"::text=m.xxjlid::text
  26. ) source ON true;
  27. CREATE OR REPLACE FUNCTION xs_review_manager(co text, actor text) RETURNS void LANGUAGE plpgsql AS $$
  28. DECLARE u jsonb; role_key text;
  29. BEGIN
  30. SELECT to_jsonb(t) INTO u FROM "_User" t WHERE t."company"=co AND t."objectId"=actor;
  31. role_key:=COALESCE(NULLIF(u->>'adminRoleKey',''),CASE WHEN u->'roles' ? 'super-admin' THEN 'super-admin' WHEN u->'roles' ? 'ops-manager' THEN 'ops-manager' WHEN u->'roles' ? 'ops-staff' THEN 'ops-staff' WHEN u->'roles' ? 'ops-auditor' THEN 'ops-auditor' WHEN u->>'isAdmin'='true' OR u->>'role'='admin' OR u->'roles' ? 'admin' THEN 'ops-manager' END);
  32. IF u IS NULL OR u->>'isDisabled'='true' OR u->>'isDeleted'='true' OR COALESCE(role_key,'') NOT IN ('super-admin','ops-manager') THEN RAISE EXCEPTION '仅管理角色可以转交复习'; END IF;
  33. END $$;
  34. CREATE OR REPLACE FUNCTION xs_review_target(co text, coach text) RETURNS void LANGUAGE plpgsql AS $$
  35. BEGIN
  36. PERFORM 1 FROM "_User" u WHERE u."company"=co AND COALESCE(u."legacyUserId"::text,u."legacyUserData"->>'UserID')=coach
  37. AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach'
  38. AND COALESCE(to_jsonb(u)->>'legacyGroupId',to_jsonb(u)->>'groupId',u."legacyUserData"->>'GroupID')='3'
  39. AND COALESCE(to_jsonb(u)->>'isDisabled','false')<>'true' AND COALESCE(to_jsonb(u)->>'isDeleted','false')<>'true' FOR SHARE;
  40. IF NOT FOUND THEN RAISE EXCEPTION '目标陪练不存在、已停用或身份异常'; END IF;
  41. END $$;
  42. CREATE OR REPLACE FUNCTION xs_review_transfer_snapshot(co text, student text, appointment text, coach text) RETURNS jsonb LANGUAGE sql STABLE AS $$
  43. SELECT COALESCE(jsonb_agg(jsonb_build_object('taskId',m."objectId",'coachId',m."plid"::text,'version',COALESCE(m."assignmentVersion",0),'updatedAt',m."updatedAt",'plannedDate',m."kywrq",'appointmentId',l."appointmentId") ORDER BY m."objectId"),'[]'::jsonb)
  44. FROM "MemoryPracticeRecord" m JOIN xs_review_task_link l ON l."company"=m."company" AND l."taskId"=m."objectId"
  45. WHERE m."company"=co AND m."yhid"::text=student AND COALESCE(m."fxzt"::text,'0')<>'1' AND COALESCE(m."rewardDuplicate",false)=false
  46. AND (appointment='' OR l."appointmentId"=appointment) AND COALESCE(m."plid"::text,'')<>coach
  47. AND NOT EXISTS(SELECT 1 FROM "ReviewReward" r WHERE r."company"=co AND r."taskId"=m."objectId");
  48. $$;
  49. CREATE OR REPLACE FUNCTION xs_review_transfer(co text, actor text, student text, appointment text, coach text, expected text, reason text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
  50. DECLARE snapshot jsonb; receipt record; fingerprint text; command_id text; task record; result jsonb;
  51. BEGIN
  52. PERFORM xs_review_manager(co,actor);
  53. IF student !~ '^[1-9][0-9]*$' OR coach !~ '^[1-9][0-9]*$' OR length(trim(reason))<2 OR length(reason)>1000 OR length(idem)<8 OR length(idem)>120 THEN RAISE EXCEPTION '缺少有效学员、陪练、原因或请求编号'; END IF;
  54. PERFORM pg_advisory_xact_lock(hashtext('review-transfer:'||co||':'||student));
  55. fingerprint:=md5(jsonb_build_array(actor,student,appointment,coach,expected,reason)::text);
  56. SELECT * INTO receipt FROM "ReviewTransferCommand" WHERE "company"=co AND "key"=idem;
  57. IF FOUND THEN IF receipt."fingerprint"<>fingerprint THEN RAISE EXCEPTION '请求编号已用于其他转交'; END IF; RETURN receipt."result"; END IF;
  58. PERFORM xs_review_target(co,coach);
  59. -- Match completion's row lock; re-read versions after a concurrent completion settles.
  60. PERFORM 1 FROM "MemoryPracticeRecord" m WHERE m."company"=co AND m."yhid"::text=student ORDER BY m."objectId" FOR UPDATE;
  61. snapshot:=xs_review_transfer_snapshot(co,student,appointment,coach);
  62. IF md5(snapshot::text)<>expected THEN RAISE EXCEPTION '复习任务已变化,请刷新预览后重试'; END IF;
  63. IF jsonb_array_length(snapshot)=0 THEN RAISE EXCEPTION '没有可转交的未完成复习'; END IF;
  64. command_id:=md5(co||':'||idem);
  65. PERFORM set_config('xiaoshu.review_transfer',command_id,true);
  66. FOR task IN SELECT value AS data FROM jsonb_array_elements(snapshot) LOOP
  67. UPDATE "MemoryPracticeRecord" SET "plid"=coach::numeric,"assignmentVersion"=COALESCE("assignmentVersion",0)+1,"updatedAt"=clock_timestamp() WHERE "company"=co AND "objectId"=task.data->>'taskId';
  68. INSERT INTO "ReviewTransferAudit" ("objectId","company","commandId","taskId","studentId","appointmentId","oldCoachId","newCoachId","oldVersion","newVersion") VALUES
  69. (md5(command_id||':'||(task.data->>'taskId')),co,command_id,task.data->>'taskId',student,task.data->>'appointmentId',task.data->>'coachId',coach,(task.data->>'version')::numeric,(task.data->>'version')::numeric+1);
  70. END LOOP;
  71. PERFORM set_config('xiaoshu.review_transfer','',true);
  72. result:=jsonb_build_object('commandId',command_id,'count',jsonb_array_length(snapshot),'studentId',student,'coachId',coach);
  73. INSERT INTO "ReviewTransferCommand" ("objectId","company","actorId","key","fingerprint","reason","result") VALUES(command_id,co,actor,idem,fingerprint,reason,result);
  74. RETURN result;
  75. END $$;
  76. -- Protect assignments even when a page-triggered legacy refresh raced the transfer.
  77. CREATE OR REPLACE FUNCTION xs_review_assignment_guard() RETURNS trigger LANGUAGE plpgsql AS $$
  78. BEGIN
  79. IF COALESCE(current_setting('xiaoshu.review_transfer',true),'')<>'' THEN RETURN NEW; END IF;
  80. NEW."assignmentVersion":=OLD."assignmentVersion";
  81. IF COALESCE(OLD."assignmentVersion",0)>0 AND
  82. (NEW."plid" IS DISTINCT FROM OLD."plid" OR (NEW."fxzt" IS DISTINCT FROM OLD."fxzt" AND COALESCE(current_setting('xiaoshu.review_completion',true),'')<>OLD."company"||':'||OLD."objectId")) THEN
  83. INSERT INTO "ReviewAssignmentConflict" ("objectId","company","taskId","incoming","preserved") VALUES(md5(OLD."company"||OLD."objectId"||to_jsonb(NEW)::text),OLD."company",OLD."objectId",jsonb_build_object('coachId',NEW."plid",'status',NEW."fxzt"),jsonb_build_object('coachId',OLD."plid",'status',OLD."fxzt")) ON CONFLICT DO NOTHING;
  84. RETURN OLD;
  85. END IF;
  86. RETURN NEW;
  87. END $$;
  88. DROP TRIGGER IF EXISTS a_review_assignment_guard ON "MemoryPracticeRecord";
  89. CREATE TRIGGER a_review_assignment_guard BEFORE UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xs_review_assignment_guard();
  90. -- Check the authenticated actor after taking the same row lock used by transfer.
  91. CREATE OR REPLACE FUNCTION xs_complete_assigned_review(co text, task_id text, actor text) RETURNS void LANGUAGE plpgsql AS $$
  92. DECLARE task record; u jsonb; uid text;
  93. BEGIN
  94. SELECT to_jsonb(t) INTO u FROM "_User" t WHERE t."company"=co AND t."objectId"=actor;
  95. uid:=COALESCE(u->>'legacyUserId',u->'legacyUserData'->>'UserID');
  96. IF u IS NULL OR u->>'isDisabled'='true' OR u->>'isDeleted'='true' THEN RAISE EXCEPTION '账号不可用'; END IF;
  97. SELECT * INTO task FROM "MemoryPracticeRecord" WHERE "company"=co AND "objectId"=task_id FOR UPDATE;
  98. IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
  99. IF uid IS NULL OR (uid<>task."plid"::text AND uid<>task."yhid"::text) THEN RAISE EXCEPTION '复习已转交或不属于当前账号,请刷新列表'; END IF;
  100. PERFORM xiaoshu_complete_review(co,task_id);
  101. END $$;
  102. CREATE INDEX IF NOT EXISTS xs_review_student_pending ON "MemoryPracticeRecord" ("company","yhid","fxzt");
  103. CREATE INDEX IF NOT EXISTS xs_review_transfer_task ON "ReviewTransferAudit" ("company","taskId");
  104. COMMIT;