Compare commits

..

No commits in common. "df7eae612588cac5e0fd303925840443977b26d9" and "c5fad308499b0eb162a0f2cf25e81494aeff8af1" have entirely different histories.

189 changed files with 8222 additions and 21079 deletions

180
CLAUDE.md
View File

@ -1,180 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
多租户少儿绘本创作活动/竞赛管理平台,前后端分离架构。
## 目录结构
| 目录 | 说明 |
|------|------|
| `backend-java/` | Spring Boot 后端(实际开发目录) |
| `frontend/` | Vue 3 前端(实际开发目录) |
| `lesingle-aicreate-client/` | AI 绘本创作客户端(独立模块) |
## 常用命令
### 后端 (backend-java/)
```bash
cd backend-java
mvn spring-boot:run -Dspring.profiles.active=dev # 开发启动(端口 8580上下文 /api
mvn flyway:migrate # 执行数据库迁移
mvn clean package # 构建打包
```
### 前端 (frontend/)
```bash
cd frontend
npm run dev # 开发模式(端口 3000代理 /api → localhost:8580
npm run build # 生产构建base: /web/
npm run build:test # 测试环境构建base: /web-test/
npm run lint # ESLint 检查
```
### AI创作客户端 (lesingle-aicreate-client/)
```bash
cd lesingle-aicreate-client
npm install && npm run dev # 独立启动
```
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端框架 | Spring Boot 3.2.5 + Java 17 |
| ORM | MyBatis-Plus 3.5.7 |
| 数据库 | MySQL 8.0 + Flyway 迁移 |
| 认证 | Spring Security + JWT |
| 缓存 | Redis |
| 工具库 | Hutool 5.8 + FastJSON2 + Knife4j 4.4API文档 |
| 前端框架 | Vue 3 + TypeScript + Vite 5 |
| UI | Ant Design Vue 4.1 |
| 状态管理 | Pinia |
| 样式 | Tailwind CSS + SCSS |
| 表单验证 | VeeValidate + Zod |
| 富文本 | WangEditor |
| 图表 | ECharts |
## 后端架构 (backend-java/)
### 基础包: `com.competition`
### 三层架构
| 层级 | 职责 | 规范 |
|------|------|------|
| Controller | HTTP 请求处理、参数校验、Entity↔VO 转换 | 统一返回 `Result<T>` |
| Service | 业务逻辑、事务控制 | 接口 `I{Module}Service`,继承 `IService<T>` |
| Mapper | 数据库 CRUD | 继承 `BaseMapper<T>` |
**核心原则**: Service/Mapper 层使用 EntityVO 转换只在 Controller 层。
### 模块划分
```
com.competition.modules/
├── biz/
│ ├── contest/ # 赛事管理(/contests
│ ├── homework/ # 作业管理(/homework
│ ├── judge/ # 评委管理
│ └── review/ # 评审管理(/contest-reviews, /contest-results
├── sys/ # 系统管理(用户/角色/权限/租户,/sys/*
├── user/ # 用户模块
├── ugc/ # 用户生成内容
├── pub/ # 公开接口(/public/*,无需认证)
└── oss/ # 对象存储(/oss/upload
```
### 实体基类 (BaseEntity)
所有实体继承 `BaseEntity`,包含字段:`id`、`createBy`、`updateBy`、`createTime`、`modifyTime`、`deleted`、`validState`。
表名规范:业务表 `t_biz_*`、系统表 `t_sys_*`、用户表 `t_user_*`
### 多租户
- 所有业务表包含 `tenant_id` 字段
- 获取租户ID: `SecurityUtil.getCurrentTenantId()`
- 超级管理员 `isSuperAdmin()` 可访问所有租户数据
- 请求头通过 `X-Tenant-Code`、`X-Tenant-Id` 传递
### 认证与权限
- JWT Token payload: `{sub: userId, username, tenantId}`
- 公开接口: `@Public` 注解或路径 `/public/**`
- 权限控制: `@RequirePermission` 注解
### 统一响应格式
```java
Result<T> → {code, message, data, timestamp, path}
PageResult<T> → {list, total, page, pageSize}
```
### 数据库迁移
- 位置: `src/main/resources/db/migration/`
- 命名: `V{number}__description.sql`(注意双下划线)
- 不使用外键约束,关联关系通过代码控制
## 前端架构 (frontend/)
### 路由与多租户
- 路由路径包含租户编码: `/:tenantCode/login`、`/:tenantCode/dashboard`
- 动态路由: 根据用户权限菜单动态生成
- 双模式: 管理端(需认证)+ 公众端(无需认证)
### 三种布局
| 布局 | 用途 |
|------|------|
| BasicLayout | 管理端(侧边栏+顶栏+面包屑) |
| PublicLayout | 公众端(简洁导航) |
| EmptyLayout | 全屏页面 |
### API 调用模式
API 模块位于 `src/api/`Axios 实例在 `src/utils/request.ts`
- 请求拦截器自动添加 Authorization token 和租户头
- 响应拦截器统一错误处理401 跳转登录403 提示)
- 函数命名: `getXxx`、`createXxx`、`updateXxx`、`deleteXxx`
### 权限控制
- 路由级: `meta.permissions`
- 组件级: `v-permission` 自定义指令
- 方法级: `hasPermission()`、`hasAnyPermission()`、`isSuperAdmin()`
### 状态管理
- auth Store: 用户信息、token、菜单、权限检查
- Token 存储在 Cookie 中
## 开发规范
- **日志/注释使用中文**
- **Git 提交格式**: `类型: 描述`(如 `feat: 添加XX功能`、`fix: 修复XX问题`
- **组件语法**: `<script setup lang="ts">`
- **文件命名**: 组件 PascalCase其他 kebab-case
- **数据库**: 不使用外键,关联通过代码控制
- **逻辑删除**: `deleted` 字段
## 环境配置
| 环境 | 后端 Profile | 前端 base | 后端端口 |
|------|-------------|-----------|---------|
| 开发 | dev | `/` | 8580 |
| 测试 | test | `/web-test/` | 8580 |
| 生产 | prod | `/web/` | 8580 |
## 注意事项
- `.cursor/rules/` 中部分规范(如 NestJS、Prisma是旧版配置当前后端已迁移至 Spring Boot + MyBatis-Plus以本文件为准
- 当前项目未配置测试框架
- 当前项目未配置 i18n所有文本为中文硬编码

View File

@ -12,8 +12,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
"com.competition.modules.biz.homework.mapper",
"com.competition.modules.biz.judge.mapper",
"com.competition.modules.user.mapper",
"com.competition.modules.ugc.mapper",
"com.competition.modules.leai.mapper"
"com.competition.modules.ugc.mapper"
})
public class CompetitionApplication {

View File

@ -1,24 +0,0 @@
package com.competition.common.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Flyway 修复配置
* 启动时自动修复失败的迁移记录然后执行迁移
*/
@Configuration
public class FlywayRepairConfig {
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
return flyway -> {
// 先修复失败的迁移记录
flyway.repair();
// 然后执行迁移
flyway.migrate();
};
}
}

View File

@ -1,21 +0,0 @@
package com.competition.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 配置
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10_000);
factory.setReadTimeout(10_000);
return new RestTemplate(factory);
}
}

View File

@ -1,12 +0,0 @@
package com.competition.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 定时任务配置
*/
@Configuration
@EnableScheduling
public class SchedulingConfig {
}

View File

@ -1,7 +1,6 @@
package com.competition.common.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,28 +11,23 @@ import java.time.LocalDateTime;
* 包含新审计字段Java规范和旧审计字段过渡期兼容
*/
@Data
@Schema(description = "基础实体")
public abstract class BaseEntity implements Serializable {
/** 主键 ID自增 */
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
// ====== 新审计字段Java 规范 ======
/** 创建人账号 */
@Schema(description = "创建人账号")
@TableField(value = "create_by", fill = FieldFill.INSERT)
private String createBy;
/** 更新人账号 */
@Schema(description = "更新人账号")
@TableField(value = "update_by", fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 逻辑删除标识0-未删除1-已删除) */
@Schema(description = "逻辑删除标识0-未删除1-已删除")
@TableLogic
@TableField(value = "deleted", fill = FieldFill.INSERT)
private Integer deleted;
@ -41,27 +35,22 @@ public abstract class BaseEntity implements Serializable {
// ====== 旧审计字段过渡期保留 ======
/** 创建人 ID */
@Schema(description = "创建人ID")
@TableField(value = "creator", fill = FieldFill.INSERT)
private Integer creator;
/** 修改人 ID */
@Schema(description = "修改人ID")
@TableField(value = "modifier", fill = FieldFill.INSERT_UPDATE)
private Integer modifier;
/** 创建时间 */
@Schema(description = "创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 修改时间 */
@Schema(description = "修改时间")
@TableField(value = "modify_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime modifyTime;
/** 有效状态1-有效2-失效 */
@Schema(description = "有效状态1-有效2-失效")
@TableField(value = "valid_state", fill = FieldFill.INSERT)
private Integer validState;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.competition.common.result.PageResult;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.contest.dto.CreateNoticeDto;
import com.competition.modules.biz.contest.entity.BizContestNotice;
import com.competition.modules.biz.contest.service.IContestNoticeService;
@ -17,7 +16,6 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Tag(name = "赛事公告")
@ -28,34 +26,6 @@ public class ContestNoticeController {
private final IContestNoticeService noticeService;
/**
* 解析日期时间字符串兼容 ISO 格式带毫秒和 Z 时区标记
*/
private LocalDateTime parseDateTime(String dateTime) {
if (!StringUtils.hasText(dateTime)) {
return null;
}
// 尝试 ISO 格式yyyy-MM-dd'T'HH:mm:ss.SSSZ yyyy-MM-dd'T'HH:mm:ss'Z'
try {
// 处理带 Z ISO 格式
if (dateTime.endsWith("Z")) {
return LocalDateTime.parse(dateTime.substring(0, dateTime.length() - 1));
}
// 处理带毫秒的格式
if (dateTime.contains(".") && dateTime.indexOf(".") + 4 == dateTime.length()) {
return LocalDateTime.parse(dateTime.substring(0, dateTime.indexOf(".")));
}
return LocalDateTime.parse(dateTime);
} catch (Exception e) {
// 尝试空格分隔格式
try {
return LocalDateTime.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
} catch (Exception ex) {
return null;
}
}
}
@PostMapping
@RequirePermission("notice:create")
@Operation(summary = "创建公告")
@ -67,10 +37,8 @@ public class ContestNoticeController {
notice.setNoticeType(dto.getNoticeType());
notice.setPriority(dto.getPriority());
if (StringUtils.hasText(dto.getPublishTime())) {
notice.setPublishTime(parseDateTime(dto.getPublishTime()));
notice.setPublishTime(LocalDateTime.parse(dto.getPublishTime()));
}
// 设置当前租户 ID租户隔离
notice.setTenantId(SecurityUtil.getCurrentTenantId());
noticeService.save(notice);
return Result.success(notice);
}
@ -79,11 +47,9 @@ public class ContestNoticeController {
@RequirePermission("notice:read")
@Operation(summary = "查询赛事下的公告列表")
public Result<List<BizContestNotice>> findByContest(@PathVariable Long contestId) {
Long tenantId = SecurityUtil.getCurrentTenantId();
List<BizContestNotice> list = noticeService.list(
new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getContestId, contestId)
.eq(BizContestNotice::getTenantId, tenantId)
.orderByDesc(BizContestNotice::getCreateTime));
return Result.success(list);
}
@ -94,21 +60,10 @@ public class ContestNoticeController {
public Result<PageResult<BizContestNotice>> findAll(
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) String title,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtil.getCurrentTenantId();
@RequestParam(required = false) String title) {
LambdaQueryWrapper<BizContestNotice> wrapper = new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getTenantId, tenantId) // 租户隔离
.like(StringUtils.hasText(title), BizContestNotice::getTitle, title);
// 发布状态过滤
if ("published".equals(status)) {
wrapper.isNotNull(BizContestNotice::getPublishTime);
} else if ("unpublished".equals(status)) {
wrapper.isNull(BizContestNotice::getPublishTime);
}
wrapper.orderByDesc(BizContestNotice::getCreateTime);
.like(StringUtils.hasText(title), BizContestNotice::getTitle, title)
.orderByDesc(BizContestNotice::getCreateTime);
Page<BizContestNotice> result = noticeService.page(new Page<>(page, pageSize), wrapper);
return Result.success(PageResult.from(result));
}
@ -131,7 +86,7 @@ public class ContestNoticeController {
notice.setNoticeType(dto.getNoticeType());
notice.setPriority(dto.getPriority());
if (StringUtils.hasText(dto.getPublishTime())) {
notice.setPublishTime(parseDateTime(dto.getPublishTime()));
notice.setPublishTime(LocalDateTime.parse(dto.getPublishTime()));
}
noticeService.updateById(notice);
return Result.success();

View File

@ -37,9 +37,7 @@ public class ContestRegistrationController {
@RequirePermission("contest:read")
@Operation(summary = "获取报名统计")
public Result<Map<String, Object>> getStats(@RequestParam(required = false) Long contestId) {
Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperAdmin = SecurityUtil.isSuperAdmin();
return Result.success(registrationService.getStats(contestId, tenantId, isSuperAdmin));
return Result.success(registrationService.getStats(contestId, SecurityUtil.getCurrentTenantId()));
}
@GetMapping

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -18,166 +17,136 @@ import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_contest", autoResultMap = true)
@Schema(description = "赛事实体")
public class BizContest extends BaseEntity {
@Schema(description = "赛事名称")
/** 赛事名称(唯一) */
@TableField("contest_name")
private String contestName;
@Schema(description = "赛事类型individual/team")
/** 赛事类型individual/team */
@TableField("contest_type")
private String contestType;
@Schema(description = "赛事发布状态unpublished/published")
/** 赛事发布状态unpublished/published */
@TableField("contest_state")
private String contestState;
@Schema(description = "赛事进度状态ongoing/finished")
/** 赛事进度状态ongoing/finished */
private String status;
@Schema(description = "开始时间")
/** 开始时间 */
@TableField("start_time")
private LocalDateTime startTime;
@Schema(description = "结束时间")
/** 结束时间 */
@TableField("end_time")
private LocalDateTime endTime;
@Schema(description = "线下地址")
/** 线下地址 */
private String address;
@Schema(description = "赛事详情(富文本)")
/** 赛事详情(富文本) */
private String content;
@Schema(description = "可见范围public/designated/internal")
/** 可见范围public/designated/internal */
private String visibility;
// ====== 授权租户JSON ======
@Schema(description = "授权租户ID数组")
/** 授权租户 ID 数组 */
@TableField(value = "contest_tenants", typeHandler = JacksonTypeHandler.class)
private List<Integer> contestTenants;
// ====== 封面和联系方式 ======
@Schema(description = "封面图URL")
@TableField("cover_url")
private String coverUrl;
@Schema(description = "海报URL")
@TableField("poster_url")
private String posterUrl;
@Schema(description = "联系人姓名")
@TableField("contact_name")
private String contactName;
@Schema(description = "联系电话")
@TableField("contact_phone")
private String contactPhone;
@Schema(description = "联系二维码")
@TableField("contact_qrcode")
private String contactQrcode;
// ====== 主办/协办/赞助JSON ======
@Schema(description = "主办方信息JSON")
@TableField(value = "organizers", typeHandler = JacksonTypeHandler.class)
private Object organizers;
@Schema(description = "协办方信息JSON")
@TableField(value = "co_organizers", typeHandler = JacksonTypeHandler.class)
private Object coOrganizers;
@Schema(description = "赞助方信息JSON")
@TableField(value = "sponsors", typeHandler = JacksonTypeHandler.class)
private Object sponsors;
// ====== 报名配置 ======
@Schema(description = "报名开始时间")
@TableField("register_start_time")
private LocalDateTime registerStartTime;
@Schema(description = "报名结束时间")
@TableField("register_end_time")
private LocalDateTime registerEndTime;
@Schema(description = "报名状态")
@TableField("register_state")
private String registerState;
@Schema(description = "是否需要审核")
@TableField("require_audit")
private Boolean requireAudit;
@Schema(description = "允许参赛的年级JSON数组")
@TableField(value = "allowed_grades", typeHandler = JacksonTypeHandler.class)
private List<Integer> allowedGrades;
@Schema(description = "允许参赛的班级JSON数组")
@TableField(value = "allowed_classes", typeHandler = JacksonTypeHandler.class)
private List<Integer> allowedClasses;
@Schema(description = "团队最小人数")
@TableField("team_min_members")
private Integer teamMinMembers;
@Schema(description = "团队最大人数")
@TableField("team_max_members")
private Integer teamMaxMembers;
// ====== 目标筛选 ======
@Schema(description = "目标城市JSON数组")
@TableField(value = "target_cities", typeHandler = JacksonTypeHandler.class)
private List<String> targetCities;
@Schema(description = "最小年龄")
@TableField("age_min")
private Integer ageMin;
@Schema(description = "最大年龄")
@TableField("age_max")
private Integer ageMax;
// ====== 提交配置 ======
@Schema(description = "提交规则")
@TableField("submit_rule")
private String submitRule;
@Schema(description = "提交开始时间")
@TableField("submit_start_time")
private LocalDateTime submitStartTime;
@Schema(description = "提交结束时间")
@TableField("submit_end_time")
private LocalDateTime submitEndTime;
@Schema(description = "作品类型")
@TableField("work_type")
private String workType;
@Schema(description = "作品要求")
@TableField("work_requirement")
private String workRequirement;
// ====== 评审配置 ======
@Schema(description = "评审规则ID")
@TableField("review_rule_id")
private Long reviewRuleId;
@Schema(description = "评审开始时间")
@TableField("review_start_time")
private LocalDateTime reviewStartTime;
@Schema(description = "评审结束时间")
@TableField("review_end_time")
private LocalDateTime reviewEndTime;
// ====== 成果发布 ======
@Schema(description = "成绩发布状态")
@TableField("result_state")
private String resultState;
@Schema(description = "成绩发布时间")
@TableField("result_publish_time")
private LocalDateTime resultPublishTime;
}

View File

@ -3,35 +3,27 @@ package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_attachment")
@Schema(description = "赛事附件实体")
public class BizContestAttachment extends BaseEntity {
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "文件名称")
@TableField("file_name")
private String fileName;
@Schema(description = "文件URL")
@TableField("file_url")
private String fileUrl;
@Schema(description = "文件格式")
private String format;
@Schema(description = "文件类型")
@TableField("file_type")
private String fileType;
@Schema(description = "文件大小")
private String size;
}

View File

@ -3,43 +3,29 @@ package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 赛事公告实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_notice")
@Schema(description = "赛事公告实体")
public class BizContestNotice extends BaseEntity {
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "公告标题")
private String title;
@Schema(description = "公告内容(富文本)")
private String content;
@Schema(description = "公告类型system/manual/urgent")
/** system/manual/urgent */
@TableField("notice_type")
private String noticeType;
@Schema(description = "优先级")
private Integer priority;
@Schema(description = "发布时间")
@TableField("publish_time")
private LocalDateTime publishTime;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -12,70 +11,61 @@ import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_registration")
@Schema(description = "赛事报名实体")
public class BizContestRegistration extends BaseEntity {
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "报名类型individual/team")
/** 报名类型individual/team */
@TableField("registration_type")
private String registrationType;
@Schema(description = "团队ID")
@TableField("team_id")
private Long teamId;
@Schema(description = "团队名称快照")
/** 团队名称快照 */
@TableField("team_name")
private String teamName;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "账号快照")
/** 账号快照 */
@TableField("account_no")
private String accountNo;
@Schema(description = "账号名称")
@TableField("account_name")
private String accountName;
@Schema(description = "角色leader/member/mentor")
/** 角色快照leader/member/mentor */
private String role;
@Schema(description = "报名状态pending/passed/rejected/withdrawn")
/** 报名状态pending/passed/rejected/withdrawn */
@TableField("registration_state")
private String registrationState;
@Schema(description = "参与者类型self/child")
/** 参与者类型self/child */
@TableField("participant_type")
private String participantType;
@Schema(description = "子女ID")
@TableField("child_id")
private Long childId;
@Schema(description = "实际提交人ID")
/** 实际提交人 ID */
private Integer registrant;
@Schema(description = "报名时间")
@TableField("registration_time")
private LocalDateTime registrationTime;
@Schema(description = "审核原因")
/** 审核原因 */
private String reason;
@Schema(description = "审核操作人")
/** 审核操作人 */
private Integer operator;
@Schema(description = "操作日期")
@TableField("operation_date")
private LocalDateTime operationDate;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -9,40 +8,29 @@ import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_registration_teacher")
@Schema(description = "赛事报名老师关联实体")
public class BizContestRegistrationTeacher implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "报名ID")
@TableField("registration_id")
private Long registrationId;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "是否默认")
@TableField("is_default")
private Boolean isDefault;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -3,33 +3,26 @@ package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_team")
@Schema(description = "赛事团队实体")
public class BizContestTeam extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "团队名称")
@TableField("team_name")
private String teamName;
@Schema(description = "队长用户ID")
@TableField("leader_user_id")
private Long leaderUserId;
@Schema(description = "最大成员数")
@TableField("max_members")
private Integer maxMembers;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -9,39 +8,29 @@ import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_team_member")
@Schema(description = "赛事团队成员实体")
public class BizContestTeamMember implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "团队ID")
@TableField("team_id")
private Long teamId;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "角色member/leader/mentor")
/** 角色member/leader/mentor */
private String role;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -15,95 +14,75 @@ import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_contest_work", autoResultMap = true)
@Schema(description = "赛事作品实体")
public class BizContestWork extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "报名ID")
@TableField("registration_id")
private Long registrationId;
@Schema(description = "作品编号")
/** 作品编号(唯一展示编号) */
@TableField("work_no")
private String workNo;
@Schema(description = "作品标题")
private String title;
@Schema(description = "作品描述")
private String description;
@Schema(description = "作品文件JSON")
@TableField(value = "files", typeHandler = JacksonTypeHandler.class)
private Object files;
@Schema(description = "版本号")
private Integer version;
@Schema(description = "是否最新版本")
@TableField("is_latest")
private Boolean isLatest;
@Schema(description = "作品状态submitted/locked/reviewing/rejected/accepted")
/** submitted/locked/reviewing/rejected/accepted */
private String status;
@Schema(description = "提交时间")
@TableField("submit_time")
private LocalDateTime submitTime;
@Schema(description = "提交人用户ID")
@TableField("submitter_user_id")
private Long submitterUserId;
@Schema(description = "提交人账号")
@TableField("submitter_account_no")
private String submitterAccountNo;
@Schema(description = "提交来源teacher/student/team_leader")
/** teacher/student/team_leader */
@TableField("submit_source")
private String submitSource;
@Schema(description = "预览图URL")
@TableField("preview_url")
private String previewUrl;
@Schema(description = "预览图URL列表JSON")
@TableField(value = "preview_urls", typeHandler = JacksonTypeHandler.class)
private List<String> previewUrls;
@Schema(description = "AI模型元数据JSON")
@TableField(value = "ai_model_meta", typeHandler = JacksonTypeHandler.class)
private Object aiModelMeta;
@Schema(description = "用户作品ID")
@TableField("user_work_id")
private Long userWorkId;
// ====== 赛果字段 ======
@Schema(description = "最终得分")
@TableField("final_score")
private BigDecimal finalScore;
@Schema(description = "排名")
@TableField("`rank`")
private Integer rank;
@Schema(description = "获奖等级first/second/third/excellent/none")
/** first/second/third/excellent/none */
@TableField("award_level")
private String awardLevel;
@Schema(description = "奖项名称")
@TableField("award_name")
private String awardName;
@Schema(description = "证书URL")
@TableField("certificate_url")
private String certificateUrl;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -9,54 +8,39 @@ import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_work_attachment")
@Schema(description = "赛事作品附件实体")
public class BizContestWorkAttachment implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "文件名称")
@TableField("file_name")
private String fileName;
@Schema(description = "文件URL")
@TableField("file_url")
private String fileUrl;
@Schema(description = "文件格式")
private String format;
@Schema(description = "文件类型")
@TableField("file_type")
private String fileType;
@Schema(description = "文件大小")
private String size;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -15,7 +15,7 @@ public interface IContestRegistrationService extends IService<BizContestRegistra
PageResult<Map<String, Object>> findAll(QueryRegistrationDto dto, Long tenantId, boolean isSuperTenant);
Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperAdmin);
Map<String, Object> getStats(Long contestId, Long tenantId);
Map<String, Object> findDetail(Long id, Long tenantId);

