feat(education): add class relationships

This commit is contained in:
2026-07-31 12:53:13 +08:00
parent 3fdc4edfe0
commit 428d4e10fd
26 changed files with 937 additions and 11 deletions

View File

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

View File

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

View File

@@ -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 - 教育租户识别
```

View File

@@ -24,6 +24,11 @@
<artifactId>yudao-module-system</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-member</artifactId>
<version>${revision}</version>
</dependency>
<!-- Test 测试相关 -->
<dependency>

View File

@@ -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<Long> create(@Valid @RequestBody EducationClassCreateReqVO request) {
return success(service.createClass(request.getName(), request.getDescription()));
}
@GetMapping
@PreAuthorize("@ss.hasPermission('education:class:query')")
public CommonResult<List<EducationClassRespVO>> list() {
return success(service.listClasses().stream().map(EducationClassRespVO::from).toList());
}
@GetMapping("/{classId}/members")
@PreAuthorize("@ss.hasPermission('education:class-member:query')")
public CommonResult<List<EducationClassMemberDO>> members(@PathVariable Long classId) {
return success(service.listMembers(classId));
}
@PostMapping("/{classId}/invitations")
@PreAuthorize("@ss.hasPermission('education:class-invitation:create')")
public CommonResult<EducationClassInvitationRespVO> invite(
@PathVariable Long classId, @Valid @RequestBody EducationClassInvitationCreateReqVO request) {
return success(EducationClassInvitationRespVO.from(service.createInvitation(classId,
request.getInviteeMemberUserId(), request.getRole(), request.getExpiresAt(),
request.getIdempotencyKey(), getLoginUserId())));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<EducationClassInvitationRespVO> accept(@PathVariable Long invitationId) {
return success(EducationClassInvitationRespVO.from(
service.acceptInvitation(invitationId, getLoginUserId())));
}
}

View File

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

View File

@@ -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<String, Object> detail;
}

View File

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

View File

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

View File

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

View File

@@ -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<EducationClassInvitationDO> {
default EducationClassInvitationDO selectByCreateKey(Long tenantId, Long actorId, String key) {
return selectOne(new LambdaQueryWrapperX<EducationClassInvitationDO>()
.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);
}

View File

@@ -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<EducationClassDO> {
default EducationClassDO selectOwned(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<EducationClassDO>()
.eq(EducationClassDO::getTenantId, tenantId).eq(EducationClassDO::getId, id)
.eq(EducationClassDO::getDeleted, false));
}
default List<EducationClassDO> selectOwnedList(Long tenantId) {
return selectList(new LambdaQueryWrapperX<EducationClassDO>()
.eq(EducationClassDO::getTenantId, tenantId).eq(EducationClassDO::getDeleted, false)
.orderByDesc(EducationClassDO::getId));
}
}

View File

@@ -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<EducationClassMemberDO> {
@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<EducationClassMemberDO> selectByClass(Long tenantId, Long classId) {
return selectList(new LambdaQueryWrapperX<EducationClassMemberDO>()
.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<EducationClassMemberDO>()
.eq(EducationClassMemberDO::getTenantId, tenantId).eq(EducationClassMemberDO::getClassId, classId)
.eq(EducationClassMemberDO::getMemberUserId, userId).eq(EducationClassMemberDO::getDeleted, false));
}
}

View File

@@ -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, "班级角色无效:{}");
}

View File

@@ -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<EducationClassDO> listClasses();
List<EducationClassMemberDO> listMembers(Long classId);
EducationClassInvitationDO createInvitation(Long classId, Long inviteeMemberUserId, String role,
LocalDateTime expiresAt, String idempotencyKey, Long actorId);
EducationClassInvitationDO acceptInvitation(Long invitationId, Long memberUserId);
}

View File

@@ -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<EducationClassDO> listClasses() {
return classMapper.selectOwnedList(TenantContextHolder.getRequiredTenantId());
}
@Override
public List<EducationClassMemberDO> 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);
}
}

View File

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

View File

@@ -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<String> 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<EducationClassDO> listClasses() { EducationClassDO c = new EducationClassDO(); c.setId(100L); return List.of(c); }
public List<EducationClassMemberDO> 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(); }
}
}

View File

@@ -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<Throwable> 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<Throwable> 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<MemberUserRespDTO> getUserList(Collection<Long> ids) { return ids.stream().map(this::getUser).toList(); }
public List<MemberUserRespDTO> getUserListByNickname(String n) { return List.of(); }
public MemberUserRespDTO getUserByMobile(String m) { return null; }
public void validateUser(Long id) { }
public Map<Long, MemberUserRespDTO> getUserMap(Collection<Long> ids) { return MemberUserApi.super.getUserMap(ids); }
};
}
}
}

View File

@@ -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 (" +

View File

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