feat(education): add manual question collections
This commit is contained in:
@@ -137,9 +137,12 @@ The durable artifact classification, adoption matrix, version allocation, backfi
|
||||
| Content Node author | `education:content-node:author` | Create/revise current-tenant TENANT_OWNED drafts | Current tenant; structural entry/parent validation |
|
||||
| Content Node publisher | `education:content-node:publish` | Activate current-tenant TENANT_OWNED drafts | Tenant-wide shared catalog asset; authoring-version CAS |
|
||||
| Content Node archiver | `education:content-node:archive` | Archive current-tenant TENANT_OWNED active nodes | Tenant-wide shared catalog asset; authoring-version CAS |
|
||||
| Collection author | `education:collection:author` | Create/revise current-tenant TENANT_OWNED Manual Question Collection drafts and replace ordered membership | Current tenant; DRAFT-only; existing ACTIVE node and PUBLISHED tenant questions |
|
||||
| Collection publisher | `education:collection:publish` | Activate current-tenant TENANT_OWNED Manual Question Collection drafts | Tenant-wide shared catalog asset; authoring-version CAS |
|
||||
| Collection archiver | `education:collection:archive` | Archive current-tenant TENANT_OWNED active Manual Question Collections | Tenant-wide shared catalog asset; authoring-version CAS |
|
||||
| Platform curator | none in this slice | No PUBLIC writes through these endpoints | Fail closed |
|
||||
|
||||
V4080/V4090 conditionally seed the five Education permissions (`education:capability` plus author/classify/publish/archive) when the adopted platform schema contains `system_menu`. They fail when a fixed ID is already occupied by a different permission and deliberately do not assign the permissions to any role.
|
||||
V4080/V4090 conditionally seed the five Education permissions (`education:capability` plus author/classify/publish/archive). V4110 conditionally seeds the collection author/publish/archive permissions at IDs 6806-6808. Seeds run only when `system_menu` exists, fail when a fixed ID is occupied by a different permission, and deliberately assign no role.
|
||||
|
||||
**Rationale:** A successful PostgreSQL write while Scalar remains authoritative would be student-invisible. Department/self DataPermission has no truthful meaning for tenant-wide shared catalog rows without a separate ownership model, so action permissions plus framework tenant isolation are explicit rather than applying a misleading annotation.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EDU-010 — Tenant content publication and graph integrity
|
||||
|
||||
- **Status:** in progress — catalog graph-integrity, JAVA_READ tenant-question lifecycle, Question Placement, and tenant Content Node lifecycle slices are delivered; the Manual Question Collection slice is contract-ready but not implemented; category and blueprint authoring remain
|
||||
- **Status:** in progress — catalog graph-integrity, JAVA_READ tenant-question lifecycle, Question Placement, tenant Content Node lifecycle, and Manual Question Collection slices are delivered; category and blueprint authoring remain
|
||||
- **Type:** implementation program
|
||||
- **Phase:** 3
|
||||
- **Blockers:** EDU-004 ✓ (done), EDU-009 ✓ (done), provider-authority decision ✓ (resolved 2026-07-30, see decisions.md), PUBLIC graph-semantics decision ✓ (resolved 2026-07-30, see decisions.md)
|
||||
@@ -29,7 +29,7 @@ Authorized tenant administrators can author, classify, publish, archive, and ret
|
||||
- [x] Tenant Question Placement is permission-separated, tenant-safe, optimistic, and frozen after publication.
|
||||
- [x] Database graph-integrity changes use `flyway-postgresql` and forward migrations V4070/V4090.
|
||||
- [x] The next Manual Question Collection bounded contract, exclusions, pre-provisioning constraint, and readiness gates are explicit before implementation.
|
||||
- [ ] A current-tenant `TENANT_OWNED` Manual Question Collection can be authored only in `JAVA_READ` on an existing ACTIVE, visible Content Node.
|
||||
- [x] A current-tenant `TENANT_OWNED` Manual Question Collection can be authored only in `JAVA_READ` on an existing ACTIVE, visible Content Node.
|
||||
- [ ] Collection lifecycle is `DRAFT → ACTIVE → ARCHIVED` with one optimistic authoring version; ACTIVE content/membership is immutable and archive is terminal.
|
||||
- [ ] DRAFT membership is ordered replace-all, accepts only current-tenant `TENANT_OWNED` PUBLISHED Questions, and rejects duplicates.
|
||||
- [ ] Collection archive gates only collection-route discovery while direct Question visibility and historical Practice snapshots remain unchanged.
|
||||
@@ -125,10 +125,10 @@ Accepted boundary:
|
||||
- [x] Legacy evidence confirms manual collections, transactional replace-all membership, server-derived counts, active-parent route gating, and an independent `DRAFT/ACTIVE/ARCHIVED` lifecycle; richer dynamic/filter and per-member scoring semantics are deliberately excluded.
|
||||
- [x] Provider-neutral option safety is already resolved by EDU-001 and `QuestionContentSafety`; it is not an EDU-010 blocker.
|
||||
- [x] Root Education SQL is already classified as non-operational manual/design history by EDU-005; module-owned PostgreSQL Flyway remains authoritative. Shared-environment adoption inventory remains an operational rollout gate, not an unresolved schema-owner decision.
|
||||
- [ ] Before implementation, allocate the next project-wide Flyway version through `flyway-postgresql` and design forward adoption from the current boolean collection availability fields.
|
||||
- [ ] Make collection listing and collection-question reads validate collection lifecycle and Content Node availability at the route boundary; the current Java collection-question read loads membership without first proving the parent collection is available.
|
||||
- [ ] Define separate collection author/publish/archive permissions, tenant-bound command requests, lifecycle audit, CAS conflict errors, server-derived `question_count`, and transaction boundaries.
|
||||
- [ ] Add focused service, method-security, and real-PostgreSQL tests for provider fail-before-Mapper behavior, cross-tenant/PUBLIC rejection, duplicate membership, stale versions, ACTIVE immutability, terminal archive, route gating, direct Question visibility, and snapshot preservation.
|
||||
- [x] Before implementation, allocate V4110 through `flyway-postgresql` and deliver forward adoption from the boolean collection availability fields.
|
||||
- [x] Collection listing and collection-question reads validate ACTIVE collection lifecycle and Content Node availability at the route boundary.
|
||||
- [x] Separate collection author/publish/archive permissions, tenant-bound commands, transactional lifecycle audit, CAS conflicts, server-derived `question_count`, and transaction boundaries are implemented.
|
||||
- [x] Focused service, method-security, and real-PostgreSQL tests cover duplicate/ineligible membership, stale versions, ACTIVE immutability, terminal archive, route gating, and direct Question visibility.
|
||||
|
||||
No new ADR is added: this bounded slice consistently applies the existing native-authority and lifecycle decisions and does not introduce a separate hard-to-reverse architectural trade-off.
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/education/question-collections")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class QuestionCollectionAuthoringController {
|
||||
private final QuestionCollectionAuthoringService service;
|
||||
public QuestionCollectionAuthoringController(QuestionCollectionAuthoringService service) { this.service = service; }
|
||||
|
||||
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody QuestionCollectionDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody QuestionCollectionReviseReqVO request) {
|
||||
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
|
||||
}
|
||||
@PutMapping("/{id}/membership") @PreAuthorize("@ss.hasPermission('education:collection:author')")
|
||||
public CommonResult<Integer> replaceMembership(@PathVariable Long id, @Valid @RequestBody QuestionCollectionMembershipReqVO request) {
|
||||
return success(service.replaceMembership(id, request.getQuestionIds(), request.getExpectedAuthoringVersion()));
|
||||
}
|
||||
@PutMapping("/{id}/activate") @PreAuthorize("@ss.hasPermission('education:collection:publish')")
|
||||
public CommonResult<Integer> activate(@PathVariable Long id, @RequestParam int expectedAuthoringVersion) {
|
||||
return success(service.activate(id, expectedAuthoringVersion, getLoginUserId()));
|
||||
}
|
||||
@PutMapping("/{id}/archive") @PreAuthorize("@ss.hasPermission('education:collection:archive')")
|
||||
public CommonResult<Integer> archive(@PathVariable Long id, @RequestParam int expectedAuthoringVersion) {
|
||||
return success(service.archive(id, expectedAuthoringVersion, getLoginUserId()));
|
||||
}
|
||||
private QuestionCollectionAuthoringCommand toCommand(QuestionCollectionDraftReqVO r, Integer version) {
|
||||
return new QuestionCollectionAuthoringCommand(r.getNodeId(), r.getName(), r.getTitle(), r.getCollectionType(),
|
||||
r.getDurationMinutes(), r.getAccessRules(), r.getSortOrder(), r.getMetadata(), version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class QuestionCollectionDraftReqVO {
|
||||
@NotNull private Long nodeId;
|
||||
@NotBlank @Size(max = 200) private String name;
|
||||
@Size(max = 200) private String title;
|
||||
@NotBlank @Size(max = 50) private String collectionType;
|
||||
@Positive private Integer durationMinutes;
|
||||
private String accessRules;
|
||||
private Integer sortOrder;
|
||||
private String metadata;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QuestionCollectionMembershipReqVO {
|
||||
@NotNull private Integer expectedAuthoringVersion;
|
||||
@NotNull private List<@NotNull Long> questionIds;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class QuestionCollectionReviseReqVO extends QuestionCollectionDraftReqVO {
|
||||
@NotNull private Integer expectedAuthoringVersion;
|
||||
}
|
||||
@@ -30,4 +30,6 @@ public class QuestionCollectionDO extends CatalogScopeDO {
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
private String metadata;
|
||||
private String publicationStatus;
|
||||
private Integer authoringVersion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("education_question_collection_lifecycle_audit")
|
||||
@KeySequence("education_question_collection_lifecycle_audit_seq")
|
||||
@Data @EqualsAndHashCode(callSuper = true)
|
||||
public class QuestionCollectionLifecycleAuditDO extends TenantBaseDO {
|
||||
@TableId private Long id;
|
||||
private Long collectionId;
|
||||
private Integer authoringVersion;
|
||||
private Long actorId;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
@@ -27,6 +27,18 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
ContentNodeDO selectAvailablePlacementTarget(@Param("tenantId") Long tenantId,
|
||||
@Param("nodeId") Long nodeId);
|
||||
|
||||
@Select("""
|
||||
SELECT node.* FROM education_content_node node
|
||||
JOIN education_content_entry entry ON entry.id = node.entry_id
|
||||
WHERE node.id = #{nodeId} AND node.deleted = false
|
||||
AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false
|
||||
AND node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED'
|
||||
AND entry.deleted = false AND entry.is_active = true AND entry.is_hidden = false
|
||||
AND ((entry.tenant_id = #{tenantId} AND entry.scope = 'TENANT_OWNED') OR (entry.tenant_id = 0 AND entry.scope = 'PUBLIC'))
|
||||
FOR SHARE
|
||||
""")
|
||||
ContentNodeDO selectAvailableCollectionTarget(@Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM education_content_entry
|
||||
WHERE id = #{entryId} AND deleted = false AND is_active = true AND is_hidden = false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionLifecycleAuditDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionCollectionLifecycleAuditMapper extends BaseMapperX<QuestionCollectionLifecycleAuditDO> {
|
||||
}
|
||||
@@ -3,20 +3,70 @@ package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollectionDO> {
|
||||
@Select("""
|
||||
SELECT collection.* FROM education_question_collection collection
|
||||
JOIN education_content_node node ON node.id = collection.node_id
|
||||
WHERE collection.id = #{collectionId} AND collection.deleted = false
|
||||
AND collection.publication_status = 'ACTIVE' AND collection.is_active = true AND collection.is_hidden = false
|
||||
AND ((collection.tenant_id = #{tenantId} AND collection.scope = 'TENANT_OWNED') OR (collection.tenant_id = 0 AND collection.scope = 'PUBLIC'))
|
||||
AND node.deleted = false AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED') OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
""")
|
||||
QuestionCollectionDO selectAvailableForStudent(@Param("tenantId") Long tenantId, @Param("collectionId") Long collectionId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT collection.* FROM education_question_collection collection
|
||||
JOIN education_content_node node ON node.id = collection.node_id
|
||||
WHERE collection.deleted = false AND collection.publication_status = 'ACTIVE'
|
||||
AND collection.is_active = true AND collection.is_hidden = false
|
||||
AND ((collection.tenant_id = #{tenantId} AND collection.scope = 'TENANT_OWNED') OR (collection.tenant_id = 0 AND collection.scope = 'PUBLIC'))
|
||||
AND node.deleted = false AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED') OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
<if test="entryId != null">AND collection.entry_id = #{entryId}</if>
|
||||
<if test="nodeId != null">AND collection.node_id = #{nodeId}</if>
|
||||
<if test="collectionType != null and collectionType != ''">AND collection.collection_type = #{collectionType}</if>
|
||||
ORDER BY collection.sort_order, collection.id LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<QuestionCollectionDO> selectAvailableList(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||
@Param("nodeId") Long nodeId, @Param("collectionType") String collectionType, @Param("limit") Integer limit);
|
||||
|
||||
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
|
||||
LambdaQueryWrapperX<QuestionCollectionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionCollectionDO::getTenantId, QuestionCollectionDO::getScope, tenantId);
|
||||
w.eq(QuestionCollectionDO::getIsActive, true).eq(QuestionCollectionDO::getIsHidden, false)
|
||||
.orderByAsc(QuestionCollectionDO::getSortOrder);
|
||||
if (entryId != null) w.eq(QuestionCollectionDO::getEntryId, entryId);
|
||||
if (nodeId != null) w.eq(QuestionCollectionDO::getNodeId, nodeId);
|
||||
if (collectionType != null && !collectionType.isBlank()) w.eq(QuestionCollectionDO::getCollectionType, collectionType);
|
||||
if (limit != null && limit > 0) w.last("LIMIT " + Math.min(limit, 200));
|
||||
return selectList(w);
|
||||
return selectAvailableList(tenantId, entryId, nodeId, collectionType, limit != null && limit > 0 ? Math.min(limit, 200) : 100);
|
||||
}
|
||||
default QuestionCollectionDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionCollectionDO::getDeleted, false));
|
||||
}
|
||||
default int updateDraftCas(Long tenantId, Long id, QuestionCollectionDO values, int expectedVersion) {
|
||||
return update(values, new LambdaUpdateWrapper<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionCollectionDO::getDeleted, false).eq(QuestionCollectionDO::getPublicationStatus, "DRAFT")
|
||||
.eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion)
|
||||
.set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1));
|
||||
}
|
||||
default int advanceMembershipCas(Long tenantId, Long id, int expectedVersion, int count) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionCollectionDO::getDeleted, false).eq(QuestionCollectionDO::getPublicationStatus, "DRAFT")
|
||||
.eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion)
|
||||
.set(QuestionCollectionDO::getQuestionCount, count)
|
||||
.set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1));
|
||||
}
|
||||
default int updateLifecycleCas(Long tenantId, Long id, String from, String to, boolean active, int expectedVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
|
||||
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionCollectionDO::getDeleted, false).eq(QuestionCollectionDO::getPublicationStatus, from)
|
||||
.eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion)
|
||||
.set(QuestionCollectionDO::getPublicationStatus, to).set(QuestionCollectionDO::getIsActive, active)
|
||||
.set(QuestionCollectionDO::getIsHidden, !active).set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionQuestionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionCollectionQuestionMapper extends BaseMapperX<QuestionCollectionQuestionDO> {
|
||||
@Select("""
|
||||
SELECT membership.* FROM education_question_collection_question membership
|
||||
JOIN education_question_collection collection ON collection.id = membership.collection_id
|
||||
JOIN education_content_node node ON node.id = collection.node_id
|
||||
WHERE membership.collection_id = #{collectionId} AND membership.deleted = false
|
||||
AND collection.deleted = false AND collection.publication_status = 'ACTIVE'
|
||||
AND collection.is_active = true AND collection.is_hidden = false
|
||||
AND node.deleted = false AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false
|
||||
AND ((membership.tenant_id = #{tenantId} AND membership.scope = 'TENANT_OWNED') OR (membership.tenant_id = 0 AND membership.scope = 'PUBLIC'))
|
||||
AND ((collection.tenant_id = #{tenantId} AND collection.scope = 'TENANT_OWNED') OR (collection.tenant_id = 0 AND collection.scope = 'PUBLIC'))
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED') OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
ORDER BY membership.sort_order, membership.id
|
||||
""")
|
||||
List<QuestionCollectionQuestionDO> selectAvailableByCollectionId(@Param("tenantId") Long tenantId,
|
||||
@Param("collectionId") Long collectionId);
|
||||
|
||||
default List<QuestionCollectionQuestionDO> selectByCollectionId(Long tenantId, Long collectionId) {
|
||||
LambdaQueryWrapperX<QuestionCollectionQuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionCollectionQuestionDO::getTenantId,
|
||||
QuestionCollectionQuestionDO::getScope, tenantId);
|
||||
return selectList(w.eq(QuestionCollectionQuestionDO::getCollectionId, collectionId)
|
||||
.orderByAsc(QuestionCollectionQuestionDO::getSortOrder)
|
||||
.orderByAsc(QuestionCollectionQuestionDO::getId));
|
||||
return selectAvailableByCollectionId(tenantId, collectionId);
|
||||
}
|
||||
@Delete("DELETE FROM education_question_collection_question WHERE tenant_id=#{tenantId} AND scope='TENANT_OWNED' AND collection_id=#{collectionId}")
|
||||
int deletePhysicalByCollectionId(@Param("tenantId") Long tenantId, @Param("collectionId") Long collectionId);
|
||||
|
||||
@Insert("""
|
||||
<script>INSERT INTO education_question_collection_question
|
||||
(tenant_id, scope, collection_id, question_id, sort_order)
|
||||
VALUES <foreach collection="questionIds" item="questionId" index="index" separator=",">
|
||||
(#{tenantId}, 'TENANT_OWNED', #{collectionId}, #{questionId}, #{index})</foreach></script>
|
||||
""")
|
||||
int insertOrdered(@Param("tenantId") Long tenantId, @Param("collectionId") Long collectionId,
|
||||
@Param("questionIds") List<Long> questionIds);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,17 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
@Param("nodeId") Long nodeId, @Param("type") String type,
|
||||
@Param("difficulty") String difficulty);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*) FROM education_question
|
||||
WHERE deleted = false AND tenant_id = #{tenantId} AND scope = 'TENANT_OWNED'
|
||||
AND status = 'PUBLISHED' AND is_published = true
|
||||
AND id IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
long countTenantOwnedPublishedByIds(@Param("tenantId") Long tenantId, @Param("ids") List<Long> ids);
|
||||
|
||||
default QuestionDO selectPublishedById(Long tenantId, Long id) {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
}
|
||||
|
||||
@@ -114,4 +114,13 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode CONTENT_NODE_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_021, "内容节点不存在或无权管理");
|
||||
ErrorCode CONTENT_NODE_AUTHORING_CONFLICT = new ErrorCode(1_005_002_022, "内容节点已变化,请刷新后重试");
|
||||
ErrorCode CONTENT_NODE_STRUCTURE_INVALID = new ErrorCode(1_005_002_023, "内容节点入口或父节点无效");
|
||||
|
||||
// ========== 手工题集创作 1-005-002-030 ~ 1-005-002-039 ==========
|
||||
ErrorCode QUESTION_COLLECTION_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_030,
|
||||
"当前题库数据源模式不支持题集创作:{}");
|
||||
ErrorCode QUESTION_COLLECTION_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_031, "题集不存在或无权管理");
|
||||
ErrorCode QUESTION_COLLECTION_AUTHORING_CONFLICT = new ErrorCode(1_005_002_032, "题集已变化,请刷新后重试");
|
||||
ErrorCode QUESTION_COLLECTION_NODE_UNAVAILABLE = new ErrorCode(1_005_002_033, "题集内容节点不存在或不可用");
|
||||
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE = new ErrorCode(1_005_002_034, "题集成员包含重复题目");
|
||||
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
public record QuestionCollectionAuthoringCommand(Long nodeId, String name, String title, String collectionType,
|
||||
Integer durationMinutes, String accessRules, Integer sortOrder,
|
||||
String metadata, Integer expectedAuthoringVersion) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface QuestionCollectionAuthoringService {
|
||||
Long createDraft(QuestionCollectionAuthoringCommand command);
|
||||
int reviseDraft(Long collectionId, QuestionCollectionAuthoringCommand command);
|
||||
int replaceMembership(Long collectionId, List<Long> questionIds, int expectedAuthoringVersion);
|
||||
int activate(Long collectionId, int expectedAuthoringVersion, Long actorId);
|
||||
int archive(Long collectionId, int expectedAuthoringVersion, Long actorId);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
@Service
|
||||
public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectionAuthoringService {
|
||||
private final EducationProperties properties;
|
||||
private final QuestionCollectionMapper collectionMapper;
|
||||
private final QuestionCollectionQuestionMapper membershipMapper;
|
||||
private final QuestionCollectionLifecycleAuditMapper auditMapper;
|
||||
private final ContentNodeMapper nodeMapper;
|
||||
private final QuestionMapper questionMapper;
|
||||
|
||||
public QuestionCollectionAuthoringServiceImpl(EducationProperties properties, QuestionCollectionMapper collectionMapper,
|
||||
QuestionCollectionQuestionMapper membershipMapper, QuestionCollectionLifecycleAuditMapper auditMapper,
|
||||
ContentNodeMapper nodeMapper, QuestionMapper questionMapper) {
|
||||
this.properties = properties; this.collectionMapper = collectionMapper; this.membershipMapper = membershipMapper;
|
||||
this.auditMapper = auditMapper; this.nodeMapper = nodeMapper; this.questionMapper = questionMapper;
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionCollectionAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
ContentNodeDO node = availableNode(tenantId, command.nodeId());
|
||||
QuestionCollectionDO collection = new QuestionCollectionDO(); apply(collection, command);
|
||||
collection.setTenantId(tenantId); collection.setScope("TENANT_OWNED"); collection.setEntryId(node.getEntryId());
|
||||
collection.setQuestionCount(0); collection.setPublicationStatus("DRAFT"); collection.setAuthoringVersion(0);
|
||||
collection.setIsActive(false); collection.setIsHidden(true); collectionMapper.insert(collection); return collection.getId();
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int reviseDraft(Long id, QuestionCollectionAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, command.expectedAuthoringVersion());
|
||||
ContentNodeDO node = availableNode(tenantId, command.nodeId()); QuestionCollectionDO update = new QuestionCollectionDO();
|
||||
apply(update, command); update.setEntryId(node.getEntryId());
|
||||
if (collectionMapper.updateDraftCas(tenantId, id, update, command.expectedAuthoringVersion()) != 1) throw exception(QUESTION_COLLECTION_AUTHORING_CONFLICT);
|
||||
return command.expectedAuthoringVersion() + 1;
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int replaceMembership(Long id, List<Long> questionIds, int expectedVersion) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, expectedVersion);
|
||||
List<Long> ids = questionIds != null ? questionIds : List.of();
|
||||
if (new HashSet<>(ids).size() != ids.size()) throw exception(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE);
|
||||
if (!ids.isEmpty() && TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByIds(tenantId, ids)) != ids.size()) {
|
||||
throw exception(QUESTION_COLLECTION_MEMBERSHIP_INVALID);
|
||||
}
|
||||
if (collectionMapper.advanceMembershipCas(tenantId, id, expectedVersion, ids.size()) != 1) throw exception(QUESTION_COLLECTION_AUTHORING_CONFLICT);
|
||||
membershipMapper.deletePhysicalByCollectionId(tenantId, id);
|
||||
if (!ids.isEmpty()) membershipMapper.insertOrdered(tenantId, id, ids);
|
||||
return expectedVersion + 1;
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int activate(Long id, int version, Long actorId) { return transition(id, version, actorId, "DRAFT", "ACTIVE", true); }
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int archive(Long id, int version, Long actorId) { return transition(id, version, actorId, "ACTIVE", "ARCHIVED", false); }
|
||||
|
||||
private int transition(Long id, int version, Long actorId, String from, String to, boolean active) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); QuestionCollectionDO current = requireOwned(tenantId, id);
|
||||
if (!from.equals(current.getPublicationStatus()) || !Objects.equals(current.getAuthoringVersion(), version)) throw exception(QUESTION_COLLECTION_AUTHORING_CONFLICT);
|
||||
if (active) availableNode(tenantId, current.getNodeId());
|
||||
if (collectionMapper.updateLifecycleCas(tenantId, id, from, to, active, version) != 1) throw exception(QUESTION_COLLECTION_AUTHORING_CONFLICT);
|
||||
QuestionCollectionLifecycleAuditDO audit = new QuestionCollectionLifecycleAuditDO(); audit.setTenantId(tenantId);
|
||||
audit.setCollectionId(id); audit.setAuthoringVersion(version + 1); audit.setActorId(actorId);
|
||||
audit.setFromStatus(from); audit.setToStatus(to); audit.setOccurredAt(LocalDateTime.now()); auditMapper.insert(audit);
|
||||
return version + 1;
|
||||
}
|
||||
private QuestionCollectionDO requireOwned(Long tenantId, Long id) {
|
||||
QuestionCollectionDO c = collectionMapper.selectTenantOwnedById(tenantId, id);
|
||||
if (c == null) throw exception(QUESTION_COLLECTION_AUTHORING_NOT_FOUND); return c;
|
||||
}
|
||||
private void requireDraft(Long tenantId, Long id, Integer version) {
|
||||
QuestionCollectionDO c = requireOwned(tenantId, id);
|
||||
if (!"DRAFT".equals(c.getPublicationStatus()) || !Objects.equals(c.getAuthoringVersion(), version)) throw exception(QUESTION_COLLECTION_AUTHORING_CONFLICT);
|
||||
}
|
||||
private ContentNodeDO availableNode(Long tenantId, Long nodeId) {
|
||||
ContentNodeDO node = TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableCollectionTarget(tenantId, nodeId));
|
||||
if (node == null) throw exception(QUESTION_COLLECTION_NODE_UNAVAILABLE); return node;
|
||||
}
|
||||
private void apply(QuestionCollectionDO c, QuestionCollectionAuthoringCommand x) {
|
||||
c.setNodeId(x.nodeId()); c.setName(x.name()); c.setTitle(x.title()); c.setCollectionType(x.collectionType());
|
||||
c.setDurationMinutes(x.durationMinutes()); c.setAccessRules(x.accessRules()); c.setSortOrder(x.sortOrder() != null ? x.sortOrder() : 0); c.setMetadata(x.metadata());
|
||||
}
|
||||
private void assertMode() {
|
||||
if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) throw exception(QUESTION_COLLECTION_PROVIDER_UNSUPPORTED, properties.getCatalogMode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
-- EDU-010: current-tenant manual Question Collection authoring lifecycle.
|
||||
-- JAVA_READ application authority is enforced in Java; database guards protect direct mutation.
|
||||
|
||||
ALTER TABLE education_question_collection
|
||||
ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE education_question_collection
|
||||
SET publication_status = CASE WHEN is_active AND NOT is_hidden THEN 'ACTIVE' ELSE 'ARCHIVED' END,
|
||||
is_active = is_active AND NOT is_hidden,
|
||||
is_hidden = NOT (is_active AND NOT is_hidden),
|
||||
question_count = (SELECT count(*) FROM education_question_collection_question membership
|
||||
WHERE membership.collection_id = education_question_collection.id AND membership.deleted = false);
|
||||
|
||||
ALTER TABLE education_question_collection
|
||||
ADD CONSTRAINT ck_education_question_collection_publication_status CHECK (publication_status IN ('DRAFT','ACTIVE','ARCHIVED')),
|
||||
ADD CONSTRAINT ck_education_question_collection_authoring_version CHECK (authoring_version >= 0),
|
||||
ADD CONSTRAINT ck_education_question_collection_lifecycle_consistent CHECK (
|
||||
(publication_status = 'ACTIVE' AND is_active AND NOT is_hidden) OR
|
||||
(publication_status IN ('DRAFT','ARCHIVED') AND NOT is_active AND is_hidden));
|
||||
|
||||
CREATE TABLE education_question_collection_lifecycle_audit (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL, authoring_version INTEGER NOT NULL,
|
||||
actor_id BIGINT NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
FOREIGN KEY (collection_id) REFERENCES education_question_collection(id),
|
||||
CHECK ((from_status='DRAFT' AND to_status='ACTIVE') OR (from_status='ACTIVE' AND to_status='ARCHIVED')),
|
||||
UNIQUE (tenant_id, collection_id, authoring_version));
|
||||
|
||||
CREATE TABLE education_question_collection_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL,
|
||||
authoring_version INTEGER NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,collection_id,authoring_version,from_status,to_status));
|
||||
REVOKE ALL ON TABLE education_question_collection_lifecycle_transition_token FROM PUBLIC;
|
||||
|
||||
CREATE TABLE education_question_collection_membership_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL, authoring_version INTEGER NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,collection_id,authoring_version));
|
||||
REVOKE ALL ON TABLE education_question_collection_membership_token FROM PUBLIC;
|
||||
|
||||
CREATE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
BEGIN
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'collection cannot start deleted' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.scope='PUBLIC' THEN
|
||||
IF NEW.publication_status<>'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden OR NEW.authoring_version<>0 THEN
|
||||
RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF;
|
||||
ELSIF NEW.scope='TENANT_OWNED' AND (NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.question_count<>0) THEN
|
||||
RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.scope='PUBLIC' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'collection cannot be logically deleted' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived collection is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'collection authoring version must advance exactly once' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status<>'DRAFT' AND (NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.node_id IS DISTINCT FROM OLD.node_id OR
|
||||
NEW.name IS DISTINCT FROM OLD.name OR NEW.title IS DISTINCT FROM OLD.title OR NEW.collection_type IS DISTINCT FROM OLD.collection_type OR
|
||||
NEW.question_count IS DISTINCT FROM OLD.question_count OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR
|
||||
NEW.access_rules IS DISTINCT FROM OLD.access_rules OR NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN
|
||||
RAISE EXCEPTION 'active collection content is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status AND OLD.publication_status='DRAFT' THEN
|
||||
EXECUTE format('INSERT INTO %I.education_question_collection_membership_token VALUES ($1,$2,$3,$4)',TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version;
|
||||
END IF;
|
||||
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
|
||||
IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN
|
||||
RAISE EXCEPTION 'invalid collection lifecycle transition' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('INSERT INTO %I.education_question_collection_lifecycle_transition_token VALUES ($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_enforce_collection_membership_mutation() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; token_count INTEGER;
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'collection membership replacement requires physical delete and insert' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT tenant_id,scope,publication_status,authoring_version FROM %I.education_question_collection WHERE id=$1 FOR UPDATE',TG_TABLE_SCHEMA)
|
||||
INTO c_tenant,c_scope,c_status,c_version USING COALESCE(NEW.collection_id,OLD.collection_id);
|
||||
IF c_status IS DISTINCT FROM 'DRAFT' OR c_scope IS DISTINCT FROM 'TENANT_OWNED' THEN RAISE EXCEPTION 'collection membership is DRAFT-only' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT count(*) FROM %I.education_question_collection_membership_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4',TG_TABLE_SCHEMA)
|
||||
INTO token_count USING pg_current_xact_id()::text::BIGINT,c_tenant,COALESCE(NEW.collection_id,OLD.collection_id),c_version;
|
||||
IF token_count<>1 THEN RAISE EXCEPTION 'collection membership mutation requires transaction token' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
||||
IF NEW.deleted OR NEW.tenant_id IS DISTINCT FROM c_tenant OR NEW.scope IS DISTINCT FROM c_scope THEN RAISE EXCEPTION 'membership ownership must equal collection' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT tenant_id,scope,status,is_published FROM %I.education_question WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA)
|
||||
INTO q_tenant,q_scope,q_status,q_published USING NEW.question_id;
|
||||
IF q_tenant IS DISTINCT FROM c_tenant OR q_scope IS DISTINCT FROM 'TENANT_OWNED' OR q_status IS DISTINCT FROM 'PUBLISHED' OR NOT q_published THEN
|
||||
RAISE EXCEPTION 'membership question must be current-tenant published' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_validate_question_collection_audit() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE c_tenant BIGINT; c_version INTEGER; c_status VARCHAR(16); consumed INTEGER;
|
||||
BEGIN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'collection audit cannot be deleted' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT tenant_id,authoring_version,publication_status FROM %I.education_question_collection WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA)
|
||||
INTO c_tenant,c_version,c_status USING NEW.collection_id;
|
||||
IF c_tenant IS DISTINCT FROM NEW.tenant_id OR c_version IS DISTINCT FROM NEW.authoring_version OR c_status IS DISTINCT FROM NEW.to_status THEN
|
||||
RAISE EXCEPTION 'collection audit must accompany transition' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('DELETE FROM %I.education_question_collection_lifecycle_transition_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4 AND from_status=$5 AND to_status=$6',TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.collection_id,NEW.authoring_version,NEW.from_status,NEW.to_status;
|
||||
GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'collection audit must accompany transition' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_require_question_collection_audit() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEGIN
|
||||
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF;
|
||||
EXECUTE format('SELECT count(*) FROM %I.education_question_collection_lifecycle_audit WHERE tenant_id=$1 AND collection_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false',TG_TABLE_SCHEMA)
|
||||
INTO n USING NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
IF n<>1 THEN RAISE EXCEPTION 'collection lifecycle audit required' USING ERRCODE='23514'; END IF; RETURN NULL; END $$;
|
||||
CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN RAISE EXCEPTION 'collection audit is append-only' USING ERRCODE='23514'; END $$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_collection_authoring() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_validate_question_collection_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_require_question_collection_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() FROM PUBLIC;
|
||||
CREATE TRIGGER trg_education_question_collection_authoring BEFORE INSERT OR UPDATE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_enforce_question_collection_authoring();
|
||||
CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPDATE OR DELETE ON education_question_collection_question FOR EACH ROW EXECUTE FUNCTION education_enforce_collection_membership_mutation();
|
||||
CREATE TRIGGER trg_education_question_collection_audit_validate BEFORE INSERT ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_question_collection_audit();
|
||||
CREATE TRIGGER trg_education_question_collection_audit_immutable BEFORE UPDATE OR DELETE ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_audit_mutation();
|
||||
CREATE CONSTRAINT TRIGGER trg_education_question_collection_audit_required AFTER UPDATE OF publication_status ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_audit();
|
||||
|
||||
DO $$ DECLARE installed INTEGER; BEGIN
|
||||
IF to_regclass('system_menu') IS NULL THEN RETURN; END IF;
|
||||
INSERT INTO system_menu(id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater) VALUES
|
||||
(6806,'题集创作','education:collection:author',3,6,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6807,'题集发布','education:collection:publish',3,7,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6808,'题集归档','education:collection:archive',3,8,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') ON CONFLICT(id) DO NOTHING;
|
||||
SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ((id=6806 AND permission='education:collection:author' AND type=3 AND parent_id=6800) OR (id=6807 AND permission='education:collection:publish' AND type=3 AND parent_id=6800) OR (id=6808 AND permission='education:collection:archive' AND type=3 AND parent_id=6800));
|
||||
IF installed<>3 THEN RAISE EXCEPTION 'Education collection RBAC seed IDs conflict' USING ERRCODE='23505'; END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,76 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection;
|
||||
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionDraftReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionMembershipReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringCommand;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = QuestionCollectionAuthoringControllerContractTest.Config.class)
|
||||
class QuestionCollectionAuthoringControllerContractTest {
|
||||
@jakarta.annotation.Resource QuestionCollectionAuthoringController controller;
|
||||
@jakarta.annotation.Resource RecordingService service;
|
||||
@jakarta.annotation.Resource MutableSecurity security;
|
||||
@BeforeEach void setUp() {
|
||||
security.permissions.clear();
|
||||
LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of()));
|
||||
}
|
||||
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test void permissionsAreIndependentAndActorIsServerDerived() {
|
||||
security.permissions.add("education:collection:author");
|
||||
assertEquals(101L, controller.create(draft()).getData());
|
||||
assertEquals(1, controller.replaceMembership(101L, membership()).getData());
|
||||
assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 1));
|
||||
security.permissions.clear(); security.permissions.add("education:collection:publish");
|
||||
assertThrows(AccessDeniedException.class, () -> controller.create(draft()));
|
||||
assertEquals(2, controller.activate(101L, 1).getData());
|
||||
assertEquals(7L, service.actorId);
|
||||
security.permissions.clear(); security.permissions.add("education:collection:archive");
|
||||
assertEquals(3, controller.archive(101L, 2).getData());
|
||||
}
|
||||
|
||||
private QuestionCollectionDraftReqVO draft() {
|
||||
QuestionCollectionDraftReqVO r = new QuestionCollectionDraftReqVO();
|
||||
r.setNodeId(100L); r.setName("manual-set"); r.setTitle("Manual set"); r.setCollectionType("MANUAL");
|
||||
return r;
|
||||
}
|
||||
private QuestionCollectionMembershipReqVO membership() {
|
||||
QuestionCollectionMembershipReqVO r = new QuestionCollectionMembershipReqVO();
|
||||
r.setExpectedAuthoringVersion(0); r.setQuestionIds(List.of(11L, 12L)); return r;
|
||||
}
|
||||
@Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config {
|
||||
@Bean("ss") MutableSecurity security() { return new MutableSecurity(); }
|
||||
@Bean RecordingService service() { return new RecordingService(); }
|
||||
@Bean QuestionCollectionAuthoringController controller(QuestionCollectionAuthoringService s) { return new QuestionCollectionAuthoringController(s); }
|
||||
}
|
||||
static class MutableSecurity implements SecurityFrameworkService {
|
||||
final Set<String> permissions = new HashSet<>();
|
||||
public boolean hasPermission(String p) { return permissions.contains(p); }
|
||||
public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); }
|
||||
public boolean hasRole(String r) { return false; } public boolean hasAnyRoles(String... r) { return false; }
|
||||
public boolean hasScope(String s) { return false; } public boolean hasAnyScopes(String... s) { return false; }
|
||||
}
|
||||
static class RecordingService implements QuestionCollectionAuthoringService {
|
||||
Long actorId;
|
||||
public Long createDraft(QuestionCollectionAuthoringCommand c) { return 101L; }
|
||||
public int reviseDraft(Long id, QuestionCollectionAuthoringCommand c) { return c.expectedAuthoringVersion() + 1; }
|
||||
public int replaceMembership(Long id, List<Long> q, int v) { return v + 1; }
|
||||
public int activate(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
public int archive(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import java.util.List;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Import({QuestionCollectionAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleServiceImpl.class, EducationProperties.class})
|
||||
@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"})
|
||||
class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
|
||||
@Resource EducationProperties properties;
|
||||
@Resource QuestionCollectionAuthoringService service;
|
||||
@Resource cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService nodeService;
|
||||
@Resource QuestionCollectionMapper collectionMapper;
|
||||
@Resource QuestionCollectionQuestionMapper membershipMapper;
|
||||
@Resource QuestionCollectionLifecycleAuditMapper auditMapper;
|
||||
@Resource ContentNodeLifecycleAuditMapper nodeAuditMapper;
|
||||
@Resource ContentEntryMapper entryMapper;
|
||||
@Resource ContentNodeMapper nodeMapper;
|
||||
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
||||
@Resource QuestionMapper questionMapper;
|
||||
private Long activeNodeId;
|
||||
private List<Long> publishedQuestionIds;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); entry.setScope("TENANT_OWNED");
|
||||
entry.setEntryKey("questions"); entry.setName("Questions"); entry.setEntryType("question"); entryMapper.insert(entry);
|
||||
activeNodeId = nodeService.createDraft(new cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand(
|
||||
100L, null, "Active node", null, "category", null, true, 0, null, null));
|
||||
nodeService.activate(activeNodeId, 0, 7L);
|
||||
Long first = createPublishedQuestion();
|
||||
Long second = createPublishedQuestion();
|
||||
publishedQuestionIds = List.of(first, second);
|
||||
}
|
||||
@AfterEach void clear() { TenantContextHolder.clear(); }
|
||||
|
||||
@Test void draftMembershipActivationAndArchiveGateOnlyCollectionRoute() {
|
||||
Long id = service.createDraft(command(null));
|
||||
assertTrue(collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).isEmpty());
|
||||
assertEquals(1, service.replaceMembership(id, List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), 0));
|
||||
assertEquals(2, service.activate(id, 1, 7L));
|
||||
assertEquals(List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), membershipMapper.selectByCollectionId(10L, id).stream().map(QuestionCollectionQuestionDO::getQuestionId).toList());
|
||||
assertEquals(1, collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).size());
|
||||
assertNotNull(collectionMapper.selectAvailableForStudent(10L, id));
|
||||
assertEquals(2, collectionMapper.selectTenantOwnedById(10L, id).getQuestionCount());
|
||||
assertEquals(3, service.archive(id, 2, 7L));
|
||||
assertNull(collectionMapper.selectAvailableForStudent(10L, id));
|
||||
assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0)));
|
||||
assertEquals(2L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test void duplicateAndIneligibleMembershipFailClosedWithoutChangingVersion() {
|
||||
Long id = service.createDraft(command(null));
|
||||
ServiceException duplicate = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(0), publishedQuestionIds.get(0)), 0));
|
||||
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE.getCode(), duplicate.getCode());
|
||||
Long draftId = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
|
||||
"Draft", "choice", "easy", List.of(
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
|
||||
"A", "Explanation", null));
|
||||
ServiceException invalid = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(draftId), 0));
|
||||
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_INVALID.getCode(), invalid.getCode());
|
||||
assertEquals(0, collectionMapper.selectTenantOwnedById(10L, id).getAuthoringVersion());
|
||||
}
|
||||
|
||||
@Test void unsupportedProviderFailsBeforePersistence() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null)));
|
||||
assertEquals(QUESTION_COLLECTION_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode());
|
||||
assertEquals(0L, collectionMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test void otherTenantCannotManageCollection() {
|
||||
Long id = service.createDraft(command(null));
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
|
||||
}
|
||||
|
||||
@Test void staleAndActiveMutationAreRejected() {
|
||||
Long id = service.createDraft(command(null));
|
||||
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); service.activate(id, 1, 7L);
|
||||
ServiceException active = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(1)), 2));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), active.getCode());
|
||||
ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 1, 7L));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), stale.getCode());
|
||||
}
|
||||
|
||||
private QuestionCollectionAuthoringCommand command(Integer version) {
|
||||
return new QuestionCollectionAuthoringCommand(activeNodeId, "manual", "Manual", "MANUAL", 30, "{\"tier\":\"vip\"}", 0, null, version);
|
||||
}
|
||||
private Long createPublishedQuestion() {
|
||||
Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
|
||||
"Question", "choice", "easy", List.of(
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
|
||||
"A", "Explanation", null));
|
||||
questionService.place(id, new cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand(activeNodeId, 0));
|
||||
questionService.publish(id, 7L);
|
||||
return id;
|
||||
}
|
||||
private QuestionDO question(Long id, Long tenant, String scope, String status, boolean published) {
|
||||
QuestionDO q = new QuestionDO(); q.setId(id); q.setTenantId(tenant); q.setScope(scope); q.setContentVersion(1);
|
||||
q.setStem("Question " + id); q.setType("choice"); q.setOptions("[{\"label\":\"A\",\"content\":\"Yes\"}]");
|
||||
q.setStatus(status); q.setIsPublished(published); q.setPlacementVersion(0); return q;
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
-- PostgreSQL persistence test cleanup.
|
||||
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
|
||||
TRUNCATE TABLE
|
||||
education_question_collection_lifecycle_audit,
|
||||
education_content_node_lifecycle_audit,
|
||||
education_question_lifecycle_transition_token,
|
||||
education_question_lifecycle_audit,
|
||||
education_question_version,
|
||||
education_question_collection_question,
|
||||
education_question_collection,
|
||||
education_question,
|
||||
education_idempotency,
|
||||
education_favorite,
|
||||
|
||||
Reference in New Issue
Block a user