feat(education): add bounded commercialization entitlements
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package cn.iocoder.yudao.module.education.enums;
|
||||
|
||||
public enum EducationResourceType {
|
||||
QUESTION_COLLECTION
|
||||
}
|
||||
@@ -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 商品不存在或不可用");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package cn.iocoder.yudao.module.education.service.commercialization;
|
||||
|
||||
public interface EducationProductCatalogPort {
|
||||
boolean isProductAvailable(Long productSpuId);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user