Compare commits

...

82 Commits

Author SHA1 Message Date
aid
8638876fa9 style: 公众端剩余页面紫粉主题清理
- Gallery.vue 编辑推荐图标 FireOutlined → CrownFilled,#f59e0b → 主色
- mine/Index.vue 我的收藏菜单图标暖黄 + 橙 → 粉色
- mine/Registrations.vue 待审核 a-tag orange → gold(更柔和的状态色)
- mine/Children.vue 受限模式 a-tag orange → purple
- ActivityDetail.vue 成果区图标 #f59e0b → 主色

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:17:00 +08:00
aid
cd8de97f79 feat: 引入未发布作品状态与状态化操作面板(前端 UI 第一阶段)
- 新增 docs/design/public/ugc-work-status-redesign.md 完整设计方案与状态流转图
- UserWork.status 类型化为 WorkStatus 联合类型,加入 unpublished 中间状态
- 作品库 Index.vue 加「未发布」tab + 紫色标签样式 + emptyDescription + dev mock 兜底
- Detail.vue 完整重写:清 emoji + 紫粉化 + 根据 status 切换 5 套操作按钮
  · draft → 继续创作
  · unpublished → 公开发布 / 编辑信息
  · pending_review → 撤回审核
  · published → 下架
  · rejected → 修改后重交(含拒绝原因卡片)
- EditInfoView 三按钮语义调整:「保存」→ unpublished、「直接发布」→ pending_review
- 删除独立 Publish.vue 与对应路由(发布功能并入 Detail.vue 公开发布按钮)
- 新建 _dev-mock.ts dev 模式数据共享文件,5 条覆盖全状态的 mock 作品 + 13 页详情
- 撤回 / 下架等接口与 leai workId 映射留 TODO,待后端第二阶段联调

详见 docs/design/public/ugc-work-status-redesign.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:48:14 +08:00
aid
951346a7a8 refactor: AI 创作流程 11 页界面全面重做与紫粉主题统一
- aicreate.scss 主题变量紫粉化,对齐 PublicLayout 设计语言
- 11 个创作流程 view 清理 emoji 改 antd 图标,文案去除"孩子/家长"等第三人称
- 路由调整:编排故事改到选画风之前(更顺的产品逻辑)
- WelcomeView 浮动 CTA + 完整 7 步流程引导
- CharactersView 单角色大图 / 多角色网格自适应
- StyleSelectView 预设路径 /aicreate/styles/{styleId}.jpg + SVG fallback
- CreatingView 改为异步任务式说明 + 去作品库入口
- PreviewView / DubbingView 缩略图统一为横向胶卷
- EditInfoView 底部三按钮(保存草稿 / 去配音 / 发布作品),配音改为可选
- BookReaderView 修复 dev 模式数据加载 + 紫粉封面
- DubbingView / BookReaderView 改用 page-fullscreen 布局类避免被 tabbar 遮挡
- store 新增 fillMockData / fillMockWorkDetail,支持 dev 无后端走通完整流程
- works/Index.vue 加 query.tab 双向同步,支持跳转携带 tab 参数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:14:26 +08:00
En
87ac3b5ed9 fix: CORS 配置简化并启用 Spring Security CORS 支持;LeaiSync 作品同步封面图逻辑修复
- CorsConfig 改回允许所有来源(*),移除配置文件域名列表注入
- SecurityConfig 添加 .cors(Customizer.withDefaults()) 使 CorsFilter Bean 生效
- LeaiSyncService 修复 originalImageUrl 同时同步到 coverUrl,单独 coverUrl 优先覆盖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:50:02 +08:00
En
f03991819d feat: 管理端全功能 E2E 测试——40 用例覆盖登录、仪表盘、活动、报名、作品、评审、用户、导航
新增 10 个管理端 E2E 测试文件和 1 个 Mock fixture:
- admin.fixture.ts: Mock 数据 + 登录注入 + 组件预热 + 兜底 API 拦截
- login/contests/dashboard/navigation/registrations/works/reviews/users 等 9 个 spec

关键修复:route.fallback() 替代 route.continue() 修正 Mock 链式传递;
review-rules/select Mock + 兜底拦截器防止未 mock 请求到达真实后端。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 12:52:39 +08:00
zhonghua
f7f97c64e4 fix: 租户表单提交前去除字符串字段首尾空格
Made-with: Cursor
2026-04-09 12:05:46 +08:00
zhonghua
7a8d909df3 fix: 活动列表按阶段筛选未发布与已结束;已结束仅保留查看操作
Made-with: Cursor
2026-04-09 11:41:57 +08:00
zhonghua
d5657d8d23 feat: 表单提交前去除首尾空格;评委性别接口返回与持久化
Made-with: Cursor
2026-04-09 11:34:40 +08:00
zhonghua
c4f4613c49 fix: 活动公告列表回显活动名称并修复取消发布
Made-with: Cursor
2026-04-09 11:21:43 +08:00
zhonghua
b19acbd6d5 feat: 作品分配仅限活动评委、评委库仅启用及 UGC 调整
- 作品管理分配评委仅使用活动显式名单,assignWork 校验 t_biz_contest_judge

- 添加评委/评审进度选择评委时仅查询启用账号;接口文档与 API 注释

- UGC 作品分页与公开创作服务相关改动

Made-with: Cursor
2026-04-09 11:04:37 +08:00
zhonghua
937f0650f0 feat: 活动创建页报名审核开关与作品详情页图片布局优化
Made-with: Cursor
2026-04-09 10:16:23 +08:00
En
c1113c937c feat: 赛事→活动术语统一,AI创作嵌套路由重构,前端依赖升级
后端:
- 全局将"赛事"统一为"活动"(Swagger注解、DTO、Entity、Controller、Service)
- 评审模块DTO/Entity/Service字段调整与优化
- 新增V9迁移脚本,修改V2/V4/V6迁移脚本注释
- PublicRegisterActivityDto字段对齐

前端:
- AI绘本创作路由重构为嵌套路由(11个子路由)
- 新增依赖:@stomp/stompjs、ali-oss、crypto-js
- 环境配置(.env)更新,vite配置调整
- API接口术语统一,PublicLayout与aicreate store优化
- 新增nginx部署文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:58:07 +08:00
En
5bb159358f fix: Webhook同步修复——实现findUserIdByPhone,补充PROCESSING状态coverUrl同步
1. 实现 findUserIdByPhone() 方法(原为 TODO 占位),注入 SysUserMapper 按手机号查询用户
2. updateProcessing() 方法补充 coverUrl 字段同步,AI创作过程中推送的封面图不再被丢弃
3. insertNewWork 中增加 WARN 日志,记录手机号未找到用户或手机号为空的情况

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:57:03 +08:00
En
6365dd8dd0 feat: AI绘本创作H5整合——引入aicreate.scss修复样式,修复checkQuota类型参数
- 在 main.ts 中引入 aicreate.scss,解决所有 CSS 变量和共享样式类缺失的根因问题
- Index.vue 从 iframe 嵌入模式重构为壳组件+子路由渲染模式
- 修复 aicreate.scss 布局适配:height:100% 填充 PublicLayout,page-fullscreen 使用 100% 而非 100dvh
- 修复 checkQuota() 的 type 参数:'A' → 'A3',对齐乐读派后端 V4.0 接口要求
- 迁移 lesingle-aicreate-client 全部 11 个视图、2 个组件、API 层、Store、工具函数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:09:05 +08:00
zhonghua
88ca6264a1 feat: 赛事作品多页预览解析与抽屉分页展示
Made-with: Cursor
2026-04-08 17:35:05 +08:00
zhonghua
cc5a5fb4e3 fix: 我的收藏列表与后端扁平字段对齐,修复 coverUrl 报错
Made-with: Cursor
2026-04-08 17:16:13 +08:00
zhonghua
e8da2ee3f8 调整代码 2026-04-08 16:58:55 +08:00
zhonghua
593f7977eb feat: 公众端活动成果卡片展示与公开公示接口
Made-with: Cursor
2026-04-08 16:31:48 +08:00
zhonghua
328533e805 feat: C端活动详情返回公告与附件,子女账号简化报名弹窗
Made-with: Cursor
2026-04-08 16:00:59 +08:00
zhonghua
3fa1ef95ac feat: 主站 /ai-web 嵌入 AI 创作子应用并修正路径与通信
Made-with: Cursor
2026-04-08 15:32:18 +08:00
En
b9ed5e17c6 feat: OSS 客户端直传改造(STS Token 签发 + 前端直传 + CORS 自动配置)
后端新增 OssUtils/OssTokenVo/OssCorsInitRunner,通过 STS 临时凭证实现客户端直传 OSS;
前端 upload API 适配直传流程,赛事创建/作品提交/作业/富文本编辑器均已切换;
多环境(dev/test/prod) OSS 配置补全;新增 oss-direct-upload-demo 示例项目及 E2E 测试。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:19:43 +08:00
zhonghua
9f036eb81f fix: 成果详情搜索表单绑定 model 以触发查询
Made-with: Cursor
2026-04-08 14:47:19 +08:00
zhonghua
36cd01c585 feat: 作品编号 workNo 生成与回填(公开端/作业/Flyway V8)及评审与前端展示
Made-with: Cursor
2026-04-08 14:44:16 +08:00
zhonghua
430dce1f09 postMessage消息对接优化 2026-04-08 14:09:52 +08:00
En
1eb76979c4 Merge remote-tracking branch 'origin/master_develop' into master_develop 2026-04-08 13:39:03 +08:00
En
fa42eca339 feat: 数据库注释补全、常量枚举重构及多模块优化
- 新增 Flyway V6/V7 迁移脚本,为全部 42 张表、591 个列添加中文注释
- 抽取公共常量类(BaseEntityConstants、CacheConstants、RoleConstants、TenantConstants)
- 新增业务枚举(CommonStatus、RegistrationStatus、WorkStatus 等 11 个)
- 优化赛事/作业/评审/UGC 等模块服务层代码
- 更新乐读派(leai)模块配置与 API 客户端
- 更新 e2e 测试用例及 demo 文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:37:14 +08:00
zhonghua
7d7ef9820b fix: 作品分配评委移除后同步数据库并允许清空评委
Made-with: Cursor
2026-04-08 11:30:54 +08:00
zhonghua
df7eae6125 fix: 纯评委角色隐藏机构端评委管理菜单
Made-with: Cursor
2026-04-08 11:13:35 +08:00
zhonghua
197064820b feat: 评委角色权限补全与租户评委菜单合并,更新 menu-config 说明
Made-with: Cursor
2026-04-08 11:07:11 +08:00
zhonghua
180c22fe49 feat: 作品列表终分回算、评审进度详情展示对齐及评委管理优化
Made-with: Cursor
2026-04-08 10:53:50 +08:00
En
bc7c17b281 refactor: 乐读派(leai)模块规范化改造
按照项目 Java 后端规范对 leai 模块进行全面重构:

- 新增 ILeaiWebhookEventService/ILeaiSyncService 接口,遵循 IService 模式
- Controller 层通过 Service 接口调用,不再直接注入 Mapper
- 新增 LeaiTokenVO/LeaiAuthRedirectDTO,替代 Map<String,String> 入参出参
- RuntimeException 替换为 BusinessException
- 添加 @Tag/@Operation Swagger 注解
- 提取共享工具类 LeaiUtil,消除 4 处重复的 toInt/toString 方法
- LeaiWebhookEvent 实体添加 @Schema 注解

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:23:11 +08:00
En
9b5c24c49c Merge remote-tracking branch 'origin/master_develop' into master_develop 2026-04-08 09:36:50 +08:00
En
a660493cf3 修复弹窗 2026-04-08 09:36:42 +08:00
En
922f650365 feat: 添加乐读派(leai)集成模块及E2E测试基础设施
后端:
- 新增 leai 模块:认证、Webhook、数据同步、定时对账
- 新增 LeaiConfig/RestTemplateConfig/SchedulingConfig 配置
- 新增 FlywayRepairConfig 处理迁移修复
- 新增 V5__leai_integration.sql 迁移脚本
- 扩展所有实体类添加 tenantId 等字段
- 更新 SecurityConfig 放行 leai 公开接口
- 添加 application-test.yml 测试环境配置

前端:
- 添加乐读派认证 API (public.ts)
- 优化 Generating.vue 生成页
- 添加 Playwright E2E 测试配置及依赖
- 添加测试 fixtures、utils、mock-h5.html
- 添加 leai 模块完整 E2E 测试套件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:52:32 +08:00
En
9ad9f5b237 fix: 创作页 iframe tab 切换状态保持
使用 v-show 始终挂载方案替代 KeepAlive,解决 iframe 内 H5 状态
在 tab 切换后丢失的问题。Vue KeepAlive 会移动 DOM 导致浏览器
重新加载 iframe 内容,v-show 只切换 CSS display 不移动 DOM。

- PublicLayout 中将 PublicCreate 渲染在 router-view 外部
- v-if 懒挂载(首次访问创建),v-show 控制显隐
- 登出时销毁组件避免数据泄漏
- 添加 RouteMeta keepAlive 类型定义
- 添加 E2E 测试覆盖 5 个 tab 切换场景

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:50:08 +08:00
zhonghua
2f84ac16d3 fix:添加评委选择回显 2026-04-07 19:32:44 +08:00
zhonghua
7a039e8403 fix:分配评委时应该查询租户下的+平台的评委 2026-04-07 19:16:13 +08:00
zhonghua
1d43501983 fix:修复评委打分403 2026-04-07 17:48:48 +08:00
zhonghua
f2c10d5e32 fix:添加作品编号,报名账号,评分字段 2026-04-07 17:16:40 +08:00
zhonghua
170d904081 feat: 作品管理分配状态/评委回显 + 评委管理租户隔离
Changes:
1. ContestWorkServiceImpl: findAll 返回 assignments、_count 数据 + assignStatus 搜索
2. ContestJudgeServiceImpl: 评委列表返回 assignedCount(已分配作品数)
3. JudgesManagementServiceImpl: 评委库租户隔离(查询当前租户+平台评委,创建在当前租户、平台评委只读)
4. judges/Index.vue: 增加"来源"列 + 平台评委操作限制
5. judges-management.ts: 类型增加 isPlatform/tenantId
6. WorksDetail.vue: 小修改

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 17:10:40 +08:00
zhonghua
3fd7002e2a fix:修复评审进度500错误 2026-04-07 15:31:06 +08:00
zhonghua
633e46e663 feat: 添加评审模块Flyway迁移脚本V3
使用 DROP TABLE IF EXISTS + CREATE TABLE 确保表结构完整对齐实体类:
- t_biz_contest_judge(评委表,继承BaseEntity)
- t_biz_contest_review_rule(评审规则表,继承BaseEntity)
- t_biz_contest_work_judge_assignment(分配表,独立字段)
- t_biz_contest_work_score(评分表,继承BaseEntity)
- t_biz_preset_comment(预设评语表,继承BaseEntity)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 15:09:31 +08:00
zhonghua
2b83c9c78a revert: 撤销评审表相关两次提交,需按设计文档规范重新实现
Reverts:
- eb40939 fix: 添加评审表自动创建兜底机制
- 015f871 feat: 创建评审模块数据库表

原因:数据库变更应使用Flyway迁移脚本规范实现,需对齐设计文档
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:42:58 +08:00
zhonghua
eb409398f3 fix: 添加评审表自动创建兜底机制
Flyway迁移可能未执行,添加ApplicationRunner在启动时自动检测并创建评审相关5张表。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:32:50 +08:00
zhonghua
015f8718c4 feat: 创建评审模块数据库表
包含5张表:评委表、评审规则表、作品评委分配表、作品评分表、预设评语表

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:23:39 +08:00
zhonghua
2f521c7249 添加环境变量 2026-04-07 14:13:17 +08:00
zhonghua
1c63cb21e5 feat: 活动提交联动作品库+多租户数据对齐
1. P0-12 活动提交联动:替换文件上传为 WorkSelector 作品选择器
   - 前端 ActivityDetail.vue 集成 WorkSelector 组件
   - 后端 submitWork 支持 userWorkId 快照复制(title/description/coverUrl/pages)
   - WorkSelector 支持 redirectUrl 创作后返回活动页

2. 多租户数据对齐:修复公众端报名/作品 tenantId 不一致
   - register() 使用活动的 contestTenants[0] 作为 tenantId
   - submitWork() 使用报名记录的 tenantId
   - 管理端报名/作品统计、列表数据一致

3. 前端报名状态区分:pending/passed/rejected 显示不同按钮
4. submitWork 报名状态检查:区分未报名/审核中/已拒绝提示
5. 活动列表添加 _count(报名数/作品数)用于已交/应交展示
6. 修复 PublicCreationService.submit() title 默认值缺失

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:11:59 +08:00
zhonghua
0252f25acd Merge branch 'master_develop' of http://8.148.151.56:3000/tonytech/library-picturebook-activity into master_develop 2026-04-07 13:44:48 +08:00
En
3c24cc3102 feat: 添加CLAUDE.md项目指导文件及AI创作客户端更新
添加 CLAUDE.md 用于 Claude Code 项目导航,包含架构说明和开发规范。
更新 AI 创作客户端至 V4.0,新增后端对接示例项目。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 12:11:15 +08:00
zhonghua
a5909f98be fix:创建作品修复 2026-04-07 09:50:44 +08:00
zhonghua
15581e04ae 添加C端AI绘本创作 2026-04-03 20:55:51 +08:00
zhonghua
1003776dd3 fix:多项前端修复与功能对齐
- 修复评委端进入评审contestId为NaN(record.id→record.contestId)
- 修复评委评审详情403(活动名称改为路由传参,跳过需要contest:read权限的接口)
- 已发布活动隐藏编辑按钮
- 添加评委成功提示去重(移除子组件重复message)
- 用户端活动阶段判断修复(报名与提交重叠时优先显示提交阶段)
- 用户端作品提交支持submitRule(once/resubmit)重新提交
- 后端公共API补充submitRule字段返回
- 报名统计接口增加租户隔离,修复统计与列表数据不一致

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:29:28 +08:00
zhonghua
3ef05de193 fix:评委端403修复 2026-04-03 19:10:44 +08:00
zhonghua
d68322f24a fix:修复我的报名列表 2026-04-03 18:47:42 +08:00
zhonghua
63c564a03b fix:修复报名 2026-04-03 16:28:38 +08:00
zhonghua
ff25e41243 fix:用户端活动报名修复报名按钮 2026-04-03 15:59:54 +08:00
zhonghua
bee5152a2d fix:修复可见范围 2026-04-03 15:35:21 +08:00
zhonghua
b3954ffcf3 fix:活动详情优化 2026-04-03 15:28:15 +08:00
zhonghua
4a70bc7d43 fix:系统国际化支持 2026-04-03 14:55:27 +08:00
zhonghua
764f6eec4b fix:公告添加租户隔离 2026-04-03 14:49:32 +08:00
zhonghua
7afb57c9bf fix:添加活动 2026-04-03 14:18:38 +08:00
zhonghua
78cd956ab5 fix:修复创建活动失败 2026-04-03 13:49:19 +08:00
zhonghua
f223e9bd41 清理文件 2026-04-03 10:17:36 +08:00
zhonghua
c5fad30849 fix: 修复用户管理页面所属机构字段显示及列表过滤逻辑
1. 前端所属机构字段改为使用后端返回的平铺 tenantName 字段
   - users.ts: 添加 tenantName, tenantCode, tenantType, tenantIsSuper 平铺字段
   - Index.vue: 表格列和详情 Drawer 使用 record.tenantName/detailData.tenantName

