diff --git a/.gitignore b/.gitignore index 4a93d797e..fab3d572c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ node_modules/ **/node_modules/ **/node_modules1/ **/node_modules2/ +.m2repo/ +.npm-cache/ *.class **/*.class diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java index f606478e3..8115addd2 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java @@ -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)); + } + /** * 删除课程信息 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainResourceController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainResourceController.java index 9ae9ee110..eb6f08067 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainResourceController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainResourceController.java @@ -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(); } /** diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java index d0d6533c8..e2d379227 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainCourse.java @@ -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()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/CourseResourceBindDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/CourseResourceBindDTO.java new file mode 100644 index 000000000..881e47ecf --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/CourseResourceBindDTO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java index be46a95e6..2c5029f8e 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CourseDetailVO.java @@ -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 resources; + private List 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 getResources() + public List getResources() { return resources; } - public void setResources(List resources) + public void setResources(List resources) { this.resources = resources; } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainCourseResourceMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainCourseResourceMapper.java new file mode 100644 index 000000000..171bcf71a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainCourseResourceMapper.java @@ -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 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainCourseResourceService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainCourseResourceService.java new file mode 100644 index 000000000..fe73c9a60 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainCourseResourceService.java @@ -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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceImpl.java new file mode 100644 index 000000000..e5ccdb227 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceImpl.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseServiceImpl.java index 5ebf09943..a29b83473 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainCourseServiceImpl.java @@ -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; } /** diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainResourceServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainResourceServiceImpl.java index 7d4a2816d..03104f25f 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainResourceServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainResourceServiceImpl.java @@ -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其他 */ diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainCourseMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainCourseMapper.xml index 51d6b1f5a..5a0db4dce 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainCourseMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainCourseMapper.xml @@ -12,7 +12,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - @@ -23,7 +22,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - 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 + 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 + + + + + + insert into train_course_resource (c_id, resource_id, sort_no, create_by, create_time) + values (#{cId}, #{resourceId}, #{sortNo}, #{createBy}, current_timestamp) + + + + update train_course_resource + set sort_no = #{sortNo} + where c_id = #{cId} and resource_id = #{resourceId} + + + + delete from train_course_resource + where c_id = #{cId} and resource_id = #{resourceId} + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceTest.java new file mode 100644 index 000000000..6176f91f7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainCourseResourceServiceTest.java @@ -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; + } +} diff --git a/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql b/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql index bc3b7a21e..e6a52ddab 100644 --- a/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql +++ b/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql @@ -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) -- ============================================================================= diff --git a/RuoYi-Vue/sql/train/v2/012_pilot_20261010_course_resource_relation.sql b/RuoYi-Vue/sql/train/v2/012_pilot_20261010_course_resource_relation.sql new file mode 100644 index 000000000..1edb9cb5d --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/012_pilot_20261010_course_resource_relation.sql @@ -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; diff --git a/RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql b/RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql index 62fb43054..148304386 100644 --- a/RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql +++ b/RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql @@ -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; diff --git a/RuoYi-Vue/sql/train/v2/rollback/012_pilot_20261010_course_resource_relation_rollback.sql b/RuoYi-Vue/sql/train/v2/rollback/012_pilot_20261010_course_resource_relation_rollback.sql new file mode 100644 index 000000000..ef2f76d98 --- /dev/null +++ b/RuoYi-Vue/sql/train/v2/rollback/012_pilot_20261010_course_resource_relation_rollback.sql @@ -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; diff --git a/build_ui/src/api/train/course.js b/build_ui/src/api/train/course.js index 6e079ade5..d7738d84e 100644 --- a/build_ui/src/api/train/course.js +++ b/build_ui/src/api/train/course.js @@ -35,6 +35,23 @@ export function updateCourse(data) { }) } +// 绑定课程教学资源 +export function bindCourseResource(cId, data) { + return request({ + url: '/train/course/' + cId + '/resources', + method: 'post', + data: data + }) +} + +// 解绑课程教学资源(不删除资源本身) +export function unbindCourseResource(cId, resourceId) { + return request({ + url: '/train/course/' + cId + '/resources/' + resourceId, + method: 'delete' + }) +} + // 删除课程信息 export function delCourse(cId) { return request({ diff --git a/build_ui/src/views/train/course/index.vue b/build_ui/src/views/train/course/index.vue index 660af78ba..8c0fe75f9 100644 --- a/build_ui/src/views/train/course/index.vue +++ b/build_ui/src/views/train/course/index.vue @@ -2,34 +2,14 @@
- + - + - - + + @@ -46,25 +26,10 @@ - 新增 + 新增 - 修改 + 修改 @@ -79,26 +44,14 @@ - - + + diff --git a/docs/development/feat-20261009-course-wizard.md b/docs/development/feat-20261009-course-wizard.md new file mode 100644 index 000000000..38b827846 --- /dev/null +++ b/docs/development/feat-20261009-course-wizard.md @@ -0,0 +1,242 @@ +# FEAT-20261009 课程两步式向导与教学资源关联 开发与验收文档 + +> **功能编号:** `FEAT-20261009-COURSE-WIZARD` +> **关联模块:** `ISSUE-01-03 课程库基础资料管理` +> **开发分支:** `feat/20261009-course-wizard` +> **开发基线:** `55143af4a518222007d9b82dfbe0677a04f06150` +> **API Contract:** `docs/api-v2/02_module_a_base_resource_api.md` +> **数据库:** KingbaseES / Database V2 +> **实施日期:** 2026-10-09 +> **当前状态:** 代码开发完成,静态构建与自动化测试通过;待提交、推送、CI 与运行时验收 + +--- + +## 1. 功能目标与范围 + +本功能将课程维护改造为两步式向导,并补齐课程与教学资源之间的真实关联能力: + +1. 第一步维护课程编码、名称、类型、学时、教学目标、课程内容、状态与备注; +2. 第二步查看已关联资源、关联资源库中的既有资源、上传新资源并自动关联、解除资源关联; +3. 课程与资源使用规范化多对多关联表 `train_course_resource`,禁止在 `train_course` 中保存逗号分隔资源 ID; +4. 复用既有课程、教学资源与通用文件上传能力,不引入新的文件存储协议; +5. 不修改培训计划课程配置、排课及其他计划流程模块。 + +### 1.1 明确边界 + +- 解除关联只删除 `train_course_resource` 关系,不删除 `train_resource` 资源记录及实际文件; +- 被课程引用的资源禁止直接删除,必须先解除课程关联; +- 本功能不提供资源文件物理删除、资源版本管理、断点续传及大文件分片上传; +- 本功能未执行真实生产数据库迁移或写入。 + +--- + +## 2. 用户操作流程 + +### 2.1 新增课程 + +1. 用户点击“新增”; +2. 在“课程基本信息”步骤填写并保存课程; +3. 后端返回已生成 `cId`,前端进入“教学资源”步骤; +4. 用户可关联既有资源,或通过通用上传组件上传文件、创建资源并自动绑定; +5. 用户点击“完成”关闭向导并刷新课程列表。 + +### 2.2 修改课程 + +1. 用户从列表进入“修改”; +2. 页面加载课程详情及其真实资源列表; +3. 用户可修改课程基本信息,也可进入第二步新增或解除资源关联; +4. 解除关联不会删除资源库中的资源。 + +--- + +## 3. 数据模型与设计决策 + +### 3.1 规范化关联模型 + +课程与资源采用标准 N:M 模型: + +```text +train_course (1) ──< train_course_resource >── (1) train_resource +``` + +`train_course_resource` 核心字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | `BIGINT IDENTITY` | 关联记录主键 | +| `c_id` | `VARCHAR(32)` | 课程 ID,外键指向 `train_course.c_id` | +| `resource_id` | `VARCHAR(32)` | 资源 ID,外键指向 `train_resource.resource_id` | +| `sort_no` | `INTEGER` | 课程内资源排序号 | +| `create_by` | `VARCHAR(64)` | 创建人 | +| `create_time` | `TIMESTAMP` | 创建时间 | + +唯一约束为 `(c_id, resource_id)`,防止同一资源被重复绑定到同一课程。 + +### 3.2 旧字段兼容迁移 + +迁移脚本兼容曾执行旧版 011 的环境: + +1. 检测 `train_course.resource_id` 是否存在; +2. 将逗号分隔的资源 ID 拆分后迁移到 `train_course_resource`; +3. 忽略不存在或已逻辑删除的资源; +4. 使用唯一约束避免重复迁移; +5. 完成迁移后删除 `train_course.resource_id` 非规范字段。 + +--- + +## 4. API 与权限 + +### 4.1 本次新增或调整的课程接口 + +| HTTP Method | Path | 权限 | 说明 | +|:---:|---|---|---| +| `GET` | `/train/course/{cId}` | `train:course:query` | 返回 `CourseDetailVO`,其中 `resources` 为真实关联资源列表 | +| `POST` | `/train/course` | `train:course:add` | 新增课程并在 `data` 中返回包含生成后 `cId` 的课程对象 | +| `POST` | `/train/course/{cId}/resources` | `train:course:edit` | 绑定资源;请求体为 `resourceId`、`sortNo` | +| `DELETE` | `/train/course/{cId}/resources/{resourceId}` | `train:course:edit` | 解除课程资源关联,不删除资源本身 | + +### 4.2 复用的资源接口 + +| HTTP Method | Path | 权限 | 用途 | +|:---:|---|---|---| +| `GET` | `/train/resource/list` | `train:resource:list` | 获取可关联资源 | +| `POST` | `/train/resource` | `train:resource:edit` | 创建教学资源并返回生成后的资源对象 | +| `POST` | `/common/upload` | 登录用户 | 复用通用上传组件上传资源文件 | + +### 4.3 请求模型 + +`CourseResourceBindDTO`: + +| 字段 | 校验 | 说明 | +|---|---|---| +| `resourceId` | `@NotBlank` | 必填资源 ID | +| `sortNo` | `@Min(0)` | 可选排序号,默认 0 | + +--- + +## 5. 后端实现 + +| 层级 | 文件 | 实现内容 | +|---|---|---| +| Controller | `TrainCourseController` | 返回新增课程对象;增加资源绑定、解绑端点 | +| Controller | `TrainResourceController` | 新增资源后返回包含 `resourceId` 的资源对象 | +| DTO | `CourseResourceBindDTO` | 绑定请求参数与校验 | +| VO | `CourseDetailVO` | `resources` 调整为 `List` | +| Service | `TrainCourseResourceServiceImpl` | 校验课程和资源、幂等绑定、更新排序、解除关联 | +| Service | `TrainCourseServiceImpl` | 查询课程详情时聚合真实资源列表 | +| Service | `TrainResourceServiceImpl` | 删除前检查课程引用并给出友好提示 | +| Mapper | `TrainCourseResourceMapper` | 关联查询、新增、更新、删除与引用计数 | +| Mapper XML | `TrainCourseResourceMapper.xml` | 实现规范化关联 SQL,并过滤已逻辑删除资源 | +| Entity/Mapper | `TrainCourse`、`TrainCourseMapper.xml` | 移除旧 `resourceId/resource_id` 逗号串字段 | + +绑定操作的业务语义为幂等:若关联已存在,则更新 `sort_no`,不重复插入关联记录。 + +--- + +## 6. 前端实现 + +主要页面:`build_ui/src/views/train/course/index.vue` + +实现内容: + +1. 使用 `el-steps` 提供“课程基本信息 → 教学资源”两步流程; +2. 新增课程时先保存课程并取得后端生成的 `cId`; +3. 展示真实关联资源及其文件/链接; +4. 支持搜索并关联资源库中尚未绑定的资源; +5. 非外部链接资源复用 `file-upload` 组件; +6. 外部链接校验必须以 `http://` 或 `https://` 开头; +7. 上传资源后调用既有资源新增接口,再调用课程资源绑定接口; +8. 支持解除关联,并明确提示资源本身不会被删除。 + +前端 API 文件 `build_ui/src/api/train/course.js` 新增: + +- `bindCourseResource(cId, data)`; +- `unbindCourseResource(cId, resourceId)`。 + +--- + +## 7. 数据库迁移与回滚 + +### 7.1 正向迁移 + +脚本:`RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql` + +- 幂等创建 `train_resource` 与 `train_course_resource`; +- 创建课程资源唯一索引、课程索引与资源索引; +- 创建课程、资源外键; +- 迁移旧逗号 ID 后删除 `train_course.resource_id`; +- 补齐资源类型字典、菜单与角色授权。 + +### 7.2 回滚 + +脚本:`RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql` + +回滚脚本会删除资源菜单、字典、课程资源关联表与资源表,并回滚 011 中的主数据扩展列。该操作具有破坏性,只允许在确认无正式数据的回滚窗口执行。 + +--- + +## 8. 自动化验证结果 + +| 验证项 | 结果 | 说明 | +|---|:---:|---| +| `ruoyi-train` 全量测试 | **PASS** | 393 tests,0 failures,0 errors,0 skipped | +| 课程资源关联定向测试 | **PASS** | 3 tests,覆盖新增绑定、重复绑定幂等更新、资源不存在拦截 | +| 后端业务模块编译 | **PASS** | `ruoyi-train` 编译成功 | +| 前端 ESLint | **PASS** | 课程页面与课程 API 文件 0 errors | +| 前端生产构建 | **PASS** | `npm run build:prod` 成功 | +| 完整 `ruoyi-admin` 本地聚合构建 | **环境阻断** | 本机缺少非公开依赖 `com.kingbase:kingbase8:8.6.0`,不是本次代码编译错误 | +| 数据库迁移实跑 | **PENDING** | 未连接目标 Kingbase 环境执行 011 | +| 浏览器端到端验收 | **PENDING** | 需在迁移后的集成环境验证上传、绑定、解绑完整流程 | + +前端构建存在一条仓库既有警告:`src/views/graph/antv/index2.vue` 使用的 `@antv/x6` 未导出 `Addon`。该警告与课程模块无关,未阻断构建。 + +--- + +## 9. Acceptance Criteria + +| AC | 验收内容 | 当前状态 | +|:---:|---|:---:| +| AC-01 | 新增课程保存后可进入资源步骤,且获得有效 `cId` | **STATIC/AUTOMATED PASS** | +| AC-02 | 课程详情返回真实关联资源列表 | **STATIC/AUTOMATED PASS** | +| AC-03 | 可关联既有资源,同一资源不会重复绑定 | **AUTOMATED PASS** | +| AC-04 | 可上传新资源并自动关联课程 | **BUILD PASS / RUNTIME PENDING** | +| AC-05 | 解除关联不删除资源本身 | **STATIC PASS / RUNTIME PENDING** | +| AC-06 | 被课程引用的资源禁止删除 | **STATIC PASS / RUNTIME PENDING** | +| AC-07 | 数据库不再使用逗号分隔资源 ID | **MIGRATION STATIC PASS / RUNTIME PENDING** | +| AC-08 | 不影响培训计划课程及排课流程 | **REGRESSION PASS** | + +--- + +## 10. 上线与验证顺序 + +1. 在备份后的集成数据库执行正向迁移 011; +2. 核对 `train_course_resource` 表、唯一约束、外键、字典和菜单; +3. 构建并部署后端、前端; +4. 使用具备课程及资源权限的账号进行浏览器验收; +5. 验证新增课程、修改课程、上传资源、关联既有资源、解除关联和资源删除保护; +6. 核对操作日志与数据库关联记录; +7. 验收通过后再合并到目标集成分支。 + +--- + +## 11. Git 与交付状态 + +- 当前代码与本文档均位于本地分支 `feat/20261009-course-wizard`; +- 当前改动尚未执行 `git commit`、`git push` 或创建合并请求; +- 远端页面显示的 `Status: Failed` 对应基线提交 `55143af4` 的既有 CI Pipeline,不代表本地尚未提交的课程向导代码已经执行或失败; +- 推荐先完成本地变更审查并按中文提交规范提交,再推送该功能分支触发新的 CI; +- 新 Pipeline 全部通过后,再向 `integration/20261010-pilot` 创建合并请求。 + +建议提交信息: + +```text +新增培训:实现课程两步式向导与教学资源关联 +``` + +--- + +## 12. 已知事项 + +1. 本地完整管理端构建依赖授权的 Kingbase JDBC 驱动;CI Runner 必须先通过 `scripts/bootstrap/check-kingbase-jdbc.sh`; +2. 当前远端失败 Pipeline 的具体失败 Job 和日志需在 GitLab Pipeline 页面查看,单凭分支页红色状态无法判断根因; +3. 推送不会自动部署生产环境:当前 `.gitlab-ci.yml` 仅允许 `feat/cicd-pipeline` 执行 `deploy_production`,本功能分支只会运行后端、前端构建测试。 diff --git a/docs/development/issue-01-03-course.md b/docs/development/issue-01-03-course.md index b9140e91b..66869a35e 100644 --- a/docs/development/issue-01-03-course.md +++ b/docs/development/issue-01-03-course.md @@ -1,5 +1,7 @@ # ISSUE-01-03 课程库基础资料管理 开发与验收文档 (A- / Final Cleanup 修订版) +> **后续能力更新(2026-10-09):** 本文记录早期课程库 P0 建设历史。其中“教学资源仅返回空列表、绑定/解绑尚未实现”等描述已被后续功能替代。当前两步式课程向导、资源上传及规范化关联的实现与验收状态,以 [`feat-20261009-course-wizard.md`](./feat-20261009-course-wizard.md) 为准。 + > **Issue ID:** `ISSUE-01-03` > **所属 Epic:** `EPIC-01 基础主数据必要集合` > **分支基线:** `feat/issue-01-03-course` (HEAD: `7fcaea29b8810546fd972ba0c9d366c71a357d63`)