Compare commits
No commits in common. "df7eae612588cac5e0fd303925840443977b26d9" and "c5fad308499b0eb162a0f2cf25e81494aeff8af1" have entirely different histories.
df7eae6125
...
c5fad30849
180
CLAUDE.md
180
CLAUDE.md
@ -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.4(API文档) |
|
||||
| 前端框架 | 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 层使用 Entity,VO 转换只在 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,所有文本为中文硬编码
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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:00,isStart=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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 为 null,isPlatform 为 true);可与 assigned 合并作为作品分配可选池")
|
||||
private List<Map<String, Object>> implicitPool;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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 对应本项目的租户 code(tenant_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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 的审计字段(updateBy、modifyTime、deleted 等),
|
||||
* 因此独立定义 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 = "事件唯一ID(X-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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 查询
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 监控
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
@ -15,7 +15,7 @@ spring:
|
||||
|
||||
# Flyway 数据库迁移
|
||||
flyway:
|
||||
enabled: true
|
||||
enabled: false
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
|
||||
1032
backend-java/src/main/resources/db/init.sql
Normal file
1032
backend-java/src/main/resources/db/init.sql
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
||||
|
||||
@ -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);
|
||||
@ -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 '赛事ID(null 表示全局)',
|
||||
judge_id BIGINT DEFAULT NULL COMMENT '评委ID(null 表示通用)',
|
||||
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='预设评语表';
|
||||
@ -1,48 +0,0 @@
|
||||
-- ============================================================
|
||||
-- V5: 乐读派 AI 绘本创作系统集成
|
||||
-- 1. t_ugc_work:status VARCHAR → INT(先转换旧数据再改类型)
|
||||
-- 2. 新增乐读派关联字段
|
||||
-- 3. 新增索引
|
||||
-- 4. Webhook 幂等去重表
|
||||
-- ============================================================
|
||||
|
||||
-- 1. t_ugc_work:status 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事件去重表';
|
||||
@ -31,6 +31,4 @@
|
||||
|
||||
## 评委端
|
||||
|
||||
| 文档 | 模块 | 状态 | 日期 |
|
||||
|------|------|------|------|
|
||||
| [评审任务](./judge-portal/review-tasks.md) | 评审任务 / 作品列表 | 已实现 | 2026-04-08 |
|
||||
(暂无)
|
||||
|
||||
@ -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` 汇总,可能与逐活动行「分配完成作品数」不完全同数,属汇总维度不同;列表/详情/评委任务以分配表为准。
|
||||
@ -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` 表 | 数据库中的菜单数据 |
|
||||
|
||||
@ -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] 组件映射修复
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user