2. 后端修复机构用户 (org) 过滤逻辑
   - SysUserServiceImpl: case "org" 分支增加 getOrgTenantIds() 调用,传递 orgTenantIdsFilter 参数
   - SysUserMapper.xml: 增加 orgTenantIdsFilter 参数处理,使用 IN 查询过滤

3. 后端修复公众 (public) 和评委 (judge) 用户过滤逻辑
   - 数据库中 public 租户的 tenant_type='platform',judge 租户的 tenant_type='other'
   - case "public"/"judge" 改为传递 tenantCodeFilter 参数,按租户 code 过滤
   - SysUserMapper.xml: 增加 tenantCodeFilter 参数处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:06:09 +08:00
zhonghua
d19d7d9a2c Merge branch 'develop' of http://8.148.151.56:3000/tonytech/library-picturebook-activity into master_develop 2026-04-02 18:49:12 +08:00
zhonghua
dcaa7e1779 调整样式 2026-04-02 18:49:01 +08:00
aid
3c4100c231 feat: 创建租户时自动生成管理员账号、角色和权限
创建租户改为事务化一站式操作:自动复制 gdlib 权限模板 + 补充基础管理权限,
创建 tenant_admin 角色和管理员用户,支持自定义账号密码。
前端表单增加管理员输入区块,成功弹窗展示凭据并支持一键复制。
同步实现 menuIds 菜单分配(消除原 TODO)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:48:34 +08:00
zhonghua
8154628d3d Merge branch 'master_develop' of http://8.148.151.56:3000/tonytech/library-picturebook-activity into develop 2026-04-02 18:31:51 +08:00
zhonghua
ea65b55332 fix: 补齐作品租户隔离与租户菜单树
- 作品列表/统计补齐 validState 与租户条件,关键字支持报名/队伍信息匹配
- 新增租户菜单树接口与服务实现,结构对齐用户菜单树
- t_biz_contest_work 增加 deleted 字段,补充 flyway 迁移与启动时轻量修复

Made-with: Cursor
2026-04-02 18:30:45 +08:00
aid
c99738fc46 新增 Java 转写开发日志 Day1
记录完整开发过程:模块剥离→转写(8个Phase)→数据库适配→写操作测试→
前端联调→端到端验证→权限检查→菜单修复→文档输出,含明日待续清单

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:16:15 +08:00
zhonghua
ab5bd36cec 移除node后端 2026-04-02 17:14:11 +08:00
aid
0b989b047a 完善菜单配置规范文档:补充全量菜单ID表+各端登录信息+权限码+调试指南
- 新增各端登录信息速查表(URL/用户名/密码/角色)
- 新增完整菜单ID对照表(54条菜单,标注所属端和废弃状态)
- 补充评委端权限码列表(10个)
- 补充公众端导航结构+无需认证接口列表
- 补充租户端各子菜单功能说明
- 补充浏览器缓存切换端的注意事项
- 补充新建租户时的菜单分配说明

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:39:59 +08:00
aid
e9676ea924 新增菜单配置规范文档 + 修正超管端移除数据统计模块
- 新增 docs/design/menu-config.md:各端菜单完整配置规范
  - 超管端 5个一级21条(活动监管/内容管理/机构管理/用户中心/系统设置)
  - 租户端 4个一级18条(工作台/数据统计/活动管理/系统设置)
  - 评委端 1个一级3条(我的评审)
  - 含排除清单、技术实现要点、权威来源说明
- 修正超管端 tenant_menus:移除数据统计(52/53/54),数据统计是租户端专属

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:31:22 +08:00
aid
fd9c739cf5 修复租户端登录和菜单:支持 tenantCode 登录 + gdlib 菜单修正
问题1: 前端登录传 body.tenantCode(如 gdlib),但 Java 后端只从 X-Tenant-Id header 取租户ID
修复: AuthController/AuthService 支持从 tenantCode 查找租户,兼容两种方式

问题2: gdlib 租户菜单错乱(包含了超管端的活动监管/内容管理等)
修复: 重置 gdlib tenant_menus 为正确的18条:
  工作台(50) + 活动管理(9+8子) + 数据统计(52+2子) + 系统设置(14+4子:机构信息/用户/角色/日志)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:26:58 +08:00
aid
5b5af63f58 修复超管端菜单错乱 + Knife4j版本兼容
- SysMenuServiceImpl: 超管也按 tenant_menus 过滤菜单(之前返回全部52个菜单导致错乱)
- 远程数据库: 超管端 tenant_menus 补全至27条(活动监管8子+数据统计2子+内容管理3子+机构管理+用户中心2子+系统设置5子)
- Knife4j 版本 4.5.0→4.4.0(修复 SpringDocConfigProperties bean 找不到)
- Flyway 禁用(同事已删除迁移脚本)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:08:40 +08:00
zhonghua
483740f10b 调整密码 2026-04-02 15:51:53 +08:00
zhonghua
249e73d252 环境更变 2026-04-02 15:49:40 +08:00
aid
5c0d87d4a6 替换数据库导出为干净的初始化脚本 init.sql
- 去掉所有外键约束(按Java规范:关联通过代码控制)
- 去掉GTID设置(兼容不同MySQL版本)
- 41张表全部使用新规范表名(t_sys_/t_biz_/t_ugc_/t_user_)
- 含新审计字段(create_by/update_by/deleted)
- 已在空库验证导入成功
- 位置: backend-java/src/main/resources/db/init.sql

使用方法: mysql -u root < backend-java/src/main/resources/db/init.sql

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:41:46 +08:00
aid
58c232dadc Java后端正式版:表名规范化 + Flyway启用 + 数据库导出
- 所有实体 @TableName 改为规范格式(t_sys_/t_biz_/t_ugc_/t_user_)
- Flyway 启用(V1重命名+V2新审计字段)
- BaseEntity 启用新审计字段(create_by/update_by/deleted)
- 逻辑删除启用(deleted字段)
- SysUserMapper.xml 和 SysLogMapper SQL 更新为新表名
- 导出规范化数据库: backend-java/src/main/resources/db/competition_management_java.sql
  - 41张表,全部 t_{module}_ 前缀
  - 含完整数据(用户/租户/权限/活动/作品/评分等)
  - admin 密码统一为 admin123

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:57:26 +08:00
aid
a6b056520d 更新数据库导出:含权限补全 + modify_time 默认值修复
- gdlib 租户补全全套权限码(97个),分配给 tenant_admin
- 所有表 modify_time 列添加 DEFAULT CURRENT_TIMESTAMP(3)
- configs 表添加 valid_state 列
- t_contest_registration 表添加 valid_state 列
- 所有 admin 账号密码重置为 admin123

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:47:53 +08:00
aid
096d06af3d Java 后端完整转写:NestJS/Prisma → Spring Boot/MyBatis-Plus
## 技术栈
- Spring Boot 3.2 + Java 17 + MyBatis-Plus 3.5
- Spring Security + JWT 认证(与 NestJS 兼容)
- MapStruct + Knife4j + Druid + Hutool + FastJSON2
- 腾讯云 COS 文件上传

## 项目规模
- 239 个 Java 文件,246 个文件总计
- 39 个实体类映射到现有数据库
- ~256 个 API 端点,与 NestJS 完全兼容

## 模块清单
- Phase 0: 脚手架 + 基础框架(BaseEntity, Result, JWT, Security, AOP权限)
- Phase 1: 认证/用户/角色/权限/租户(~35 接口)
- Phase 2: 菜单/字典/配置/日志(~25 接口)
- Phase 3: 赛事核心 — 赛事/报名/作品/团队/附件/公告(~46 接口)
- Phase 4: 评审/计分/成果 — 评审规则/评委/分配/评分/排名/奖项/发布(~52 接口)
- Phase 5: 作业 — 作业/提交/评分/评审规则(~20 接口)
- Phase 6: 公众端 — 注册/登录/画廊/活动/作品库/子女/互动/内容审核(~55 接口)
- Phase 7: UGC — 作品/绘本页/标签/点赞/收藏/评论/举报/审核日志(~15 接口)
- Phase 8: 文件上传/OSS(1 接口)

## 验证结果
- mvn compile 零错误,1.8秒启动
- 62 个 API 端点手动测试通过(GET + POST/PATCH/DELETE)
- 20 个前端页面 Playwright 自动化测试通过,69+ API 调用零错误
- 核心业务全流程验证:登录→赛事→报名→作品→评审→计分→排名→奖项→发布

## 数据库适配
- 使用现有表名(Flyway 暂禁用,待正式切换时启用)
- Flyway V1/V2 迁移脚本已准备(表重命名+新审计字段)
- 修复:configs/t_contest_registration 添加 valid_state 列
- 修复:所有表 modify_time 添加 DEFAULT CURRENT_TIMESTAMP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:05:41 +08:00
aid
bead1cf4dc 剥离学校端/教师端/学生端/3D建模模块,清理跨模块引用
- 移除 backend: school/ (schools, departments, grades, classes, teachers, students)
- 移除 backend: ai-3d/ (controller, service, providers, utils)
- 移除 frontend: views/school/, views/workbench/ai-3d/, views/model/
- 移除 prisma schema: School, Grade, Department, Class, Teacher, Student, StudentInterestClass, AI3DTask 共8个模型
- 移除 app.module.ts: SchoolModule, AI3DModule 导入
- 移除 router/index.ts: 3D建模4条路由
- 移除 menu.ts: componentMap 中学校/3D映射
- 修复 registrations.service.ts: 教师判断从 Teacher 模型改为角色判断
- 修复 results.service.ts: 移除 student include
- 修复 homework services: 移除 student/class/grade 相关 Prisma 查询
- 保留 students.ts/teachers.ts/ai-3d.ts 最小类型存根供赛事组件引用
- 原始代码备份至 competition-management-system-stripped-modules/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:04:40 +08:00
891 changed files with 69039 additions and 67268 deletions

180
CLAUDE.md Normal file
View File

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

5
backend-java/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
uploads/
*.log
.idea/
*.iml

198
backend-java/pom.xml Normal file
View File

@ -0,0 +1,198 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.competition</groupId>
<artifactId>competition-management-system</artifactId>
<version>1.0.0</version>
<name>competition-management-system</name>
<description>少儿绘本创作活动管理平台 - Java 后端</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<druid.version>1.2.23</druid.version>
<jjwt.version>0.12.6</jjwt.version>
<knife4j.version>4.4.0</knife4j.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<hutool.version>5.8.32</hutool.version>
<fastjson2.version>2.0.53</fastjson2.version>
<aliyun-oss.version>3.17.1</aliyun-oss.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Druid 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Flyway 数据库迁移 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Knife4j API 文档 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Hutool 工具集 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- FastJSON2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- 阿里云 OSS -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun-oss.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- Lombok-MapStruct 绑定,确保 MapStruct 能识别 Lombok 生成的方法 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,23 @@
package com.competition;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan({
"com.competition.modules.sys.mapper",
"com.competition.modules.biz.contest.mapper",
"com.competition.modules.biz.review.mapper",
"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"
})
public class CompetitionApplication {
public static void main(String[] args) {
SpringApplication.run(CompetitionApplication.class, args);
}
}

View File

@ -0,0 +1,34 @@
package com.competition.common.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口速率限制注解
* 用于公开接口防止恶意调用
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 时间窗口内允许的最大请求次数
*/
int permits() default 10;
/**
* 时间窗口大小
*/
long duration() default 1;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限制维度ip / user
*/
String key() default "ip";
}

View File

@ -0,0 +1,49 @@
package com.competition.common.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 数据库启动时轻量修复
* 由于本项目部分环境可能没有跑过最新的 init.sql
* 导致 `t_biz_contest_work` 缺少 `deleted` 字段从而 MyBatis-Plus 逻辑删除查询报 500
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ContestWorkSchemaRepair implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(ApplicationArguments args) {
try {
Integer columnCnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) " +
"FROM information_schema.columns " +
"WHERE table_schema = DATABASE() " +
"AND table_name = 't_biz_contest_work' " +
"AND column_name = 'deleted'",
Integer.class
);
if (columnCnt == null || columnCnt == 0) {
log.warn("检测到表 `t_biz_contest_work` 缺少字段 `deleted`,尝试补齐...");
jdbcTemplate.execute(
"ALTER TABLE t_biz_contest_work " +
"ADD COLUMN deleted tinyint NOT NULL DEFAULT '0' " +
"COMMENT '逻辑删除0-未删除1-已删除'"
);
log.info("补齐字段 `t_biz_contest_work.deleted` 完成");
}
} catch (Exception e) {
// 不阻断启动给调用方避免 500 需要依赖数据库权限/执行成功
log.warn("补齐 `t_biz_contest_work.deleted` 失败:{}", e.getMessage());
}
}
}

View File

@ -0,0 +1,36 @@
package com.competition.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
* 跨域配置
* 允许所有来源访问方便前后端分离开发部署
*/
@Slf4j
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
// 允许所有来源
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addExposedHeader("X-Trace-Id");
log.info("CORS 配置:允许所有来源(*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

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

View File

@ -0,0 +1,29 @@
package com.competition.common.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Knife4j / OpenAPI 配置
*/
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("少儿绘本创作活动管理平台 API")
.description("Competition Management System - Java Backend")
.version("1.0.0"))
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
.schemaRequirement("Bearer", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT"));
}
}

View File

@ -0,0 +1,22 @@
package com.competition.common.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*/
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

View File

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

View File

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

View File

@ -0,0 +1,27 @@
package com.competition.common.config;
import com.competition.common.interceptor.RateLimitInterceptor;
import com.competition.common.interceptor.TraceIdInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* WebMvc 配置
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final TraceIdInterceptor traceIdInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**");
// 速率限制拦截器仅对公开接口生效
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/public/**", "/webhook/**");
}
}

View File

@ -0,0 +1,21 @@
package com.competition.common.constants;
/**
* BaseEntity 基础字段常量
*/
public final class BaseEntityConstants {
private BaseEntityConstants() {}
/** 未删除 */
public static final int NOT_DELETED = 0;
/** 已删除 */
public static final int DELETED = 1;
/** 有效状态 */
public static final int VALID = 1;
/** 无效状态 */
public static final int INVALID = 2;
}

View File

@ -0,0 +1,21 @@
package com.competition.common.constants;
/**
* 缓存相关常量
*/
public final class CacheConstants {
private CacheConstants() {}
/** 用户角色缓存 key 前缀 */
public static final String USER_ROLES_PREFIX = "user:roles:";
/** 用户权限缓存 key 前缀 */
public static final String USER_PERMS_PREFIX = "user:perms:";
/** 认证缓存天数 */
public static final int AUTH_CACHE_DAYS = 7;
/** Token 黑名单 key 前缀(用于登出/密码修改后使旧 Token 失效) */
public static final String TOKEN_BLACKLIST_PREFIX = "token:blacklist:";
}

View File

@ -0,0 +1,21 @@
package com.competition.common.constants;
/**
* 角色相关常量
*/
public final class RoleConstants {
private RoleConstants() {}
/** 超级管理员角色编码 */
public static final String SUPER_ADMIN = "super_admin";
/** 公众用户角色编码 */
public static final String PUBLIC_USER = "public_user";
/** 评委角色编码 */
public static final String JUDGE = "judge";
/** 租户管理员角色编码 */
public static final String TENANT_ADMIN = "tenant_admin";
}

View File

@ -0,0 +1,23 @@
package com.competition.common.constants;
import java.util.Set;
/**
* 租户相关常量
*/
public final class TenantConstants {
private TenantConstants() {}
/** 超级管理员租户编码 */
public static final String CODE_SUPER = "super";
/** 公众端租户编码 */
public static final String CODE_PUBLIC = "public";
/** 评委租户编码 */
public static final String CODE_JUDGE = "judge";
/** 内部系统租户编码集合(不可删除、不可注册) */
public static final Set<String> INTERNAL_TENANT_CODES = Set.of(CODE_SUPER, CODE_PUBLIC, CODE_JUDGE, "school", "teacher", "student");
}

View File

