forked from wangziqi/ruoyi-vue-pro
feat(education): add class relationships
This commit is contained in:
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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, "班级角色无效:{}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user