View File

@ -106,7 +106,6 @@ public class ContestRegistrationServiceImpl extends ServiceImpl<ContestRegistrat
.or().like(BizContestRegistration::getAccountName, dto.getKeyword()));
}
// 租户过滤
if (!isSuperTenant && tenantId != null) {
wrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
@ -124,28 +123,20 @@ public class ContestRegistrationServiceImpl extends ServiceImpl<ContestRegistrat
}
@Override
public Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperAdmin) {
log.info("获取报名统计赛事ID{}租户ID{},超管:{}", contestId, tenantId, isSuperAdmin);
// 非超管需要按租户过滤
boolean needTenantFilter = !isSuperAdmin && tenantId != null;
public Map<String, Object> getStats(Long contestId, Long tenantId) {
log.info("获取报名统计赛事ID{}", contestId);
LambdaQueryWrapper<BizContestRegistration> baseWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
baseWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
baseWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
long total = count(baseWrapper);
LambdaQueryWrapper<BizContestRegistration> pendingWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
pendingWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
pendingWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
pendingWrapper.eq(BizContestRegistration::getRegistrationState, "pending");
long pending = count(pendingWrapper);
@ -153,9 +144,6 @@ public class ContestRegistrationServiceImpl extends ServiceImpl<ContestRegistrat
if (contestId != null) {
passedWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
passedWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
passedWrapper.eq(BizContestRegistration::getRegistrationState, "passed");
long passed = count(passedWrapper);
@ -163,9 +151,6 @@ public class ContestRegistrationServiceImpl extends ServiceImpl<ContestRegistrat
if (contestId != null) {
rejectedWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
rejectedWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
rejectedWrapper.eq(BizContestRegistration::getRegistrationState, "rejected");
long rejected = count(rejectedWrapper);

View File

@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.contest.dto.CreateContestDto;
import com.competition.modules.biz.contest.dto.QueryContestDto;
import com.competition.modules.biz.contest.entity.BizContest;
@ -18,10 +17,6 @@ import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.contest.service.IContestService;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.mapper.SysTenantMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -41,66 +36,28 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
private final ContestAttachmentMapper contestAttachmentMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper;
private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper;
private final SysTenantMapper sysTenantMapper;
// 支持两种日期格式ISO 格式 (T 分隔) 和空格分隔格式
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final DateTimeFormatter SPACE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 解析日期时间字符串兼容 ISO 格式和空格分隔格式
*/
private LocalDateTime parseDateTime(String dateTime) {
if (!StringUtils.hasText(dateTime)) {
return null;
}
// 尝试 ISO 格式 (yyyy-MM-ddTHH:mm:ss)
try {
return LocalDateTime.parse(dateTime, ISO_FORMATTER);
} catch (Exception e) {
// 尝试空格分隔格式 (yyyy-MM-dd HH:mm:ss)
try {
return LocalDateTime.parse(dateTime, SPACE_FORMATTER);
} catch (Exception ex) {
log.warn("日期格式解析失败:{}", dateTime, ex);
return null;
}
}
}
private static final DateTimeFormatter DT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Override
public BizContest createContest(CreateContestDto dto, Long creatorId) {
log.info("开始创建赛事,名称:{}", dto.getContestName());
try {
BizContest entity = new BizContest();
mapDtoToEntity(dto, entity);
BizContest entity = new BizContest();
mapDtoToEntity(dto, entity);
// 默认状态
entity.setContestState("unpublished");
entity.setStatus("ongoing");
entity.setResultState("unpublished");
if (!StringUtils.hasText(entity.getSubmitRule())) {
entity.setSubmitRule("once");
}
entity.setCreator(creatorId != null ? creatorId.intValue() : null);
// 如果没有设置授权租户默认添加当前租户
if (entity.getContestTenants() == null || entity.getContestTenants().isEmpty()) {
Long currentTenantId = SecurityUtil.getCurrentTenantId();
if (currentTenantId != null) {
entity.setContestTenants(Collections.singletonList(currentTenantId.intValue()));
}
}
save(entity);
log.info("赛事创建成功ID{}, 名称:{}", entity.getId(), entity.getContestName());
return entity;
} catch (Exception e) {
log.error("创建赛事失败,名称:{}", dto.getContestName(), e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "创建赛事失败:" + e.getMessage());
// 默认状态
entity.setContestState("unpublished");
entity.setStatus("ongoing");
entity.setResultState("unpublished");
if (!StringUtils.hasText(entity.getSubmitRule())) {
entity.setSubmitRule("once");
}
entity.setCreator(creatorId != null ? creatorId.intValue() : null);
save(entity);
log.info("赛事创建成功ID{}", entity.getId());
return entity;
}
@Override
@ -157,60 +114,8 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
Page<BizContest> page = new Page<>(dto.getPage(), dto.getPageSize());
Page<BizContest> result = contestMapper.selectPage(page, wrapper);
// 批量查询报名数和作品数
List<Long> contestIds = result.getRecords().stream()
.map(BizContest::getId).toList();
Map<Long, Long> registrationCountMap = new HashMap<>();
Map<Long, Long> workCountMap = new HashMap<>();
Map<Long, Long> reviewedWorkCountMap = new HashMap<>();
if (!contestIds.isEmpty()) {
// 报名数所有状态
contestRegistrationMapper.selectList(
new LambdaQueryWrapper<BizContestRegistration>()
.in(BizContestRegistration::getContestId, contestIds))
.stream()
.collect(Collectors.groupingBy(BizContestRegistration::getContestId, Collectors.counting()))
.forEach(registrationCountMap::put);
// 作品最新有效版本评审完成数 = 已分配且该作品全部分配记录均为 completed与评委端ProgressDetail 一致
List<BizContestWork> contestWorks = contestWorkMapper.selectList(
new LambdaQueryWrapper<BizContestWork>()
.in(BizContestWork::getContestId, contestIds)
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getValidState, 1));
Set<Long> workIdSet = contestWorks.stream().map(BizContestWork::getId).collect(Collectors.toSet());
Map<Long, List<BizContestWorkJudgeAssignment>> assignByWorkId = new HashMap<>();
if (!workIdSet.isEmpty()) {
List<BizContestWorkJudgeAssignment> allAssign = contestWorkJudgeAssignmentMapper.selectList(
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
.in(BizContestWorkJudgeAssignment::getWorkId, workIdSet));
assignByWorkId = allAssign.stream()
.collect(Collectors.groupingBy(BizContestWorkJudgeAssignment::getWorkId));
}
for (BizContestWork w : contestWorks) {
Long cid = w.getContestId();
workCountMap.merge(cid, 1L, Long::sum);
List<BizContestWorkJudgeAssignment> assigns = assignByWorkId.getOrDefault(w.getId(), Collections.emptyList());
if (!assigns.isEmpty() && assigns.stream().allMatch(a -> "completed".equals(a.getStatus()))) {
reviewedWorkCountMap.merge(cid, 1L, Long::sum);
}
}
}
List<Map<String, Object>> voList = result.getRecords().stream()
.map(entity -> {
Map<String, Object> map = entityToMap(entity);
Map<String, Object> countMap = new LinkedHashMap<>();
long works = workCountMap.getOrDefault(entity.getId(), 0L);
long reviewedWorks = reviewedWorkCountMap.getOrDefault(entity.getId(), 0L);
countMap.put("registrations", registrationCountMap.getOrDefault(entity.getId(), 0L));
countMap.put("works", works);
map.put("_count", countMap);
map.put("totalWorksCount", works);
map.put("reviewedCount", reviewedWorks);
return map;
})
.map(this::entityToMap)
.collect(Collectors.toList());
return PageResult.from(result, voList);
@ -262,24 +167,6 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
List<BizContestAttachment> attachments = contestAttachmentMapper.selectList(attWrapper);
result.put("attachments", attachments);
// 查询授权租户的详细信息
List<Integer> tenantIds = contest.getContestTenants();
if (tenantIds != null && !tenantIds.isEmpty()) {
List<Map<String, Object>> tenantInfoList = new ArrayList<>();
for (Integer tenantId : tenantIds) {
SysTenant tenant = sysTenantMapper.selectById(tenantId.longValue());
if (tenant != null) {
Map<String, Object> tenantInfo = new LinkedHashMap<>();
tenantInfo.put("id", tenant.getId());
tenantInfo.put("name", tenant.getName());
tenantInfo.put("code", tenant.getCode());
tenantInfo.put("tenantType", tenant.getTenantType());
tenantInfoList.add(tenantInfo);
}
}
result.put("contestTenantInfos", tenantInfoList);
}
return result;
}
@ -436,10 +323,10 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
entity.setAgeMax(dto.getAgeMax());
}
if (StringUtils.hasText(dto.getStartTime())) {
entity.setStartTime(parseDateTime(dto.getStartTime()));
entity.setStartTime(LocalDateTime.parse(dto.getStartTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getEndTime())) {
entity.setEndTime(parseDateTime(dto.getEndTime()));
entity.setEndTime(LocalDateTime.parse(dto.getEndTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getAddress())) {
entity.setAddress(dto.getAddress());
@ -475,10 +362,10 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
entity.setSponsors(dto.getSponsors());
}
if (StringUtils.hasText(dto.getRegisterStartTime())) {
entity.setRegisterStartTime(parseDateTime(dto.getRegisterStartTime()));
entity.setRegisterStartTime(LocalDateTime.parse(dto.getRegisterStartTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getRegisterEndTime())) {
entity.setRegisterEndTime(parseDateTime(dto.getRegisterEndTime()));
entity.setRegisterEndTime(LocalDateTime.parse(dto.getRegisterEndTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getRegisterState())) {
entity.setRegisterState(dto.getRegisterState());
@ -502,10 +389,10 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
entity.setSubmitRule(dto.getSubmitRule());
}
if (StringUtils.hasText(dto.getSubmitStartTime())) {
entity.setSubmitStartTime(parseDateTime(dto.getSubmitStartTime()));
entity.setSubmitStartTime(LocalDateTime.parse(dto.getSubmitStartTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getSubmitEndTime())) {
entity.setSubmitEndTime(parseDateTime(dto.getSubmitEndTime()));
entity.setSubmitEndTime(LocalDateTime.parse(dto.getSubmitEndTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getWorkType())) {
entity.setWorkType(dto.getWorkType());
@ -517,13 +404,13 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
entity.setReviewRuleId(dto.getReviewRuleId());
}
if (StringUtils.hasText(dto.getReviewStartTime())) {
entity.setReviewStartTime(parseDateTime(dto.getReviewStartTime()));
entity.setReviewStartTime(LocalDateTime.parse(dto.getReviewStartTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getReviewEndTime())) {
entity.setReviewEndTime(parseDateTime(dto.getReviewEndTime()));
entity.setReviewEndTime(LocalDateTime.parse(dto.getReviewEndTime(), DT_FORMATTER));
}
if (StringUtils.hasText(dto.getResultPublishTime())) {
entity.setResultPublishTime(parseDateTime(dto.getResultPublishTime()));
entity.setResultPublishTime(LocalDateTime.parse(dto.getResultPublishTime(), DT_FORMATTER));
}
}

View File

@ -18,25 +18,12 @@ import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkAttachmentMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.contest.service.IContestWorkService;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.entity.BizContestReviewRule;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.entity.BizContestWorkScore;
import com.competition.modules.biz.review.mapper.ContestJudgeMapper;
import com.competition.modules.biz.review.mapper.ContestReviewRuleMapper;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper;
import com.competition.modules.biz.review.util.ContestFinalScoreCalculator;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@ -52,11 +39,6 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
private final ContestWorkAttachmentMapper contestWorkAttachmentMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestMapper contestMapper;
private final ContestWorkJudgeAssignmentMapper assignmentMapper;
private final SysUserMapper sysUserMapper;
private final ContestWorkScoreMapper contestWorkScoreMapper;
private final ContestReviewRuleMapper contestReviewRuleMapper;
private final ContestJudgeMapper contestJudgeMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@ -124,7 +106,6 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
work.setStatus("submitted");
work.setSubmitTime(LocalDateTime.now());
work.setSubmitterUserId(submitterId);
work.setSubmitterAccountNo(registration.getAccountNo());
work.setPreviewUrl(dto.getPreviewUrl());
work.setPreviewUrls(dto.getPreviewUrls());
work.setAiModelMeta(dto.getAiModelMeta());
@ -179,57 +160,6 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
} else if (!isSuperTenant && tenantId != null) {
wrapper.eq(BizContestWork::getTenantId, tenantId);
}
// username 筛选对应报名表的 account_no
if (StringUtils.hasText(dto.getUsername())) {
LambdaQueryWrapper<BizContestRegistration> userRegWrapper = new LambdaQueryWrapper<>();
if (dto.getContestId() != null) {
userRegWrapper.eq(BizContestRegistration::getContestId, dto.getContestId());
}
if (dto.getTenantId() != null) {
userRegWrapper.eq(BizContestRegistration::getTenantId, dto.getTenantId());
} else if (!isSuperTenant && tenantId != null) {
userRegWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
userRegWrapper.eq(BizContestRegistration::getValidState, 1);
userRegWrapper.like(BizContestRegistration::getAccountNo, dto.getUsername());
List<BizContestRegistration> userRegs = contestRegistrationMapper.selectList(userRegWrapper);
Set<Long> userRegIds = userRegs.stream()
.map(BizContestRegistration::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!userRegIds.isEmpty()) {
wrapper.in(BizContestWork::getRegistrationId, userRegIds);
} else {
// 没有匹配的报名记录返回空结果
wrapper.eq(BizContestWork::getId, -1L);
}
}
// name 筛选对应报名表的 account_name
if (StringUtils.hasText(dto.getName())) {
LambdaQueryWrapper<BizContestRegistration> nameRegWrapper = new LambdaQueryWrapper<>();
if (dto.getContestId() != null) {
nameRegWrapper.eq(BizContestRegistration::getContestId, dto.getContestId());
}
if (dto.getTenantId() != null) {
nameRegWrapper.eq(BizContestRegistration::getTenantId, dto.getTenantId());
} else if (!isSuperTenant && tenantId != null) {
nameRegWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
nameRegWrapper.eq(BizContestRegistration::getValidState, 1);
nameRegWrapper.like(BizContestRegistration::getAccountName, dto.getName());
List<BizContestRegistration> nameRegs = contestRegistrationMapper.selectList(nameRegWrapper);
Set<Long> nameRegIds = nameRegs.stream()
.map(BizContestRegistration::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!nameRegIds.isEmpty()) {
wrapper.in(BizContestWork::getRegistrationId, nameRegIds);
} else {
wrapper.eq(BizContestWork::getId, -1L);
}
}
Set<Long> keywordRegistrationIds = Collections.emptySet();
if (StringUtils.hasText(dto.getKeyword())) {
String keyword = dto.getKeyword();
@ -270,34 +200,12 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
}
if (StringUtils.hasText(dto.getSubmitStartTime())) {
wrapper.ge(BizContestWork::getSubmitTime, parseDateTime(dto.getSubmitStartTime(), true));
wrapper.ge(BizContestWork::getSubmitTime,
LocalDateTime.parse(dto.getSubmitStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
if (StringUtils.hasText(dto.getSubmitEndTime())) {
wrapper.le(BizContestWork::getSubmitTime, parseDateTime(dto.getSubmitEndTime(), false));
}
// assignStatus 筛选基于分配表判断已分配/未分配
if (StringUtils.hasText(dto.getAssignStatus()) && dto.getContestId() != null) {
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignQueryWrapper = new LambdaQueryWrapper<>();
assignQueryWrapper.eq(BizContestWorkJudgeAssignment::getContestId, dto.getContestId());
assignQueryWrapper.select(BizContestWorkJudgeAssignment::getWorkId);
assignQueryWrapper.groupBy(BizContestWorkJudgeAssignment::getWorkId);
List<BizContestWorkJudgeAssignment> assignedRecords = assignmentMapper.selectList(assignQueryWrapper);
Set<Long> assignedWorkIds = assignedRecords.stream()
.map(BizContestWorkJudgeAssignment::getWorkId)
.collect(Collectors.toSet());
if ("assigned".equals(dto.getAssignStatus())) {
if (assignedWorkIds.isEmpty()) {
wrapper.eq(BizContestWork::getId, -1L);
} else {
wrapper.in(BizContestWork::getId, assignedWorkIds);
}
} else if ("unassigned".equals(dto.getAssignStatus())) {
if (!assignedWorkIds.isEmpty()) {
wrapper.notIn(BizContestWork::getId, assignedWorkIds);
}
}
wrapper.le(BizContestWork::getSubmitTime,
LocalDateTime.parse(dto.getSubmitEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 默认只查最新版本
@ -333,82 +241,8 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
.collect(Collectors.toMap(BizContest::getId, c -> c));
}
// 批量查询分配信息
Set<Long> workIds = result.getRecords().stream()
.map(BizContestWork::getId)
.collect(Collectors.toSet());
Map<Long, List<BizContestWorkJudgeAssignment>> assignmentMap = new HashMap<>();
Map<Long, String> judgeNameMap = new HashMap<>();
if (!workIds.isEmpty()) {
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignWrapper = new LambdaQueryWrapper<>();
assignWrapper.in(BizContestWorkJudgeAssignment::getWorkId, workIds);
if (dto.getContestId() != null) {
assignWrapper.eq(BizContestWorkJudgeAssignment::getContestId, dto.getContestId());
}
List<BizContestWorkJudgeAssignment> allAssignments = assignmentMapper.selectList(assignWrapper);
assignmentMap = allAssignments.stream()
.collect(Collectors.groupingBy(BizContestWorkJudgeAssignment::getWorkId));
// 批量查询评委用户信息
Set<Long> judgeIds = allAssignments.stream()
.map(BizContestWorkJudgeAssignment::getJudgeId)
.collect(Collectors.toSet());
if (!judgeIds.isEmpty()) {
List<SysUser> judgeUsers = sysUserMapper.selectBatchIds(judgeIds);
for (SysUser u : judgeUsers) {
judgeNameMap.put(u.getId(), u.getNickname());
}
}
}
Map<Long, BizContestRegistration> finalRegistrationMap = registrationMap;
Map<Long, BizContest> finalContestMap = contestMap;
Map<Long, List<BizContestWorkJudgeAssignment>> finalAssignmentMap = assignmentMap;
Map<Long, String> finalJudgeNameMap = judgeNameMap;
// 批量评分列表评委评分与详情抽屉一致作品表 final_score 未落库时按评审规则从评分表回算
Map<Long, List<BizContestWorkScore>> scoresByWorkId = new HashMap<>();
if (!workIds.isEmpty()) {
LambdaQueryWrapper<BizContestWorkScore> scoreWrapper = new LambdaQueryWrapper<>();
scoreWrapper.in(BizContestWorkScore::getWorkId, workIds);
scoreWrapper.eq(BizContestWorkScore::getValidState, 1);
List<BizContestWorkScore> allPageScores = contestWorkScoreMapper.selectList(scoreWrapper);
scoresByWorkId = allPageScores.stream()
.collect(Collectors.groupingBy(BizContestWorkScore::getWorkId));
}
Map<Long, String> contestCalculationRuleCache = new HashMap<>();
Map<Long, Map<Long, BigDecimal>> contestWeightMapCache = new HashMap<>();
for (Long cid : contestIds) {
BizContest c = contestMap.get(cid);
if (c == null) {
continue;
}
String calculationRule = "average";
if (c.getReviewRuleId() != null) {
BizContestReviewRule rule = contestReviewRuleMapper.selectById(c.getReviewRuleId());
if (rule != null && StringUtils.hasText(rule.getCalculationRule())) {
calculationRule = rule.getCalculationRule();
}
}
contestCalculationRuleCache.put(cid, calculationRule);
LambdaQueryWrapper<BizContestJudge> judgeWrapper = new LambdaQueryWrapper<>();
judgeWrapper.eq(BizContestJudge::getContestId, cid);
judgeWrapper.eq(BizContestJudge::getValidState, 1);
List<BizContestJudge> judges = contestJudgeMapper.selectList(judgeWrapper);
Map<Long, BigDecimal> weightMap = new HashMap<>();
for (BizContestJudge j : judges) {
weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE);
}
contestWeightMapCache.put(cid, weightMap);
}
Map<Long, List<BizContestWorkScore>> finalScoresByWorkId = scoresByWorkId;
Map<Long, String> finalContestCalculationRuleCache = contestCalculationRuleCache;
Map<Long, Map<Long, BigDecimal>> finalContestWeightMapCache = contestWeightMapCache;
List<Map<String, Object>> voList = result.getRecords().stream()
.map(work -> {
Map<String, Object> map = workToMap(work);
@ -445,49 +279,6 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
map.put("accountName", reg.getAccountName());
map.put("userId", reg.getUserId());
}
// 分配信息
List<BizContestWorkJudgeAssignment> workAssignments = finalAssignmentMap.getOrDefault(work.getId(), Collections.emptyList());
List<Map<String, Object>> assignmentVoList = workAssignments.stream().map(a -> {
Map<String, Object> assignVo = new LinkedHashMap<>();
assignVo.put("id", a.getId());
assignVo.put("judgeId", a.getJudgeId());
assignVo.put("status", a.getStatus());
assignVo.put("assignmentTime", a.getAssignmentTime());
Map<String, Object> judgeVo = new LinkedHashMap<>();
judgeVo.put("id", a.getJudgeId());
judgeVo.put("nickname", finalJudgeNameMap.getOrDefault(a.getJudgeId(), ""));
assignVo.put("judge", judgeVo);
return assignVo;
}).collect(Collectors.toList());
map.put("assignments", assignmentVoList);
// _count 用于分配状态判断
Map<String, Object> countVo = new LinkedHashMap<>();
countVo.put("assignments", workAssignments.size());
map.put("_count", countVo);
int totalJudgesCount = workAssignments.size();
long reviewedCount = workAssignments.stream()
.filter(a -> "completed".equals(a.getStatus()))
.count();
map.put("totalJudgesCount", totalJudgesCount);
map.put("reviewedCount", reviewedCount);
BigDecimal displayFinal = work.getFinalScore();
if (displayFinal == null) {
List<BizContestWorkScore> scores = finalScoresByWorkId.getOrDefault(work.getId(), Collections.emptyList());
if (!scores.isEmpty()) {
String rule = finalContestCalculationRuleCache.getOrDefault(work.getContestId(), "average");
Map<Long, BigDecimal> wm = finalContestWeightMapCache.getOrDefault(work.getContestId(), Collections.emptyMap());
displayFinal = ContestFinalScoreCalculator.compute(scores, rule, wm);
}
}
if (displayFinal != null) {
map.put("finalScore", displayFinal);
map.put("averageScore", displayFinal);
}
return map;
})
.collect(Collectors.toList());
@ -499,16 +290,13 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
public Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant) {
log.info("获取作品统计赛事ID{}", contestId);
// 租户过滤
boolean needTenantFilter = !isSuperTenant && tenantId != null;
LambdaQueryWrapper<BizContestWork> baseWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
baseWrapper.eq(BizContestWork::getContestId, contestId);
}
baseWrapper.eq(BizContestWork::getIsLatest, true);
baseWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
if (!isSuperTenant && tenantId != null) {
baseWrapper.eq(BizContestWork::getTenantId, tenantId);
}
long total = count(baseWrapper);
@ -519,7 +307,7 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
submittedWrapper.eq(BizContestWork::getIsLatest, true);
submittedWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
if (!isSuperTenant && tenantId != null) {
submittedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
submittedWrapper.eq(BizContestWork::getStatus, "submitted");
@ -531,7 +319,7 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
reviewingWrapper.eq(BizContestWork::getIsLatest, true);
reviewingWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
if (!isSuperTenant && tenantId != null) {
reviewingWrapper.eq(BizContestWork::getTenantId, tenantId);
}
reviewingWrapper.eq(BizContestWork::getStatus, "reviewing");
@ -543,9 +331,10 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
reviewedWrapper.eq(BizContestWork::getIsLatest, true);
reviewedWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
if (!isSuperTenant && tenantId != null) {
reviewedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
// 已评完口径兼容 accepted/awarded 两种结果状态
reviewedWrapper.in(BizContestWork::getStatus, Arrays.asList("accepted", "awarded"));
long reviewed = count(reviewedWrapper);
@ -644,18 +433,6 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
// ====== 私有辅助方法 ======
/**
* 解析时间参数支持 "yyyy-MM-dd" "yyyy-MM-ddTHH:mm:ss" 两种格式
* isStart=true 时纯日期补 00:00:00isStart=false 时纯日期补 23:59:59
*/
private LocalDateTime parseDateTime(String value, boolean isStart) {
if (value.contains("T")) {
return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
LocalDate date = LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE);
return isStart ? date.atStartOfDay() : date.atTime(23, 59, 59);
}
private String generateWorkNo(Long contestId) {
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getContestId, contestId);

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,43 +12,33 @@ import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_homework", autoResultMap = true)
@Schema(description = "作业实体")
public class BizHomework extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "作业名称")
private String name;
@Schema(description = "作业内容")
private String content;
@Schema(description = "提交开始时间")
@TableField("submit_start_time")
private LocalDateTime submitStartTime;
@Schema(description = "提交结束时间")
@TableField("submit_end_time")
private LocalDateTime submitEndTime;
@Schema(description = "附件JSON")
@TableField(value = "attachments", typeHandler = JacksonTypeHandler.class)
private Object attachments;
@Schema(description = "发布范围JSON")
@TableField(value = "publish_scope", typeHandler = JacksonTypeHandler.class)
private Object publishScope;
@Schema(description = "评审规则ID")
@TableField("review_rule_id")
private Long reviewRuleId;
@Schema(description = "状态unpublished/published")
/** unpublished / published */
private String status;
@Schema(description = "发布时间")
@TableField("publish_time")
private LocalDateTime publishTime;
}

View File

@ -4,27 +4,22 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_homework_review_rule", autoResultMap = true)
@Schema(description = "作业评审规则实体")
public class BizHomeworkReviewRule extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "规则名称")
private String name;
@Schema(description = "规则描述")
private String description;
@Schema(description = "评分标准JSON数组")
/** JSON array of {name, maxScore, description} */
@TableField(value = "criteria", typeHandler = JacksonTypeHandler.class)
private Object criteria;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -14,33 +13,25 @@ import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_homework_score", autoResultMap = true)
@Schema(description = "作业评分实体")
public class BizHomeworkScore extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "提交ID")
@TableField("submission_id")
private Long submissionId;
@Schema(description = "评审人ID")
@TableField("reviewer_id")
private Long reviewerId;
@Schema(description = "各维度得分JSON")
@TableField(value = "dimension_scores", typeHandler = JacksonTypeHandler.class)
private Object dimensionScores;
@Schema(description = "总分")
@TableField("total_score")
private BigDecimal totalScore;
@Schema(description = "评语")
private String comments;
@Schema(description = "评分时间")
@TableField("score_time")
private LocalDateTime scoreTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -14,49 +13,38 @@ import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_homework_submission", autoResultMap = true)
@Schema(description = "作业提交实体")
public class BizHomeworkSubmission extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "作业ID")
@TableField("homework_id")
private Long homeworkId;
@Schema(description = "学生ID")
@TableField("student_id")
private Long studentId;
@Schema(description = "作品编号")
@TableField("work_no")
private String workNo;
@Schema(description = "作品名称")
@TableField("work_name")
private String workName;
@Schema(description = "作品描述")
@TableField("work_description")
private String workDescription;
@Schema(description = "作品文件JSON")
@TableField(value = "files", typeHandler = JacksonTypeHandler.class)
private Object files;
@Schema(description = "附件JSON")
@TableField(value = "attachments", typeHandler = JacksonTypeHandler.class)
private Object attachments;
@Schema(description = "提交时间")
@TableField("submit_time")
private LocalDateTime submitTime;
@Schema(description = "状态pending/reviewed")
/** pending / reviewed */
private String status;
@Schema(description = "总分")
@TableField("total_score")
private BigDecimal totalScore;
}

View File

@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.judge.service.IJudgesManagementService;
import com.competition.modules.sys.entity.SysRole;
import com.competition.modules.sys.entity.SysTenant;
@ -13,7 +12,6 @@ import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.entity.SysUserRole;
import com.competition.modules.sys.mapper.SysRoleMapper;
import com.competition.modules.sys.mapper.SysTenantMapper;
import com.competition.modules.sys.config.JudgeRolePermissionConfigurer;
import com.competition.modules.sys.mapper.SysUserMapper;
import com.competition.modules.sys.mapper.SysUserRoleMapper;
import lombok.RequiredArgsConstructor;
@ -35,7 +33,6 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
private final SysRoleMapper sysRoleMapper;
private final SysTenantMapper sysTenantMapper;
private final PasswordEncoder passwordEncoder;
private final JudgeRolePermissionConfigurer judgeRolePermissionConfigurer;
/**
* 获取评委专属租户 ID
@ -51,58 +48,23 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
}
/**
* 获取或自动创建评委角色 ID
* 获取评委角色 ID
*/
private Long getOrCreateJudgeRoleId(Long tenantId) {
private Long getJudgeRoleId(Long tenantId) {
LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysRole::getCode, "judge");
wrapper.eq(SysRole::getTenantId, tenantId);
SysRole role = sysRoleMapper.selectOne(wrapper);
if (role != null) {
judgeRolePermissionConfigurer.ensureJudgeRolePermissions(tenantId, role.getId());
return role.getId();
if (role == null) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "评委角色不存在,请先在评委租户下创建 code='judge' 的角色");
}
// 自动创建 judge 角色
role = new SysRole();
role.setTenantId(tenantId);
role.setCode("judge");
role.setName("评委");
role.setDescription("评委角色");
sysRoleMapper.insert(role);
log.info("自动创建评委角色租户ID{}角色ID{}", tenantId, role.getId());
judgeRolePermissionConfigurer.ensureJudgeRolePermissions(tenantId, role.getId());
return role.getId();
}
/**
* 验证评委是否可被当前租户操作查看/编辑/删除
* 返回 true 表示是平台评委只读false 表示是本租户评委
*/
private boolean checkJudgeOwnership(SysUser user) {
Long currentTenantId = SecurityUtil.getCurrentTenantId();
Long judgeTenantId = getJudgeTenantId();
// 不属于当前租户也不属于平台评委租户
if (!currentTenantId.equals(user.getTenantId()) && !judgeTenantId.equals(user.getTenantId())) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
return judgeTenantId.equals(user.getTenantId());
}
/**
* 验证评委可被修改操作非平台评委 + 属于当前租户
*/
private void checkJudgeWritable(SysUser user) {
if (checkJudgeOwnership(user)) {
throw BusinessException.of(ErrorCode.FORBIDDEN, "平台评委不允许修改");
}
}
/**
* SysUser 转为前端需要的 Map
*/
private Map<String, Object> toMap(SysUser user) {
Long judgeTenantId = getJudgeTenantId();
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", user.getId());
map.put("username", user.getUsername());
@ -115,8 +77,6 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
map.put("userSource", user.getUserSource());
map.put("createTime", user.getCreateTime());
map.put("modifyTime", user.getModifyTime());
map.put("tenantId", user.getTenantId());
map.put("isPlatform", judgeTenantId.equals(user.getTenantId()));
return map;
}
@ -137,19 +97,19 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "密码不能为空");
}
Long currentTenantId = SecurityUtil.getCurrentTenantId();
Long judgeTenantId = getJudgeTenantId();
// 检查用户名在当前租户内唯一
// 检查用户名在评委租户内唯一
LambdaQueryWrapper<SysUser> dupWrapper = new LambdaQueryWrapper<>();
dupWrapper.eq(SysUser::getTenantId, currentTenantId);
dupWrapper.eq(SysUser::getTenantId, judgeTenantId);
dupWrapper.eq(SysUser::getUsername, username);
if (sysUserMapper.selectCount(dupWrapper) > 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "该用户名已存在");
}
// 创建用户归属当前租户
// 创建用户
SysUser user = new SysUser();
user.setTenantId(currentTenantId);
user.setTenantId(judgeTenantId);
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(nickname);
@ -161,8 +121,8 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
user.setStatus("enabled");
sysUserMapper.insert(user);
// 分配评委角色在当前租户下查找或自动创建 judge 角色
Long judgeRoleId = getOrCreateJudgeRoleId(currentTenantId);
// 分配评委角色
Long judgeRoleId = getJudgeRoleId(judgeTenantId);
SysUserRole userRole = new SysUserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(judgeRoleId);
@ -174,41 +134,10 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
@Override
public PageResult<Map<String, Object>> findAll(Long page, Long pageSize, String keyword, String status) {
Long currentTenantId = SecurityUtil.getCurrentTenantId();
Long judgeTenantId = getJudgeTenantId();
// 查询当前租户和平台评委租户的 judge 角色 ID
Set<Long> judgeRoleIds = new HashSet<>();
for (Long tid : List.of(currentTenantId, judgeTenantId)) {
if (tid == null) continue;
LambdaQueryWrapper<SysRole> roleWrapper = new LambdaQueryWrapper<>();
roleWrapper.eq(SysRole::getCode, "judge");
roleWrapper.eq(SysRole::getTenantId, tid);
SysRole role = sysRoleMapper.selectOne(roleWrapper);
if (role != null) {
judgeRoleIds.add(role.getId());
}
}
// 查询拥有 judge 角色的用户 ID
Set<Long> judgeUserIds = new HashSet<>();
if (!judgeRoleIds.isEmpty()) {
LambdaQueryWrapper<SysUserRole> urWrapper = new LambdaQueryWrapper<>();
urWrapper.in(SysUserRole::getRoleId, judgeRoleIds);
List<SysUserRole> userRoles = sysUserRoleMapper.selectList(urWrapper);
for (SysUserRole ur : userRoles) {
judgeUserIds.add(ur.getUserId());
}
}
if (judgeUserIds.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L, page, pageSize);
}
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
// 只查询拥有 judge 角色且属于当前租户或平台评委租户的用户
wrapper.in(SysUser::getId, judgeUserIds);
wrapper.in(SysUser::getTenantId, List.of(currentTenantId, judgeTenantId));
wrapper.eq(SysUser::getTenantId, judgeTenantId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w
@ -239,7 +168,12 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
if (user == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
checkJudgeOwnership(user);
Long judgeTenantId = getJudgeTenantId();
if (!judgeTenantId.equals(user.getTenantId())) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
return toMap(user);
}
@ -250,7 +184,11 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
if (user == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
checkJudgeWritable(user);
Long judgeTenantId = getJudgeTenantId();
if (!judgeTenantId.equals(user.getTenantId())) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
if (params.containsKey("nickname")) {
user.setNickname((String) params.get("nickname"));
@ -293,7 +231,11 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
if (user == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
checkJudgeWritable(user);
Long judgeTenantId = getJudgeTenantId();
if (!judgeTenantId.equals(user.getTenantId())) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
user.setStatus(status);
sysUserMapper.updateById(user);
@ -305,7 +247,11 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
if (user == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
checkJudgeWritable(user);
Long judgeTenantId = getJudgeTenantId();
if (!judgeTenantId.equals(user.getTenantId())) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "评委不存在");
}
sysUserMapper.deleteById(id);
log.info("评委已删除ID{}", id);
@ -318,15 +264,15 @@ public class JudgesManagementServiceImpl implements IJudgesManagementService {
return;
}
Long currentTenantId = SecurityUtil.getCurrentTenantId();
Long judgeTenantId = getJudgeTenantId();
// 校验所有 ID 都属于当前租户不允许删除平台评委
// 校验所有 ID 都属于评委租户
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.in(SysUser::getId, ids);
wrapper.eq(SysUser::getTenantId, currentTenantId);
wrapper.eq(SysUser::getTenantId, judgeTenantId);
Long count = sysUserMapper.selectCount(wrapper);
if (count != ids.size()) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "部分评委不存在或不属于当前机构");
throw BusinessException.of(ErrorCode.BAD_REQUEST, "部分评委不存在或不属于评委库");
}
sysUserMapper.deleteBatchIds(ids);

View File

@ -1,7 +1,6 @@
package com.competition.modules.biz.review.controller;
import com.competition.common.result.Result;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.service.IContestJudgeService;
import com.competition.security.annotation.RequirePermission;
@ -11,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Tag(name = "赛事评委")
@ -35,9 +35,8 @@ public class ContestJudgeController {
@GetMapping("/contest/{contestId}")
@RequirePermission("contest:read")
@Operation(summary = "查询赛事评委列表",
description = "返回 assigned显式关联与 implicitPool平台隐式池。添加评委抽屉仅用 assigned 回显;作品分配可选池为 assigned implicitPool前端合并")
public Result<ContestJudgesForContestVo> findByContest(@PathVariable Long contestId) {
@Operation(summary = "查询赛事评委列表")
public Result<List<Map<String, Object>>> findByContest(@PathVariable Long contestId) {
return Result.success(contestJudgeService.findByContest(contestId));
}

View File

@ -129,12 +129,4 @@ public class ContestReviewController {
public Result<Map<String, Object>> calculateFinalScore(@PathVariable Long workId) {
return Result.success(contestReviewService.calculateFinalScore(workId));
}
@GetMapping("/judge/contests/{contestId}/detail")
@RequirePermission("review:read")
@Operation(summary = "获取评委视角的赛事详情(含评审规则)")
public Result<Map<String, Object>> getJudgeContestDetail(@PathVariable Long contestId) {
Long judgeId = SecurityUtil.getCurrentUserId();
return Result.success(contestReviewService.getJudgeContestDetail(judgeId, contestId));
}
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.biz.review.controller;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.review.dto.CreatePresetCommentDto;
import com.competition.modules.biz.review.dto.SyncPresetCommentsDto;
import com.competition.modules.biz.review.entity.BizPresetComment;
import com.competition.modules.biz.review.service.IPresetCommentService;
import io.swagger.v3.oas.annotations.Operation;
@ -66,10 +65,11 @@ public class PresetCommentController {
return Result.success();
}
@SuppressWarnings("unchecked")
@PostMapping("/batch-delete")
@Operation(summary = "批量删除预设评语")
public Result<Void> batchDelete(@RequestBody Map<String, List<Long>> body) {
List<Long> ids = body.get("ids");
public Result<Void> batchDelete(@RequestBody Map<String, Object> body) {
List<Long> ids = (List<Long>) body.get("ids");
Long judgeId = SecurityUtil.getCurrentUserId();
presetCommentService.batchDelete(ids, judgeId);
return Result.success();
@ -77,9 +77,12 @@ public class PresetCommentController {
@PostMapping("/sync")
@Operation(summary = "同步评语到其他赛事")
public Result<Map<String, Object>> syncComments(@Valid @RequestBody SyncPresetCommentsDto dto) {
public Result<Map<String, Object>> syncComments(@RequestBody Map<String, Object> body) {
Long sourceContestId = Long.valueOf(body.get("sourceContestId").toString());
@SuppressWarnings("unchecked")
List<Long> targetContestIds = (List<Long>) body.get("targetContestIds");
Long judgeId = SecurityUtil.getCurrentUserId();
return Result.success(presetCommentService.syncComments(dto.getSourceContestId(), dto.getTargetContestIds(), judgeId));
return Result.success(presetCommentService.syncComments(sourceContestId, targetContestIds, judgeId));
}
@PostMapping("/{id}/use")

View File

@ -1,21 +0,0 @@
package com.competition.modules.biz.review.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 某赛事下的评委数据显式关联与平台隐式池分离避免扁平列表语义混淆
*/
@Data
@Schema(description = "赛事评委查询结果")
public class ContestJudgesForContestVo {
@Schema(description = "机构为该赛事显式添加的评委t_biz_contest_judge每条必有 id、judgeId添加评委抽屉回显与提交差集仅基于此列表")
private List<Map<String, Object>> assigned;
@Schema(description = "平台评委租户下对该赛事默认可用、未写入关联表的用户id 为 nullisPlatform 为 true可与 assigned 合并作为作品分配可选池")
private List<Map<String, Object>> implicitPool;
}

View File

@ -1,21 +0,0 @@
package com.competition.modules.biz.review.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "同步预设评语DTO")
public class SyncPresetCommentsDto {
@NotNull(message = "源赛事ID不能为空")
@Schema(description = "源赛事ID")
private Long sourceContestId;
@NotEmpty(message = "目标赛事列表不能为空")
@Schema(description = "目标赛事ID列表")
private List<Long> targetContestIds;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.biz.review.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -12,23 +11,19 @@ import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_judge")
@Schema(description = "赛事评委实体")
public class BizContestJudge extends BaseEntity {
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "评委用户ID")
/** 用户 ID */
@TableField("judge_id")
private Long judgeId;
@Schema(description = "专业特长")
private String specialty;
@Schema(description = "评委权重0-1")
/** 评委权重 0-1, Decimal(3,2) */
private BigDecimal weight;
@Schema(description = "评委描述")
private String description;
}

View File

@ -4,37 +4,31 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_contest_review_rule", autoResultMap = true)
@Schema(description = "赛事评审规则实体")
public class BizContestReviewRule extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "规则名称")
@TableField("rule_name")
private String ruleName;
@Schema(description = "规则描述")
@TableField("rule_description")
private String ruleDescription;
@Schema(description = "评委人数")
@TableField("judge_count")
private Integer judgeCount;
@Schema(description = "评分维度JSON数组")
/** JSON array of {name, percentage, description} */
@TableField(value = "dimensions", typeHandler = JacksonTypeHandler.class)
private Object dimensions;
@Schema(description = "计算规则average/remove_max_min/remove_min/max/weighted")
/** average/remove_max_min/remove_min/max/weighted */
@TableField("calculation_rule")
private String calculationRule;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,43 +11,33 @@ import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_work_judge_assignment")
@Schema(description = "赛事作品评委分配实体")
public class BizContestWorkJudgeAssignment implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "评委ID")
@TableField("judge_id")
private Long judgeId;
@Schema(description = "分配时间")
@TableField("assignment_time")
private LocalDateTime assignmentTime;
@Schema(description = "状态assigned/reviewing/completed")
/** assigned/reviewing/completed */
private String status;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -14,45 +13,34 @@ import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_contest_work_score", autoResultMap = true)
@Schema(description = "赛事作品评分实体")
public class BizContestWorkScore extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "分配ID")
@TableField("assignment_id")
private Long assignmentId;
@Schema(description = "评委ID")
@TableField("judge_id")
private Long judgeId;
@Schema(description = "评委姓名")
@TableField("judge_name")
private String judgeName;
@Schema(description = "各维度得分JSON")
@TableField(value = "dimension_scores", typeHandler = JacksonTypeHandler.class)
private Object dimensionScores;
@Schema(description = "总分")
@TableField("total_score")
private BigDecimal totalScore;
@Schema(description = "评语")
private String comments;
@Schema(description = "评分时间")
@TableField("score_time")
private LocalDateTime scoreTime;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.biz.review.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -12,28 +11,22 @@ import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_preset_comment")
@Schema(description = "预设评语实体")
public class BizPresetComment extends BaseEntity {
@Schema(description = "赛事ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "评委ID")
@TableField("judge_id")
private Long judgeId;
@Schema(description = "评语内容")
private String content;
@Schema(description = "对应分数")
/** Decimal(10,2) */
private BigDecimal score;
@Schema(description = "排序序号")
@TableField("sort_order")
private Integer sortOrder;
@Schema(description = "使用次数")
@TableField("use_count")
private Integer useCount;
}

View File

@ -1,21 +1,17 @@
package com.competition.modules.biz.review.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface IContestJudgeService extends IService<BizContestJudge> {
BizContestJudge createJudge(Long contestId, Long judgeId, String specialty, BigDecimal weight, String description);
/**
* 查询某赛事评委{@link ContestJudgesForContestVo#getAssigned()} 为显式关联
* {@link ContestJudgesForContestVo#getImplicitPool()} 为平台默认可用未落库项
*/
ContestJudgesForContestVo findByContest(Long contestId);
List<Map<String, Object>> findByContest(Long contestId);
Map<String, Object> findDetail(Long id);

View File

@ -31,6 +31,4 @@ public interface IContestReviewService {
List<Map<String, Object>> getWorkScores(Long workId);
Map<String, Object> calculateFinalScore(Long workId);
Map<String, Object> getJudgeContestDetail(Long judgeId, Long contestId);
}

View File

@ -4,15 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.mapper.ContestJudgeMapper;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.biz.review.service.IContestJudgeService;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysTenantMapper;
import com.competition.modules.sys.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -28,9 +23,7 @@ import java.util.stream.Collectors;
public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, BizContestJudge> implements IContestJudgeService {
private final ContestJudgeMapper contestJudgeMapper;
private final ContestWorkJudgeAssignmentMapper assignmentMapper;
private final SysUserMapper sysUserMapper;
private final SysTenantMapper sysTenantMapper;
@Override
public BizContestJudge createJudge(Long contestId, Long judgeId, String specialty, BigDecimal weight, String description) {
@ -58,13 +51,9 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
}
@Override
public ContestJudgesForContestVo findByContest(Long contestId) {
public List<Map<String, Object>> findByContest(Long contestId) {
log.info("查询赛事评委列表赛事ID{}", contestId);
// 获取平台评委租户 ID
Long judgeTenantId = getJudgeTenantId();
// 1. 查询已显式分配的评委
LambdaQueryWrapper<BizContestJudge> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestJudge::getContestId, contestId);
wrapper.eq(BizContestJudge::getValidState, 1);
@ -72,51 +61,17 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
List<BizContestJudge> judges = contestJudgeMapper.selectList(wrapper);
// 收集所有需要查询的用户 ID已分配评委
Set<Long> assignedJudgeIds = judges.stream().map(BizContestJudge::getJudgeId).collect(Collectors.toSet());
// 2. 查询平台评委自动对所有赛事可用
List<SysUser> platformJudges = new ArrayList<>();
if (judgeTenantId != null) {
LambdaQueryWrapper<SysUser> platformWrapper = new LambdaQueryWrapper<>();
platformWrapper.eq(SysUser::getTenantId, judgeTenantId);
platformWrapper.eq(SysUser::getStatus, "enabled");
platformJudges = sysUserMapper.selectList(platformWrapper);
}
// 平台评委中去掉已显式分配的避免重复
Set<Long> platformOnlyIds = platformJudges.stream()
.map(SysUser::getId)
.filter(id -> !assignedJudgeIds.contains(id))
.collect(Collectors.toSet());
// 合并所有需要查询的用户 ID
Set<Long> allUserIds = new HashSet<>(assignedJudgeIds);
allUserIds.addAll(platformOnlyIds);
// 批量查询用户信息
Set<Long> userIds = judges.stream().map(BizContestJudge::getJudgeId).collect(Collectors.toSet());
Map<Long, SysUser> userMap = new HashMap<>();
if (!allUserIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(allUserIds);
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
for (SysUser user : users) {
userMap.put(user.getId(), user);
}
}
// 批量查询每个评委在该赛事下的已分配作品数
Map<Long, Long> assignedCountMap = new HashMap<>();
for (Long judgeId : allUserIds) {
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignWrapper = new LambdaQueryWrapper<>();
assignWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
assignWrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
assignedCountMap.put(judgeId, assignmentMapper.selectCount(assignWrapper));
}
List<Map<String, Object>> assigned = new ArrayList<>();
List<Map<String, Object>> implicitPool = new ArrayList<>();
// 构建已显式分配的评委数据
for (BizContestJudge j : judges) {
return judges.stream().map(j -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", j.getId());
map.put("contestId", j.getContestId());
@ -125,59 +80,14 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
map.put("weight", j.getWeight());
map.put("description", j.getDescription());
map.put("createTime", j.getCreateTime());
map.put("assignedCount", assignedCountMap.getOrDefault(j.getJudgeId(), 0L));
SysUser user = userMap.get(j.getJudgeId());
if (user != null) {
map.put("judgeName", user.getNickname());
map.put("judgeUsername", user.getUsername());
map.put("tenantId", user.getTenantId());
map.put("status", user.getStatus());
map.put("organization", user.getOrganization());
map.put("isPlatform", judgeTenantId != null && judgeTenantId.equals(user.getTenantId()));
} else {
map.put("isPlatform", false);
}
assigned.add(map);
}
// 未显式分配的平台评委隐式池
for (SysUser platformJudge : platformJudges) {
if (assignedJudgeIds.contains(platformJudge.getId())) {
continue; // 已在显式分配列表中跳过
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", null);
map.put("contestId", contestId);
map.put("judgeId", platformJudge.getId());
map.put("specialty", null);
map.put("weight", null);
map.put("description", null);
map.put("createTime", null);
map.put("assignedCount", assignedCountMap.getOrDefault(platformJudge.getId(), 0L));
map.put("judgeName", platformJudge.getNickname());
map.put("judgeUsername", platformJudge.getUsername());
map.put("tenantId", platformJudge.getTenantId());
map.put("status", platformJudge.getStatus());
map.put("organization", platformJudge.getOrganization());
map.put("isPlatform", true);
implicitPool.add(map);
}
ContestJudgesForContestVo vo = new ContestJudgesForContestVo();
vo.setAssigned(assigned);
vo.setImplicitPool(implicitPool);
return vo;
}
/**
* 获取平台评委租户 ID
*/
private Long getJudgeTenantId() {
LambdaQueryWrapper<SysTenant> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysTenant::getCode, "judge");
SysTenant tenant = sysTenantMapper.selectOne(wrapper);
return tenant != null ? tenant.getId() : null;
return map;
}).collect(Collectors.toList());
}
@Override

View File

@ -6,10 +6,8 @@ import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import com.competition.modules.biz.contest.entity.BizContestWork;
import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.review.dto.CreateScoreDto;
import com.competition.modules.biz.review.entity.BizContestJudge;
@ -44,7 +42,6 @@ public class ContestReviewServiceImpl implements IContestReviewService {
private final ContestJudgeMapper judgeMapper;
private final ContestWorkMapper workMapper;
private final ContestMapper contestMapper;
private final ContestRegistrationMapper registrationMapper;
private final ContestReviewRuleMapper reviewRuleMapper;
private final SysUserMapper sysUserMapper;
@ -313,55 +310,28 @@ public class ContestReviewServiceImpl implements IContestReviewService {
public List<Map<String, Object>> getJudgeContests(Long judgeId) {
log.info("查询评委关联赛事评委ID{}", judgeId);
Set<Long> contestIds = new LinkedHashSet<>();
LambdaQueryWrapper<BizContestJudge> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestJudge::getJudgeId, judgeId);
wrapper.eq(BizContestJudge::getValidState, 1);
LambdaQueryWrapper<BizContestJudge> judgeQw = new LambdaQueryWrapper<>();
judgeQw.eq(BizContestJudge::getJudgeId, judgeId);
judgeQw.eq(BizContestJudge::getValidState, 1);
for (BizContestJudge r : judgeMapper.selectList(judgeQw)) {
contestIds.add(r.getContestId());
}
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignQw = new LambdaQueryWrapper<>();
assignQw.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
assignQw.select(BizContestWorkJudgeAssignment::getContestId);
for (BizContestWorkJudgeAssignment a : assignmentMapper.selectList(assignQw)) {
contestIds.add(a.getContestId());
}
List<BizContestJudge> judgeRecords = judgeMapper.selectList(wrapper);
Set<Long> contestIds = judgeRecords.stream().map(BizContestJudge::getContestId).collect(Collectors.toSet());
if (contestIds.isEmpty()) {
return Collections.emptyList();
}
List<BizContest> contests = contestMapper.selectBatchIds(contestIds);
return contests.stream()
.sorted(Comparator.comparing(BizContest::getId).reversed())
.map(c -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("contestId", c.getId());
map.put("contestName", c.getContestName());
map.put("contestState", c.getContestState());
map.put("status", c.getStatus());
map.put("reviewStartTime", c.getReviewStartTime());
map.put("reviewEndTime", c.getReviewEndTime());
LambdaQueryWrapper<BizContestWorkJudgeAssignment> totalW = new LambdaQueryWrapper<>();
totalW.eq(BizContestWorkJudgeAssignment::getContestId, c.getId());
totalW.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
long totalAssigned = assignmentMapper.selectCount(totalW);
LambdaQueryWrapper<BizContestWorkJudgeAssignment> doneW = new LambdaQueryWrapper<>();
doneW.eq(BizContestWorkJudgeAssignment::getContestId, c.getId());
doneW.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
doneW.eq(BizContestWorkJudgeAssignment::getStatus, "completed");
long reviewed = assignmentMapper.selectCount(doneW);
map.put("totalAssigned", totalAssigned);
map.put("reviewed", reviewed);
map.put("pending", totalAssigned - reviewed);
return map;
})
.collect(Collectors.toList());
return contests.stream().map(c -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("contestId", c.getId());
map.put("contestName", c.getContestName());
map.put("contestState", c.getContestState());
map.put("status", c.getStatus());
map.put("reviewStartTime", c.getReviewStartTime());
map.put("reviewEndTime", c.getReviewEndTime());
return map;
}).collect(Collectors.toList());
}
@Override
@ -375,13 +345,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
wrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
if (StringUtils.hasText(reviewStatus)) {
if ("reviewed".equalsIgnoreCase(reviewStatus)) {
wrapper.eq(BizContestWorkJudgeAssignment::getStatus, "completed");
} else if ("pending".equalsIgnoreCase(reviewStatus)) {
wrapper.ne(BizContestWorkJudgeAssignment::getStatus, "completed");
} else {
wrapper.eq(BizContestWorkJudgeAssignment::getStatus, reviewStatus);
}
wrapper.eq(BizContestWorkJudgeAssignment::getStatus, reviewStatus);
}
wrapper.orderByAsc(BizContestWorkJudgeAssignment::getAssignmentTime);
@ -413,39 +377,19 @@ public class ContestReviewServiceImpl implements IContestReviewService {
}
}
// 批量查询报名信息用于补充submitterAccountNo
Set<Long> registrationIds = workMap.values().stream()
.map(BizContestWork::getRegistrationId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> regAccountMap = new HashMap<>();
if (!registrationIds.isEmpty()) {
List<BizContestRegistration> registrations = registrationMapper.selectBatchIds(registrationIds);
for (BizContestRegistration r : registrations) {
regAccountMap.put(r.getId(), r.getAccountNo());
}
}
// 组装VO并应用筛选
List<Map<String, Object>> voList = new ArrayList<>();
for (BizContestWorkJudgeAssignment a : assignments) {
BizContestWork work = workMap.get(a.getWorkId());
if (work == null) continue;
// 优先取作品上的账号为空则从报名记录补充
String submitterAcc = work.getSubmitterAccountNo();
if (submitterAcc == null && work.getRegistrationId() != null) {
submitterAcc = regAccountMap.get(work.getRegistrationId());
}
// workNo筛选
if (StringUtils.hasText(workNo) && (work.getWorkNo() == null
|| !work.getWorkNo().contains(workNo))) {
if (StringUtils.hasText(workNo) && !work.getWorkNo().contains(workNo)) {
continue;
}
// accountNo筛选
if (StringUtils.hasText(accountNo) && (submitterAcc == null
|| !submitterAcc.contains(accountNo))) {
if (StringUtils.hasText(accountNo) && work.getSubmitterAccountNo() != null
&& !work.getSubmitterAccountNo().contains(accountNo)) {
continue;
}
@ -458,7 +402,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
map.put("title", work.getTitle());
map.put("previewUrl", work.getPreviewUrl());
map.put("previewUrls", work.getPreviewUrls());
map.put("submitterAccountNo", submitterAcc);
map.put("submitterAccountNo", work.getSubmitterAccountNo());
BizContestWorkScore scoreRecord = scoreMap.get(a.getId());
if (scoreRecord != null) {
@ -701,57 +645,4 @@ public class ContestReviewServiceImpl implements IContestReviewService {
result.put("calculationRule", calculationRule);
return result;
}
// ====== 评委赛事详情 ======
@Override
public Map<String, Object> getJudgeContestDetail(Long judgeId, Long contestId) {
log.info("获取评委赛事详情评委ID{}赛事ID{}", judgeId, contestId);
// 显式评委 已有作品分配记录
LambdaQueryWrapper<BizContestJudge> judgeWrapper = new LambdaQueryWrapper<>();
judgeWrapper.eq(BizContestJudge::getContestId, contestId);
judgeWrapper.eq(BizContestJudge::getJudgeId, judgeId);
judgeWrapper.eq(BizContestJudge::getValidState, 1);
boolean explicitJudge = judgeMapper.selectCount(judgeWrapper) > 0;
if (!explicitJudge) {
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignCheck = new LambdaQueryWrapper<>();
assignCheck.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
assignCheck.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
if (assignmentMapper.selectCount(assignCheck) == 0) {
throw BusinessException.of(ErrorCode.FORBIDDEN, "您不是该赛事的评委");
}
}
BizContest contest = contestMapper.selectById(contestId);
if (contest == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在");
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("contestId", contest.getId());
result.put("contestName", contest.getContestName());
result.put("contestType", contest.getContestType());
result.put("contestState", contest.getContestState());
result.put("status", contest.getStatus());
result.put("reviewStartTime", contest.getReviewStartTime());
result.put("reviewEndTime", contest.getReviewEndTime());
// 评审规则前端ReviewWorkModal需要此数据
if (contest.getReviewRuleId() != null) {
BizContestReviewRule rule = reviewRuleMapper.selectById(contest.getReviewRuleId());
if (rule != null) {
Map<String, Object> ruleMap = new LinkedHashMap<>();
ruleMap.put("id", rule.getId());
ruleMap.put("ruleName", rule.getRuleName());
ruleMap.put("ruleDescription", rule.getRuleDescription());
ruleMap.put("judgeCount", rule.getJudgeCount());
ruleMap.put("dimensions", rule.getDimensions());
ruleMap.put("calculationRule", rule.getCalculationRule());
result.put("reviewRule", ruleMap);
}
}
return result;
}
}

View File

@ -6,8 +6,6 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.modules.biz.review.dto.CreatePresetCommentDto;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.entity.BizPresetComment;
import com.competition.modules.biz.review.mapper.ContestJudgeMapper;
@ -27,7 +25,6 @@ public class PresetCommentServiceImpl extends ServiceImpl<PresetCommentMapper, B
private final PresetCommentMapper presetCommentMapper;
private final ContestJudgeMapper contestJudgeMapper;
private final ContestMapper contestMapper;
@Override
public BizPresetComment createComment(CreatePresetCommentDto dto, Long judgeId) {
@ -165,14 +162,7 @@ public class PresetCommentServiceImpl extends ServiceImpl<PresetCommentMapper, B
return judgeRecords.stream().map(j -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", j.getContestId());
// 查询赛事详情获取名称和状态
BizContest contest = contestMapper.selectById(j.getContestId());
if (contest != null) {
map.put("contestName", contest.getContestName());
map.put("contestState", contest.getContestState());
map.put("status", contest.getStatus());
}
map.put("contestId", j.getContestId());
return map;
}).collect(Collectors.toList());
}
@ -211,8 +201,9 @@ public class PresetCommentServiceImpl extends ServiceImpl<PresetCommentMapper, B
log.info("预设评语同步完成,新建数量:{}", created);
Map<String, Object> result = new LinkedHashMap<>();
result.put("message", "同步成功");
result.put("count", created);
result.put("sourceCount", sourceComments.size());
result.put("targetContests", targetContestIds.size());
result.put("createdCount", created);
return result;
}

View File

@ -1,72 +0,0 @@
package com.competition.modules.biz.review.util;
import com.competition.modules.biz.review.entity.BizContestWorkScore;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* {@code ContestReviewServiceImpl#calculateFinalScore}成果发布计算逻辑一致的终分计算列表场景对不足条数规则做温和回退
*/
public final class ContestFinalScoreCalculator {
private ContestFinalScoreCalculator() {
}
public static BigDecimal compute(
List<BizContestWorkScore> scores,
String calculationRule,
Map<Long, BigDecimal> weightMap) {
if (scores == null || scores.isEmpty()) {
return null;
}
String rule = calculationRule != null ? calculationRule : "average";
Map<Long, BigDecimal> wm = weightMap != null ? weightMap : Collections.emptyMap();
List<BigDecimal> scoreValues = scores.stream()
.map(BizContestWorkScore::getTotalScore)
.sorted()
.collect(Collectors.toList());
switch (rule) {
case "max":
return Collections.max(scoreValues);
case "min":
return Collections.min(scoreValues);
case "weighted":
BigDecimal weightedSum = BigDecimal.ZERO;
BigDecimal totalWeight = BigDecimal.ZERO;
for (BizContestWorkScore s : scores) {
BigDecimal w = wm.getOrDefault(s.getJudgeId(), BigDecimal.ONE);
weightedSum = weightedSum.add(s.getTotalScore().multiply(w));
totalWeight = totalWeight.add(w);
}
return totalWeight.compareTo(BigDecimal.ZERO) > 0
? weightedSum.divide(totalWeight, 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
case "remove_max_min":
if (scoreValues.size() < 3) {
BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP);
}
List<BigDecimal> trimmed = scoreValues.subList(1, scoreValues.size() - 1);
BigDecimal trimmedSum = trimmed.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return trimmedSum.divide(BigDecimal.valueOf(trimmed.size()), 2, RoundingMode.HALF_UP);
case "remove_min":
if (scoreValues.size() < 2) {
return scoreValues.get(0);
}
List<BigDecimal> withoutMin = scoreValues.subList(1, scoreValues.size());
BigDecimal withoutMinSum = withoutMin.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return withoutMinSum.divide(BigDecimal.valueOf(withoutMin.size()), 2, RoundingMode.HALF_UP);
case "average":
default:
BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP);
}
}
}

View File

@ -1,28 +0,0 @@
package com.competition.modules.leai.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 乐读派 AI 创作系统配置
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "leai")
public class LeaiConfig {
/** 机构ID乐读派管理后台分配 */
// private String orgId = "LESINGLE888888888";
private String orgId = "gdlib";
/** 机构密钥(乐读派管理后台分配) */
private String appSecret = "leai_mnoi9q1a_mtcawrn8y";
// private String appSecret = "leai_test_secret_2026_abc123xyz";
/** 乐读派后端 API 地址 */
private String apiUrl = "http://192.168.1.72:8080";
/** 乐读派 H5 前端地址 */
private String h5Url = "http://192.168.1.72:3001";
}

View File

@ -1,147 +0,0 @@
package com.competition.modules.leai.controller;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.leai.config.LeaiConfig;
import com.competition.modules.leai.dto.LeaiAuthRedirectDTO;
import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.vo.LeaiTokenVO;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.service.ISysUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 乐读派认证入口控制器
* 前端 iframe 模式的主入口
*/
@Slf4j
@Tag(name = "乐读派认证")
@RestController
@RequestMapping("/leai-auth")
@RequiredArgsConstructor
public class LeaiAuthController {
private final LeaiApiClient leaiApiClient;
private final LeaiConfig leaiConfig;
private final ISysUserService sysUserService;
/**
* 前端 iframe 主入口返回 token 信息 JSON
* 需要登录认证
*/
@GetMapping("/token")
@Operation(summary = "获取乐读派创作 Token")
public Result<LeaiTokenVO> getToken() {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserService.getById(userId);
if (user == null) {
throw new BusinessException(404, "用户不存在");
}
String phone = user.getPhone();
if (phone == null || phone.isEmpty()) {
throw new BusinessException(400, "用户未绑定手机号,无法使用创作功能");
}
try {
String token = leaiApiClient.exchangeToken(phone);
// Entity VO 转换Controller 层负责
// 注意: orgId 对应本项目的租户 codetenant_code
LeaiTokenVO vo = new LeaiTokenVO();
vo.setToken(token);
vo.setOrgId(leaiConfig.getOrgId()); // 即租户 tenant_code
vo.setH5Url(leaiConfig.getH5Url());
vo.setPhone(phone);
log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone);
return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[乐读派] 获取创作Token失败, userId={}", userId, e);
throw new BusinessException(500, "获取创作Token失败: " + e.getMessage());
}
}
/**
* 跳转模式备选 token + 302 重定向到 H5
* 需要登录认证
*/
@GetMapping
@Operation(summary = "重定向到乐读派 H5 创作页")
public void authRedirect(LeaiAuthRedirectDTO dto, HttpServletResponse response) throws IOException {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
response.sendError(401, "请先登录并绑定手机号");
return;
}
String phone = user.getPhone();
try {
String token = leaiApiClient.exchangeToken(phone);
StringBuilder url = new StringBuilder(leaiConfig.getH5Url())
.append("/?token=").append(URLEncoder.encode(token, StandardCharsets.UTF_8))
.append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8))
.append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8));
if (dto.getReturnPath() != null && !dto.getReturnPath().isEmpty()) {
url.append("&returnPath=").append(URLEncoder.encode(dto.getReturnPath(), StandardCharsets.UTF_8));
}
log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone);
response.sendRedirect(url.toString());
} catch (Exception e) {
log.error("[乐读派] 重定向失败, userId={}", userId, e);
response.sendError(500, "跳转乐读派失败: " + e.getMessage());
}
}
/**
* iframe Token 刷新接口
* 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口
*/
@GetMapping("/refresh-token")
@Operation(summary = "刷新乐读派 Token")
public Result<LeaiTokenVO> refreshToken() {
Long userId = SecurityUtil.getCurrentUserId();
SysUser user = sysUserService.getById(userId);
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
throw new BusinessException(401, "请先登录并绑定手机号");
}
String phone = user.getPhone();
try {
String token = leaiApiClient.exchangeToken(phone);
// Entity VO 转换Controller 层负责
LeaiTokenVO vo = new LeaiTokenVO();
vo.setToken(token);
vo.setOrgId(leaiConfig.getOrgId());
vo.setPhone(phone);
log.info("[乐读派] Token刷新成功, userId={}", userId);
return Result.success(vo);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[乐读派] Token刷新失败, userId={}", userId, e);
throw new BusinessException(500, "Token刷新失败: " + e.getMessage());
}
}
}

View File

@ -1,111 +0,0 @@
package com.competition.modules.leai.controller;
import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.service.ILeaiWebhookEventService;
import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 乐读派 Webhook 接收控制器
* 无需认证由乐读派服务端调用通过 HMAC 签名验证
*/
@Slf4j
@Tag(name = "乐读派 Webhook")
@RestController
@RequiredArgsConstructor
public class LeaiWebhookController {
private final LeaiApiClient leaiApiClient;
private final ILeaiSyncService leaiSyncService;
private final ILeaiWebhookEventService webhookEventService;
private final ObjectMapper objectMapper;
/**
* 接收乐读派 Webhook 回调
* POST /webhook/leai
* <p>
* Header:
* X-Webhook-Id: 事件唯一 ID
* X-Webhook-Event: 事件类型 (work.status_changed / work.progress)
* X-Webhook-Timestamp: 毫秒时间戳
* X-Webhook-Signature: HMAC-SHA256={hex}
*/
@PostMapping("/webhook/leai")
@Operation(summary = "接收乐读派 Webhook 回调")
public Map<String, String> webhook(
@RequestBody String rawBody,
@RequestHeader("X-Webhook-Id") String webhookId,
@RequestHeader("X-Webhook-Event") String webhookEvent,
@RequestHeader("X-Webhook-Timestamp") String timestamp,
@RequestHeader("X-Webhook-Signature") String signature) {
log.info("[Webhook] 收到事件: event={}, id={}", webhookEvent, webhookId);
// 1. 时间窗口检查5分钟防重放攻击
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
log.warn("[Webhook] 时间戳格式错误: {}", timestamp);
throw new BusinessException(400, "时间戳格式错误");
}
if (Math.abs(System.currentTimeMillis() - ts) > 300_000) {
log.warn("[Webhook] 时间戳已过期: {}", timestamp);
throw new BusinessException(400, "时间戳已过期");
}
// 2. 幂等去重
if (webhookEventService.existsByEventId(webhookId)) {
log.info("[Webhook] 重复事件,跳过: {}", webhookId);
return Collections.singletonMap("status", "duplicate");
}
// 3. 验证 HMAC-SHA256 签名
if (!leaiApiClient.verifyWebhookSignature(webhookId, timestamp, rawBody, signature)) {
log.warn("[Webhook] 签名验证失败! webhookId={}", webhookId);
throw new BusinessException(401, "签名验证失败");
}
// 4. 解析事件 payload
Map<String, Object> payload;
try {
payload = objectMapper.readValue(rawBody, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.error("[Webhook] payload 解析失败", e);
throw new BusinessException(400, "payload 解析失败");
}
String event = LeaiUtil.toString(payload.get("event"), "");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) payload.get("data");
if (data == null) {
data = new HashMap<>();
}
String remoteWorkId = LeaiUtil.toString(data.get("work_id"), null);
// 5. V4.0 同步规则处理
if (remoteWorkId != null && !remoteWorkId.isEmpty()) {
try {
leaiSyncService.syncWork(remoteWorkId, data, "Webhook[" + event + "]");
} catch (Exception e) {
log.error("[Webhook] 同步处理异常: remoteWorkId={}", remoteWorkId, e);
}
}
// 6. 记录事件幂等去重
webhookEventService.saveEvent(webhookId, webhookEvent, remoteWorkId, payload);
return Collections.singletonMap("status", "ok");
}
}

