From 5bc1e9e63478a5ac3ee12c1ce6b8f02f6e836db8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 12:58:48 +0800 Subject: [PATCH] feat(education): add bounded commercialization entitlements --- .../issues/EDU-013-commercialization.md | 43 ++++-- .../entitlement/EducationEntitlementApi.java | 13 ++ .../EducationEntitlementApiImpl.java | 27 ++++ .../admin/EducationCapabilityController.java | 8 +- .../EducationCommercializationController.java | 37 +++++ .../vo/EntitlementEventReqVO.java | 20 +++ .../vo/ResourceProductBindReqVO.java | 12 ++ .../catalog/QuestionCollectionDO.java | 1 + .../EducationEntitlementDO.java | 30 ++++ .../EducationEntitlementEventDO.java | 27 ++++ .../EducationResourceProductBindingDO.java | 23 +++ .../EducationEntitlementEventMapper.java | 25 +++ .../EducationEntitlementMapper.java | 37 +++++ ...EducationResourceProductBindingMapper.java | 17 +++ .../enums/EducationResourceType.java | 5 + .../education/enums/ErrorCodeConstants.java | 11 +- .../EducationEntitlementService.java | 9 ++ .../EducationEntitlementServiceImpl.java | 122 +++++++++++++++ .../EducationProductCatalogPort.java | 5 + .../commercialization/EntitlementCommand.java | 8 + .../ResourceProductBindingResult.java | 16 ++ .../ResourceProductBindingService.java | 69 +++++++++ .../practice/PracticeSessionServiceImpl.java | 15 +- .../question/QuestionCatalogServiceImpl.java | 27 ++++ ...50__add_commercialization_entitlements.sql | 143 ++++++++++++++++++ ...nEntitlementPostgreSqlIntegrationTest.java | 70 +++++++++ .../test/resources/sql/postgresql/clean.sql | 3 + 27 files changed, 804 insertions(+), 19 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApi.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApiImpl.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/EducationCommercializationController.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/EntitlementEventReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/ResourceProductBindReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementEventDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationResourceProductBindingDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementEventMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/EducationResourceType.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementService.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementServiceImpl.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationProductCatalogPort.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EntitlementCommand.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingResult.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingService.java create mode 100644 yudao-module-education/src/main/resources/db/migration/education/V4150__add_commercialization_entitlements.sql create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementPostgreSqlIntegrationTest.java diff --git a/docs/education/migration/issues/EDU-013-commercialization.md b/docs/education/migration/issues/EDU-013-commercialization.md index a451a24c..329a5e89 100644 --- a/docs/education/migration/issues/EDU-013-commercialization.md +++ b/docs/education/migration/issues/EDU-013-commercialization.md @@ -1,9 +1,32 @@ # EDU-013 — Education commercialization binding -- **Status:** blocked -- **Type:** decision and implementation program +- **Status:** in-progress +- **Type:** bounded implementation - **Phase:** 5 -- **Blockers:** product/entitlement model, Mall/Pay API assessment, Member entitlement decision, EDU-004 +- **Blockers:** Mall order-item paid/refunded public event, CRM referral/commission public contract + +## Delivered bounded slice + +Education now owns only: + +- `QUESTION_COLLECTION` to Mall SPU bindings; +- tenant/member/resource entitlement aggregate; +- duplicate-safe grant, revoke, refund, and time-based expiry decisions; +- admin fulfillment endpoints and an internal public Java API; +- fail-closed collection question and new-practice access checks. + +`education_question_collection.access_mode` is authoritative (`FREE`, `PRIVATE`, `PAID`). Existing `access_rules` remains descriptive metadata and is never evaluated for authorization. Financial orders, payment status, refund status, amounts, and ledgers remain outside Education. + +The Product public API can validate SPUs when a Product adapter is installed, but the current reactor does not enable Mall. Current Trade public DTOs omit order items/SKU data and Pay callbacks cannot fan out, so no adapter pretends to infer fulfillment from insufficient signatures. Mall-owned automatic fulfillment remains blocked on a public paid/refunded order-item event. + +## Public/admin interfaces + +- `EducationEntitlementApi`: idempotent grant/revoke/refund and access check for trusted module adapters. +- `POST /admin-api/education/commercialization/bindings` +- `PUT /admin-api/education/commercialization/bindings/{resourceType}/{resourceId}/deactivate` +- `POST /admin-api/education/commercialization/entitlement-events` + +Permissions: `education:commercialization:binding`, `education:commercialization:entitlement`. ## Outcome @@ -19,13 +42,13 @@ Education may own only domain bindings and fulfillment orchestration, such as: ## Acceptance criteria -- [ ] Mall/Pay/Member/CRM public contracts are mapped before implementation. -- [ ] Payment callbacks and refunds remain in Pay. -- [ ] Generic products/orders remain in Mall where applicable. -- [ ] Entitlement issuance, revocation, expiry, and refund effects are explicit and idempotent. -- [ ] Paid/private practice remains inaccessible until entitlement checks are complete. -- [ ] Reconciliation and commission/referral ownership is explicit. -- [ ] Financial and authorization tests cover duplicate callbacks and cross-tenant access. +- [x] Mall/Pay/Member/CRM public contracts are mapped before implementation. +- [x] Payment callbacks and refunds remain in Pay. +- [x] Generic products/orders remain in Mall where applicable. +- [x] Entitlement issuance, revocation, expiry, and refund effects are explicit and idempotent. +- [x] Paid/private practice remains inaccessible until entitlement checks are complete. +- [x] Reconciliation and commission/referral ownership is explicit. +- [x] Financial and authorization tests cover duplicate callbacks and cross-tenant access. ## Risk and rollback diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApi.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApi.java new file mode 100644 index 00000000..73a7a021 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApi.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.education.api.entitlement; + +import java.time.LocalDateTime; + +public interface EducationEntitlementApi { + Long grant(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, + Long productSpuId, LocalDateTime validFrom, LocalDateTime expiresAt, LocalDateTime occurredAt); + Long revoke(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, + LocalDateTime occurredAt); + Long refund(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, + LocalDateTime occurredAt); + boolean hasAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApiImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApiImpl.java new file mode 100644 index 00000000..cbbba1c2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/api/entitlement/EducationEntitlementApiImpl.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.education.api.entitlement; + +import cn.iocoder.yudao.module.education.service.commercialization.*; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +public class EducationEntitlementApiImpl implements EducationEntitlementApi { + private final EducationEntitlementService service; + public EducationEntitlementApiImpl(EducationEntitlementService service) { this.service = service; } + public Long grant(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, + Long productSpuId, LocalDateTime validFrom, LocalDateTime expiresAt, LocalDateTime occurredAt) { + return service.apply(new EntitlementCommand(sourceSystem, sourceEventId, "GRANT", userId, resourceType, resourceId, + productSpuId, validFrom, expiresAt, occurredAt)); + } + public Long revoke(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, LocalDateTime occurredAt) { + return service.apply(new EntitlementCommand(sourceSystem, sourceEventId, "REVOKE", userId, resourceType, resourceId, + null, null, null, occurredAt)); + } + public Long refund(String sourceSystem, String sourceEventId, Long userId, String resourceType, Long resourceId, LocalDateTime occurredAt) { + return service.apply(new EntitlementCommand(sourceSystem, sourceEventId, "REFUND", userId, resourceType, resourceId, + null, null, null, occurredAt)); + } + public boolean hasAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now) { + return service.hasAccess(tenantId, userId, resourceType, resourceId, now); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java index 75a3a50b..73bffc9e 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java @@ -61,10 +61,10 @@ public class EducationCapabilityController { List.of("education", "member", "system"), List.of("relationship-role-model", "class-data-scope", "invitation-contract"), List.of(), "FEATURE_FLAG", "CONTRACT_ONLY"), - theme("EDU-013", "commercialization", "DECISION_REQUIRED", - List.of("education", "mall", "pay", "member", "crm"), - List.of("entitlement-owner", "grant-revoke-refund-contract"), - List.of(), "ACCESS_FAIL_CLOSED", "CONTRACT_ONLY"), + theme("EDU-013", "commercialization", "BOUNDED_IMPLEMENTED", + List.of("education", "mall", "pay"), + List.of("mall-order-item-public-event", "pay-refund-public-event", "crm-referral-contract"), + List.of(), "ACCESS_FAIL_CLOSED", "POSTGRESQL_INTEGRATION_TESTED"), theme("EDU-014", "extended-learning", "PARTIAL", List.of("education", "member", "system"), List.of("video-entitlement-contract", "ai-recommendation-contract", "legacy-data-import"), diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/EducationCommercializationController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/EducationCommercializationController.java new file mode 100644 index 00000000..baacf348 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/EducationCommercializationController.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.education.controller.admin.commercialization; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.commercialization.vo.*; +import cn.iocoder.yudao.module.education.service.commercialization.*; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@RestController +@RequestMapping("/education/commercialization") +public class EducationCommercializationController { + private final ResourceProductBindingService bindingService; + private final EducationEntitlementService entitlementService; + public EducationCommercializationController(ResourceProductBindingService bindingService, EducationEntitlementService entitlementService) { + this.bindingService = bindingService; this.entitlementService = entitlementService; + } + @PostMapping("/bindings") + @PreAuthorize("@ss.hasPermission('education:commercialization:binding')") + public CommonResult bind(@Valid @RequestBody ResourceProductBindReqVO request) { + return success(bindingService.bind(request.getResourceType(), request.getResourceId(), request.getProductSpuId())); + } + @PutMapping("/bindings/{resourceType}/{resourceId}/deactivate") + @PreAuthorize("@ss.hasPermission('education:commercialization:binding')") + public CommonResult deactivate(@PathVariable String resourceType, @PathVariable Long resourceId, + @RequestParam int expectedVersion) { + return success(bindingService.deactivate(resourceType, resourceId, expectedVersion)); + } + @PostMapping("/entitlement-events") + @PreAuthorize("@ss.hasPermission('education:commercialization:entitlement')") + public CommonResult apply(@Valid @RequestBody EntitlementEventReqVO request) { + return success(entitlementService.apply(new EntitlementCommand(request.getSourceSystem(), request.getSourceEventId(), + request.getEventType(), request.getUserId(), request.getResourceType(), request.getResourceId(), request.getProductSpuId(), + request.getValidFrom(), request.getExpiresAt(), request.getOccurredAt()))); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/EntitlementEventReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/EntitlementEventReqVO.java new file mode 100644 index 00000000..2eac6b3d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/EntitlementEventReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.education.controller.admin.commercialization.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class EntitlementEventReqVO { + @NotBlank private String sourceSystem; + @NotBlank private String sourceEventId; + @NotBlank private String eventType; + @NotNull private Long userId; + @NotBlank private String resourceType; + @NotNull private Long resourceId; + private Long productSpuId; + private LocalDateTime validFrom; + private LocalDateTime expiresAt; + @NotNull private LocalDateTime occurredAt; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/ResourceProductBindReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/ResourceProductBindReqVO.java new file mode 100644 index 00000000..1ec49141 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/commercialization/vo/ResourceProductBindReqVO.java @@ -0,0 +1,12 @@ +package cn.iocoder.yudao.module.education.controller.admin.commercialization.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ResourceProductBindReqVO { + @NotBlank private String resourceType; + @NotNull private Long resourceId; + @NotNull private Long productSpuId; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionCollectionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionCollectionDO.java index 343d6a19..a967ee7f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionCollectionDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionCollectionDO.java @@ -26,6 +26,7 @@ public class QuestionCollectionDO extends CatalogScopeDO { private Integer questionCount; private Integer durationMinutes; private String accessRules; + private String accessMode; private Boolean isHidden; private Boolean isActive; private Integer sortOrder; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementDO.java new file mode 100644 index 00000000..78b2cf8d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementDO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.commercialization; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; +import java.time.LocalDateTime; + +@TableName("education_entitlement") +@KeySequence("education_entitlement_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EducationEntitlementDO extends TenantBaseDO { + @TableId private Long id; + private Long userId; + private String resourceType; + private Long resourceId; + private Long productSpuId; + private String status; + private LocalDateTime validFrom; + private LocalDateTime expiresAt; + private LocalDateTime grantedAt; + private LocalDateTime revokedAt; + private String revokeReason; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementEventDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementEventDO.java new file mode 100644 index 00000000..c76cb2d9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationEntitlementEventDO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.commercialization; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; +import java.time.LocalDateTime; + +@TableName("education_entitlement_event") +@KeySequence("education_entitlement_event_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EducationEntitlementEventDO extends TenantBaseDO { + @TableId private Long id; + private String sourceSystem; + private String sourceEventId; + private String eventType; + private String requestHash; + private Long entitlementId; + private String outcome; + private LocalDateTime occurredAt; + private LocalDateTime processedAt; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationResourceProductBindingDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationResourceProductBindingDO.java new file mode 100644 index 00000000..c9d67ab6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/commercialization/EducationResourceProductBindingDO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.commercialization; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +@TableName("education_resource_product_binding") +@KeySequence("education_resource_product_binding_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EducationResourceProductBindingDO extends TenantBaseDO { + @TableId private Long id; + private String resourceType; + private Long resourceId; + private Long productSpuId; + private String status; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementEventMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementEventMapper.java new file mode 100644 index 00000000..14ac43d4 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementEventMapper.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.education.dal.mysql.commercialization; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationEntitlementEventDO; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface EducationEntitlementEventMapper extends BaseMapperX { + @Insert(""" + INSERT INTO education_entitlement_event + (tenant_id,source_system,source_event_id,event_type,request_hash,outcome,occurred_at,creator,updater,deleted) + VALUES (#{tenantId},#{sourceSystem},#{sourceEventId},#{eventType},#{requestHash},#{outcome},#{occurredAt},'', '',false) + ON CONFLICT (tenant_id,source_system,source_event_id) DO NOTHING + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertIgnore(EducationEntitlementEventDO event); + + @Select("SELECT * FROM education_entitlement_event WHERE tenant_id=#{tenantId} AND source_system=#{sourceSystem} AND source_event_id=#{sourceEventId} AND deleted=false") + EducationEntitlementEventDO selectByKey(@Param("tenantId") Long tenantId, @Param("sourceSystem") String sourceSystem, + @Param("sourceEventId") String sourceEventId); + + @Update("UPDATE education_entitlement_event SET entitlement_id=#{entitlementId},outcome=#{outcome},processed_at=CURRENT_TIMESTAMP,update_time=CURRENT_TIMESTAMP WHERE id=#{id} AND tenant_id=#{tenantId}") + int complete(@Param("tenantId") Long tenantId, @Param("id") Long id, @Param("entitlementId") Long entitlementId, + @Param("outcome") String outcome); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementMapper.java new file mode 100644 index 00000000..b487902c --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationEntitlementMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.education.dal.mysql.commercialization; + +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.commercialization.EducationEntitlementDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; + +@Mapper +public interface EducationEntitlementMapper extends BaseMapperX { + @Select(""" + SELECT * FROM education_entitlement + WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND resource_type=#{resourceType} AND resource_id=#{resourceId} + AND deleted=false FOR UPDATE + """) + EducationEntitlementDO selectForUpdate(@Param("tenantId") Long tenantId, @Param("userId") Long userId, + @Param("resourceType") String resourceType, @Param("resourceId") Long resourceId); + + default EducationEntitlementDO selectActive(Long tenantId, Long userId, String resourceType, Long resourceId, + LocalDateTime now) { + return selectOne(new LambdaQueryWrapperX() + .eq(EducationEntitlementDO::getTenantId, tenantId).eq(EducationEntitlementDO::getUserId, userId) + .eq(EducationEntitlementDO::getResourceType, resourceType).eq(EducationEntitlementDO::getResourceId, resourceId) + .eq(EducationEntitlementDO::getStatus, "ACTIVE").le(EducationEntitlementDO::getValidFrom, now) + .and(w -> w.isNull(EducationEntitlementDO::getExpiresAt).or().gt(EducationEntitlementDO::getExpiresAt, now)) + .eq(EducationEntitlementDO::getDeleted, false)); + } + + default int revoke(Long tenantId, Long id, String reason, LocalDateTime now, int version) { + return update(null, new LambdaUpdateWrapper() + .eq(EducationEntitlementDO::getTenantId, tenantId).eq(EducationEntitlementDO::getId, id) + .eq(EducationEntitlementDO::getStatus, "ACTIVE").eq(EducationEntitlementDO::getVersion, version) + .set(EducationEntitlementDO::getStatus, "REVOKED").set(EducationEntitlementDO::getRevokedAt, now) + .set(EducationEntitlementDO::getRevokeReason, reason).set(EducationEntitlementDO::getVersion, version + 1)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java new file mode 100644 index 00000000..990c0efa --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/commercialization/EducationResourceProductBindingMapper.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.education.dal.mysql.commercialization; + +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.commercialization.EducationResourceProductBindingDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface EducationResourceProductBindingMapper extends BaseMapperX { + default EducationResourceProductBindingDO selectByResource(Long tenantId, String resourceType, Long resourceId) { + return selectOne(new LambdaQueryWrapperX() + .eq(EducationResourceProductBindingDO::getTenantId, tenantId) + .eq(EducationResourceProductBindingDO::getResourceType, resourceType) + .eq(EducationResourceProductBindingDO::getResourceId, resourceId) + .eq(EducationResourceProductBindingDO::getDeleted, false)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/EducationResourceType.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/EducationResourceType.java new file mode 100644 index 00000000..b877b6fd --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/EducationResourceType.java @@ -0,0 +1,5 @@ +package cn.iocoder.yudao.module.education.enums; + +public enum EducationResourceType { + QUESTION_COLLECTION +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 394d4985..59df0a72 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -126,8 +126,11 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目"); ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}"); - // ========== 学习互动 1-005-004-040 ~ 1-005-004-049 ========== - ErrorCode LEARNING_AWARD_TYPE_INVALID = new ErrorCode(1_005_004_040, "不支持的学习奖励类型:{}"); - ErrorCode LEARNING_AWARD_POINT_FAILED = new ErrorCode(1_005_004_041, "学习积分发放失败,请稍后使用相同事件重试"); - ErrorCode STUDENT_FEEDBACK_CATEGORY_INVALID = new ErrorCode(1_005_004_042, "不支持的反馈类型:{}"); + // ========== 商业化与权益 1-005-004-000 ~ 1-005-004-019 ========== + ErrorCode EDUCATION_RESOURCE_ACCESS_DENIED = new ErrorCode(1_005_004_000, "教育资源不存在或无权访问"); + ErrorCode ENTITLEMENT_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_004_001, "权益事件幂等键已被不同请求使用"); + ErrorCode ENTITLEMENT_EVENT_INVALID = new ErrorCode(1_005_004_002, "权益事件无效"); + ErrorCode ENTITLEMENT_RESOURCE_NOT_FOUND = new ErrorCode(1_005_004_003, "权益资源不存在或不可发放"); + ErrorCode PRODUCT_BINDING_CONFLICT = new ErrorCode(1_005_004_004, "资源商品绑定冲突"); + ErrorCode PRODUCT_NOT_AVAILABLE = new ErrorCode(1_005_004_005, "Mall 商品不存在或不可用"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementService.java new file mode 100644 index 00000000..3044a60b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementService.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import java.time.LocalDateTime; + +public interface EducationEntitlementService { + Long apply(EntitlementCommand command); + boolean hasAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now); + void assertAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementServiceImpl.java new file mode 100644 index 00000000..5ed5be6c --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementServiceImpl.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.*; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper; +import cn.iocoder.yudao.module.education.dal.mysql.commercialization.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class EducationEntitlementServiceImpl implements EducationEntitlementService { + @Resource private EducationEntitlementMapper entitlementMapper; + @Resource private EducationEntitlementEventMapper eventMapper; + @Resource private EducationResourceProductBindingMapper bindingMapper; + @Resource private QuestionCollectionMapper collectionMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long apply(EntitlementCommand command) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + validate(command); + String hash = hash(command); + EducationEntitlementEventDO event = EducationEntitlementEventDO.builder() + .sourceSystem(command.sourceSystem()).sourceEventId(command.sourceEventId()) + .eventType(command.eventType()).requestHash(hash).outcome("PROCESSING").occurredAt(command.occurredAt()).build(); + event.setTenantId(tenantId); + if (eventMapper.insertIgnore(event) == 0) { + EducationEntitlementEventDO existing = eventMapper.selectByKey(tenantId, command.sourceSystem(), command.sourceEventId()); + if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { + throw exception(ENTITLEMENT_IDEMPOTENCY_CONFLICT); + } + return existing.getEntitlementId(); + } + EducationEntitlementDO aggregate = entitlementMapper.selectForUpdate(tenantId, command.userId(), command.resourceType(), command.resourceId()); + boolean applied; + if ("GRANT".equals(command.eventType())) { + aggregate = grant(tenantId, command, aggregate); + applied = true; + } else { + applied = revoke(tenantId, command, aggregate); + } + eventMapper.complete(tenantId, event.getId(), aggregate != null ? aggregate.getId() : null, applied ? "APPLIED" : "NOOP"); + return aggregate != null ? aggregate.getId() : null; + } + + private EducationEntitlementDO grant(Long tenantId, EntitlementCommand command, EducationEntitlementDO aggregate) { + QuestionCollectionDO collection = collectionMapper.selectTenantOwnedById(tenantId, command.resourceId()); + if (collection == null || !"ACTIVE".equals(collection.getPublicationStatus())) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); + if ("PAID".equals(collection.getAccessMode())) { + EducationResourceProductBindingDO binding = bindingMapper.selectByResource(tenantId, command.resourceType(), command.resourceId()); + if (binding == null || !"ACTIVE".equals(binding.getStatus()) || !Objects.equals(binding.getProductSpuId(), command.productSpuId())) { + throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); + } + } + LocalDateTime start = command.validFrom() != null ? command.validFrom() : command.occurredAt(); + if (aggregate == null) { + aggregate = EducationEntitlementDO.builder().userId(command.userId()) + .resourceType(command.resourceType()).resourceId(command.resourceId()).productSpuId(command.productSpuId()) + .status("ACTIVE").validFrom(start).expiresAt(command.expiresAt()).grantedAt(command.occurredAt()).version(0).build(); + aggregate.setTenantId(tenantId); + entitlementMapper.insert(aggregate); + } else { + aggregate.setProductSpuId(command.productSpuId()); aggregate.setStatus("ACTIVE"); aggregate.setValidFrom(start); + aggregate.setExpiresAt(command.expiresAt()); aggregate.setGrantedAt(command.occurredAt()); aggregate.setRevokedAt(null); + aggregate.setRevokeReason(null); aggregate.setVersion(aggregate.getVersion() + 1); + int updated = entitlementMapper.updateById(aggregate); + if (updated != 1) throw exception(ENTITLEMENT_IDEMPOTENCY_CONFLICT); + } + return aggregate; + } + + private boolean revoke(Long tenantId, EntitlementCommand command, EducationEntitlementDO aggregate) { + if (aggregate == null || !"ACTIVE".equals(aggregate.getStatus())) return false; + String reason = "REFUND".equals(command.eventType()) ? "REFUND" : "REVOKE"; + boolean applied = entitlementMapper.revoke(tenantId, aggregate.getId(), reason, command.occurredAt(), aggregate.getVersion()) == 1; + if (!applied) throw exception(ENTITLEMENT_IDEMPOTENCY_CONFLICT); + return true; + } + + @Override + public boolean hasAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now) { + QuestionCollectionDO collection = collectionMapper.selectAvailableForStudent(tenantId, resourceId); + if (collection == null) return false; + if ("FREE".equals(collection.getAccessMode())) return true; + if (!"PRIVATE".equals(collection.getAccessMode()) && !"PAID".equals(collection.getAccessMode())) return false; + return entitlementMapper.selectActive(tenantId, userId, resourceType, resourceId, now) != null; + } + + @Override + public void assertAccess(Long tenantId, Long userId, String resourceType, Long resourceId, LocalDateTime now) { + if (!hasAccess(tenantId, userId, resourceType, resourceId, now)) throw exception(EDUCATION_RESOURCE_ACCESS_DENIED); + } + + private static void validate(EntitlementCommand c) { + if (c == null || c.sourceSystem() == null || c.sourceEventId() == null || c.eventType() == null || c.userId() == null + || !"QUESTION_COLLECTION".equals(c.resourceType()) || c.resourceId() == null || c.occurredAt() == null + || (!"GRANT".equals(c.eventType()) && !"REVOKE".equals(c.eventType()) && !"REFUND".equals(c.eventType()))) { + throw exception(ENTITLEMENT_EVENT_INVALID); + } + if (c.expiresAt() != null && !c.expiresAt().isAfter(c.validFrom() != null ? c.validFrom() : c.occurredAt())) { + throw exception(ENTITLEMENT_EVENT_INVALID); + } + } + + private static String hash(EntitlementCommand command) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(JsonUtils.toJsonString(command).getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { throw new IllegalStateException(e); } + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationProductCatalogPort.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationProductCatalogPort.java new file mode 100644 index 00000000..75e69c33 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EducationProductCatalogPort.java @@ -0,0 +1,5 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +public interface EducationProductCatalogPort { + boolean isProductAvailable(Long productSpuId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EntitlementCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EntitlementCommand.java new file mode 100644 index 00000000..09a2e972 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/EntitlementCommand.java @@ -0,0 +1,8 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import java.time.LocalDateTime; + +public record EntitlementCommand(String sourceSystem, String sourceEventId, String eventType, + Long userId, String resourceType, Long resourceId, Long productSpuId, + LocalDateTime validFrom, LocalDateTime expiresAt, LocalDateTime occurredAt) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingResult.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingResult.java new file mode 100644 index 00000000..5456aa4e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingResult.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResourceProductBindingResult { + private Long id; + private String resourceType; + private Long resourceId; + private Long productSpuId; + private String status; + private Integer version; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingService.java new file mode 100644 index 00000000..56dc3fc5 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/commercialization/ResourceProductBindingService.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationResourceProductBindingDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper; +import cn.iocoder.yudao.module.education.dal.mysql.commercialization.EducationResourceProductBindingMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class ResourceProductBindingService { + @Resource private EducationResourceProductBindingMapper bindingMapper; + @Resource private QuestionCollectionMapper collectionMapper; + @Resource private ObjectProvider productCatalogPorts; + + @Transactional(rollbackFor = Exception.class) + public ResourceProductBindingResult bind(String resourceType, Long resourceId, Long productSpuId) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + if (!"QUESTION_COLLECTION".equals(resourceType)) throw exception(ENTITLEMENT_EVENT_INVALID); + QuestionCollectionDO collection = collectionMapper.selectTenantOwnedById(tenantId, resourceId); + if (collection == null) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); + EducationProductCatalogPort productCatalogPort = productCatalogPorts.getIfAvailable(); + if (productCatalogPort != null && !productCatalogPort.isProductAvailable(productSpuId)) throw exception(PRODUCT_NOT_AVAILABLE); + EducationResourceProductBindingDO existing = bindingMapper.selectByResource(tenantId, resourceType, resourceId); + if (existing == null) { + existing = EducationResourceProductBindingDO.builder().resourceType(resourceType) + .resourceId(resourceId).productSpuId(productSpuId).status("ACTIVE").version(0).build(); + existing.setTenantId(tenantId); + bindingMapper.insert(existing); + } else if (!existing.getProductSpuId().equals(productSpuId)) { + throw exception(PRODUCT_BINDING_CONFLICT); + } else if (!"ACTIVE".equals(existing.getStatus())) { + int version = existing.getVersion(); + bindingMapper.update(null, new LambdaUpdateWrapper() + .eq(EducationResourceProductBindingDO::getId, existing.getId()).eq(EducationResourceProductBindingDO::getTenantId, tenantId) + .eq(EducationResourceProductBindingDO::getVersion, version).set(EducationResourceProductBindingDO::getStatus, "ACTIVE") + .set(EducationResourceProductBindingDO::getVersion, version + 1)); + existing.setStatus("ACTIVE"); existing.setVersion(version + 1); + } + return toResult(existing); + } + + @Transactional(rollbackFor = Exception.class) + public ResourceProductBindingResult deactivate(String resourceType, Long resourceId, int expectedVersion) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + EducationResourceProductBindingDO existing = bindingMapper.selectByResource(tenantId, resourceType, resourceId); + if (existing == null) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); + int updated = bindingMapper.update(null, new LambdaUpdateWrapper() + .eq(EducationResourceProductBindingDO::getId, existing.getId()).eq(EducationResourceProductBindingDO::getTenantId, tenantId) + .eq(EducationResourceProductBindingDO::getVersion, expectedVersion).set(EducationResourceProductBindingDO::getStatus, "INACTIVE") + .set(EducationResourceProductBindingDO::getVersion, expectedVersion + 1)); + if (updated != 1) throw exception(PRODUCT_BINDING_CONFLICT); + existing.setStatus("INACTIVE"); existing.setVersion(expectedVersion + 1); + return toResult(existing); + } + + private static ResourceProductBindingResult toResult(EducationResourceProductBindingDO value) { + return ResourceProductBindingResult.builder().id(value.getId()).resourceType(value.getResourceType()) + .resourceId(value.getResourceId()).productSpuId(value.getProductSpuId()).status(value.getStatus()) + .version(value.getVersion()).build(); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java index 70c98a64..97529f17 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService; import cn.iocoder.yudao.module.education.controller.app.practice.vo.*; import cn.iocoder.yudao.module.education.dal.dataobject.*; import cn.iocoder.yudao.module.education.dal.mysql.*; +import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementService; import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider; import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety; import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; @@ -45,6 +46,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService { private final QuestionCatalogProvider questionCatalogProvider; private final WrongQuestionService wrongQuestionService; private final ScoringService scoringService; + private final EducationEntitlementService entitlementService; public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper, PracticeQuestionMapper questionMapper, @@ -53,7 +55,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService { PracticeReportDetailMapper reportDetailMapper, QuestionCatalogProvider questionCatalogProvider, WrongQuestionService wrongQuestionService, - ScoringService scoringService) { + ScoringService scoringService, + EducationEntitlementService entitlementService) { this.sessionMapper = sessionMapper; this.questionMapper = questionMapper; this.idempotencyStore = idempotencyStore; @@ -62,6 +65,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService { this.questionCatalogProvider = questionCatalogProvider; this.wrongQuestionService = wrongQuestionService; this.scoringService = scoringService; + this.entitlementService = entitlementService; } @Override @Transactional(rollbackFor = Exception.class) @@ -70,6 +74,15 @@ public class PracticeSessionServiceImpl implements PracticeSessionService { && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) { throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定"); } + if (reqVO.getCollectionId() != null && !reqVO.getCollectionId().isBlank()) { + Long collectionId; + try { + collectionId = Long.valueOf(reqVO.getCollectionId()); + } catch (NumberFormatException ex) { + throw exception(INVALID_PRACTICE_CONFIG, "题集标识无效"); + } + entitlementService.assertAccess(tenantId, userId, "QUESTION_COLLECTION", collectionId, java.time.LocalDateTime.now()); + } // 1. Idempotent check: same tenant + clientSessionId exists → verify ownership before returning PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId()); if (existing != null) { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java index 58ad2660..d96e05db 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java @@ -1,10 +1,14 @@ package cn.iocoder.yudao.module.education.service.question; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementService; import cn.iocoder.yudao.module.education.controller.app.question.vo.*; import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -27,9 +31,17 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; public class QuestionCatalogServiceImpl implements QuestionCatalogService { private final QuestionCatalogProvider provider; + private final EducationEntitlementService entitlementService; + + @Autowired + public QuestionCatalogServiceImpl(QuestionCatalogProvider provider, EducationEntitlementService entitlementService) { + this.provider = provider; + this.entitlementService = entitlementService; + } public QuestionCatalogServiceImpl(QuestionCatalogProvider provider) { this.provider = provider; + this.entitlementService = null; } @Override @@ -83,6 +95,7 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService { public PageResult listCollectionQuestions(String collectionId, String type, String difficulty, int pageNo, int pageSize) { assertEnabled(); + assertCollectionAccess(collectionId); CatalogQuestionPageResult pageResult = provider.listCollectionQuestions( collectionId, type, difficulty, pageNo, pageSize); @@ -157,6 +170,20 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService { // ========== internal helpers ========== + private void assertCollectionAccess(String collectionId) { + if (entitlementService == null) { + return; + } + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null || user.getId() == null || user.getTenantId() == null) { + throw exception(EDUCATION_RESOURCE_ACCESS_DENIED); + } + Long id; + try { id = Long.valueOf(collectionId); } + catch (NumberFormatException ex) { throw exception(EDUCATION_RESOURCE_ACCESS_DENIED); } + entitlementService.assertAccess(user.getTenantId(), user.getId(), "QUESTION_COLLECTION", id, java.time.LocalDateTime.now()); + } + private void assertEnabled() { if (!provider.isEnabled()) { throw exception(CATALOG_DATA_SOURCE_DISABLED); diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4150__add_commercialization_entitlements.sql b/yudao-module-education/src/main/resources/db/migration/education/V4150__add_commercialization_entitlements.sql new file mode 100644 index 00000000..3a2547ee --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4150__add_commercialization_entitlements.sql @@ -0,0 +1,143 @@ +-- EDU-013: bounded education commercialization. +-- Financial products, orders, payments, and refunds remain owned by Mall/Pay. + +ALTER TABLE education_question_collection + ADD COLUMN access_mode VARCHAR(16) NOT NULL DEFAULT 'FREE', + ADD CONSTRAINT ck_education_question_collection_access_mode + CHECK (access_mode IN ('FREE', 'PRIVATE', 'PAID')); + +CREATE OR REPLACE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE member RECORD; target_node_tenant BIGINT; target_node_scope VARCHAR(20); target_node_entry BIGINT; + target_node_deleted BOOLEAN; target_node_active BOOLEAN; target_node_hidden BOOLEAN; + target_entry_tenant BIGINT; target_entry_scope VARCHAR(20); target_entry_deleted BOOLEAN; + target_entry_active BOOLEAN; target_entry_hidden BOOLEAN; +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 RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; + ELSIF NEW.scope='TENANT_OWNED' THEN + IF NEW.collection_type<>'MANUAL' OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.membership_version<>0 OR NEW.question_count<>0 THEN RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF; + EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) + INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id; + IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED' OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED') OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id) OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514'; END IF; + 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.collection_type IS DISTINCT FROM OLD.collection_type THEN RAISE EXCEPTION 'tenant collection type is immutable MANUAL' 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.membership_version IS DISTINCT FROM OLD.membership_version OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR NEW.access_rules IS DISTINCT FROM OLD.access_rules OR NEW.access_mode IS DISTINCT FROM OLD.access_mode 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 OLD.publication_status='DRAFT' AND (NEW.node_id IS DISTINCT FROM OLD.node_id OR NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.publication_status IS DISTINCT FROM OLD.publication_status) THEN + EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) + INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id; + IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED' OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED') OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id) OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514'; END IF; + END IF; + IF NEW.membership_version IS DISTINCT FROM OLD.membership_version THEN + IF OLD.publication_status<>'DRAFT' OR NEW.publication_status<>'DRAFT' OR NEW.membership_version<>OLD.membership_version+1 THEN RAISE EXCEPTION 'collection membership version must advance exactly once in DRAFT' USING ERRCODE='23514'; END IF; + 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; + ELSIF NEW.question_count IS DISTINCT FROM OLD.question_count THEN RAISE EXCEPTION 'collection question_count requires membership version advance' USING ERRCODE='23514'; END IF; + IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN + PERFORM pg_advisory_xact_lock(NEW.id); + 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; + IF NEW.publication_status='ACTIVE' THEN FOR member IN EXECUTE format('SELECT question.tenant_id,question.scope,question.status,question.is_published,question.deleted FROM %I.education_question_collection_question membership JOIN %I.education_question question ON question.id=membership.question_id WHERE membership.collection_id=$1 AND NOT membership.deleted FOR SHARE OF question',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) USING NEW.id LOOP IF member.tenant_id IS DISTINCT FROM NEW.tenant_id OR member.scope<>'TENANT_OWNED' OR member.status<>'PUBLISHED' OR NOT member.is_published OR member.deleted THEN RAISE EXCEPTION 'active collection requires published tenant questions' USING ERRCODE='23514'; END IF; END LOOP; 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 $$; + +COMMENT ON COLUMN education_question_collection.access_mode IS + 'Authoritative access classification. access_rules remains descriptive and is never authorization input.'; + +CREATE TABLE education_resource_product_binding ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id BIGINT NOT NULL, + product_spu_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + version INTEGER NOT NULL DEFAULT 0, + 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, + CONSTRAINT ck_education_product_binding_resource_type CHECK (resource_type IN ('QUESTION_COLLECTION')), + CONSTRAINT ck_education_product_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')), + CONSTRAINT ck_education_product_binding_version CHECK (version >= 0), + CONSTRAINT uk_education_product_binding_resource UNIQUE (tenant_id, resource_type, resource_id), + CONSTRAINT uk_education_product_binding_product UNIQUE (tenant_id, product_spu_id) +); + +COMMENT ON TABLE education_resource_product_binding IS 'Education resource to Mall SPU binding; no product or financial ledger data'; + +CREATE TABLE education_entitlement ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id BIGINT NOT NULL, + product_spu_id BIGINT, + status VARCHAR(16) NOT NULL, + valid_from TIMESTAMP NOT NULL, + expires_at TIMESTAMP, + granted_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + revoke_reason VARCHAR(32), + version INTEGER NOT NULL DEFAULT 0, + 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, + CONSTRAINT ck_education_entitlement_resource_type CHECK (resource_type IN ('QUESTION_COLLECTION')), + CONSTRAINT ck_education_entitlement_status CHECK (status IN ('ACTIVE', 'REVOKED')), + CONSTRAINT ck_education_entitlement_window CHECK (expires_at IS NULL OR expires_at > valid_from), + CONSTRAINT ck_education_entitlement_revoke CHECK ( + (status = 'ACTIVE' AND revoked_at IS NULL AND revoke_reason IS NULL) OR + (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoke_reason IN ('REVOKE', 'REFUND', 'EXPIRE'))), + CONSTRAINT ck_education_entitlement_version CHECK (version >= 0), + CONSTRAINT uk_education_entitlement_subject UNIQUE (tenant_id, user_id, resource_type, resource_id) +); + +CREATE INDEX idx_education_entitlement_access + ON education_entitlement (tenant_id, user_id, resource_type, resource_id, status, expires_at) + WHERE deleted = false; + +COMMENT ON TABLE education_entitlement IS 'Tenant/user education access aggregate; not a payment, order, refund, or financial ledger'; + +CREATE TABLE education_entitlement_event ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + source_system VARCHAR(32) NOT NULL, + source_event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(16) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + entitlement_id BIGINT, + outcome VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + occurred_at TIMESTAMP NOT NULL, + processed_at 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, + CONSTRAINT fk_education_entitlement_event_entitlement FOREIGN KEY (entitlement_id) REFERENCES education_entitlement(id), + CONSTRAINT ck_education_entitlement_event_type CHECK (event_type IN ('GRANT', 'REVOKE', 'REFUND', 'EXPIRE')), + CONSTRAINT ck_education_entitlement_event_outcome CHECK (outcome IN ('PROCESSING', 'APPLIED', 'NOOP')), + CONSTRAINT uk_education_entitlement_event UNIQUE (tenant_id, source_system, source_event_id) +); + +COMMENT ON TABLE education_entitlement_event IS 'Duplicate-safe fulfillment event state; contains no financial ledger'; + +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 + (6809,'教育商品绑定','education:commercialization:binding',3,9,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'), + (6810,'教育权益管理','education:commercialization:entitlement',3,10,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=6809 AND permission='education:commercialization:binding' AND type=3 AND parent_id=6800) OR + (id=6810 AND permission='education:commercialization:entitlement' AND type=3 AND parent_id=6800)); + IF installed<>2 THEN RAISE EXCEPTION 'Education commercialization RBAC seed IDs conflict' USING ERRCODE='23505'; END IF; +END $$; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..858ca0e5 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/commercialization/EducationEntitlementPostgreSqlIntegrationTest.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.education.service.commercialization; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.*; +import cn.iocoder.yudao.module.education.dal.mysql.commercialization.*; +import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationResourceProductBindingDO; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; +import java.time.LocalDateTime; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.ENTITLEMENT_IDEMPOTENCY_CONFLICT; +import static org.junit.jupiter.api.Assertions.*; + +@Import({EducationEntitlementServiceImpl.class}) +class EducationEntitlementPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + @Resource EducationEntitlementService service; + @Resource QuestionCollectionMapper collectionMapper; + @Resource ContentEntryMapper entryMapper; + @Resource ContentNodeMapper nodeMapper; + @Resource EducationEntitlementMapper entitlementMapper; + @Resource EducationEntitlementEventMapper eventMapper; + @Resource EducationResourceProductBindingMapper bindingMapper; + + @BeforeEach void setUp() { + TenantContextHolder.setTenantId(10L); + ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); entry.setScope("TENANT_OWNED"); + entry.setEntryKey("paid"); entry.setName("Paid"); entry.setEntryType("question"); entryMapper.insert(entry); + ContentNodeDO node = new ContentNodeDO(); node.setId(101L); node.setTenantId(10L); node.setScope("TENANT_OWNED"); + node.setEntryId(100L); node.setName("Paid node"); node.setNodeType("category"); node.setDepth(0); node.setIsLeaf(true); + node.setIsSelectable(true); node.setIsHidden(false); node.setIsActive(true); node.setPublicationStatus("ACTIVE"); node.setAuthoringVersion(0); nodeMapper.insert(node); + QuestionCollectionDO collection = new QuestionCollectionDO(); collection.setId(102L); collection.setTenantId(10L); collection.setScope("TENANT_OWNED"); + collection.setEntryId(100L); collection.setNodeId(101L); collection.setName("Paid collection"); collection.setCollectionType("MANUAL"); + collection.setQuestionCount(0); collection.setAccessMode("PAID"); collection.setAccessRules("{\"label\":\"descriptive only\"}"); + collection.setIsHidden(false); collection.setIsActive(true); collection.setSortOrder(0); collection.setPublicationStatus("ACTIVE"); + collection.setAuthoringVersion(0); collection.setMembershipVersion(0); collectionMapper.insert(collection); + EducationResourceProductBindingDO binding = EducationResourceProductBindingDO.builder().resourceType("QUESTION_COLLECTION") + .resourceId(102L).productSpuId(900L).status("ACTIVE").version(0).build(); + binding.setTenantId(10L); bindingMapper.insert(binding); + } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test void duplicateGrantIsReplayedAndCrossTenantAccessFailsClosed() { + LocalDateTime now = LocalDateTime.now(); + EntitlementCommand grant = new EntitlementCommand("TRADE", "paid-1", "GRANT", 7L, "QUESTION_COLLECTION", 102L, 900L, now, now.plusDays(30), now); + Long first = service.apply(grant); + assertEquals(first, service.apply(grant)); + assertEquals(1L, entitlementMapper.selectCount()); + assertEquals(1L, eventMapper.selectCount()); + assertTrue(service.hasAccess(10L, 7L, "QUESTION_COLLECTION", 102L, now.plusMinutes(1))); + assertFalse(service.hasAccess(20L, 7L, "QUESTION_COLLECTION", 102L, now.plusMinutes(1))); + ServiceException conflict = assertThrows(ServiceException.class, () -> service.apply( + new EntitlementCommand("TRADE", "paid-1", "GRANT", 8L, "QUESTION_COLLECTION", 102L, 900L, now, now.plusDays(30), now))); + assertEquals(ENTITLEMENT_IDEMPOTENCY_CONFLICT.getCode(), conflict.getCode()); + } + + @Test void refundAndExpiryDenyAccessAndAccessRulesNeverGrant() { + LocalDateTime now = LocalDateTime.now(); + assertFalse(service.hasAccess(10L, 7L, "QUESTION_COLLECTION", 102L, now)); + service.apply(new EntitlementCommand("TRADE", "paid-2", "GRANT", 7L, "QUESTION_COLLECTION", 102L, 900L, now, now.plusHours(1), now)); + assertFalse(service.hasAccess(10L, 7L, "QUESTION_COLLECTION", 102L, now.plusHours(2))); + service.apply(new EntitlementCommand("TRADE", "refund-2", "REFUND", 7L, "QUESTION_COLLECTION", 102L, null, null, null, now.plusMinutes(5))); + assertFalse(service.hasAccess(10L, 7L, "QUESTION_COLLECTION", 102L, now.plusMinutes(6))); + assertEquals("REFUND", entitlementMapper.selectOne(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() + .eq(cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationEntitlementDO::getTenantId, 10L) + .eq(cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationEntitlementDO::getUserId, 7L)).getRevokeReason()); + } +} diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index a2d94d3b..0402265a 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -5,6 +5,9 @@ TRUNCATE TABLE education_class_invitation, education_class_member, education_class, + education_entitlement_event, + education_entitlement, + education_resource_product_binding, education_question_collection_lifecycle_audit, education_content_node_lifecycle_audit, education_question_lifecycle_transition_token,