@ -0,0 +1,69 @@
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;
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;
// ====== 旧审计字段过渡期保留请使用 createBy/updateBy ======
/** 创建人 ID已弃用请使用 createBy */
@Deprecated
@Schema(description = "创建人ID已弃用请使用 createBy")
@TableField(value = "creator", fill = FieldFill.INSERT)
private Integer creator;
/** 修改人 ID已弃用请使用 updateBy */
@Deprecated
@Schema(description = "修改人ID已弃用请使用 updateBy")
@TableField(value = "modifier", fill = FieldFill.INSERT_UPDATE)
private Integer modifier;
/** 创建时间 */
@Schema(description = "创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 修改时间 */
@Schema(description = "修改时间")
@TableField(value = "modify_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime modifyTime;
/** 有效状态1-有效2-失效 */
@Schema(description = "有效状态1-有效2-失效")
@TableField(value = "valid_state", fill = FieldFill.INSERT)
private Integer validState;
}

View File

@ -0,0 +1,18 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 通用状态枚举启用/禁用
*/
@Getter
@AllArgsConstructor
public enum CommonStatus {
ENABLED("enabled", "启用"),
DISABLED("disabled", "禁用");
private final String value;
private final String description;
}

View File

@ -0,0 +1,58 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 错误码枚举
* HTTP 状态码级别 + 业务错误码分组
* 10xx 用户模块 / 20xx 活动模块 / 30xx 评审模块 / 40xx 作品模块 / 50xx 系统模块
*/
@Getter
@AllArgsConstructor
public enum ErrorCode {
// ====== HTTP 状态码级别 ======
SUCCESS(200, "success"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或 Token 已过期"),
FORBIDDEN(403, "没有访问权限"),
NOT_FOUND(404, "资源不存在"),
CONFLICT(409, "数据冲突"),
INTERNAL_ERROR(500, "系统内部错误"),
// ====== 用户模块 10xx ======
USER_NOT_FOUND(1001, "用户不存在"),
USER_DISABLED(1002, "用户已被禁用"),
USER_PASSWORD_ERROR(1003, "密码错误"),
USER_DUPLICATE(1004, "用户名已存在"),
USER_PHONE_DUPLICATE(1005, "手机号已注册"),
// ====== 活动模块 20xx ======
CONTEST_NOT_FOUND(2001, "活动不存在"),
CONTEST_ALREADY_PUBLISHED(2002, "活动已发布"),
CONTEST_NOT_PUBLISHED(2003, "活动未发布"),
CONTEST_TIME_INVALID(2004, "活动时间配置无效"),
CONTEST_REGISTRATION_CLOSED(2005, "报名已截止"),
CONTEST_SUBMIT_CLOSED(2006, "提交已截止"),
CONTEST_ALREADY_FINISHED(2007, "活动已结束"),
CONTEST_REVIEW_INCOMPLETE(2008, "评审未完成"),
// ====== 评审模块 30xx ======
REVIEW_NOT_FOUND(3001, "评审记录不存在"),
REVIEW_ALREADY_SCORED(3002, "已评分,请勿重复提交"),
REVIEW_FINAL_SCORE_LOCKED(3003, "终分已锁定,无法修改评分"),
REVIEW_NOT_ASSIGNED(3004, "作品未分配给该评委"),
// ====== 作品模块 40xx ======
WORK_NOT_FOUND(4001, "作品不存在"),
WORK_ALREADY_SUBMITTED(4002, "作品已提交"),
WORK_RESUBMIT_NOT_ALLOWED(4003, "不允许重新提交"),
// ====== 文件上传模块 50xx ======
FILE_TYPE_NOT_ALLOWED(5001, "不支持的文件类型"),
FILE_SIZE_EXCEEDED(5002, "文件大小超限");
private final Integer code;
private final String message;
}

View File

@ -0,0 +1,18 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 参赛者类型枚举
*/
@Getter
@AllArgsConstructor
public enum ParticipantType {
SELF("self", "本人"),
CHILD("child", "儿童");
private final String value;
private final String description;
}

View File

@ -0,0 +1,18 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 发布状态枚举
*/
@Getter
@AllArgsConstructor
public enum PublishStatus {
PUBLISHED("published", "已发布"),
UNPUBLISHED("unpublished", "未发布");
private final String value;
private final String description;
}

View File

@ -0,0 +1,19 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 报名状态枚举
*/
@Getter
@AllArgsConstructor
public enum RegistrationStatus {
PENDING("pending", "待审核"),
PASSED("passed", "已通过"),
REJECTED("rejected", "已拒绝");
private final String value;
private final String description;
}

View File

@ -0,0 +1,18 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 提交规则枚举
*/
@Getter
@AllArgsConstructor
public enum SubmitRule {
ONCE("once", "仅一次"),
RESUBMIT("resubmit", "可重提交");
private final String value;
private final String description;
}

View File

@ -0,0 +1,22 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 租户类型枚举
*/
@Getter
@AllArgsConstructor
public enum TenantType {
PLATFORM("platform", "平台"),
LIBRARY("library", "图书馆"),
KINDERGARTEN("kindergarten", "幼儿园"),
SCHOOL("school", "学校"),
INSTITUTION("institution", "机构"),
OTHER("other", "其他");
private final String value;
private final String description;
}

View File

@ -0,0 +1,19 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户来源枚举
*/
@Getter
@AllArgsConstructor
public enum UserSource {
ADMIN_CREATED("admin_created", "管理员创建"),
SELF_REGISTERED("self_registered", "自主注册"),
CHILD_MIGRATED("child_migrated", "儿童迁移");
private final String value;
private final String description;
}

View File

@ -0,0 +1,18 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户类型枚举
*/
@Getter
@AllArgsConstructor
public enum UserType {
ADULT("adult", "成人"),
CHILD("child", "儿童");
private final String value;
private final String description;
}

View File

@ -0,0 +1,20 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 可见性枚举
*/
@Getter
@AllArgsConstructor
public enum Visibility {
PUBLIC("public", "公开"),
DESIGNATED("designated", "指定"),
INTERNAL("internal", "内部"),
PRIVATE("private", "私有");
private final String value;
private final String description;
}

View File

@ -0,0 +1,23 @@
package com.competition.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 作品状态枚举
*/
@Getter
@AllArgsConstructor
public enum WorkStatus {
SUBMITTED("submitted", "已提交"),
LOCKED("locked", "已锁定"),
REVIEWING("reviewing", "评审中"),
REJECTED("rejected", "已拒绝"),
ACCEPTED("accepted", "已采纳"),
AWARDED("awarded", "已获奖"),
TAKEN_DOWN("taken_down", "已下架");
private final String value;
private final String description;
}

View File

@ -0,0 +1,36 @@
package com.competition.common.exception;
import com.competition.common.enums.ErrorCode;
import lombok.Getter;
/**
* 业务异常
*/
@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}
public static BusinessException of(ErrorCode errorCode) {
return new BusinessException(errorCode);
}
public static BusinessException of(ErrorCode errorCode, String message) {
return new BusinessException(errorCode, message);
}
}

View File

@ -0,0 +1,83 @@
package com.competition.common.exception;
import com.competition.common.enums.ErrorCode;
import com.competition.common.result.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 业务异常 */
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("业务异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage());
return Result.error(e.getCode(), e.getMessage(), request.getRequestURI());
}
/** 参数校验异常(@Valid */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("参数校验失败,路径:{},消息:{}", request.getRequestURI(), message);
return Result.error(400, message, request.getRequestURI());
}
/** 参数绑定异常 */
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBindException(BindException e, HttpServletRequest request) {
String message = e.getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("参数绑定失败,路径:{},消息:{}", request.getRequestURI(), message);
return Result.error(400, message, request.getRequestURI());
}
/** 认证异常 */
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<Void> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {
return Result.error(401, ErrorCode.UNAUTHORIZED.getMessage(), request.getRequestURI());
}
/** 授权异常 */
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) {
return Result.error(403, ErrorCode.FORBIDDEN.getMessage(), request.getRequestURI());
}
/** 资源不存在 */
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Void> handleNotFoundException(NoResourceFoundException e, HttpServletRequest request) {
return Result.error(404, ErrorCode.NOT_FOUND.getMessage(), request.getRequestURI());
}
/** 兜底:未知异常 */
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
log.error("系统异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage(), e);
return Result.error(500, ErrorCode.INTERNAL_ERROR.getMessage(), request.getRequestURI());
}
}

View File

@ -0,0 +1,57 @@
package com.competition.common.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.competition.common.util.SecurityUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 审计字段自动填充
* 同时填充新字段create_by/update_by/deleted和旧字段creator/modifier/valid_state
*/
@Slf4j
@Component
public class AuditMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
String username = SecurityUtil.getCurrentUsername();
Long userId = SecurityUtil.getCurrentUserIdOrNull();
// 新审计字段
this.strictInsertFill(metaObject, "createBy", String.class, username);
this.strictInsertFill(metaObject, "updateBy", String.class, username);
this.strictInsertFill(metaObject, "deleted", Integer.class, 0);
// 旧审计字段
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "modifyTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "validState", Integer.class, 1);
if (userId != null) {
this.strictInsertFill(metaObject, "creator", Integer.class, userId.intValue());
this.strictInsertFill(metaObject, "modifier", Integer.class, userId.intValue());
}
}
@Override
public void updateFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
String username = SecurityUtil.getCurrentUsername();
Long userId = SecurityUtil.getCurrentUserIdOrNull();
// 新审计字段
this.strictUpdateFill(metaObject, "updateBy", String.class, username);
// 旧审计字段
this.strictUpdateFill(metaObject, "modifyTime", LocalDateTime.class, now);
if (userId != null) {
this.strictUpdateFill(metaObject, "modifier", Integer.class, userId.intValue());
}
}
}

View File

@ -0,0 +1,101 @@
package com.competition.common.interceptor;
import com.competition.common.annotation.RateLimit;
import com.competition.common.result.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.concurrent.TimeUnit;
/**
* 速率限制拦截器
* 基于 Redis 实现滑动窗口限流
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private static final String RATE_LIMIT_PREFIX = "rate_limit:";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
String key = buildKey(request, rateLimit);
String redisKey = RATE_LIMIT_PREFIX + key;
// 获取当前计数
String countStr = redisTemplate.opsForValue().get(redisKey);
long currentCount = countStr != null ? Long.parseLong(countStr) : 0;
if (currentCount >= rateLimit.permits()) {
log.warn("接口速率限制触发key={},已请求 {} 次,限制 {} 次", key, currentCount, rateLimit.permits());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(429);
response.getWriter().write(objectMapper.writeValueAsString(
Result.error(429, "请求过于频繁,请稍后再试")));
return false;
}
// 增加计数
Long newCount = redisTemplate.opsForValue().increment(redisKey);
if (newCount != null && newCount == 1) {
// 首次请求设置过期时间
redisTemplate.expire(redisKey, rateLimit.duration(), rateLimit.timeUnit());
}
return true;
}
/**
* 构建限流 key
*/
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String identity;
if ("user".equals(rateLimit.key())) {
// 基于 User-ID需要认证后才有
identity = request.getHeader("X-User-Id");
if (identity == null) identity = request.getRemoteAddr();
} else {
// 基于 IP
identity = getClientIp(request);
}
return request.getMethod() + ":" + request.getRequestURI() + ":" + identity;
}
/**
* 获取客户端真实 IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@ -0,0 +1,33 @@
package com.competition.common.interceptor;
import cn.hutool.core.util.IdUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* TraceId 链路追踪拦截器
*/
@Component
public class TraceIdInterceptor implements HandlerInterceptor {
private static final String TRACE_ID = "traceId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = IdUtil.fastSimpleUUID();
}
MDC.put(TRACE_ID, traceId);
response.setHeader("X-Trace-Id", traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
MDC.remove(TRACE_ID);
}
}

View File

@ -0,0 +1,60 @@
package com.competition.common.result;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 分页结果格式与前端完全兼容{ list, total, page, pageSize }
*/
@Data
public class PageResult<T> implements Serializable {
/** 数据列表 */
private List<T> list;
/** 总记录数 */
private Long total;
/** 当前页码 */
private Long page;
/** 每页大小 */
private Long pageSize;
public PageResult() {
}
public PageResult(List<T> list, Long total, Long page, Long pageSize) {
this.list = list;
this.total = total;
this.page = page;
this.pageSize = pageSize;
}
/**
* MyBatis-Plus IPage 转换
*/
public static <T> PageResult<T> from(IPage<T> page) {
return new PageResult<>(
page.getRecords(),
page.getTotal(),
page.getCurrent(),
page.getSize()
);
}
/**
* MyBatis-Plus IPage 转换支持 VO 列表替换
*/
public static <T> PageResult<T> from(IPage<?> page, List<T> voList) {
return new PageResult<>(
voList,
page.getTotal(),
page.getCurrent(),
page.getSize()
);
}
}

View File

@ -0,0 +1,62 @@
package com.competition.common.result;
import com.competition.common.enums.ErrorCode;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 统一响应结果
* 格式与 NestJS 前端完全兼容{ code, message, data, timestamp, path }
*/
@Data
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
private String timestamp;
private String path;
private Result() {
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> success(String message, T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage(message);
result.setData(data);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
result.setTimestamp(LocalDateTime.now().toString());
return result;
}
public static <T> Result<T> error(ErrorCode errorCode) {
return error(errorCode.getCode(), errorCode.getMessage());
}
public static <T> Result<T> error(Integer code, String message, String path) {
Result<T> result = error(code, message);
result.setPath(path);
return result;
}
}

View File

@ -0,0 +1,68 @@
package com.competition.common.util;
import com.competition.security.model.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 安全工具类 - 获取当前登录用户信息
*/
public final class SecurityUtil {
private SecurityUtil() {
}
/**
* 获取当前登录用户
*/
public static LoginUser getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
return loginUser;
}
return null;
}
/**
* 获取当前用户 ID未登录返回 null
*/
public static Long getCurrentUserIdOrNull() {
LoginUser user = getCurrentUser();
return user != null ? user.getUserId() : null;
}
/**
* 获取当前用户 ID未登录抛异常
*/
public static Long getCurrentUserId() {
LoginUser user = getCurrentUser();
if (user == null) {
throw new RuntimeException("用户未登录");
}
return user.getUserId();
}
/**
* 获取当前用户名未登录返回 "system"
*/
public static String getCurrentUsername() {
LoginUser user = getCurrentUser();
return user != null ? user.getUsername() : "system";
}
/**
* 获取当前租户 ID未登录返回 null
*/
public static Long getCurrentTenantId() {
LoginUser user = getCurrentUser();
return user != null ? user.getTenantId() : null;
}
/**
* 当前用户是否为超级管理员
*/
public static boolean isSuperAdmin() {
LoginUser user = getCurrentUser();
return user != null && user.isSuperAdmin();
}
}

View File

@ -0,0 +1,51 @@
package com.competition.common.util;
/**
* 敏感信息脱敏工具类
* 用于日志输出时对手机号身份证号Token 等进行脱敏处理
*/
public final class SensitiveUtil {
private SensitiveUtil() {}
/**
* 手机号脱敏保留前3位和后4位
* 13812345678 138****5678
*/
public static String phone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/**
* Token 脱敏只显示前8位和后4位
* eyJhbGciOi...xyz eyJhbGci...xyz
*/
public static String token(String token) {
if (token == null || token.length() < 12) {
return "***";
}
return token.substring(0, 8) + "..." + token.substring(token.length() - 4);
}
/**
* 身份证号脱敏保留前3位和后4位
*/
public static String idCard(String idCard) {
if (idCard == null || idCard.length() < 7) {
return "***";
}
return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4);
}
/**
* 通用脱敏只显示前后各 n
*/
public static String mask(String value, int keepChars) {
if (value == null) return "***";
if (value.length() <= keepChars * 2) return "***";
return value.substring(0, keepChars) + "***" + value.substring(value.length() - keepChars);
}
}

View File

@ -0,0 +1,65 @@
package com.competition.modules.biz.contest.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.competition.common.result.Result;
import com.competition.modules.biz.contest.entity.BizContestAttachment;
import com.competition.modules.biz.contest.service.IContestAttachmentService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "活动附件")
@RestController
@RequestMapping("/contests/attachments")
@RequiredArgsConstructor
public class ContestAttachmentController {
private final IContestAttachmentService attachmentService;
@PostMapping
@RequirePermission("contest:update")
@Operation(summary = "上传附件")
public Result<BizContestAttachment> create(@RequestBody BizContestAttachment attachment) {
attachmentService.save(attachment);
return Result.success(attachment);
}
@GetMapping("/contest/{contestId}")
@RequirePermission("contest:read")
@Operation(summary = "查询活动下的附件列表")
public Result<List<BizContestAttachment>> findByContest(@PathVariable Long contestId) {
List<BizContestAttachment> list = attachmentService.list(
new LambdaQueryWrapper<BizContestAttachment>()
.eq(BizContestAttachment::getContestId, contestId)
.orderByDesc(BizContestAttachment::getCreateTime));
return Result.success(list);
}
@GetMapping("/{id}")
@RequirePermission("contest:read")
@Operation(summary = "查询附件详情")
public Result<BizContestAttachment> findDetail(@PathVariable Long id) {
return Result.success(attachmentService.getById(id));
}
@PatchMapping("/{id}")
@RequirePermission("contest:update")
@Operation(summary = "更新附件")
public Result<Void> update(@PathVariable Long id, @RequestBody BizContestAttachment attachment) {
attachment.setId(id);
attachmentService.updateById(attachment);
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("contest:update")
@Operation(summary = "删除附件")
public Result<Void> remove(@PathVariable Long id) {
attachmentService.removeById(id);
return Result.success();
}
}

View File

@ -0,0 +1,115 @@
package com.competition.modules.biz.contest.controller;
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.CreateContestDto;
import com.competition.modules.biz.contest.dto.QueryContestDto;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.service.IContestService;
import com.competition.modules.sys.service.ISysTenantService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "活动管理")
@RestController
@RequestMapping("/contests")
@RequiredArgsConstructor
public class ContestController {
private final IContestService contestService;
private final ISysTenantService tenantService;
@PostMapping
@RequirePermission("contest:create")
@Operation(summary = "创建活动")
public Result<BizContest> create(@Valid @RequestBody CreateContestDto dto) {
return Result.success(contestService.createContest(dto, SecurityUtil.getCurrentUserId()));
}
@GetMapping("/stats")
@RequirePermission("contest:read")
@Operation(summary = "获取活动统计")
public Result<Map<String, Object>> getStats() {
Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperTenant = tenantService.isSuperTenant(tenantId);
return Result.success(contestService.getStats(tenantId, isSuperTenant));
}
@GetMapping("/dashboard")
@RequirePermission("contest:read")
@Operation(summary = "获取活动看板")
public Result<Map<String, Object>> getDashboard() {
return Result.success(contestService.getDashboard(SecurityUtil.getCurrentTenantId()));
}
@GetMapping
@RequirePermission("contest:read")
@Operation(summary = "查询活动列表")
public Result<PageResult<Map<String, Object>>> findAll(QueryContestDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperTenant = tenantService.isSuperTenant(tenantId);
return Result.success(contestService.findAll(dto, tenantId, isSuperTenant));
}
@GetMapping("/my-contests")
@RequirePermission({"contest:read", "contest:activity:read"})
@Operation(summary = "获取我的活动")
public Result<PageResult<Map<String, Object>>> getMyContests(QueryContestDto dto) {
Long userId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(contestService.getMyContests(dto, userId, tenantId));
}
@GetMapping("/{id}")
@RequirePermission("contest:read")
@Operation(summary = "查询活动详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(contestService.findDetail(id));
}
@PatchMapping("/{id}")
@RequirePermission("contest:update")
@Operation(summary = "更新活动")
public Result<BizContest> update(@PathVariable Long id, @RequestBody CreateContestDto dto) {
return Result.success(contestService.updateContest(id, dto));
}
@PatchMapping("/{id}/publish")
@RequirePermission("contest:publish")
@Operation(summary = "发布/撤回活动")
public Result<Void> publish(@PathVariable Long id, @RequestBody Map<String, String> body) {
contestService.publishContest(id, body.get("contestState"));
return Result.success();
}
@PatchMapping("/{id}/finish")
@RequirePermission("contest:update")
@Operation(summary = "结束活动")
public Result<Void> finish(@PathVariable Long id) {
contestService.finishContest(id);
return Result.success();
}
@PatchMapping("/{id}/reopen")
@RequirePermission("contest:update")
@Operation(summary = "重新开放活动")
public Result<Void> reopen(@PathVariable Long id) {
contestService.reopenContest(id);
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("contest:delete")
@Operation(summary = "删除活动")
public Result<Void> remove(@PathVariable Long id) {
contestService.removeContest(id);
return Result.success();
}
}

View File

@ -0,0 +1,217 @@
package com.competition.modules.biz.contest.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.competition.common.enums.ErrorCode;
import com.competition.common.enums.PublishStatus;
import com.competition.common.exception.BusinessException;
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;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Tag(name = "活动公告")
@RestController
@RequestMapping("/contests/notices")
@RequiredArgsConstructor
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 = "创建公告")
public Result<BizContestNotice> create(@Valid @RequestBody CreateNoticeDto dto) {
BizContestNotice notice = new BizContestNotice();
notice.setContestId(dto.getContestId());
notice.setTitle(dto.getTitle());
notice.setContent(dto.getContent());
notice.setNoticeType(dto.getNoticeType());
notice.setPriority(dto.getPriority());
if (StringUtils.hasText(dto.getPublishTime())) {
notice.setPublishTime(parseDateTime(dto.getPublishTime()));
}
// 设置当前租户 ID租户隔离
notice.setTenantId(SecurityUtil.getCurrentTenantId());
noticeService.save(notice);
return Result.success(notice);
}
@GetMapping("/contest/{contestId}")
@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));
noticeService.fillContestInfo(list);
return Result.success(list);
}
@GetMapping
@RequirePermission("notice:read")
@Operation(summary = "分页查询公告列表")
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();
LambdaQueryWrapper<BizContestNotice> wrapper = new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getTenantId, tenantId) // 租户隔离
.like(StringUtils.hasText(title), BizContestNotice::getTitle, title);
// 发布状态过滤
if (PublishStatus.PUBLISHED.getValue().equals(status)) {
wrapper.isNotNull(BizContestNotice::getPublishTime);
} else if (PublishStatus.UNPUBLISHED.getValue().equals(status)) {
wrapper.isNull(BizContestNotice::getPublishTime);
}
wrapper.orderByDesc(BizContestNotice::getCreateTime);
Page<BizContestNotice> result = noticeService.page(new Page<>(page, pageSize), wrapper);
noticeService.fillContestInfo(result.getRecords());
return Result.success(PageResult.from(result));
}
@GetMapping("/{id}")
@RequirePermission("notice:read")
@Operation(summary = "查询公告详情")
public Result<BizContestNotice> findDetail(@PathVariable Long id) {
BizContestNotice notice = noticeService.getById(id);
if (notice != null) {
noticeService.fillContestInfo(Collections.singletonList(notice));
}
return Result.success(notice);
}
@PatchMapping("/{id}")
@RequirePermission("notice:update")
@Operation(summary = "更新公告部分字段publishTime 为 null 或空串表示取消发布并写入数据库 NULL")
public Result<Void> update(@PathVariable Long id, @RequestBody Map<String, Object> body) {
Long tenantId = SecurityUtil.getCurrentTenantId();
BizContestNotice existing = noticeService.getOne(
new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getId, id)
.eq(BizContestNotice::getTenantId, tenantId));
if (existing == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "公告不存在");
}
LambdaUpdateWrapper<BizContestNotice> uw = new LambdaUpdateWrapper<>();
uw.eq(BizContestNotice::getId, id);
uw.eq(BizContestNotice::getTenantId, tenantId);
boolean hasUpdate = false;
if (body.containsKey("title")) {
Object v = body.get("title");
if (v != null) {
uw.set(BizContestNotice::getTitle, String.valueOf(v));
hasUpdate = true;
}
}
if (body.containsKey("content")) {
Object v = body.get("content");
if (v != null) {
uw.set(BizContestNotice::getContent, String.valueOf(v));
hasUpdate = true;
}
}
if (body.containsKey("noticeType")) {
Object v = body.get("noticeType");
if (v != null) {
uw.set(BizContestNotice::getNoticeType, String.valueOf(v));
hasUpdate = true;
}
}
if (body.containsKey("priority")) {
Object v = body.get("priority");
if (v instanceof Number) {
uw.set(BizContestNotice::getPriority, ((Number) v).intValue());
hasUpdate = true;
}
}
if (body.containsKey("contestId")) {
Object v = body.get("contestId");
if (v instanceof Number) {
uw.set(BizContestNotice::getContestId, ((Number) v).longValue());
hasUpdate = true;
}
}
// 仅当请求体包含 publishTime 键时才改发布时间null / 空串 = 取消发布必须写入 SQL NULL
if (body.containsKey("publishTime")) {
Object v = body.get("publishTime");
if (v == null || (v instanceof String && !StringUtils.hasText((String) v))) {
uw.set(BizContestNotice::getPublishTime, null);
hasUpdate = true;
} else if (v instanceof String && StringUtils.hasText((String) v)) {
LocalDateTime pt = parseDateTime((String) v);
if (pt != null) {
uw.set(BizContestNotice::getPublishTime, pt);
hasUpdate = true;
}
}
}
if (!hasUpdate) {
return Result.success();
}
noticeService.getBaseMapper().update(null, uw);
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("notice:delete")
@Operation(summary = "删除公告")
public Result<Void> remove(@PathVariable Long id) {
noticeService.removeById(id);
return Result.success();
}
}

