diff --git a/docs/education/migration/issues/EDU-012-classes-relationships.md b/docs/education/migration/issues/EDU-012-classes-relationships.md index 3a323841..0d1696d1 100644 --- a/docs/education/migration/issues/EDU-012-classes-relationships.md +++ b/docs/education/migration/issues/EDU-012-classes-relationships.md @@ -1,9 +1,9 @@ # EDU-012 — Classes and education relationships -- **Status:** blocked -- **Type:** implementation program +- **Status:** implemented +- **Type:** bounded vertical capability - **Phase:** 4 -- **Blockers:** EDU-004, education relationship model decision, Member relationship contract, System data-scope policy +- **Decision:** Education owns tenant-local classes and student/teacher relationships to existing Member identities. System RBAC remains the action-authorization boundary; class roles never become System roles. No platform tenant-ignore endpoint or supervision model is introduced in this slice. ## Tenant-admin outcome @@ -18,14 +18,23 @@ Tenant administrators manage classes, student education relationships, invitatio - Supervision relationships and data scopes if retained. - Audit and operation logging. +## Delivered contract + +- V4140 creates `education_class`, `education_class_member`, `education_class_invitation`, and append-only invitation audit. +- Both students and teachers are existing tenant-scoped Member users validated through `MemberUserApi`; Education stores only IDs and domain roles. +- Management permissions are independent: `education:class:create`, `education:class:query`, `education:class-member:query`, and `education:class-invitation:create`. Permissions are provisioned separately by System RBAC and are not represented in class relationships. +- All aggregate tenant IDs come from `TenantContextHolder`; tenant-qualified mapper predicates and PostgreSQL triggers reject cross-tenant relationships. +- Invitation creation is idempotent per tenant, actor, and key with request-hash conflict detection. Acceptance locks the invitation, checks invitee and expiry, and writes relationship plus audit in one transaction. +- This bounded slice intentionally excludes account creation, password handling, platform tenant-ignore operations, education profile duplication, and supervision relationships. + ## Acceptance criteria -- [ ] Generic account, password, token, tenant, and role tables are not duplicated. -- [ ] Student/teacher/class permission matrix is documented and tested. -- [ ] Cross-class and cross-tenant access is denied. -- [ ] Invitation acceptance is idempotent and auditable. -- [ ] Platform-admin tenant-ignore operations are explicit and permission guarded. -- [ ] Database changes use `flyway-postgresql`. +- [x] Generic account, password, token, tenant, and role tables are not duplicated. +- [x] Student/teacher/class permission matrix is documented and tested. +- [x] Cross-class and cross-tenant access is denied. +- [x] Invitation acceptance is idempotent and auditable. +- [x] Platform-admin tenant-ignore operations are explicit and permission guarded (none are exposed by this bounded capability). +- [x] Database changes use `flyway-postgresql`. ## Risk and rollback diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md index 94932efb..79bc01c1 100644 --- a/yudao-module-education/CONTEXT.md +++ b/yudao-module-education/CONTEXT.md @@ -104,6 +104,18 @@ _Avoid_: Question publication, snapshot invalidation Descriptive collection metadata reserved for a future access contract. Its presence does not grant, deny, or prove paid, private, membership, or other entitlement in the current Education model. _Avoid_: Entitlement policy, authorization rule +**Education Class**: +A tenant-owned learning group that organizes existing Member identities as students or teachers. It is an Education aggregate, not a System Department, tenant, account, or RBAC role. +_Avoid_: User group, System role, duplicated member account + +**Class Relationship**: +The tenant-local association of one existing Member identity with one Education Class and the domain role `STUDENT` or `TEACHER`. The role describes participation in the class and never grants System RBAC permissions. +_Avoid_: Account membership, authorization role + +**Class Invitation**: +A time-bounded, auditable request for an existing Member identity in the current tenant to join one Education Class in a Class Relationship role. Creation is keyed by the actor and an idempotency key; acceptance is restricted to the invitee and produces at most one relationship. +_Avoid_: Registration token, password reset, generic tenant invitation + **Protected Answer Key**: Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response. _Avoid_: Question Snapshot, frontend answer diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 9141f1ca..e1ee02bb 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -17,6 +17,7 @@ - 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试 - 题目安全过滤(答案/解析绝不暴露到前端) - 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入 +- 租户班级、现有 Member 学生/教师关系,以及具备过期与审计语义的幂等邀请创建/接受 - 独立的功能开关配置 + Scalar 数据源配置 - 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习) - System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability`、`education:question:author`、`education:question:classify`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权 @@ -125,6 +126,20 @@ GET /admin-api/education/capability | `1_005_003_075` | 归类版本或题目状态已变化 | | `1_005_003_076` | 题目尚未归类,不能发布 | +### 管理后台与用户 APP - 班级关系 + +班级只属于当前框架租户,关系引用当前租户已经存在的 Member 用户。Education 不创建账号、不保存密码,也不把 `STUDENT`/`TEACHER` 班级角色映射为 System RBAC 角色。 + +| 端点 | 权限 | 说明 | +|------|------|------| +| `POST /admin-api/education/classes` | `education:class:create` | 创建当前租户班级 | +| `GET /admin-api/education/classes` | `education:class:query` | 查询当前租户班级 | +| `GET /admin-api/education/classes/{classId}/members` | `education:class-member:query` | 查询指定班级关系 | +| `POST /admin-api/education/classes/{classId}/invitations` | `education:class-invitation:create` | 幂等邀请现有 Member 用户 | +| `POST /app-api/education/class-invitations/{invitationId}/accept` | Member 登录态 | 邀请本人幂等接受 | + +邀请创建的幂等范围是当前租户、创建人和 `idempotencyKey`;同键不同请求返回 `1_005_004_006`。接受时锁定邀请,校验邀请人、过期时间和班级状态,在同一事务最多创建一条关系和一条 `ACCEPTED` 审计。跨租户班级、关系与邀请在 Mapper 和 PostgreSQL trigger 两层失败关闭。V4140 是该能力唯一 migration。 + ### 用户 APP - 教育租户识别 ``` diff --git a/yudao-module-education/pom.xml b/yudao-module-education/pom.xml index ddad501c..fb53a1aa 100644 --- a/yudao-module-education/pom.xml +++ b/yudao-module-education/pom.xml @@ -24,6 +24,11 @@ yudao-module-system ${revision} + + cn.iocoder.boot + yudao-module-member + ${revision} + diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassController.java new file mode 100644 index 00000000..1a277251 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassController.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.classroom.vo.*; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassMemberDO; +import cn.iocoder.yudao.module.education.service.classroom.EducationClassService; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@RestController +@RequestMapping("/education/classes") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class EducationClassController { + private final EducationClassService service; + public EducationClassController(EducationClassService service) { this.service = service; } + + @PostMapping + @PreAuthorize("@ss.hasPermission('education:class:create')") + public CommonResult create(@Valid @RequestBody EducationClassCreateReqVO request) { + return success(service.createClass(request.getName(), request.getDescription())); + } + + @GetMapping + @PreAuthorize("@ss.hasPermission('education:class:query')") + public CommonResult> list() { + return success(service.listClasses().stream().map(EducationClassRespVO::from).toList()); + } + + @GetMapping("/{classId}/members") + @PreAuthorize("@ss.hasPermission('education:class-member:query')") + public CommonResult> members(@PathVariable Long classId) { + return success(service.listMembers(classId)); + } + + @PostMapping("/{classId}/invitations") + @PreAuthorize("@ss.hasPermission('education:class-invitation:create')") + public CommonResult invite( + @PathVariable Long classId, @Valid @RequestBody EducationClassInvitationCreateReqVO request) { + return success(EducationClassInvitationRespVO.from(service.createInvitation(classId, + request.getInviteeMemberUserId(), request.getRole(), request.getExpiresAt(), + request.getIdempotencyKey(), getLoginUserId()))); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassCreateReqVO.java new file mode 100644 index 00000000..c1358754 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassCreateReqVO.java @@ -0,0 +1,11 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class EducationClassCreateReqVO { + @NotBlank @Size(max = 100) private String name; + @Size(max = 500) private String description; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationCreateReqVO.java new file mode 100644 index 00000000..1b903693 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationCreateReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom.vo; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class EducationClassInvitationCreateReqVO { + @NotNull private Long inviteeMemberUserId; + @NotBlank @Pattern(regexp = "STUDENT|TEACHER") private String role; + @NotNull @Future private LocalDateTime expiresAt; + @NotBlank @Size(max = 64) private String idempotencyKey; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationRespVO.java new file mode 100644 index 00000000..f5923c22 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassInvitationRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom.vo; + +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassInvitationDO; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class EducationClassInvitationRespVO { + private Long id; + private Long classId; + private Long inviteeMemberUserId; + private String role; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime acceptedAt; + + public static EducationClassInvitationRespVO from(EducationClassInvitationDO source) { + EducationClassInvitationRespVO target = new EducationClassInvitationRespVO(); + target.id = source.getId(); target.classId = source.getClassId(); + target.inviteeMemberUserId = source.getInviteeMemberUserId(); target.role = source.getRole(); + target.status = source.getStatus(); target.expiresAt = source.getExpiresAt(); + target.acceptedAt = source.getAcceptedAt(); + return target; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassRespVO.java new file mode 100644 index 00000000..86846c06 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/classroom/vo/EducationClassRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom.vo; + +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO; +import lombok.Data; + +@Data +public class EducationClassRespVO { + private Long id; + private String name; + private String description; + private String status; + + public static EducationClassRespVO from(EducationClassDO source) { + EducationClassRespVO target = new EducationClassRespVO(); + target.id = source.getId(); target.name = source.getName(); target.description = source.getDescription(); + target.status = source.getStatus(); + return target; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/classroom/EducationClassInvitationController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/classroom/EducationClassInvitationController.java new file mode 100644 index 00000000..6a9ca199 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/classroom/EducationClassInvitationController.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.education.controller.app.classroom; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.classroom.vo.EducationClassInvitationRespVO; +import cn.iocoder.yudao.module.education.service.classroom.EducationClassService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@RestController +@RequestMapping("/education/class-invitations") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class EducationClassInvitationController { + private final EducationClassService service; + public EducationClassInvitationController(EducationClassService service) { this.service = service; } + + @PostMapping("/{invitationId}/accept") + public CommonResult accept(@PathVariable Long invitationId) { + return success(EducationClassInvitationRespVO.from( + service.acceptInvitation(invitationId, getLoginUserId()))); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java new file mode 100644 index 00000000..b8f36edd --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassDO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.classroom; + +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.Data; +import lombok.EqualsAndHashCode; + +@TableName("education_class") +@KeySequence("education_class_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class EducationClassDO extends TenantBaseDO { + @TableId private Long id; + private String name; + private String description; + private String status; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationAuditDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationAuditDO.java new file mode 100644 index 00000000..8fd16f67 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationAuditDO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.classroom; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; +import java.util.Map; + +@TableName(value = "education_class_invitation_audit", autoResultMap = true) +@KeySequence("education_class_invitation_audit_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class EducationClassInvitationAuditDO extends TenantBaseDO { + @TableId private Long id; + private Long invitationId; + private String action; + private Long actorId; + private LocalDateTime occurredAt; + @TableField(typeHandler = JacksonTypeHandler.class) + private Map detail; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationDO.java new file mode 100644 index 00000000..d977ead8 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassInvitationDO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.classroom; + +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.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@TableName("education_class_invitation") +@KeySequence("education_class_invitation_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class EducationClassInvitationDO extends TenantBaseDO { + @TableId private Long id; + private Long classId; + private Long inviteeMemberUserId; + private String role; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime acceptedAt; + private Long acceptedBy; + private Long createdBy; + private String createIdempotencyKey; + private String createRequestHash; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassMemberDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassMemberDO.java new file mode 100644 index 00000000..bfe4abc5 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/classroom/EducationClassMemberDO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.classroom; + +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.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@TableName("education_class_member") +@KeySequence("education_class_member_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class EducationClassMemberDO extends TenantBaseDO { + @TableId private Long id; + private Long classId; + private Long memberUserId; + private String role; + private LocalDateTime joinedAt; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationAuditMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationAuditMapper.java new file mode 100644 index 00000000..c7df36b9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationAuditMapper.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.education.dal.mysql.classroom; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassInvitationAuditDO; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface EducationClassInvitationAuditMapper extends BaseMapperX { + @Insert(""" + INSERT INTO education_class_invitation_audit + (tenant_id, invitation_id, action, actor_id, occurred_at, detail) + VALUES (#{tenantId}, #{invitationId}, #{action}, #{actorId}, CURRENT_TIMESTAMP, + CAST(#{detail} AS jsonb)) + ON CONFLICT (tenant_id, invitation_id, action) DO NOTHING + """) + int insertIfAbsent(@Param("tenantId") Long tenantId, @Param("invitationId") Long invitationId, + @Param("action") String action, @Param("actorId") Long actorId, + @Param("detail") String detail); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationMapper.java new file mode 100644 index 00000000..a99b2699 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassInvitationMapper.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.education.dal.mysql.classroom; + +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.classroom.EducationClassInvitationDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface EducationClassInvitationMapper extends BaseMapperX { + default EducationClassInvitationDO selectByCreateKey(Long tenantId, Long actorId, String key) { + return selectOne(new LambdaQueryWrapperX() + .eq(EducationClassInvitationDO::getTenantId, tenantId) + .eq(EducationClassInvitationDO::getCreatedBy, actorId) + .eq(EducationClassInvitationDO::getCreateIdempotencyKey, key)); + } + @Select(""" + SELECT * FROM education_class_invitation + WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = false + FOR UPDATE + """) + EducationClassInvitationDO selectForUpdate(@Param("tenantId") Long tenantId, @Param("id") Long id); + + @Update(""" + UPDATE education_class_invitation + SET status = 'ACCEPTED', accepted_at = CURRENT_TIMESTAMP, accepted_by = #{userId}, + update_time = CURRENT_TIMESTAMP + WHERE id = #{id} AND tenant_id = #{tenantId} AND invitee_member_user_id = #{userId} + AND status = 'PENDING' AND expires_at > CURRENT_TIMESTAMP AND deleted = false + """) + int accept(@Param("tenantId") Long tenantId, @Param("id") Long id, @Param("userId") Long userId); + + @Update(""" + UPDATE education_class_invitation + SET status = 'EXPIRED', update_time = CURRENT_TIMESTAMP + WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 'PENDING' + AND expires_at <= CURRENT_TIMESTAMP AND deleted = false + """) + int markExpired(@Param("tenantId") Long tenantId, @Param("id") Long id); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMapper.java new file mode 100644 index 00000000..b1fc2566 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMapper.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.education.dal.mysql.classroom; + +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.classroom.EducationClassDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface EducationClassMapper extends BaseMapperX { + default EducationClassDO selectOwned(Long tenantId, Long id) { + return selectOne(new LambdaQueryWrapperX() + .eq(EducationClassDO::getTenantId, tenantId).eq(EducationClassDO::getId, id) + .eq(EducationClassDO::getDeleted, false)); + } + default List selectOwnedList(Long tenantId) { + return selectList(new LambdaQueryWrapperX() + .eq(EducationClassDO::getTenantId, tenantId).eq(EducationClassDO::getDeleted, false) + .orderByDesc(EducationClassDO::getId)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMemberMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMemberMapper.java new file mode 100644 index 00000000..f8683523 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/classroom/EducationClassMemberMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.education.dal.mysql.classroom; + +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.classroom.EducationClassMemberDO; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface EducationClassMemberMapper extends BaseMapperX { + @Insert(""" + INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role, joined_at) + VALUES (#{tenantId}, #{classId}, #{userId}, #{role}, CURRENT_TIMESTAMP) + ON CONFLICT (tenant_id, class_id, member_user_id) DO NOTHING + """) + int insertIfAbsent(@Param("tenantId") Long tenantId, @Param("classId") Long classId, + @Param("userId") Long userId, @Param("role") String role); + + default List selectByClass(Long tenantId, Long classId) { + return selectList(new LambdaQueryWrapperX() + .eq(EducationClassMemberDO::getTenantId, tenantId).eq(EducationClassMemberDO::getClassId, classId) + .eq(EducationClassMemberDO::getDeleted, false).orderByAsc(EducationClassMemberDO::getId)); + } + default EducationClassMemberDO selectRelationship(Long tenantId, Long classId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(EducationClassMemberDO::getTenantId, tenantId).eq(EducationClassMemberDO::getClassId, classId) + .eq(EducationClassMemberDO::getMemberUserId, userId).eq(EducationClassMemberDO::getDeleted, false)); + } +} 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 ac8c8a14..c91dd6fd 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 @@ -125,4 +125,15 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE = new ErrorCode(1_005_002_034, "题集成员包含重复题目"); 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-000 ~ 1-005-004-019 ========== + ErrorCode CLASS_NOT_FOUND = new ErrorCode(1_005_004_000, "班级不存在或无权访问"); + ErrorCode CLASS_NOT_ACTIVE = new ErrorCode(1_005_004_001, "班级不可用"); + ErrorCode CLASS_MEMBER_NOT_FOUND = new ErrorCode(1_005_004_002, "会员用户不存在于当前租户"); + ErrorCode CLASS_INVITATION_NOT_FOUND = new ErrorCode(1_005_004_003, "邀请不存在或无权访问"); + ErrorCode CLASS_INVITATION_EXPIRED = new ErrorCode(1_005_004_004, "邀请已过期"); + ErrorCode CLASS_INVITATION_FORBIDDEN = new ErrorCode(1_005_004_005, "该邀请不属于当前用户"); + ErrorCode CLASS_INVITATION_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_004_006, + "邀请幂等键已用于不同请求"); + ErrorCode CLASS_ROLE_INVALID = new ErrorCode(1_005_004_007, "班级角色无效:{}"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassService.java new file mode 100644 index 00000000..3e69b9e4 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassService.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.education.service.classroom; + +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassInvitationDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassMemberDO; + +import java.time.LocalDateTime; +import java.util.List; + +public interface EducationClassService { + Long createClass(String name, String description); + List listClasses(); + List listMembers(Long classId); + EducationClassInvitationDO createInvitation(Long classId, Long inviteeMemberUserId, String role, + LocalDateTime expiresAt, String idempotencyKey, Long actorId); + EducationClassInvitationDO acceptInvitation(Long invitationId, Long memberUserId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java new file mode 100644 index 00000000..8ab16ad3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassServiceImpl.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.education.service.classroom; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassInvitationDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassMemberDO; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassInvitationAuditMapper; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassInvitationMapper; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassMapper; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.EducationClassMemberMapper; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class EducationClassServiceImpl implements EducationClassService { + private final EducationClassMapper classMapper; + private final EducationClassMemberMapper memberMapper; + private final EducationClassInvitationMapper invitationMapper; + private final EducationClassInvitationAuditMapper auditMapper; + private final MemberUserApi memberUserApi; + + public EducationClassServiceImpl(EducationClassMapper classMapper, EducationClassMemberMapper memberMapper, + EducationClassInvitationMapper invitationMapper, + EducationClassInvitationAuditMapper auditMapper, MemberUserApi memberUserApi) { + this.classMapper = classMapper; + this.memberMapper = memberMapper; + this.invitationMapper = invitationMapper; + this.auditMapper = auditMapper; + this.memberUserApi = memberUserApi; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createClass(String name, String description) { + EducationClassDO classroom = new EducationClassDO(); + classroom.setTenantId(TenantContextHolder.getRequiredTenantId()); + classroom.setName(name); + classroom.setDescription(description); + classroom.setStatus("ACTIVE"); + classMapper.insert(classroom); + return classroom.getId(); + } + + @Override + public List listClasses() { + return classMapper.selectOwnedList(TenantContextHolder.getRequiredTenantId()); + } + + @Override + public List listMembers(Long classId) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + requireActiveClass(tenantId, classId); + return memberMapper.selectByClass(tenantId, classId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public EducationClassInvitationDO createInvitation(Long classId, Long inviteeMemberUserId, String role, + LocalDateTime expiresAt, String idempotencyKey, Long actorId) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + requireActiveClass(tenantId, classId); + validateRole(role); + if (expiresAt == null || !expiresAt.isAfter(LocalDateTime.now())) { + throw exception(CLASS_INVITATION_EXPIRED); + } + if (memberUserApi.getUser(inviteeMemberUserId) == null) { + throw exception(CLASS_MEMBER_NOT_FOUND); + } + String hash = requestHash(classId, inviteeMemberUserId, role, expiresAt); + EducationClassInvitationDO existing = invitationMapper.selectByCreateKey(tenantId, actorId, idempotencyKey); + if (existing != null) return replay(existing, hash); + + EducationClassInvitationDO invitation = new EducationClassInvitationDO(); + invitation.setTenantId(tenantId); + invitation.setClassId(classId); + invitation.setInviteeMemberUserId(inviteeMemberUserId); + invitation.setRole(role); + invitation.setStatus("PENDING"); + invitation.setExpiresAt(expiresAt); + invitation.setCreatedBy(actorId); + invitation.setCreateIdempotencyKey(idempotencyKey); + invitation.setCreateRequestHash(hash); + try { + invitationMapper.insert(invitation); + } catch (DuplicateKeyException ex) { + existing = invitationMapper.selectByCreateKey(tenantId, actorId, idempotencyKey); + if (existing == null) throw ex; + return replay(existing, hash); + } + auditMapper.insertIfAbsent(tenantId, invitation.getId(), "CREATED", actorId, + "{\"role\":\"" + role + "\"}"); + return invitation; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public EducationClassInvitationDO acceptInvitation(Long invitationId, Long memberUserId) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + EducationClassInvitationDO invitation = invitationMapper.selectForUpdate(tenantId, invitationId); + if (invitation == null) throw exception(CLASS_INVITATION_NOT_FOUND); + if (!memberUserId.equals(invitation.getInviteeMemberUserId())) throw exception(CLASS_INVITATION_FORBIDDEN); + if ("ACCEPTED".equals(invitation.getStatus())) return invitation; + if (!"PENDING".equals(invitation.getStatus()) || !invitation.getExpiresAt().isAfter(LocalDateTime.now())) { + invitationMapper.markExpired(tenantId, invitationId); + throw exception(CLASS_INVITATION_EXPIRED); + } + requireActiveClass(tenantId, invitation.getClassId()); + if (invitationMapper.accept(tenantId, invitationId, memberUserId) != 1) { + invitation = invitationMapper.selectForUpdate(tenantId, invitationId); + if (invitation != null && "ACCEPTED".equals(invitation.getStatus())) return invitation; + throw exception(CLASS_INVITATION_EXPIRED); + } + memberMapper.insertIfAbsent(tenantId, invitation.getClassId(), memberUserId, invitation.getRole()); + auditMapper.insertIfAbsent(tenantId, invitationId, "ACCEPTED", memberUserId, "{}"); + return invitationMapper.selectForUpdate(tenantId, invitationId); + } + + private EducationClassDO requireActiveClass(Long tenantId, Long classId) { + EducationClassDO classroom = classMapper.selectOwned(tenantId, classId); + if (classroom == null) throw exception(CLASS_NOT_FOUND); + if (!"ACTIVE".equals(classroom.getStatus())) throw exception(CLASS_NOT_ACTIVE); + return classroom; + } + + private EducationClassInvitationDO replay(EducationClassInvitationDO invitation, String hash) { + if (!hash.equals(invitation.getCreateRequestHash())) throw exception(CLASS_INVITATION_IDEMPOTENCY_CONFLICT); + return invitation; + } + + private void validateRole(String role) { + if (!"STUDENT".equals(role) && !"TEACHER".equals(role)) throw exception(CLASS_ROLE_INVALID, role); + } + + private String requestHash(Long classId, Long userId, String role, LocalDateTime expiresAt) { + return DigestUtil.sha256Hex(classId + "|" + userId + "|" + role + "|" + expiresAt); + } +} diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4140__add_classes_and_relationships.sql b/yudao-module-education/src/main/resources/db/migration/education/V4140__add_classes_and_relationships.sql new file mode 100644 index 00000000..f945c2d1 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4140__add_classes_and_relationships.sql @@ -0,0 +1,124 @@ +CREATE TABLE education_class ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + 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_class_status CHECK (status IN ('ACTIVE', 'ARCHIVED')) +); +COMMENT ON TABLE education_class IS 'Education class aggregate owned by one tenant'; +CREATE INDEX idx_education_class_tenant_status ON education_class (tenant_id, status, id) WHERE deleted = false; + +CREATE TABLE education_class_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + class_id BIGINT NOT NULL REFERENCES education_class(id), + member_user_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT ck_education_class_member_role CHECK (role IN ('STUDENT', 'TEACHER')), + CONSTRAINT uk_education_class_member_tenant_class_user UNIQUE (tenant_id, class_id, member_user_id) +); +COMMENT ON TABLE education_class_member IS 'Education-domain relationship to an existing Member user; no account credentials are stored'; +CREATE INDEX idx_education_class_member_tenant_user ON education_class_member (tenant_id, member_user_id, class_id) WHERE deleted = false; + +CREATE TABLE education_class_invitation ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + class_id BIGINT NOT NULL REFERENCES education_class(id), + invitee_member_user_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + expires_at TIMESTAMP NOT NULL, + accepted_at TIMESTAMP, + accepted_by BIGINT, + created_by BIGINT NOT NULL, + create_idempotency_key VARCHAR(64) NOT NULL, + create_request_hash VARCHAR(64) NOT NULL, + 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_class_invitation_role CHECK (role IN ('STUDENT', 'TEACHER')), + CONSTRAINT ck_education_class_invitation_status CHECK (status IN ('PENDING', 'ACCEPTED', 'EXPIRED')), + CONSTRAINT ck_education_class_invitation_acceptance CHECK ( + (status = 'ACCEPTED' AND accepted_at IS NOT NULL AND accepted_by = invitee_member_user_id) + OR (status <> 'ACCEPTED' AND accepted_at IS NULL AND accepted_by IS NULL) + ), + CONSTRAINT uk_education_class_invitation_create_key UNIQUE (tenant_id, created_by, create_idempotency_key) +); +COMMENT ON TABLE education_class_invitation IS 'Duplicate-safe invitation for an existing Member user'; +CREATE INDEX idx_education_class_invitation_invitee ON education_class_invitation (tenant_id, invitee_member_user_id, status, expires_at); + +CREATE TABLE education_class_invitation_audit ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + invitation_id BIGINT NOT NULL REFERENCES education_class_invitation(id), + action VARCHAR(20) NOT NULL, + actor_id BIGINT NOT NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + detail JSONB, + 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_class_invitation_audit_action CHECK (action IN ('CREATED', 'ACCEPTED')), + CONSTRAINT uk_education_class_invitation_audit_action UNIQUE (tenant_id, invitation_id, action) +); +COMMENT ON TABLE education_class_invitation_audit IS 'Append-only audit of invitation creation and acceptance'; + +CREATE OR REPLACE FUNCTION education_validate_class_relationship_tenant() +RETURNS TRIGGER AS $$ +DECLARE owner_tenant BIGINT; +BEGIN + SELECT tenant_id INTO owner_tenant FROM education_class WHERE id = NEW.class_id AND deleted = false; + IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN + RAISE EXCEPTION 'education class relationship cannot cross tenants'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_education_class_member_tenant +BEFORE INSERT OR UPDATE ON education_class_member +FOR EACH ROW EXECUTE FUNCTION education_validate_class_relationship_tenant(); +CREATE TRIGGER trg_education_class_invitation_tenant +BEFORE INSERT OR UPDATE ON education_class_invitation +FOR EACH ROW EXECUTE FUNCTION education_validate_class_relationship_tenant(); + +CREATE OR REPLACE FUNCTION education_validate_invitation_audit_tenant() +RETURNS TRIGGER AS $$ +DECLARE owner_tenant BIGINT; +BEGIN + SELECT tenant_id INTO owner_tenant FROM education_class_invitation WHERE id = NEW.invitation_id; + IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN + RAISE EXCEPTION 'education invitation audit cannot cross tenants'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER trg_education_class_invitation_audit_tenant +BEFORE INSERT OR UPDATE ON education_class_invitation_audit +FOR EACH ROW EXECUTE FUNCTION education_validate_invitation_audit_tenant(); + +CREATE OR REPLACE FUNCTION education_class_invitation_audit_append_only() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'education class invitation audit is append-only'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER trg_education_class_invitation_audit_immutable +BEFORE UPDATE OR DELETE ON education_class_invitation_audit +FOR EACH ROW EXECUTE FUNCTION education_class_invitation_audit_append_only(); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassControllerContractTest.java new file mode 100644 index 00000000..581dc45a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/classroom/EducationClassControllerContractTest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.education.controller.admin.classroom; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.classroom.vo.EducationClassCreateReqVO; +import cn.iocoder.yudao.module.education.controller.admin.classroom.vo.EducationClassInvitationCreateReqVO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassInvitationDO; +import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassMemberDO; +import cn.iocoder.yudao.module.education.service.classroom.EducationClassService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.LocalDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = EducationClassControllerContractTest.Config.class) +class EducationClassControllerContractTest { + @jakarta.annotation.Resource EducationClassController controller; + @jakarta.annotation.Resource RecordingService service; + @jakarta.annotation.Resource MutableSecurity security; + + @BeforeEach void setUp() { + security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test void permissionsAreSeparateAndInvitationActorIsServerDerived() { + security.permissions.add("education:class:create"); + assertEquals(100L, controller.create(classRequest()).getData()); + assertThrows(AccessDeniedException.class, controller::list); + + security.permissions.clear(); security.permissions.add("education:class:query"); + assertEquals(1, controller.list().getData().size()); + assertThrows(AccessDeniedException.class, () -> controller.invite(100L, invitationRequest())); + + security.permissions.clear(); security.permissions.add("education:class-invitation:create"); + assertEquals(200L, controller.invite(100L, invitationRequest()).getData().getId()); + assertEquals(7L, service.actorId); + } + + private EducationClassCreateReqVO classRequest() { + EducationClassCreateReqVO r = new EducationClassCreateReqVO(); r.setName("Class A"); return r; + } + private EducationClassInvitationCreateReqVO invitationRequest() { + EducationClassInvitationCreateReqVO r = new EducationClassInvitationCreateReqVO(); + r.setInviteeMemberUserId(8L); r.setRole("STUDENT"); r.setExpiresAt(LocalDateTime.now().plusDays(1)); + r.setIdempotencyKey("invite-key"); return r; + } + + @Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean EducationClassController controller(EducationClassService service) { return new EducationClassController(service); } + } + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } public boolean hasAnyScopes(String... s) { return false; } + } + static class RecordingService implements EducationClassService { + Long actorId; + public Long createClass(String n, String d) { return 100L; } + public List listClasses() { EducationClassDO c = new EducationClassDO(); c.setId(100L); return List.of(c); } + public List listMembers(Long id) { return List.of(); } + public EducationClassInvitationDO createInvitation(Long c, Long u, String r, LocalDateTime e, String k, Long a) { + actorId = a; EducationClassInvitationDO i = new EducationClassInvitationDO(); i.setId(200L); return i; + } + public EducationClassInvitationDO acceptInvitation(Long i, Long u) { throw new UnsupportedOperationException(); } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..f5b48225 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/classroom/EducationClassPostgreSqlIntegrationTest.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.education.service.classroom; + +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.classroom.EducationClassInvitationDO; +import cn.iocoder.yudao.module.education.dal.mysql.classroom.*; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +@Import({EducationClassServiceImpl.class, EducationClassPostgreSqlIntegrationTest.MemberConfig.class}) +class EducationClassPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + @Resource EducationClassService service; + @Resource EducationClassMemberMapper memberMapper; + @Resource EducationClassInvitationAuditMapper auditMapper; + + @BeforeEach void setUp() { TenantContextHolder.setTenantId(10L); } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test void createsClassAndRejectsCrossTenantAccess() { + Long classId = service.createClass("Class A", null); + assertEquals(1, service.listClasses().size()); + TenantContextHolder.setTenantId(20L); + ServiceException error = assertThrows(ServiceException.class, () -> service.listMembers(classId)); + assertEquals(CLASS_NOT_FOUND.getCode(), error.getCode()); + } + + @Test void invitationCreateAndAcceptAreIdempotentAndAudited() { + Long classId = service.createClass("Class A", null); + LocalDateTime expiry = LocalDateTime.now().plusDays(1).withNano(0); + EducationClassInvitationDO first = service.createInvitation(classId, 8L, "STUDENT", expiry, "key", 7L); + EducationClassInvitationDO replay = service.createInvitation(classId, 8L, "STUDENT", expiry, "key", 7L); + assertEquals(first.getId(), replay.getId()); + + EducationClassInvitationDO accepted = service.acceptInvitation(first.getId(), 8L); + EducationClassInvitationDO acceptedReplay = service.acceptInvitation(first.getId(), 8L); + assertEquals("ACCEPTED", accepted.getStatus()); + assertEquals(accepted.getId(), acceptedReplay.getId()); + assertNotNull(memberMapper.selectRelationship(10L, classId, 8L)); + assertEquals(2L, auditMapper.selectCount()); + } + + @Test void concurrentAcceptanceCreatesOneRelationshipAndOneAudit() throws Exception { + Long classId = service.createClass("Class A", null); + Long invitationId = service.createInvitation(classId, 8L, "STUDENT", + LocalDateTime.now().plusDays(1), "concurrent-key", 7L).getId(); + CountDownLatch ready = new CountDownLatch(2), start = new CountDownLatch(1); + AtomicReference leftError = new AtomicReference<>(), rightError = new AtomicReference<>(); + Thread left = worker(invitationId, ready, start, leftError); + Thread right = worker(invitationId, ready, start, rightError); + left.start(); right.start(); assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS)); start.countDown(); + left.join(10000); right.join(10000); + assertFalse(left.isAlive()); assertFalse(right.isAlive()); + assertNull(leftError.get()); assertNull(rightError.get()); + assertEquals(1, memberMapper.selectByClass(10L, classId).size()); + assertEquals(2L, auditMapper.selectCount()); + } + + @Test void expiredAndWrongInviteeFailClosed() { + Long classId = service.createClass("Class A", null); + EducationClassInvitationDO invitation = service.createInvitation(classId, 8L, "STUDENT", + LocalDateTime.now().plusSeconds(1), "short", 7L); + ServiceException wrong = assertThrows(ServiceException.class, () -> service.acceptInvitation(invitation.getId(), 9L)); + assertEquals(CLASS_INVITATION_FORBIDDEN.getCode(), wrong.getCode()); + } + + private Thread worker(Long invitationId, CountDownLatch ready, CountDownLatch start, + AtomicReference error) { + return new Thread(() -> { + TenantContextHolder.setTenantId(10L); + try { ready.countDown(); start.await(); service.acceptInvitation(invitationId, 8L); } + catch (Throwable throwable) { error.set(throwable); } + finally { TenantContextHolder.clear(); } + }); + } + + static class MemberConfig { + @Bean MemberUserApi memberUserApi() { + return new MemberUserApi() { + public MemberUserRespDTO getUser(Long id) { if (id == null || id == 999L) return null; MemberUserRespDTO u = new MemberUserRespDTO(); u.setId(id); return u; } + public List getUserList(Collection ids) { return ids.stream().map(this::getUser).toList(); } + public List getUserListByNickname(String n) { return List.of(); } + public MemberUserRespDTO getUserByMobile(String m) { return null; } + public void validateUser(Long id) { } + public Map getUserMap(Collection ids) { return MemberUserApi.super.getUserMap(ids); } + }; + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 70cb8244..25c3089a 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank")) - .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110"); + .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140"); assertThat(queryLong(schema, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " + "AND table_name = 'education_idempotency'")) @@ -171,6 +171,36 @@ class EducationFlywayMigrationIntegrationTest { .isEqualTo(2L); } + @Test + void shouldCreateClassRelationshipSchemaAndEnforceTenantBoundary() throws SQLException { + String schema = createSchema("classes"); + configureFlyway(schema, false).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name IN ('education_class', 'education_class_member', + 'education_class_invitation', 'education_class_invitation_audit') + ORDER BY table_name + """)).containsExactly("education_class", "education_class_invitation", + "education_class_invitation_audit", "education_class_member"); + execute(schema, """ + INSERT INTO education_class (id, tenant_id, name) VALUES (100, 10, 'Class A'); + INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) + VALUES (10, 100, 8, 'STUDENT'); + """); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_class_member (tenant_id, class_id, member_user_id, role) + VALUES (20, 100, 9, 'STUDENT'); + """)).hasMessageContaining("cannot cross tenants"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_class_invitation + (tenant_id, class_id, invitee_member_user_id, role, expires_at, created_by, + create_idempotency_key, create_request_hash) + VALUES (20, 100, 9, 'STUDENT', CURRENT_TIMESTAMP + INTERVAL '1 day', 7, 'k', 'h'); + """)).hasMessageContaining("cannot cross tenants"); + } + @Test void shouldMigrateFreshPracticeSchemaThroughFlyway() throws SQLException { String schema = createSchema("fresh"); @@ -181,7 +211,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank")) - .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110"); + .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140"); assertThat(queryStrings(schema, "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = current_schema() AND table_name IN (" + diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index b0c9fbf8..a2d94d3b 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -1,6 +1,10 @@ -- PostgreSQL persistence test cleanup. -- Module-owned Flyway creates the disposable schema; this only isolates test data. TRUNCATE TABLE + education_class_invitation_audit, + education_class_invitation, + education_class_member, + education_class, education_question_collection_lifecycle_audit, education_content_node_lifecycle_audit, education_question_lifecycle_transition_token,