合并分支:合并课程两步式向导与教学资源关联管理能力

This commit is contained in:
wangzhibiao committed 2026-10-09 21:33:35 +08:00
commit 9030d797f4
22 files changed
+1178 -291

No files matched your search

@@ -3,6 +3,7 @@ package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -17,7 +18,9 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.dto.CourseResourceBindDTO;
import com.ruoyi.train.domain.vo.CourseDetailVO;
import com.ruoyi.train.service.ITrainCourseResourceService;
import com.ruoyi.train.service.ITrainCourseService;
import com.ruoyi.common.core.page.TableDataInfo;
@@ -34,6 +37,9 @@ public class TrainCourseController extends BaseController
@Autowired
private ITrainCourseService trainCourseService;
@Autowired
private ITrainCourseResourceService trainCourseResourceService;
/**
* 查询课程信息列表 【P0】
*/
@@ -48,7 +54,7 @@ public class TrainCourseController extends BaseController
/**
* 获取课程信息详细信息 【P0】
* 返回结构:CourseDetailVO (包含课程基础字段与契约预留resources列表)
* 返回结构:CourseDetailVO(包含课程基础字段与已关联资源列表)
*/
@PreAuthorize("@ss.hasPermi('train:course:query')")
@GetMapping(value = "/{cId}")
@@ -82,7 +88,8 @@ public class TrainCourseController extends BaseController
{
return AjaxResult.error(400, "课程学时必须为正整数");
}
return toAjax(trainCourseService.insertTrainCourse(trainCourse));
int rows = trainCourseService.insertTrainCourse(trainCourse);
return rows > 0 ? success(trainCourse) : error();
}
/**
@@ -105,6 +112,30 @@ public class TrainCourseController extends BaseController
return toAjax(trainCourseService.updateTrainCourse(trainCourse));
}
/**
* 为课程绑定教学资源。
*/
@PreAuthorize("@ss.hasPermi('train:course:edit')")
@Log(title = "课程教学资源", businessType = BusinessType.INSERT)
@PostMapping("/{cId}/resources")
public AjaxResult bindResource(@PathVariable("cId") String cId,
@Validated @RequestBody CourseResourceBindDTO bindDTO)
{
return toAjax(trainCourseResourceService.bindResource(cId, bindDTO.getResourceId(), bindDTO.getSortNo()));
}
/**
* 解除课程与教学资源的关联,不删除资源本身。
*/
@PreAuthorize("@ss.hasPermi('train:course:edit')")
@Log(title = "课程教学资源", businessType = BusinessType.DELETE)
@DeleteMapping("/{cId}/resources/{resourceId}")
public AjaxResult unbindResource(@PathVariable("cId") String cId,
@PathVariable("resourceId") String resourceId)
{
return toAjax(trainCourseResourceService.unbindResource(cId, resourceId));
}
/**
* 删除课程信息
*/
@@ -64,7 +64,8 @@ public class TrainResourceController extends BaseController
public AjaxResult add(@Validated @RequestBody TrainResource trainResource)
{
trainResource.setCreateBy(SecurityUtils.getUsername());
return toAjax(trainResourceService.insertTrainResource(trainResource));
int rows = trainResourceService.insertTrainResource(trainResource);
return rows > 0 ? success(trainResource) : error();
}
/**
@@ -43,10 +43,6 @@ public class TrainCourse extends BaseEntity
@Excel(name = "课程内容")
private String courseContent;
/** 课程资源ID,多个资源以英文逗号分隔,对应 train_resource.resource_id */
@Excel(name = "课程资源ID")
private String resourceId;
/** 课程状态 (1:正常, 0:停用) */
@Excel(name = "课程状态", readConverterExp = "1=正常,0=停用")
private Integer status;
@@ -184,16 +180,6 @@ public class TrainCourse extends BaseEntity
return courseContent;
}
public void setResourceId(String resourceId)
{
this.resourceId = resourceId;
}
public String getResourceId()
{
return resourceId;
}
public void setStatus(Integer status)
{
this.status = status;
@@ -224,7 +210,6 @@ public class TrainCourse extends BaseEntity
.append("cDuration", getcDuration())
.append("teachingObjective", getTeachingObjective())
.append("courseContent", getCourseContent())
.append("resourceId", getResourceId())
.append("status", getStatus())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
@@ -0,0 +1,36 @@
package com.ruoyi.train.domain.dto;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
/**
* 课程绑定教学资源请求。
*/
public class CourseResourceBindDTO
{
@NotBlank(message = "资源ID不能为空")
private String resourceId;
@Min(value = 0, message = "资源排序号不能小于0")
private Integer sortNo;
public String getResourceId()
{
return resourceId;
}
public void setResourceId(String resourceId)
{
this.resourceId = resourceId;
}
public Integer getSortNo()
{
return sortNo;
}
public void setSortNo(Integer sortNo)
{
this.sortNo = sortNo;
}
}
@@ -1,12 +1,12 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainResource;
/**
* 课程详情视图对象 (API Contract: CourseDetailVO)
@@ -60,14 +60,10 @@ public class CourseDetailVO implements Serializable
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
private String resourceId;
/**
* 已关联的教学资源列表
* 注:当前 ISSUE-01-03 未实现真实 Course<->Resource 查询;
* 绑定/解绑及真实关联能力属于 P1。为保证集合字段结构稳定,默认返回空列表 (empty list)。
*/
private List<Object> resources;
private List<TrainResource> resources;
public CourseDetailVO()
{
@@ -93,9 +89,6 @@ public class CourseDetailVO implements Serializable
vo.setCreateTime(course.getCreateTime());
vo.setUpdateBy(course.getUpdateBy());
vo.setUpdateTime(course.getUpdateTime());
vo.setResourceId(course.getResourceId());
// 资源绑定/解绑能力属于 P1,当前返回空列表保持响应结构稳定
vo.setResources(Collections.emptyList());
return vo;
}
@@ -289,20 +282,12 @@ public class CourseDetailVO implements Serializable
this.updateTime = updateTime;
}
public String getResourceId() {
return resourceId;
}
public void setResourceId(String resourceId) {
this.resourceId = resourceId;
}
public List<Object> getResources()
public List<TrainResource> getResources()
{
return resources;
}
public void setResources(List<Object> resources)
public void setResources(List<TrainResource> resources)
{
this.resources = resources;
}
@@ -0,0 +1,25 @@
package com.ruoyi.train.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainResource;
/**
* 课程与教学资源关联Mapper。
*/
public interface TrainCourseResourceMapper
{
public List<TrainResource> selectResourcesByCourseId(@Param("cId") String cId);
public int countBinding(@Param("cId") String cId, @Param("resourceId") String resourceId);
public int insertBinding(@Param("cId") String cId, @Param("resourceId") String resourceId,
@Param("sortNo") Integer sortNo, @Param("createBy") String createBy);
public int updateBindingSort(@Param("cId") String cId, @Param("resourceId") String resourceId,
@Param("sortNo") Integer sortNo);
public int deleteBinding(@Param("cId") String cId, @Param("resourceId") String resourceId);
public int countCourseReferencesByResourceId(@Param("resourceId") String resourceId);
}
@@ -0,0 +1,11 @@
package com.ruoyi.train.service;
/**
* 课程与教学资源关联Service。
*/
public interface ITrainCourseResourceService
{
public int bindResource(String cId, String resourceId, Integer sortNo);
public int unbindResource(String cId, String resourceId);
}
@@ -0,0 +1,78 @@
package com.ruoyi.train.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.domain.TrainResource;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
import com.ruoyi.train.mapper.TrainResourceMapper;
import com.ruoyi.train.service.ITrainCourseResourceService;
/**
* 课程与教学资源关联Service实现。
*/
@Service
public class TrainCourseResourceServiceImpl implements ITrainCourseResourceService
{
@Autowired
private TrainCourseMapper trainCourseMapper;
@Autowired
private TrainResourceMapper trainResourceMapper;
@Autowired
private TrainCourseResourceMapper trainCourseResourceMapper;
@Override
@Transactional
public int bindResource(String cId, String resourceId, Integer sortNo)
{
if (StringUtils.isEmpty(cId) || trainCourseMapper.selectTrainCourseByCId(cId) == null)
{
throw new ServiceException("课程不存在", 404);
}
TrainResource resource = trainResourceMapper.selectTrainResourceByResourceId(resourceId);
if (resource == null)
{
throw new ServiceException("教学资源不存在", 404);
}
int normalizedSortNo = sortNo == null ? 0 : sortNo;
if (trainCourseResourceMapper.countBinding(cId, resourceId) > 0)
{
trainCourseResourceMapper.updateBindingSort(cId, resourceId, normalizedSortNo);
return 1;
}
String createBy = null;
try
{
createBy = SecurityUtils.getUsername();
}
catch (Exception e)
{
// 无登录上下文的测试环境允许为空。
}
return trainCourseResourceMapper.insertBinding(cId, resourceId, normalizedSortNo, createBy);
}
@Override
@Transactional
public int unbindResource(String cId, String resourceId)
{
if (StringUtils.isEmpty(cId) || trainCourseMapper.selectTrainCourseByCId(cId) == null)
{
throw new ServiceException("课程不存在", 404);
}
int rows = trainCourseResourceMapper.deleteBinding(cId, resourceId);
if (rows == 0)
{
throw new ServiceException("课程未关联该教学资源", 404);
}
return rows;
}
}
@@ -9,6 +9,7 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.vo.CourseDetailVO;
@@ -26,6 +27,9 @@ public class TrainCourseServiceImpl implements ITrainCourseService
@Autowired
private TrainCourseMapper trainCourseMapper;
@Autowired
private TrainCourseResourceMapper trainCourseResourceMapper;
@Autowired
private TrainPlanCourseMapper trainPlanCourseMapper;
@@ -39,7 +43,12 @@ public class TrainCourseServiceImpl implements ITrainCourseService
public CourseDetailVO selectCourseDetailByCId(String cId)
{
TrainCourse course = trainCourseMapper.selectTrainCourseByCId(cId);
return CourseDetailVO.from(course);
CourseDetailVO detail = CourseDetailVO.from(course);
if (detail != null)
{
detail.setResources(trainCourseResourceMapper.selectResourcesByCourseId(cId));
}
return detail;
}
/**
@@ -9,6 +9,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.train.domain.TrainResource;
import com.ruoyi.train.mapper.TrainResourceMapper;
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
import com.ruoyi.train.service.ITrainResourceService;
/**
@@ -25,6 +26,9 @@ public class TrainResourceServiceImpl implements ITrainResourceService
@Autowired
private TrainResourceMapper trainResourceMapper;
@Autowired
private TrainCourseResourceMapper trainCourseResourceMapper;
/**
* 查询教学资源
*
@@ -112,6 +116,10 @@ public class TrainResourceServiceImpl implements ITrainResourceService
{
throw new ServiceException("请选择要删除的资源");
}
for (String resourceId : resourceIds)
{
assertResourceNotReferenced(resourceId);
}
return trainResourceMapper.deleteTrainResourceByResourceIds(resourceIds);
}
@@ -128,9 +136,18 @@ public class TrainResourceServiceImpl implements ITrainResourceService
{
throw new ServiceException("资源ID不能为空");
}
assertResourceNotReferenced(resourceId);
return trainResourceMapper.deleteTrainResourceByResourceId(resourceId);
}
private void assertResourceNotReferenced(String resourceId)
{
if (trainCourseResourceMapper.countCourseReferencesByResourceId(resourceId) > 0)
{
throw new ServiceException("教学资源已被课程引用,请先解除课程关联");
}
}
/**
* 校验资源类型取值:1文档 2视频 33D模型 4VR资源 5外部教学链接 6其他
*/
@@ -12,7 +12,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="cDuration" column="c_duration" />
<result property="teachingObjective" column="teaching_objective" />
<result property="courseContent" column="course_content" />
<result property="resourceId" column="resource_id" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
@@ -23,7 +22,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectTrainCourseVo">
select c_id, c_code, c_name, c_type, c_duration, teaching_objective, course_content, resource_id, status, del_flag, create_by, create_time, update_by, update_time, remark from train_course
select c_id, c_code, c_name, c_type, c_duration, teaching_objective, course_content, status, del_flag, create_by, create_time, update_by, update_time, remark from train_course
</sql>
<select id="selectTrainCourseList" parameterType="TrainCourse" resultMap="TrainCourseResult">
@@ -64,7 +63,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="cDuration != null">c_duration,</if>
<if test="teachingObjective != null and teachingObjective != ''">teaching_objective,</if>
<if test="courseContent != null and courseContent != ''">course_content,</if>
<if test="resourceId != null and resourceId != ''">resource_id,</if>
<if test="status != null">status,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
@@ -81,7 +79,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="cDuration != null">#{cDuration},</if>
<if test="teachingObjective != null and teachingObjective != ''">#{teachingObjective},</if>
<if test="courseContent != null and courseContent != ''">#{courseContent},</if>
<if test="resourceId != null and resourceId != ''">#{resourceId},</if>
<if test="status != null">#{status},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
@@ -101,7 +98,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="cDuration != null">c_duration = #{cDuration},</if>
<if test="teachingObjective != null">teaching_objective = #{teachingObjective},</if>
<if test="courseContent != null">course_content = #{courseContent},</if>
<if test="resourceId != null">resource_id = #{resourceId},</if>
<if test="status != null">status = #{status},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainCourseResourceMapper">
<resultMap type="TrainResource" id="CourseResourceResult">
<result property="resourceId" column="resource_id" />
<result property="resourceName" column="resource_name" />
<result property="resourceType" column="resource_type" />
<result property="resourceUrl" column="resource_url" />
<result property="description" column="description" />
<result property="status" column="status" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<select id="selectResourcesByCourseId" parameterType="String" resultMap="CourseResourceResult">
select r.resource_id, r.resource_name, r.resource_type, r.resource_url,
r.description, r.status, r.create_by, r.create_time,
r.update_by, r.update_time, r.remark
from train_course_resource cr
join train_resource r on r.resource_id = cr.resource_id and r.del_flag = '0'
where cr.c_id = #{cId}
order by cr.sort_no asc, cr.id asc
</select>
<select id="countBinding" resultType="int">
select count(1)
from train_course_resource
where c_id = #{cId} and resource_id = #{resourceId}
</select>
<insert id="insertBinding">
insert into train_course_resource (c_id, resource_id, sort_no, create_by, create_time)
values (#{cId}, #{resourceId}, #{sortNo}, #{createBy}, current_timestamp)
</insert>
<update id="updateBindingSort">
update train_course_resource
set sort_no = #{sortNo}
where c_id = #{cId} and resource_id = #{resourceId}
</update>
<delete id="deleteBinding">
delete from train_course_resource
where c_id = #{cId} and resource_id = #{resourceId}
</delete>
<select id="countCourseReferencesByResourceId" parameterType="String" resultType="int">
select count(1)
from train_course_resource
where resource_id = #{resourceId}
</select>
</mapper>
@@ -0,0 +1,149 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainResource;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
import com.ruoyi.train.mapper.TrainResourceMapper;
/**
* 课程与教学资源规范关联服务测试。
*/
public class TrainCourseResourceServiceTest
{
private TrainCourseResourceServiceImpl service;
private TrainCourse course;
private TrainResource resource;
@Before
public void setUp()
{
service = new TrainCourseResourceServiceImpl();
course = new TrainCourse();
course.setcId("C001");
resource = new TrainResource();
resource.setResourceId("R001");
}
@Test
public void bindCreatesAssociationInsteadOfSerializingIds() throws Exception
{
AtomicInteger insertedSortNo = new AtomicInteger(-1);
inject("trainCourseMapper", courseMapper(course));
inject("trainResourceMapper", resourceMapper(resource));
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> {
if ("countBinding".equals(method.getName()))
{
return 0;
}
if ("insertBinding".equals(method.getName()))
{
insertedSortNo.set((Integer) args[2]);
return 1;
}
return defaultValue(method.getReturnType());
}));
assertEquals(1, service.bindResource("C001", "R001", 3));
assertEquals(3, insertedSortNo.get());
}
@Test
public void duplicateBindUpdatesSortAndRemainsIdempotent() throws Exception
{
AtomicInteger updates = new AtomicInteger();
inject("trainCourseMapper", courseMapper(course));
inject("trainResourceMapper", resourceMapper(resource));
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> {
if ("countBinding".equals(method.getName()))
{
return 1;
}
if ("updateBindingSort".equals(method.getName()))
{
updates.incrementAndGet();
return 1;
}
if ("insertBinding".equals(method.getName()))
{
fail("重复绑定不应再次插入关联记录");
}
return defaultValue(method.getReturnType());
}));
assertEquals(1, service.bindResource("C001", "R001", 5));
assertEquals(1, updates.get());
}
@Test
public void bindRejectsMissingResource() throws Exception
{
inject("trainCourseMapper", courseMapper(course));
inject("trainResourceMapper", resourceMapper(null));
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> defaultValue(method.getReturnType())));
try
{
service.bindResource("C001", "R404", 0);
fail("不存在的教学资源必须被拒绝");
}
catch (ServiceException expected)
{
assertEquals("教学资源不存在", expected.getMessage());
}
}
private TrainCourseMapper courseMapper(TrainCourse value)
{
return (TrainCourseMapper) Proxy.newProxyInstance(
TrainCourseMapper.class.getClassLoader(),
new Class<?>[] { TrainCourseMapper.class },
(proxy, method, args) -> "selectTrainCourseByCId".equals(method.getName())
? value : defaultValue(method.getReturnType()));
}
private TrainResourceMapper resourceMapper(TrainResource value)
{
return (TrainResourceMapper) Proxy.newProxyInstance(
TrainResourceMapper.class.getClassLoader(),
new Class<?>[] { TrainResourceMapper.class },
(proxy, method, args) -> "selectTrainResourceByResourceId".equals(method.getName())
? value : defaultValue(method.getReturnType()));
}
private TrainCourseResourceMapper associationMapper(java.lang.reflect.InvocationHandler handler)
{
return (TrainCourseResourceMapper) Proxy.newProxyInstance(
TrainCourseResourceMapper.class.getClassLoader(),
new Class<?>[] { TrainCourseResourceMapper.class }, handler);
}
private void inject(String fieldName, Object value) throws Exception
{
Field field = TrainCourseResourceServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(service, value);
}
private Object defaultValue(Class<?> type)
{
if (type == int.class)
{
return 0;
}
if (type == boolean.class)
{
return false;
}
return null;
}
}
@@ -3,7 +3,7 @@
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
-- 执行目标:
-- 1. 补齐学员、教师、教室、实训场地、课程主数据新增扩展字段 (11个物理列)
-- 2. 创建统一教学资源表 (train_resource) 及其索引与字典
-- 2. 创建统一教学资源表及课程-资源规范关联表
-- 3. 补齐教学资源管理菜单 (sys_menu) 与培训管理员/超级管理员角色授权 (sys_role_menu)
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
-- 幂等保证: 全部 DDL / DML 均具备 IF NOT EXISTS 或 NOT EXISTS 守卫,可安全重复执行
@@ -45,10 +45,6 @@ ALTER TABLE public.train_training_site ADD COLUMN IF NOT EXISTS seat_arrange VAR
COMMENT ON COLUMN public.train_training_site.major IS '适用专业/实训工种';
COMMENT ON COLUMN public.train_training_site.seat_arrange IS '工位布局形式';
-- 1.5 课程表 train_course 增加 资源ID关联串 (逗号分隔资源ID)
ALTER TABLE public.train_course ADD COLUMN IF NOT EXISTS resource_id VARCHAR(500);
COMMENT ON COLUMN public.train_course.resource_id IS '关联教学资源ID集合 (逗号分隔)';
-- =============================================================================
-- 步骤 2: 创建统一教学资源表 (train_resource)
-- =============================================================================
@@ -81,6 +77,9 @@ COMMENT ON COLUMN public.train_resource.del_flag IS '删除标志 (0:存在, 2:
CREATE INDEX IF NOT EXISTS idx_train_resource_type ON public.train_resource (resource_type);
CREATE INDEX IF NOT EXISTS idx_train_resource_status ON public.train_resource (status);
-- 步骤 2 结束: 教学资源表创建完成。课程资源多对多关联见增量迁移脚本 012。
-- =============================================================================
-- 步骤 3: 补齐教学资源类型字典 (train_resource_type)
-- =============================================================================
@@ -0,0 +1,91 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 增量迁移脚本: 012_pilot_20261010_course_resource_relation.sql
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
-- 执行目标: 创建课程与教学资源规范多对多关系表 (train_course_resource),迁移历史关联,保留物理字段兼容
-- 架构方案: 改进 B+ 方案 (唯一权威存储为 train_course_resource;保留 train_course.resource_id 禁止 DROP COLUMN)
-- ==============================================================================
BEGIN;
-- 步骤 1: 创建规范课程资源多对多关联表
CREATE TABLE IF NOT EXISTS public.train_course_resource (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
c_id VARCHAR(32) NOT NULL,
resource_id VARCHAR(32) NOT NULL,
sort_no INTEGER DEFAULT 0,
create_by VARCHAR(64),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_train_course_resource PRIMARY KEY (id)
);
COMMENT ON TABLE public.train_course_resource IS '课程与教学资源关联表 (N:M 唯一权威存储)';
COMMENT ON COLUMN public.train_course_resource.id IS '主键ID';
COMMENT ON COLUMN public.train_course_resource.c_id IS '课程ID';
COMMENT ON COLUMN public.train_course_resource.resource_id IS '教学资源ID';
COMMENT ON COLUMN public.train_course_resource.sort_no IS '课程内资源排序号';
COMMENT ON COLUMN public.train_course_resource.create_by IS '绑定操作人';
COMMENT ON COLUMN public.train_course_resource.create_time IS '绑定时间';
-- 步骤 2: 创建索引与唯一约束 (保证幂等防重复)
CREATE UNIQUE INDEX IF NOT EXISTS uq_course_resource
ON public.train_course_resource (c_id, resource_id);
CREATE INDEX IF NOT EXISTS idx_course_resource_course
ON public.train_course_resource (c_id);
CREATE INDEX IF NOT EXISTS idx_course_resource_resource
ON public.train_course_resource (resource_id);
-- 外键约束 (如果不存在则添加)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cr_course') THEN
ALTER TABLE public.train_course_resource
ADD CONSTRAINT fk_cr_course FOREIGN KEY (c_id)
REFERENCES public.train_course (c_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cr_resource') THEN
ALTER TABLE public.train_course_resource
ADD CONSTRAINT fk_cr_resource FOREIGN KEY (resource_id)
REFERENCES public.train_resource (resource_id);
END IF;
END $$;
-- 步骤 3: 幂等迁移历史 train_course.resource_id 字段数据
-- 仅当 train_course.resource_id 非空且关联在 train_resource 中有效(del_flag='0')时迁移,
-- 且 ON CONFLICT (c_id, resource_id) DO NOTHING 避免覆盖已存在的关联,亦不破坏已解绑关系。
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'train_course'
AND column_name = 'resource_id'
) THEN
INSERT INTO public.train_course_resource
(c_id, resource_id, sort_no, create_by, create_time)
SELECT legacy.c_id,
legacy.resource_id,
legacy.sort_no,
'migration_012',
CURRENT_TIMESTAMP
FROM (
SELECT c.c_id,
trim(parts.resource_id) AS resource_id,
CAST(parts.ordinality - 1 AS INTEGER) AS sort_no
FROM public.train_course c
CROSS JOIN LATERAL regexp_split_to_table(c.resource_id, ',')
WITH ORDINALITY AS parts(resource_id, ordinality)
WHERE c.resource_id IS NOT NULL
AND trim(c.resource_id) <> ''
) legacy
JOIN public.train_resource r
ON r.resource_id = legacy.resource_id
AND r.del_flag = '0'
WHERE legacy.resource_id <> ''
ON CONFLICT (c_id, resource_id) DO NOTHING;
END IF;
END $$;
-- 注意:根据改进 B+ 方案决策,train_course.resource_id 物理字段必须永久保留(供向下兼容只读/兼容调用),
-- 禁止执行 ALTER TABLE train_course DROP COLUMN resource_id!
COMMIT;
@@ -14,8 +14,8 @@ DELETE FROM public.sys_menu WHERE menu_id IN (2270, 2271, 2272);
DELETE FROM public.sys_dict_data WHERE dict_type = 'train_resource_type';
DELETE FROM public.sys_dict_type WHERE dict_type = 'train_resource_type';
-- 3. 删除教学资源表及索引
DROP TABLE IF EXISTS public.train_resource CASCADE;
-- 3. 删除教学资源表及索引 (无 CASCADE 保护数据安全;train_course_resource 回滚见 012 回滚脚本)
DROP TABLE IF EXISTS public.train_resource;
-- 4. 删除主数据扩展列
ALTER TABLE public.train_student DROP COLUMN IF EXISTS avatar;
@@ -33,6 +33,4 @@ ALTER TABLE public.train_classroom_info DROP COLUMN IF EXISTS seat_arrange;
ALTER TABLE public.train_training_site DROP COLUMN IF EXISTS major;
ALTER TABLE public.train_training_site DROP COLUMN IF EXISTS seat_arrange;
ALTER TABLE public.train_course DROP COLUMN IF EXISTS resource_id;
COMMIT;
@@ -0,0 +1,14 @@
-- ==============================================================================
-- RailwaySystem 数据库 V2 回滚脚本: 012_pilot_20261010_course_resource_relation_rollback.sql
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
-- 执行目标: 安全回滚 012_pilot_20261010_course_resource_relation.sql 的变更
-- ==============================================================================
BEGIN;
-- 移除外键约束与关联表(禁止 CASCADE)
ALTER TABLE IF EXISTS public.train_course_resource DROP CONSTRAINT IF EXISTS fk_cr_course;
ALTER TABLE IF EXISTS public.train_course_resource DROP CONSTRAINT IF EXISTS fk_cr_resource;
DROP TABLE IF EXISTS public.train_course_resource;
COMMIT;