View File

@ -1,15 +0,0 @@
package com.competition.modules.leai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 乐读派重定向请求 DTO
*/
@Data
@Schema(description = "乐读派重定向请求")
public class LeaiAuthRedirectDTO {
@Schema(description = "重定向后的路径")
private String returnPath;
}

View File

@ -1,51 +0,0 @@
package com.competition.modules.leai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 乐读派 Webhook 事件去重表
* <p>
* 注意此表为追加-only日志表不需要 BaseEntity 的审计字段updateBymodifyTimedeleted
* 因此独立定义 id createTime不继承 BaseEntity
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName(value = "t_leai_webhook_event", autoResultMap = true)
@Schema(description = "乐读派 Webhook 事件")
public class LeaiWebhookEvent {
@Schema(description = "主键ID")
@TableField("id")
private Long id;
@Schema(description = "事件唯一IDX-Webhook-Id")
@TableField("event_id")
private String eventId;
@Schema(description = "事件类型")
@TableField("event_type")
private String eventType;
@Schema(description = "乐读派作品ID")
@TableField("remote_work_id")
private String remoteWorkId;
@Schema(description = "事件原始载荷")
@TableField(value = "payload", typeHandler = JacksonTypeHandler.class)
private Object payload;
@Schema(description = "是否已处理0-未处理1-已处理")
@TableField("processed")
private Integer processed;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -1,12 +0,0 @@
package com.competition.modules.leai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
import org.apache.ibatis.annotations.Mapper;
/**
* 乐读派 Webhook 事件 Mapper
*/
@Mapper
public interface LeaiWebhookEventMapper extends BaseMapper<LeaiWebhookEvent> {
}

View File

@ -1,26 +0,0 @@
package com.competition.modules.leai.service;
import java.util.Map;
/**
* 乐读派作品同步 Service 接口
* <p>
* Webhook 回调和 B3 对账共用此服务
*/
public interface ILeaiSyncService {
/**
* 同步乐读派作品到本地
* <p>
* 同步规则
* - remoteStatus == -1 强制更新FAILED
* - remoteStatus == 2 强制更新PROCESSING 进度变化
* - remoteStatus > localStatus 全量更新状态前进
* - else 忽略
*
* @param remoteWorkId 乐读派作品ID
* @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果
* @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/
void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source);
}

View File

@ -1,28 +0,0 @@
package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
/**
* 乐读派 Webhook 事件 Service 接口
*/
public interface ILeaiWebhookEventService extends IService<LeaiWebhookEvent> {
/**
* 检查事件是否已存在幂等去重
*
* @param eventId 事件唯一ID
* @return true-已存在重复事件
*/
boolean existsByEventId(String eventId);
/**
* 保存 Webhook 事件记录
*
* @param eventId 事件唯一ID
* @param eventType 事件类型
* @param remoteWorkId 乐读派作品ID
* @param payload 事件载荷
*/
void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload);
}

View File

@ -1,261 +0,0 @@
package com.competition.modules.leai.service;
import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.config.LeaiConfig;
import com.competition.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 乐读派 API 客户端
* 使用 RestTemplate + Jackson 对接乐读派后端
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LeaiApiClient {
private final RestTemplate restTemplate;
private final LeaiConfig leaiConfig;
private final ObjectMapper objectMapper;
// 状态常量V4.0 数值状态机
public static final int STATUS_FAILED = -1;
public static final int STATUS_DRAFT = 0;
public static final int STATUS_PENDING = 1;
public static final int STATUS_PROCESSING = 2;
public static final int STATUS_COMPLETED = 3;
public static final int STATUS_CATALOGED = 4;
public static final int STATUS_DUBBED = 5;
/**
* 换取 Session Token
* POST /api/v1/auth/session
*/
public String exchangeToken(String phone) {
String url = leaiConfig.getApiUrl() + "/api/v1/auth/session";
Map<String, String> body = new HashMap<>();
body.put("orgId", leaiConfig.getOrgId());
body.put("appSecret", leaiConfig.getAppSecret());
body.put("phone", phone);
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {});
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
throw new BusinessException(502, "令牌交换失败: code=" + code
+ ", msg=" + LeaiUtil.toString(result.get("msg"), "unknown"));
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get("data");
if (data == null) {
throw new BusinessException(502, "令牌交换失败: data 为 null");
}
String sessionToken = LeaiUtil.toString(data.get("sessionToken"), null);
if (sessionToken == null || sessionToken.isEmpty()) {
throw new BusinessException(502, "令牌交换失败: sessionToken 为空");
}
log.info("[乐读派] 令牌交换成功, phone={}, expiresIn={}s", phone, data.get("expiresIn"));
return sessionToken;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException(502, "令牌交换请求失败: " + e.getMessage());
}
}
/**
* B2 查询作品详情
* GET /api/v1/query/work/{workId}
*/
public Map<String, Object> fetchWorkDetail(String workId) {
Map<String, String> queryParams = new TreeMap<>();
queryParams.put("orgId", leaiConfig.getOrgId());
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
try {
String url = leaiConfig.getApiUrl() + "/api/v1/query/work/"
+ URLEncoder.encode(workId, StandardCharsets.UTF_8)
+ "?orgId=" + URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
hmacHeaders.forEach(headers::set);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {});
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
log.warn("[乐读派] B2查询失败: workId={}, code={}", workId, code);
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) result.get("data");
return data;
} catch (Exception e) {
log.error("[乐读派] B2查询异常: workId={}", workId, e);
return null;
}
}
/**
* B3 批量查询作品
* GET /api/v1/query/works
*/
public List<Map<String, Object>> queryWorks(String updatedAfter) {
Map<String, String> queryParams = new TreeMap<>();
queryParams.put("orgId", leaiConfig.getOrgId());
queryParams.put("updatedAfter", updatedAfter);
queryParams.put("page", "1");
queryParams.put("size", "100");
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
try {
StringBuilder queryString = new StringBuilder();
for (Map.Entry<String, String> e : queryParams.entrySet()) {
if (queryString.length() > 0) queryString.append("&");
queryString.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
.append("=")
.append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
}
String url = leaiConfig.getApiUrl() + "/api/v1/query/works?" + queryString;
HttpHeaders headers = new HttpHeaders();
hmacHeaders.forEach(headers::set);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
Map<String, Object> result = objectMapper.readValue(response.getBody(),
new TypeReference<Map<String, Object>>() {});
int code = LeaiUtil.toInt(result.get("code"), 0);
if (code != 200) {
log.warn("[乐读派] B3查询失败: code={}, msg={}", code, result.get("msg"));
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) result.get("data");
if (dataMap == null) return Collections.emptyList();
Object recordsObj = dataMap.get("records");
if (recordsObj instanceof List) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) recordsObj;
return records;
}
return Collections.emptyList();
} catch (Exception e) {
log.error("[乐读派] B3查询异常", e);
return Collections.emptyList();
}
}
/**
* 生成 HMAC 签名请求头
*/
public Map<String, String> buildHmacHeaders(Map<String, String> queryParams) {
String ts = String.valueOf(System.currentTimeMillis());
String nonce = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(System.nanoTime());
TreeMap<String, String> allParams = new TreeMap<>(queryParams);
allParams.put("timestamp", ts);
allParams.put("nonce", nonce);
StringBuilder signStr = new StringBuilder();
for (Map.Entry<String, String> entry : allParams.entrySet()) {
if (signStr.length() > 0) signStr.append("&");
signStr.append(entry.getKey()).append("=").append(entry.getValue());
}
String sig = hmacSha256(signStr.toString(), leaiConfig.getAppSecret());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("X-App-Key", leaiConfig.getOrgId());
headers.put("X-Timestamp", ts);
headers.put("X-Nonce", nonce);
headers.put("X-Signature", sig);
return headers;
}
/**
* 验证 Webhook 签名
*/
public boolean verifyWebhookSignature(String webhookId, String timestamp, String rawBody, String signature) {
String signData = webhookId + "." + timestamp + "." + rawBody;
String expected = "HMAC-SHA256=" + hmacSha256(signData, leaiConfig.getAppSecret());
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}
/**
* HMAC-SHA256 签名
*/
private String hmacSha256(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
} catch (Exception e) {
throw new BusinessException(500, "HMAC-SHA256 签名失败");
}
}
/**
* 获取 2 小时前的 UTC 时间 ISO 格式用于 B3 对账
*/
public static String getUtcTwoHoursAgoIso() {
return ZonedDateTime.ofInstant(Instant.now().minusSeconds(7200), ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT);
}
}

