fix: 补齐作品租户隔离与租户菜单树

- 作品列表/统计补齐 validState 与租户条件,关键字支持报名/队伍信息匹配
- 新增租户菜单树接口与服务实现,结构对齐用户菜单树
- t_biz_contest_work 增加 deleted 字段,补充 flyway 迁移与启动时轻量修复

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-02 18:30:45 +08:00
parent ab5bd36cec
commit ea65b55332
12 changed files with 266 additions and 83 deletions

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

@ -37,7 +37,9 @@ public class ContestWorkController {
@RequirePermission("work:read")
@Operation(summary = "获取作品统计")
public Result<Map<String, Object>> getStats(@RequestParam(required = false) Long contestId) {
return Result.success(workService.getStats(contestId, SecurityUtil.getCurrentTenantId()));
return Result.success(
workService.getStats(contestId, SecurityUtil.getCurrentTenantId(), SecurityUtil.isSuperAdmin())
);
}
@GetMapping

View File

@ -15,7 +15,7 @@ public interface IContestWorkService extends IService<BizContestWork> {
PageResult<Map<String, Object>> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant);
Map<String, Object> getStats(Long contestId, Long tenantId);
Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant);
Map<String, Object> findDetail(Long id);

View File

@ -160,9 +160,44 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
} else if (!isSuperTenant && tenantId != null) {
wrapper.eq(BizContestWork::getTenantId, tenantId);
}
Set<Long> keywordRegistrationIds = Collections.emptySet();
if (StringUtils.hasText(dto.getKeyword())) {
wrapper.and(w -> w.like(BizContestWork::getTitle, dto.getKeyword())
.or().like(BizContestWork::getWorkNo, 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,
@ -175,17 +210,23 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
// 默认只查最新版本
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);
@ -193,12 +234,47 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
.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));
}
Map<Long, BizContestRegistration> finalRegistrationMap = registrationMap;
Map<Long, BizContest> finalContestMap = contestMap;
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());
@ -211,7 +287,7 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
@Override
public Map<String, Object> getStats(Long contestId, Long tenantId) {
public Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant) {
log.info("获取作品统计赛事ID{}", contestId);
LambdaQueryWrapper<BizContestWork> baseWrapper = new LambdaQueryWrapper<>();
@ -219,6 +295,10 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
baseWrapper.eq(BizContestWork::getContestId, contestId);
}
baseWrapper.eq(BizContestWork::getIsLatest, true);
baseWrapper.eq(BizContestWork::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
baseWrapper.eq(BizContestWork::getTenantId, tenantId);
}
long total = count(baseWrapper);
LambdaQueryWrapper<BizContestWork> submittedWrapper = new LambdaQueryWrapper<>();
@ -226,6 +306,10 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
submittedWrapper.eq(BizContestWork::getContestId, contestId);
}
submittedWrapper.eq(BizContestWork::getIsLatest, true);
submittedWrapper.eq(BizContestWork::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
submittedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
submittedWrapper.eq(BizContestWork::getStatus, "submitted");
long submitted = count(submittedWrapper);
@ -234,31 +318,31 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
reviewingWrapper.eq(BizContestWork::getContestId, contestId);
}
reviewingWrapper.eq(BizContestWork::getIsLatest, true);
reviewingWrapper.eq(BizContestWork::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
reviewingWrapper.eq(BizContestWork::getTenantId, tenantId);
}
reviewingWrapper.eq(BizContestWork::getStatus, "reviewing");
long reviewing = count(reviewingWrapper);
LambdaQueryWrapper<BizContestWork> acceptedWrapper = new LambdaQueryWrapper<>();
LambdaQueryWrapper<BizContestWork> reviewedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
acceptedWrapper.eq(BizContestWork::getContestId, contestId);
reviewedWrapper.eq(BizContestWork::getContestId, contestId);
}
acceptedWrapper.eq(BizContestWork::getIsLatest, true);
acceptedWrapper.eq(BizContestWork::getStatus, "accepted");
long accepted = count(acceptedWrapper);
LambdaQueryWrapper<BizContestWork> rejectedWrapper = new LambdaQueryWrapper<>();
if (contestId != null) {
rejectedWrapper.eq(BizContestWork::getContestId, contestId);
reviewedWrapper.eq(BizContestWork::getIsLatest, true);
reviewedWrapper.eq(BizContestWork::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
reviewedWrapper.eq(BizContestWork::getTenantId, tenantId);
}
rejectedWrapper.eq(BizContestWork::getIsLatest, true);
rejectedWrapper.eq(BizContestWork::getStatus, "rejected");
long rejected = count(rejectedWrapper);
// 已评完口径兼容 accepted/awarded 两种结果状态
reviewedWrapper.in(BizContestWork::getStatus, Arrays.asList("accepted", "awarded"));
long reviewed = count(reviewedWrapper);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", total);
stats.put("submitted", submitted);
stats.put("reviewing", reviewing);
stats.put("accepted", accepted);
stats.put("rejected", rejected);
stats.put("reviewed", reviewed);
return stats;
}

