forked from wangziqi/ruoyi-vue-pro
feat(education): add class relationships
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 - 教育租户识别
|
||||
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (" +
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user