View File

@ -1,278 +0,0 @@
package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.competition.modules.leai.util.LeaiUtil;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
/**
* V4.0 状态同步核心服务
* Webhook B3 对账共用此服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LeaiSyncService implements ILeaiSyncService {
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final LeaiApiClient leaiApiClient;
/**
* V4.0 核心同步逻辑
* <p>
* 同步规则
* - remoteStatus == -1 强制更新FAILED
* - remoteStatus == 2 强制更新PROCESSING 进度变化
* - remoteStatus > localStatus 全量更新状态前进
* - else 忽略
*
* @param remoteWorkId 乐读派作品ID
* @param remoteData 远程作品数据来自 Webhook payload B2/B3 查询结果
* @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source) {
int remoteStatus = LeaiUtil.toInt(remoteData.get("status"), 0);
String phone = LeaiUtil.toString(remoteData.get("phone"), null);
// 查找本地作品通过 remoteWorkId
UgcWork localWork = findByRemoteWorkId(remoteWorkId);
if (localWork == null) {
// 新作品首次收到此 remoteWorkId创建本地记录
insertNewWork(remoteWorkId, remoteData, phone);
log.info("[{}] 新增作品 remoteWorkId={}, status={}, phone={}", source, remoteWorkId, remoteStatus, phone);
return;
}
int localStatus = localWork.getStatus() != null ? localWork.getStatus() : 0;
if (remoteStatus == LeaiApiClient.STATUS_FAILED) {
// FAILED(-1): 创作失败强制更新
updateFailed(localWork, remoteData);
log.info("[{}] 强制更新(FAILED) remoteWorkId={}", source, remoteWorkId);
return;
}
if (remoteStatus == LeaiApiClient.STATUS_PROCESSING) {
// PROCESSING(2): 创作进行中强制更新进度
updateProcessing(localWork, remoteData);
log.info("[{}] 强制更新(PROCESSING) remoteWorkId={}, progress={}",
source, remoteWorkId, remoteData.get("progress"));
return;
}
if (remoteStatus > localStatus) {
// 状态前进: 全量更新
// status=3(COMPLETED): pageList 包含图片 URL
// status=4(CATALOGED): title/author 已更新
// status=5(DUBBED): pageList audioUrl 已填充
updateStatusForward(localWork, remoteData, remoteStatus);
log.info("[{}] 状态更新 remoteWorkId={}: {} -> {}", source, remoteWorkId, localStatus, remoteStatus);
return;
}
// 旧数据或重复推送忽略
log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus);
}
/**
* 新增作品记录
*/
private void insertNewWork(String remoteWorkId, Map<String, Object> remoteData, String phone) {
UgcWork work = new UgcWork();
work.setRemoteWorkId(remoteWorkId);
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
work.setStatus(LeaiUtil.toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING));
work.setVisibility("private");
work.setIsDeleted(0);
work.setIsRecommended(false);
work.setViewCount(0);
work.setLikeCount(0);
work.setFavoriteCount(0);
work.setCommentCount(0);
work.setShareCount(0);
work.setProgress(LeaiUtil.toInt(remoteData.get("progress"), 0));
work.setProgressMessage(LeaiUtil.toString(remoteData.get("progressMessage"), null));
work.setStyle(LeaiUtil.toString(remoteData.get("style"), null));
work.setAuthorName(LeaiUtil.toString(remoteData.get("author"), null));
work.setFailReason(LeaiUtil.toString(remoteData.get("failReason"), null));
work.setCreateTime(LocalDateTime.now());
work.setModifyTime(LocalDateTime.now());
// 设置封面图
Object coverUrl = remoteData.get("coverUrl");
if (coverUrl == null) coverUrl = remoteData.get("cover_url");
if (coverUrl != null) work.setCoverUrl(coverUrl.toString());
// 设置原始图片
Object originalImageUrl = remoteData.get("originalImageUrl");
if (originalImageUrl == null) originalImageUrl = remoteData.get("original_image_url");
if (originalImageUrl != null) work.setOriginalImageUrl(originalImageUrl.toString());
// 通过手机号查找用户ID多租户场景
if (phone != null && work.getUserId() == null) {
Long userId = findUserIdByPhone(phone);
if (userId != null) {
work.setUserId(userId);
}
}
ugcWorkMapper.insert(work);
// 如果有 pageList status >= 3保存页面数据
if (work.getStatus() != null && work.getStatus() >= LeaiApiClient.STATUS_COMPLETED) {
savePageList(work.getId(), remoteData.get("pageList"));
}
}
/**
* 更新失败状态
*/
private void updateFailed(UgcWork work, Map<String, Object> remoteData) {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, LeaiApiClient.STATUS_FAILED)
.set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), "未知错误"))
.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
}
/**
* 更新处理中状态进度变化
*/
private void updateProcessing(UgcWork work, Map<String, Object> remoteData) {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, LeaiApiClient.STATUS_PROCESSING);
if (remoteData.containsKey("progress")) {
wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
}
if (remoteData.containsKey("progressMessage")) {
wrapper.set(UgcWork::getProgressMessage, LeaiUtil.toString(remoteData.get("progressMessage"), null));
}
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
}
/**
* 状态前进全量更新
*/
private void updateStatusForward(UgcWork work, Map<String, Object> remoteData, int remoteStatus) {
// CAS 乐观锁确保并发安全只有当前 status < remoteStatus 时才更新
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.lt(UgcWork::getStatus, remoteStatus)
.set(UgcWork::getStatus, remoteStatus);
// 更新可变字段
if (remoteData.containsKey("title")) {
wrapper.set(UgcWork::getTitle, LeaiUtil.toString(remoteData.get("title"), null));
}
if (remoteData.containsKey("progress")) {
wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
}
if (remoteData.containsKey("progressMessage")) {
wrapper.set(UgcWork::getProgressMessage, LeaiUtil.toString(remoteData.get("progressMessage"), null));
}
if (remoteData.containsKey("style")) {
wrapper.set(UgcWork::getStyle, LeaiUtil.toString(remoteData.get("style"), null));
}
if (remoteData.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(remoteData.get("author"), null));
}
if (remoteData.containsKey("failReason")) {
wrapper.set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), null));
}
Object coverUrl = remoteData.get("coverUrl");
if (coverUrl == null) coverUrl = remoteData.get("cover_url");
if (coverUrl != null) {
wrapper.set(UgcWork::getCoverUrl, coverUrl.toString());
}
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
int updated = ugcWorkMapper.update(null, wrapper);
if (updated == 0) {
log.warn("CAS乐观锁冲突跳过更新: remoteWorkId={}, remoteStatus={}", work.getRemoteWorkId(), remoteStatus);
return;
}
// status >= 3 时保存 pageList
if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED) {
savePageList(work.getId(), remoteData.get("pageList"));
}
}
/**
* 保存作品页面数据
* status=3(COMPLETED): pageList 包含 imageUrl
* status=5(DUBBED): pageList audioUrl 已填充
*/
@SuppressWarnings("unchecked")
private void savePageList(Long workId, Object pageListObj) {
if (pageListObj == null) return;
List<Map<String, Object>> pageList;
if (pageListObj instanceof List) {
pageList = (List<Map<String, Object>>) pageListObj;
} else {
return;
}
if (pageList.isEmpty()) return;
// 先删除旧页面数据
LambdaQueryWrapper<UgcWorkPage> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(UgcWorkPage::getWorkId, workId);
ugcWorkPageMapper.delete(deleteWrapper);
// 插入新页面数据
for (int i = 0; i < pageList.size(); i++) {
Map<String, Object> pageData = pageList.get(i);
UgcWorkPage page = new UgcWorkPage();
page.setWorkId(workId);
page.setPageNo(i + 1);
page.setImageUrl(LeaiUtil.toString(pageData.get("imageUrl"), null));
page.setText(LeaiUtil.toString(pageData.get("text"), null));
page.setAudioUrl(LeaiUtil.toString(pageData.get("audioUrl"), null));
ugcWorkPageMapper.insert(page);
}
log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size());
}
/**
* 通过 remoteWorkId 查找本地作品
*/
private UgcWork findByRemoteWorkId(String remoteWorkId) {
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UgcWork::getRemoteWorkId, remoteWorkId)
.eq(UgcWork::getIsDeleted, 0);
return ugcWorkMapper.selectOne(wrapper);
}
/**
* 通过手机号查找用户ID
* 多租户场景需要确定租户后查找
*/
private Long findUserIdByPhone(String phone) {
// TODO: 如果需要按租户隔离需要传入 orgId 查找租户再查找用户
// 当前简化处理直接通过手机号查用户
return null; // 暂时不自动关联用户后续通过 phone + orgId 查询
}
}

