feat(education): add bounded commercialization entitlements

This commit is contained in:
2026-07-31 12:58:48 +08:00
parent 8312859b39
commit 5bc1e9e634
27 changed files with 804 additions and 19 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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"),

View File

@@ -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<ResourceProductBindingResult> 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<ResourceProductBindingResult> 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<Long> 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())));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<EducationEntitlementEventDO> {
@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);
}

View File

@@ -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<EducationEntitlementDO> {
@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<EducationEntitlementDO>()
.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<EducationEntitlementDO>()
.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));
}
}

View File

@@ -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<EducationResourceProductBindingDO> {
default EducationResourceProductBindingDO selectByResource(Long tenantId, String resourceType, Long resourceId) {
return selectOne(new LambdaQueryWrapperX<EducationResourceProductBindingDO>()
.eq(EducationResourceProductBindingDO::getTenantId, tenantId)
.eq(EducationResourceProductBindingDO::getResourceType, resourceType)
.eq(EducationResourceProductBindingDO::getResourceId, resourceId)
.eq(EducationResourceProductBindingDO::getDeleted, false));
}
}

View File

@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.education.enums;
public enum EducationResourceType {
QUESTION_COLLECTION
}

View File

@@ -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 商品不存在或不可用");
}

View File

@@ -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);
}

View File

@@ -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); }
}
}

View File

@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.education.service.commercialization;
public interface EducationProductCatalogPort {
boolean isProductAvailable(Long productSpuId);
}

View File

@@ -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) {
}

View File

@@ -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;
}

View File

@@ -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<EducationProductCatalogPort> 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<EducationResourceProductBindingDO>()
.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<EducationResourceProductBindingDO>()
.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();
}
}

View File

@@ -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) {

View File

@@ -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<SafeQuestionRespVO> 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);

View File

@@ -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 $$;

View File

@@ -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<cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationEntitlementDO>()
.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());
}
}

View File

@@ -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,