View File

@ -2,10 +2,14 @@ package com.competition.modules.sys.controller;
import com.competition.common.result.PageResult;
import com.competition.common.result.Result;
import com.competition.common.exception.BusinessException;
import com.competition.common.enums.ErrorCode;
import com.competition.common.util.SecurityUtil;
import com.competition.modules.sys.dto.CreateTenantDto;
import com.competition.modules.sys.dto.UpdateTenantDto;
import com.competition.modules.sys.entity.SysMenu;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.service.ISysMenuService;
import com.competition.modules.sys.service.ISysTenantService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
@ -15,6 +19,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.List;
import java.util.Objects;
@Tag(name = "租户管理")
@RestController
@ -23,6 +29,7 @@ import java.util.Map;
public class SysTenantController {
private final ISysTenantService tenantService;
private final ISysMenuService menuService;
@PostMapping
@RequirePermission("tenant:create")
@ -90,4 +97,24 @@ public class SysTenantController {
tenantService.removeTenant(id, currentTenantId);
return Result.success();
}
@GetMapping("/{id}/menus")
@RequirePermission("tenant:read")
@Operation(summary = "获取租户菜单树")
public Result<List<SysMenu>> getTenantMenus(@PathVariable Long id) {
Long currentTenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperAdmin = SecurityUtil.isSuperAdmin();
// 非超管只能查询自身租户
if (!isSuperAdmin && !Objects.equals(id, currentTenantId)) {
throw BusinessException.of(ErrorCode.FORBIDDEN, "只能查询当前租户菜单");
}
// 校验租户是否存在
if (tenantService.getById(id) == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在");
}
return Result.success(menuService.getTenantMenus(id));
}
}

View File