View File

@ -1,41 +0,0 @@
package com.competition.modules.leai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
import com.competition.modules.leai.mapper.LeaiWebhookEventMapper;
import com.competition.modules.leai.service.ILeaiWebhookEventService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 乐读派 Webhook 事件 Service 实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LeaiWebhookEventServiceImpl extends ServiceImpl<LeaiWebhookEventMapper, LeaiWebhookEvent>
implements ILeaiWebhookEventService {
@Override
public boolean existsByEventId(String eventId) {
LambdaQueryWrapper<LeaiWebhookEvent> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LeaiWebhookEvent::getEventId, eventId);
return count(wrapper) > 0;
}
@Override
public void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload) {
LeaiWebhookEvent event = new LeaiWebhookEvent();
event.setEventId(eventId);
event.setEventType(eventType);
event.setRemoteWorkId(remoteWorkId);
event.setPayload(payload);
event.setProcessed(1);
event.setCreateTime(LocalDateTime.now());
save(event);
}
}

View File

@ -1,77 +0,0 @@
package com.competition.modules.leai.task;
import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.util.LeaiUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* B3 定时对账任务
* 30 分钟调用 B3 接口对账补偿 Webhook 遗漏
* <p>
* 查询范围最近 2 小时内更新的作品覆盖 2 个对账周期确保不遗漏边界数据
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LeaiReconcileTask {
private final LeaiApiClient leaiApiClient;
private final ILeaiSyncService leaiSyncService;
/**
* 每30分钟执行一次初始延迟60秒
*/
@Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
public void reconcile() {
log.info("[B3对账] 开始执行...");
try {
String updatedAfter = LeaiApiClient.getUtcTwoHoursAgoIso();
List<Map<String, Object>> works = leaiApiClient.queryWorks(updatedAfter);
if (works.isEmpty()) {
log.info("[B3对账] 未查询到变更作品");
return;
}
int synced = 0;
for (Map<String, Object> work : works) {
String workId = LeaiUtil.toString(work.get("workId"), null);
if (workId == null) {
continue;
}
// 尝试调 B2 获取完整数据
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId);
if (fullData != null) {
try {
leaiSyncService.syncWork(workId, fullData, "B3对账");
synced++;
} catch (Exception e) {
log.warn("[B3对账] 同步失败: workId={}", workId, e);
}
} else {
// B2 失败时用 B3 摘要数据做简易同步
try {
leaiSyncService.syncWork(workId, work, "B3对账(摘要)");
synced++;
} catch (Exception e) {
log.warn("[B3对账] 摘要同步失败: workId={}", workId, e);
}
}
}
log.info("[B3对账] 完成: 检查 {} 个作品, 同步 {} 个", works.size(), synced);
} catch (Exception e) {
log.error("[B3对账] 执行异常", e);
}
}
}