View File

@ -0,0 +1,127 @@
package com.competition.modules.biz.contest.controller;
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.CreateRegistrationDto;
import com.competition.modules.biz.contest.dto.QueryRegistrationDto;
import com.competition.modules.biz.contest.service.IContestRegistrationService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "报名管理")
@RestController
@RequestMapping("/contests/registrations")
@RequiredArgsConstructor
public class ContestRegistrationController {
private final IContestRegistrationService registrationService;
@PostMapping
@RequirePermission("contest:register")
@Operation(summary = "创建报名")
public Result<Map<String, Object>> create(@Valid @RequestBody CreateRegistrationDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(registrationService.createRegistration(dto, tenantId, userId));
}
@GetMapping("/stats")
@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));
}
@GetMapping
@RequirePermission("contest:read")
@Operation(summary = "查询报名列表")
public Result<PageResult<Map<String, Object>>> findAll(QueryRegistrationDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperTenant = SecurityUtil.isSuperAdmin();
return Result.success(registrationService.findAll(dto, tenantId, isSuperTenant));
}
@GetMapping("/my/{contestId}")
@RequirePermission("contest:read")
@Operation(summary = "获取我的报名信息")
public Result<Map<String, Object>> getMyRegistration(@PathVariable Long contestId) {
Long userId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(registrationService.getMyRegistration(contestId, userId, tenantId));
}
@GetMapping("/{id}")
@RequirePermission("contest:read")
@Operation(summary = "查询报名详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(registrationService.findDetail(id, SecurityUtil.getCurrentTenantId()));
}
@PatchMapping("/{id}/review")
@RequirePermission("contest:update")
@Operation(summary = "审核报名")
public Result<Void> review(@PathVariable Long id, @RequestBody Map<String, String> body) {
Long operatorId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
registrationService.reviewRegistration(id, body.get("registrationState"), body.get("reason"), operatorId, tenantId);
return Result.success();
}
@PatchMapping("/{id}/revoke")
@RequirePermission("contest:update")
@Operation(summary = "撤回审核")
public Result<Void> revoke(@PathVariable Long id) {
registrationService.revokeReview(id, SecurityUtil.getCurrentTenantId());
return Result.success();
}
@PostMapping("/batch-review")
@RequirePermission("contest:update")
@Operation(summary = "批量审核报名")
@SuppressWarnings("unchecked")
public Result<Void> batchReview(@RequestBody Map<String, Object> body) {
List<Long> ids = (List<Long>) body.get("ids");
String registrationState = (String) body.get("registrationState");
String reason = (String) body.get("reason");
Long operatorId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
registrationService.batchReview(ids, registrationState, reason, operatorId, tenantId);
return Result.success();
}
@PostMapping("/{id}/teachers")
@RequirePermission("contest:update")
@Operation(summary = "添加指导老师")
public Result<Void> addTeacher(@PathVariable Long id, @RequestBody Map<String, Long> body) {
Long tenantId = SecurityUtil.getCurrentTenantId();
Long creatorId = SecurityUtil.getCurrentUserId();
registrationService.addTeacher(id, body.get("teacherUserId"), tenantId, creatorId);
return Result.success();
}
@DeleteMapping("/{id}/teachers/{teacherUserId}")
@RequirePermission("contest:update")
@Operation(summary = "移除指导老师")
public Result<Void> removeTeacher(@PathVariable Long id, @PathVariable Long teacherUserId) {
registrationService.removeTeacher(id, teacherUserId, SecurityUtil.getCurrentTenantId());
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("contest:update")
@Operation(summary = "删除报名")
public Result<Void> remove(@PathVariable Long id) {
registrationService.removeRegistration(id, SecurityUtil.getCurrentTenantId());
return Result.success();
}
}

View File

@ -0,0 +1,81 @@
package com.competition.modules.biz.contest.controller;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.contest.dto.CreateTeamDto;
import com.competition.modules.biz.contest.entity.BizContestTeam;
import com.competition.modules.biz.contest.service.IContestTeamService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "团队管理")
@RestController
@RequestMapping("/contests/teams")
@RequiredArgsConstructor
public class ContestTeamController {
private final IContestTeamService teamService;
@PostMapping
@RequirePermission("team:create")
@Operation(summary = "创建团队")
public Result<Map<String, Object>> create(@Valid @RequestBody CreateTeamDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
Long creatorId = SecurityUtil.getCurrentUserId();
return Result.success(teamService.createTeam(dto, tenantId, creatorId));
}
@GetMapping("/contest/{contestId}")
@RequirePermission("team:read")
@Operation(summary = "查询活动下的团队列表")
public Result<List<Map<String, Object>>> findByContest(@PathVariable Long contestId) {
return Result.success(teamService.findByContest(contestId, SecurityUtil.getCurrentTenantId()));
}
@GetMapping("/{id}")
@RequirePermission("team:read")
@Operation(summary = "查询团队详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(teamService.findDetail(id));
}
@PatchMapping("/{id}")
@RequirePermission("team:update")
@Operation(summary = "更新团队")
public Result<BizContestTeam> update(@PathVariable Long id, @RequestBody CreateTeamDto dto) {
return Result.success(teamService.updateTeam(id, dto));
}
@PostMapping("/{id}/members")
@RequirePermission("team:update")
@Operation(summary = "添加团队成员")
public Result<Void> addMember(@PathVariable Long id, @RequestBody Map<String, Object> body) {
Long userId = ((Number) body.get("userId")).longValue();
String role = (String) body.get("role");
teamService.addMember(id, userId, role, SecurityUtil.getCurrentTenantId());
return Result.success();
}
@DeleteMapping("/{id}/members/{userId}")
@RequirePermission("team:update")
@Operation(summary = "移除团队成员")
public Result<Void> removeMember(@PathVariable Long id, @PathVariable Long userId) {
teamService.removeMember(id, userId);
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("team:delete")
@Operation(summary = "删除团队")
public Result<Void> remove(@PathVariable Long id) {
teamService.removeTeam(id);
return Result.success();
}
}

View File

@ -0,0 +1,89 @@
package com.competition.modules.biz.contest.controller;
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.QueryWorkDto;
import com.competition.modules.biz.contest.dto.SubmitWorkDto;
import com.competition.modules.biz.contest.service.IContestWorkService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "作品管理")
@RestController
@RequestMapping("/contests/works")
@RequiredArgsConstructor
public class ContestWorkController {
private final IContestWorkService workService;
@PostMapping("/submit")
@RequirePermission("work:submit")
@Operation(summary = "提交作品")
public Result<Map<String, Object>> submit(@Valid @RequestBody SubmitWorkDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
Long submitterId = SecurityUtil.getCurrentUserId();
return Result.success(workService.submitWork(dto, tenantId, submitterId));
}
@GetMapping("/stats")
@RequirePermission("work:read")
@Operation(summary = "获取作品统计")
public Result<Map<String, Object>> getStats(@RequestParam(required = false) Long contestId) {
return Result.success(
workService.getStats(contestId, SecurityUtil.getCurrentTenantId(), SecurityUtil.isSuperAdmin())
);
}
@GetMapping
@RequirePermission("work:read")
@Operation(summary = "查询作品列表")
public Result<PageResult<Map<String, Object>>> findAll(QueryWorkDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperTenant = SecurityUtil.isSuperAdmin();
return Result.success(workService.findAll(dto, tenantId, isSuperTenant));
}
@GetMapping("/guided")
@RequirePermission("activity:read")
@Operation(summary = "查询辅导作品")
public Result<PageResult<Map<String, Object>>> getGuidedWorks(
@RequestParam(required = false) Long contestId,
@RequestParam(required = false) String workNo,
@RequestParam(required = false) String playerName,
@RequestParam(required = false) String accountNo,
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize) {
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(workService.getGuidedWorks(contestId, workNo, playerName, accountNo, page, pageSize, userId));
}
@GetMapping("/{id}")
@RequirePermission("work:read")
@Operation(summary = "查询作品详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(workService.findDetail(id));
}
@GetMapping("/registration/{registrationId}/versions")
@RequirePermission("work:read")
@Operation(summary = "查询作品版本历史")
public Result<List<Map<String, Object>>> getWorkVersions(@PathVariable Long registrationId) {
return Result.success(workService.getWorkVersions(registrationId));
}
@DeleteMapping("/{id}")
@RequirePermission("work:update")
@Operation(summary = "删除作品")
public Result<Void> remove(@PathVariable Long id) {
workService.removeWork(id);
return Result.success();
}
}

View File

@ -0,0 +1,130 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "创建活动DTO")
public class CreateContestDto {
@NotBlank(message = "活动名称不能为空")
@Schema(description = "活动名称")
private String contestName;
@NotBlank(message = "活动类型不能为空")
@Schema(description = "活动类型")
private String contestType;
@Schema(description = "可见性")
private String visibility;
@Schema(description = "目标城市列表")
private List<String> targetCities;
@Schema(description = "最小年龄")
private Integer ageMin;
@Schema(description = "最大年龄")
private Integer ageMax;
@NotBlank(message = "开始时间不能为空")
@Schema(description = "开始时间")
private String startTime;
@NotBlank(message = "结束时间不能为空")
@Schema(description = "结束时间")
private String endTime;
@Schema(description = "地址")
private String address;
@Schema(description = "活动内容")
private String content;
@Schema(description = "活动关联租户ID列表")
private List<Integer> contestTenants;
@Schema(description = "封面图URL")
private String coverUrl;
@Schema(description = "海报URL")
private String posterUrl;
@Schema(description = "联系人姓名")
private String contactName;
@Schema(description = "联系人电话")
private String contactPhone;
@Schema(description = "联系人二维码")
private String contactQrcode;
@Schema(description = "主办方")
private Object organizers;
@Schema(description = "协办方")
private Object coOrganizers;
@Schema(description = "赞助方")
private Object sponsors;
@NotBlank(message = "报名开始时间不能为空")
@Schema(description = "报名开始时间")
private String registerStartTime;
@NotBlank(message = "报名结束时间不能为空")
@Schema(description = "报名结束时间")
private String registerEndTime;
@Schema(description = "报名状态")
private String registerState;
@Schema(description = "是否需要审核")
private Boolean requireAudit;
@Schema(description = "允许的年级列表")
private List<Integer> allowedGrades;
@Schema(description = "允许的班级列表")
private List<Integer> allowedClasses;
@Schema(description = "团队最小人数")
private Integer teamMinMembers;
@Schema(description = "团队最大人数")
private Integer teamMaxMembers;
@Schema(description = "提交规则")
private String submitRule;
@NotBlank(message = "提交开始时间不能为空")
@Schema(description = "提交开始时间")
private String submitStartTime;
@NotBlank(message = "提交结束时间不能为空")
@Schema(description = "提交结束时间")
private String submitEndTime;
@Schema(description = "作品类型")
private String workType;
@Schema(description = "作品要求")
private String workRequirement;
@Schema(description = "评审规则ID")
private Long reviewRuleId;
@NotBlank(message = "评审开始时间不能为空")
@Schema(description = "评审开始时间")
private String reviewStartTime;
@NotBlank(message = "评审结束时间不能为空")
@Schema(description = "评审结束时间")
private String reviewEndTime;
@Schema(description = "结果发布时间")
private String resultPublishTime;
}

View File

@ -0,0 +1,32 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "创建公告DTO")
public class CreateNoticeDto {
@NotNull(message = "活动ID不能为空")
@Schema(description = "活动ID")
private Long contestId;
@NotBlank(message = "公告标题不能为空")
@Schema(description = "公告标题")
private String title;
@NotBlank(message = "公告内容不能为空")
@Schema(description = "公告内容")
private String content;
@Schema(description = "公告类型")
private String noticeType;
@Schema(description = "优先级")
private Integer priority;
@Schema(description = "发布时间")
private String publishTime;
}

View File

@ -0,0 +1,26 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "创建报名DTO")
public class CreateRegistrationDto {
@NotNull(message = "活动ID不能为空")
@Schema(description = "活动ID")
private Long contestId;
@NotBlank(message = "报名类型不能为空")
@Schema(description = "报名类型")
private String registrationType;
@Schema(description = "团队ID")
private Long teamId;
@NotNull(message = "用户ID不能为空")
@Schema(description = "用户ID")
private Long userId;
}

View File

@ -0,0 +1,34 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "创建团队DTO")
public class CreateTeamDto {
@NotNull(message = "活动ID不能为空")
@Schema(description = "活动ID")
private Long contestId;
@NotBlank(message = "团队名称不能为空")
@Schema(description = "团队名称")
private String teamName;
@NotNull(message = "队长ID不能为空")
@Schema(description = "队长ID")
private Long leaderId;
@Schema(description = "成员ID列表")
private List<Long> memberIds;
@Schema(description = "指导老师ID列表")
private List<Long> teacherIds;
@Schema(description = "最大成员数")
private Integer maxMembers;
}

View File

@ -0,0 +1,39 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "查询活动DTO")
public class QueryContestDto {
@Schema(description = "页码", defaultValue = "1")
private Long page = 1L;
@Schema(description = "每页条数", defaultValue = "10")
private Long pageSize = 10L;
@Schema(description = "活动名称")
private String contestName;
@Schema(description = "活动状态")
private String contestState;
@Schema(description = "状态")
private String status;
@Schema(description = "活动类型")
private String contestType;
@Schema(description = "可见性")
private String visibility;
@Schema(description = "活动阶段")
private String stage;
@Schema(description = "创建者租户ID")
private Long creatorTenantId;
@Schema(description = "角色")
private String role;
}

View File

@ -0,0 +1,33 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "查询报名DTO")
public class QueryRegistrationDto {
@Schema(description = "页码", defaultValue = "1")
private Long page = 1L;
@Schema(description = "每页条数", defaultValue = "10")
private Long pageSize = 10L;
@Schema(description = "活动ID")
private Long contestId;
@Schema(description = "报名状态")
private String registrationState;
@Schema(description = "报名类型")
private String registrationType;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "参赛者类型")
private String participantType;
@Schema(description = "关键词")
private String keyword;
}

View File

@ -0,0 +1,51 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "查询作品DTO")
public class QueryWorkDto {
@Schema(description = "页码", defaultValue = "1")
private Long page = 1L;
@Schema(description = "每页条数", defaultValue = "10")
private Long pageSize = 10L;
@Schema(description = "活动ID")
private Long contestId;
@Schema(description = "报名ID")
private Long registrationId;
@Schema(description = "状态")
private String status;
@Schema(description = "作品标题")
private String title;
@Schema(description = "作品编号")
private String workNo;
@Schema(description = "用户名")
private String username;
@Schema(description = "关键词")
private String keyword;
@Schema(description = "姓名")
private String name;
@Schema(description = "分配状态")
private String assignStatus;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "提交开始时间")
private String submitStartTime;
@Schema(description = "提交结束时间")
private String submitEndTime;
}

View File

@ -0,0 +1,56 @@
package com.competition.modules.biz.contest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "提交作品DTO")
public class SubmitWorkDto {
@NotNull(message = "报名ID不能为空")
@Schema(description = "报名ID")
private Long registrationId;
@NotBlank(message = "作品标题不能为空")
@Schema(description = "作品标题")
private String title;
@Schema(description = "作品描述")
private String description;
@Schema(description = "文件信息")
private Object files;
@Schema(description = "预览图URL")
private String previewUrl;
@Schema(description = "预览图URL列表")
private List<String> previewUrls;
@Schema(description = "AI模型元数据")
private Object aiModelMeta;
@Schema(description = "附件列表")
private List<AttachmentItem> attachments;
@Data
@Schema(description = "附件项")
public static class AttachmentItem {
@Schema(description = "文件名")
private String fileName;
@Schema(description = "文件URL")
private String fileUrl;
@Schema(description = "文件类型")
private String fileType;
@Schema(description = "文件大小")
private String size;
}
}

