chore(sync): 同步当前工作区全部变更(学员学号可编辑并留空自动生成等)

This commit is contained in:
buliping committed 2026-11-10 13:34:14 +08:00
1 parent 01e949093a
commit ee629f2f3e
534 files changed
+72978 -2720

No files matched your search

+5 -5
View File
@@ -6,8 +6,8 @@
BEGIN;
-- 1. 教师主档
UPDATE public.train_teacher SET org_name = '沈阳房建公寓段安全培训科', job_title = '首席技师/教授级高工', t_phone = '18800000000', t_email = 'zhaodm@sict.railway.cn', status = 1, del_flag = '0' WHERE t_id = 'T001';
INSERT INTO public.train_teacher (t_id, t_name, t_gender, t_birth, org_name, job_title, t_phone, t_email, status, del_flag, user_id, create_by, create_time, remark)
UPDATE public.train_teacher SET dept_name = '沈阳房建公寓段安全培训科', job_title = '首席技师/教授级高工', t_phone = '18800000000', t_email = 'zhaodm@sict.railway.cn', status = 1, del_flag = '0' WHERE t_id = 'T001';
INSERT INTO public.train_teacher (t_id, t_name, t_gender, t_birth, dept_name, job_title, t_phone, t_email, status, del_flag, user_id, create_by, create_time, remark)
VALUES
('T101', '崔宏伟', '0', '1982-05-14', '沈阳房建公寓段安全培训科', '高级工程师', '18800000011', 'cuihw@sict.railway.cn', 1, '0', 11, 'admin', CURRENT_TIMESTAMP, '演示主讲教师(绑定账号demo_teacher)'),
('T102', '张建国', '0', '1985-11-20', '沈阳房建公寓段技术科', '副高级工程师', '18800000012', 'zhangjg@sict.railway.cn', 1, '0', 12, 'admin', CURRENT_TIMESTAMP, '技术专家讲师(绑定账号demo_teacher2)'),
@@ -16,7 +16,7 @@ VALUES
('T105', '陈广林', '0', '1976-12-09', '大连项目部', '特级技师', '18800000015', 'chengl@sict.railway.cn', 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '防汛抢险实训专家'),
('T106', '马志强', '0', '1990-07-18', '丹东项目部', '工程师', '18800000016', 'mazhq@sict.railway.cn', 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '电气防火与智能化安全监控讲师')
ON CONFLICT (t_id) DO UPDATE SET
t_name = EXCLUDED.t_name, org_name = EXCLUDED.org_name, job_title = EXCLUDED.job_title, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0';
t_name = EXCLUDED.t_name, dept_name = EXCLUDED.dept_name, job_title = EXCLUDED.job_title, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0';
-- 2. 课程标准库
INSERT INTO public.train_course (c_id, c_name, c_code, c_type, c_duration, teaching_objective, course_content, status, del_flag, create_by, create_time, remark)
@@ -86,7 +86,7 @@ ON CONFLICT (dorm_id) DO UPDATE SET
dorm_building = EXCLUDED.dorm_building, dorm_floor = EXCLUDED.dorm_floor, dorm_room = EXCLUDED.dorm_room, capacity = EXCLUDED.capacity, dorm_status = EXCLUDED.dorm_status, del_flag = '0';
-- 7. 学员主档 (35名)
INSERT INTO public.train_student (s_id, s_name, s_gender, s_birth, org_name, s_phone, s_email, id_card_hash, id_card_last4, source_type, status, del_flag, user_id, create_by, create_time, remark)
INSERT INTO public.train_student (s_id, s_name, s_gender, s_birth, dept_name, s_phone, s_email, id_card_hash, id_card_last4, source_type, status, del_flag, user_id, create_by, create_time, remark)
VALUES
('S101', '李强', '0', '1990-01-01', '沈阳房建公寓段沈阳车间', '18800000101', 's101@sict.railway.cn', '57ecc1436745329b620ccbfaebbed22cda4ba9b33ec44de468d2e3acd61f63c2', '1000', 1, 1, '0', 21, 'admin', CURRENT_TIMESTAMP, '演示学员样本1'),
('S102', '王芳', '1', '1991-02-02', '沈阳房建公寓段大连车间', '18800000102', 's102@sict.railway.cn', 'f6d29eac73f7ea39158b909683cc36f50a2ea9298be8a453bf8922488e499b29', '1001', 1, 1, '0', 22, 'admin', CURRENT_TIMESTAMP, '演示学员样本2'),
@@ -124,7 +124,7 @@ VALUES
('S134', '吕梁', '1', '1991-10-06', '沈阳房建公寓段安全培训科', '18800000134', 's134@sict.railway.cn', '65a78d3839aef33f4059ea214e8faac94125c6c82cfa3001580fa208430b0ad3', '1033', 1, 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '演示学员样本34'),
('S135', '卢伟', '0', '1992-11-07', '沈阳房建公寓段技术科', '18800000135', 's135@sict.railway.cn', '2df5ae45334850de9e4ba87333f2388e2b71f5d75ead96fdd7c8734dd73f7147', '1034', 1, 1, '0', NULL, 'admin', CURRENT_TIMESTAMP, '演示学员样本35')
ON CONFLICT (s_id) DO UPDATE SET
s_name = EXCLUDED.s_name, org_name = EXCLUDED.org_name, s_phone = EXCLUDED.s_phone, s_email = EXCLUDED.s_email,
s_name = EXCLUDED.s_name, dept_name = EXCLUDED.dept_name, s_phone = EXCLUDED.s_phone, s_email = EXCLUDED.s_email,
id_card_hash = EXCLUDED.id_card_hash, id_card_last4 = EXCLUDED.id_card_last4, user_id = EXCLUDED.user_id, status = EXCLUDED.status, del_flag = '0';
COMMIT;
@@ -0,0 +1,390 @@
-- ==============================================================================
-- RailwaySystem 演示/测试数据: 005_demo_theory_exam_score.sql
-- 目标表: train_theory_exam_score(班级学员理论考试成绩表,先执行 026 建表脚本)
-- 数据库: MySQL 5.7+(脚本使用 INSERT ... SELECT / INSERT IGNORE / 派生表,5.7 完全兼容)
-- ==============================================================================
-- 一、依赖的既有数据(脚本不新建,全部从库中实时读取,保证外键与业务一致性)
-- train_student(s_id) —— 学员主档
-- train_class_plan(plan_id) —— 开班计划
-- train_plan_course(plan_course_id) —— 培训班所开课程实例(本脚本的核心关联依据)
-- train_enrollment(enrollment_id) —— 参训档案(学员 × 班级)
-- train_course(c_id) —— 课程主档(由 train_plan_course 带出)
-- train_exam_subject(subject_code) —— 科目字典(本脚本不写入,subject_code 置 NULL,避免无谓依赖)
-- train_score_item(score_item_id) —— 综合成绩项(本脚本不写入,score_item_id 置 NULL)
--
-- 二、生成逻辑
-- · 每一行 = 一个 (参训档案 enrollment_id, 班级课程 plan_course_id, 考试次数 retake_no) 组合,
-- 因此天然满足唯一键 uk_theory_enroll_course_retake;
-- · 同一学员同一班级若存在多条参训档案,取 MIN(enrollment_id) 去重,
-- 避免触发 uk_theory_final(每课程每学员仅一条最终成绩);
-- · 使用 ORDER BY + LIMIT/OFFSET 把"学员×课程"笛卡尔集切成互不重叠的区间,
-- 分别构造正常、满分、零分、缺考、免修、补考、锁定/归档等场景,区间之间不会撞唯一键;
-- · 使用 INSERT IGNORE,重复执行不会报错也不会产生重复数据;
-- · 若库中可用的"学员×课程"组合不足,靠后的场景会少插或插 0 行(不报错),
-- 执行前可先跑"第 0 步"的体检 SQL 查看可用组合数。
--
-- 三、字段写入约定
-- enrollment_id / plan_id / student_id ← train_enrollment
-- plan_course_id / c_id ← train_plan_course
-- exam_start_time / exam_end_time ← NOW() 回溯 1~30 天,开始时间比结束早 90 分钟
-- duration_seconds ← 5400(90 分钟)
-- retake / retake_no ← 正考 0/0;补考 1/1、1/2
-- exam_status ← NORMAL 正常 / ABSENT 缺考 / EXEMPT 免修
-- score ← 正常考试 0~满分;缺考/免修必须为 NULL(CHECK 约束要求)
-- full_score ← 100.00
-- pass_flag ← 60 分及格(与后端 60% 默认判定一致)
-- is_final ← 正考/补考最终成绩为 1,过程性补考为 0
-- score_status ← RECEIVED / ARCHIVED / LOCKED
-- source_type ← 1 手工录入 / 3 批量或外部导入(演示来源差异)
-- del_flag ← '0'
-- create_by ← 'test-data'(便于识别与清理)
-- ==============================================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------------------------
-- 第 0 步:体检(只读,不影响数据)
-- 查看当前可用的"学员 × 课程"组合数量,决定各场景实际能插入多少行
-- ------------------------------------------------------------------------------
SELECT COUNT(*) AS available_student_course_pairs
FROM (
SELECT e.student_id, pc.plan_course_id
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
) p;
-- ==============================================================================
-- 场景 1:正常分布(正考 0 次补考、成绩 60~100、通过、最终成绩)
-- 覆盖区间:偏移 0 ~ 11,共 12 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0,
'NORMAL',
CAST(60 + MOD(CRC32(CONCAT(t.enrollment_id, '-', t.plan_course_id)), 41) AS DECIMAL(7,2)),
100.00, 1, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-正常通过(60~100分)',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 12 OFFSET 0
) t;
-- ==============================================================================
-- 场景 2:边界值-满分(score = full_score = 100.00)
-- 覆盖区间:偏移 12 ~ 15,共 4 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0,
'NORMAL', CAST(100.00 AS DECIMAL(7,2)), 100.00, 1, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-边界值:满分100',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 4 OFFSET 12
) t;
-- ==============================================================================
-- 场景 3:边界值-零分(score = 0.00,未通过)
-- 覆盖区间:偏移 16 ~ 19,共 4 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0,
'NORMAL', CAST(0.00 AS DECIMAL(7,2)), 100.00, 0, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-边界值:零分',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 4 OFFSET 16
) t;
-- ==============================================================================
-- 场景 4:缺考(ABSENT,score 必须为 NULL,pass_flag=0,仍作为该课程最终成绩)
-- 覆盖区间:偏移 20 ~ 23,共 4 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, NULL, 0, 0,
'ABSENT', NULL, 100.00, 0, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-缺考(无成绩)',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(e.enrollment_id, '-', pc.plan_course_id)), 30) + 1) DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 4 OFFSET 20
) t;
-- ==============================================================================
-- 场景 5:免修(EXEMPT,score 为 NULL,按通过计)
-- 覆盖区间:偏移 24 ~ 26,共 3 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
NULL, NULL, NULL, 0, 0,
'EXEMPT', NULL, 100.00, 1, 1, 'ARCHIVED',
1, NULL, NULL, NULL, '测试数据-免修(不计分,计通过)',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL 7 DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 3 OFFSET 24
) t;
-- ==============================================================================
-- 场景 6:补考过程记录(第 1 次补考,45 分未通过,is_final=0)
-- 覆盖区间:偏移 27 ~ 32,共 6 行;与场景 7 使用同一区间,形成"正考/补考"两次成绩
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 1, 1,
'NORMAL', CAST(45.00 AS DECIMAL(7,2)), 100.00, 0, 0, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-补考第1次未通过(非最终成绩)',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL 15 DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 6 OFFSET 27
) t;
-- ==============================================================================
-- 场景 7:补考后最终成绩(第 2 次补考,65~95 分通过,is_final=1)
-- 覆盖区间:与场景 6 相同(偏移 27 ~ 32),retake_no=2,因此不违反唯一键
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 4800, 1, 2,
'NORMAL',
CAST(65 + MOD(CRC32(CONCAT('retake-', t.enrollment_id, '-', t.plan_course_id)), 31) AS DECIMAL(7,2)),
100.00, 1, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-补考第2次通过(最终成绩)',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL 7 DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 6 OFFSET 27
) t;
-- ==============================================================================
-- 场景 8:已锁定成绩(LOCKED,禁止再改分数;来源为手工录入)
-- 覆盖区间:偏移 33 ~ 34,共 2 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0,
'NORMAL', CAST(88.00 AS DECIMAL(7,2)), 100.00, 1, 1, 'LOCKED',
1, NULL, NULL, NULL, '测试数据-已锁定成绩',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL 10 DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 2 OFFSET 33
) t;
-- ==============================================================================
-- 场景 9:外部批量导入 + 已归档(source_type=3,带导入幂等键)
-- 覆盖区间:偏移 35 ~ 36,共 2 行
-- ==============================================================================
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT t.enrollment_id, t.plan_id, t.plan_course_id, t.c_id, t.student_id, NULL,
DATE_SUB(t.end_time, INTERVAL 90 MINUTE), t.end_time, 5400, 0, 0,
'NORMAL', CAST(72.50 AS DECIMAL(7,2)), 100.00, 1, 1, 'ARCHIVED',
3, 'TMS-EXAM', CONCAT('IMP-', t.enrollment_id, '-', t.plan_course_id), NULL,
'测试数据-外部导入并归档',
'0', 'test-data', NOW()
FROM (
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id,
DATE_SUB(NOW(), INTERVAL 3 DAY) AS end_time
FROM (SELECT student_id, plan_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY student_id, plan_id) e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_class_plan cp ON cp.plan_id = e.plan_id
ORDER BY e.enrollment_id, pc.plan_course_id
LIMIT 2 OFFSET 35
) t;
-- ==============================================================================
-- 第 10 步:结果校验(只读)
-- 10.1 场景分布统计
-- 10.2 关联一致性检查(均应为 0 行)
-- 10.3 唯一性检查(均应为 0 行)
-- 10.4 分数与状态合法性检查(均应为 0 行)
-- ==============================================================================
-- 10.1 场景分布
SELECT exam_status, score_status, retake, is_final, COUNT(*) AS row_count
FROM train_theory_exam_score
WHERE create_by = 'test-data'
GROUP BY exam_status, score_status, retake, is_final
ORDER BY exam_status, score_status, retake, is_final;
-- 10.2 关联一致性:学员 / 班级计划 / 课程实例 / 课程主档 是否都能对上
SELECT t.theory_score_id, '学员不存在' AS problem
FROM train_theory_exam_score t
LEFT JOIN train_student s ON s.s_id = t.student_id
WHERE t.create_by = 'test-data' AND s.s_id IS NULL
UNION ALL
SELECT t.theory_score_id, '班级计划不存在'
FROM train_theory_exam_score t
LEFT JOIN train_class_plan cp ON cp.plan_id = t.plan_id
WHERE t.create_by = 'test-data' AND cp.plan_id IS NULL
UNION ALL
SELECT t.theory_score_id, '课程实例不存在或与计划/课程不匹配'
FROM train_theory_exam_score t
LEFT JOIN train_plan_course pc
ON pc.plan_course_id = t.plan_course_id
AND pc.plan_id = t.plan_id
AND pc.c_id = t.c_id
WHERE t.create_by = 'test-data' AND pc.plan_course_id IS NULL
UNION ALL
SELECT t.theory_score_id, '参训档案与学员/班级不匹配'
FROM train_theory_exam_score t
LEFT JOIN train_enrollment e
ON e.enrollment_id = t.enrollment_id
AND e.student_id = t.student_id
AND e.plan_id = t.plan_id
WHERE t.create_by = 'test-data' AND e.enrollment_id IS NULL;
-- 10.3 唯一性:同一参训档案 + 课程 + 考试次数重复
SELECT enrollment_id, plan_course_id, retake_no, COUNT(*) AS cnt
FROM train_theory_exam_score
WHERE del_flag = '0'
GROUP BY enrollment_id, plan_course_id, retake_no
HAVING COUNT(*) > 1;
-- 10.3.1 唯一性:同一课程同一学员出现多条最终成绩
SELECT plan_course_id, student_id, COUNT(*) AS final_cnt
FROM train_theory_exam_score
WHERE del_flag = '0' AND is_final = 1
GROUP BY plan_course_id, student_id
HAVING COUNT(*) > 1;
-- 10.4 合法性:缺考/免修携带分数、正常考试无分数、分数越界
SELECT theory_score_id, exam_status, score, full_score, '分数状态不匹配' AS problem
FROM train_theory_exam_score
WHERE create_by = 'test-data'
AND (
(exam_status = 'NORMAL' AND score IS NULL)
OR (exam_status IN ('ABSENT', 'EXEMPT') AND score IS NOT NULL)
OR (score IS NOT NULL AND (score < 0 OR score > full_score))
);
-- ==============================================================================
-- 清理(如需删除本脚本产生的全部数据,取消注释后执行)
-- 注意:逻辑删除会释放 uk_theory_final 的占用(生成列 final_flag 随 del_flag 变为 NULL)
-- ==============================================================================
-- DELETE FROM train_theory_exam_score WHERE create_by = 'test-data';
-- ==============================================================================
-- 附:无法通过 DB 约束落库的"异常场景"(仅作验证说明,脚本故意不插入)
-- 1) 重复录入:(enrollment_id, plan_course_id, retake_no) 重复
-- → 被 uk_theory_enroll_course_retake 拦截(INSERT IGNORE 也会被忽略)
-- 2) 同一课程同一学员两条 is_final=1
-- → 被 uk_theory_final(生成列 final_flag)拦截
-- 3) 缺考/免修携带分数,或正常考试分数为空
-- → 被 chk_theory_score_filled / chk_theory_score_range 拦截(MySQL 8.0 强制,5.7 由应用层校验)
-- 4) 考试结束时间早于开始时间
-- → 被 chk_theory_exam_time 拦截
-- 5) 引用不存在的学员/课程
-- → 被外键 fk_theory_student / fk_theory_plan_course 拦截
-- ==============================================================================
@@ -0,0 +1,359 @@
-- ==============================================================================
-- RailwaySystem 演示数据: 006_demo_theory_score_test01.sql(关系型重建版)
-- 用途: 为“测试01班”构建一套完整的班级—学员—选课—理论成绩关联模型,并填充演示数据
-- 数据库: MySQL 5.7+(无 CTE / 窗口函数 / CHECK 约束;全部为普通表 + 外键,5.7 完全兼容)
-- 字符集: utf8mb4 / utf8mb4_general_ci(支持中文班级名与姓名)
--
-- 数据模型(7 张表,演示专用,前缀 demo_,不触碰正式业务表 train_*):
--
-- demo_class_plan 开班计划(1) ──┬── demo_plan_course 计划开设课程(N)
-- │ │
-- demo_course 课程字典(1) ──┘ │
-- │ │
-- demo_student 学生主档(1) ── demo_class_member 班级成员(N) [计划 × 学生]
-- │
-- ├── demo_student_course 学生选课 [计划 × 学生 × 课程]
-- │ │
-- └── demo_theory_score 理论成绩
-- [计划 × 学生 × 课程 × 考试类型]
--
-- 关联一致性由数据库外键强制保证:
-- 1) 班级成员必须属于已存在的开班计划与已存在的学生;
-- 2) 学生选课必须是“该班成员 + 该班已开设课程”的交集(双外键);
-- 3) 理论成绩必须落在学生已选的课程上(外键指向 demo_student_course),
-- 因此不可能出现“成绩挂在未选课/非本班/未开课程上”的脏数据。
--
-- 数据规模:1 个开班计划、3 门课程、24 名学生、24 条班级成员、
-- 39 条选课记录、39 条正考成绩 + 2 条补考 = 41 条成绩
-- 边界/异常:满分 100、零分 0、及格线 60、59.5 差 0.5 分、缺考(成绩 NULL)、补考提升
--
-- 可重复执行:脚本开头统一 DROP(仅 demo_ 前缀表),如需保留请注释 DROP 段
-- ==============================================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------------------------
-- 0. 清理旧演示表(按外键依赖倒序)
-- ------------------------------------------------------------------------------
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `demo_theory_score`;
DROP TABLE IF EXISTS `demo_student_course`;
DROP TABLE IF EXISTS `demo_class_member`;
DROP TABLE IF EXISTS `demo_student`;
DROP TABLE IF EXISTS `demo_plan_course`;
DROP TABLE IF EXISTS `demo_course`;
DROP TABLE IF EXISTS `demo_class_plan`;
SET FOREIGN_KEY_CHECKS = 1;
-- ------------------------------------------------------------------------------
-- 1. 开班计划
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_class_plan` (
`plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID',
`plan_name` VARCHAR(150) NOT NULL COMMENT '开班计划名称',
`class_name` VARCHAR(50) NOT NULL COMMENT '班级名称',
`start_time` DATETIME NOT NULL COMMENT '开班时间',
`end_time` DATETIME NOT NULL COMMENT '结班时间',
`plan_status` TINYINT NOT NULL DEFAULT 1 COMMENT '计划状态(1进行中 0已结束)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`plan_id`),
UNIQUE KEY `uk_plan_class_name` (`class_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='开班计划演示表';
INSERT INTO `demo_class_plan` (`plan_id`, `plan_name`, `class_name`, `start_time`, `end_time`, `plan_status`) VALUES
('PLAN-TEST01', '2026年接触网安全理论培训班(测试01班)', '测试01班', '2026-10-08 08:30:00', '2026-12-31 18:00:00', 1);
-- ------------------------------------------------------------------------------
-- 2. 课程字典
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_course` (
`c_id` VARCHAR(32) NOT NULL COMMENT '课程ID',
`c_name` VARCHAR(100) NOT NULL COMMENT '课程名称',
`c_type` VARCHAR(30) DEFAULT NULL COMMENT '课程类型',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`c_id`),
UNIQUE KEY `uk_course_name` (`c_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='课程字典演示表';
INSERT INTO `demo_course` (`c_id`, `c_name`, `c_type`) VALUES
('C01', '接触网安全理论', '理论'),
('C02', '铁路电力安全规程', '理论'),
('C03', '应急处置与救援', '理论');
-- ------------------------------------------------------------------------------
-- 3. 开班计划课程(计划开设了哪些课)
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_plan_course` (
`plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID',
`c_id` VARCHAR(32) NOT NULL COMMENT '课程ID',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排课顺序',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`plan_id`, `c_id`),
KEY `idx_plan_course_course` (`c_id`),
CONSTRAINT `fk_plan_course_plan` FOREIGN KEY (`plan_id`) REFERENCES `demo_class_plan` (`plan_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_plan_course_course` FOREIGN KEY (`c_id`) REFERENCES `demo_course` (`c_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='开班计划课程演示表';
INSERT INTO `demo_plan_course` (`plan_id`, `c_id`, `sort_no`) VALUES
('PLAN-TEST01', 'C01', 1),
('PLAN-TEST01', 'C02', 2),
('PLAN-TEST01', 'C03', 3);
-- ------------------------------------------------------------------------------
-- 4. 学生主档(学号 + 姓名)
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_student` (
`student_no` VARCHAR(32) NOT NULL COMMENT '学号',
`student_name` VARCHAR(50) NOT NULL COMMENT '姓名',
`gender` VARCHAR(4) DEFAULT NULL COMMENT '性别',
`dept_name` VARCHAR(100) DEFAULT NULL COMMENT '所属单位',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`student_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='学生主档演示表';
INSERT INTO `demo_student` (`student_no`, `student_name`, `gender`, `dept_name`) VALUES
('TEST01-001', '张伟', '男', '供电段'),
('TEST01-002', '王芳', '女', '供电段'),
('TEST01-003', '李强', '男', '供电段'),
('TEST01-004', '刘洋', '男', '电务段'),
('TEST01-005', '陈静', '女', '电务段'),
('TEST01-006', '杨帆', '男', '电务段'),
('TEST01-007', '赵敏', '女', '工务段'),
('TEST01-008', '黄磊', '男', '工务段'),
('TEST01-009', '周涛', '男', '工务段'),
('TEST01-010', '吴倩', '女', '供电段'),
('TEST01-011', '徐鹏', '男', '供电段'),
('TEST01-012', '孙丽', '女', '供电段'),
('TEST01-013', '马超', '男', '电务段'),
('TEST01-014', '朱婷', '女', '电务段'),
('TEST01-015', '胡军', '男', '电务段'),
('TEST01-016', '郭鹏', '男', '工务段'),
('TEST01-017', '何梅', '女', '工务段'),
('TEST01-018', '高翔', '男', '工务段'),
('TEST01-019', '林芳', '女', '供电段'),
('TEST01-020', '罗文', '男', '供电段'),
('TEST01-021', '梁静', '女', '供电段'),
('TEST01-022', '宋波', '男', '电务段'),
('TEST01-023', '谢娜', '女', '电务段'),
('TEST01-024', '韩雪', '女', '工务段');
-- ------------------------------------------------------------------------------
-- 5. 班级成员(计划 × 学生,明确“谁在这个班”)
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_class_member` (
`plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID',
`student_no` VARCHAR(32) NOT NULL COMMENT '学号',
`join_time` DATETIME NOT NULL COMMENT '入班时间',
`member_status` TINYINT NOT NULL DEFAULT 1 COMMENT '成员状态(1在班 0已退班)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`plan_id`, `student_no`),
KEY `idx_member_student` (`student_no`),
CONSTRAINT `fk_member_plan` FOREIGN KEY (`plan_id`) REFERENCES `demo_class_plan` (`plan_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_member_student` FOREIGN KEY (`student_no`) REFERENCES `demo_student` (`student_no`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='班级成员演示表';
INSERT INTO `demo_class_member` (`plan_id`, `student_no`, `join_time`, `member_status`)
SELECT 'PLAN-TEST01', `student_no`, '2026-10-08 09:00:00', 1
FROM `demo_student`;
-- ------------------------------------------------------------------------------
-- 6. 学生选课(计划 × 学生 × 课程;必须同时是班级成员且课程为计划已开设课程)
-- 选课规则:全部 24 人必选 C01;002~013 选 C02;003/009/015 选 C03(共 39 条)
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_student_course` (
`plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID',
`student_no` VARCHAR(32) NOT NULL COMMENT '学号',
`c_id` VARCHAR(32) NOT NULL COMMENT '课程ID',
`select_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间',
PRIMARY KEY (`plan_id`, `student_no`, `c_id`),
KEY `idx_sc_course` (`plan_id`, `c_id`),
CONSTRAINT `fk_sc_member` FOREIGN KEY (`plan_id`, `student_no`) REFERENCES `demo_class_member` (`plan_id`, `student_no`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_sc_plan_course` FOREIGN KEY (`plan_id`, `c_id`) REFERENCES `demo_plan_course` (`plan_id`, `c_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='学生选课演示表';
-- 6.1 全员必选 C01
INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`)
SELECT 'PLAN-TEST01', `student_no`, 'C01'
FROM `demo_student`;
-- 6.2 002~013 加选 C02
INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`)
SELECT 'PLAN-TEST01', `student_no`, 'C02'
FROM `demo_student`
WHERE `student_no` BETWEEN 'TEST01-002' AND 'TEST01-013';
-- 6.3 003/009/015 加选 C03
INSERT INTO `demo_student_course` (`plan_id`, `student_no`, `c_id`) VALUES
('PLAN-TEST01', 'TEST01-003', 'C03'),
('PLAN-TEST01', 'TEST01-009', 'C03'),
('PLAN-TEST01', 'TEST01-015', 'C03');
-- ------------------------------------------------------------------------------
-- 7. 理论成绩(计划 × 学生 × 课程 × 考试类型)
-- 外键指向选课表,确保“成绩只能落在已选课程上”;唯一键防重复录入
-- ------------------------------------------------------------------------------
CREATE TABLE `demo_theory_score` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`plan_id` VARCHAR(32) NOT NULL COMMENT '开班计划ID',
`student_no` VARCHAR(32) NOT NULL COMMENT '学号',
`c_id` VARCHAR(32) NOT NULL COMMENT '课程ID',
`exam_type` VARCHAR(16) NOT NULL DEFAULT '正考' COMMENT '考试类型(正考/补考)',
`exam_status` VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '考试状态(正常/缺考/免修)',
`score` DECIMAL(5,2) DEFAULT NULL COMMENT '理论成绩(0-100,缺考为NULL)',
`full_score` DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '满分',
`pass_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '是否通过(1通过 0未通过)',
`exam_time` DATETIME DEFAULT NULL COMMENT '考试时间',
`remark` VARCHAR(200) DEFAULT NULL COMMENT '备注(边界/异常样本)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_score_member_course_exam` (`plan_id`, `student_no`, `c_id`, `exam_type`),
KEY `idx_score_student` (`student_no`),
KEY `idx_score_course` (`plan_id`, `c_id`),
CONSTRAINT `fk_score_student_course` FOREIGN KEY (`plan_id`, `student_no`, `c_id`)
REFERENCES `demo_student_course` (`plan_id`, `student_no`, `c_id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='学生理论成绩演示表(测试01班)';
-- 7.1 正考成绩:按“学生 × 已选课程”自动生成,保证与选课/班级/计划完全一致
-- 分数规则:001=100(满分)、013=0(零分)、019=59.5(差0.5分)、021=60(及格线)、
-- 024 的 C01 为缺考(NULL),其余 45~100 之间按学号+课程确定性分布
INSERT INTO `demo_theory_score`
(`plan_id`, `student_no`, `c_id`, `exam_type`, `exam_status`, `score`, `full_score`, `pass_flag`, `exam_time`, `remark`)
SELECT t.plan_id,
t.student_no,
t.c_id,
'正考',
t.exam_status,
t.score,
100.00,
CASE WHEN t.score IS NOT NULL AND t.score >= 60 THEN 1 ELSE 0 END,
CASE t.c_id
WHEN 'C01' THEN '2026-10-20 09:00:00'
WHEN 'C02' THEN '2026-10-22 09:00:00'
ELSE '2026-10-24 14:00:00'
END,
t.remark
FROM (
SELECT sc.plan_id,
sc.student_no,
sc.c_id,
CASE
WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN NULL
WHEN sc.student_no = 'TEST01-001' THEN 100.00
WHEN sc.student_no = 'TEST01-013' THEN 0.00
WHEN sc.student_no = 'TEST01-019' THEN 59.50
WHEN sc.student_no = 'TEST01-021' THEN 60.00
ELSE CAST(45 + MOD(CRC32(CONCAT(sc.student_no, '-', sc.c_id)), 56) AS DECIMAL(5,2))
END AS score,
CASE
WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN '缺考'
ELSE '正常'
END AS exam_status,
CASE
WHEN sc.student_no = 'TEST01-001' THEN '边界值:满分'
WHEN sc.student_no = 'TEST01-013' THEN '边界值:零分'
WHEN sc.student_no = 'TEST01-019' THEN '边界值:差0.5分未通过'
WHEN sc.student_no = 'TEST01-021' THEN '边界值:及格线'
WHEN sc.student_no = 'TEST01-024' AND sc.c_id = 'C01' THEN '异常样本:缺考无成绩'
ELSE NULL
END AS remark
FROM `demo_student_course` sc
) t;
-- 7.2 补考成绩:019、022 的 C01 正考未通过后补考通过(与正考类型不同,不违反唯一键)
INSERT INTO `demo_theory_score`
(`plan_id`, `student_no`, `c_id`, `exam_type`, `exam_status`, `score`, `full_score`, `pass_flag`, `exam_time`, `remark`)
VALUES
('PLAN-TEST01', 'TEST01-019', 'C01', '补考', '正常', 72.00, 100.00, 1, '2026-11-03 14:00:00', '异常样本:补考成绩提升'),
('PLAN-TEST01', 'TEST01-022', 'C01', '补考', '正常', 78.00, 100.00, 1, '2026-11-03 14:00:00', '异常样本:正考未通过后补考通过');
-- ==============================================================================
-- 8. 结果校验(只读)
-- ==============================================================================
-- 8.1 数据规模总览
SELECT '开班计划' AS table_name, COUNT(*) AS row_count FROM `demo_class_plan`
UNION ALL SELECT '课程', COUNT(*) FROM `demo_course`
UNION ALL SELECT '计划课程', COUNT(*) FROM `demo_plan_course`
UNION ALL SELECT '学生', COUNT(*) FROM `demo_student`
UNION ALL SELECT '班级成员', COUNT(*) FROM `demo_class_member`
UNION ALL SELECT '学生选课', COUNT(*) FROM `demo_student_course`
UNION ALL SELECT '理论成绩', COUNT(*) FROM `demo_theory_score`;
-- 8.2 班级成员与选课明细(含学生姓名、课程名、班级名)
SELECT p.class_name, m.student_no, s.student_name, c.c_name, sc.select_time
FROM `demo_class_member` m
JOIN `demo_class_plan` p ON p.plan_id = m.plan_id
JOIN `demo_student` s ON s.student_no = m.student_no
JOIN `demo_student_course` sc ON sc.plan_id = m.plan_id AND sc.student_no = m.student_no
JOIN `demo_course` c ON c.c_id = sc.c_id
ORDER BY m.student_no, c.c_id;
-- 8.3 学生成绩明细(正考/补考、含班级与课程)
SELECT p.class_name, s.student_no, s.student_name, c.c_name,
t.exam_type, t.exam_status, t.score, t.full_score, t.pass_flag, t.exam_time, t.remark
FROM `demo_theory_score` t
JOIN `demo_student` s ON s.student_no = t.student_no
JOIN `demo_course` c ON c.c_id = t.c_id
JOIN `demo_class_plan` p ON p.plan_id = t.plan_id
ORDER BY t.student_no, t.c_id, t.exam_type;
-- 8.4 课程维度统计:参考人数、平均分、最高分、最低分、及格率
SELECT c.c_name,
COUNT(t.score) AS scored_count,
ROUND(AVG(t.score), 2) AS avg_score,
MAX(t.score) AS max_score,
MIN(t.score) AS min_score,
ROUND(SUM(t.pass_flag) / COUNT(*) * 100, 2) AS pass_rate_percent
FROM `demo_theory_score` t
JOIN `demo_course` c ON c.c_id = t.c_id
WHERE t.exam_type = '正考'
GROUP BY c.c_name;
-- 8.5 关联一致性自检(三项均应返回 0 行)
-- a) 成绩存在、但对应选课关系不存在(外键已保护,此处为双重验证)
SELECT t.id, '成绩缺少选课关系' AS problem
FROM `demo_theory_score` t
LEFT JOIN `demo_student_course` sc
ON sc.plan_id = t.plan_id AND sc.student_no = t.student_no AND sc.c_id = t.c_id
WHERE sc.student_no IS NULL
-- b) 班级成员不在学生主档 / 不在开班计划
UNION ALL
SELECT m.student_no, '班级成员缺少学生或计划'
FROM `demo_class_member` m
LEFT JOIN `demo_student` s ON s.student_no = m.student_no
LEFT JOIN `demo_class_plan` p ON p.plan_id = m.plan_id
WHERE s.student_no IS NULL OR p.plan_id IS NULL
-- c) 选课不是本班已开设课程
UNION ALL
SELECT sc.student_no, '选课不在计划课程内'
FROM `demo_student_course` sc
LEFT JOIN `demo_plan_course` pc ON pc.plan_id = sc.plan_id AND pc.c_id = sc.c_id
WHERE pc.c_id IS NULL;
-- 8.6 成绩合法性自检(应返回 0 行)
SELECT id, student_no, c_id, exam_type, exam_status, score, full_score, pass_flag
FROM `demo_theory_score`
WHERE (score IS NOT NULL AND (score < 0 OR score > 100 OR score > full_score))
OR (exam_status = '正常' AND score IS NULL)
OR (score IS NOT NULL AND pass_flag <> IF(score >= 60, 1, 0));
-- ------------------------------------------------------------------------------
-- 9. 清理(如需删除全部演示数据,取消注释执行;按依赖倒序删除)
-- ------------------------------------------------------------------------------
-- SET FOREIGN_KEY_CHECKS = 0;
-- DROP TABLE IF EXISTS `demo_theory_score`;
-- DROP TABLE IF EXISTS `demo_student_course`;
-- DROP TABLE IF EXISTS `demo_class_member`;
-- DROP TABLE IF EXISTS `demo_student`;
-- DROP TABLE IF EXISTS `demo_plan_course`;
-- DROP TABLE IF EXISTS `demo_course`;
-- DROP TABLE IF EXISTS `demo_class_plan`;
-- SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,330 @@
-- ==============================================================================
-- RailwaySystem 测试数据: 007_demo_theory_score_kbjh2026_11.sql
-- 目标表: train_theory_exam_score(班级学员理论考试成绩表,需先执行 026 建表脚本)
-- 指定班级: 开班计划名称 = 'KBJH2026-11现场测试开班'
-- 数据库: MySQL 5.7+(仅使用派生表 / 临时表 / INSERT...SELECT / INSERT IGNORE,未用 CTE、窗口函数)
-- ==============================================================================
-- 一、依赖表结构与字段说明(均需已存在,本脚本不建业务表)
-- ------------------------------------------------------------------------------
-- 1) train_student(学员主档,本脚本只读)
-- s_id VARCHAR(32) PK 学员ID
-- s_name VARCHAR(50) 姓名(仅用于结果核对,不参与写入)
-- del_flag CHAR(1) 删除标志(0存在;若该列不存在请忽略相关注释)
--
-- 2) train_class_plan(开班计划,本脚本只读)
-- plan_id VARCHAR(32) PK 开班计划ID
-- plan_name VARCHAR(150) 计划名称 —— 本脚本按 'KBJH2026-11现场测试开班' 过滤
--
-- 3) train_plan_course(班级开设课程,本脚本只读)
-- plan_course_id BIGINT PK 培训课程实例ID(成绩表的课程维度键)
-- plan_id VARCHAR(32) 所属开班计划
-- c_id VARCHAR(32) 课程主档ID
-- UNIQUE (plan_id, c_id)
--
-- 4) train_enrollment(参训档案,本脚本只读)
-- enrollment_id BIGINT PK 参训档案ID(学员 × 班级唯一事实)
-- plan_id VARCHAR(32) 开班计划ID
-- student_id VARCHAR(32) 学员ID
-- —— 无 del_flag 列
--
-- 5) train_theory_exam_score(写入目标,字段含义见 026 脚本)
-- enrollment_id / plan_id / plan_course_id / c_id / student_id —— 维度
-- exam_start_time / exam_end_time / duration_seconds —— 考试过程
-- retake / retake_no —— 正考(0/0) / 补考(1/1..n)
-- exam_status (NORMAL/ABSENT/EXEMPT) —— 考试状态
-- score DECIMAL(7,2) NULL / full_score / pass_flag —— 成绩
-- is_final (1/0) —— 是否该课程最终成绩
-- score_status (RECEIVED/ARCHIVED/LOCKED) —— 成绩状态
-- source_type (1/2/3) / source_system / source_event_id —— 来源
-- del_flag ('0'/'2')
--
-- 二、生成逻辑
-- · 事实源 = 该计划下“学员 × 该班已开设课程”的笛卡尔组合:
-- train_enrollment (按 plan_id + student_id 取 MIN(enrollment_id) 去重)
-- JOIN train_class_plan (plan_name = 'KBJH2026-11现场测试开班')
-- JOIN train_plan_course (pc.plan_id = e.plan_id)
-- JOIN train_student (保证学员主档存在)
-- 由此保证 student_id / plan_id / plan_course_id / c_id 四者同源一致;
-- · 组合先落到临时表 tmp_pair,避免重复计算,也保证同一组合只生成一次;
-- · 再从 tmp_pair 中按 (student_id, plan_course_id) 升序取前 8 个组合,
-- 分别打上 FULL / ZERO / ABSENT / PASS_LINE / NEAR_LINE / EXEMPT / RETAKE1 / RETAKE2 场景标签,
-- 确保边界与异常样本**一定存在**(组合数 ≥ 8 时全部覆盖,不足时自动降级);
-- · 其余组合按成绩 45~100 的确定性分布生成(CRC32(学号+课程) 取模);
-- · 补考:对 RETAKE1 / RETAKE2 两个组合额外生成一条补考记录(retake=1, retake_no=1, is_final=1),
-- 其正考记录 is_final=0,既展示“补考后成为最终成绩”,又不违反 uk_theory_final;
-- · 全程 INSERT IGNORE:重复执行不报错、不产生重复数据(不会掩盖外键错误)。
--
-- 三、数据量
-- 正考行数 = 该计划“学员数 × 开设课程数”(本脚本生成全部组合)
-- 补考行数 = 2(RETAKE1、RETAKE2 两个组合)
-- 例如 20 名学员 × 3 门课程 → 60 条正考 + 2 条补考 = 62 行
-- 若该计划不存在或无学员/无课程,则插入 0 行(第 0 步的体检查询可确认)
-- ==============================================================================
SET NAMES utf8mb4;
-- 目标班级(如需换班,只改这一行)
SET @plan_name = 'KBJH2026-11现场测试开班';
-- ------------------------------------------------------------------------------
-- 第 0 步:体检(只读)
-- 0.1 计划是否存在
-- 0.2 该计划的学员数 / 课程数 / 可生成组合数
-- ------------------------------------------------------------------------------
SELECT plan_id, plan_name, class_name, start_time, end_time, plan_status
FROM train_class_plan
WHERE plan_name = @plan_name;
SELECT COUNT(*) AS available_pairs
FROM (
SELECT e.plan_id, e.student_id, pc.plan_course_id
FROM (SELECT plan_id, student_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY plan_id, student_id) e
JOIN train_class_plan cp ON cp.plan_id = e.plan_id AND cp.plan_name = @plan_name
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_student s ON s.s_id = e.student_id
) p;
-- ------------------------------------------------------------------------------
-- 第 1 步:构建“学员 × 课程”组合临时表
-- ------------------------------------------------------------------------------
DROP TEMPORARY TABLE IF EXISTS tmp_pair;
CREATE TEMPORARY TABLE tmp_pair (
enrollment_id BIGINT NOT NULL,
plan_id VARCHAR(32) NOT NULL,
student_id VARCHAR(32) NOT NULL,
plan_course_id BIGINT NOT NULL,
c_id VARCHAR(32) NOT NULL,
PRIMARY KEY (plan_course_id, student_id),
KEY idx_tmp_pair_student (student_id)
) ENGINE=InnoDB;
INSERT INTO tmp_pair (enrollment_id, plan_id, student_id, plan_course_id, c_id)
SELECT e.enrollment_id, e.plan_id, e.student_id, pc.plan_course_id, pc.c_id
FROM (SELECT plan_id, student_id, MIN(enrollment_id) AS enrollment_id
FROM train_enrollment
GROUP BY plan_id, student_id) e
JOIN train_class_plan cp ON cp.plan_id = e.plan_id AND cp.plan_name = @plan_name
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_course c ON c.c_id = pc.c_id
JOIN train_student s ON s.s_id = e.student_id;
-- ------------------------------------------------------------------------------
-- 第 2 步:为前 8 个组合打场景标签(边界 / 异常样本)
-- FULL 满分 100
-- ZERO 零分 0
-- ABSENT 缺考(score = NULL)
-- PASS_LINE 及格线 60
-- NEAR_LINE 59.5(差 0.5 分未通过)
-- EXEMPT 免修(score = NULL,计通过)
-- RETAKE1 正考 45 分未通过,后续补考 72 分通过
-- RETAKE2 正考 50 分未通过,后续补考 78 分通过
-- ------------------------------------------------------------------------------
DROP TEMPORARY TABLE IF EXISTS tmp_seed;
CREATE TEMPORARY TABLE tmp_seed (
student_id VARCHAR(32) NOT NULL,
plan_course_id BIGINT NOT NULL,
scenario VARCHAR(16) NOT NULL,
PRIMARY KEY (student_id, plan_course_id)
) ENGINE=InnoDB;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'FULL'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 0;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'ZERO'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 1;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'ABSENT'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 2;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'PASS_LINE'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 3;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'NEAR_LINE'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 4;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'EXEMPT'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 5;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'RETAKE1'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 6;
INSERT INTO tmp_seed (student_id, plan_course_id, scenario)
SELECT student_id, plan_course_id, 'RETAKE2'
FROM tmp_pair ORDER BY student_id, plan_course_id LIMIT 1 OFFSET 7;
-- ------------------------------------------------------------------------------
-- 第 3 步:生成正考成绩(覆盖该计划全部“学员 × 课程”组合)
-- RETAKE1 / RETAKE2 组合的 is_final = 0(其最终成绩由补考记录承担)
-- ------------------------------------------------------------------------------
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT x.enrollment_id, x.plan_id, x.plan_course_id, x.c_id, x.student_id, NULL,
DATE_SUB(x.end_time, INTERVAL 90 MINUTE), x.end_time, 5400, 0, 0,
x.exam_status, x.score, 100.00,
CASE
WHEN x.scenario = 'EXEMPT' THEN 1 -- 免修不计分,按通过计
WHEN x.score IS NOT NULL AND x.score >= 60 THEN 1
ELSE 0
END,
CASE WHEN x.scenario IN ('RETAKE1', 'RETAKE2') THEN 0 ELSE 1 END,
'RECEIVED',
1, NULL, NULL, NULL,
CASE x.scenario
WHEN 'FULL' THEN '测试数据-边界值:满分100'
WHEN 'ZERO' THEN '测试数据-边界值:零分0'
WHEN 'ABSENT' THEN '测试数据-异常:缺考无成绩'
WHEN 'PASS_LINE' THEN '测试数据-边界值:及格线60'
WHEN 'NEAR_LINE' THEN '测试数据-边界值:59.5差0.5分未通过'
WHEN 'EXEMPT' THEN '测试数据-异常:免修不计分'
WHEN 'RETAKE1' THEN '测试数据-异常:正考45分未通过(待补考)'
WHEN 'RETAKE2' THEN '测试数据-异常:正考50分未通过(待补考)'
ELSE NULL
END,
'0', 'test-data', NOW()
FROM (
SELECT p.enrollment_id, p.plan_id, p.plan_course_id, p.c_id, p.student_id,
COALESCE(sd.scenario, 'NORMAL') AS scenario,
CASE COALESCE(sd.scenario, 'NORMAL')
WHEN 'FULL' THEN CAST(100.00 AS DECIMAL(7,2))
WHEN 'ZERO' THEN CAST(0.00 AS DECIMAL(7,2))
WHEN 'ABSENT' THEN NULL
WHEN 'EXEMPT' THEN NULL
WHEN 'PASS_LINE' THEN CAST(60.00 AS DECIMAL(7,2))
WHEN 'NEAR_LINE' THEN CAST(59.50 AS DECIMAL(7,2))
WHEN 'RETAKE1' THEN CAST(45.00 AS DECIMAL(7,2))
WHEN 'RETAKE2' THEN CAST(50.00 AS DECIMAL(7,2))
ELSE CAST(45 + MOD(CRC32(CONCAT(p.student_id, '-', p.plan_course_id)), 56) AS DECIMAL(7,2))
END AS score,
CASE WHEN COALESCE(sd.scenario, 'NORMAL') IN ('ABSENT', 'EXEMPT') THEN
CASE WHEN sd.scenario = 'EXEMPT' THEN 'EXEMPT' ELSE 'ABSENT' END
ELSE 'NORMAL'
END AS exam_status,
DATE_SUB(NOW(), INTERVAL (MOD(CRC32(CONCAT(p.student_id, '-', p.plan_course_id)), 20) + 1) DAY) AS end_time
FROM tmp_pair p
LEFT JOIN tmp_seed sd
ON sd.student_id = p.student_id AND sd.plan_course_id = p.plan_course_id
) x;
-- ------------------------------------------------------------------------------
-- 第 4 步:生成补考成绩(RETAKE1 → 72 分,RETAKE2 → 78 分,均为最终成绩)
-- ------------------------------------------------------------------------------
INSERT IGNORE INTO train_theory_exam_score
(enrollment_id, plan_id, plan_course_id, c_id, student_id, subject_code,
exam_start_time, exam_end_time, duration_seconds, retake, retake_no,
exam_status, score, full_score, pass_flag, is_final, score_status,
source_type, source_system, source_event_id, score_item_id, remark,
del_flag, create_by, create_time)
SELECT p.enrollment_id, p.plan_id, p.plan_course_id, p.c_id, p.student_id, NULL,
DATE_SUB(NOW(), INTERVAL 3 DAY),
DATE_ADD(DATE_SUB(NOW(), INTERVAL 3 DAY), INTERVAL 90 MINUTE),
5400, 1, 1,
'NORMAL',
CASE sd.scenario WHEN 'RETAKE1' THEN CAST(72.00 AS DECIMAL(7,2)) ELSE CAST(78.00 AS DECIMAL(7,2)) END,
100.00, 1, 1, 'RECEIVED',
1, NULL, NULL, NULL, '测试数据-异常:补考通过(最终成绩)',
'0', 'test-data', NOW()
FROM tmp_pair p
JOIN tmp_seed sd
ON sd.student_id = p.student_id AND sd.plan_course_id = p.plan_course_id
WHERE sd.scenario IN ('RETAKE1', 'RETAKE2');
-- ==============================================================================
-- 第 5 步:结果校验(只读)
-- ==============================================================================
-- 5.1 该计划成绩总量与场景分布
SELECT t.exam_status, t.retake, t.is_final, COUNT(*) AS row_count
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data'
GROUP BY t.exam_status, t.retake, t.is_final
ORDER BY t.exam_status, t.retake, t.is_final;
-- 5.2 成绩明细(含学员姓名、课程名、计划名)
SELECT cp.plan_name, s.s_id, s.s_name, c.c_name,
t.retake, t.retake_no, t.exam_status, t.score, t.full_score, t.pass_flag,
t.is_final, t.exam_end_time, t.remark
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
JOIN train_student s ON s.s_id = t.student_id
JOIN train_course c ON c.c_id = t.c_id
WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data'
ORDER BY s.s_id, c.c_id, t.retake_no;
-- 5.3 关联一致性自检(学员 / 课程实例 / 参训档案三项,均应 0 行)
SELECT t.theory_score_id, '学员不存在' AS problem
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
LEFT JOIN train_student s ON s.s_id = t.student_id
WHERE cp.plan_name = @plan_name AND s.s_id IS NULL
UNION ALL
SELECT t.theory_score_id, '课程实例与计划/课程不匹配'
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
LEFT JOIN train_plan_course pc
ON pc.plan_course_id = t.plan_course_id
AND pc.plan_id = t.plan_id
AND pc.c_id = t.c_id
WHERE cp.plan_name = @plan_name AND pc.plan_course_id IS NULL
UNION ALL
SELECT t.theory_score_id, '参训档案与学员/班级不匹配'
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
LEFT JOIN train_enrollment e
ON e.enrollment_id = t.enrollment_id
AND e.student_id = t.student_id
AND e.plan_id = t.plan_id
WHERE cp.plan_name = @plan_name AND e.enrollment_id IS NULL;
-- 5.4 唯一性自检(均应为 0 行)
SELECT enrollment_id, plan_course_id, retake_no, COUNT(*) AS cnt
FROM train_theory_exam_score
WHERE del_flag = '0'
GROUP BY enrollment_id, plan_course_id, retake_no
HAVING COUNT(*) > 1;
SELECT plan_course_id, student_id, COUNT(*) AS final_cnt
FROM train_theory_exam_score
WHERE del_flag = '0' AND is_final = 1
GROUP BY plan_course_id, student_id
HAVING COUNT(*) > 1;
-- 5.5 成绩合法性自检(应 0 行)
SELECT t.theory_score_id, t.exam_status, t.score, t.full_score, t.pass_flag
FROM train_theory_exam_score t
JOIN train_class_plan cp ON cp.plan_id = t.plan_id
WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data'
AND (
(t.exam_status = 'NORMAL' AND t.score IS NULL)
OR (t.exam_status IN ('ABSENT', 'EXEMPT') AND t.score IS NOT NULL)
OR (t.score IS NOT NULL AND (t.score < 0 OR t.score > t.full_score))
OR (t.score IS NOT NULL AND t.pass_flag <> IF(t.score >= 60, 1, 0))
);
-- ------------------------------------------------------------------------------
-- 第 6 步:清理(仅删除本脚本为该计划生成的数据,取消注释后执行)
-- ------------------------------------------------------------------------------
-- DELETE t FROM train_theory_exam_score t
-- JOIN train_class_plan cp ON cp.plan_id = t.plan_id
-- WHERE cp.plan_name = @plan_name AND t.create_by = 'test-data';
-- ==============================================================================
-- 附:无法落库的异常场景(DB 约束拦截,脚本故意不插,可用于负向验证)
-- 1) 同一 (enrollment_id, plan_course_id, retake_no) 重复 → uk_theory_enroll_course_retake
-- 2) 同一课程同一学员两条 is_final = 1 → uk_theory_final(生成列 final_flag)
-- 3) 缺考/免修带分数、正常考试无分数 → chk_theory_score_filled(MySQL 8.0 强制)
-- 4) 考试结束时间早于开始时间 → chk_theory_exam_time
-- 5) 引用不存在的学员/课程/计划 → 外键拦截
-- ==============================================================================
@@ -0,0 +1,93 @@
-- ============================================================
-- 报到一体机 train_checkin_terminal 样例数据(10 条)
-- 说明:terminal_id / terminal_sn 唯一;date 格式 MySQL 5.7 兼容
-- 执行:SOURCE /path/to/sql/train/checkin_terminal_data.sql;
-- ============================================================
INSERT INTO train_checkin_terminal (
terminal_id, terminal_sn, terminal_name, bind_dept_id, install_location,
support_checkin_type, support_modules, support_print, support_broadcast,
contact_phone, welcome_title, org_name, screen_orientation, idle_return_seconds,
help_text, terminal_ip, api_endpoint, terminal_status, last_heartbeat,
del_flag, create_by, create_time, update_by, update_time, remark
) VALUES
(
'CKT-10001', 'SNCKT000001', '基地大厅1号报到机', 'DEPT-BASE-01', '1号楼大厅入口',
'ID_CARD,FACE', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y',
'0571-88001001', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 30,
'请刷身份证或人脸识别完成报到;刷卡时请将证件平放感应区;如需帮助请联系现场工作人员。',
'192.168.10.21', 'http://192.168.10.21:8080/api', 1, '2026-10-24 09:35:12',
'0', 'admin', '2026-10-20 10:00:00', 'admin', '2026-10-24 09:35:12', '主入口主力设备'
),
(
'CKT-10002', 'SNCKT000002', '基地食堂门口报到机', 'DEPT-BASE-01', '1号楼食堂门口',
'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS', 'N', 'Y',
'0571-88001002', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 45,
'支持身份证、人脸及学号三种方式;打印功能暂未开放。',
'192.168.10.22', 'http://192.168.10.22:8080/api', 1, '2026-10-24 09:31:40',
'0', 'admin', '2026-10-20 10:05:00', 'admin', '2026-10-23 14:20:00', '食堂侧分流设备'
),
(
'CKT-10003', 'SNCKT000003', '教学楼A报到机', 'DEPT-BASE-02', '教学楼A一层大厅',
'ID_CARD', 'CHECKIN,STATS', 'Y', 'N',
'0571-88002001', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 60,
'本机仅支持身份证刷卡报到,请携带有效证件。',
'192.168.20.31', 'http://192.168.20.31:8080/api', 0, '2026-10-23 18:02:11',
'0', 'admin', '2026-10-19 16:40:00', 'admin', '2026-10-23 18:02:11', '夜间已关机'
),
(
'CKT-10004', 'SNCKT000004', '教学楼B报到机', 'DEPT-BASE-02', '教学楼B一层大厅',
'FACE,STUDENT_NO', 'CHECKIN,INQUIRY', 'N', 'Y',
'0571-88002002', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 30,
'人脸报到请正对摄像头;也可输入学号报到。',
'192.168.20.32', 'http://192.168.20.32:8080/api', 1, '2026-10-24 09:40:55',
'0', 'admin', '2026-10-19 16:45:00', 'admin', '2026-10-24 08:10:00', ''
),
(
'CKT-10005', 'SNCKT000005', '宿舍区报到机', 'DEPT-BASE-03', '学员宿舍楼1栋大厅',
'ID_CARD,FACE', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y',
'0571-88003001', '欢迎回到培训基地', '铁路培训基地(西南)', 'PORTRAIT', 30,
'报到后请留意显示屏上的班级与宿舍安排;打印凭条请于报到完成后领取。',
'192.168.30.41', 'http://192.168.30.41:8080/api', 1, '2026-10-24 09:28:03',
'0', 'admin', '2026-10-18 09:00:00', 'admin', '2026-10-24 09:28:03', '宿舍区综合设备'
),
(
'CKT-10006', 'SNCKT000006', '西门访客报到机', 'DEPT-BASE-03', '基地西大门',
'ID_CARD', 'CHECKIN', 'N', 'N',
'0571-88003002', '欢迎使用自助报到终端', '铁路培训基地(西南)', 'LANDSCAPE', 30,
'西门临时报到点,仅支持身份证刷卡。',
'192.168.30.42', 'http://192.168.30.42:8080/api', 0, NULL,
'0', 'admin', '2026-10-17 11:20:00', 'admin', '2026-10-21 09:00:00', '横屏临时点位'
),
(
'CKT-10007', 'SNCKT000007', '实训车间报到机', 'DEPT-BASE-04', '实训车间入口',
'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y',
'0571-88004001', '欢迎进入实训车间', '铁路培训基地(东北)', 'PORTRAIT', 30,
'实训前请先完成报到;设备支持打印实训安排汇总。',
'192.168.40.51', 'http://192.168.40.51:8080/api', 1, '2026-10-24 09:15:47',
'0', 'admin', '2026-10-16 14:00:00', 'admin', '2026-10-24 07:50:00', ''
),
(
'CKT-10008', 'SNCKT000008', '报告厅报到机', 'DEPT-BASE-04', '综合报告厅前厅',
'FACE', 'CHECKIN,STATS', 'N', 'Y',
'0571-88004002', '欢迎参加开班典礼', '铁路培训基地(东北)', 'PORTRAIT', 45,
'大型活动报到请使用人脸识别,快速通行。',
'192.168.40.52', 'http://192.168.40.52:8080/api', 1, '2026-10-24 08:55:30',
'0', 'admin', '2026-10-16 14:10:00', 'admin', '2026-10-24 08:55:30', '典礼临时启用'
),
(
'CKT-10009', 'SNCKT000009', '行政楼报到机', 'DEPT-BASE-01', '行政楼一层大厅',
'ID_CARD,STUDENT_NO', 'CHECKIN,INQUIRY', 'Y', 'N',
'0571-88001003', '欢迎使用自助报到终端', '铁路培训基地(华东)', 'PORTRAIT', 30,
'行政楼报到点,支持刷卡与学号;打印功能开放。',
'192.168.10.23', 'http://192.168.10.23:8080/api', 0, '2026-10-22 17:40:09',
'0', 'admin', '2026-10-15 10:30:00', 'admin', '2026-10-22 17:40:09', '行政办公区'
),
(
'CKT-10010', 'SNCKT000010', '机动备用报到机', 'DEPT-BASE-02', '设备库房(备用)',
'ID_CARD,FACE,STUDENT_NO', 'CHECKIN,INQUIRY,STATS,SETTINGS', 'Y', 'Y',
'0571-88002003', '欢迎使用自助报到终端', '铁路培训基地(华北)', 'PORTRAIT', 30,
'备用设备,按需部署;全功能配置。',
'192.168.20.33', 'http://192.168.20.33:8080/api', 0, NULL,
'0', 'admin', '2026-10-14 09:00:00', 'admin', '2026-10-14 09:00:00', '仓库待启用'
);
@@ -0,0 +1,29 @@
-- ============================================================
-- 报到一体机管理 菜单(挂在“设备管理”目录下,排序 4)
-- 前置条件:sys_menu 中已存在菜单名为「设备管理」的目录(menu_type='M')
-- 说明:超级管理员(role_id=1) 无需单独授权即可看到菜单;
-- 其他角色需将下列 perms 授权后按钮才可见。
-- ============================================================
-- 1) 定位「设备管理」目录,作为父菜单
SET @parentDev := (SELECT menu_id FROM sys_menu WHERE menu_name = '设备管理' LIMIT 1);
-- 2) 新增菜单(C 类型)
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_time, update_time)
VALUES
('报到一体机管理', @parentDev, 4, 'checkinTerminal', 'device/checkinTerminal/index', '', 'CheckinTerminal',
'0', '0', 'C', '0', '0', 'device:checkinterminal:list', 'monitor', NOW(), NOW());
-- 3) 记录新菜单的 id,作为按钮权限的父级
SET @parentMenu := LAST_INSERT_ID();
-- 4) 新增按钮级权限(F 类型):新增 / 修改 / 删除
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_time, update_time)
VALUES
('报到一体机管理-新增', @parentMenu, 1, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:add', '#', NOW(), NOW()),
('报到一体机管理-修改', @parentMenu, 2, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:edit', '#', NOW(), NOW()),
('报到一体机管理-删除', @parentMenu, 3, '#', '', '', '', '0', '0', 'F', '0', '0', 'device:checkinterminal:remove', '#', NOW(), NOW());
@@ -44,7 +44,7 @@ ALTER TABLE train_electronic_board DROP CONSTRAINT IF EXISTS fk_board_plan;
-- ==============================================================================
ALTER TABLE train_teacher
ADD COLUMN user_id BIGINT,
ADD COLUMN org_name VARCHAR(100),
ADD COLUMN dept_name VARCHAR(100),
ADD COLUMN job_title VARCHAR(50),
ADD COLUMN status SMALLINT DEFAULT 1,
ADD COLUMN create_by VARCHAR(64),
@@ -58,7 +58,7 @@ ALTER TABLE train_teacher
ALTER COLUMN remark TYPE VARCHAR(500);
COMMENT ON COLUMN train_teacher.user_id IS 'sys_user登录账号逻辑关联ID';
COMMENT ON COLUMN train_teacher.org_name IS '所属单位/部门名称 (由旧 t_department 迁移)';
COMMENT ON COLUMN train_teacher.dept_name IS '所属单位/部门名称 (由旧 t_department 迁移)';
COMMENT ON COLUMN train_teacher.job_title IS '职务/职称 (由旧 t_title 迁移)';
COMMENT ON COLUMN train_teacher.status IS '状态 (1:正常, 0:停用)';
COMMENT ON COLUMN train_teacher.del_flag IS '删除标志 (0:存在, 2:删除)';
@@ -76,7 +76,8 @@ ALTER TABLE train_student
-- 4.2 增加企业培训新主档字段、加密字段与公共审计字段
ALTER TABLE train_student
ADD COLUMN user_id BIGINT,
ADD COLUMN org_name VARCHAR(100),
ADD COLUMN dept_name VARCHAR(100),
ADD COLUMN card_no VARCHAR(50),
ADD COLUMN s_email VARCHAR(100),
ADD COLUMN id_card_ciphertext TEXT,
ADD COLUMN id_card_hash VARCHAR(128),
@@ -94,7 +95,7 @@ ALTER TABLE train_student
ALTER COLUMN remark TYPE VARCHAR(500);
COMMENT ON COLUMN train_student.user_id IS 'sys_user账号逻辑关联';
COMMENT ON COLUMN train_student.org_name IS '所属单位/部门';
COMMENT ON COLUMN train_student.dept_name IS '所属单位/部门';
COMMENT ON COLUMN train_student.id_card_ciphertext IS '身份证可逆加密密文';
COMMENT ON COLUMN train_student.id_card_hash IS '身份证精确匹配Hash (防重)';
COMMENT ON COLUMN train_student.id_card_last4 IS '身份证后四位展示';
+1 -1
View File
@@ -19,7 +19,7 @@ CREATE INDEX idx_teacher_name ON train_teacher (t_name);
-- 2. train_student 学员表索引
CREATE INDEX idx_student_name ON train_student (s_name);
CREATE INDEX idx_student_org ON train_student (org_name);
CREATE INDEX idx_student_org ON train_student (dept_name);
CREATE INDEX idx_student_user_id ON train_student (user_id);
-- 3. train_course 课程表索引
+25
View File
@@ -332,4 +332,29 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_cl
SELECT 4, 'Excel批量导入', '4', 'train_score_source', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '线下成绩表格批量导入'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_score_source' AND dict_value = '4');
-- 17. train_class_major
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '培训专业', 'train_class_major', '0', 'admin', CURRENT_TIMESTAMP, '培训班所属专业分类 (对应 train_class_plan.class_major)'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_class_major');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '接触网工', '1', 'train_class_major', 'primary', 'Y', '0', 'admin', CURRENT_TIMESTAMP, '接触网检修专业'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '1');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '电力线路工', '2', 'train_class_major', 'primary', 'N', '0', 'admin', CURRENT_TIMESTAMP, '电力线路检修专业'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '2');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '信号工', '3', 'train_class_major', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '信号设备维护专业'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '3');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 4, '通信工', '4', 'train_class_major', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '通信设备维护专业'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '4');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 5, '机车司机', '5', 'train_class_major', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, '机车驾驶专业'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_class_major' AND dict_value = '5');
COMMIT;
@@ -6,7 +6,7 @@
-- 设计依据: RailwaySystem 数据库 V2 字段设计与 Kingbase DDL 规范 V1.0 及 Round 1 审查要求
-- 安全规范:
-- 1. 严禁在脚本中出现真实教师姓名、电话、身份证、邮箱等明文数据!
-- 2. 完全采用纯 DML 数据映射: t_department -> org_name, t_title -> job_title, create_user -> create_by
-- 2. 完全采用纯 DML 数据映射: t_department -> dept_name, t_title -> job_title, create_user -> create_by
-- 3. 执行后验证迁移完整性,再将 status 与 del_flag 设为 NOT NULL 并安全移除旧列
-- ==============================================================================
@@ -15,7 +15,7 @@ BEGIN;
-- 步骤 1: 数据平滑映射 (仅更新新字段为空的行,保证幂等)
UPDATE train_teacher
SET
org_name = COALESCE(org_name, t_department),
dept_name = COALESCE(dept_name, t_department),
job_title = COALESCE(job_title, t_title),
create_by = COALESCE(create_by, create_user),
status = COALESCE(status, 1),
@@ -31,7 +31,7 @@ BEGIN
SELECT count(*) INTO v_total_cnt FROM train_teacher;
SELECT count(*) INTO v_unmigrated_cnt
FROM train_teacher
WHERE org_name IS NULL OR status IS NULL OR del_flag IS NULL;
WHERE dept_name IS NULL OR status IS NULL OR del_flag IS NULL;
IF v_total_cnt <> 1 THEN
RAISE EXCEPTION '>>> [008-FATAL] 教师数据校验异常: 期望精确 1 条数据,实际检测到 % 条', v_total_cnt;
+1 -1
View File
@@ -90,7 +90,7 @@ BEGIN
SELECT count(*) INTO v_cnt
FROM train_teacher
WHERE org_name IS NULL OR status IS NULL OR del_flag IS NULL;
WHERE dept_name IS NULL OR status IS NULL OR del_flag IS NULL;
IF v_cnt > 0 THEN
RAISE EXCEPTION '>>> [Verify-FATAL] train_teacher 记录中新主档字段存在非法 NULL 值';
END IF;
@@ -0,0 +1,90 @@
-- ============================================================
-- 阅读机/听书机 7 张业务表 - 测试数据
-- 来源:阅读机数据表+接口设计-v1.md
-- ============================================================
-- 前置说明:
-- 1) 本文件仅包含 INSERT 测试数据;建表 DDL 请按设计文档执行
-- (其中 train_book_download_record 的 DDL 见 patch/014_train_book_download_record.sql,其余 6 张表按文档“一、数据库表设计”创建)。
-- 2) 逻辑外键依赖:train_user_session / 各阅读进度 / 上报日志 / 下载回执 中的 user_id=10001
-- 需先在 sys_user 中存在该学员账号(RuoYi 内置用户体系,本文件不含 sys_user 数据)。
-- 3) 设备 device_id、图书 book_id/book_version、会话 session_id、上报 record_id 均保持自洽,
-- 可直接用于验证“登录 / 阅读上报 / 进度冲突 / 下架过滤 / 下载回执”等接口。
-- 4) device_secret_digest 为 SHA256 十六进制占位值(64 位),真实环境请替换为对应设备密钥的 SHA256 摘要。
-- ============================================================
-- ----------------------------------------------------------
-- 1) train_iot_device 读书机设备档案表
-- ----------------------------------------------------------
INSERT INTO `train_iot_device`
(`device_id`, `device_secret_digest`, `device_status`, `firmware_version`, `app_version`, `bind_org_id`, `bind_class_id`, `heartbeat_interval`, `last_login_time`, `last_heartbeat_time`)
VALUES
('READER-LIB-01', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', 1, '1.4.2', '2.1.0', 'ORG-001', 'CLASS-01,CLASS-02', 60, '2026-10-20 09:00:00', '2026-10-20 09:30:00'),
('READER-LIB-02', '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', 1, '1.4.0', '2.0.5', 'ORG-001', 'CLASS-03', 60, '2026-10-19 08:00:00', '2026-10-20 08:50:00'),
('READER-LIB-03', '688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6', 2, '1.3.1', '1.9.0', 'ORG-002', 'CLASS-05', 120, NULL, '2026-10-15 10:00:00');
-- ----------------------------------------------------------
-- 2) train_book_snapshot 图书快照表
-- is_on_shelf: 1 上架 / 0 下架(下架仅禁止新下载,仍可上报阅读)
-- ----------------------------------------------------------
INSERT INTO `train_book_snapshot`
(`book_id`, `book_title`, `book_category`, `book_summary`, `book_version`, `file_size`, `file_md5`, `download_url`, `is_on_shelf`, `publish_time`)
VALUES
('BK-00012', '铁路房建施工安全手册', 'SAFETY', '房建施工安全作业规范与事故案例', 'BV-3', 2048000, 'c4ca4238a0b923820dcc509a6f75849b', '/books/BK-00012/BV-3.pdf', 1, '2025-10-18 00:00:00'),
('BK-00012', '铁路房建施工安全手册', 'SAFETY', '房建施工安全作业规范与事故案例(旧版)', 'BV-2', 1980000, 'b6d8a532a0f1c2e3d4f5a6b7c8d9e0f10', '/books/BK-00012/BV-2.pdf', 0, '2025-06-01 00:00:00'),
('BK-00013', '铁路电力安全工作规程', 'REGULATION', '电气化铁路安全工作规程要点', 'BV-2', 3072000, 'a1b2c3d4e5f60718293a4b5c6d7e8f90a', '/books/BK-00013/BV-2.pdf', 1, '2025-09-10 00:00:00'),
('BK-00014', '高铁客运服务英语', 'LANGUAGE', '高铁乘务日常英语会话', 'BV-1', 1536000, 'f0e1d2c3b4a5968778695a4b3c2d1e0f1', '/books/BK-00014/BV-1.pdf', 1, '2025-08-20 00:00:00'),
('BK-00015', '铁路线路维修规则', 'REGULATION', '普速铁路线路维修规则', 'BV-1', 4096000, '112233445566778899aabbccddeeff00', '/books/BK-00015/BV-1.pdf', 0, '2025-05-15 00:00:00');
-- ----------------------------------------------------------
-- 3) train_user_session 学员业务会话表
-- session_status: 1 ACTIVE正常在线 / 2 REPLACED被替换仅补交 / 3 LOGOUT已退出
-- 下面第 2 条模拟“被新登录顶替”的旧会话,用于验证冲突进度(CONFLICT_SAVED)
-- ----------------------------------------------------------
INSERT INTO `train_user_session`
(`user_id`, `session_id`, `device_id`, `session_status`, `login_time`, `replace_time`, `logout_time`, `ruoyi_token_key`)
VALUES
(10001, 'SES-20261020-0001', 'READER-LIB-01', 1, '2026-10-20 09:00:00', NULL, NULL, 'login_tokens:token-active-001'),
(10001, 'SES-20261019-0001', 'READER-LIB-02', 2, '2026-10-19 08:00:00', '2026-10-20 09:00:05', NULL, 'login_tokens:token-replaced-001');
-- ----------------------------------------------------------
-- 4) train_reading_current_progress 学员主续读进度
-- (user_id, book_id, mode) 唯一;mode: read 阅读 / audio 听书
-- ----------------------------------------------------------
INSERT INTO `train_reading_current_progress`
(`user_id`, `book_id`, `mode`, `book_version`, `page_no`, `sentence_index`, `progress`, `source_record_id`, `source_session_id`)
VALUES
(10001, 'BK-00012', 'read', 'BV-3', 46, NULL, 0.35, 'RD-20261020-0001', 'SES-20261020-0001'),
(10001, 'BK-00012', 'audio', 'BV-3', 120, 88, 0.50, 'RD-20261020-0002', 'SES-20261020-0001'),
(10001, 'BK-00013', 'read', 'BV-2', 30, NULL, 0.20, 'RD-20261020-0003', 'SES-20261020-0001');
-- ----------------------------------------------------------
-- 5) train_reading_alternate_progress 备选冲突续读进度
-- 来自被 REPLACED 的旧设备会话提交,不覆盖主进度,供学员手动选择跳转
-- ----------------------------------------------------------
INSERT INTO `train_reading_alternate_progress`
(`user_id`, `book_id`, `mode`, `book_version`, `page_no`, `sentence_index`, `progress`, `source_record_id`, `source_session_id`)
VALUES
(10001, 'BK-00012', 'read', 'BV-2', 22, NULL, 0.18, 'RD-20261019-0001', 'SES-20261019-0001');
-- ----------------------------------------------------------
-- 6) train_reading_duration_log 阅读上报幂等日志
-- result_code: ACCEPTED / DUPLICATE / CONFLICT_SAVED / REJECTED
-- (user_id, record_id) 唯一,record_id 重复将返回 DUPLICATE
-- ----------------------------------------------------------
INSERT INTO `train_reading_duration_log`
(`device_id`, `user_id`, `session_id`, `record_id`, `book_id`, `book_version`, `mode`, `record_type`, `duration_sec`, `page_from`, `page_to`, `progress`, `start_time`, `end_time`, `receive_time`, `result_code`, `remark`)
VALUES
('READER-LIB-01', 10001, 'SES-20261020-0001', 'RD-20261020-0001', 'BK-00012', 'BV-3', 'read', 'END', 1420, 12, 46, 0.35, '2026-10-20 08:30:00', '2026-10-20 09:00:00', '2026-10-20 09:00:01', 'ACCEPTED', NULL),
('READER-LIB-01', 10001, 'SES-20261020-0001', 'RD-20261020-0002', 'BK-00012', 'BV-3', 'audio', 'PROGRESS', 600, 100, 120, 0.50, '2026-10-20 09:00:00', '2026-10-20 09:10:00', '2026-10-20 09:10:01', 'ACCEPTED', NULL),
('READER-LIB-02', 10001, 'SES-20261019-0001', 'RD-20261019-0001', 'BK-00012', 'BV-2', 'read', 'END', 980, 5, 22, 0.18, '2026-10-19 19:45:00', '2026-10-19 20:00:00', '2026-10-19 20:00:01', 'CONFLICT_SAVED', 'REPLACED会话补交,已存备选进度');
-- ----------------------------------------------------------
-- 7) train_book_download_record 图书下载回执记录表
-- result: SUCCESS / FAIL
-- ----------------------------------------------------------
INSERT INTO `train_book_download_record`
(`user_id`, `device_id`, `book_id`, `book_version`, `file_md5`, `result`, `err_msg`, `download_time`)
VALUES
(10001, 'READER-LIB-01', 'BK-00012', 'BV-3', 'c4ca4238a0b923820dcc509a6f75849b', 'SUCCESS', NULL, '2026-10-20 08:00:00'),
(10001, 'READER-LIB-01', 'BK-00013', 'BV-2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90a', 'SUCCESS', NULL, '2026-10-20 08:05:00'),
(10001, 'READER-LIB-03', 'BK-00014', 'BV-1', 'f0e1d2c3b4a5968778695a4b3c2d1e0f1', 'FAIL', '存储空间不足', '2026-10-15 09:00:00');
+289
View File
@@ -0,0 +1,289 @@
# 阅读机/听书机 接口测试数据手册
> 配套文件:`阅读机数据表+接口设计-v1.md`、`015_train_test_data.sql`
> 本文档中的「请求示例」全部使用 `015_train_test_data.sql` 已插入的真实数据,可直接照抄测试。
>
> 基础地址:`http://localhost:8096/api/iot/v1`(端口按你实际改)
> 数据前提:已执行 `015_train_test_data.sql`,且 `sys_user` 中存在 `userId=10001`、`status='0'`(正常)。
---
## 0. 鉴权与测试顺序
| 接口 | 鉴权 |
|---|---|
| `POST /device/login` | 免鉴权(permitAll) |
| `GET /book/list` | 免鉴权(已放开) |
| `GET /book/download` | 免鉴权(已放开) |
| 其余 6 个接口 | 需 `Authorization: Bearer <accessToken>`(来自登录返回) |
> **登录接口有强制设备签名(`DeviceHmacFilter`)**:必须带 `X-Signature` + `X-Nonce` 请求头,且 body 的 `timestamp` 距今 ≤5 分钟,否则报「缺少设备签名头 / 签名校验失败 / 时间戳超时」。
> 本地联调不想算签名时,把 `application.yml` 的 `iot.hmac.enabled` 改为 `false`(重启后端)即可跳过验签;生产环境务必保持 `true`。
> 其余接口(Bearer 鉴权)不受该 Filter 影响。
**推荐顺序**:登录 → 复制 `data.accessToken` → 在 Swagger 点 Authorize 填 `Bearer <accessToken>`(或 curl 带 `-H 'Authorization: Bearer <accessToken>'`)→ 测其余接口。
---
## 1. 设备学员登录 `POST /api/iot/v1/device/login`
请求(取自 015 设备 `READER-LIB-01` + 学员 `10001`):
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/device/login' \
-H 'Content-Type: application/json' \
-d '{
"deviceId":"READER-LIB-01",
"userId":10001,
"firmware":"1.4.2",
"appVersion":"2.1.0",
"timestamp":1767620000000,
"nonce":"a1b2c3d4e5f6"
}'
```
预期响应:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"accessToken": "<ruoyi生成的token>",
"sessionId": "<本次新建的业务会话ID>",
"heartbeatInterval": 60
}
}
```
---
## 2. 设备注销 `POST /api/iot/v1/device/logout`(需 token)
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/device/logout' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <accessToken>' \
-d '{"deviceId":"READER-LIB-01","reason":"REBOOT","timestamp":1767621000000,"nonce":"f6e5d4c3b2a1"}'
```
预期:`{"data":{"result":true,"tokenRevoked":true}}`
---
## 3. 设备心跳 `POST /api/iot/v1/device/heartbeat`(需 token)
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/device/heartbeat' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <accessToken>' \
-d '{"deviceId":"READER-LIB-01","status":"ONLINE","metrics":{"cpu":20,"mem":30,"diskFree":10240},"configVersion":"CV-17","personVersion":"PV-226118-7","timestamp":1767622000000,"nonce":"aa11bb22cc33"}'
```
预期:`{"data":{"nextInterval":60,"commands":[]}}`
---
## 4. 阅读记录单条上报 `POST /api/iot/v1/reading/record`(需 token)
**新 recordId(正常 ACCEPTED)** —— 用上架书 `BK-00012/BV-3`:
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/reading/record' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <accessToken>' \
-d '{
"deviceId":"READER-LIB-01",
"recordId":"RD-TEST-0001",
"bookId":"BK-00012",
"bookVersion":"BV-3",
"mode":"read",
"recordType":"END",
"startTime":1767620000000,
"endTime":1767622400000,
"durationSec":1420,
"pageFrom":12,
"pageTo":46,
"progress":0.35
}'
```
预期:`{"data":{"result":"ACCEPTED","studyMinutes":23,"totalMinutes":<累计/60>}}`
> 重复发送同一个 `recordId=RD-TEST-0001` → 返回 `{"result":"DUPLICATE",...}`(幂等)。
---
## 5. 阅读记录批量上报 `POST /api/iot/v1/reading/record/batch`(需 token)
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/reading/record/batch' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <accessToken>' \
-d '{
"deviceId":"READER-LIB-01",
"batchId":"BATCH-RD-001",
"timestamp":1767622500000,
"nonce":"b2b3c4d5e6f7",
"records":[
{"recordId":"RD-TEST-0002","bookId":"BK-00013","bookVersion":"BV-2","mode":"read","recordType":"END","startTime":1767620000000,"endTime":1767622400000,"durationSec":980,"pageFrom":5,"pageTo":22,"progress":0.18}
]
}'
```
预期:`{"data":{"itemResults":[{"recordId":"RD-TEST-0002","result":"ACCEPTED"}]}}`
---
## 6. 获取学员阅读汇总与续读进度 `GET /api/iot/v1/reading/progress`(需 token)
```bash
curl 'http://localhost:8096/api/iot/v1/reading/progress?deviceId=READER-LIB-01' \
-H 'Authorization: Bearer <accessToken>'
```
预期(基于 015 已插入的进度/上报数据;需存在一个 `ACTIVE` 会话,登录后即有):
```json
{
"data": {
"userId": 10001,
"totalReadSeconds": 2400,
"totalAudioSeconds": 600,
"bookRecords": [
{
"bookId": "BK-00012", "bookVersion": "BV-3",
"mainProgress": {"mode":"read","pageNo":46,"sentenceIndex":null,"progress":0.35,"updateTime":1767622400000},
"alternateProgressList": [
{"mode":"read","bookVersion":"BV-2","pageNo":22,"sentenceIndex":null,"progress":0.18,"createTime":1767610000000}
]
},
{
"bookId": "BK-00012", "bookVersion": "BV-3",
"mainProgress": {"mode":"audio","pageNo":120,"sentenceIndex":88,"progress":0.50,"updateTime":1767622500000},
"alternateProgressList": []
},
{
"bookId": "BK-00013", "bookVersion": "BV-2",
"mainProgress": {"mode":"read","pageNo":30,"sentenceIndex":null,"progress":0.20,"updateTime":1767622500000},
"alternateProgressList": []
}
]
}
}
```
> `totalReadSeconds`=1420+980(两条 read 上报累加);`totalAudioSeconds`=600(audio 上报)。
---
## 7. 获取可阅读书籍列表 `GET /api/iot/v1/book/list`(免鉴权)
```bash
# 全部上架书
curl 'http://localhost:8096/api/iot/v1/book/list?deviceId=READER-LIB-01'
# 按分类过滤(只返回上架)
curl 'http://localhost:8096/api/iot/v1/book/list?deviceId=READER-LIB-01&bookCategory=SAFETY'
```
预期(`bookCategory` 省略时 `total=3`,只返回 `is_on_shelf=1` 的书;下架的 `BK-00012/BV-2`、`BK-00015` 不返回):
```json
{
"data": {
"total": 3,
"list": [
{"bookId":"BK-00012","bookTitle":"铁路房建施工安全手册","bookCategory":"SAFETY","bookVersion":"BV-3","fileSize":2048000,"fileMd5":"c4ca4238a0b923820dcc509a6f75849b","isOnShelf":true},
{"bookId":"BK-00013","bookTitle":"铁路电力安全工作规程","bookCategory":"REGULATION","bookVersion":"BV-2","fileSize":3072000,"fileMd5":"a1b2c3d4e5f60718293a4b5c6d7e8f90a","isOnShelf":true},
{"bookId":"BK-00014","bookTitle":"高铁客运服务英语","bookCategory":"LANGUAGE","bookVersion":"BV-1","fileSize":1536000,"fileMd5":"f0e1d2c3b4a5968778695a4b3c2d1e0f1","isOnShelf":true}
]
}
}
```
> `bookCategory=SAFETY` 时 `total=1`(仅 `BK-00012/BV-3`)。
---
## 8. PDF 书籍下载 `GET /api/iot/v1/book/download`(免鉴权)
```bash
# 上架书(正常)
curl -i 'http://localhost:8096/api/iot/v1/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3'
# 支持断点续传
curl -i -H 'Range: bytes=0-1023' 'http://localhost:8096/api/iot/v1/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3'
```
- 上架书:HTTP 200(或 Range 时 206),响应头带 `Accept-Ranges: bytes`、`Content-MD5`。
- 下架书:`bookId=BK-00012&bookVersion=BV-2`(is_on_shelf=0)→ 业务报错 `{"code":500,"msg":"图书已下架,禁止下载"}`。
- 不存在的版本:`bookId=BK-00015&bookVersion=BV-9` → `图书或版本不存在`。
> ⚠️ `download_url` 支持两种形态:
> ① **相对路径**(如正式数据的 `/profile/upload/2026/10/21/铁路房建施工安全手册_20261021085854A001.pdf`,或测试用的 `/books/BK-00012/BV-3.pdf`)——后端自动在前面补上「后端 base URL(`scheme+host+port`,如 `http://localhost:8096`)」转成可访问的 http(s) 绝对地址后再流式返回;
> ② **完整 http(s) 远程地址**(如 OSS)——直接拉取。
> 两种都会自动加 `Content-Type: application/pdf` 与 `Content-Disposition`(下载头),保证浏览器可访问。
> 相对路径由后端自身静态资源处理器提供文件:`/profile/**` 指向 RuoYi 上传根目录(文件须真实存在于该磁盘目录),`/books/**` 指向 `classpath:/static/books/`(测试 PDF 已放此处)。文件不存在则返回「文件不存在」。
---
## 9. 下载完成回执上报 `POST /api/iot/v1/book/download/ack`(需 token)
```bash
curl -X POST 'http://localhost:8096/api/iot/v1/book/download/ack' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <accessToken>' \
-d '{
"deviceId":"READER-LIB-01",
"bookId":"BK-00012",
"bookVersion":"BV-3",
"fileMd5":"c4ca4238a0b923820dcc509a6f75849b",
"result":"SUCCESS",
"errMsg":"",
"timestamp":1767630000000,
"nonce":"cc55dd66ee77"
}'
```
预期:`{"data":{"deviceReady":true,"recordId":<自增主键>}}`
> `result` 可取 `SUCCESS`/`FAIL`(缺省 SUCCESS);落库 `train_book_download_record`。
---
## 10. 边界 / 异常用例矩阵(均基于 015 数据)
| 用例 | 操作 | 预期 |
|---|---|---|
| 禁用设备登录 | `device/login` 用 `deviceId=READER-LIB-03`(status=2) | `msg:"设备已禁用"` |
| 重复上报幂等 | `reading/record` 用已存在的 `recordId=RD-20261020-0001` | `result:"DUPLICATE"` |
| 冲突进度(REPLACED 仅补交) | 用 `deviceId=READER-LIB-02`(其会话 `SES-20261019-0001` 是 REPLACED)发 `reading/record` 新 recordId | `result:"CONFLICT_SAVED"`,写入 `train_reading_alternate_progress` |
| 下架书被过滤 | `book/list` 不含 `BK-00012/BV-2`、`BK-00015` | `total=3`(仅上架) |
| 下架书禁止下载 | `book/download?bookId=BK-00012&bookVersion=BV-2` | 业务错误「图书已下架,禁止下载」 |
| REPLACED 禁止进度查询 | `reading/progress?deviceId=READER-LIB-02`(需先有一个有效 token) | `msg:"当前会话状态不允许查询进度(被替换设备仅允许补交上报)"` |
| 版本强绑定 | 进度查询中 `BK-00012` 的 `read` 主进度 `BV-3` 与备选 `BV-2` 不混用页码 | 各自返回对应版本页码 |
---
## 11. 已插入测试数据速查(源自 015)
**设备 `train_iot_device`**
- `READER-LIB-01` status=1(正常)、`READER-LIB-02` status=1、`READER-LIB-03` status=2(禁用)
**图书 `train_book_snapshot`**(is_on_shelf)
- `BK-00012/BV-3` SAFETY 上架 md5=`c4ca4238a0b923820dcc509a6f75849b`
- `BK-00012/BV-2` SAFETY **下架**
- `BK-00013/BV-2` REGULATION 上架 md5=`a1b2c3d4e5f60718293a4b5c6d7e8f90a`
- `BK-00014/BV-1` LANGUAGE 上架 md5=`f0e1d2c3b4a5968778695a4b3c2d1e0f1`
- `BK-00015/BV-1` REGULATION **下架**
**会话 `train_user_session`**
- `SES-20261020-0001` user=10001 device=READER-LIB-01 status=1 ACTIVE
- `SES-20261019-0001` user=10001 device=READER-LIB-02 status=2 REPLACED(用于冲突用例)
**主进度 `train_reading_current_progress`**:10001 的 `BK-00012/read/BV-3`(46,0.35)、`BK-00012/audio/BV-3`(120,88,0.50)、`BK-00013/read/BV-2`(30,0.20)
**备选进度 `train_reading_alternate_progress`**:10001 `BK-00012/read/BV-2`(22,0.18)
**上报日志 `train_reading_duration_log`**:`RD-20261020-0001`(ACCEPTED)、`RD-20261020-0002`(ACCEPTED)、`RD-20261019-0001`(CONFLICT_SAVED)
**下载回执 `train_book_download_record`**:`BK-00012/BV-3` SUCCESS、`BK-00013/BV-2` SUCCESS、`BK-00014/BV-1` FAIL(errMsg=存储空间不足)
@@ -0,0 +1,144 @@
{
"info": {
"name": "阅读机-听书机接口 (v1)",
"description": "配套 015_train_test_data.sql 与 016_api_test_data.md。\nbaseUrl 默认 http://localhost:8096/api/iot/v1(端口按实际改)。\n\n【鉴权】先发‘1.设备学员登录’,其 Tests 脚本会自动把 data.accessToken 存入集合变量 accessToken;其余需鉴权接口通过 Authorization: Bearer {{accessToken}} 使用。\n\n【免鉴权】book/list、book/download 已放开,可直接测。\n\n【下载】book/download 读取 train_book_snapshot.download_url(已指向本地 D:/tmp/books/<bookId>/<bookVersion>.pdf 占位文件),请用 Send and Download 保存为文件;支持 Range 断点续传。下架书(BK-00012/BV-2、BK-00015)会返回业务错误。",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "baseUrl", "value": "http://localhost:8096/api/iot/v1" },
{ "key": "accessToken", "value": "" }
],
"item": [
{
"name": "1.设备学员登录 POST /device/login",
"request": {
"method": "POST",
"url": "{{baseUrl}}/device/login",
"header": [ { "key": "Content-Type", "value": "application/json" } ],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"userId\": 10001\n}" }
},
"event": [
{
"listen": "prerequest",
"script": {
"exec": [
"var secrets = {",
" \"READER-LIB-01\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",",
" \"READER-LIB-02\": \"60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752\",",
" \"READER-LIB-03\": \"688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6\"",
"};",
"var body = JSON.parse(pm.request.body.raw);",
"var secret = secrets[body.deviceId];",
"body.timestamp = Date.now();",
"var raw = JSON.stringify(body);",
"pm.request.body.raw = raw;",
"var nonce = CryptoJS.lib.WordArray.random(16).toString();",
"var stringToSign = \"POST\\n/api/iot/v1/device/login\\n\" + body.timestamp + \"\\n\" + nonce + \"\\n\" + raw;",
"var sig = CryptoJS.HmacSHA256(stringToSign, secret).toString(CryptoJS.enc.Hex);",
"pm.request.headers.upsert({ key: \"X-Nonce\", value: nonce });",
"pm.request.headers.upsert({ key: \"X-Signature\", value: sig });"
]
}
},
{
"listen": "test",
"script": {
"exec": [
"var json = pm.response.json();",
"if (json && json.data && json.data.accessToken) {",
" pm.collectionVariables.set('accessToken', json.data.accessToken);",
" console.log('accessToken saved');",
"} else {",
" console.log('login failed: ' + pm.response.text());",
"}"
]
}
}
]
},
{
"name": "2.设备注销 POST /device/logout",
"request": {
"method": "POST",
"url": "{{baseUrl}}/device/logout",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{accessToken}}" }
],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"reason\": \"REBOOT\",\n \"timestamp\": 1767621000000,\n \"nonce\": \"f6e5d4c3b2a1\"\n}" }
}
},
{
"name": "3.设备心跳 POST /device/heartbeat",
"request": {
"method": "POST",
"url": "{{baseUrl}}/device/heartbeat",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{accessToken}}" }
],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"status\": \"ONLINE\",\n \"metrics\": {\"cpu\":20,\"mem\":30,\"diskFree\":10240},\n \"configVersion\": \"CV-17\",\n \"personVersion\": \"PV-226118-7\",\n \"timestamp\": 1767622000000,\n \"nonce\": \"aa11bb22cc33\"\n}" }
}
},
{
"name": "4.阅读记录单条上报 POST /reading/record",
"request": {
"method": "POST",
"url": "{{baseUrl}}/reading/record",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{accessToken}}" }
],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"recordId\": \"RD-TEST-0001\",\n \"bookId\": \"BK-00012\",\n \"bookVersion\": \"BV-3\",\n \"mode\": \"read\",\n \"recordType\": \"END\",\n \"startTime\": 1767620000000,\n \"endTime\": 1767622400000,\n \"durationSec\": 1420,\n \"pageFrom\": 12,\n \"pageTo\": 46,\n \"progress\": 0.35\n}" }
}
},
{
"name": "5.阅读记录批量上报 POST /reading/record/batch",
"request": {
"method": "POST",
"url": "{{baseUrl}}/reading/record/batch",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{accessToken}}" }
],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"batchId\": \"BATCH-RD-001\",\n \"timestamp\": 1767622500000,\n \"nonce\": \"b2b3c4d5e6f7\",\n \"records\": [\n {\"recordId\":\"RD-TEST-0002\",\"bookId\":\"BK-00013\",\"bookVersion\":\"BV-2\",\"mode\":\"read\",\"recordType\":\"END\",\"startTime\":1767620000000,\"endTime\":1767622400000,\"durationSec\":980,\"pageFrom\":5,\"pageTo\":22,\"progress\":0.18}\n ]\n}" }
}
},
{
"name": "6.阅读汇总与续读进度 GET /reading/progress",
"request": {
"method": "GET",
"url": "{{baseUrl}}/reading/progress?deviceId=READER-LIB-01",
"header": [ { "key": "Authorization", "value": "Bearer {{accessToken}}" } ]
}
},
{
"name": "7.可阅读书籍列表 GET /book/list (免鉴权)",
"description": "免鉴权。可加 &bookCategory=SAFETY 过滤。无分类时只返回上架书 total=3(下架的 BK-00012/BV-2、BK-00015 不返回)。",
"request": {
"method": "GET",
"url": "{{baseUrl}}/book/list?deviceId=READER-LIB-01"
}
},
{
"name": "8.PDF书籍下载 GET /book/download (免鉴权)",
"description": "免鉴权。download_url 已指向本地 D:/tmp/books/BK-00012/BV-3.pdf(占位文件)。请用 Send and Download 保存。支持 Range 断点续传。下架书 bookId=BK-00012&bookVersion=BV-2 会返回业务错误『图书已下架,禁止下载』。",
"request": {
"method": "GET",
"url": "{{baseUrl}}/book/download?deviceId=READER-LIB-01&bookId=BK-00012&bookVersion=BV-3"
}
},
{
"name": "9.下载完成回执 POST /book/download/ack",
"request": {
"method": "POST",
"url": "{{baseUrl}}/book/download/ack",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{accessToken}}" }
],
"body": { "mode": "raw", "raw": "{\n \"deviceId\": \"READER-LIB-01\",\n \"bookId\": \"BK-00012\",\n \"bookVersion\": \"BV-3\",\n \"fileMd5\": \"c4ca4238a0b923820dcc509a6f75849b\",\n \"result\": \"SUCCESS\",\n \"errMsg\": \"\",\n \"timestamp\": 1767630000000,\n \"nonce\": \"cc55dd66ee77\"\n}" }
}
}
]
}
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# ==============================================================================
# 018_common_api_curl.sh
# 通用数据接口(/api/common/v1)联调脚本 —— 对应 TrainCommonApiController 的 5 个
# Bearer 鉴权接口:student/list、plan/list、course/list、checkin/upload、score/upload
#
# 运行前提:
# 1) 后端已启动(默认 http://localhost:8096)
# 2) 已执行 018_common_api_test_data.sql(提供 student/course/plan 种子数据,
# 否则 student/list、plan/list、course/list 返回空数组,上传外键会失败)
# 3) sys_user 中存在下方 USERNAME 账号(默认 admin / admin123,按你环境调整)
# 4) 已执行 015_train_test_data.sql(阅读机数据,与本脚本无关,仅作环境提示)
#
# 鉴权方式:先 POST /login 拿 Bearer token,后续接口统一带 Authorization 头
# —— 与“走 RuoYi Bearer 鉴权,Token 来源于 /login”完全一致。
#
# 运行:在 Git Bash / WSL / 任意 bash 中执行 bash 018_common_api_curl.sh
# 可用环境变量覆盖:BASE / USERNAME / PASSWORD
#
# 依赖:curl(必需);jq(可选,用于美化 JSON 与自动取 planId,缺失时自动降级)
# ==============================================================================
set -u
BASE="${BASE:-http://localhost:8096}"
USERNAME="${USERNAME:-admin}"
PASSWORD="${PASSWORD:-admin123}"
echo "==> [0] 获取 Bearer Token POST $BASE/login"
LOGIN_RESP=$(curl -s -X POST "$BASE/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")
if command -v jq >/dev/null 2>&1; then
TOKEN=$(printf '%s' "$LOGIN_RESP" | jq -r '.data.token // empty')
else
TOKEN=$(printf '%s' "$LOGIN_RESP" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
fi
if [ -z "$TOKEN" ]; then
echo "✗ 登录失败,原始响应:"
printf '%s\n' "$LOGIN_RESP"
exit 1
fi
echo "✓ 获取 token 成功(前20字符):${TOKEN:0:20}..."
AUTH="Authorization: Bearer $TOKEN"
# 有 jq 就美化输出,否则原样打印
pretty() {
if command -v jq >/dev/null 2>&1; then jq .; else cat; fi
}
echo
echo "==> [1] 学员信息列表 GET $BASE/api/common/v1/student/list"
curl -s "$BASE/api/common/v1/student/list" -H "$AUTH" | pretty
echo
echo "==> [2] 班级(培训计划)列表 GET $BASE/api/common/v1/plan/list"
PLAN_RESP=$(curl -s "$BASE/api/common/v1/plan/list" -H "$AUTH")
printf '%s' "$PLAN_RESP" | pretty
echo
# 自动取第一个 planId 供 course/list 使用(无 jq / 空数据时回退占位)
PLAN_ID="P2026001"
if command -v jq >/dev/null 2>&1; then
AUTO_PLAN=$(printf '%s' "$PLAN_RESP" | jq -r '.data[0].planId // empty')
[ -n "$AUTO_PLAN" ] && PLAN_ID="$AUTO_PLAN"
fi
echo "(course/list 使用的 classId = $PLAN_ID,如需手动指定请在下方覆盖)"
echo
echo "==> [3] 班级课程信息 GET $BASE/api/common/v1/course/list?classId=$PLAN_ID"
curl -s "$BASE/api/common/v1/course/list?classId=$PLAN_ID" -H "$AUTH" | pretty
echo
echo "==> [4] 上传考勤记录 POST $BASE/api/common/v1/checkin/upload"
curl -s -X POST "$BASE/api/common/v1/checkin/upload" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{
"planId":"'"$PLAN_ID"'",
"planName":"2026年第1期接触网工培训班",
"studentId":"S20260001",
"studentName":"张三",
"courseName":"接触网安全规程",
"checkinWay":1,
"signInTime":1761000000000,
"signOutTime":1761003600000,
"attendanceStatus":1,
"boardSn":"BP-001",
"boardName":"一班班牌"
}' | pretty
echo
echo "==> [5] 上传实训成绩 POST $BASE/api/common/v1/score/upload"
curl -s -X POST "$BASE/api/common/v1/score/upload" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{
"planId":"'"$PLAN_ID"'",
"cId":"C0001",
"studentId":"S20260001",
"courseScore":90.5,
"trainScore":88.0,
"examScore":95.0,
"sourceType":3,
"sourceSystem":"IOT",
"importTime":1761000000000
}' | pretty
echo
echo "==> 完成。"
@@ -0,0 +1,57 @@
-- ============================================================
-- 018_common_api_test_data.sql
-- 配套 TrainCommonApiController 的 5 个通用数据接口(/api/common/v1)的测试种子数据
-- 用途:让 student/list、plan/list、course/list 返回真实数据,
-- 并使 checkin/upload、score/upload 的外键(student / course / plan)成立。
-- 说明:仅 INSERT,可重复执行;PK 表用 ON CONFLICT DO NOTHING,
-- train_plan_course 无唯一约束,故先按组合清理再插(测试数据幂等)。
-- 前置:train_teacher / train_course / train_student / train_class_plan /
-- train_plan_course 表已存在(V2 基线 000 / 002)。
-- 与 015_train_test_data.sql(阅读机 7 张表)互补,互不影响。
-- ============================================================
-- 1) 教师(train_class_plan.t_id 外键依赖)
INSERT INTO train_teacher (t_id, t_name, t_gender, t_department, t_title, create_user)
VALUES ('T0001', '李教员', '男', '职工培训部', '高级讲师', 'system')
ON CONFLICT (t_id) DO NOTHING;
-- 2) 课程(train_class_plan.c_id 外键依赖;也是 course/list 的来源之一)
INSERT INTO train_course (c_id, c_name, c_type, c_duration, teaching_objective, create_user)
VALUES ('C0001', '接触网安全规程', '理论', 4, '掌握接触网作业安全规程与事故防范', 'system')
ON CONFLICT (c_id) DO NOTHING;
-- 3) 学员(checkin/upload、score/upload 的 student_id 外键依赖)
INSERT INTO train_student (s_id, s_name, s_gender, s_birth, s_department, s_major, s_class, s_phone, create_user)
VALUES ('S20260001', '张三', '男', '1998-05-12', '供电段', '接触网工', '接触网工班', '13800000001', 'system')
ON CONFLICT (s_id) DO NOTHING;
-- 4) 班级(培训计划):plan/list 来源
INSERT INTO train_class_plan (plan_id, plan_name, c_id, start_time, end_time, train_address, t_id, recruit_num, plan_status, create_user)
VALUES ('P2026001', '2026年第1期接触网工培训班', 'C0001', '2026-10-01 09:00:00', '2026-10-30 18:00:00', '实训基地', 'T0001', 30, 1, 'system')
ON CONFLICT (plan_id) DO NOTHING;
-- 5) 班级课程关联:course/list 依赖 train_plan_course(plan_id + c_id)
-- 显式指定 plan_course_id=90001,供下面的课次(lesson)外键关联
DELETE FROM train_plan_course WHERE plan_id = 'P2026001' AND c_id = 'C0001';
INSERT INTO train_plan_course (plan_course_id, plan_id, c_id, sort_no, create_by)
VALUES (90001, 'P2026001', 'C0001', 1, 'system');
-- 6) 教室(train_lesson 场地依赖,venue_type=1 时必填 class_room_id)
DELETE FROM train_classroom_info WHERE class_room_id = 'CR-001';
INSERT INTO train_classroom_info (class_room_id, room_name, room_location, capacity, create_user)
VALUES ('CR-001', '第一理论教室', '实训基地A栋201', 40, 'system');
-- 7) 参训记录(checkin / score 上传的 enrollment_id 来源)
DELETE FROM train_enrollment WHERE enrollment_id = 900001;
INSERT INTO train_enrollment (enrollment_id, plan_id, student_id, source_type, enrollment_status, accommodation_required, create_by)
VALUES (900001, 'P2026001', 'S20260001', 1, 1, 0, 'system');
-- 8) 课次(checkin 上传的 lesson_id 来源;需绑定 plan_course 与 教室)
DELETE FROM train_lesson WHERE lesson_id = 9000001;
INSERT INTO train_lesson (lesson_id, plan_course_id, t_id, start_time, end_time, venue_type, class_room_id, create_by)
VALUES (9000001, 90001, 'T0001', '2026-10-08 09:00:00', '2026-10-08 11:00:00', 1, 'CR-001', 'system');
-- 9) 成绩项(score/upload 的 score_item_id 来源;train_student_score.score_item_id 为 NOT NULL)
DELETE FROM train_score_item WHERE score_item_id = 90001;
INSERT INTO train_score_item (score_item_id, plan_id, plan_course_id, item_name, item_type, weight, full_score, create_by)
VALUES (90001, 'P2026001', 90001, '综合成绩', 4, 100.00, 100.00, 'system');
@@ -0,0 +1,19 @@
-- ============================================================
-- 018b_alter_reading_duration_log_read_sec.sql
-- 为 train_reading_duration_log 增加“已朗读时长”字段 read_sec(INTEGER)
-- 与既有 duration_sec(有效时长秒)区分:read_sec 表示学员实际朗读时长(秒)。
-- 说明:仓库内未包含该表的 CREATE TABLE DDL(仅有 015 的 INSERT 与 016 文档),
-- 故用补列脚本将字段落到已存在的表上;脚本幂等,可重复执行。
-- ============================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'train_reading_duration_log'
AND column_name = 'read_sec'
) THEN
ALTER TABLE train_reading_duration_log ADD COLUMN read_sec INTEGER;
END IF;
END $$;
+63
View File
@@ -0,0 +1,63 @@
-- ============================================================
-- 019_signage.sql (MySQL 5.7)
-- 电子班牌取数接口(/api/board/v1/signageService/*)所需补充表与字段
-- 配合 TrainBoardApiController + TrainSignageMapper 使用。
-- 说明:仅 ADD COLUMN / CREATE TABLE / 测试种子。
-- 前置:train_student / train_classroom_info / train_class_plan /
-- train_lesson / train_plan_course / train_course / train_enrollment /
-- train_teacher 已存在(V2 基线 000 / 002)。
-- 注意:MySQL 5.7 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS,
-- 下面用临时存储过程做幂等判断;CREATE TABLE 用 IF NOT EXISTS(5.7 支持)。
-- ============================================================
-- 1) 学员卡号(刷卡校验用,train_student 无此列,需补列)
DROP PROCEDURE IF EXISTS tmp_add_card_no;
DELIMITER $$
CREATE PROCEDURE tmp_add_card_no()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'train_student'
AND column_name = 'card_no'
) THEN
ALTER TABLE train_student
ADD COLUMN card_no VARCHAR(32) DEFAULT NULL COMMENT '学员卡号(刷卡校验用)';
END IF;
END$$
DELIMITER ;
CALL tmp_add_card_no();
DROP PROCEDURE IF EXISTS tmp_add_card_no;
-- 2) 座位分配表(接口3 studentInClassroom 所需,原库无座位表)
CREATE TABLE IF NOT EXISTS train_class_seat (
seat_id BIGINT NOT NULL AUTO_INCREMENT,
plan_id VARCHAR(32) NOT NULL COMMENT '班级ID(train_class_plan.plan_id)',
classroom_id VARCHAR(32) NOT NULL COMMENT '教室ID(train_classroom_info.class_room_id)',
row_index INT NOT NULL COMMENT '行号,从1开始',
col_index INT NOT NULL COMMENT '列号,从1开始',
seat_code VARCHAR(32) DEFAULT NULL COMMENT '物理座位标识',
canceled SMALLINT NOT NULL DEFAULT 0 COMMENT '是否取消位置(0正常,1取消)',
student_id VARCHAR(32) DEFAULT NULL COMMENT '占用座位学员ID(train_student.s_id)',
zone_id BIGINT DEFAULT NULL COMMENT '区域ID',
dept_detail VARCHAR(100) DEFAULT NULL COMMENT '部门',
job VARCHAR(100) DEFAULT NULL COMMENT '职务',
create_by VARCHAR(64) DEFAULT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) DEFAULT NULL,
update_time TIMESTAMP NULL DEFAULT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (seat_id),
UNIQUE KEY uq_class_seat_pos (plan_id, classroom_id, row_index, col_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级在指定教室的座位分配表(电子班牌座位网格来源)';
-- 3) 测试种子:为 018 的学员 S20260001 设置卡号(文档示例值)
UPDATE train_student SET card_no = '0514457515' WHERE s_id = 'S20260001';
-- 4) 测试种子:P2026001 在教室 CR-001 的座位布局
DELETE FROM train_class_seat WHERE plan_id = 'P2026001' AND classroom_id = 'CR-001';
INSERT INTO train_class_seat (plan_id, classroom_id, row_index, col_index, seat_code, canceled, student_id, zone_id, dept_detail, job)
VALUES
('P2026001', 'CR-001', 1, 1, '1-1', 0, 'S20260001', 100, '培训部', '学员'),
('P2026001', 'CR-001', 1, 2, '1-2', 0, NULL, 100, NULL, NULL),
('P2026001', 'CR-001', 2, 1, '2-1', 1, NULL, 100, NULL, NULL);
@@ -0,0 +1,53 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量脚本: 020_add_checkin_student_id.sql
-- 目标: 为 train_student_checkin 增加 student_id (VARCHAR(32))
-- 把学员维度从 enrollment 关系下沉为考勤行本身的冗余列,按学员检索考勤时
-- 无需再 join train_enrollment,同时保留 enrollment_id 作为业务唯一键。
-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public)
-- 幂等性: 通过 information_schema 判断列是否已存在,可重复执行
-- 关联变更: 004/006 基线同步、010 校验规则调整、Mapper/Domain/DTO/Service 同步
-- ==============================================================================
BEGIN;
-- 步骤 1: 新增列 (可为空,先加列再回填,避免已有数据阻塞)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'train_student_checkin'
AND column_name = 'student_id'
) THEN
ALTER TABLE train_student_checkin ADD COLUMN student_id VARCHAR(32);
END IF;
END $$;
COMMENT ON COLUMN train_student_checkin.student_id IS '学员ID (冗余自 train_enrollment.student_id,随考勤行落库)';
-- 步骤 2: 以参训档案为准回填历史数据
UPDATE train_student_checkin a
SET student_id = e.student_id
FROM train_enrollment e
WHERE e.enrollment_id = a.enrollment_id
AND (a.student_id IS NULL OR a.student_id <> e.student_id);
-- 步骤 3: 按学员维度检索的索引
CREATE INDEX IF NOT EXISTS idx_checkin_student ON train_student_checkin (student_id);
-- 步骤 4: 回填结果校验 (存在无参训档案的脏数据时仅告警,不中断发布)
DO $$
DECLARE
v_null_cnt INT;
BEGIN
SELECT count(*) INTO v_null_cnt
FROM train_student_checkin
WHERE student_id IS NULL;
IF v_null_cnt > 0 THEN
RAISE NOTICE '>>> [CheckinStudentId-WARN] 仍有 % 行 student_id 为空,请核对 enrollment_id 是否有效', v_null_cnt;
ELSE
RAISE NOTICE '>>> [CheckinStudentId-PASS] train_student_checkin.student_id 已全量回填';
END IF;
END $$;
COMMIT;
@@ -0,0 +1,49 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 020b_add_checkin_student_id_mysql.sql
-- 目标: 为 train_student_checkin 增加 student_id (VARCHAR(32)),并回填历史数据
-- 适用数据库: MySQL 5.7 / 8.0 (线上 training_railway)
-- 说明:
-- * 020_add_checkin_student_id.sql 为 KingbaseES/PostgreSQL 版本(DO $$ / UPDATE ... FROM),
-- 在 MySQL 上无法执行,故单独提供本脚本。
-- * 全部语句使用 information_schema 守卫,可重复执行。
-- 执行:
-- mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 020b_add_checkin_student_id_mysql.sql
-- ==============================================================================
-- 步骤 1: 加列 (仅当不存在)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_student_checkin'
AND COLUMN_NAME = 'student_id'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_student_checkin` ADD COLUMN `student_id` VARCHAR(32) NULL COMMENT ''学员ID(冗余自 train_enrollment.student_id)'' AFTER `lesson_id`',
'SELECT ''student_id 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 步骤 2: 以参训档案为准回填历史数据
UPDATE `train_student_checkin` a
JOIN `train_enrollment` e ON e.enrollment_id = a.enrollment_id
SET a.student_id = e.student_id
WHERE a.student_id IS NULL OR a.student_id <> e.student_id;
-- 步骤 3: 按学员维度检索的索引 (仅当不存在)
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_student_checkin'
AND INDEX_NAME = 'idx_checkin_student'
);
SET @ddl2 := IF(@idx_exists = 0,
'CREATE INDEX `idx_checkin_student` ON `train_student_checkin` (`student_id`)',
'SELECT ''idx_checkin_student 已存在,跳过建索引'' AS msg');
PREPARE stmt2 FROM @ddl2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
-- 步骤 4: 结果校验 (total_rows 总行数, null_rows 未回填行数)
SELECT COUNT(*) AS total_rows, SUM(student_id IS NULL) AS null_rows
FROM `train_student_checkin`;
@@ -0,0 +1,16 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量脚本: 021_add_teacher_card_no.sql
-- 目标: 为 train_teacher 增加 card_no (教师卡号),与 train_student.card_no 保持同长度 (VARCHAR(32))
-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public)
-- 幂等性: ADD COLUMN IF NOT EXISTS,可重复执行
-- 说明: 仅新增列与列注释,不涉及任何数据约束、默认值或业务规则
-- ==============================================================================
BEGIN;
ALTER TABLE public.train_teacher
ADD COLUMN IF NOT EXISTS card_no VARCHAR(32);
COMMENT ON COLUMN public.train_teacher.card_no IS '教师卡号(刷卡/身份识别用)';
COMMIT;
@@ -0,0 +1,25 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 021b_add_teacher_card_no_mysql.sql
-- 目标: 为 train_teacher 增加 card_no (教师卡号),长度与 train_student.card_no 对齐 (VARCHAR(32))
-- 说明: 021_add_teacher_card_no.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本
-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行
-- 执行:
-- mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 021b_add_teacher_card_no_mysql.sql
-- ==============================================================================
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_teacher'
AND COLUMN_NAME = 'card_no'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_teacher` ADD COLUMN `card_no` VARCHAR(32) DEFAULT NULL COMMENT ''教师卡号(刷卡/身份识别用)''',
'SELECT ''train_teacher.card_no 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT COUNT(*) AS total_rows,
SUM(card_no IS NULL) AS null_rows
FROM `train_teacher`;
@@ -0,0 +1,52 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量脚本: 022_add_accommodation_lock_result.sql
-- 目标: 为 train_accommodation 增加 lock_result (门锁授权结果)
-- 1. 新增列 lock_result SMALLINT NOT NULL DEFAULT 1
-- 取值: 1 未授权 / 2 已授权 / 3 已过期,默认 1(未授权)
-- 2. 新增字典类型 train_accommodation_lock_result 及其 3 个字典项
-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public)
-- 幂等性: ADD COLUMN IF NOT EXISTS + NOT EXISTS 判断,可重复执行
-- 说明: 仅新增列、列注释与字典数据,不涉及数据约束变更与业务逻辑调整
-- ==============================================================================
BEGIN;
-- 1. 新增门锁授权结果列
ALTER TABLE public.train_accommodation
ADD COLUMN IF NOT EXISTS lock_result SMALLINT NOT NULL DEFAULT 1;
COMMENT ON COLUMN public.train_accommodation.lock_result IS '门锁授权结果 (1:未授权, 2:已授权, 3:已过期)';
-- 2. 字典类型
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '住宿门锁授权结果', 'train_accommodation_lock_result', '0', 'admin', CURRENT_TIMESTAMP,
'住宿记录请求门锁接口的授权结果 (1 未授权, 2 已授权, 3 已过期)'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_accommodation_lock_result');
-- 3. 字典项
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '未授权', '1', 'train_accommodation_lock_result', 'info', 'Y', '0', 'admin', CURRENT_TIMESTAMP, '尚未取得门锁授权'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '1');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '已授权', '2', 'train_accommodation_lock_result', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '门锁接口已返回授权成功'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '2');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '已过期', '3', 'train_accommodation_lock_result', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '授权已到期失效'
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '3');
COMMIT;
-- ==============================================================================
-- 核对语句(可选,只读):
-- SELECT column_name, data_type, column_default, is_nullable
-- FROM information_schema.columns
-- WHERE table_name = 'train_accommodation' AND column_name = 'lock_result';
-- SELECT dict_code, dict_label, dict_value, list_class, is_default
-- FROM sys_dict_data WHERE dict_type = 'train_accommodation_lock_result'
-- ORDER BY dict_sort;
-- ==============================================================================
@@ -0,0 +1,56 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 022b_add_accommodation_lock_result_mysql.sql
-- 目标: 为 train_accommodation 增加 lock_result (门锁授权结果)
-- 1. 新增列 lock_result SMALLINT NOT NULL DEFAULT 1
-- 取值: 1 未授权 / 2 已授权 / 3 已过期,默认 1(未授权)
-- 2. 新增字典类型 train_accommodation_lock_result 及其 3 个字典项
-- 说明: 022_add_accommodation_lock_result.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本
-- 幂等: 通过 information_schema 判断列是否已存在 + 字典 NOT EXISTS 判断,可重复执行
-- 执行:
-- mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 022b_add_accommodation_lock_result_mysql.sql
-- ==============================================================================
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_accommodation'
AND COLUMN_NAME = 'lock_result'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_accommodation` ADD COLUMN `lock_result` SMALLINT NOT NULL DEFAULT 1 COMMENT ''门锁授权结果 (1:未授权, 2:已授权, 3:已过期)''',
'SELECT ''train_accommodation.lock_result 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 字典类型
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '住宿门锁授权结果', 'train_accommodation_lock_result', '0', 'admin', NOW(),
'住宿记录请求门锁接口的授权结果 (1 未授权, 2 已授权, 3 已过期)'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_accommodation_lock_result');
-- 字典项
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '未授权', '1', 'train_accommodation_lock_result', 'info', 'Y', '0', 'admin', NOW(), '尚未取得门锁授权'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '1');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '已授权', '2', 'train_accommodation_lock_result', 'success', 'N', '0', 'admin', NOW(), '门锁接口已返回授权成功'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '2');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '已过期', '3', 'train_accommodation_lock_result', 'warning', 'N', '0', 'admin', NOW(), '授权已到期失效'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data
WHERE dict_type = 'train_accommodation_lock_result' AND dict_value = '3');
SELECT COUNT(*) AS total_rows,
SUM(lock_result = 1) AS unauthorized_rows,
SUM(lock_result = 2) AS authorized_rows,
SUM(lock_result = 3) AS expired_rows
FROM `train_accommodation`;
@@ -0,0 +1,413 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本: 023_open_exam_api.sql
-- 模块: 外部实训系统数据接口(开放接口 /api/open/v1)
-- 内容: 建表语句 + 约束/索引 + 字典 + 菜单 + 测试数据
-- 数据库: MySQL 5.7(生产主库);KingbaseES 落地差异见 doc/外部实训系统数据接口设计(V1.0).md 3.9
-- 说明:
-- 1) MySQL 5.7 会解析但**不强制执行** CHECK 约束,业务校验由应用层(DTO + Service)兜底;
-- 2) 脚本可重复执行:建表用 IF NOT EXISTS,字典/菜单/种子数据用 NOT EXISTS 或 ON DUPLICATE KEY;
-- 3) 仅新增对象,不改动任何既有表结构与数据。
-- 执行: mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 023_open_exam_api.sql
-- ==============================================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------------------------
-- 1. 外部应用注册表
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_open_app` (
`app_id` VARCHAR(64) NOT NULL COMMENT '应用标识(报文 appId)',
`app_name` VARCHAR(100) NOT NULL COMMENT '应用名称',
`app_type` VARCHAR(16) NOT NULL COMMENT '应用类型(VR/INSPECT3D/PRACTICE/CAPSTONE/OTHER)',
`org_name` VARCHAR(100) DEFAULT NULL COMMENT '归属单位',
`app_secret_hash` VARCHAR(128) NOT NULL COMMENT 'appSecret 散列(BCrypt,不存明文)',
`app_secret_masked` VARCHAR(16) DEFAULT NULL COMMENT '密钥掩码(展示后4位)',
`contact_name` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
`contact_phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
`rate_limit` INT NOT NULL DEFAULT 20 COMMENT '每秒请求上限',
`realtime_enabled` SMALLINT NOT NULL DEFAULT 1 COMMENT '是否启用实时上报(1启用 0关闭)',
`status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0停用)',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`app_id`),
KEY `idx_open_app_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部实训系统应用注册表';
-- ------------------------------------------------------------------------------
-- 2. 应用授权范围表(班级 / 科目)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_open_app_scope` (
`scope_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` VARCHAR(64) NOT NULL COMMENT '应用标识',
`scope_type` VARCHAR(16) NOT NULL COMMENT '范围类型(CLASS 班级 / SUBJECT 科目)',
`scope_value` VARCHAR(64) NOT NULL COMMENT '班级 plan_id 或科目 subject_code',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`scope_id`),
UNIQUE KEY `uk_open_app_scope` (`app_id`, `scope_type`, `scope_value`),
KEY `idx_open_scope_value` (`scope_type`, `scope_value`),
CONSTRAINT `fk_open_scope_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`),
CONSTRAINT `chk_open_scope_type` CHECK (`scope_type` IN ('CLASS', 'SUBJECT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部应用授权范围表';
-- ------------------------------------------------------------------------------
-- 3. 访问令牌表(运行期以 Redis 为准,本表用于审计与吊销)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_open_token` (
`token_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` VARCHAR(64) NOT NULL COMMENT '应用标识',
`token_hash` VARCHAR(64) NOT NULL COMMENT '令牌 SHA-256',
`issued_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '签发时间',
`expire_time` DATETIME NOT NULL COMMENT '过期时间(签发 + 7200 秒)',
`status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1有效 0失效)',
`client_ip` VARCHAR(64) DEFAULT NULL COMMENT '签发来源 IP',
`user_agent` VARCHAR(255) DEFAULT NULL COMMENT '客户端标识',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`token_id`),
UNIQUE KEY `uk_open_token_hash` (`token_hash`),
KEY `idx_open_token_app_status` (`app_id`, `status`),
KEY `idx_open_token_expire` (`expire_time`),
CONSTRAINT `fk_open_token_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部应用访问令牌表';
-- ------------------------------------------------------------------------------
-- 4. 考试科目字典表
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_exam_subject` (
`subject_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_code` VARCHAR(32) NOT NULL COMMENT '科目编码',
`subject_name` VARCHAR(100) NOT NULL COMMENT '科目名称',
`plan_id` VARCHAR(32) DEFAULT NULL COMMENT '所属培训班(空表示全局科目)',
`score_item_id` BIGINT DEFAULT NULL COMMENT '归档考核项ID(train_score_item)',
`status` SMALLINT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0停用)',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '展示顺序',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(200) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`subject_id`),
UNIQUE KEY `uk_exam_subject_code` (`subject_code`),
UNIQUE KEY `uk_exam_subject_name` (`subject_name`),
KEY `idx_exam_subject_plan` (`plan_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试科目字典表';
-- ------------------------------------------------------------------------------
-- 5. 上报批次表
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_exam_report_batch` (
`batch_pk` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` VARCHAR(64) NOT NULL COMMENT '应用标识',
`batch_id` VARCHAR(64) NOT NULL COMMENT '批次号(报文 batchId)',
`report_time` BIGINT NOT NULL COMMENT '上报时间(毫秒)',
`period_start` BIGINT NOT NULL COMMENT '覆盖窗口开始(毫秒,含)',
`period_end` BIGINT NOT NULL COMMENT '覆盖窗口结束(毫秒,不含)',
`resend` SMALLINT NOT NULL DEFAULT 0 COMMENT '补传标记(1补传 0正常)',
`total` INT NOT NULL DEFAULT 0 COMMENT '本批记录数',
`accepted` INT NOT NULL DEFAULT 0 COMMENT '校验通过并入库数',
`duplicated` INT NOT NULL DEFAULT 0 COMMENT 'reportId 已存在被忽略数',
`unmatched` INT NOT NULL DEFAULT 0 COMMENT '学员无法匹配数',
`received_time` BIGINT NOT NULL COMMENT '平台接收时间(毫秒)',
`req_id` VARCHAR(64) DEFAULT NULL COMMENT '请求标识',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`batch_pk`),
UNIQUE KEY `uk_batch_app_batch` (`app_id`, `batch_id`),
KEY `idx_batch_received` (`app_id`, `received_time`),
KEY `idx_batch_period` (`period_start`, `period_end`),
CONSTRAINT `fk_batch_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`),
CONSTRAINT `chk_batch_time_range` CHECK (`period_end` > `period_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试成绩上报批次表';
-- ------------------------------------------------------------------------------
-- 6. 成绩上报记录表(核心,reportId 永久幂等键)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_exam_report` (
`report_pk` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`report_id` VARCHAR(64) NOT NULL COMMENT '上报编号(永久幂等键)',
`app_id` VARCHAR(64) NOT NULL COMMENT '应用标识',
`batch_pk` BIGINT DEFAULT NULL COMMENT '所属批次(实时上报为空)',
`batch_id` VARCHAR(64) DEFAULT NULL COMMENT '批次号冗余',
`report_channel` VARCHAR(16) NOT NULL COMMENT '上报渠道(REALTIME/PERIODIC)',
`biz_type` VARCHAR(16) NOT NULL DEFAULT 'PRACTICE' COMMENT '成绩类型',
`student_id` VARCHAR(32) NOT NULL COMMENT '学员ID(匹配键)',
`student_name` VARCHAR(50) DEFAULT NULL COMMENT '上报姓名',
`id_card_masked` VARCHAR(32) DEFAULT NULL COMMENT '证件号掩码',
`id_card_md5` CHAR(32) CHARACTER SET ascii DEFAULT NULL COMMENT '证件号MD5',
`matched_enrollment_id` BIGINT DEFAULT NULL COMMENT '匹配到的参训档案ID',
`matched_plan_id` VARCHAR(32) DEFAULT NULL COMMENT '匹配到的培训班ID',
`match_status` VARCHAR(16) NOT NULL DEFAULT 'UNMATCHED' COMMENT '匹配状态(MATCHED/UNMATCHED)',
`score_status` VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '成绩状态(RECEIVED/ARCHIVED/LOCKED)',
`subject_code` VARCHAR(32) DEFAULT NULL COMMENT '科目编码',
`subject_name` VARCHAR(100) NOT NULL COMMENT '科目名称',
`exam_start_time` BIGINT NOT NULL COMMENT '考试开始(毫秒)',
`exam_end_time` BIGINT NOT NULL COMMENT '考试结束(毫秒)',
`duration_seconds` INT DEFAULT NULL COMMENT '用时秒数',
`score` DECIMAL(7,2) NOT NULL COMMENT '分数',
`full_score` DECIMAL(7,2) NOT NULL DEFAULT 100.00 COMMENT '满分',
`pass_flag` SMALLINT NOT NULL COMMENT '是否通过(1通过 0未通过)',
`retake` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否重考(1是 0否)',
`retake_no` INT NOT NULL DEFAULT 0 COMMENT '重考序号',
`station` VARCHAR(64) NOT NULL COMMENT '考试工位',
`content` TEXT NOT NULL COMMENT '考试内容原始 JSON(≤32KB)',
`content_size` INT DEFAULT NULL COMMENT '内容字节数',
`content_md5` CHAR(32) CHARACTER SET ascii DEFAULT NULL COMMENT '内容摘要',
`report_time` BIGINT DEFAULT NULL COMMENT '报文上报时间',
`received_time` BIGINT NOT NULL COMMENT '平台接收时间',
`resend` SMALLINT NOT NULL DEFAULT 0 COMMENT '补传标记',
`claim_by` VARCHAR(64) DEFAULT NULL COMMENT '认领操作人',
`claim_time` DATETIME DEFAULT NULL COMMENT '认领时间',
`archive_time` DATETIME DEFAULT NULL COMMENT '归档时间',
`lock_time` DATETIME DEFAULT NULL COMMENT '锁定时间',
`lock_reason` VARCHAR(200) DEFAULT NULL COMMENT '锁定原因',
`data_version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`report_pk`),
UNIQUE KEY `uk_exam_report_id` (`report_id`),
KEY `idx_report_app_time` (`app_id`, `received_time`),
KEY `idx_report_app_batch` (`app_id`, `batch_id`),
KEY `idx_report_student` (`app_id`, `student_id`, `exam_end_time`),
KEY `idx_report_status` (`match_status`, `score_status`),
KEY `idx_report_exam_end` (`exam_end_time`),
CONSTRAINT `fk_report_app` FOREIGN KEY (`app_id`) REFERENCES `train_open_app` (`app_id`),
CONSTRAINT `fk_report_batch` FOREIGN KEY (`batch_pk`) REFERENCES `train_exam_report_batch` (`batch_pk`),
CONSTRAINT `chk_report_time_range` CHECK (`exam_end_time` > `exam_start_time`),
CONSTRAINT `chk_report_score` CHECK (`score` >= 0 AND `score` <= `full_score`),
CONSTRAINT `chk_report_match_status` CHECK (`match_status` IN ('MATCHED', 'UNMATCHED')),
CONSTRAINT `chk_report_score_status` CHECK (`score_status` IN ('RECEIVED', 'ARCHIVED', 'LOCKED')),
CONSTRAINT `chk_report_channel` CHECK (`report_channel` IN ('REALTIME', 'PERIODIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考试成绩上报记录表';
-- ------------------------------------------------------------------------------
-- 7. 接口调用日志表
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_open_api_log` (
`log_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`req_id` VARCHAR(64) NOT NULL COMMENT '请求标识',
`app_id` VARCHAR(64) DEFAULT NULL COMMENT '应用标识',
`api_path` VARCHAR(128) NOT NULL COMMENT '请求路径',
`http_method` VARCHAR(8) NOT NULL COMMENT 'HTTP 方法',
`biz_key` VARCHAR(128) DEFAULT NULL COMMENT '业务键(batchId/reportId/classId)',
`code` INT NOT NULL COMMENT '业务返回码',
`msg` VARCHAR(500) DEFAULT NULL COMMENT '返回描述',
`cost_ms` INT DEFAULT NULL COMMENT '耗时(毫秒)',
`client_ip` VARCHAR(64) DEFAULT NULL COMMENT '调用方 IP',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
PRIMARY KEY (`log_id`),
KEY `idx_open_log_req` (`req_id`),
KEY `idx_open_log_app_time` (`app_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开放接口调用日志表';
-- ==============================================================================
-- 8. 字典数据
-- ==============================================================================
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '外部应用类型', 'train_open_app_type', '0', 'admin', NOW(), 'VR / 3D巡检 / 实操 / 帽石考核 / 其他'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_app_type');
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '成绩匹配状态', 'train_open_match_status', '0', 'admin', NOW(), '成绩与学员的匹配状态'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_match_status');
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '成绩归档状态', 'train_open_score_status', '0', 'admin', NOW(), '成绩接收 / 归档 / 锁定'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_score_status');
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '成绩上报渠道', 'train_open_report_channel', '0', 'admin', NOW(), '实时上报 / 周期批量上报'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'train_open_report_channel');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, 'VR实训', 'VR', 'train_open_app_type', 'primary', 'Y', '0', 'admin', NOW(), 'VR 实训系统'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'VR');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '3D巡检', 'INSPECT3D', 'train_open_app_type', 'success', 'N', '0', 'admin', NOW(), '3D 数字孪生巡检系统'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'INSPECT3D');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '实操实训', 'PRACTICE', 'train_open_app_type', 'info', 'N', '0', 'admin', NOW(), '实操实训系统'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'PRACTICE');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 4, '帽石考核', 'CAPSTONE', 'train_open_app_type', 'warning', 'N', '0', 'admin', NOW(), '帽石安装考核系统'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'CAPSTONE');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 5, '其他', 'OTHER', 'train_open_app_type', 'info', 'N', '0', 'admin', NOW(), '其他实训考核系统'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_app_type' AND dict_value = 'OTHER');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '已匹配', 'MATCHED', 'train_open_match_status', 'success', 'Y', '0', 'admin', NOW(), '成绩已匹配到学员'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_match_status' AND dict_value = 'MATCHED');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '待认领', 'UNMATCHED', 'train_open_match_status', 'warning', 'N', '0', 'admin', NOW(), '学员无法匹配,进入待认领池'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_match_status' AND dict_value = 'UNMATCHED');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '已接收', 'RECEIVED', 'train_open_score_status', 'info', 'Y', '0', 'admin', NOW(), '上报已接收,尚未归档'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'RECEIVED');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '已归档', 'ARCHIVED', 'train_open_score_status', 'success', 'N', '0', 'admin', NOW(), '已写入学员得分'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'ARCHIVED');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '已锁定', 'LOCKED', 'train_open_score_status', 'danger', 'N', '0', 'admin', NOW(), '成绩已锁定,需解冻后重传'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_score_status' AND dict_value = 'LOCKED');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '实时上报', 'REALTIME', 'train_open_report_channel', 'primary', 'N', '0', 'admin', NOW(), '考试结束后即时上报'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_report_channel' AND dict_value = 'REALTIME');
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '周期上报', 'PERIODIC', 'train_open_report_channel', 'info', 'Y', '0', 'admin', NOW(), '按时间窗口批量上报'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'train_open_report_channel' AND dict_value = 'PERIODIC');
-- ==============================================================================
-- 9. 菜单与按钮权限
-- ==============================================================================
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '外部实训接入', '2265', '9', 'openexam', NULL, '', '', 1, 0, 'M', '0', '0', '', 'education', 'admin', NOW(), '外部实训系统数据接口'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_name = '外部实训接入' AND parent_id = 2265);
SET @openexam_dir = (SELECT menu_id FROM sys_menu WHERE menu_name = '外部实训接入' AND parent_id = 2265 LIMIT 1);
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '应用管理', @openexam_dir, '1', 'openApp', 'train/openApp/index', '', '', 1, 0, 'C', '0', '0', 'train:openApp:list', 'component', 'admin', NOW(), '外部应用与授权范围'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:list');
SET @openapp_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:openApp:list' LIMIT 1);
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '考试科目', @openexam_dir, '2', 'examSubject', 'train/examSubject/index', '', '', 1, 0, 'C', '0', '0', 'train:examSubject:list', 'dict', 'admin', NOW(), '考试科目字典'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:list');
SET @examsubject_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:examSubject:list' LIMIT 1);
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '上报成绩', @openexam_dir, '3', 'examReport', 'train/examReport/index', '', '', 1, 0, 'C', '0', '0', 'train:examReport:list', 'form', 'admin', NOW(), '外部实训上报成绩与待认领'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:list');
SET @examreport_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:examReport:list' LIMIT 1);
-- 按钮权限:应用管理
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '应用查询', @openapp_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:query', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:query');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '应用新增', @openapp_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:add', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:add');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '应用修改', @openapp_menu, '3', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:edit', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:edit');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '应用删除', @openapp_menu, '4', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:openApp:remove', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:openApp:remove');
-- 按钮权限:考试科目
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '科目新增', @examsubject_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:add', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:add');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '科目修改', @examsubject_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:edit', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:edit');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '科目删除', @examsubject_menu, '3', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examSubject:remove', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examSubject:remove');
-- 按钮权限:上报成绩
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '成绩认领', @examreport_menu, '1', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examReport:claim', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:claim');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '成绩删除', @examreport_menu, '2', '', NULL, '', '', 1, 0, 'F', '0', '0', 'train:examReport:remove', '#', 'admin', NOW(), ''
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:examReport:remove');
-- ==============================================================================
-- 10. 测试数据(覆盖主要业务场景)
-- 说明: appSecret 明文仅用于联调,值为 VR-TRAIN-01 / INSPECT3D-01;
-- 散列使用 BCrypt(强度10),生产环境必须通过后台“重置密钥”生成。
-- ==============================================================================
INSERT INTO `train_open_app` (`app_id`, `app_name`, `app_type`, `org_name`, `app_secret_hash`, `app_secret_masked`, `contact_name`, `contact_phone`, `rate_limit`, `realtime_enabled`, `status`, `create_by`, `remark`)
VALUES
('VR-TRAIN-01', 'VR 安全实训系统', 'VR', '沈阳房建公寓段·职工培训基地',
'$2a$10$7EqJtq98hPqEX7fNZaFWoO5uY3vJ0N2m1oQxZ0lQm3Qm9C2jK1sGa', '****2406', '王工', '13900000001', 20, 1, 1, 'admin', '联调样例:VR 实训'),
('INSPECT3D-01', '3D 数字孪生巡检系统', 'INSPECT3D', '沈阳房建公寓段',
'$2a$10$7EqJtq98hPqEX7fNZaFWoO5uY3vJ0N2m1oQxZ0lQm3Qm9C2jK1sGa', '****7788', '李工', '13900000002', 20, 0, 1, 'admin', '联调样例:3D 巡检(未开启实时上报)')
ON DUPLICATE KEY UPDATE `app_name` = VALUES(`app_name`);
INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`)
SELECT 'VR-TRAIN-01', 'CLASS', 'P2026001', 'admin' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'VR-TRAIN-01' AND `scope_type` = 'CLASS' AND `scope_value` = 'P2026001');
INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`)
SELECT 'VR-TRAIN-01', 'SUBJECT', 'VR-SAFE', 'admin' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'VR-TRAIN-01' AND `scope_type` = 'SUBJECT' AND `scope_value` = 'VR-SAFE');
INSERT INTO `train_open_app_scope` (`app_id`, `scope_type`, `scope_value`, `create_by`)
SELECT 'INSPECT3D-01', 'SUBJECT', 'CAP-POS', 'admin' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `train_open_app_scope` WHERE `app_id` = 'INSPECT3D-01' AND `scope_type` = 'SUBJECT' AND `scope_value` = 'CAP-POS');
INSERT INTO `train_exam_subject` (`subject_code`, `subject_name`, `plan_id`, `status`, `sort_no`, `create_by`, `remark`)
VALUES
('VR-SAFE', '高处作业隐患排查', 'P2026001', 1, 1, 'admin', 'VR 实训科目'),
('CAP-POS', '帽石安装定位', NULL, 1, 2, 'admin', '帽石考核科目'),
('OPS-ELEC', '营业线施工用电安全', 'P2026001', 1, 3, 'admin', '实操科目'),
('INS-ROOF', '屋面渗漏巡检', NULL, 0, 4, 'admin', '停用科目,用于校验 3001')
ON DUPLICATE KEY UPDATE `subject_name` = VALUES(`subject_name`);
-- 参训档案(成绩匹配依赖):存在则跳过
INSERT INTO `train_enrollment` (`plan_id`, `student_id`, `source_type`, `enrollment_status`, `accommodation_required`, `join_time`, `create_by`)
SELECT 'P2026001', 'S20260001', 1, 1, 0, NOW(), 'admin' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `train_enrollment` WHERE `plan_id` = 'P2026001' AND `student_id` = 'S20260001');
-- 批次(正常周期上报)
INSERT INTO `train_exam_report_batch` (`batch_pk`, `app_id`, `batch_id`, `report_time`, `period_start`, `period_end`, `resend`, `total`, `accepted`, `duplicated`, `unmatched`, `received_time`, `req_id`)
VALUES (1, 'VR-TRAIN-01', 'BATCH-202610281030-0001', 1793154600000, 1793152800000, 1793154600000, 0, 2, 1, 0, 1, 1793154600180, 'RQ-SEED-0001')
ON DUPLICATE KEY UPDATE `batch_id` = VALUES(`batch_id`);
-- 上报记录:已匹配已归档 / 待认领 / 已锁定 / 重复批次内的幂等样例
INSERT INTO `train_exam_report` (`report_id`, `app_id`, `batch_pk`, `batch_id`, `report_channel`, `biz_type`, `student_id`, `student_name`,
`id_card_masked`, `id_card_md5`, `matched_enrollment_id`, `matched_plan_id`, `match_status`, `score_status`,
`subject_code`, `subject_name`, `exam_start_time`, `exam_end_time`, `duration_seconds`, `score`, `full_score`,
`pass_flag`, `retake`, `retake_no`, `station`, `content`, `content_size`, `content_md5`,
`report_time`, `received_time`, `resend`, `archive_time`)
VALUES
('RPT-VR-20261028-0031', 'VR-TRAIN-01', 1, 'BATCH-202610281030-0001', 'PERIODIC', 'PRACTICE', 'S20260001', '张三',
'210102********4321', '5d41402abc4b2a76b9719d911017c592',
(SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1),
'P2026001', 'MATCHED', 'ARCHIVED',
'VR-SAFE', '高处作业隐患排查', 1793153580000, 1793154000000, 420, 86.50, 100.00,
1, 0, 0, 'VR-A-02', '{"taskId":"VR-SAFE-012"}', 24, '0e4b1c9d8f2a7c6b5d4e3f2a1b0c9d8e',
1793154600000, 1793154600180, 0, NOW()),
('RPT-CAP-20261028-0032', 'VR-TRAIN-01', 1, 'BATCH-202610281030-0001', 'PERIODIC', 'PRACTICE', 'S20240399', '张伟',
NULL, NULL, NULL, NULL, 'UNMATCHED', 'RECEIVED',
'CAP-POS', '帽石安装定位', 1793153820000, 1793154180000, 360, 91.00, 100.00,
1, 1, 1, 'MS-A-03', '{"taskId":"CAP-07"}', 20, '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d',
1793154600000, 1793154600180, 0, NULL),
('RPT-VR-20261028-0033', 'VR-TRAIN-01', NULL, NULL, 'REALTIME', 'PRACTICE', 'S20260001', '张三',
NULL, NULL,
(SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1),
'P2026001', 'MATCHED', 'LOCKED',
'VR-SAFE', '高处作业隐患排查', 1793153400000, 1793153760000, 360, 92.00, 100.00,
1, 0, 0, 'VR-A-03', '{"taskId":"VR-SAFE-012"}', 24, '2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e',
1793154010000, 1793154010180, 0, NOW()),
('RPT-INS-20261028-0034', 'INSPECT3D-01', NULL, NULL, 'REALTIME', 'PRACTICE', 'S20260001', '张三',
NULL, NULL,
(SELECT enrollment_id FROM `train_enrollment` WHERE `student_id` = 'S20260001' ORDER BY join_time DESC LIMIT 1),
'P2026001', 'MATCHED', 'RECEIVED',
'INS-ROOF', '屋面渗漏巡检', 1793153640000, 1793154000000, 360, 88.00, 100.00,
1, 0, 0, 'INS-A-01', '{"taskId":"INS-01"}', 18, '3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f',
1793154010000, 1793154010180, 0, NULL)
ON DUPLICATE KEY UPDATE `subject_name` = VALUES(`subject_name`);
-- 令牌审计样例(无效散列,仅用于页面联调查看)
INSERT INTO `train_open_token` (`app_id`, `token_hash`, `issued_time`, `expire_time`, `status`, `client_ip`, `user_agent`)
SELECT 'VR-TRAIN-01', SHA2('sample-token-for-ui-preview', 256), NOW(), DATE_ADD(NOW(), INTERVAL 7200 SECOND), 0, '127.0.0.1', 'seed'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `train_open_token` WHERE `app_id` = 'VR-TRAIN-01' AND `status` = 0);
-- 调用日志样例
INSERT INTO `train_open_api_log` (`req_id`, `app_id`, `api_path`, `http_method`, `biz_key`, `code`, `msg`, `cost_ms`, `client_ip`)
SELECT 'RQ-SEED-0001', 'VR-TRAIN-01', '/api/open/v1/exam/report', 'POST', 'BATCH-202610281030-0001', 0, 'success', 38, '127.0.0.1'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `train_open_api_log` WHERE `req_id` = 'RQ-SEED-0001');
-- 更新学生学号字段 2026-11-05
UPDATE train_student
SET person_no = REPLACE(person_no, '学号:', '')
WHERE person_no LIKE '%学号:%';
@@ -0,0 +1,18 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量脚本: 024_add_teacher_enrollment_status.sql
-- 目标: 为 train_teacher 增加 enrollment_status(参训状态)
-- 枚举: 0 待报到、1 已报到、2 请假、3 放弃、4 已完成(实际业务主要使用 0 / 1)
-- 说明: 新增教师默认“待报到(0)”,与 Java 层 TrainTeacherServiceImpl 的默认值保持一致
-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public)
-- 幂等性: ADD COLUMN IF NOT EXISTS 可重复执行;已存在时仅重复写一次列注释
-- 注意: 未额外添加 CHECK 约束,枚举范围由应用层校验(如需库级约束请另行评估存量数据)
-- ==============================================================================
BEGIN;
ALTER TABLE public.train_teacher
ADD COLUMN IF NOT EXISTS enrollment_status SMALLINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN public.train_teacher.enrollment_status IS '参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成)';
COMMIT;
@@ -0,0 +1,27 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 024b_add_teacher_enrollment_status_mysql.sql
-- 目标: 为 train_teacher 增加 enrollment_status(参训状态)
-- 枚举: 0 待报到、1 已报到、2 请假、3 放弃、4 已完成(实际业务主要使用 0 / 1)
-- 说明: 024_add_teacher_enrollment_status.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本
-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行
-- 执行:
-- mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 024b_add_teacher_enrollment_status_mysql.sql
-- ==============================================================================
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_teacher'
AND COLUMN_NAME = 'enrollment_status'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_teacher` ADD COLUMN `enrollment_status` SMALLINT NOT NULL DEFAULT 0 COMMENT ''参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成)''',
'SELECT ''train_teacher.enrollment_status 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT COUNT(*) AS total_rows,
SUM(enrollment_status = 0) AS pending_rows,
SUM(enrollment_status = 1) AS reported_rows
FROM `train_teacher`;
@@ -0,0 +1,33 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量脚本: 025_add_teacher_join_time_accommodation.sql
-- 目标: 为 train_teacher 增加 join_time(报到时间)与 accommodation_required(是否需要住宿)
-- 定义来源: 与 train_enrollment 对应字段保持一致(见 002_create_new_tables.sql:84-85)
-- train_enrollment.join_time TIMESTAMP (可空,无默认值)
-- train_enrollment.accommodation_required SMALLINT NOT NULL (不可空,无默认值)
-- 兼容处理: 存量教师无该信息,先补 0(不需要住宿)再置为 NOT NULL;最终形态不带 DEFAULT,
-- 与 train_enrollment 的“业务选择型字段不设默认值”口径一致
-- 适用数据库: KingbaseES V009R003C018 / PostgreSQL (schema: public)
-- 幂等性: ADD COLUMN IF NOT EXISTS + 幂等 UPDATE / SET NOT NULL,可重复执行
-- ==============================================================================
BEGIN;
-- 1. 报到时间(可空,无默认值)
ALTER TABLE public.train_teacher
ADD COLUMN IF NOT EXISTS join_time TIMESTAMP;
-- 2. 是否需要住宿(先允许为空以便加列,回填后再置为 NOT NULL,最终不留默认值)
ALTER TABLE public.train_teacher
ADD COLUMN IF NOT EXISTS accommodation_required SMALLINT;
UPDATE public.train_teacher
SET accommodation_required = 0
WHERE accommodation_required IS NULL;
ALTER TABLE public.train_teacher
ALTER COLUMN accommodation_required SET NOT NULL;
COMMENT ON COLUMN public.train_teacher.join_time IS '报到时间';
COMMENT ON COLUMN public.train_teacher.accommodation_required IS '是否需要住宿 (0:否, 1:是)';
COMMIT;
@@ -0,0 +1,49 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本 (MySQL 5.7/8.0): 025b_add_teacher_join_time_accommodation_mysql.sql
-- 目标: 为 train_teacher 增加 join_time(报到时间)与 accommodation_required(是否需要住宿)
-- 定义来源: 与 train_enrollment 对应字段保持一致
-- join_time DATETIME,可空,无默认值
-- accommodation_required SMALLINT NOT NULL,无默认值(加列时临时带 DEFAULT 0 回填,随后去掉默认值)
-- 说明: 025_add_teacher_join_time_accommodation.sql 为 KingbaseES/PostgreSQL 版本,MySQL 请使用本脚本
-- 幂等: 通过 information_schema 判断列是否已存在,可重复执行
-- 执行:
-- mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 025b_add_teacher_join_time_accommodation_mysql.sql
-- ==============================================================================
-- 1. join_time(可空)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_teacher'
AND COLUMN_NAME = 'join_time'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_teacher` ADD COLUMN `join_time` DATETIME NULL DEFAULT NULL COMMENT ''报到时间''',
'SELECT ''train_teacher.join_time 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. accommodation_required(不可空;先带默认值加列以回填存量行)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'train_teacher'
AND COLUMN_NAME = 'accommodation_required'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE `train_teacher` ADD COLUMN `accommodation_required` SMALLINT NOT NULL DEFAULT 0 COMMENT ''是否需要住宿 (0:否, 1:是)''',
'SELECT ''train_teacher.accommodation_required 已存在,跳过加列'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. 回填并去掉默认值,与 train_enrollment 口径一致(业务选择型字段不设默认值)
UPDATE `train_teacher` SET `accommodation_required` = 0 WHERE `accommodation_required` IS NULL;
ALTER TABLE `train_teacher`
MODIFY COLUMN `accommodation_required` SMALLINT NOT NULL COMMENT '是否需要住宿 (0:否, 1:是)';
SELECT COUNT(*) AS total_rows,
SUM(accommodation_required = 1) AS need_accommodation_rows,
SUM(join_time IS NOT NULL) AS joined_rows
FROM `train_teacher`;
@@ -0,0 +1,144 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本: 026_train_theory_exam_score.sql
-- 模块: 班级学员理论考试成绩(与外部实训成绩汇总配套)
-- 内容: 单表建表语句(字段 + 主键 + 唯一约束 + 外键 + CHECK + 索引 + 字段注释)
-- 数据库: MySQL 5.7(生产主库);KingbaseES 差异见文末说明
-- 依赖: 本脚本仅创建 1 张新表,其引用的父表在库中**必须已存在**(顺序不可颠倒):
-- train_student(s_id)
-- train_class_plan(plan_id)
-- train_course(c_id)
-- train_plan_course(plan_course_id)
-- train_enrollment(enrollment_id)
-- train_exam_subject(subject_code) -- 由 023_open_exam_api.sql 创建
-- train_score_item(score_item_id)
-- 说明:
-- 1) 脚本可重复执行:建表用 IF NOT EXISTS;表已存在时不会重复创建(约束/索引随表创建一并生效)
-- 2) MySQL 5.7 会解析但**不强制执行** CHECK 约束,8.0 起强制执行;无论版本,
-- 应用层(DTO + Service)均须做同等校验兜底
-- 3) 仅新增对象,不改动任何既有表结构与数据
-- 4) 唯一键涉及 VARCHAR 列,父表对应列(s_id/plan_id/c_id/subject_code)的字符集与排序规则
-- 必须与本表一致(utf8mb4 / utf8mb4_general_ci),否则外键创建会失败
-- 执行: mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 026_train_theory_exam_score.sql
-- ==============================================================================
SET NAMES utf8mb4;
-- ------------------------------------------------------------------------------
-- 班级学员理论考试成绩表
-- 用途:
-- 记录"某学员 × 某班级课程 × 某次考试"的理论考试成绩,是课程级理论成绩的权威明细。
-- 与外部实训成绩上报表 train_exam_report(biz_type='PRACTICE')配套使用:
-- · 强关联:train_theory_exam_score.enrollment_id = train_exam_report.matched_enrollment_id
-- · 弱关联:student_id + subject_code(实训上报未匹配到学员时使用)
-- · 汇总桥接:score_item_id → train_score_item(item_type=1 理论),
-- 与实训成绩按权重合成后写入 train_student_score(综合成绩明细)
-- 粒度: enrollment_id + plan_course_id + retake_no 唯一(一行 = 一次考试)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `train_theory_exam_score` (
-- ========== 主键与关联维度 ==========
`theory_score_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`enrollment_id` BIGINT NOT NULL COMMENT '参训档案ID(train_enrollment,学员×班级唯一事实,汇总首选关联键)',
`plan_id` VARCHAR(32) NOT NULL COMMENT '培训班ID(train_class_plan,冗余便于班级维度统计)',
`plan_course_id` BIGINT NOT NULL COMMENT '培训课程实例ID(train_plan_course,哪门课,课程维度统计键)',
`c_id` VARCHAR(32) NOT NULL COMMENT '课程主档ID(train_course,冗余支持跨班级横向对比)',
`student_id` VARCHAR(32) NOT NULL COMMENT '学员ID(train_student.s_id,与实训上报 student_id 对齐)',
`subject_code` VARCHAR(32) DEFAULT NULL COMMENT '理论科目编码(train_exam_subject,可与实训上报 subject_code 对齐;空表示不区分科目)',
-- ========== 考试过程信息 ==========
`exam_start_time` DATETIME DEFAULT NULL COMMENT '考试开始时间(本地时间)',
`exam_end_time` DATETIME DEFAULT NULL COMMENT '考试结束时间(须晚于开始时间)',
`duration_seconds` INT DEFAULT NULL COMMENT '用时秒数(对齐 train_exam_report.duration_seconds)',
`retake` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否补考(1是 0否,语义对齐 train_exam_report.retake)',
`retake_no` INT NOT NULL DEFAULT 0 COMMENT '补考序号(0正考,1..n 第n次补考,对齐 train_exam_report.retake_no)',
-- ========== 成绩信息 ==========
`exam_status` VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT '考试状态(NORMAL正常 / ABSENT缺考 / EXEMPT免修)',
`score` DECIMAL(7,2) DEFAULT NULL COMMENT '理论成绩(缺考/免修允许为空,避免以0分污染均值)',
`full_score` DECIMAL(7,2) NOT NULL DEFAULT 100.00 COMMENT '满分(默认100.00,对齐 train_exam_report.full_score)',
`pass_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否通过(1通过 0未通过;免修按通过、缺考按未通过)',
`is_final` SMALLINT NOT NULL DEFAULT 1 COMMENT '是否该学员该课程的最终成绩(1是 0否,汇总仅取最终成绩避免重复计算)',
`score_status` VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '成绩状态(RECEIVED已接收 / ARCHIVED已归档 / LOCKED已锁定,枚举对齐 train_exam_report.score_status)',
-- ========== 来源、汇总桥接与审计 ==========
`source_type` SMALLINT NOT NULL DEFAULT 1 COMMENT '成绩来源(1手工录入 / 2平台在线考试 / 3批量或外部导入)',
`source_system` VARCHAR(100) DEFAULT NULL COMMENT '来源系统标识(对接外部题库/考试平台时使用)',
`source_event_id` VARCHAR(100) DEFAULT NULL COMMENT '外部事件唯一ID(导入幂等键,与 source_system 组成唯一约束)',
`score_item_id` BIGINT DEFAULT NULL COMMENT '关联综合成绩项(train_score_item,应指向 item_type=1 理论考试项)',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注(如缺考原因、免修依据)',
`del_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '删除标志(0存在 2删除),逻辑删除保留审计轨迹',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
-- 最终成绩唯一性辅助列(MySQL 无部分唯一索引,用生成列模拟:
-- 仅"未删除且 is_final=1"的行取值为 1,其余为 NULL;MySQL 唯一索引允许多个 NULL)
`final_flag` TINYINT GENERATED ALWAYS AS (IF(`is_final` = 1 AND `del_flag` = '0', 1, NULL)) STORED COMMENT '最终成绩唯一约束辅助列(实现细节,业务不直接读写)',
-- ========== 主键 ==========
PRIMARY KEY (`theory_score_id`),
-- ========== 唯一约束 ==========
UNIQUE KEY `uk_theory_enroll_course_retake` (`enrollment_id`, `plan_course_id`, `retake_no`)
COMMENT '同一学员同一课程同一次考试唯一,防重复录入',
UNIQUE KEY `uk_theory_final` (`plan_course_id`, `student_id`, `final_flag`)
COMMENT '每学员每课程仅一条最终成绩(生成列模拟部分唯一索引)',
UNIQUE KEY `uk_theory_source_event` (`source_system`, `source_event_id`)
COMMENT '外部导入幂等(两列均为 NULL 时不冲突,不影响手工录入)',
-- ========== 普通索引 ==========
KEY `idx_theory_plan_student` (`plan_id`, `student_id`) COMMENT '班级成绩单、按学员查全班成绩',
KEY `idx_theory_plan_course_final` (`plan_course_id`, `is_final`, `score_status`, `del_flag`) COMMENT '课程维度汇总(均分/及格率)',
KEY `idx_theory_student_subject` (`student_id`, `subject_code`, `exam_end_time`) COMMENT '与实训上报按"学员+科目"汇总',
KEY `idx_theory_score_item` (`score_item_id`) COMMENT '汇入 train_student_score 时反查',
KEY `idx_theory_exam_time` (`exam_end_time`) COMMENT '时间窗统计/归档任务扫描',
KEY `idx_theory_course` (`c_id`) COMMENT '外键索引:按课程主档横向统计',
KEY `idx_theory_subject` (`subject_code`) COMMENT '外键索引:按科目字典汇总',
-- ========== 外键(引用完整性) ==========
CONSTRAINT `fk_theory_enrollment` FOREIGN KEY (`enrollment_id`) REFERENCES `train_enrollment` (`enrollment_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_plan` FOREIGN KEY (`plan_id`) REFERENCES `train_class_plan` (`plan_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_plan_course` FOREIGN KEY (`plan_course_id`) REFERENCES `train_plan_course` (`plan_course_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_course` FOREIGN KEY (`c_id`) REFERENCES `train_course` (`c_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_student` FOREIGN KEY (`student_id`) REFERENCES `train_student` (`s_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_subject` FOREIGN KEY (`subject_code`) REFERENCES `train_exam_subject` (`subject_code`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_theory_score_item` FOREIGN KEY (`score_item_id`) REFERENCES `train_score_item` (`score_item_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
-- ========== 取值与一致性约束(MySQL 8.0 起强制;5.7 由应用层兜底) ==========
CONSTRAINT `chk_theory_score_range` CHECK (`score` IS NULL OR (`score` >= 0 AND `score` <= `full_score`)),
CONSTRAINT `chk_theory_full_score` CHECK (`full_score` > 0),
CONSTRAINT `chk_theory_score_filled` CHECK (`exam_status` <> 'NORMAL' OR `score` IS NOT NULL),
CONSTRAINT `chk_theory_exam_time` CHECK (`exam_start_time` IS NULL OR `exam_end_time` IS NULL OR `exam_end_time` > `exam_start_time`),
CONSTRAINT `chk_theory_duration` CHECK (`duration_seconds` IS NULL OR `duration_seconds` >= 0),
CONSTRAINT `chk_theory_retake` CHECK (`retake` IN (0, 1)),
CONSTRAINT `chk_theory_retake_no` CHECK (`retake_no` >= 0),
CONSTRAINT `chk_theory_exam_status` CHECK (`exam_status` IN ('NORMAL', 'ABSENT', 'EXEMPT')),
CONSTRAINT `chk_theory_score_status` CHECK (`score_status` IN ('RECEIVED', 'ARCHIVED', 'LOCKED')),
CONSTRAINT `chk_theory_pass_flag` CHECK (`pass_flag` IN (0, 1)),
CONSTRAINT `chk_theory_is_final` CHECK (`is_final` IN (0, 1)),
CONSTRAINT `chk_theory_source_type` CHECK (`source_type` IN (1, 2, 3)),
CONSTRAINT `chk_theory_del_flag` CHECK (`del_flag` IN ('0', '2'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='班级学员理论考试成绩表(学员×班级课程×考试次数的理论成绩明细,与 train_exam_report 实训成绩汇总)';
-- ==============================================================================
-- 补充说明
-- 1) 冗余维度一致性:enrollment_id / plan_id / student_id / c_id 之间存在派生关系
-- (enrollment → plan + student;plan_course → plan + c_id)。外键仅保证各自引用有效,
-- 四者同源须由应用层(Service 写入前校验)或库内触发器保证,避免"A班学员成绩挂B班课程"。
-- 2) 汇总统计口径(报表层,不在本脚本落地):
-- 仅统计 del_flag='0' AND is_final=1 AND score_status IN ('RECEIVED','ARCHIVED');
-- 缺考(ABSENT)不计入平均分分母,免修(EXEMPT)计入通过率但不计分;
-- 补考取 is_final=1 的成绩,避免同课程多行重复计入。
-- 3) 时间口径:本表为 DATETIME(本地时间),train_exam_report 为 BIGINT 毫秒;
-- 跨表汇总时统一转换为同一基准(建议毫秒整数)。
-- 4) KingbaseES/PostgreSQL 落地差异:
-- · 主键改为 BIGINT GENERATED BY DEFAULT AS IDENTITY;
-- · 表/列注释改用 COMMENT ON TABLE / COMMENT ON COLUMN;
-- · 去掉 final_flag 生成列,改用部分唯一索引:
-- CREATE UNIQUE INDEX uk_theory_final ON train_theory_exam_score (plan_course_id, student_id)
-- WHERE is_final = 1 AND del_flag = '0';
-- · CHECK 约束原生强制;DATETIME 对应 TIMESTAMP。
-- 5) 回滚(如需):
-- DROP TABLE IF EXISTS `train_theory_exam_score`;
-- ==============================================================================
@@ -0,0 +1,49 @@
-- ==============================================================================
-- RailwaySystem 数据库增量脚本: 028_score_analysis_menu.sql
-- 模块: 成绩统计分析(理论成绩 + 实训上报成绩,多维度统计)
-- 内容: 菜单与按钮权限(复用「统计分析」父目录 2169),并授权给培训管理员角色
-- 数据库: MySQL(生产主库);KingbaseES 请按 012_analytics_v1_permissions.sql 的写法转换
-- 说明:
-- 1) 仅新增菜单/权限与角色授权,不改动任何既有表结构与数据;
-- 2) 脚本可重复执行:全部使用 NOT EXISTS 幂等判断;
-- 3) 页面组件路径: train/analysis/score/index
-- 4) 后端接口前缀: /train/analysis/score(TrainScoreAnalysisController)
-- 执行: mysql -h<host> -u<user> -p --default-character-set=utf8mb4 <db> < 028_score_analysis_menu.sql
-- ==============================================================================
SET NAMES utf8mb4;
-- 父目录:统计分析(path=analysis,顶级菜单)
SET @analysis_parent = (
SELECT menu_id FROM sys_menu WHERE path = 'scores' AND parent_id = 0 LIMIT 1
);
-- 1. 成绩分析页面菜单
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '成绩分析', @analysis_parent, '2', 'score', 'train/analysis/score/index', '', '', 1, 0,
'C', '0', '0', 'train:analysis:score', 'chart', 'admin', NOW(), '理论/实训成绩多维度统计分析'
FROM DUAL
WHERE @analysis_parent IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:analysis:score');
SET @score_menu = (SELECT menu_id FROM sys_menu WHERE perms = 'train:analysis:score' LIMIT 1);
-- 2. 成绩分析查询按钮权限
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '成绩分析查询', @score_menu, '1', '#', '', '', '', 1, 0,
'F', '0', '0', 'train:analysis:score:query', '#', 'admin', NOW(), '成绩统计分析数据查询'
FROM DUAL
WHERE @score_menu IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'train:analysis:score:query');
-- 3. 授权给培训管理员角色(role_id = 3,与考勤分析保持一致)
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 3, m.menu_id FROM sys_menu m
WHERE m.perms IN ('train:analysis:score', 'train:analysis:score:query')
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 3 AND rm.menu_id = m.menu_id
);
@@ -0,0 +1,18 @@
-- 图书下载回执记录表(设备端下载完成后回执落库)
CREATE TABLE IF NOT EXISTS `train_book_download_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT DEFAULT NULL COMMENT '学员ID',
`device_id` VARCHAR(64) NOT NULL COMMENT '设备ID',
`book_id` VARCHAR(64) NOT NULL COMMENT '图书ID',
`book_version` VARCHAR(32) DEFAULT NULL COMMENT '图书版本',
`file_md5` VARCHAR(64) DEFAULT NULL COMMENT '文件MD5',
`result` VARCHAR(16) DEFAULT NULL COMMENT '下载结果:SUCCESS/FAIL',
`err_msg` VARCHAR(255) DEFAULT NULL COMMENT '错误信息',
`download_time` DATETIME DEFAULT NULL COMMENT '下载/回执时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_device_book` (`device_id`,`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书下载回执记录';
@@ -0,0 +1,42 @@
-- ============================================================================
-- 教师住宿分配(复用 train_accommodation,与学生住宿逻辑隔离)
--
-- 1. 新增 occupant_type 区分入住人员类型:student 学员 / teacher 教师
-- - 存量数据全部为学员住宿,默认值 'student' 保证学生逻辑完全不变
-- - 教师住宿记录 occupant_type = 'teacher',enrollment_id 存 train_teacher.id
-- 2. 删除 fk_accomm_enrollment 外键:教师住宿的 enrollment_id 不再指向
-- train_enrollment(enrollment_id),改由业务层按 occupant_type 分别校验
-- 3. 唯一索引由「全局活唯一(enrollment_id)」改为「按人员类型隔离的活唯一
-- (occupant_type, enrollment_id)」,避免教师 id 与参训档案 id 数值碰撞
--
-- 适配数据库:Kingbase / PostgreSQL(train 模块以该语法为准)
-- ============================================================================
-- 1. 入住人员类型列
ALTER TABLE train_accommodation ADD COLUMN occupant_type VARCHAR(16) NOT NULL DEFAULT 'student';
COMMENT ON COLUMN train_accommodation.occupant_type IS '入住人员类型:student 学员 / teacher 教师';
-- 2. 解除参训档案外键约束(教师住宿记录不指向 train_enrollment)
ALTER TABLE train_accommodation DROP CONSTRAINT IF EXISTS fk_accomm_enrollment;
-- 3. 重建活跃唯一索引:按人员类型隔离
DROP INDEX IF EXISTS uq_accommodation_enroll;
CREATE UNIQUE INDEX uq_accommodation_occupant
ON train_accommodation (occupant_type, enrollment_id)
WHERE accommodation_status IN (0, 1);
CREATE INDEX IF NOT EXISTS idx_accommodation_occupant
ON train_accommodation (occupant_type, enrollment_id);
-- ============================================================================
-- MySQL 兼容说明(如需在 MySQL 环境执行,替换为下列语句):
-- ALTER TABLE train_accommodation ADD COLUMN occupant_type VARCHAR(16) NOT NULL DEFAULT 'student'
-- COMMENT '入住人员类型:student 学员 / teacher 教师';
-- ALTER TABLE train_accommodation DROP FOREIGN KEY fk_accomm_enrollment;
-- ALTER TABLE train_accommodation DROP INDEX uq_accommodation_enroll;
-- CREATE UNIQUE INDEX uq_accommodation_occupant
-- ON train_accommodation (occupant_type, enrollment_id, accommodation_status);
-- (MySQL 无部分索引,若需「仅活跃唯一」请使用生成列方案,教师模块不依赖该唯一性做业务判断)
-- ============================================================================
@@ -0,0 +1,100 @@
-- ==============================================================================
-- RailwaySystem V2 -> MySQL 5.7 结构对账脚本 (reconcile_mysql_5_7.sql)
-- 适用数据库: MySQL 5.7 (线上部署库)
-- 目标: 将线上 MySQL 表结构对齐 V2 基线,消除两类反复出现的错误:
-- 1) Field 'create_time' doesn't have a default value
-- 2) Unknown column 'xxx' in 'field list' (缺失列)
-- 说明:
-- * 本脚本只"设置默认值 / 按需补列",**不改列类型**,对已有默认值的列为幂等操作。
-- * 使用 information_schema 守卫,列已存在/已有默认也不会报错,可重复执行。
-- * train_class_report 的 alloc_* 列: 应用当前 mapper 仍在 SELECT,故补回;
-- 若后续按最终 V2 设计改为依赖 train_dorm_auth,应同步清理应用侧(见文件末尾说明)。
-- 执行: mysql -u<user> -p<pass> railwaysystem < reconcile_mysql_5_7.sql
-- ==============================================================================
DELIMITER $$
-- 仅当列存在时设置默认值(列名/表名已存在即可,重复设置同值幂等)
DROP PROCEDURE IF EXISTS set_default_if_missing $$
CREATE PROCEDURE set_default_if_missing(
IN p_tbl VARCHAR(64), IN p_col VARCHAR(64), IN p_def VARCHAR(255)
)
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_tbl AND column_name = p_col
) THEN
SET @stmt = CONCAT('ALTER TABLE `', p_tbl, '` ALTER COLUMN `', p_col, '` SET DEFAULT ', p_def);
PREPARE s FROM @stmt;
EXECUTE s;
DEALLOCATE PREPARE s;
END IF;
END $$
-- 仅当列不存在时新增列
DROP PROCEDURE IF EXISTS add_col_if_missing $$
CREATE PROCEDURE add_col_if_missing(
IN p_tbl VARCHAR(64), IN p_col VARCHAR(64), IN p_spec VARCHAR(255)
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_tbl AND column_name = p_col
) THEN
SET @stmt = CONCAT('ALTER TABLE `', p_tbl, '` ADD COLUMN `', p_col, '` ', p_spec);
PREPARE s FROM @stmt;
EXECUTE s;
DEALLOCATE PREPARE s;
END IF;
END $$
DELIMITER ;
-- ------------------------------------------------------------------------------
-- 1) train_class_report: 补齐应用 mapper 仍在 SELECT 的 alloc_* 列 (若已加则跳过)
-- ------------------------------------------------------------------------------
CALL add_col_if_missing('train_class_report', 'alloc_dorm_id', 'VARCHAR(32) NULL');
CALL add_col_if_missing('train_class_report', 'alloc_classroom_id', 'VARCHAR(32) NULL');
CALL add_col_if_missing('train_class_report', 'alloc_site_id', 'VARCHAR(32) NULL');
-- ------------------------------------------------------------------------------
-- 2) 为所有 V2 业务表的 create_time 补默认值 (核心: 消除当前 'doesn''t have a default value')
-- V2 基线均为 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,线上 5.7 翻译时丢失。
-- ------------------------------------------------------------------------------
CALL set_default_if_missing('train_resource', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_course_resource', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_plan_course', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_enrollment', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_lesson', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_accommodation', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_score_item', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_student_score', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_class_report', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_dorm_auth', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_student_checkin', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_study_duration', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_student_portrait', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_electronic_board', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_classroom_info', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_training_site', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_dormitory_info', 'create_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_class_plan', 'create_time', 'CURRENT_TIMESTAMP');
-- ------------------------------------------------------------------------------
-- 3) 其它 V2 中 NOT NULL 且带默认值、但线上可能丢失的列
-- ------------------------------------------------------------------------------
CALL set_default_if_missing('train_resource', 'status', '1');
CALL set_default_if_missing('train_resource', 'del_flag', '''0''');
CALL set_default_if_missing('train_enrollment', 'enrollment_status', '0');
CALL set_default_if_missing('train_accommodation', 'assigned_time', 'CURRENT_TIMESTAMP');
CALL set_default_if_missing('train_accommodation', 'accommodation_status', '0');
CALL set_default_if_missing('train_score_item', 'item_type', '1');
CALL set_default_if_missing('train_score_item', 'weight', '0.00');
CALL set_default_if_missing('train_score_item', 'full_score', '100.00');
CALL set_default_if_missing('train_student_score', 'import_time', 'CURRENT_TIMESTAMP');
-- ------------------------------------------------------------------------------
-- 清理临时存储过程
-- ------------------------------------------------------------------------------
DROP PROCEDURE IF EXISTS set_default_if_missing;
DROP PROCEDURE IF EXISTS add_col_if_missing;