View File

@ -1,44 +0,0 @@
package com.competition.modules.leai.util;
/**
* 乐读派模块共享工具类
* 提取多处重复使用的类型转换方法
*/
public final class LeaiUtil {
private LeaiUtil() {
// 工具类禁止实例化
}
/**
* 安全转换为 int
*
* @param obj 待转换对象
* @param defaultVal 默认值
* @return 转换结果
*/
public static int toInt(Object obj, int defaultVal) {
if (obj == null) {
return defaultVal;
}
if (obj instanceof Number) {
return ((Number) obj).intValue();
}
try {
return Integer.parseInt(obj.toString());
} catch (Exception e) {
return defaultVal;
}
}
/**
* 安全转换为 String
*
* @param obj 待转换对象
* @param defaultVal 默认值
* @return 转换结果
*/
public static String toString(Object obj, String defaultVal) {
return obj != null ? obj.toString() : defaultVal;
}
}

View File

@ -1,24 +0,0 @@
package com.competition.modules.leai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 乐读派 Token 响应 VO
*/
@Data
@Schema(description = "乐读派 Token 信息")
public class LeaiTokenVO {
@Schema(description = "Session Token")
private String token;
@Schema(description = "机构ID对应本项目的租户 code即 tenant_code")
private String orgId;
@Schema(description = "H5 前端地址")
private String h5Url;
@Schema(description = "用户手机号")
private String phone;
}

View File