View File

@ -0,0 +1,183 @@
package com.competition.modules.biz.contest.entity;
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;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 活动实体35+ 字段7 JSON
*/
@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")
@TableField("contest_type")
private String contestType;
@Schema(description = "活动发布状态", allowableValues = {"published", "unpublished"})
@TableField("contest_state")
private String contestState;
@Schema(description = "活动进度状态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 = "可见范围", allowableValues = {"public", "designated", "internal", "private"})
private String visibility;
// ====== 授权租户JSON ======
@Schema(description = "授权租户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 = "提交规则", allowableValues = {"once", "resubmit"})
@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 = "成绩发布状态", allowableValues = {"published", "unpublished"})
@TableField("result_state")
private String resultState;
@Schema(description = "成绩发布时间")
@TableField("result_publish_time")
private LocalDateTime resultPublishTime;
}

View File

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

View File

@ -0,0 +1,50 @@
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")
@TableField("notice_type")
private String noticeType;
@Schema(description = "优先级")
private Integer priority;
@Schema(description = "发布时间")
@TableField("publish_time")
private LocalDateTime publishTime;
/** 关联活动(仅查询接口填充,不落库) */
@Schema(description = "关联活动")
@TableField(exist = false)
private BizContest contest;
}

View File

@ -0,0 +1,81 @@
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_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")
@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")
private String role;
@Schema(description = "报名状态", allowableValues = {"pending", "passed", "rejected"})
@TableField("registration_state")
private String registrationState;
@Schema(description = "参与者类型", allowableValues = {"self", "child"})
@TableField("participant_type")
private String participantType;
@Schema(description = "子女ID")
@TableField("child_id")
private Long childId;
@Schema(description = "实际提交人ID")
private Integer registrant;
@Schema(description = "报名时间")
@TableField("registration_time")
private LocalDateTime registrationTime;
@Schema(description = "审核原因")
private String reason;
@Schema(description = "审核操作人")
private Integer operator;
@Schema(description = "操作日期")
@TableField("operation_date")
private LocalDateTime operationDate;
}

View File

@ -0,0 +1,48 @@
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;
import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_registration_teacher")
@Schema(description = "活动报名老师关联实体")
public class BizContestRegistrationTeacher implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "报名ID")
@TableField("registration_id")
private Long registrationId;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "用户ID")
@TableField("user_id")
private Long userId;
@Schema(description = "是否默认")
@TableField("is_default")
private Boolean isDefault;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

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

View File

@ -0,0 +1,47 @@
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;
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")
private String role;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -0,0 +1,109 @@
package com.competition.modules.biz.contest.entity;
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;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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 = "作品状态", allowableValues = {"submitted", "locked", "reviewing", "rejected", "accepted", "awarded", "taken_down"})
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")
@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")
@TableField("award_level")
private String awardLevel;
@Schema(description = "奖项名称")
@TableField("award_name")
private String awardName;
@Schema(description = "证书URL")
@TableField("certificate_url")
private String certificateUrl;
}

View File

@ -0,0 +1,62 @@
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;
import java.time.LocalDateTime;
@Data
@TableName("t_biz_contest_work_attachment")
@Schema(description = "活动作品附件实体")
public class BizContestWorkAttachment implements Serializable {
@Schema(description = "主键ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "活动ID")
@TableField("contest_id")
private Long contestId;
@Schema(description = "作品ID")
@TableField("work_id")
private Long workId;
@Schema(description = "文件名称")
@TableField("file_name")
private String fileName;
@Schema(description = "文件URL")
@TableField("file_url")
private String fileUrl;
@Schema(description = "文件格式")
private String format;
@Schema(description = "文件类型")
@TableField("file_type")
private String fileType;
@Schema(description = "文件大小")
private String size;
@Schema(description = "创建人ID")
private Integer creator;
@Schema(description = "修改人ID")
private Integer modifier;
@Schema(description = "创建时间")
@TableField("create_time")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@TableField("modify_time")
private LocalDateTime modifyTime;
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestAttachment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestAttachmentMapper extends BaseMapper<BizContestAttachment> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContest;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestMapper extends BaseMapper<BizContest> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestNotice;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestNoticeMapper extends BaseMapper<BizContestNotice> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestRegistrationMapper extends BaseMapper<BizContestRegistration> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestRegistrationTeacher;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestRegistrationTeacherMapper extends BaseMapper<BizContestRegistrationTeacher> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestTeam;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestTeamMapper extends BaseMapper<BizContestTeam> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestTeamMember;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestTeamMemberMapper extends BaseMapper<BizContestTeamMember> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestWorkAttachment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestWorkAttachmentMapper extends BaseMapper<BizContestWorkAttachment> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.contest.entity.BizContestWork;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestWorkMapper extends BaseMapper<BizContestWork> {
}

View File

@ -0,0 +1,7 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.contest.entity.BizContestAttachment;
public interface IContestAttachmentService extends IService<BizContestAttachment> {
}

View File

@ -0,0 +1,14 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.contest.entity.BizContestNotice;
import java.util.List;
public interface IContestNoticeService extends IService<BizContestNotice> {
/**
* 批量填充关联活动名称仅设置 idcontestName供前端展示
*/
void fillContestInfo(List<BizContestNotice> notices);
}

View File

@ -0,0 +1,35 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.dto.CreateRegistrationDto;
import com.competition.modules.biz.contest.dto.QueryRegistrationDto;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import java.util.List;
import java.util.Map;
public interface IContestRegistrationService extends IService<BizContestRegistration> {
Map<String, Object> createRegistration(CreateRegistrationDto dto, Long tenantId, Long creatorId);
PageResult<Map<String, Object>> findAll(QueryRegistrationDto dto, Long tenantId, boolean isSuperTenant);
Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperAdmin);
Map<String, Object> findDetail(Long id, Long tenantId);
Map<String, Object> getMyRegistration(Long contestId, Long userId, Long tenantId);
void reviewRegistration(Long id, String state, String reason, Long operatorId, Long tenantId);
void revokeReview(Long id, Long tenantId);
void batchReview(List<Long> ids, String state, String reason, Long operatorId, Long tenantId);
void addTeacher(Long registrationId, Long teacherUserId, Long tenantId, Long creatorId);
void removeTeacher(Long registrationId, Long teacherUserId, Long tenantId);
void removeRegistration(Long id, Long tenantId);
}

View File

@ -0,0 +1,34 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.dto.CreateContestDto;
import com.competition.modules.biz.contest.dto.QueryContestDto;
import com.competition.modules.biz.contest.entity.BizContest;
import java.util.Map;
public interface IContestService extends IService<BizContest> {
BizContest createContest(CreateContestDto dto, Long creatorId);
PageResult<Map<String, Object>> findAll(QueryContestDto dto, Long tenantId, boolean isSuperTenant);
PageResult<Map<String, Object>> getMyContests(QueryContestDto dto, Long userId, Long tenantId);
Map<String, Object> findDetail(Long id);
BizContest updateContest(Long id, CreateContestDto dto);
void publishContest(Long id, String contestState);
void finishContest(Long id);
void reopenContest(Long id);
void removeContest(Long id);
Map<String, Object> getStats(Long tenantId, boolean isSuperTenant);
Map<String, Object> getDashboard(Long tenantId);
}

View File

@ -0,0 +1,25 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.contest.dto.CreateTeamDto;
import com.competition.modules.biz.contest.entity.BizContestTeam;
import java.util.List;
import java.util.Map;
public interface IContestTeamService extends IService<BizContestTeam> {
Map<String, Object> createTeam(CreateTeamDto dto, Long tenantId, Long creatorId);
List<Map<String, Object>> findByContest(Long contestId, Long tenantId);
Map<String, Object> findDetail(Long id);
BizContestTeam updateTeam(Long id, CreateTeamDto dto);
void addMember(Long teamId, Long userId, String role, Long tenantId);
void removeMember(Long teamId, Long userId);
void removeTeam(Long id);
}

View File

@ -0,0 +1,32 @@
package com.competition.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.dto.QueryWorkDto;
import com.competition.modules.biz.contest.dto.SubmitWorkDto;
import com.competition.modules.biz.contest.entity.BizContestWork;
import java.util.List;
import java.util.Map;
public interface IContestWorkService extends IService<BizContestWork> {
/**
* 为指定活动生成下一个作品编号 {@link #submitWork} 所用规则一致W{contestId}-{序号}
*/
String nextContestWorkNo(Long contestId);
Map<String, Object> submitWork(SubmitWorkDto dto, Long tenantId, Long submitterId);
PageResult<Map<String, Object>> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant);
Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant);
Map<String, Object> findDetail(Long id);
List<Map<String, Object>> getWorkVersions(Long registrationId);
PageResult<Map<String, Object>> getGuidedWorks(Long contestId, String workNo, String playerName, String accountNo, Long page, Long pageSize, Long userId);
void removeWork(Long id);
}

View File

@ -0,0 +1,11 @@
package com.competition.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.modules.biz.contest.entity.BizContestAttachment;
import com.competition.modules.biz.contest.mapper.ContestAttachmentMapper;
import com.competition.modules.biz.contest.service.IContestAttachmentService;
import org.springframework.stereotype.Service;
@Service
public class ContestAttachmentServiceImpl extends ServiceImpl<ContestAttachmentMapper, BizContestAttachment> implements IContestAttachmentService {
}

View File

@ -0,0 +1,54 @@
package com.competition.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.entity.BizContestNotice;
import com.competition.modules.biz.contest.mapper.ContestNoticeMapper;
import com.competition.modules.biz.contest.service.IContestNoticeService;
import com.competition.modules.biz.contest.service.IContestService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ContestNoticeServiceImpl extends ServiceImpl<ContestNoticeMapper, BizContestNotice> implements IContestNoticeService {
private final IContestService contestService;
@Override
public void fillContestInfo(List<BizContestNotice> notices) {
if (notices == null || notices.isEmpty()) {
return;
}
Set<Long> contestIds = notices.stream()
.map(BizContestNotice::getContestId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (contestIds.isEmpty()) {
return;
}
List<BizContest> contests = contestService.listByIds(contestIds);
Map<Long, BizContest> map = contests.stream()
.collect(Collectors.toMap(BizContest::getId, Function.identity(), (a, b) -> a));
for (BizContestNotice notice : notices) {
Long cid = notice.getContestId();
if (cid == null) {
continue;
}
BizContest c = map.get(cid);
if (c != null) {
BizContest brief = new BizContest();
brief.setId(c.getId());
brief.setContestName(c.getContestName());
notice.setContest(brief);
}
}
}
}

View File

@ -0,0 +1,350 @@
package com.competition.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.enums.ParticipantType;
import com.competition.common.enums.PublishStatus;
import com.competition.common.enums.RegistrationStatus;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.dto.CreateRegistrationDto;
import com.competition.modules.biz.contest.dto.QueryRegistrationDto;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import com.competition.modules.biz.contest.entity.BizContestRegistrationTeacher;
import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationTeacherMapper;
import com.competition.modules.biz.contest.service.IContestRegistrationService;
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.util.StringUtils;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContestRegistrationServiceImpl extends ServiceImpl<ContestRegistrationMapper, BizContestRegistration>
implements IContestRegistrationService {
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestRegistrationTeacherMapper contestRegistrationTeacherMapper;
private final ContestMapper contestMapper;
private final SysUserMapper sysUserMapper;
@Override
public Map<String, Object> createRegistration(CreateRegistrationDto dto, Long tenantId, Long creatorId) {
log.info("开始创建报名活动ID{}用户ID{}", dto.getContestId(), dto.getUserId());
// 验证活动存在且已发布
BizContest contest = contestMapper.selectById(dto.getContestId());
if (contest == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
if (!PublishStatus.PUBLISHED.getValue().equals(contest.getContestState())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "活动未发布,无法报名");
}
// 获取用户信息
SysUser user = sysUserMapper.selectById(dto.getUserId());
String accountNo = user != null ? user.getUsername() : null;
String accountName = user != null ? user.getNickname() : null;
BizContestRegistration registration = new BizContestRegistration();
registration.setContestId(dto.getContestId());
registration.setTenantId(tenantId);
registration.setRegistrationType(dto.getRegistrationType());
registration.setTeamId(dto.getTeamId());
registration.setUserId(dto.getUserId());
registration.setAccountNo(accountNo);
registration.setAccountName(accountName);
registration.setRegistrationState(RegistrationStatus.PENDING.getValue());
registration.setParticipantType(ParticipantType.SELF.getValue());
registration.setRegistrationTime(LocalDateTime.now());
registration.setRegistrant(creatorId != null ? creatorId.intValue() : null);
registration.setCreator(creatorId != null ? creatorId.intValue() : null);
save(registration);
log.info("报名创建成功ID{}", registration.getId());
// 如果创建者是教师角色自动添加为默认指导教师
// 简化实现此处由调用方根据角色判断后调用 addTeacher
// TODO: 检查创建者角色自动关联教师
Map<String, Object> result = registrationToMap(registration);
return result;
}
@Override
public PageResult<Map<String, Object>> findAll(QueryRegistrationDto dto, Long tenantId, boolean isSuperTenant) {
log.info("查询报名列表活动ID{},页码:{}", dto.getContestId(), dto.getPage());
LambdaQueryWrapper<BizContestRegistration> wrapper = new LambdaQueryWrapper<>();
if (dto.getContestId() != null) {
wrapper.eq(BizContestRegistration::getContestId, dto.getContestId());
}
if (StringUtils.hasText(dto.getRegistrationState())) {
wrapper.eq(BizContestRegistration::getRegistrationState, dto.getRegistrationState());
}
if (StringUtils.hasText(dto.getRegistrationType())) {
wrapper.eq(BizContestRegistration::getRegistrationType, dto.getRegistrationType());
}
if (StringUtils.hasText(dto.getParticipantType())) {
wrapper.eq(BizContestRegistration::getParticipantType, dto.getParticipantType());
}
if (dto.getUserId() != null) {
wrapper.eq(BizContestRegistration::getUserId, dto.getUserId());
}
if (StringUtils.hasText(dto.getKeyword())) {
wrapper.and(w -> w.like(BizContestRegistration::getAccountNo, dto.getKeyword())
.or().like(BizContestRegistration::getAccountName, dto.getKeyword()));
}
// 租户过滤
if (!isSuperTenant && tenantId != null) {
wrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
wrapper.orderByDesc(BizContestRegistration::getRegistrationTime);
Page<BizContestRegistration> page = new Page<>(dto.getPage(), dto.getPageSize());
Page<BizContestRegistration> result = contestRegistrationMapper.selectPage(page, wrapper);
List<Map<String, Object>> voList = result.getRecords().stream()
.map(this::registrationToMap)
.collect(Collectors.toList());
return PageResult.from(result, voList);
}
@Override
public Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperAdmin) {
log.info("获取报名统计活动ID{}租户ID{},超管:{}", contestId, tenantId, isSuperAdmin);
// 非超管需要按租户过滤
boolean needTenantFilter = !isSuperAdmin && tenantId != null;
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, RegistrationStatus.PENDING.getValue());
long pending = count(pendingWrapper);
LambdaQueryWrapper<BizContestRegistration> passedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
passedWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
passedWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
passedWrapper.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue());
long passed = count(passedWrapper);
LambdaQueryWrapper<BizContestRegistration> rejectedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
rejectedWrapper.eq(BizContestRegistration::getContestId, contestId);
}
if (needTenantFilter) {
rejectedWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
rejectedWrapper.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.REJECTED.getValue());
long rejected = count(rejectedWrapper);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", total);
stats.put("pending", pending);
stats.put("passed", passed);
stats.put("rejected", rejected);
return stats;
}
@Override
public Map<String, Object> findDetail(Long id, Long tenantId) {
log.info("查询报名详情ID{}", id);
BizContestRegistration registration = getById(id);
if (registration == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在");
}
Map<String, Object> result = registrationToMap(registration);
// 查询用户详情
if (registration.getUserId() != null) {
SysUser user = sysUserMapper.selectById(registration.getUserId());
if (user != null) {
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId());
userInfo.put("username", user.getUsername());
userInfo.put("nickname", user.getNickname());
userInfo.put("phone", user.getPhone());
userInfo.put("email", user.getEmail());
userInfo.put("avatar", user.getAvatar());
result.put("userInfo", userInfo);
}
}
// 查询指导教师列表
LambdaQueryWrapper<BizContestRegistrationTeacher> teacherWrapper = new LambdaQueryWrapper<>();
teacherWrapper.eq(BizContestRegistrationTeacher::getRegistrationId, id);
List<BizContestRegistrationTeacher> teachers = contestRegistrationTeacherMapper.selectList(teacherWrapper);
result.put("teachers", teachers);
return result;
}
@Override
public Map<String, Object> getMyRegistration(Long contestId, Long userId, Long tenantId) {
log.info("查询我的报名活动ID{}用户ID{}", contestId, userId);
LambdaQueryWrapper<BizContestRegistration> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestRegistration::getContestId, contestId);
wrapper.eq(BizContestRegistration::getUserId, userId);
wrapper.last("LIMIT 1");
BizContestRegistration registration = getOne(wrapper, false);
if (registration == null) {
return null;
}
return registrationToMap(registration);
}
@Override
public void reviewRegistration(Long id, String state, String reason, Long operatorId, Long tenantId) {
log.info("审核报名ID{},状态:{}", id, state);
BizContestRegistration registration = getById(id);
if (registration == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在");
}
registration.setRegistrationState(state);
registration.setReason(reason);
registration.setOperator(operatorId != null ? operatorId.intValue() : null);
registration.setOperationDate(LocalDateTime.now());
updateById(registration);
log.info("报名审核完成ID{},结果:{}", id, state);
}
@Override
public void revokeReview(Long id, Long tenantId) {
log.info("撤回审核ID{}", id);
BizContestRegistration registration = getById(id);
if (registration == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在");
}
registration.setRegistrationState(RegistrationStatus.PENDING.getValue());
registration.setReason(null);
registration.setOperator(null);
registration.setOperationDate(null);
updateById(registration);
log.info("审核已撤回ID{}", id);
}
@Override
public void batchReview(List<Long> ids, String state, String reason, Long operatorId, Long tenantId) {
log.info("批量审核报名,数量:{},状态:{}", ids.size(), state);
for (Long id : ids) {
reviewRegistration(id, state, reason, operatorId, tenantId);
}
log.info("批量审核完成,共处理 {} 条", ids.size());
}
@Override
public void addTeacher(Long registrationId, Long teacherUserId, Long tenantId, Long creatorId) {
log.info("添加指导教师报名ID{}教师用户ID{}", registrationId, teacherUserId);
// 检查重复
LambdaQueryWrapper<BizContestRegistrationTeacher> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestRegistrationTeacher::getRegistrationId, registrationId);
wrapper.eq(BizContestRegistrationTeacher::getUserId, teacherUserId);
Long existCount = contestRegistrationTeacherMapper.selectCount(wrapper);
if (existCount > 0) {
throw BusinessException.of(ErrorCode.CONFLICT, "该教师已关联此报名");
}
BizContestRegistrationTeacher teacher = new BizContestRegistrationTeacher();
teacher.setRegistrationId(registrationId);
teacher.setTenantId(tenantId);
teacher.setUserId(teacherUserId);
teacher.setIsDefault(false);
teacher.setCreator(creatorId != null ? creatorId.intValue() : null);
teacher.setCreateTime(LocalDateTime.now());
contestRegistrationTeacherMapper.insert(teacher);
log.info("指导教师添加成功报名ID{}教师用户ID{}", registrationId, teacherUserId);
}
@Override
public void removeTeacher(Long registrationId, Long teacherUserId, Long tenantId) {
log.info("移除指导教师报名ID{}教师用户ID{}", registrationId, teacherUserId);
LambdaQueryWrapper<BizContestRegistrationTeacher> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestRegistrationTeacher::getRegistrationId, registrationId);
wrapper.eq(BizContestRegistrationTeacher::getUserId, teacherUserId);
contestRegistrationTeacherMapper.delete(wrapper);
log.info("指导教师移除成功报名ID{}教师用户ID{}", registrationId, teacherUserId);
}
@Override
public void removeRegistration(Long id, Long tenantId) {
log.info("删除报名ID{}", id);
removeById(id);
log.info("报名删除成功ID{}", id);
}
// ====== 私有辅助方法 ======
private Map<String, Object> registrationToMap(BizContestRegistration entity) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", entity.getId());
map.put("contestId", entity.getContestId());
map.put("tenantId", entity.getTenantId());
map.put("registrationType", entity.getRegistrationType());
map.put("teamId", entity.getTeamId());
map.put("teamName", entity.getTeamName());
map.put("userId", entity.getUserId());
map.put("accountNo", entity.getAccountNo());
map.put("accountName", entity.getAccountName());
map.put("role", entity.getRole());
map.put("registrationState", entity.getRegistrationState());
map.put("participantType", entity.getParticipantType());
map.put("childId", entity.getChildId());
map.put("registrant", entity.getRegistrant());
map.put("registrationTime", entity.getRegistrationTime());
map.put("reason", entity.getReason());
map.put("operator", entity.getOperator());
map.put("operationDate", entity.getOperationDate());
map.put("createTime", entity.getCreateTime());
return map;
}
}

