()
+ .eq(StudentSupervisionRuleDO::getTenantId, tenantId)
+ .eq(StudentSupervisionRuleDO::getId, id)
+ .eq(StudentSupervisionRuleDO::getDeleted, false));
+ }
+
+ @Update("""
+ UPDATE education_student_supervision_rule
+ SET name = #{rule.name}, status = #{rule.status}, class_id = #{rule.classId},
+ assigned_admin_user_id = #{rule.assignedAdminUserId}, dept_id = #{rule.deptId},
+ window_days = #{rule.windowDays}, inactivity_days = #{rule.inactivityDays},
+ min_answers = #{rule.minAnswers}, low_accuracy_permille = #{rule.lowAccuracyPermille},
+ wrong_question_threshold = #{rule.wrongQuestionThreshold},
+ vocabulary_due_threshold = #{rule.vocabularyDueThreshold},
+ stale_session_days = #{rule.staleSessionDays}, schedule_frequency = #{rule.scheduleFrequency},
+ schedule_hour = #{rule.scheduleHour}, schedule_minute = #{rule.scheduleMinute},
+ schedule_weekdays = #{rule.scheduleWeekdays}, limit_count = #{rule.limitCount},
+ next_run_time = #{rule.nextRunTime}, version = version + 1,
+ updater = #{rule.updater}, update_time = CURRENT_TIMESTAMP
+ WHERE tenant_id = #{tenantId} AND id = #{rule.id} AND version = #{expectedVersion}
+ AND deleted = false
+ """)
+ int updateCas(@Param("tenantId") Long tenantId, @Param("rule") StudentSupervisionRuleDO rule,
+ @Param("expectedVersion") Integer expectedVersion);
+
+ @Update("""
+ UPDATE education_student_supervision_rule
+ SET last_run_time = CURRENT_TIMESTAMP, last_candidate_count = #{candidateCount},
+ last_generated_count = #{generatedCount}, last_error = #{lastError},
+ version = version + 1, update_time = CURRENT_TIMESTAMP
+ WHERE tenant_id = #{tenantId} AND id = #{id} AND deleted = false
+ """)
+ int recordRun(@Param("tenantId") Long tenantId, @Param("id") Long id,
+ @Param("candidateCount") Integer candidateCount,
+ @Param("generatedCount") Integer generatedCount, @Param("lastError") String lastError);
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
index 25521163..9f0dae33 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
@@ -162,6 +162,41 @@ public interface ErrorCodeConstants {
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, "不支持的反馈类型:{}");
+ ErrorCode STUDENT_FEEDBACK_NOT_FOUND = new ErrorCode(1_005_004_043, "反馈不存在或无权管理");
+ ErrorCode STUDENT_FEEDBACK_STATUS_INVALID = new ErrorCode(1_005_004_044, "不支持的反馈状态:{}");
+ ErrorCode STUDENT_FEEDBACK_PRIORITY_INVALID = new ErrorCode(1_005_004_045, "不支持的反馈优先级:{}");
+ ErrorCode STUDENT_FEEDBACK_CONFLICT = new ErrorCode(1_005_004_046, "反馈已被其他管理员更新,请刷新后重试");
+ ErrorCode STUDENT_FEEDBACK_REWARD_FAILED = new ErrorCode(1_005_004_047, "反馈积分奖励发放失败,可稍后重试");
+ ErrorCode SUPERVISION_RULE_NOT_FOUND = new ErrorCode(1_005_004_048, "督导规则不存在或超出数据范围");
+ ErrorCode SUPERVISION_RULE_CONFLICT = new ErrorCode(1_005_004_049, "督导规则已被其他管理员更新,请刷新后重试");
+ ErrorCode SUPERVISION_RULE_INVALID = new ErrorCode(1_005_004_050, "督导规则配置无效:{}");
+ ErrorCode STUDENT_FOLLOWUP_NOT_FOUND = new ErrorCode(1_005_004_051, "学生跟进任务不存在或超出数据范围");
+ ErrorCode STUDENT_FOLLOWUP_CONFLICT = new ErrorCode(1_005_004_052, "学生跟进任务已被其他管理员更新,请刷新后重试");
+ ErrorCode STUDENT_FOLLOWUP_STATUS_INVALID = new ErrorCode(1_005_004_053, "学生跟进状态无效:{}");
+ ErrorCode SUPERVISION_ASSIGNEE_INVALID = new ErrorCode(1_005_004_054, "督导负责人不是有效的后台管理员");
+ ErrorCode BADGE_DEFINITION_NOT_FOUND = new ErrorCode(1_005_004_055, "徽章定义不存在或无权管理");
+ ErrorCode BADGE_DEFINITION_CONFLICT = new ErrorCode(1_005_004_056, "徽章定义已被其他管理员更新,请刷新后重试");
+ ErrorCode BADGE_DEFINITION_CODE_CONFLICT = new ErrorCode(1_005_004_057, "徽章编码已存在或不允许修改");
+ ErrorCode BADGE_RULE_INVALID = new ErrorCode(1_005_004_058, "徽章自动授予规则无效");
+ ErrorCode BADGE_DEFINITION_DISABLED = new ErrorCode(1_005_004_059, "停用徽章不能发放");
+ ErrorCode BADGE_TARGET_MEMBER_INVALID = new ErrorCode(1_005_004_080, "徽章目标不是有效会员用户");
+ ErrorCode BADGE_GRANT_CONFLICT = new ErrorCode(1_005_004_081, "徽章发放冲突,请刷新后重试");
+
+ // ========== 租户外观、公开设置与主题 1-005-004-090 ~ 1-005-004-099 ==========
+ ErrorCode TENANT_APPEARANCE_CONFIG_INVALID = new ErrorCode(1_005_004_090, "租户外观配置无效:{}");
+ ErrorCode TENANT_APPEARANCE_SECRET_REJECTED = new ErrorCode(1_005_004_091,
+ "公开配置不能包含密钥、密码或令牌,请仅保存 secretRef");
+ ErrorCode TENANT_THEME_TEMPLATE_NOT_FOUND = new ErrorCode(1_005_004_092, "主题模板不存在或已停用");
+ ErrorCode TENANT_THEME_DRAFT_NOT_FOUND = new ErrorCode(1_005_004_093, "没有可发布的主题草稿");
+ ErrorCode TENANT_APPEARANCE_CONFLICT = new ErrorCode(1_005_004_094, "租户外观配置已更新,请刷新后重试");
+
+ // ========== 学习激活码 1-005-004-100 ~ 1-005-004-109 ==========
+ ErrorCode ACTIVATION_CODE_BATCH_NOT_FOUND = new ErrorCode(1_005_004_100, "激活码批次不存在或无权管理");
+ ErrorCode ACTIVATION_CODE_BATCH_CONFLICT = new ErrorCode(1_005_004_101, "激活码批次已更新,请刷新后重试");
+ ErrorCode ACTIVATION_CODE_BATCH_INVALID = new ErrorCode(1_005_004_102, "激活码批次配置无效");
+ ErrorCode ACTIVATION_CODE_NOT_FOUND = new ErrorCode(1_005_004_103, "激活码无效或不可兑换");
+ ErrorCode ACTIVATION_CODE_USED = new ErrorCode(1_005_004_104, "激活码已被其他会员兑换");
+ ErrorCode ACTIVATION_CODE_CONFLICT = new ErrorCode(1_005_004_105, "激活码状态已变化,请重试");
// ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ==========
ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问");
@@ -170,6 +205,7 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_IMPORT_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描");
ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用");
ErrorCode QUESTION_IMPORT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_085, "当前题库数据源模式不支持执行导入:{}");
+ ErrorCode QUESTION_IMPORT_INVALID_FILE = new ErrorCode(1_005_003_086, "题目导入文件格式或内容不安全:{}");
// ========== 内容导入任务 1-005-004-060 ~ 1-005-004-069 ==========
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_004_060, "内容导入任务不存在或无权访问");
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java
new file mode 100644
index 00000000..8b6f9aca
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/framework/datapermission/EducationDataPermissionConfiguration.java
@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.education.framework.datapermission;
+
+import cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRuleCustomizer;
+import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO;
+import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentFollowupDO;
+import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentSupervisionRuleDO;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 将教育运营对象接入 RuoYi 的部门/本人数据权限规则。
+ *
+ * Education 只冗余 System 的部门与管理员编号作为授权投影,不复制账号、角色或部门模型。
+ */
+@Configuration(proxyBeanMethods = false)
+public class EducationDataPermissionConfiguration {
+
+ @Bean
+ public DeptDataPermissionRuleCustomizer educationDeptDataPermissionRuleCustomizer() {
+ return rule -> {
+ rule.addDeptColumn(EducationClassDO.class);
+ rule.addUserColumn(EducationClassDO.class, "owner_user_id");
+ rule.addDeptColumn(StudentSupervisionRuleDO.class);
+ rule.addUserColumn(StudentSupervisionRuleDO.class, "owner_user_id");
+ rule.addDeptColumn(StudentFollowupDO.class);
+ rule.addUserColumn(StudentFollowupDO.class, "owner_user_id");
+ };
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java
index f830376c..d3a27b8f 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java
@@ -17,8 +17,13 @@ public class MemberPointAwardPort {
}
public void addLearningPoints(Long userId, Integer points, Long awardId) {
- memberPointApi.addPoint(userId, points, MemberPointBizType.EDUCATION_LEARNING,
+ memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
"education-learning-award:" + awardId);
}
+ public void addFeedbackReward(Long userId, Integer points, Long feedbackId) {
+ memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
+ "education-feedback-reward:" + feedbackId);
+ }
+
}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java
index fc3ba1e6..255e0f03 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/runtime/ScalarLegacyDependencyTelemetry.java
@@ -3,13 +3,10 @@ package cn.iocoder.yudao.module.education.runtime;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
-import org.springframework.stereotype.Component;
-
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
-@Component
public class ScalarLegacyDependencyTelemetry implements EducationRuntimeDependency {
private final EducationProperties educationProperties;
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java
new file mode 100644
index 00000000..ad7ba9f2
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeService.java
@@ -0,0 +1,16 @@
+package cn.iocoder.yudao.module.education.service.activationcode;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*;
+import cn.iocoder.yudao.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*;
+
+public interface ActivationCodeService {
+ PageResult batchPage(BatchPageReq req);
+ BatchResp createBatch(BatchSaveReq req, Long actorId);
+ BatchResp updateBatch(Long id, BatchSaveReq req, Long actorId);
+ GenerateResp generate(Long id, GenerateReq req, Long actorId);
+ PageResult codePage(CodePageReq req);
+ CodeResp disable(Long id, Integer expectedVersion);
+ CheckResp check(String code);
+ RedeemResp redeem(String code, Long userId);
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java
new file mode 100644
index 00000000..4f9e2bbe
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/activationcode/ActivationCodeServiceImpl.java
@@ -0,0 +1,166 @@
+package cn.iocoder.yudao.module.education.service.activationcode;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*;
+import cn.iocoder.yudao.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*;
+import cn.iocoder.yudao.module.education.dal.dataobject.activationcode.*;
+import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationResourceProductBindingDO;
+import cn.iocoder.yudao.module.education.dal.mysql.activationcode.*;
+import cn.iocoder.yudao.module.education.dal.mysql.commercialization.EducationResourceProductBindingMapper;
+import cn.iocoder.yudao.module.education.service.commercialization.*;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.nio.charset.StandardCharsets;
+import java.security.*;
+import java.time.LocalDateTime;
+import java.util.*;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
+
+@Service
+public class ActivationCodeServiceImpl implements ActivationCodeService {
+ private static final Set STATUSES = Set.of("ACTIVE", "DISABLED");
+ private static final Set CODE_STATUSES = Set.of("AVAILABLE", "REDEEMED", "DISABLED");
+ private static final char[] ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private final ActivationCodeBatchMapper batchMapper;
+ private final ActivationCodeMapper codeMapper;
+ private final EducationResourceProductBindingMapper bindingMapper;
+ private final ObjectProvider productCatalogPorts;
+ private final EducationEntitlementService entitlementService;
+
+ public ActivationCodeServiceImpl(ActivationCodeBatchMapper batchMapper, ActivationCodeMapper codeMapper,
+ EducationResourceProductBindingMapper bindingMapper,
+ ObjectProvider productCatalogPorts,
+ EducationEntitlementService entitlementService) {
+ this.batchMapper=batchMapper; this.codeMapper=codeMapper; this.bindingMapper=bindingMapper;
+ this.productCatalogPorts=productCatalogPorts; this.entitlementService=entitlementService;
+ }
+
+ @Override public PageResult batchPage(BatchPageReq req) {
+ PageResult page=batchMapper.selectAdminPage(req,tenantId(),trim(req.getKeyword()),optional(req.getStatus(),STATUSES));
+ return new PageResult<>(page.getList().stream().map(this::batchResp).toList(),page.getTotal());
+ }
+
+ @Override @Transactional(rollbackFor=Exception.class)
+ public BatchResp createBatch(BatchSaveReq req,Long actorId) {
+ validateTarget(req.getProductSpuId());
+ ActivationCodeBatchDO item=build(req,actorId); item.setTotalCount(0); item.setRedeemedCount(0); item.setVersion(0);
+ batchMapper.insert(item); return batchResp(requireBatch(item.getId()));
+ }
+
+ @Override @Transactional(rollbackFor=Exception.class)
+ public BatchResp updateBatch(Long id,BatchSaveReq req,Long actorId) {
+ ActivationCodeBatchDO current=requireBatch(id);
+ if(req.getExpectedVersion()==null||!req.getExpectedVersion().equals(current.getVersion())) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
+ if(current.getTotalCount()>0&&(!Objects.equals(current.getProductSpuId(),req.getProductSpuId())
+ ||!Objects.equals(current.getDurationDays(),req.getDurationDays())
+ ||!Objects.equals(current.getCodePrefix(),prefix(req.getCodePrefix())))) throw exception(ACTIVATION_CODE_BATCH_INVALID);
+ validateTarget(req.getProductSpuId()); ActivationCodeBatchDO update=build(req,actorId); update.setId(id);
+ if(batchMapper.updateCas(tenantId(),update,req.getExpectedVersion())!=1) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
+ return batchResp(requireBatch(id));
+ }
+
+ @Override @Transactional(rollbackFor=Exception.class)
+ public GenerateResp generate(Long id,GenerateReq req,Long actorId) {
+ ActivationCodeBatchDO batch=requireBatch(id);
+ if(!"ACTIVE".equals(batch.getStatus())||!Objects.equals(batch.getVersion(),req.getExpectedVersion())) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
+ List generated=new ArrayList<>(req.getCount());
+ for(int i=0;i codePage(CodePageReq req) {
+ PageResult page=codeMapper.selectAdminPage(req,tenantId(),req.getBatchId(),optional(req.getStatus(),CODE_STATUSES));
+ return new PageResult<>(page.getList().stream().map(this::codeResp).toList(),page.getTotal());
+ }
+
+ @Override @Transactional(rollbackFor=Exception.class)
+ public CodeResp disable(Long id,Integer expectedVersion) {
+ if(expectedVersion==null||codeMapper.disable(tenantId(),id,expectedVersion)!=1) throw exception(ACTIVATION_CODE_CONFLICT);
+ ActivationCodeDO item=codeMapper.selectById(id);
+ if(item==null||!Objects.equals(item.getTenantId(),tenantId())) throw exception(ACTIVATION_CODE_NOT_FOUND);
+ return codeResp(item);
+ }
+
+ @Override public CheckResp check(String raw) {
+ ActivationCodeDO code=codeMapper.selectByHash(tenantId(),hash(normalize(raw)));
+ if(code==null||!"AVAILABLE".equals(code.getStatus())) return CheckResp.builder().valid(false).build();
+ ActivationCodeBatchDO batch=batchMapper.selectOwned(tenantId(),code.getBatchId());
+ if(batch==null||!"ACTIVE".equals(batch.getStatus())) return CheckResp.builder().valid(false).build();
+ EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),batch.getProductSpuId());
+ if(binding==null||!"ACTIVE".equals(binding.getStatus())) return CheckResp.builder().valid(false).build();
+ return CheckResp.builder().valid(true).codeMasked(code.getCodeMasked()).batchName(batch.getName())
+ .durationDays(batch.getDurationDays()).build();
+ }
+
+ @Override @Transactional(rollbackFor=Exception.class)
+ public RedeemResp redeem(String raw,Long userId) {
+ ActivationCodeDO code=codeMapper.selectForUpdate(tenantId(),hash(normalize(raw)));
+ if(code==null) throw exception(ACTIVATION_CODE_NOT_FOUND);
+ ActivationCodeBatchDO batch=requireBatch(code.getBatchId());
+ EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),batch.getProductSpuId());
+ if(binding==null) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND);
+ if("REDEEMED".equals(code.getStatus())){
+ if(!Objects.equals(code.getRedeemedBy(),userId)) throw exception(ACTIVATION_CODE_USED);
+ return result(code.getEntitlementId(),binding,batch,true);
+ }
+ if(!"AVAILABLE".equals(code.getStatus())||!"ACTIVE".equals(batch.getStatus())||!"ACTIVE".equals(binding.getStatus()))
+ throw exception(ACTIVATION_CODE_NOT_FOUND);
+ LocalDateTime now=LocalDateTime.now(); LocalDateTime expires=batch.getDurationDays()==0?null:now.plusDays(batch.getDurationDays());
+ Long entitlementId=entitlementService.apply(new EntitlementCommand("ACTIVATION_CODE",String.valueOf(code.getId()),"GRANT",
+ userId,binding.getResourceType(),binding.getResourceId(),binding.getProductSpuId(),now,expires,now));
+ if(codeMapper.markRedeemed(tenantId(),code.getId(),userId,entitlementId)!=1) throw exception(ACTIVATION_CODE_CONFLICT);
+ if(batchMapper.incrementRedeemed(tenantId(),batch.getId())!=1) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
+ return result(entitlementId,binding,batch,false);
+ }
+
+ private RedeemResp result(Long entitlementId,EducationResourceProductBindingDO binding,ActivationCodeBatchDO batch,boolean replay){
+ return RedeemResp.builder().entitlementId(entitlementId).resourceId(binding.getResourceId())
+ .productSpuId(binding.getProductSpuId()).durationDays(batch.getDurationDays()).idempotent(replay).build();
+ }
+ private ActivationCodeBatchDO build(BatchSaveReq req,Long actorId){
+ String status=choice(req.getStatus(),STATUSES,"ACTIVE");
+ ActivationCodeBatchDO out=ActivationCodeBatchDO.builder().name(req.getName().trim()).productSpuId(req.getProductSpuId())
+ .durationDays(req.getDurationDays()).codePrefix(prefix(req.getCodePrefix())).status(status).build();
+ out.setTenantId(tenantId()); out.setCreator(String.valueOf(actorId)); out.setUpdater(String.valueOf(actorId)); return out;
+ }
+ private void validateTarget(Long productSpuId){
+ EducationProductCatalogPort productCatalog=productCatalogPorts.getIfAvailable();
+ if(productCatalog!=null&&!productCatalog.isProductAvailable(productSpuId)) throw exception(PRODUCT_NOT_AVAILABLE);
+ activeBinding(productSpuId);
+ }
+ private EducationResourceProductBindingDO activeBinding(Long productSpuId){
+ EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),productSpuId);
+ if(binding==null||!"ACTIVE".equals(binding.getStatus())) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); return binding;
+ }
+ private ActivationCodeBatchDO requireBatch(Long id){ ActivationCodeBatchDO out=batchMapper.selectOwned(tenantId(),id); if(out==null)throw exception(ACTIVATION_CODE_BATCH_NOT_FOUND); return out; }
+ private BatchResp batchResp(ActivationCodeBatchDO i){return BatchResp.builder().id(i.getId()).name(i.getName()).productSpuId(i.getProductSpuId())
+ .durationDays(i.getDurationDays()).codePrefix(i.getCodePrefix()).status(i.getStatus()).totalCount(i.getTotalCount())
+ .redeemedCount(i.getRedeemedCount()).version(i.getVersion()).createTime(i.getCreateTime()).updateTime(i.getUpdateTime()).build();}
+ private CodeResp codeResp(ActivationCodeDO i){return CodeResp.builder().id(i.getId()).batchId(i.getBatchId()).codeMasked(i.getCodeMasked())
+ .status(i.getStatus()).redeemedBy(i.getRedeemedBy()).redeemedAt(i.getRedeemedAt()).entitlementId(i.getEntitlementId())
+ .version(i.getVersion()).createTime(i.getCreateTime()).build();}
+ private static String newCode(String prefix){StringBuilder out=new StringBuilder(prefix);if(!prefix.isEmpty())out.append('-');for(int i=0;i<16;i++)out.append(ALPHABET[RANDOM.nextInt(ALPHABET.length)]);return out.toString();}
+ private static String normalize(String value){return value==null?"":value.trim().toUpperCase(Locale.ROOT);}
+ private static String prefix(String value){return value==null?"":value.trim().toUpperCase(Locale.ROOT);}
+ private static String mask(String value){return value.substring(0,Math.min(4,value.length()))+"****"+value.substring(Math.max(0,value.length()-4));}
+ private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
+ private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
+ private static String optional(String value,Set allowed){if(value==null||value.isBlank())return null;return choice(value,allowed,null);}
+ private static String choice(String value,Set allowed,String fallback){String out=value==null||value.isBlank()?fallback:value.trim().toUpperCase(Locale.ROOT);if(out==null||!allowed.contains(out))throw exception(ACTIVATION_CODE_BATCH_INVALID);return out;}
+ private Long tenantId(){return TenantContextHolder.getRequiredTenantId();}
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java
new file mode 100644
index 00000000..29edeffb
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/appearance/TenantAppearancePolicy.java
@@ -0,0 +1,268 @@
+package cn.iocoder.yudao.module.education.service.appearance;
+
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.util.*;
+import java.util.regex.Pattern;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.TENANT_APPEARANCE_CONFIG_INVALID;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.TENANT_APPEARANCE_SECRET_REJECTED;
+
+/**
+ * Public appearance policy. It fails closed because all accepted values are eventually rendered by a client.
+ */
+@Component
+public class TenantAppearancePolicy {
+
+ private static final Set THEME_MODES = Set.of("light", "dark", "auto");
+ private static final Set THEME_DENSITIES = Set.of("compact", "comfortable", "dense");
+ private static final Set THEME_KEYS = Set.of("mode", "primaryColor", "accentColor",
+ "backgroundColor", "surfaceColor", "textColor", "mutedColor", "borderColor", "successColor",
+ "warningColor", "dangerColor", "borderRadius", "buttonRadius", "fontFamily", "layoutDensity",
+ "customCssVars", "icons");
+ private static final Set ASSET_KEYS = Set.of("logoUrl", "faviconUrl", "appIconUrl", "shareImageUrl",
+ "loginPosterUrl", "splashImageUrl", "iconSet", "shareCardStyle");
+ private static final Set URL_ASSET_KEYS = Set.of("logoUrl", "faviconUrl", "appIconUrl", "shareImageUrl",
+ "loginPosterUrl", "splashImageUrl");
+ private static final Pattern SAFE_TOKEN = Pattern.compile("^[a-z0-9][a-z0-9_-]{0,31}$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern SAFE_CSS_KEY = Pattern.compile("^--tiku-[a-z0-9-]{1,48}$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9a-f]{6}$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern SAFE_FONT = Pattern.compile("^[\\u4e00-\\u9fa5a-zA-Z0-9\\s,\"'\\-]{1,80}$");
+ private static final Pattern UNSAFE_PUBLIC_STRING = Pattern.compile(
+ "(app_private\\.tenant_secrets|-----BEGIN|