@ -44,20 +44,11 @@ public class PublicActivityController {
@GetMapping("/{id}/my-registration")
@Operation(summary = "查询我的报名信息")
public Result<Map<String, Object>> getMyRegistration(@PathVariable Long id) {
public Result<BizContestRegistration> getMyRegistration(@PathVariable Long id) {
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(publicActivityService.getMyRegistration(id, userId));
}
@GetMapping("/mine/registrations")
@Operation(summary = "我的报名列表")
public Result<Map<String, Object>> getMyRegistrations(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int pageSize) {
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(publicActivityService.getMyRegistrations(userId, page, pageSize));
}
@PostMapping("/{id}/register")
@Operation(summary = "报名活动")
public Result<BizContestRegistration> register(

View File

@ -12,12 +12,6 @@ import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.pub.dto.PublicRegisterActivityDto;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import com.competition.modules.user.entity.UserChild;
import com.competition.modules.user.mapper.UserChildMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -35,9 +29,6 @@ public class PublicActivityService {
private final ContestMapper contestMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper;
private final UserChildMapper userChildMapper;
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
/**
* 活动列表公开
@ -73,7 +64,6 @@ public class PublicActivityService {
result.put("contestType", contest.getContestType());
result.put("contestState", contest.getContestState());
result.put("status", contest.getStatus());
result.put("visibility", contest.getVisibility());
result.put("startTime", contest.getStartTime());
result.put("endTime", contest.getEndTime());
result.put("coverUrl", contest.getCoverUrl());
@ -91,9 +81,6 @@ public class PublicActivityService {
result.put("registerState", contest.getRegisterState());
result.put("submitStartTime", contest.getSubmitStartTime());
result.put("submitEndTime", contest.getSubmitEndTime());
result.put("submitRule", contest.getSubmitRule());
result.put("reviewStartTime", contest.getReviewStartTime());
result.put("reviewEndTime", contest.getReviewEndTime());
result.put("workType", contest.getWorkType());
result.put("workRequirement", contest.getWorkRequirement());
result.put("resultState", contest.getResultState());
@ -102,167 +89,14 @@ public class PublicActivityService {
}
/**
* 查询当前用户的报名信息包含作品提交状态
* 查询当前用户的报名信息
*/
public Map<String, Object> getMyRegistration(Long contestId, Long userId) {
BizContestRegistration reg = contestRegistrationMapper.selectOne(
public BizContestRegistration getMyRegistration(Long contestId, Long userId) {
return contestRegistrationMapper.selectOne(
new LambdaQueryWrapper<BizContestRegistration>()
.eq(BizContestRegistration::getContestId, contestId)
.eq(BizContestRegistration::getUserId, userId)
.last("LIMIT 1"));
if (reg == null) {
return null;
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", reg.getId());
result.put("contestId", reg.getContestId());
result.put("userId", reg.getUserId());
result.put("registrationType", reg.getRegistrationType());
result.put("registrationState", reg.getRegistrationState());
result.put("registrationTime", reg.getRegistrationTime());
// 查询是否已提交作品只统计最新版本
Long workCount = contestWorkMapper.selectCount(
new LambdaQueryWrapper<BizContestWork>()
.eq(BizContestWork::getRegistrationId, reg.getId())
.eq(BizContestWork::getIsLatest, true));
result.put("hasSubmittedWork", workCount > 0);
result.put("workCount", workCount);
return result;
}
/**
* 我的报名列表
*/
public Map<String, Object> getMyRegistrations(Long userId, int page, int pageSize) {
// 查询报名列表
LambdaQueryWrapper<BizContestRegistration> regWrapper = new LambdaQueryWrapper<>();
regWrapper.eq(BizContestRegistration::getUserId, userId)
.orderByDesc(BizContestRegistration::getRegistrationTime);
IPage<BizContestRegistration> regPage = contestRegistrationMapper.selectPage(new Page<>(page, pageSize), regWrapper);
// 获取所有 contestId
List<Long> contestIds = regPage.getRecords().stream()
.map(BizContestRegistration::getContestId)
.distinct()
.toList();
// 批量查询活动信息
Map<Long, BizContest> contestMap = new HashMap<>();
if (!contestIds.isEmpty()) {
List<BizContest> contests = contestMapper.selectBatchIds(contestIds);
for (BizContest contest : contests) {
contestMap.put(contest.getId(), contest);
}
}
// 查询所有报名对应的作品
List<BizContestRegistration> registrations = regPage.getRecords();
List<Long> registrationIds = registrations.stream()
.map(BizContestRegistration::getId)
.toList();
Map<Long, List<BizContestWork>> worksMap = new HashMap<>();
if (!registrationIds.isEmpty()) {
LambdaQueryWrapper<BizContestWork> workWrapper = new LambdaQueryWrapper<>();
workWrapper.in(BizContestWork::getRegistrationId, registrationIds);
List<BizContestWork> works = contestWorkMapper.selectList(workWrapper);
for (BizContestWork work : works) {
worksMap.computeIfAbsent(work.getRegistrationId(), k -> new ArrayList<>()).add(work);
}
}
// 获取所有 childId
List<Long> childIds = registrations.stream()
.map(BizContestRegistration::getChildId)
.filter(Objects::nonNull)
.distinct()
.toList();
// 批量查询子女信息
Map<Long, UserChild> childMap = new HashMap<>();
if (!childIds.isEmpty()) {
List<UserChild> children = userChildMapper.selectBatchIds(childIds);
for (UserChild child : children) {
childMap.put(child.getId(), child);
}
}
// 组装返回数据
List<Map<String, Object>> list = new ArrayList<>();
for (BizContestRegistration reg : registrations) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", reg.getId());
item.put("contestId", reg.getContestId());
item.put("userId", reg.getUserId());
item.put("registrationType", reg.getRegistrationType());
item.put("registrationState", reg.getRegistrationState());
item.put("registrationTime", reg.getRegistrationTime());
item.put("participantType", reg.getParticipantType());
item.put("childId", reg.getChildId());
item.put("teamId", reg.getTeamId());
// 关联子女信息
if (reg.getChildId() != null) {
UserChild child = childMap.get(reg.getChildId());
if (child != null) {
Map<String, Object> childInfo = new LinkedHashMap<>();
childInfo.put("id", child.getId());
childInfo.put("name", child.getName());
childInfo.put("gender", child.getGender());
childInfo.put("grade", child.getGrade());
item.put("child", childInfo);
}
}
// 关联活动信息
BizContest contest = contestMap.get(reg.getContestId());
if (contest != null) {
Map<String, Object> contestInfo = new LinkedHashMap<>();
contestInfo.put("id", contest.getId());
contestInfo.put("contestName", contest.getContestName());
contestInfo.put("contestType", contest.getContestType());
contestInfo.put("coverUrl", contest.getCoverUrl());
contestInfo.put("posterUrl", contest.getPosterUrl());
contestInfo.put("startTime", contest.getStartTime());
contestInfo.put("endTime", contest.getEndTime());
contestInfo.put("submitStartTime", contest.getSubmitStartTime());
contestInfo.put("submitEndTime", contest.getSubmitEndTime());
item.put("contest", contestInfo);
}
// 关联作品信息
List<BizContestWork> works = worksMap.get(reg.getId());
if (works != null && !works.isEmpty()) {
List<Map<String, Object>> worksList = new ArrayList<>();
for (BizContestWork work : works) {
Map<String, Object> workInfo = new LinkedHashMap<>();
workInfo.put("id", work.getId());
workInfo.put("title", work.getTitle());
workInfo.put("description", work.getDescription());
workInfo.put("previewUrl", work.getPreviewUrl());
workInfo.put("previewUrls", work.getPreviewUrls());
workInfo.put("attachments", work.getFiles());
workInfo.put("createTime", work.getCreateTime());
worksList.add(workInfo);
}
item.put("works", worksList);
}
list.add(item);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", list);
result.put("total", regPage.getTotal());
result.put("page", regPage.getCurrent());
result.put("pageSize", regPage.getSize());
return result;
}
/**
@ -270,8 +104,6 @@ public class PublicActivityService {
*/
@Transactional
public BizContestRegistration register(Long contestId, Long userId, Long tenantId, PublicRegisterActivityDto dto) {
log.info("开始报名活动contestId: {}, userId: {}, tenantId: {}", contestId, userId, tenantId);
// 检查是否已报名
Long existCount = contestRegistrationMapper.selectCount(
new LambdaQueryWrapper<BizContestRegistration>()
@ -293,26 +125,14 @@ public class PublicActivityService {
BizContestRegistration reg = new BizContestRegistration();
reg.setContestId(contestId);
reg.setUserId(userId);
// 使用活动的授权租户ID管理端按租户查询报名数据
if (contest.getContestTenants() != null && !contest.getContestTenants().isEmpty()) {
reg.setTenantId(contest.getContestTenants().get(0).longValue());
} else {
reg.setTenantId(tenantId);
}
reg.setTenantId(tenantId);
reg.setRegistrationType(contest.getContestType());
reg.setParticipantType(dto.getParticipantType() != null ? dto.getParticipantType() : "self");
reg.setChildId(dto.getChildId());
reg.setTeamId(dto.getTeamId());
reg.setRegistrationState(Boolean.TRUE.equals(contest.getRequireAudit()) ? "pending" : "passed");
reg.setRegistrationTime(LocalDateTime.now());
// 设置必填字段
reg.setRegistrant(userId.intValue());
reg.setAccountNo("user_" + userId);
reg.setAccountName("user_" + userId);
log.info("保存报名记录:{}", reg);
contestRegistrationMapper.insert(reg);
log.info("报名成功ID: {}", reg.getId());
return reg;
}
@ -321,115 +141,30 @@ public class PublicActivityService {
*/
@Transactional
public BizContestWork submitWork(Long contestId, Long userId, Long tenantId, Map<String, Object> dto) {
// 检查报名状态区分不同状态给出明确提示
BizContestRegistration anyReg = contestRegistrationMapper.selectOne(
// 检查报名状态
BizContestRegistration reg = contestRegistrationMapper.selectOne(
new LambdaQueryWrapper<BizContestRegistration>()
.eq(BizContestRegistration::getContestId, contestId)
.eq(BizContestRegistration::getUserId, userId)
.last("LIMIT 1"));
if (anyReg == null) {
throw new BusinessException(400, "您尚未报名该活动,请先报名");
.eq(BizContestRegistration::getRegistrationState, "passed"));
if (reg == null) {
throw new BusinessException(400, "未报名或报名未通过");
}
if (!"passed".equals(anyReg.getRegistrationState())) {
String state = anyReg.getRegistrationState();
String msg = switch (state) {
case "pending" -> "报名审核中,请等待审核通过后再提交作品";
case "rejected" -> "报名已被拒绝,无法提交作品";
case "withdrawn" -> "报名已撤回,请重新报名";
default -> "报名状态异常(" + state + "),无法提交作品";
};
throw new BusinessException(400, msg);
}
BizContestRegistration reg = anyReg;
// 查询活动提交规则
BizContest contest = contestMapper.selectById(contestId);
String submitRule = contest != null ? contest.getSubmitRule() : "once";
// 查询已有作品
BizContestWork existingWork = contestWorkMapper.selectOne(
new LambdaQueryWrapper<BizContestWork>()
.eq(BizContestWork::getContestId, contestId)
.eq(BizContestWork::getRegistrationId, reg.getId())
.eq(BizContestWork::getIsLatest, true)
.orderByDesc(BizContestWork::getVersion)
.last("LIMIT 1"));
if (existingWork != null) {
if ("once".equals(submitRule)) {
throw new BusinessException(400, "该活动仅允许提交一次作品");
}
// resubmit 模式将旧作品标记为非最新
existingWork.setIsLatest(false);
contestWorkMapper.updateById(existingWork);
}
int nextVersion = (existingWork != null) ? existingWork.getVersion() + 1 : 1;
BizContestWork work = new BizContestWork();
work.setContestId(contestId);
work.setRegistrationId(reg.getId());
// 使用报名记录的租户ID已在 register 时设置为活动的租户确保管理端可见
work.setTenantId(reg.getTenantId());
work.setTenantId(tenantId);
work.setTitle((String) dto.get("title"));
work.setDescription((String) dto.get("description"));
work.setFiles(dto.get("files"));
work.setSubmitterUserId(userId);
work.setStatus("submitted");
work.setSubmitTime(LocalDateTime.now());
work.setVersion(nextVersion);
work.setVersion(1);
work.setIsLatest(true);
// 从作品库选择作品提交快照复制
if (dto.get("userWorkId") != null) {
Long userWorkId = Long.valueOf(dto.get("userWorkId").toString());
work.setUserWorkId(userWorkId);
// 查询用户作品
UgcWork ugcWork = ugcWorkMapper.selectById(userWorkId);
if (ugcWork == null || ugcWork.getIsDeleted() == 1) {
throw new BusinessException(404, "作品不存在");
}
if (!ugcWork.getUserId().equals(userId)) {
throw new BusinessException(403, "无权使用该作品");
}
if ("rejected".equals(ugcWork.getStatus()) || "taken_down".equals(ugcWork.getStatus())) {
throw new BusinessException(400, "该作品状态不可提交");
}
// 查询绘本分页
List<UgcWorkPage> pages = ugcWorkPageMapper.selectList(
new LambdaQueryWrapper<UgcWorkPage>()
.eq(UgcWorkPage::getWorkId, userWorkId)
.orderByAsc(UgcWorkPage::getPageNo));
// 复制快照字段
work.setTitle(ugcWork.getTitle());
work.setDescription(ugcWork.getDescription());
work.setPreviewUrl(ugcWork.getCoverUrl());
work.setAiModelMeta(ugcWork.getAiMeta());
// previewUrls = 所有页面图片
List<String> previewUrls = pages.stream()
.map(UgcWorkPage::getImageUrl)
.filter(Objects::nonNull)
.toList();
work.setPreviewUrls(previewUrls);
// files = 分页完整快照
List<Map<String, Object>> filesSnapshot = pages.stream()
.map(p -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("pageNo", p.getPageNo());
m.put("imageUrl", p.getImageUrl());
m.put("text", p.getText());
m.put("audioUrl", p.getAudioUrl());
return m;
})
.toList();
work.setFiles(filesSnapshot);
} else {
// 旧逻辑直接上传
work.setTitle((String) dto.get("title"));
work.setDescription((String) dto.get("description"));
work.setFiles(dto.get("files"));
work.setUserWorkId(Long.valueOf(dto.get("userWorkId").toString()));
}
contestWorkMapper.insert(work);
return work;

View File

@ -101,7 +101,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(3); // 3=COMPLETED/PUBLISHED
work.setStatus("published");
work.setReviewNote(note);
work.setReviewerId(operatorId);
work.setReviewTime(LocalDateTime.now());
@ -120,7 +120,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(-1); // -1=FAILED/REJECTED
work.setStatus("rejected");
work.setReviewNote(note);
work.setReviewerId(operatorId);
work.setReviewTime(LocalDateTime.now());
@ -158,7 +158,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(1); // 1=PENDING
work.setStatus("pending_review");
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "revoke", null, null, operatorId);
@ -173,7 +173,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(-2); // -2=TAKEN_DOWN
work.setStatus("taken_down");
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "takedown", reason, null, operatorId);
@ -188,7 +188,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(3); // 3=COMPLETED/PUBLISHED
work.setStatus("published");
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "restore", null, null, operatorId);

View File

@ -6,9 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -22,16 +20,14 @@ import java.util.*;
public class PublicCreationService {
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
/**
* 提交 AI 创作保留但降级为辅助接口
* 提交 AI 创作
*/
public UgcWork submit(Long userId, String originalImageUrl, String voiceInputUrl, String textInput) {
UgcWork work = new UgcWork();
work.setUserId(userId);
work.setTitle("未命名作品");
work.setStatus(0); // DRAFT
work.setStatus("draft");
work.setOriginalImageUrl(originalImageUrl);
work.setVoiceInputUrl(voiceInputUrl);
work.setTextInput(textInput);
@ -51,7 +47,6 @@ public class PublicCreationService {
/**
* 获取创作状态
* 返回 INT 类型的 status + progress + progressMessage
*/
public Map<String, Object> getStatus(Long id, Long userId) {
UgcWork work = ugcWorkMapper.selectById(id);
@ -61,48 +56,29 @@ public class PublicCreationService {
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", work.getId());
result.put("status", work.getStatus());
result.put("progress", work.getProgress());
result.put("progressMessage", work.getProgressMessage());
result.put("remoteWorkId", work.getRemoteWorkId());
result.put("title", work.getTitle());
result.put("coverUrl", work.getCoverUrl());
result.put("aiMeta", work.getAiMeta());
return result;
}
/**
* 获取创作结果包含 pageList 的完整数据
* 获取创作结果
*/
public Map<String, Object> getResult(Long id, Long userId) {
UgcWork work = ugcWorkMapper.selectById(id);
if (work == null || !work.getUserId().equals(userId)) {
throw new BusinessException(404, "创作记录不存在");
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", work.getId());
result.put("title", work.getTitle());
result.put("coverUrl", work.getCoverUrl());
result.put("description", work.getDescription());
result.put("status", work.getStatus());
result.put("progress", work.getProgress());
result.put("progressMessage", work.getProgressMessage());
result.put("originalImageUrl", work.getOriginalImageUrl());
result.put("voiceInputUrl", work.getVoiceInputUrl());
result.put("textInput", work.getTextInput());
result.put("aiMeta", work.getAiMeta());
result.put("style", work.getStyle());
result.put("authorName", work.getAuthorName());
result.put("failReason", work.getFailReason());
result.put("remoteWorkId", work.getRemoteWorkId());
result.put("createTime", work.getCreateTime());
// 查询页面列表
LambdaQueryWrapper<UgcWorkPage> pageWrapper = new LambdaQueryWrapper<>();
pageWrapper.eq(UgcWorkPage::getWorkId, id)
.orderByAsc(UgcWorkPage::getPageNo);
List<UgcWorkPage> pages = ugcWorkPageMapper.selectList(pageWrapper);
result.put("pages", pages);
return result;
}

View File

@ -42,7 +42,7 @@ public class PublicUserWorkService {
work.setCoverUrl(coverUrl);
work.setDescription(description);
work.setVisibility(visibility != null ? visibility : "private");
work.setStatus(0); // 0=DRAFT
work.setStatus("draft");
work.setViewCount(0);
work.setLikeCount(0);
work.setFavoriteCount(0);
@ -154,10 +154,10 @@ public class PublicUserWorkService {
if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) {
throw new BusinessException(404, "作品不存在或无权操作");
}
if (work.getStatus() != 0 && work.getStatus() != -1) {
if (!"draft".equals(work.getStatus()) && !"rejected".equals(work.getStatus())) {
throw new BusinessException(400, "当前状态不可发布");
}
work.setStatus(1); // 1=PENDING
work.setStatus("pending_review");
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
}

View File

@ -1,136 +0,0 @@
package com.competition.modules.sys.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.competition.modules.sys.entity.SysPermission;
import com.competition.modules.sys.entity.SysRole;
import com.competition.modules.sys.entity.SysRolePermission;
import com.competition.modules.sys.mapper.SysPermissionMapper;
import com.competition.modules.sys.mapper.SysRoleMapper;
import com.competition.modules.sys.mapper.SysRolePermissionMapper;
import com.competition.modules.sys.mapper.SysTenantMapper;
import com.competition.modules.sys.entity.SysTenant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 评委角色权限 docs/design/menu-config.md评委端权限码一致并从评委租户或 gdlib 模板复制缺失的权限定义
*/
@Slf4j
@Component
@RequiredArgsConstructor
@Order(100)
public class JudgeRolePermissionConfigurer implements ApplicationRunner {
/** 与 menu-config 评委端权限码一致 */
public static final List<String> JUDGE_PERMISSION_CODES = List.of(
"review:score", "review:read", "review:create", "review:update",
"activity:read", "judge:read", "judge:assign", "work:read",
"notice:read", "workbench:read"
);
private static final String TEMPLATE_TENANT_CODE_GDLIB = "gdlib";
private static final String JUDGE_TENANT_CODE = "judge";
private final SysPermissionMapper permissionMapper;
private final SysRolePermissionMapper rolePermissionMapper;
private final SysRoleMapper roleMapper;
private final SysTenantMapper tenantMapper;
@Override
public void run(ApplicationArguments args) {
List<SysRole> judgeRoles = roleMapper.selectList(
new LambdaQueryWrapper<SysRole>()
.eq(SysRole::getCode, "judge")
.eq(SysRole::getValidState, 1));
for (SysRole role : judgeRoles) {
try {
ensureJudgeRolePermissions(role.getTenantId(), role.getId());
} catch (Exception e) {
log.warn("启动时补全评委角色权限失败 tenantId={} roleId={}: {}",
role.getTenantId(), role.getId(), e.getMessage());
}
}
if (!judgeRoles.isEmpty()) {
log.info("评委角色权限补全完成,共 {} 个 judge 角色", judgeRoles.size());
}
}
/**
* 确保指定租户下评委角色拥有 menu-config 所列权限缺失的权限从评委租户或 gdlib 复制到本租户后再绑定
*/
public void ensureJudgeRolePermissions(Long tenantId, Long roleId) {
if (tenantId == null || roleId == null) {
return;
}
for (String code : JUDGE_PERMISSION_CODES) {
SysPermission perm = permissionMapper.selectOne(
new LambdaQueryWrapper<SysPermission>()
.eq(SysPermission::getTenantId, tenantId)
.eq(SysPermission::getCode, code)
.eq(SysPermission::getValidState, 1));
if (perm == null) {
SysPermission template = findTemplatePermission(code);
if (template == null) {
log.debug("评委权限模板中无编码 {},跳过", code);
continue;
}
perm = new SysPermission();
perm.setTenantId(tenantId);
perm.setName(template.getName());
perm.setCode(template.getCode());
perm.setResource(template.getResource());
perm.setAction(template.getAction());
perm.setDescription(template.getDescription());
permissionMapper.insert(perm);
}
Long permId = perm.getId();
long exists = rolePermissionMapper.selectCount(
new LambdaQueryWrapper<SysRolePermission>()
.eq(SysRolePermission::getRoleId, roleId)
.eq(SysRolePermission::getPermissionId, permId));
if (exists == 0) {
SysRolePermission rp = new SysRolePermission();
rp.setRoleId(roleId);
rp.setPermissionId(permId);
rolePermissionMapper.insert(rp);
}
}
}
private SysPermission findTemplatePermission(String code) {
Long judgeTid = tenantIdByCode(JUDGE_TENANT_CODE);
if (judgeTid != null) {
SysPermission p = permissionMapper.selectOne(
new LambdaQueryWrapper<SysPermission>()
.eq(SysPermission::getTenantId, judgeTid)
.eq(SysPermission::getCode, code)
.eq(SysPermission::getValidState, 1));
if (p != null) {
return p;
}
}
Long gdlibTid = tenantIdByCode(TEMPLATE_TENANT_CODE_GDLIB);
if (gdlibTid != null) {
return permissionMapper.selectOne(
new LambdaQueryWrapper<SysPermission>()
.eq(SysPermission::getTenantId, gdlibTid)
.eq(SysPermission::getCode, code)
.eq(SysPermission::getValidState, 1));
}
return null;
}
private Long tenantIdByCode(String code) {
SysTenant t = tenantMapper.selectOne(
new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getCode, code)
.eq(SysTenant::getValidState, 1));
return t != null ? t.getId() : null;
}
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,20 +12,19 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_config")
@Schema(description = "系统配置实体")
public class SysConfig extends BaseEntity {
@Schema(description = "租户ID")
/** 租户 ID */
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "配置键(租户内唯一)")
/** 配置键(租户内唯一) */
@TableField("`key`")
private String key;
@Schema(description = "配置值")
/** 配置值 */
private String value;
@Schema(description = "描述")
/** 描述 */
private String description;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -15,23 +14,22 @@ import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_dict")
@Schema(description = "数据字典实体")
public class SysDict extends BaseEntity {
@Schema(description = "租户ID")
/** 租户 ID */
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "字典名称")
/** 字典名称 */
private String name;
@Schema(description = "字典编码(租户内唯一)")
/** 字典编码(租户内唯一) */
private String code;
@Schema(description = "描述")
/** 描述 */
private String description;
@Schema(description = "字典项列表")
/** 字典项(非数据库字段) */
@TableField(exist = false)
private List<SysDictItem> items;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,19 +12,18 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_dict_item")
@Schema(description = "数据字典项实体")
public class SysDictItem extends BaseEntity {
@Schema(description = "字典ID")
/** 字典 ID */
@TableField("dict_id")
private Long dictId;
@Schema(description = "标签")
/** 标签 */
private String label;
@Schema(description = "")
/** 值 */
private String value;
@Schema(description = "排序")
/** 排序 */
private Integer sort;
}

View File

@ -1,42 +1,39 @@
package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 系统日志实体
* 系统日志实体不继承 BaseEntity字段结构不同
*/
@Data
@TableName("t_sys_log")
@Schema(description = "系统日志实体")
public class SysLog implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "用户ID")
/** 用户 ID */
@TableField("user_id")
private Long userId;
@Schema(description = "操作")
/** 操作 */
private String action;
@Schema(description = "内容")
/** 内容 */
private String content;
@Schema(description = "IP地址")
/** IP 地址 */
private String ip;
@Schema(description = "用户代理")
/** User Agent */
@TableField("user_agent")
private String userAgent;
@Schema(description = "创建时间")
/** 创建时间 */
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -15,32 +14,31 @@ import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_menu")
@Schema(description = "系统菜单实体")
public class SysMenu extends BaseEntity {
@Schema(description = "菜单名称")
/** 菜单名称 */
private String name;
@Schema(description = "路由路径")
/** 路由路径 */
private String path;
@Schema(description = "图标")
/** 图标 */
private String icon;
@Schema(description = "前端组件路径")
/** 前端组件路径 */
private String component;
@Schema(description = "父菜单ID")
/** 父菜单 ID */
@TableField("parent_id")
private Long parentId;
@Schema(description = "权限标识")
/** 权限标识 */
private String permission;
@Schema(description = "排序")
/** 排序 */
private Integer sort;
@Schema(description = "子菜单列表")
/** 子菜单(非数据库字段) */
@TableField(exist = false)
private List<SysMenu> children;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,25 +12,24 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_permission")
@Schema(description = "系统权限实体")
public class SysPermission extends BaseEntity {
@Schema(description = "租户ID")
/** 租户 ID */
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "权限名称")
/** 权限名称 */
private String name;
@Schema(description = "权限编码格式resource:action")
/** 权限编码格式resource:action */
private String code;
@Schema(description = "资源")
/** 资源 */
private String resource;
@Schema(description = "操作")
/** 操作 */
private String action;
@Schema(description = "描述")
/** 描述 */
private String description;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,19 +12,18 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_role")
@Schema(description = "系统角色实体")
public class SysRole extends BaseEntity {
@Schema(description = "租户ID")
/** 租户 ID */
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "角色名称")
/** 角色名称 */
private String name;
@Schema(description = "角色编码")
/** 角色编码 */
private String code;
@Schema(description = "角色描述")
/** 角色描述 */
private String description;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -11,18 +10,14 @@ import java.io.Serializable;
*/
@Data
@TableName("t_sys_role_permission")
@Schema(description = "角色权限关联实体")
public class SysRolePermission implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "角色ID")
@TableField("role_id")
private Long roleId;
@Schema(description = "权限ID")
@TableField("permission_id")
private Long permissionId;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -13,26 +12,25 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_tenant")
@Schema(description = "系统租户实体")
public class SysTenant extends BaseEntity {
@Schema(description = "租户名称")
/** 租户名称 */
private String name;
@Schema(description = "租户编码(唯一,用于访问链接)")
/** 租户编码(唯一,用于访问链接) */
private String code;
@Schema(description = "租户域名(可选)")
/** 租户域名(可选) */
private String domain;
@Schema(description = "租户描述")
/** 租户描述 */
private String description;
@Schema(description = "是否为超级租户0-否1-是")
/** 是否为超级租户0-否1-是 */
@TableField("is_super")
private Integer isSuper;
@Schema(description = "租户类型platform/library/kindergarten/school/institution/other")
/** 租户类型platform/library/kindergarten/school/institution/other */
@TableField("tenant_type")
private String tenantType;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -11,18 +10,14 @@ import java.io.Serializable;
*/
@Data
@TableName("t_sys_tenant_menu")
@Schema(description = "租户菜单关联实体")
public class SysTenantMenu implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "菜单ID")
@TableField("menu_id")
private Long menuId;
}

View File

@ -3,7 +3,6 @@ package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.competition.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -15,59 +14,58 @@ import java.time.LocalDate;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_sys_user")
@Schema(description = "系统用户实体")
public class SysUser extends BaseEntity {
@Schema(description = "租户ID")
/** 租户 ID */
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "用户名(租户内唯一)")
/** 用户名(在租户内唯一) */
private String username;
@Schema(description = "密码(加密存储)")
/** 密码(加密存储) */
private String password;
@Schema(description = "昵称")
/** 昵称 */
private String nickname;
@Schema(description = "邮箱")
/** 邮箱 */
private String email;
@Schema(description = "手机号(全局唯一)")
/** 手机号(全局唯一) */
private String phone;
@Schema(description = "微信OpenID")
/** 微信 OpenID */
@TableField("wx_openid")
private String wxOpenid;
@Schema(description = "微信UnionID")
/** 微信 UnionID */
@TableField("wx_unionid")
private String wxUnionid;
@Schema(description = "用户来源admin_created/self_registered/child_migrated")
/** 用户来源admin_created/self_registered/child_migrated */
@TableField("user_source")
private String userSource;
@Schema(description = "用户类型adult/child")
/** 用户类型adult/child */
@TableField("user_type")
private String userType;
@Schema(description = "所在城市")
/** 所在城市 */
private String city;
@Schema(description = "出生日期")
/** 出生日期 */
private LocalDate birthday;
@Schema(description = "性别")
/** 性别 */
private String gender;
@Schema(description = "头像URL")
/** 头像 URL */
private String avatar;
@Schema(description = "所属单位")
/** 所属单位 */
private String organization;
@Schema(description = "账号状态enabled/disabled")
/** 账号状态enabled/disabled */
private String status;
}

View File

@ -1,7 +1,6 @@
package com.competition.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -11,18 +10,14 @@ import java.io.Serializable;
*/
@Data
@TableName("t_sys_user_role")
@Schema(description = "用户角色关联实体")
public class SysUserRole implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "角色ID")
@TableField("role_id")
private Long roleId;
}

View File

@ -59,14 +59,7 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
// 获取租户分配的菜单 ID
List<SysTenantMenu> tenantMenus = tenantMenuMapper.selectList(
new LambdaQueryWrapper<SysTenantMenu>().eq(SysTenantMenu::getTenantId, tenantId));
Set<Long> tenantMenuIds = new HashSet<>(tenantMenus.stream().map(SysTenantMenu::getMenuId).collect(Collectors.toSet()));
List<String> roles = isSuperAdmin ? Collections.emptyList() : userMapper.selectRolesByUserId(userId);
// 租户评委与平台评委共用我的评审菜单树机构租户未在 t_sys_tenant_menu 中配置时按角色合并评委端菜单
if (!isSuperAdmin && roles != null && roles.contains("judge")) {
tenantMenuIds.addAll(collectJudgePortalMenuIds(allMenus));
}
Set<Long> tenantMenuIds = tenantMenus.stream().map(SysTenantMenu::getMenuId).collect(Collectors.toSet());
if (isSuperAdmin) {
// 超管按租户菜单过滤但不做权限码过滤
@ -87,14 +80,10 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
List<String> userPermissions = userMapper.selectPermissionsByUserId(userId);
Set<String> permSet = new HashSet<>(userPermissions);
// 纯评委角色不展示机构端评委管理 judge:read 权限码重叠否则侧栏会出现该菜单
boolean hideTenantJudgeMgmtForPureJudge = shouldHideTenantJudgeManagementMenuForJudge(roles);
// 过滤菜单必须属于租户且用户有对应权限无权限要求的菜单直接放行
List<SysMenu> filteredMenus = allMenus.stream()
.filter(menu -> tenantMenuIds.contains(menu.getId()))
.filter(menu -> menu.getPermission() == null || menu.getPermission().isBlank() || permSet.contains(menu.getPermission()))
.filter(menu -> !hideTenantJudgeMgmtForPureJudge || !isTenantJudgeManagementMenu(menu))
.collect(Collectors.toList());
// 补全父菜单确保树结构完整
@ -179,53 +168,6 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
.collect(Collectors.toList());
}
/** 机构租户「评委管理」页(非评委工作台) */
private static final String TENANT_JUDGE_MANAGEMENT_COMPONENT = "contests/judges/Index";
/**
* 仅评委角色 tenant_admin / super_admin不展示机构端评委管理菜单
*/
private boolean shouldHideTenantJudgeManagementMenuForJudge(List<String> roles) {
if (roles == null || roles.isEmpty()) {
return false;
}
if (!roles.contains("judge")) {
return false;
}
return !roles.contains("tenant_admin") && !roles.contains("super_admin");
}
private boolean isTenantJudgeManagementMenu(SysMenu menu) {
return TENANT_JUDGE_MANAGEMENT_COMPONENT.equals(menu.getComponent());
}
/**
* 评委端菜单评审任务预设评语及其父级 docs/design/menu-config 一致不依赖固定菜单 ID
*/
private Set<Long> collectJudgePortalMenuIds(List<SysMenu> allMenus) {
Set<Long> ids = new HashSet<>();
Set<String> leafComponents = Set.of("activities/Review", "activities/PresetComments");
for (SysMenu m : allMenus) {
if (m.getComponent() != null && leafComponents.contains(m.getComponent())) {
ids.add(m.getId());
Long pid = m.getParentId();
while (pid != null) {
if (ids.contains(pid)) {
break;
}
ids.add(pid);
final Long currentPid = pid;
SysMenu parent = allMenus.stream()
.filter(x -> x.getId().equals(currentPid))
.findFirst()
.orElse(null);
pid = parent != null ? parent.getParentId() : null;
}
}
}
return ids;
}
/** 递归补全父菜单 */
private void addParentsIfMissing(SysMenu menu, List<SysMenu> allMenus, List<SysMenu> filtered, Set<Long> filteredIds) {
if (menu.getParentId() == null || filteredIds.contains(menu.getParentId())) return;

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,39 +11,29 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_review_log")
@Schema(description = "UGC审核日志实体")
public class UgcReviewLog implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "目标类型")
@TableField("target_type")
private String targetType;
@Schema(description = "目标ID")
@TableField("target_id")
private Long targetId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "操作动作")
private String action;
@Schema(description = "原因")
private String reason;
@Schema(description = "备注")
private String note;
@Schema(description = "操作人ID")
@TableField("operator_id")
private Long operatorId;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,37 +11,27 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_tag")
@Schema(description = "UGC标签实体")
public class UgcTag implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "标签名称")
private String name;
@Schema(description = "标签分类")
private String category;
@Schema(description = "标签颜色")
private String color;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "状态")
private String status;
@Schema(description = "使用次数")
@TableField("usage_count")
private Integer usageCount;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -5,7 +5,6 @@ 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -13,128 +12,79 @@ import java.time.LocalDateTime;
@Data
@TableName(value = "t_ugc_work", autoResultMap = true)
@Schema(description = "UGC作品实体")
public class UgcWork implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "乐读派远程作品ID")
@TableField("remote_work_id")
private String remoteWorkId;
@Schema(description = "作品标题")
private String title;
@Schema(description = "封面图URL")
@TableField("cover_url")
private String coverUrl;
@Schema(description = "作品描述")
private String description;
@Schema(description = "可见范围")
private String visibility;
@Schema(description = "作品状态:-1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED")
private Integer status;
private String status;
@Schema(description = "审核备注")
@TableField("review_note")
private String reviewNote;
@Schema(description = "审核时间")
@TableField("review_time")
private LocalDateTime reviewTime;
@Schema(description = "审核人ID")
@TableField("reviewer_id")
private Long reviewerId;
@Schema(description = "机审结果")
@TableField("machine_review_result")
private String machineReviewResult;
@Schema(description = "机审备注")
@TableField("machine_review_note")
private String machineReviewNote;
@Schema(description = "是否推荐")
@TableField("is_recommended")
private Boolean isRecommended;
@Schema(description = "浏览数")
@TableField("view_count")
private Integer viewCount;
@Schema(description = "点赞数")
@TableField("like_count")
private Integer likeCount;
@Schema(description = "收藏数")
@TableField("favorite_count")
private Integer favoriteCount;
@Schema(description = "评论数")
@TableField("comment_count")
private Integer commentCount;
@Schema(description = "分享数")
@TableField("share_count")
private Integer shareCount;
@Schema(description = "原图URL")
@TableField("original_image_url")
private String originalImageUrl;
@Schema(description = "语音输入URL")
@TableField("voice_input_url")
private String voiceInputUrl;
@Schema(description = "文本输入")
@TableField("text_input")
private String textInput;
@Schema(description = "AI元数据JSON")
@TableField(value = "ai_meta", typeHandler = JacksonTypeHandler.class)
private Object aiMeta;
@Schema(description = "AI创作进度百分比")
private Integer progress;
@Schema(description = "进度描述")
@TableField("progress_message")
private String progressMessage;
@Schema(description = "创作风格")
private String style;
@Schema(description = "作者")
@TableField("author_name")
private String authorName;
@Schema(description = "失败原因")
@TableField("fail_reason")
private String failReason;
@Schema(description = "发布时间")
@TableField("publish_time")
private LocalDateTime publishTime;
@Schema(description = "是否删除0-未删除1-已删除")
@TableField("is_deleted")
private Integer isDeleted;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,32 +11,24 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_work_comment")
@Schema(description = "作品评论实体")
public class UgcWorkComment implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "父评论ID")
@TableField("parent_id")
private Long parentId;
@Schema(description = "评论内容")
private String content;
@Schema(description = "状态")
private String status;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,22 +11,17 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_work_favorite")
@Schema(description = "作品收藏实体")
public class UgcWorkFavorite implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,22 +11,17 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_work_like")
@Schema(description = "作品点赞实体")
public class UgcWorkLike implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -4,36 +4,28 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("t_ugc_work_page")
@Schema(description = "作品页面实体")
public class UgcWorkPage implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "页码")
@TableField("page_no")
private Integer pageNo;
@Schema(description = "图片URL")
@TableField("image_url")
private String imageUrl;
@Schema(description = "文本内容")
private String text;
@Schema(description = "音频URL")
@TableField("audio_url")
private String audioUrl;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,55 +11,41 @@ import java.time.LocalDateTime;
@Data
@TableName("t_ugc_work_report")
@Schema(description = "作品举报实体")
public class UgcWorkReport implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "举报人ID")
@TableField("reporter_id")
private Long reporterId;
@Schema(description = "目标类型")
@TableField("target_type")
private String targetType;
@Schema(description = "目标ID")
@TableField("target_id")
private Long targetId;
@Schema(description = "被举报用户ID")
@TableField("target_user_id")
private Long targetUserId;
@Schema(description = "举报原因")
private String reason;
@Schema(description = "举报描述")
private String description;
@Schema(description = "状态")
private String status;
@Schema(description = "处理动作")
@TableField("handle_action")
private String handleAction;
@Schema(description = "处理备注")
@TableField("handle_note")
private String handleNote;
@Schema(description = "处理人ID")
@TableField("handler_id")
private Long handlerId;
@Schema(description = "处理时间")
@TableField("handle_time")
private LocalDateTime handleTime;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -4,25 +4,20 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("t_ugc_work_tag")
@Schema(description = "作品标签关联实体")
public class UgcWorkTag implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "标签ID")
@TableField("tag_id")
private Long tagId;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -13,48 +12,35 @@ import java.time.LocalDateTime;
@Data
@TableName("t_user_child")
@Schema(description = "用户子女实体")
public class UserChild implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "家长ID")
@TableField("parent_id")
private Long parentId;
@Schema(description = "姓名")
private String name;
@Schema(description = "性别")
private String gender;
@Schema(description = "出生日期")
private LocalDate birthday;
@Schema(description = "年级")
private String grade;
@Schema(description = "城市")
private String city;
@Schema(description = "学校名称")
@TableField("school_name")
private String schoolName;
@Schema(description = "头像")
private String avatar;
@Schema(description = "是否删除0-未删除1-已删除")
@TableField("is_deleted")
private Integer isDeleted;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@ -12,29 +11,22 @@ import java.time.LocalDateTime;
@Data
@TableName("t_user_parent_child")
@Schema(description = "家长子女关联实体")
public class UserParentChild implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "家长用户ID")
@TableField("parent_user_id")
private Long parentUserId;
@Schema(description = "子女用户ID")
@TableField("child_user_id")
private Long childUserId;
@Schema(description = "关系")
private String relationship;
@Schema(description = "管控模式")
@TableField("control_mode")
private String controlMode;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
}

View File

@ -53,8 +53,6 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/public/gallery", "/public/gallery/**").permitAll()
.requestMatchers(HttpMethod.GET, "/public/tags", "/public/tags/**").permitAll()
.requestMatchers(HttpMethod.GET, "/public/users/*/works").permitAll()
// 乐读派 Webhook 回调无用户上下文由乐读派服务端调用
.requestMatchers("/webhook/leai").permitAll()
// Knife4j 文档
.requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
// Druid 监控

View File