@ -12,6 +12,11 @@ public interface ISysMenuService extends IService<SysMenu> {
List<SysMenu> findAllTree();
/**
* 获取指定租户可用的菜单树基于 t_sys_tenant_menu 分配
*/
List<SysMenu> getTenantMenus(Long tenantId);
List<SysMenu> getUserMenus(Long userId, Long tenantId, boolean isSuperAdmin);
SysMenu findDetail(Long id);

View File

@ -95,6 +95,34 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
return buildTree(filteredMenus, null);
}
@Override
public List<SysMenu> getTenantMenus(Long tenantId) {
if (tenantId == null) return Collections.emptyList();
// 获取所有有效菜单
List<SysMenu> allMenus = list(new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getValidState, 1)
.orderByAsc(SysMenu::getSort));
// 获取租户已分配的菜单 ID
List<SysTenantMenu> tenantMenus = tenantMenuMapper.selectList(
new LambdaQueryWrapper<SysTenantMenu>().eq(SysTenantMenu::getTenantId, tenantId));
Set<Long> tenantMenuIds = tenantMenus.stream().map(SysTenantMenu::getMenuId).collect(Collectors.toSet());
// 过滤出租户可用的菜单
List<SysMenu> filteredMenus = allMenus.stream()
.filter(menu -> tenantMenuIds.contains(menu.getId()))
.collect(Collectors.toList());
// 补全父菜单确保树结构完整
Set<Long> filteredIds = filteredMenus.stream().map(SysMenu::getId).collect(Collectors.toSet());
for (SysMenu menu : new ArrayList<>(filteredMenus)) {
addParentsIfMissing(menu, allMenus, filteredMenus, filteredIds);
}
return buildTree(filteredMenus, null);
}
@Override
public SysMenu findDetail(Long id) {
SysMenu menu = getById(id);

View File

@ -568,6 +568,7 @@ CREATE TABLE `t_biz_contest_work` (
`user_work_id` int DEFAULT NULL,
`create_by` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人账号',
`update_by` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人账号',
`deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除0-未删除1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `t_contest_work_work_no_key` (`work_no`),
KEY `t_contest_work_tenant_id_contest_id_is_latest_idx` (`tenant_id`,`contest_id`,`is_latest`),
@ -579,7 +580,7 @@ CREATE TABLE `t_biz_contest_work` (
KEY `t_contest_work_user_work_id_fkey` (`user_work_id`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
INSERT INTO `t_biz_contest_work` VALUES (1,9,4,4,NULL,'森林里的音乐会','小动物们在森林里举办了一场盛大的音乐会',NULL,1,1,'awarded','2026-03-18 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c1-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.394',1,'一等奖','一等奖',NULL,90.33,NULL,1,NULL,NULL,NULL),(2,9,4,5,NULL,'海边的贝壳梦','小女孩在海边捡到了一个会说话的贝壳',NULL,1,1,'awarded','2026-03-20 14:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c1-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.401',1,'三等奖','三等奖',NULL,79.00,NULL,3,NULL,NULL,NULL),(3,9,4,6,NULL,'云端上的图书馆','一座建在云朵上的神奇图书馆',NULL,1,1,'awarded','2026-03-22 16:30:00.000',14,NULL,'student','https://picsum.photos/seed/work-c1-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.398',1,'二等奖','二等奖',NULL,85.33,NULL,2,NULL,NULL,NULL),(4,9,5,9,NULL,'和妈妈一起画星星','记录了和妈妈一起画画的温馨夜晚',NULL,1,1,'submitted','2026-03-05 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c2-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(5,9,5,10,NULL,'爸爸的大手','用绘画记录爸爸温暖的大手牵着我的小手',NULL,1,1,'submitted','2026-03-08 11:30:00.000',13,NULL,'student','https://picsum.photos/seed/work-c2-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(6,9,5,11,NULL,'全家福的故事','一幅全家福背后的温暖故事',NULL,1,1,'submitted','2026-03-10 09:00:00.000',14,NULL,'student','https://picsum.photos/seed/work-c2-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(7,9,6,13,NULL,'我的寒假阅读日记','记录了寒假30天的阅读旅程',NULL,1,1,'accepted','2026-02-10 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c3-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(8,9,6,14,NULL,'绘本里的四季','用绘画展现四季的变化',NULL,1,1,'accepted','2026-02-12 15:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c3-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);
INSERT INTO `t_biz_contest_work` (id,tenant_id,contest_id,registration_id,work_no,title,description,files,version,is_latest,status,submit_time,submitter_user_id,submitter_account_no,submit_source,preview_url,ai_model_meta,creator,modifier,create_time,modify_time,valid_state,award_level,award_name,certificate_url,final_score,preview_urls,rank,user_work_id,create_by,update_by) VALUES (1,9,4,4,NULL,'森林里的音乐会','小动物们在森林里举办了一场盛大的音乐会',NULL,1,1,'awarded','2026-03-18 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c1-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.394',1,'一等奖','一等奖',NULL,90.33,NULL,1,NULL,NULL,NULL),(2,9,4,5,NULL,'海边的贝壳梦','小女孩在海边捡到了一个会说话的贝壳',NULL,1,1,'awarded','2026-03-20 14:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c1-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.401',1,'三等奖','三等奖',NULL,79.00,NULL,3,NULL,NULL,NULL),(3,9,4,6,NULL,'云端上的图书馆','一座建在云朵上的神奇图书馆',NULL,1,1,'awarded','2026-03-22 16:30:00.000',14,NULL,'student','https://picsum.photos/seed/work-c1-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.398',1,'二等奖','二等奖',NULL,85.33,NULL,2,NULL,NULL,NULL),(4,9,5,9,NULL,'和妈妈一起画星星','记录了和妈妈一起画画的温馨夜晚',NULL,1,1,'submitted','2026-03-05 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c2-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(5,9,5,10,NULL,'爸爸的大手','用绘画记录爸爸温暖的大手牵着我的小手',NULL,1,1,'submitted','2026-03-08 11:30:00.000',13,NULL,'student','https://picsum.photos/seed/work-c2-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(6,9,5,11,NULL,'全家福的故事','一幅全家福背后的温暖故事',NULL,1,1,'submitted','2026-03-10 09:00:00.000',14,NULL,'student','https://picsum.photos/seed/work-c2-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(7,9,6,13,NULL,'我的寒假阅读日记','记录了寒假30天的阅读旅程',NULL,1,1,'accepted','2026-02-10 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c3-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(8,9,6,14,NULL,'绘本里的四季','用绘画展现四季的变化',NULL,1,1,'accepted','2026-02-12 15:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c3-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `t_biz_contest_work_attachment` (

View File

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

View File

@ -145,7 +145,7 @@ const users = await prisma.user.findMany({ where });
- `GET /api/tenants/:id` - 获取租户详情
- `PATCH /api/tenants/:id` - 更新租户(包括菜单分配)
- `DELETE /api/tenants/:id` - 删除租户
- `GET /api/tenants/:id/menus` - 获取租户菜单树
- `GET /api/tenants/:id/menus` - 获取租户菜单树(返回与 `/api/menus/user-menus` 相同的菜单树结构;数据来源 `t_sys_tenant_menu`;非超管只能查询自身租户)
### 其他接口

View File

@ -275,8 +275,9 @@ const handleEdit = async (record: Role) => {
form.code = detail.code
form.description = detail.description || ''
//
form.permissionIds = detail.permissions?.map((rp) => rp.permission.id) || []
form.permissionIds = detail.permissions?.map((rp) => rp.id) || []
} catch (error) {
console.error(error)
message.error('获取角色详情失败')
modalVisible.value = false
} finally {

View File

@ -7,12 +7,8 @@
<!-- 统计卡片 -->
<div class="stats-row">
<div
v-for="item in statsItems"
:key="item.type"
:class="['stat-card', { active: activeType === item.type }]"
@click="handleStatClick(item.type)"
>
<div v-for="item in statsItems" :key="item.type" :class="['stat-card', { active: activeType === item.type }]"
@click="handleStatClick(item.type)">
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
@ -27,43 +23,21 @@
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="关键词">
<a-input
v-model:value="searchParams.keyword"
placeholder="用户名 / 昵称 / 手机号"
allow-clear
style="width: 200px"
@press-enter="handleSearch"
/>
<a-input v-model:value="searchParams.keyword" placeholder="用户名 / 昵称 / 手机号" allow-clear style="width: 200px"
@press-enter="handleSearch" />
</a-form-item>
<a-form-item v-if="!activeType || activeType === 'org'" label="所属机构">
<a-select
v-model:value="searchParams.filterTenantId"
placeholder="全部机构"
allow-clear
show-search
:filter-option="filterTenantOption"
style="width: 180px"
:options="tenantOptions"
/>
<a-select v-model:value="searchParams.filterTenantId" placeholder="全部机构" allow-clear show-search
:filter-option="filterTenantOption" style="width: 180px" :options="tenantOptions" />
</a-form-item>
<a-form-item label="用户来源">
<a-select
v-model:value="searchParams.userSource"
placeholder="全部"
allow-clear
style="width: 130px"
>
<a-select v-model:value="searchParams.userSource" placeholder="全部" allow-clear style="width: 130px">
<a-select-option value="admin_created">管理创建</a-select-option>
<a-select-option value="self_registered">自主注册</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchParams.status"
placeholder="全部"
allow-clear
style="width: 100px"
>
<a-select v-model:value="searchParams.status" placeholder="全部" allow-clear style="width: 100px">
<a-select-option value="enabled">正常</a-select-option>
<a-select-option value="disabled">禁用</a-select-option>
</a-select>
@ -71,11 +45,15 @@
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-space>
@ -84,15 +62,8 @@
</div>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="data-table"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id"
@change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userInfo'">
<div class="user-cell">
@ -156,12 +127,7 @@
</a-table>
<!-- 详情 Drawer -->
<a-drawer
v-model:open="detailVisible"
title="用户详情"
:width="560"
:destroy-on-close="true"
>
<a-drawer v-model:open="detailVisible" title="用户详情" :width="560" :destroy-on-close="true">
<template v-if="detailLoading">
<div class="drawer-loading"><a-spin /></div>
</template>
@ -191,7 +157,8 @@
{{ detailData.status === 'enabled' ? '正常' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization
}}</a-descriptions-item>
</a-descriptions>
<!-- 公众用户子女账号 -->
@ -273,12 +240,8 @@
</a-drawer>
<!-- 重置密码弹窗 -->
<a-modal
v-model:open="passwordModalVisible"
title="重置密码"
:confirm-loading="passwordLoading"
@ok="handlePasswordSubmit"
>
<a-modal v-model:open="passwordModalVisible" title="重置密码" :confirm-loading="passwordLoading"
@ok="handlePasswordSubmit">
<a-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" :label-col="{ span: 6 }">
<a-form-item label="新密码" name="newPassword">
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
@ -571,11 +534,13 @@ $primary: #6366f1;
:deep(.ant-card-head) {
border-bottom: none;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
}
}
:deep(.ant-card-body) {
padding: 0;
}