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

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