@ -35,7 +35,7 @@ mybatis-plus:
oss:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:},
secret-key: ${COS_SECRET_KEY:}
bucket: ${COS_BUCKET:}
region: ${COS_REGION:ap-guangzhou}
url-prefix: ${COS_URL_PREFIX:}
@ -43,10 +43,3 @@ oss:
logging:
level:
com.competition: debug
# 乐读派 AI 创作系统配置
leai:
org-id: ${LEAI_ORG_ID:gdlib}
app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y}
api-url: ${LEAI_API_URL:http://192.168.1.72:8080}
h5-url: ${LEAI_H5_URL:http://192.168.1.72:3001}

View File

@ -1,52 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.1.250:3306/competition_management?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: lesingle-creation-test
password: 8ErFZiPBGbyrTHsy
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
data:
redis:
host: ${REDIS_HOST:192.168.1.250}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:QWErty123}
database: ${REDIS_DB:8}
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-idle: 20
min-idle: 5
max-wait: -1ms
flyway:
clean-disabled: false
# 开发环境开启 SQL 日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
oss:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:}
bucket: ${COS_BUCKET:}
region: ${COS_REGION:ap-guangzhou}
url-prefix: ${COS_URL_PREFIX:}
logging:
level:
com.competition: debug
# 乐读派 AI 创作系统配置
leai:
org-id: ${LEAI_ORG_ID:gdlib}
app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y}
api-url: ${LEAI_API_URL:http://192.168.1.72:8080}
h5-url: ${LEAI_H5_URL:http://192.168.1.72:3001}

View File

@ -15,7 +15,7 @@ spring:
# Flyway 数据库迁移
flyway:
enabled: true
enabled: false
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
-- Flyway migration:
-- 兼容部分环境没有跑过最新的 init.sql
-- 导致 t_biz_contest_work 缺少 deleted 字段,从而逻辑删除查询报错。
SET @column_cnt := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 't_biz_contest_work'
AND column_name = 'deleted'
);
SET @sql := IF(
@column_cnt = 0,
'ALTER TABLE t_biz_contest_work ADD COLUMN deleted tinyint NOT NULL DEFAULT ''0'' COMMENT ''逻辑删除0-未删除1-已删除''',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,20 +0,0 @@
-- 活动公告表添加租户隔离字段
-- 执行时间2026-04-03
-- 1. 添加 tenant_id 字段
ALTER TABLE t_biz_contest_notice
ADD COLUMN tenant_id BIGINT COMMENT '租户 ID用于租户隔离' AFTER contest_id;
-- 2. 为现有数据设置默认租户 ID根据实际业务可能需要手动设置
-- 假设现有公告都属于当前租户(需要根据实际情况修改 tenant_id
UPDATE t_biz_contest_notice
SET tenant_id = 9
WHERE tenant_id IS NULL;
-- 3. 添加索引优化查询性能
ALTER TABLE t_biz_contest_notice
ADD INDEX idx_tenant_id (tenant_id);
-- 4. 添加联合索引优化按租户和赛事查询
ALTER TABLE t_biz_contest_notice
ADD INDEX idx_tenant_contest (tenant_id, contest_id);

View File

@ -1,135 +0,0 @@
-- 评审模块重建表脚本(修复 V3 建表字段不全问题)
-- 执行时间2026-04-07
-- 对齐实体BizContestJudge, BizContestReviewRule, BizContestWorkJudgeAssignment, BizContestWorkScore, BizPresetComment
-- 使用 DROP + CREATE 确保表结构完整,因为评审模块尚未有生产数据
-- ============================================================
-- 1. 赛事评委表(对应实体 BizContestJudge继承 BaseEntity
-- ============================================================
DROP TABLE IF EXISTS t_biz_contest_judge;
CREATE TABLE t_biz_contest_judge (
id BIGINT NOT NULL AUTO_INCREMENT,
contest_id BIGINT NOT NULL COMMENT '赛事ID',
judge_id BIGINT NOT NULL COMMENT '评委用户ID',
specialty VARCHAR(100) DEFAULT NULL COMMENT '专业领域',
weight DECIMAL(3,2) DEFAULT 1.00 COMMENT '评委权重 0-1',
description VARCHAR(500) DEFAULT NULL COMMENT '评委描述',
-- BaseEntity 审计字段
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效',
PRIMARY KEY (id),
INDEX idx_contest_id (contest_id),
INDEX idx_judge_id (judge_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='赛事评委表';
-- ============================================================
-- 2. 评审规则表(对应实体 BizContestReviewRule继承 BaseEntity
-- ============================================================
DROP TABLE IF EXISTS t_biz_contest_review_rule;
CREATE TABLE t_biz_contest_review_rule (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT DEFAULT NULL COMMENT '租户ID',
rule_name VARCHAR(200) NOT NULL COMMENT '规则名称',
rule_description VARCHAR(500) DEFAULT NULL COMMENT '规则描述',
judge_count INT NOT NULL DEFAULT 3 COMMENT '评委数量',
dimensions JSON DEFAULT NULL COMMENT '评分维度 JSON [{name,percentage,description}]',
calculation_rule VARCHAR(50) NOT NULL DEFAULT 'average' COMMENT '计算规则average/remove_max_min/remove_min/max/weighted',
-- BaseEntity 审计字段
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效',
PRIMARY KEY (id),
INDEX idx_tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评审规则表';
-- ============================================================
-- 3. 作品评委分配表(对应实体 BizContestWorkJudgeAssignment不继承 BaseEntity
-- 注意:该实体只有 id/creator/modifier/createTime/modifyTime无 deleted/valid_state/create_by/update_by
-- ============================================================
DROP TABLE IF EXISTS t_biz_contest_work_judge_assignment;
CREATE TABLE t_biz_contest_work_judge_assignment (
id BIGINT NOT NULL AUTO_INCREMENT,
contest_id BIGINT NOT NULL COMMENT '赛事ID',
work_id BIGINT NOT NULL COMMENT '作品ID',
judge_id BIGINT NOT NULL COMMENT '评委用户ID',
assignment_time DATETIME DEFAULT NULL COMMENT '分配时间',
status VARCHAR(20) NOT NULL DEFAULT 'assigned' COMMENT '状态assigned/reviewing/completed',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (id),
INDEX idx_contest_work (contest_id, work_id),
INDEX idx_judge_status (judge_id, status),
UNIQUE INDEX uk_contest_work_judge (contest_id, work_id, judge_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评委分配表';
-- ============================================================
-- 4. 作品评分表(对应实体 BizContestWorkScore继承 BaseEntity
-- ============================================================
DROP TABLE IF EXISTS t_biz_contest_work_score;
CREATE TABLE t_biz_contest_work_score (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT DEFAULT NULL COMMENT '租户ID',
contest_id BIGINT NOT NULL COMMENT '赛事ID',
work_id BIGINT NOT NULL COMMENT '作品ID',
assignment_id BIGINT DEFAULT NULL COMMENT '分配记录ID',
judge_id BIGINT NOT NULL COMMENT '评委用户ID',
judge_name VARCHAR(100) DEFAULT NULL COMMENT '评委姓名',
dimension_scores JSON DEFAULT NULL COMMENT '维度评分 JSON [{dimension,score}]',
total_score DECIMAL(10,2) DEFAULT NULL COMMENT '总分',
comments TEXT DEFAULT NULL COMMENT '评语',
score_time DATETIME DEFAULT NULL COMMENT '评分时间',
-- BaseEntity 审计字段
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效',
PRIMARY KEY (id),
INDEX idx_work_id (work_id),
INDEX idx_contest_id (contest_id),
INDEX idx_judge_id (judge_id),
INDEX idx_assignment_id (assignment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评分表';
-- ============================================================
-- 5. 预设评语表(对应实体 BizPresetComment继承 BaseEntity
-- ============================================================
DROP TABLE IF EXISTS t_biz_preset_comment;
CREATE TABLE t_biz_preset_comment (
id BIGINT NOT NULL AUTO_INCREMENT,
contest_id BIGINT DEFAULT NULL COMMENT '赛事IDnull 表示全局)',
judge_id BIGINT DEFAULT NULL COMMENT '评委IDnull 表示通用)',
content VARCHAR(1000) NOT NULL COMMENT '评语文本',
category VARCHAR(50) DEFAULT NULL COMMENT '评语分类',
score DECIMAL(10,2) DEFAULT NULL COMMENT '关联分数',
sort_order INT DEFAULT 0 COMMENT '排序',
use_count INT NOT NULL DEFAULT 0 COMMENT '使用次数',
-- BaseEntity 审计字段
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效',
PRIMARY KEY (id),
INDEX idx_contest_id (contest_id),
INDEX idx_judge_id (judge_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预设评语表';

View File

@ -1,48 +0,0 @@
-- ============================================================
-- V5: 乐读派 AI 绘本创作系统集成
-- 1. t_ugc_workstatus VARCHAR → INT先转换旧数据再改类型
-- 2. 新增乐读派关联字段
-- 3. 新增索引
-- 4. Webhook 幂等去重表
-- ============================================================
-- 1. t_ugc_workstatus VARCHAR → INT
-- 先将旧字符串状态值转换为整数值
UPDATE t_ugc_work SET status = '0' WHERE status = 'draft';
UPDATE t_ugc_work SET status = '1' WHERE status = 'pending_review';
UPDATE t_ugc_work SET status = '2' WHERE status = 'processing';
UPDATE t_ugc_work SET status = '3' WHERE status = 'published';
UPDATE t_ugc_work SET status = '-1' WHERE status = 'rejected';
UPDATE t_ugc_work SET status = '-2' WHERE status = 'taken_down';
-- 其他未识别的值统一设为 0(DRAFT)
UPDATE t_ugc_work SET status = '0' WHERE status NOT REGEXP '^-?[0-9]+$';
ALTER TABLE t_ugc_work MODIFY COLUMN status INT NOT NULL DEFAULT 0
COMMENT '创作状态:-1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
-- 2. 新增乐读派关联字段
ALTER TABLE t_ugc_work ADD COLUMN remote_work_id VARCHAR(64) DEFAULT NULL COMMENT '乐读派远程作品ID' AFTER user_id;
ALTER TABLE t_ugc_work ADD COLUMN progress INT DEFAULT 0 COMMENT 'AI创作进度百分比' AFTER ai_meta;
ALTER TABLE t_ugc_work ADD COLUMN progress_message VARCHAR(200) DEFAULT NULL COMMENT '进度描述' AFTER progress;
ALTER TABLE t_ugc_work ADD COLUMN style VARCHAR(100) DEFAULT NULL COMMENT '创作风格' AFTER progress_message;
ALTER TABLE t_ugc_work ADD COLUMN author_name VARCHAR(100) DEFAULT NULL COMMENT '作者' AFTER style;
ALTER TABLE t_ugc_work ADD COLUMN fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因' AFTER author_name;
-- 3. 新增索引
ALTER TABLE t_ugc_work ADD UNIQUE INDEX uk_remote_work_id (remote_work_id);
ALTER TABLE t_ugc_work ADD INDEX idx_user_status (user_id, status);
-- 4. Webhook 幂等去重表
CREATE TABLE IF NOT EXISTS t_leai_webhook_event (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL COMMENT '事件唯一ID (X-Webhook-Id)',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
remote_work_id VARCHAR(64) DEFAULT NULL COMMENT '乐读派作品ID',
payload JSON DEFAULT NULL COMMENT '事件原始载荷',
processed TINYINT NOT NULL DEFAULT 1 COMMENT '是否已处理',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE INDEX uk_event_id (event_id),
INDEX idx_remote_work_id (remote_work_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='乐读派Webhook事件去重表';

View File

@ -31,6 +31,4 @@
## 评委端
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [评审任务](./judge-portal/review-tasks.md) | 评审任务 / 作品列表 | 已实现 | 2026-04-08 |
(暂无)

View File

@ -1,60 +0,0 @@
# 评委端:评审任务
> 所属端:评委端(`tenant_id` 对应评委租户,如 `code=judge`
> 菜单与定位见 [菜单配置说明](../menu-config.md) 中「评委端」章节:仅能查看**被分配**的活动与作品。
## 活动列表
**接口**`GET /contests/reviews/judge/contests`
**活动来源(并集)**
1. `t_biz_contest_judge``judge_id``valid_state=1` 的赛事(机构显式添加的评委);
2. `t_biz_contest_work_judge_assignment` 中该评委出现过的 `contest_id`(含仅通过作品分配参与的隐式场景)。
**响应字段(与前端 `activities/Review.vue` 对齐)**
| 字段 | 说明 |
|------|------|
| `contestId` | 活动 ID |
| `contestName` | 活动名称 |
| `contestState` / `status` | 活动状态 |
| `reviewStartTime` / `reviewEndTime` | 评审时间窗 |
| `totalAssigned` | 该评委在该活动下的分配记录总数 |
| `reviewed` | 其中 `status=completed` 的数量(已提交评分) |
| `pending` | `totalAssigned - reviewed`(待评审) |
## 活动下作品列表
**接口**`GET /contests/reviews/judge/contests/{contestId}/works`
**分配状态 `reviewStatus` 查询参数(与库表兼容)**
- 库中 `t_biz_contest_work_judge_assignment.status` 实际使用:`assigned`(已分配未评完)、`completed`(已评审)。
- 前端下拉「未评审」传 `pending` → 后端按 **`status != completed`** 筛选。
- 前端「已评审」传 `reviewed` → 后端按 **`status = completed`** 筛选。
**作品编号**`workNo` 为空时,前端可用作品 `workId` 展示兜底(如 `#123`)。
## 活动详情(含评审规则)
**接口**`GET /contests/reviews/judge/contests/{contestId}/detail`
**权限**:满足以下**任一**即可:
- 存在有效的 `t_biz_contest_judge` 关联;或
- 存在该 `contestId` + `judgeId` 的作品分配记录。
避免「列表能进、详情 403」与隐式评委场景不一致。
---
## 与租户端「评审进度」的口径对齐
| 维度 | 租户机构端 `contests/reviews/progress`(活动列表行) | 评委端 `activities/review`(上表) |
|------|------------------------------------------------------|-------------------------------------|
| 数据来源 | `GET /contests` 列表项中的 `reviewedCount` / `totalWorksCount` | `GET /contests/reviews/judge/contests` |
| 含义 | `totalWorksCount`:该活动最新有效作品总数。`reviewedCount`**该活动下已分配评委且全部分配记录均为 `completed` 的作品数**(与分配表 `t_biz_contest_work_judge_assignment` 一致,与作品表 `accepted`/`awarded` 终态无关) | **评委维度**`reviewed`/`totalAssigned`/`pending` 为该评委在分配表上的任务数;与租户「整作品是否全部评委评完」为不同聚合粒度 |
| 作品列表/详情 | `GET /contests/works` 每条作品含 `reviewedCount`/`totalJudgesCount`(按该作品分配条数统计) | 评委在单活动下作品列表同样基于分配 `completed` |
说明:顶部「作品统计」卡片若仍按作品 `status` 汇总,可能与逐活动行「分配完成作品数」不完全同数,属汇总维度不同;列表/详情/评委任务以分配表为准。

View File

@ -1,6 +1,6 @@
# 各端菜单配置规范
> 最后更新2026-04-08
> 最后更新2026-04-02
> 维护人:开发团队
本文档记录各端的正确菜单配置,是菜单分配的**唯一权威来源**。修改菜单时必须对照此文档。
@ -112,13 +112,6 @@
9, 10, 11, 12, 14, 15, 16, 20, 23, 24, 25, 26, 27, 50, 51, 52, 53, 54
```
### 租户评委与平台评委(菜单一致)
- **平台评委**:在评委租户(`code=judge`)登录,见第三节「评委端」,`t_sys_tenant_menu` 仅含 **34、35、36**
- **租户评委**:在机构租户(如 `tenantCode=test2`)登录,角色为 `judge`,与平台评委使用**同一套**「我的评审」菜单(仍为 **34、35、36** 对应的 `component``activities/Review`、`activities/PresetComments` 及父级)。
- **实现**`GET /api/menus/user-menus` 在 `SysMenuServiceImpl.getUserMenus` 中,若当前用户角色含 `judge`,会在 `t_sys_tenant_menu` 基础上**合并**评委端菜单(按组件路径识别,不依赖固定 ID评委角色权限由 `JudgeRolePermissionConfigurer``JudgesManagementServiceImpl` 保证与上表「评委端权限码」一致,以便 `/api/auth/user-info``permissions` 与菜单 `permission` 字段匹配。纯评委(仅有 `judge`、无 `tenant_admin`/`super_admin`**不展示**机构端「评委管理」菜单(`component=contests/judges/Index`),避免与 `judge:read` 权限码重叠导致误显。
- **可选**:若希望机构租户在「菜单管理」中显式看到评委菜单,也可在 `t_sys_tenant_menu` 中手工追加 **34、35、36**(与第三节一致),与合并逻辑效果相同。
### 租户端系统设置不包含的子菜单
| 菜单 | ID | 原因 |
@ -137,9 +130,7 @@
## 三、评委端tenant_id=7, code='judge')— 3条
**定位**:评委评审工作台,只能看到自己被分配的活动和作品。租户评委(机构租户下的 `judge` 角色)与平台评委共用本节菜单与权限码。
**详细接口与字段说明**[评委端评审任务](./judge-portal/review-tasks.md)。
**定位**:评委评审工作台,只能看到自己被分配的活动和作品。
**一级菜单**1 个(我的评审)
@ -274,7 +265,7 @@
```
1. 查询所有 valid_state=1 的菜单
2. 查询当前用户 tenant_id 对应的 t_sys_tenant_menu
3. 按 tenant_menus 过滤菜单;若用户角色含 judge且非超管合并评委端菜单 ID评审任务/预设评语及其父级,按 component 识别)
3. 按 tenant_menus 过滤菜单
4. 如果是超管(isSuperAdmin):不做权限码过滤
5. 如果是普通用户:按用户权限码过滤(菜单.permission 字段匹配用户 permissions
6. 补全父菜单(确保树结构完整)
@ -283,8 +274,6 @@
**⚠️ 重要**:超管也必须按 tenant_menus 过滤,不能返回全部菜单。之前的 bug 就是超管返回全部 52 个菜单导致错乱。
**评委角色**`judge` 角色须绑定「评委端权限码」中的权限(见 `JudgeRolePermissionConfigurer`),否则 `permissions` 为空会导致第 5 步过滤掉所有菜单。
### 登录时租户识别
前端通过 URL 提取 tenantCode`/gdlib/login``tenantCode=gdlib`),登录请求:
@ -306,7 +295,6 @@ Java 后端 `AuthService.login` 支持两种方式确定租户:
| 来源 | 用途 |
|------|------|
| `docs/design/menu-config.md` | **本文档**,菜单配置唯一权威 |
| `backend-java/.../JudgeRolePermissionConfigurer.java` | 评委角色权限码补全、`judge`/`gdlib` 模板复制 |
| `backend/data/menus.json` | 菜单定义(所有菜单的字段) |
| `backend/scripts/init-menus.ts` | 菜单初始化脚本SUPER_TENANT_MENUS / NORMAL_TENANT_MENUS |
| `t_sys_menu` 表 | 数据库中的菜单数据 |

View File

@ -3,7 +3,7 @@
> 所属端:租户端(机构管理员视角)
> 状态:已优化
> 创建日期2026-03-31
> 最后更新2026-04-08
> 最后更新2026-03-31
---
@ -58,12 +58,6 @@
- [x] 主色调统一 #6366f1
- [x] 冻结/解冻二次确认
#### 赛事评委接口(`GET /contests/judges/contest/:id`
- 响应为结构化对象,包含两部分:**`assigned`**(机构在该赛事下**显式添加**的评委,对应 `t_biz_contest_judge`,每条均有 `id`、`judgeId` 等)与 **`implicitPool`**(平台评委租户下对该赛事**默认可用**、尚未写入关联表的用户,`id` 为 null`isPlatform` 为 true
- **添加评委抽屉**:「已选」回显与提交时的增删差集**仅基于 `assigned`**;可选评委仍来自评委管理分页接口。
- **作品分配**:可选评委池为 **`assigned` `implicitPool`**(前端合并);表格行键与选中状态统一使用 **`judgeId`**,与分配接口提交的 `judgeIds` 一致。
### 报名管理Index
- [x] 去掉个人/团队 Tab合并展示加类型列
- [x] 统计概览(总报名/待审核/已通过/已拒绝)
@ -99,7 +93,6 @@
- [x] 评审状态改用实际完成率(无作品/未开始/进行中/已完成)
- [x] 进度数字颜色区分
- [x] 评审进度详情页筛选修复(评审进度前端过滤生效)
- 活动列表接口 `GET /contests` 为每行返回 `reviewedCount`(该活动下**已分配且全部分配均为 completed** 的作品数)与 `totalWorksCount`(最新有效作品总数),与分配表及评委端评审任务口径一致;见 [评委端评审任务](../judge-portal/review-tasks.md#与租户端评审进度的口径对齐)。
### 评审规则
- [x] 组件映射修复

View File

@ -275,7 +275,7 @@ JWT 改造:
---
#### P0-12. 活动提交联动 ✅ 已实现 (2026-04-07)
#### P0-12. 活动提交联动
**改动范围**:活动报名+提交流程改造
@ -283,39 +283,14 @@ JWT 改造:
后端改动:
├── POST /api/public/activities/:id/submit-work — 改造:支持从作品库选择作品
│ 新增参数userWorkId用户作品ID
│ 逻辑:根据 userWorkId 从 UgcWork + UgcWorkPage 复制快照到 ContestWork
│ 校验:归属当前用户、未删除、非 rejected/taken_down 状态
└── contest_works 表 — user_work_id 字段已存在
│ 逻辑:根据 userWorkId 复制快照到 contest_works
└── contest_works 表 — 新增 user_work_id 字段
前端改动:
├── ActivityDetail.vue — 替换文件上传弹窗为 WorkSelector 作品选择器
├── WorkSelector.vue — 已有组件,新增 redirectUrl prop 支持创作后返回活动页
└── public.ts — submitWork API 新增 userWorkId 参数
├── 活动详情页 提交作品流程 — 改造:弹出作品库选择器,从"我的作品库"选择
├── 作品库选择器组件 — 网格展示可选作品(已发布+私密均可选),确认后提交
```
**快照字段映射**
| UgcWork / UgcWorkPage 字段 | ContestWork 字段 | 说明 |
|---|---|---|
| title | title | 直接复制 |
| description | description | 直接复制 |
| coverUrl | previewUrl | 封面 → 预览图 |
| aiMeta | aiModelMeta | AI 元数据 |
| (所有 page.imageUrl) | previewUrls | 所有页面图片 URL 列表 |
| (所有 page 数据) | files | 分页快照 [{pageNo, imageUrl, text, audioUrl}] |
**用户交互流程**
1. 用户在活动详情页点击"从作品库选择"
2. 弹出 WorkSelector 展示用户所有作品(排除 rejected/taken_down
3. 用户选择作品后确认提交 → 后端复制快照到 ContestWork
4. 若作品库为空,显示"去创作"按钮 → 跳转创作页 → 完成后 redirect 回活动详情页
5. 支持 resubmit 模式:可重新选择不同作品提交
**关键设计**
- 快照不可变性:提交后 ContestWork 数据与 UgcWork 解耦,后续修改/删除作品不影响活动中的作品
- 向后兼容userWorkId 为 null 时走旧逻辑(直接上传)
- 无需数据库变更t_biz_contest_work 已有所需字段
**依赖**P0-4 + P0-6 + 现有活动模块
**产出**:用户可从作品库选作品参与活动

Some files were not shown because too many files have changed in this diff Show More