View File

@ -0,0 +1,637 @@
package com.competition.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.enums.PublishStatus;
import com.competition.common.enums.SubmitRule;
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;
import com.competition.modules.biz.contest.entity.BizContestAttachment;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import com.competition.modules.biz.contest.entity.BizContestWork;
import com.competition.modules.biz.contest.mapper.ContestAttachmentMapper;
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;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> implements IContestService {
private final ContestMapper contestMapper;
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);
throw new BusinessException(ErrorCode.BAD_REQUEST, "日期格式无效:" + dateTime + ",请使用 yyyy-MM-ddTHH:mm:ss 或 yyyy-MM-dd HH:mm:ss 格式");
}
}
}
@Override
public BizContest createContest(CreateContestDto dto, Long creatorId) {
log.info("开始创建活动,名称:{}", dto.getContestName());
try {
BizContest entity = new BizContest();
mapDtoToEntity(dto, entity);
// 默认状态
entity.setContestState(PublishStatus.UNPUBLISHED.getValue());
entity.setStatus("ongoing");
entity.setResultState(PublishStatus.UNPUBLISHED.getValue());
if (!StringUtils.hasText(entity.getSubmitRule())) {
entity.setSubmitRule(SubmitRule.ONCE.getValue());
}
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());
}
}
@Override
public PageResult<Map<String, Object>> findAll(QueryContestDto dto, Long tenantId, boolean isSuperTenant) {
log.info("查询活动列表,页码:{},每页:{}", dto.getPage(), dto.getPageSize());
LambdaQueryWrapper<BizContest> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContest::getValidState, 1);
if (StringUtils.hasText(dto.getContestName())) {
wrapper.like(BizContest::getContestName, dto.getContestName());
}
if (StringUtils.hasText(dto.getContestState())) {
wrapper.eq(BizContest::getContestState, dto.getContestState());
}
if (StringUtils.hasText(dto.getStatus())) {
wrapper.eq(BizContest::getStatus, dto.getStatus());
}
if (StringUtils.hasText(dto.getVisibility())) {
wrapper.eq(BizContest::getVisibility, dto.getVisibility());
}
if (StringUtils.hasText(dto.getContestType())) {
wrapper.eq(BizContest::getContestType, dto.getContestType());
}
// 阶段筛选与前端活动列表活动阶段一致unpublished/finished/registering/submitting/reviewing
if (StringUtils.hasText(dto.getStage())) {
LocalDateTime now = LocalDateTime.now();
switch (dto.getStage()) {
case "unpublished":
wrapper.eq(BizContest::getContestState, PublishStatus.UNPUBLISHED.getValue());
break;
case "finished":
wrapper.eq(BizContest::getStatus, "finished");
break;
case "registering":
wrapper.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
wrapper.le(BizContest::getRegisterStartTime, now)
.ge(BizContest::getRegisterEndTime, now);
break;
case "submitting":
wrapper.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
wrapper.le(BizContest::getSubmitStartTime, now)
.ge(BizContest::getSubmitEndTime, now);
break;
case "reviewing":
wrapper.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
wrapper.le(BizContest::getReviewStartTime, now)
.ge(BizContest::getReviewEndTime, now);
break;
default:
break;
}
}
// 非超级租户按授权租户过滤
if (!isSuperTenant && tenantId != null) {
wrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
wrapper.orderByDesc(BizContest::getCreateTime);
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;
})
.collect(Collectors.toList());
return PageResult.from(result, voList);
}
@Override
public PageResult<Map<String, Object>> getMyContests(QueryContestDto dto, Long userId, Long tenantId) {
log.info("查询我的活动用户ID{}租户ID{}", userId, tenantId);
LambdaQueryWrapper<BizContest> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContest::getValidState, 1);
wrapper.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
if (StringUtils.hasText(dto.getContestName())) {
wrapper.like(BizContest::getContestName, dto.getContestName());
}
// 按租户过滤
if (tenantId != null) {
wrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
wrapper.orderByDesc(BizContest::getCreateTime);
Page<BizContest> page = new Page<>(dto.getPage(), dto.getPageSize());
Page<BizContest> result = contestMapper.selectPage(page, wrapper);
List<Map<String, Object>> voList = result.getRecords().stream()
.map(this::entityToMap)
.collect(Collectors.toList());
return PageResult.from(result, voList);
}
@Override
public Map<String, Object> findDetail(Long id) {
log.info("查询活动详情ID{}", id);
BizContest contest = getById(id);
if (contest == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
Map<String, Object> result = entityToMap(contest);
// 查询附件列表
LambdaQueryWrapper<BizContestAttachment> attWrapper = new LambdaQueryWrapper<>();
attWrapper.eq(BizContestAttachment::getContestId, id);
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;
}
@Override
public BizContest updateContest(Long id, CreateContestDto dto) {
log.info("更新活动ID{}", id);
BizContest entity = getById(id);
if (entity == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
// 已发布的活动限制编辑需先撤回
if (PublishStatus.PUBLISHED.getValue().equals(entity.getContestState())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "活动已发布,请先撤回后再编辑");
}
mapDtoToEntity(dto, entity);
updateById(entity);
log.info("活动更新成功ID{}", id);
return entity;
}
@Override
public void publishContest(Long id, String contestState) {
log.info("发布/撤回活动ID{},状态:{}", id, contestState);
BizContest entity = getById(id);
if (entity == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
// 发布时校验关键时间字段的完整性和合理性
if (PublishStatus.PUBLISHED.getValue().equals(contestState)) {
validateContestTimes(entity);
}
entity.setContestState(contestState);
updateById(entity);
log.info("活动状态更新成功ID{},新状态:{}", id, contestState);
}
@Override
public void finishContest(Long id) {
log.info("结束活动ID{}", id);
BizContest entity = getById(id);
if (entity == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
// 检查是否有未完成的评审任务
LambdaQueryWrapper<BizContestWorkJudgeAssignment> pendingWrapper = new LambdaQueryWrapper<>();
pendingWrapper.eq(BizContestWorkJudgeAssignment::getContestId, id);
pendingWrapper.ne(BizContestWorkJudgeAssignment::getStatus, "completed");
long pendingCount = contestWorkJudgeAssignmentMapper.selectCount(pendingWrapper);
if (pendingCount > 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST,
String.format("还有 %d 个评审任务未完成,无法结束活动", pendingCount));
}
entity.setStatus("finished");
updateById(entity);
log.info("活动已结束ID{}", id);
}
/**
* 校验活动时间合理性
*/
private void validateContestTimes(BizContest entity) {
// 检查报名时间是否完整
if (entity.getRegisterStartTime() == null || entity.getRegisterEndTime() == null) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "请先设置完整的报名时间");
}
// 检查时间顺序报名开始 < 报名结束
if (entity.getRegisterStartTime().isAfter(entity.getRegisterEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名开始时间不能晚于报名结束时间");
}
// 检查提交时间
if (entity.getSubmitStartTime() != null && entity.getSubmitEndTime() != null) {
if (entity.getSubmitStartTime().isAfter(entity.getSubmitEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "提交开始时间不能晚于提交结束时间");
}
// 提交开始应该在报名结束之后或同时
if (entity.getSubmitStartTime().isBefore(entity.getRegisterEndTime())) {
log.warn("提交开始时间早于报名结束时间,允许报名与提交重叠");
}
}
// 检查评审时间
if (entity.getReviewStartTime() != null && entity.getReviewEndTime() != null) {
if (entity.getReviewStartTime().isAfter(entity.getReviewEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "评审开始时间不能晚于评审结束时间");
}
}
}
@Override
public void reopenContest(Long id) {
log.info("重新开启活动ID{}", id);
BizContest entity = getById(id);
if (entity == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
}
entity.setStatus("ongoing");
updateById(entity);
log.info("活动已重新开启ID{}", id);
}
@Override
public void removeContest(Long id) {
log.info("删除活动ID{}", id);
removeById(id);
log.info("活动删除成功ID{}", id);
}
@Override
public Map<String, Object> getStats(Long tenantId, boolean isSuperTenant) {
log.info("获取活动统计租户ID{}", tenantId);
LambdaQueryWrapper<BizContest> baseWrapper = new LambdaQueryWrapper<>();
baseWrapper.eq(BizContest::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
baseWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
long total = count(baseWrapper);
LambdaQueryWrapper<BizContest> publishedWrapper = new LambdaQueryWrapper<>();
publishedWrapper.eq(BizContest::getValidState, 1);
publishedWrapper.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
if (!isSuperTenant && tenantId != null) {
publishedWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
long published = count(publishedWrapper);
LambdaQueryWrapper<BizContest> ongoingWrapper = new LambdaQueryWrapper<>();
ongoingWrapper.eq(BizContest::getValidState, 1);
ongoingWrapper.eq(BizContest::getStatus, "ongoing");
if (!isSuperTenant && tenantId != null) {
ongoingWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
long ongoing = count(ongoingWrapper);
LambdaQueryWrapper<BizContest> finishedWrapper = new LambdaQueryWrapper<>();
finishedWrapper.eq(BizContest::getValidState, 1);
finishedWrapper.eq(BizContest::getStatus, "finished");
if (!isSuperTenant && tenantId != null) {
finishedWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
long finished = count(finishedWrapper);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", total);
stats.put("published", published);
stats.put("ongoing", ongoing);
stats.put("finished", finished);
return stats;
}
@Override
public Map<String, Object> getDashboard(Long tenantId) {
log.info("获取仪表盘数据租户ID{}", tenantId);
LambdaQueryWrapper<BizContest> contestWrapper = new LambdaQueryWrapper<>();
contestWrapper.eq(BizContest::getValidState, 1);
if (tenantId != null) {
contestWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
long totalContests = count(contestWrapper);
long totalRegistrations = contestRegistrationMapper.selectCount(new LambdaQueryWrapper<BizContestRegistration>());
long totalWorks = contestWorkMapper.selectCount(new LambdaQueryWrapper<BizContestWork>());
Map<String, Object> dashboard = new LinkedHashMap<>();
dashboard.put("totalContests", totalContests);
dashboard.put("totalRegistrations", totalRegistrations);
dashboard.put("totalWorks", totalWorks);
return dashboard;
}
// ====== 私有辅助方法 ======
private void mapDtoToEntity(CreateContestDto dto, BizContest entity) {
if (StringUtils.hasText(dto.getContestName())) {
entity.setContestName(dto.getContestName());
}
if (StringUtils.hasText(dto.getContestType())) {
entity.setContestType(dto.getContestType());
}
if (StringUtils.hasText(dto.getVisibility())) {
entity.setVisibility(dto.getVisibility());
}
if (dto.getTargetCities() != null) {
entity.setTargetCities(dto.getTargetCities());
}
if (dto.getAgeMin() != null) {
entity.setAgeMin(dto.getAgeMin());
}
if (dto.getAgeMax() != null) {
entity.setAgeMax(dto.getAgeMax());
}
if (StringUtils.hasText(dto.getStartTime())) {
entity.setStartTime(parseDateTime(dto.getStartTime()));
}
if (StringUtils.hasText(dto.getEndTime())) {
entity.setEndTime(parseDateTime(dto.getEndTime()));
}
if (StringUtils.hasText(dto.getAddress())) {
entity.setAddress(dto.getAddress());
}
if (dto.getContent() != null) {
entity.setContent(dto.getContent());
}
if (dto.getContestTenants() != null) {
entity.setContestTenants(dto.getContestTenants());
}
if (StringUtils.hasText(dto.getCoverUrl())) {
entity.setCoverUrl(dto.getCoverUrl());
}
if (StringUtils.hasText(dto.getPosterUrl())) {
entity.setPosterUrl(dto.getPosterUrl());
}
if (StringUtils.hasText(dto.getContactName())) {
entity.setContactName(dto.getContactName());
}
if (StringUtils.hasText(dto.getContactPhone())) {
entity.setContactPhone(dto.getContactPhone());
}
if (StringUtils.hasText(dto.getContactQrcode())) {
entity.setContactQrcode(dto.getContactQrcode());
}
if (dto.getOrganizers() != null) {
entity.setOrganizers(dto.getOrganizers());
}
if (dto.getCoOrganizers() != null) {
entity.setCoOrganizers(dto.getCoOrganizers());
}
if (dto.getSponsors() != null) {
entity.setSponsors(dto.getSponsors());
}
if (StringUtils.hasText(dto.getRegisterStartTime())) {
entity.setRegisterStartTime(parseDateTime(dto.getRegisterStartTime()));
}
if (StringUtils.hasText(dto.getRegisterEndTime())) {
entity.setRegisterEndTime(parseDateTime(dto.getRegisterEndTime()));
}
if (StringUtils.hasText(dto.getRegisterState())) {
entity.setRegisterState(dto.getRegisterState());
}
if (dto.getRequireAudit() != null) {
entity.setRequireAudit(dto.getRequireAudit());
}
if (dto.getAllowedGrades() != null) {
entity.setAllowedGrades(dto.getAllowedGrades());
}
if (dto.getAllowedClasses() != null) {
entity.setAllowedClasses(dto.getAllowedClasses());
}
if (dto.getTeamMinMembers() != null) {
entity.setTeamMinMembers(dto.getTeamMinMembers());
}
if (dto.getTeamMaxMembers() != null) {
entity.setTeamMaxMembers(dto.getTeamMaxMembers());
}
if (StringUtils.hasText(dto.getSubmitRule())) {
entity.setSubmitRule(dto.getSubmitRule());
}
if (StringUtils.hasText(dto.getSubmitStartTime())) {
entity.setSubmitStartTime(parseDateTime(dto.getSubmitStartTime()));
}
if (StringUtils.hasText(dto.getSubmitEndTime())) {
entity.setSubmitEndTime(parseDateTime(dto.getSubmitEndTime()));
}
if (StringUtils.hasText(dto.getWorkType())) {
entity.setWorkType(dto.getWorkType());
}
if (dto.getWorkRequirement() != null) {
entity.setWorkRequirement(dto.getWorkRequirement());
}
if (dto.getReviewRuleId() != null) {
entity.setReviewRuleId(dto.getReviewRuleId());
}
if (StringUtils.hasText(dto.getReviewStartTime())) {
entity.setReviewStartTime(parseDateTime(dto.getReviewStartTime()));
}
if (StringUtils.hasText(dto.getReviewEndTime())) {
entity.setReviewEndTime(parseDateTime(dto.getReviewEndTime()));
}
if (StringUtils.hasText(dto.getResultPublishTime())) {
entity.setResultPublishTime(parseDateTime(dto.getResultPublishTime()));
}
}
private Map<String, Object> entityToMap(BizContest entity) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", entity.getId());
map.put("contestName", entity.getContestName());
map.put("contestType", entity.getContestType());
map.put("contestState", entity.getContestState());
map.put("status", entity.getStatus());
map.put("visibility", entity.getVisibility());
map.put("startTime", entity.getStartTime());
map.put("endTime", entity.getEndTime());
map.put("address", entity.getAddress());
map.put("content", entity.getContent());
map.put("contestTenants", entity.getContestTenants());
map.put("coverUrl", entity.getCoverUrl());
map.put("posterUrl", entity.getPosterUrl());
map.put("contactName", entity.getContactName());
map.put("contactPhone", entity.getContactPhone());
map.put("contactQrcode", entity.getContactQrcode());
map.put("organizers", entity.getOrganizers());
map.put("coOrganizers", entity.getCoOrganizers());
map.put("sponsors", entity.getSponsors());
map.put("registerStartTime", entity.getRegisterStartTime());
map.put("registerEndTime", entity.getRegisterEndTime());
map.put("registerState", entity.getRegisterState());
map.put("requireAudit", entity.getRequireAudit());
map.put("allowedGrades", entity.getAllowedGrades());
map.put("allowedClasses", entity.getAllowedClasses());
map.put("teamMinMembers", entity.getTeamMinMembers());
map.put("teamMaxMembers", entity.getTeamMaxMembers());
map.put("targetCities", entity.getTargetCities());
map.put("ageMin", entity.getAgeMin());
map.put("ageMax", entity.getAgeMax());
map.put("submitRule", entity.getSubmitRule());
map.put("submitStartTime", entity.getSubmitStartTime());
map.put("submitEndTime", entity.getSubmitEndTime());
map.put("workType", entity.getWorkType());
map.put("workRequirement", entity.getWorkRequirement());
map.put("reviewRuleId", entity.getReviewRuleId());
map.put("reviewStartTime", entity.getReviewStartTime());
map.put("reviewEndTime", entity.getReviewEndTime());
map.put("resultState", entity.getResultState());
map.put("resultPublishTime", entity.getResultPublishTime());
map.put("createTime", entity.getCreateTime());
map.put("modifyTime", entity.getModifyTime());
return map;
}
}

View File

@ -0,0 +1,215 @@
package com.competition.modules.biz.contest.service.impl;
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.contest.dto.CreateTeamDto;
import com.competition.modules.biz.contest.entity.BizContestTeam;
import com.competition.modules.biz.contest.entity.BizContestTeamMember;
import com.competition.modules.biz.contest.mapper.ContestTeamMapper;
import com.competition.modules.biz.contest.mapper.ContestTeamMemberMapper;
import com.competition.modules.biz.contest.service.IContestTeamService;
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.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContestTeamServiceImpl extends ServiceImpl<ContestTeamMapper, BizContestTeam>
implements IContestTeamService {
private final ContestTeamMapper contestTeamMapper;
private final ContestTeamMemberMapper contestTeamMemberMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> createTeam(CreateTeamDto dto, Long tenantId, Long creatorId) {
log.info("开始创建团队活动ID{},团队名称:{}", dto.getContestId(), dto.getTeamName());
BizContestTeam team = new BizContestTeam();
team.setTenantId(tenantId);
team.setContestId(dto.getContestId());
team.setTeamName(dto.getTeamName());
team.setLeaderUserId(dto.getLeaderId());
team.setMaxMembers(dto.getMaxMembers());
team.setCreator(creatorId != null ? creatorId.intValue() : null);
save(team);
log.info("团队创建成功ID{}", team.getId());
// 添加队长为成员
addMemberInternal(team.getId(), dto.getLeaderId(), "leader", tenantId, creatorId);
// 添加其他成员
if (dto.getMemberIds() != null) {
for (Long memberId : dto.getMemberIds()) {
if (!memberId.equals(dto.getLeaderId())) {
addMemberInternal(team.getId(), memberId, "member", tenantId, creatorId);
}
}
}
// 添加指导老师
if (dto.getTeacherIds() != null) {
for (Long teacherId : dto.getTeacherIds()) {
addMemberInternal(team.getId(), teacherId, "mentor", tenantId, creatorId);
}
}
log.info("团队成员添加完成团队ID{}", team.getId());
return teamToMap(team, getMemberList(team.getId()));
}
@Override
public List<Map<String, Object>> findByContest(Long contestId, Long tenantId) {
log.info("查询活动团队列表活动ID{}", contestId);
LambdaQueryWrapper<BizContestTeam> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestTeam::getContestId, contestId);
if (tenantId != null) {
wrapper.eq(BizContestTeam::getTenantId, tenantId);
}
wrapper.orderByDesc(BizContestTeam::getCreateTime);
List<BizContestTeam> teams = list(wrapper);
return teams.stream()
.map(team -> teamToMap(team, getMemberList(team.getId())))
.collect(Collectors.toList());
}
@Override
public Map<String, Object> findDetail(Long id) {
log.info("查询团队详情ID{}", id);
BizContestTeam team = getById(id);
if (team == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "团队不存在");
}
return teamToMap(team, getMemberList(id));
}
@Override
public BizContestTeam updateTeam(Long id, CreateTeamDto dto) {
log.info("更新团队ID{}", id);
BizContestTeam team = getById(id);
if (team == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "团队不存在");
}
if (StringUtils.hasText(dto.getTeamName())) {
team.setTeamName(dto.getTeamName());
}
if (dto.getLeaderId() != null) {
team.setLeaderUserId(dto.getLeaderId());
}
if (dto.getMaxMembers() != null) {
team.setMaxMembers(dto.getMaxMembers());
}
updateById(team);
log.info("团队更新成功ID{}", id);
return team;
}
@Override
public void addMember(Long teamId, Long userId, String role, Long tenantId) {
log.info("添加团队成员团队ID{}用户ID{},角色:{}", teamId, userId, role);
// 检查重复
LambdaQueryWrapper<BizContestTeamMember> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestTeamMember::getTeamId, teamId);
wrapper.eq(BizContestTeamMember::getUserId, userId);
Long existCount = contestTeamMemberMapper.selectCount(wrapper);
if (existCount > 0) {
throw BusinessException.of(ErrorCode.CONFLICT, "该用户已在团队中");
}
addMemberInternal(teamId, userId, role, tenantId, null);
log.info("团队成员添加成功团队ID{}用户ID{}", teamId, userId);
}
@Override
public void removeMember(Long teamId, Long userId) {
log.info("移除团队成员团队ID{}用户ID{}", teamId, userId);
LambdaQueryWrapper<BizContestTeamMember> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestTeamMember::getTeamId, teamId);
wrapper.eq(BizContestTeamMember::getUserId, userId);
contestTeamMemberMapper.delete(wrapper);
log.info("团队成员移除成功团队ID{}用户ID{}", teamId, userId);
}
@Override
public void removeTeam(Long id) {
log.info("删除团队ID{}", id);
// 删除团队成员
LambdaQueryWrapper<BizContestTeamMember> memberWrapper = new LambdaQueryWrapper<>();
memberWrapper.eq(BizContestTeamMember::getTeamId, id);
contestTeamMemberMapper.delete(memberWrapper);
removeById(id);
log.info("团队删除成功ID{}", id);
}
// ====== 私有辅助方法 ======
private void addMemberInternal(Long teamId, Long userId, String role, Long tenantId, Long creatorId) {
BizContestTeamMember member = new BizContestTeamMember();
member.setTeamId(teamId);
member.setTenantId(tenantId);
member.setUserId(userId);
member.setRole(role);
member.setCreator(creatorId != null ? creatorId.intValue() : null);
member.setCreateTime(LocalDateTime.now());
contestTeamMemberMapper.insert(member);
}
private List<BizContestTeamMember> getMemberList(Long teamId) {
LambdaQueryWrapper<BizContestTeamMember> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestTeamMember::getTeamId, teamId);
wrapper.orderByAsc(BizContestTeamMember::getCreateTime);
return contestTeamMemberMapper.selectList(wrapper);
}
private Map<String, Object> teamToMap(BizContestTeam entity, List<BizContestTeamMember> members) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", entity.getId());
map.put("tenantId", entity.getTenantId());
map.put("contestId", entity.getContestId());
map.put("teamName", entity.getTeamName());
map.put("leaderUserId", entity.getLeaderUserId());
map.put("maxMembers", entity.getMaxMembers());
map.put("createTime", entity.getCreateTime());
if (members != null) {
List<Map<String, Object>> memberList = members.stream()
.map(m -> {
Map<String, Object> mMap = new LinkedHashMap<>();
mMap.put("id", m.getId());
mMap.put("teamId", m.getTeamId());
mMap.put("userId", m.getUserId());
mMap.put("role", m.getRole());
mMap.put("createTime", m.getCreateTime());
return mMap;
})
.collect(Collectors.toList());
map.put("members", memberList);
map.put("memberCount", memberList.size());
}
return map;
}
}

View File

@ -0,0 +1,745 @@
package com.competition.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.enums.WorkStatus;
import com.competition.common.enums.RegistrationStatus;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.dto.QueryWorkDto;
import com.competition.modules.biz.contest.dto.SubmitWorkDto;
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.entity.BizContestWorkAttachment;
import com.competition.modules.biz.contest.mapper.ContestMapper;
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.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizContestWork>
implements IContestWorkService {
private final ContestWorkMapper contestWorkMapper;
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)
public Map<String, Object> submitWork(SubmitWorkDto dto, Long tenantId, Long submitterId) {
log.info("开始提交作品报名ID{},提交者:{}", dto.getRegistrationId(), submitterId);
// 验证报名存在且已通过
BizContestRegistration registration = contestRegistrationMapper.selectById(dto.getRegistrationId());
if (registration == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "报名记录不存在");
}
if (!RegistrationStatus.PASSED.getValue().equals(registration.getRegistrationState())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名未通过审核,无法提交作品");
}
// 校验报名归属只有报名者本人才能提交作品
if (registration.getUserId() != null && !registration.getUserId().equals(submitterId)) {
log.warn("越权提交尝试报名用户ID={}提交者ID={}报名ID={}", registration.getUserId(), submitterId, dto.getRegistrationId());
throw BusinessException.of(ErrorCode.FORBIDDEN, "无权替他人提交作品");
}
Long contestId = registration.getContestId();
// 查询活动提交规则
BizContest contest = contestMapper.selectById(contestId);
String submitRule = contest != null ? contest.getSubmitRule() : "once";
// 计算版本号
int version = 1;
if ("resubmit".equals(submitRule)) {
// 将旧版本标记为非最新
LambdaUpdateWrapper<BizContestWork> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId())
.eq(BizContestWork::getIsLatest, true)
.set(BizContestWork::getIsLatest, false);
update(updateWrapper);
// 查询当前最大版本号
LambdaQueryWrapper<BizContestWork> versionWrapper = new LambdaQueryWrapper<>();
versionWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId());
versionWrapper.orderByDesc(BizContestWork::getVersion);
versionWrapper.last("LIMIT 1");
BizContestWork latestWork = getOne(versionWrapper, false);
if (latestWork != null) {
version = latestWork.getVersion() + 1;
}
} else {
// once 规则检查是否已有作品
LambdaQueryWrapper<BizContestWork> existWrapper = new LambdaQueryWrapper<>();
existWrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId());
long existCount = count(existWrapper);
if (existCount > 0) {
throw BusinessException.of(ErrorCode.CONFLICT, "该报名已提交作品,不允许重复提交");
}
}
// 生成作品编号
String workNo = nextContestWorkNo(contestId);
// 创建作品
BizContestWork work = new BizContestWork();
work.setTenantId(tenantId);
work.setContestId(contestId);
work.setRegistrationId(dto.getRegistrationId());
work.setWorkNo(workNo);
work.setTitle(dto.getTitle());
work.setDescription(dto.getDescription());
work.setFiles(dto.getFiles());
work.setVersion(version);
work.setIsLatest(true);
work.setStatus(WorkStatus.SUBMITTED.getValue());
work.setSubmitTime(LocalDateTime.now());
work.setSubmitterUserId(submitterId);
work.setSubmitterAccountNo(registration.getAccountNo());
work.setPreviewUrl(dto.getPreviewUrl());
work.setPreviewUrls(dto.getPreviewUrls());
work.setAiModelMeta(dto.getAiModelMeta());
save(work);
log.info("作品提交成功ID{},编号:{}", work.getId(), workNo);
// 保存附件
if (dto.getAttachments() != null && !dto.getAttachments().isEmpty()) {
for (SubmitWorkDto.AttachmentItem item : dto.getAttachments()) {
BizContestWorkAttachment attachment = new BizContestWorkAttachment();
attachment.setTenantId(tenantId);
attachment.setContestId(contestId);
attachment.setWorkId(work.getId());
attachment.setFileName(item.getFileName());
attachment.setFileUrl(item.getFileUrl());
attachment.setFileType(item.getFileType());
attachment.setSize(item.getSize());
attachment.setCreator(submitterId != null ? submitterId.intValue() : null);
attachment.setCreateTime(LocalDateTime.now());
contestWorkAttachmentMapper.insert(attachment);
}
log.info("作品附件保存成功,数量:{}", dto.getAttachments().size());
}
return workToMap(work);
}
@Override
public PageResult<Map<String, Object>> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant) {
log.info("查询作品列表活动ID{},页码:{}", dto.getContestId(), dto.getPage());
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
if (dto.getContestId() != null) {
wrapper.eq(BizContestWork::getContestId, dto.getContestId());
}
if (StringUtils.hasText(dto.getStatus())) {
wrapper.eq(BizContestWork::getStatus, dto.getStatus());
}
if (StringUtils.hasText(dto.getTitle())) {
wrapper.like(BizContestWork::getTitle, dto.getTitle());
}
if (StringUtils.hasText(dto.getWorkNo())) {
wrapper.eq(BizContestWork::getWorkNo, dto.getWorkNo());
}
if (dto.getRegistrationId() != null) {
wrapper.eq(BizContestWork::getRegistrationId, dto.getRegistrationId());
}
if (dto.getTenantId() != null) {
wrapper.eq(BizContestWork::getTenantId, dto.getTenantId());
} 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();
// keyword 命中口径作品编号work_no/作者姓名account_name/报名账号account_no/队伍名称team_name
LambdaQueryWrapper<BizContestRegistration> regWrapper = new LambdaQueryWrapper<>();
if (dto.getContestId() != null) {
regWrapper.eq(BizContestRegistration::getContestId, dto.getContestId());
}
regWrapper.eq(BizContestRegistration::getValidState, 1);
if (dto.getTenantId() != null) {
regWrapper.eq(BizContestRegistration::getTenantId, dto.getTenantId());
} else if (!isSuperTenant && tenantId != null) {
regWrapper.eq(BizContestRegistration::getTenantId, tenantId);
}
regWrapper.and(w -> w.like(BizContestRegistration::getAccountNo, keyword)
.or().like(BizContestRegistration::getAccountName, keyword)
.or().like(BizContestRegistration::getTeamName, keyword));
List<BizContestRegistration> regs = contestRegistrationMapper.selectList(regWrapper);
keywordRegistrationIds = regs.stream()
.map(BizContestRegistration::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!keywordRegistrationIds.isEmpty()) {
// 使用 apply 拼接 IN 条件避免当前 MyBatis-Plus LambdaQueryWrapper
// or().in() 链式泛型推断导致的编译失败
String registrationIdIn = keywordRegistrationIds.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
wrapper.and(w -> w.like(BizContestWork::getWorkNo, keyword)
.or().apply("registration_id IN (" + registrationIdIn + ")"));
} else {
wrapper.and(w -> w.like(BizContestWork::getWorkNo, keyword));
}
}
if (StringUtils.hasText(dto.getSubmitStartTime())) {
wrapper.ge(BizContestWork::getSubmitTime, parseDateTime(dto.getSubmitStartTime(), true));
}
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.eq(BizContestWork::getIsLatest, true);
wrapper.eq(BizContestWork::getValidState, 1);
wrapper.orderByDesc(BizContestWork::getSubmitTime);
Page<BizContestWork> page = new Page<>(dto.getPage(), dto.getPageSize());
Page<BizContestWork> result = contestWorkMapper.selectPage(page, wrapper);
// 批量查询报名/活动信息
Set<Long> registrationIds = result.getRecords().stream()
.map(BizContestWork::getRegistrationId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Set<Long> contestIds = result.getRecords().stream()
.map(BizContestWork::getContestId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, BizContestRegistration> registrationMap = new HashMap<>();
if (!registrationIds.isEmpty()) {
List<BizContestRegistration> registrations = contestRegistrationMapper.selectBatchIds(registrationIds);
registrationMap = registrations.stream()
.collect(Collectors.toMap(BizContestRegistration::getId, r -> r));
}
Map<Long, BizContest> contestMap = new HashMap<>();
if (!contestIds.isEmpty()) {
List<BizContest> contests = contestMapper.selectBatchIds(contestIds);
contestMap = contests.stream()
.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<>();
// 批量预加载评审规则避免 N+1 查询
Set<Long> ruleIds = contestIds.stream()
.map(cid -> finalContestMap.get(cid))
.filter(c -> c != null && c.getReviewRuleId() != null)
.map(BizContest::getReviewRuleId)
.collect(Collectors.toSet());
Map<Long, BizContestReviewRule> ruleMap = new HashMap<>();
if (!ruleIds.isEmpty()) {
LambdaQueryWrapper<BizContestReviewRule> ruleWrapper = new LambdaQueryWrapper<>();
ruleWrapper.in(BizContestReviewRule::getId, ruleIds);
contestReviewRuleMapper.selectList(ruleWrapper)
.forEach(r -> ruleMap.put(r.getId(), r));
}
// 批量预加载所有相关活动的评委权重
LambdaQueryWrapper<BizContestJudge> allJudgesWrapper = new LambdaQueryWrapper<>();
allJudgesWrapper.in(BizContestJudge::getContestId, contestIds);
allJudgesWrapper.eq(BizContestJudge::getValidState, 1);
List<BizContestJudge> allJudges = contestJudgeMapper.selectList(allJudgesWrapper);
Map<Long, List<BizContestJudge>> judgesByContestId = allJudges.stream()
.collect(Collectors.groupingBy(BizContestJudge::getContestId));
for (Long cid : contestIds) {
BizContest c = finalContestMap.get(cid);
if (c == null) {
continue;
}
String calculationRule = "average";
if (c.getReviewRuleId() != null) {
BizContestReviewRule rule = ruleMap.get(c.getReviewRuleId());
if (rule != null && StringUtils.hasText(rule.getCalculationRule())) {
calculationRule = rule.getCalculationRule();
}
}
contestCalculationRuleCache.put(cid, calculationRule);
List<BizContestJudge> judges = judgesByContestId.getOrDefault(cid, Collections.emptyList());
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);
BizContest contest = finalContestMap.get(work.getContestId());
if (contest != null) {
Map<String, Object> contestVo = new LinkedHashMap<>();
contestVo.put("id", contest.getId());
contestVo.put("contestName", contest.getContestName());
map.put("contest", contestVo);
}
BizContestRegistration reg = finalRegistrationMap.get(work.getRegistrationId());
if (reg != null) {
Map<String, Object> userVo = new LinkedHashMap<>();
userVo.put("id", reg.getUserId());
userVo.put("username", reg.getAccountNo());
userVo.put("nickname", reg.getAccountName());
Map<String, Object> regVo = new LinkedHashMap<>();
regVo.put("id", reg.getId());
regVo.put("user", userVo);
if (StringUtils.hasText(reg.getTeamName()) || reg.getTeamId() != null) {
Map<String, Object> teamVo = new LinkedHashMap<>();
teamVo.put("teamName", reg.getTeamName());
regVo.put("team", teamVo);
}
map.put("registration", regVo);
// 兼容旧字段保留扁平账号信息
map.put("accountNo", reg.getAccountNo());
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());
return PageResult.from(result, voList);
}
@Override
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) {
baseWrapper.eq(BizContestWork::getTenantId, tenantId);
}
long total = count(baseWrapper);
LambdaQueryWrapper<BizContestWork> submittedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
submittedWrapper.eq(BizContestWork::getContestId, contestId);
}
submittedWrapper.eq(BizContestWork::getIsLatest, true);
submittedWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
submittedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
submittedWrapper.eq(BizContestWork::getStatus, WorkStatus.SUBMITTED.getValue());
long submitted = count(submittedWrapper);
LambdaQueryWrapper<BizContestWork> reviewingWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
reviewingWrapper.eq(BizContestWork::getContestId, contestId);
}
reviewingWrapper.eq(BizContestWork::getIsLatest, true);
reviewingWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
reviewingWrapper.eq(BizContestWork::getTenantId, tenantId);
}
reviewingWrapper.eq(BizContestWork::getStatus, WorkStatus.REVIEWING.getValue());
long reviewing = count(reviewingWrapper);
LambdaQueryWrapper<BizContestWork> reviewedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
reviewedWrapper.eq(BizContestWork::getContestId, contestId);
}
reviewedWrapper.eq(BizContestWork::getIsLatest, true);
reviewedWrapper.eq(BizContestWork::getValidState, 1);
if (needTenantFilter) {
reviewedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
reviewedWrapper.in(BizContestWork::getStatus, Arrays.asList(WorkStatus.ACCEPTED.getValue(), WorkStatus.AWARDED.getValue()));
long reviewed = count(reviewedWrapper);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", total);
stats.put("submitted", submitted);
stats.put("reviewing", reviewing);
stats.put("reviewed", reviewed);
return stats;
}
@Override
public Map<String, Object> findDetail(Long id) {
log.info("查询作品详情ID{}", id);
BizContestWork work = getById(id);
if (work == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在");
}
Map<String, Object> result = workToMap(work);
// 查询报名信息
if (work.getRegistrationId() != null) {
BizContestRegistration registration = contestRegistrationMapper.selectById(work.getRegistrationId());
if (registration != null) {
Map<String, Object> regInfo = new LinkedHashMap<>();
regInfo.put("id", registration.getId());
regInfo.put("userId", registration.getUserId());
regInfo.put("accountNo", registration.getAccountNo());
regInfo.put("accountName", registration.getAccountName());
regInfo.put("registrationType", registration.getRegistrationType());
regInfo.put("participantType", registration.getParticipantType());
result.put("registration", regInfo);
}
}
// 查询附件
LambdaQueryWrapper<BizContestWorkAttachment> attWrapper = new LambdaQueryWrapper<>();
attWrapper.eq(BizContestWorkAttachment::getWorkId, id);
List<BizContestWorkAttachment> attachments = contestWorkAttachmentMapper.selectList(attWrapper);
result.put("attachments", attachments);
return result;
}
@Override
public List<Map<String, Object>> getWorkVersions(Long registrationId) {
log.info("查询作品版本历史报名ID{}", registrationId);
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getRegistrationId, registrationId);
wrapper.orderByDesc(BizContestWork::getVersion);
List<BizContestWork> works = list(wrapper);
return works.stream()
.map(this::workToMap)
.collect(Collectors.toList());
}
@Override
public PageResult<Map<String, Object>> getGuidedWorks(Long contestId, String workNo, String playerName,
String accountNo, Long page, Long pageSize, Long userId) {
log.info("查询指导作品活动ID{}教师用户ID{}", contestId, userId);
// 简化实现查询当前教师指导的报名ID列表再查对应作品
// 完整实现需要关联 t_biz_contest_registration_teacher
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
wrapper.eq(BizContestWork::getContestId, contestId);
}
wrapper.eq(BizContestWork::getIsLatest, true);
if (StringUtils.hasText(workNo)) {
wrapper.eq(BizContestWork::getWorkNo, workNo);
}
wrapper.orderByDesc(BizContestWork::getSubmitTime);
Page<BizContestWork> pageObj = new Page<>(page != null ? page : 1L, pageSize != null ? pageSize : 10L);
Page<BizContestWork> result = contestWorkMapper.selectPage(pageObj, wrapper);
List<Map<String, Object>> voList = result.getRecords().stream()
.map(this::workToMap)
.collect(Collectors.toList());
return PageResult.from(result, voList);
}
@Override
public void removeWork(Long id) {
log.info("删除作品ID{}", id);
removeById(id);
log.info("作品删除成功ID{}", id);
}
// ====== 私有辅助方法 ======
/**
* 解析时间参数支持 "yyyy-MM-dd" "yyyy-MM-ddTHH:mm:ss" 两种格式
* isStart=true 时纯日期补 00:00:00isStart=false 时纯日期补 23:59:59
*/
private LocalDateTime parseDateTime(String value, boolean isStart) {
if (value.contains("T")) {
return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
LocalDate date = LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE);
return isStart ? date.atStartOfDay() : date.atTime(23, 59, 59);
}
@Override
public synchronized String nextContestWorkNo(Long contestId) {
// 使用 MAX(work_no) 查询避免并发编号重复
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getContestId, contestId);
wrapper.likeRight(BizContestWork::getWorkNo, "W" + contestId + "-");
wrapper.orderByDesc(BizContestWork::getWorkNo);
wrapper.last("LIMIT 1");
BizContestWork lastWork = getOne(wrapper, false);
int nextSeq = 1;
if (lastWork != null && lastWork.getWorkNo() != null) {
try {
String no = lastWork.getWorkNo();
int dashIndex = no.lastIndexOf("-");
if (dashIndex > 0) {
nextSeq = Integer.parseInt(no.substring(dashIndex + 1)) + 1;
}
} catch (NumberFormatException e) {
log.warn("解析作品编号失败:{},将使用默认序号", lastWork.getWorkNo());
// 降级使用 count + 1
LambdaQueryWrapper<BizContestWork> countWrapper = new LambdaQueryWrapper<>();
countWrapper.eq(BizContestWork::getContestId, contestId);
nextSeq = (int) count(countWrapper) + 1;
}
}
return "W" + contestId + "-" + nextSeq;
}
private Map<String, Object> workToMap(BizContestWork entity) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", entity.getId());
map.put("tenantId", entity.getTenantId());
map.put("contestId", entity.getContestId());
map.put("registrationId", entity.getRegistrationId());
map.put("workNo", entity.getWorkNo());
map.put("title", entity.getTitle());
map.put("description", entity.getDescription());
map.put("files", entity.getFiles());
map.put("version", entity.getVersion());
map.put("isLatest", entity.getIsLatest());
map.put("status", entity.getStatus());
map.put("submitTime", entity.getSubmitTime());
map.put("submitterUserId", entity.getSubmitterUserId());
map.put("submitterAccountNo", entity.getSubmitterAccountNo());
map.put("submitSource", entity.getSubmitSource());
map.put("previewUrl", entity.getPreviewUrl());
map.put("previewUrls", entity.getPreviewUrls());
map.put("aiModelMeta", entity.getAiModelMeta());
map.put("userWorkId", entity.getUserWorkId());
map.put("finalScore", entity.getFinalScore());
map.put("rank", entity.getRank());
map.put("awardLevel", entity.getAwardLevel());
map.put("awardName", entity.getAwardName());
map.put("certificateUrl", entity.getCertificateUrl());
map.put("createTime", entity.getCreateTime());
return map;
}
}

View File

@ -0,0 +1,96 @@
package com.competition.modules.biz.homework.controller;
import com.competition.common.result.PageResult;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.homework.dto.CreateHomeworkDto;
import com.competition.modules.biz.homework.entity.BizHomework;
import com.competition.modules.biz.homework.service.IHomeworkService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "作业管理")
@RestController
@RequestMapping("/homework/homeworks")
@RequiredArgsConstructor
public class HomeworkController {
private final IHomeworkService homeworkService;
@PostMapping
@RequirePermission("homework:create")
@Operation(summary = "创建作业")
public Result<BizHomework> create(@Valid @RequestBody CreateHomeworkDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkService.create(dto, tenantId));
}
@GetMapping
@RequirePermission("homework:read")
@Operation(summary = "查询作业列表")
public Result<PageResult<Map<String, Object>>> findAll(
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) String name,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkService.findAll(page, pageSize, tenantId, name, status));
}
@GetMapping("/my")
@RequirePermission({"homework:read", "homework:student:read"})
@Operation(summary = "查询我的作业列表")
public Result<PageResult<Map<String, Object>>> findMyHomeworks(
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) String name) {
Long userId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkService.findMyHomeworks(page, pageSize, userId, tenantId, name));
}
@GetMapping("/{id}")
@RequirePermission({"homework:read", "homework:student:read"})
@Operation(summary = "查询作业详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(homeworkService.findDetail(id));
}
@PatchMapping("/{id}")
@RequirePermission("homework:update")
@Operation(summary = "更新作业")
public Result<BizHomework> update(@PathVariable Long id, @RequestBody CreateHomeworkDto dto) {
return Result.success(homeworkService.update(id, dto));
}
@PostMapping("/{id}/publish")
@RequirePermission("homework:update")
@Operation(summary = "发布作业")
public Result<Void> publish(@PathVariable Long id, @RequestBody(required = false) List<Integer> publishScope) {
homeworkService.publish(id, publishScope);
return Result.success();
}
@PostMapping("/{id}/unpublish")
@RequirePermission("homework:update")
@Operation(summary = "取消发布作业")
public Result<Void> unpublish(@PathVariable Long id) {
homeworkService.unpublish(id);
return Result.success();
}
@DeleteMapping("/{id}")
@RequirePermission("homework:delete")
@Operation(summary = "删除作业")
public Result<Void> remove(@PathVariable Long id) {
homeworkService.remove(id);
return Result.success();
}
}

View File

@ -0,0 +1,75 @@
package com.competition.modules.biz.homework.controller;
import com.competition.common.result.PageResult;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.homework.dto.CreateHomeworkReviewRuleDto;
import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule;
import com.competition.modules.biz.homework.service.IHomeworkReviewRuleService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "作业评审规则")
@RestController
@RequestMapping("/homework/review-rules")
@RequiredArgsConstructor
public class HomeworkReviewRuleController {
private final IHomeworkReviewRuleService homeworkReviewRuleService;
@GetMapping
@RequirePermission("homework:read")
@Operation(summary = "查询作业评审规则列表")
public Result<PageResult<Map<String, Object>>> findAll(
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) String name) {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkReviewRuleService.findAll(page, pageSize, tenantId, name));
}
@GetMapping("/select")
@RequirePermission("homework:read")
@Operation(summary = "查询作业评审规则选项列表")
public Result<List<Map<String, Object>>> findAllForSelect() {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkReviewRuleService.findAllForSelect(tenantId));
}
@PostMapping
@RequirePermission("homework:update")
@Operation(summary = "创建作业评审规则")
public Result<BizHomeworkReviewRule> create(@Valid @RequestBody CreateHomeworkReviewRuleDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkReviewRuleService.create(dto, tenantId));
}
@GetMapping("/{id}")
@RequirePermission("homework:read")
@Operation(summary = "查询作业评审规则详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(homeworkReviewRuleService.findDetail(id));
}
@PatchMapping("/{id}")
@RequirePermission("homework:update")
@Operation(summary = "更新作业评审规则")
public Result<BizHomeworkReviewRule> update(@PathVariable Long id, @RequestBody CreateHomeworkReviewRuleDto dto) {
return Result.success(homeworkReviewRuleService.update(id, dto));
}
@DeleteMapping("/{id}")
@RequirePermission("homework:update")
@Operation(summary = "删除作业评审规则")
public Result<Void> remove(@PathVariable Long id) {
homeworkReviewRuleService.remove(id);
return Result.success();
}
}

View File

@ -0,0 +1,73 @@
package com.competition.modules.biz.homework.controller;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.homework.dto.CreateHomeworkScoreDto;
import com.competition.modules.biz.homework.entity.BizHomeworkScore;
import com.competition.modules.biz.homework.entity.BizHomeworkSubmission;
import com.competition.modules.biz.homework.service.IHomeworkScoreService;
import com.competition.modules.biz.homework.service.IHomeworkSubmissionService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Slf4j
@Tag(name = "作业评分")
@RestController
@RequestMapping("/homework/scores")
@RequiredArgsConstructor
public class HomeworkScoreController {
private final IHomeworkScoreService homeworkScoreService;
private final IHomeworkSubmissionService homeworkSubmissionService;
@PostMapping
@RequirePermission("homework:update")
@Operation(summary = "创建评分")
public Result<BizHomeworkScore> create(@Valid @RequestBody CreateHomeworkScoreDto dto) {
Long reviewerId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkScoreService.create(dto, reviewerId, tenantId));
}
@PostMapping("/{submissionId}/violation")
@RequirePermission("homework:update")
@Operation(summary = "标记违规")
public Result<Void> markViolation(@PathVariable Long submissionId) {
log.info("标记作业提交违规提交ID{}", submissionId);
BizHomeworkSubmission submission = homeworkSubmissionService.getById(submissionId);
if (submission == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在");
}
submission.setStatus("violation");
homeworkSubmissionService.updateById(submission);
log.info("作业提交已标记为违规提交ID{}", submissionId);
return Result.success();
}
@PostMapping("/{submissionId}/reset")
@RequirePermission("homework:update")
@Operation(summary = "重置评分状态")
public Result<Void> resetStatus(@PathVariable Long submissionId) {
log.info("重置作业提交评分状态提交ID{}", submissionId);
BizHomeworkSubmission submission = homeworkSubmissionService.getById(submissionId);
if (submission == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作业提交不存在");
}
submission.setStatus("pending");
submission.setTotalScore(null);
homeworkSubmissionService.updateById(submission);
log.info("作业提交评分状态已重置提交ID{}", submissionId);
return Result.success();
}
}

View File

@ -0,0 +1,75 @@
package com.competition.modules.biz.homework.controller;
import com.competition.common.result.PageResult;
import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.homework.dto.CreateSubmissionDto;
import com.competition.modules.biz.homework.entity.BizHomeworkSubmission;
import com.competition.modules.biz.homework.service.IHomeworkSubmissionService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "作业提交")
@RestController
@RequestMapping("/homework/submissions")
@RequiredArgsConstructor
public class HomeworkSubmissionController {
private final IHomeworkSubmissionService homeworkSubmissionService;
@GetMapping
@RequirePermission("homework:read")
@Operation(summary = "查询作业提交列表")
public Result<PageResult<Map<String, Object>>> findAll(
@RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) Long homeworkId,
@RequestParam(required = false) String workNo,
@RequestParam(required = false) String workName,
@RequestParam(required = false) String studentAccount,
@RequestParam(required = false) String studentName,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkSubmissionService.findAll(page, pageSize, tenantId, homeworkId,
workNo, workName, studentAccount, studentName, status));
}
@GetMapping("/class-tree")
@RequirePermission("homework:read")
@Operation(summary = "获取班级树")
public Result<List<Map<String, Object>>> getClassTree() {
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkSubmissionService.getClassTree(tenantId));
}
@GetMapping("/my/{homeworkId}")
@RequirePermission({"homework:read", "homework:student:read"})
@Operation(summary = "查询我的作业提交")
public Result<Map<String, Object>> findMySubmission(@PathVariable Long homeworkId) {
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(homeworkSubmissionService.findMySubmission(homeworkId, userId));
}
@PostMapping
@RequirePermission({"homework:read", "homework:student:read"})
@Operation(summary = "提交作业")
public Result<BizHomeworkSubmission> create(@Valid @RequestBody CreateSubmissionDto dto) {
Long studentId = SecurityUtil.getCurrentUserId();
Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(homeworkSubmissionService.create(dto, studentId, tenantId));
}
@GetMapping("/{id}")
@RequirePermission("homework:read")
@Operation(summary = "查询作业提交详情")
public Result<Map<String, Object>> findDetail(@PathVariable Long id) {
return Result.success(homeworkSubmissionService.findDetail(id));
}
}

View File

@ -0,0 +1,36 @@
package com.competition.modules.biz.homework.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "创建作业DTO")
public class CreateHomeworkDto {
@NotBlank(message = "作业名称不能为空")
@Schema(description = "作业名称")
private String name;
@Schema(description = "作业内容")
private String content;
@NotBlank(message = "提交开始时间不能为空")
@Schema(description = "提交开始时间")
private String submitStartTime;
@NotBlank(message = "提交截止时间不能为空")
@Schema(description = "提交截止时间")
private String submitEndTime;
@Schema(description = "附件")
private Object attachments;
@Schema(description = "发布范围")
private List<Integer> publishScope;
@Schema(description = "评审规则ID")
private Long reviewRuleId;
}

View File

@ -0,0 +1,22 @@
package com.competition.modules.biz.homework.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "创建作业评审规则DTO")
public class CreateHomeworkReviewRuleDto {
@NotBlank(message = "规则名称不能为空")
@Schema(description = "规则名称")
private String name;
@Schema(description = "规则描述")
private String description;
@NotNull(message = "评分标准不能为空")
@Schema(description = "评分标准JSON数组")
private Object criteria;
}

View File

@ -0,0 +1,26 @@
package com.competition.modules.biz.homework.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(description = "创建作业评分DTO")
public class CreateHomeworkScoreDto {
@NotNull(message = "提交ID不能为空")
@Schema(description = "提交ID")
private Long submissionId;
@Schema(description = "维度评分JSON")
private Object dimensionScores;
@NotNull(message = "总分不能为空")
@Schema(description = "总分")
private BigDecimal totalScore;
@Schema(description = "评语")
private String comments;
}

View File

@ -0,0 +1,25 @@
package com.competition.modules.biz.homework.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "创建作业提交DTO")
public class CreateSubmissionDto {
@NotNull(message = "作业ID不能为空")
@Schema(description = "作业ID")
private Long homeworkId;
@NotBlank(message = "作品名称不能为空")
@Schema(description = "作品名称")
private String workName;
@Schema(description = "作品描述")
private String workDescription;
@Schema(description = "作品文件")
private Object files;
}

View File

@ -0,0 +1,55 @@
package com.competition.modules.biz.homework.entity;
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;
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")
private String status;
@Schema(description = "发布时间")
@TableField("publish_time")
private LocalDateTime publishTime;
}

View File

@ -0,0 +1,30 @@
package com.competition.modules.biz.homework.entity;
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数组")
@TableField(value = "criteria", typeHandler = JacksonTypeHandler.class)
private Object criteria;
}

View File

@ -0,0 +1,46 @@
package com.competition.modules.biz.homework.entity;
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;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "t_biz_homework_score", autoResultMap = true)
@Schema(description = "作业评分实体")
public class BizHomeworkScore extends BaseEntity {
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "提交ID")
@TableField("submission_id")
private Long submissionId;
@Schema(description = "评审人ID")
@TableField("reviewer_id")
private Long reviewerId;
@Schema(description = "各维度得分JSON")
@TableField(value = "dimension_scores", typeHandler = JacksonTypeHandler.class)
private Object dimensionScores;
@Schema(description = "总分")
@TableField("total_score")
private BigDecimal totalScore;
@Schema(description = "评语")
private String comments;
@Schema(description = "评分时间")
@TableField("score_time")
private LocalDateTime scoreTime;
}

View File

@ -0,0 +1,62 @@
package com.competition.modules.biz.homework.entity;
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;
import java.math.BigDecimal;
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")
private String status;
@Schema(description = "总分")
@TableField("total_score")
private BigDecimal totalScore;
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.homework.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.homework.entity.BizHomework;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HomeworkMapper extends BaseMapper<BizHomework> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.homework.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HomeworkReviewRuleMapper extends BaseMapper<BizHomeworkReviewRule> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.homework.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.homework.entity.BizHomeworkScore;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HomeworkScoreMapper extends BaseMapper<BizHomeworkScore> {
}

View File

@ -0,0 +1,9 @@
package com.competition.modules.biz.homework.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.biz.homework.entity.BizHomeworkSubmission;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HomeworkSubmissionMapper extends BaseMapper<BizHomeworkSubmission> {
}

View File

@ -0,0 +1,24 @@
package com.competition.modules.biz.homework.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.homework.dto.CreateHomeworkReviewRuleDto;
import com.competition.modules.biz.homework.entity.BizHomeworkReviewRule;
import java.util.List;
import java.util.Map;
public interface IHomeworkReviewRuleService extends IService<BizHomeworkReviewRule> {
BizHomeworkReviewRule create(CreateHomeworkReviewRuleDto dto, Long tenantId);
PageResult<Map<String, Object>> findAll(Long page, Long pageSize, Long tenantId, String name);
List<Map<String, Object>> findAllForSelect(Long tenantId);
Map<String, Object> findDetail(Long id);
BizHomeworkReviewRule update(Long id, CreateHomeworkReviewRuleDto dto);
void remove(Long id);
}

View File

@ -0,0 +1,15 @@
package com.competition.modules.biz.homework.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.homework.dto.CreateHomeworkScoreDto;
import com.competition.modules.biz.homework.entity.BizHomeworkScore;
import java.util.List;
import java.util.Map;
public interface IHomeworkScoreService extends IService<BizHomeworkScore> {
BizHomeworkScore create(CreateHomeworkScoreDto dto, Long reviewerId, Long tenantId);
List<Map<String, Object>> findBySubmission(Long submissionId);
}

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