Browse Source

refactor: 根据代码审计报告优化代码质量 - 敏感配置外部化,application.yml使用环境变量,新增application-dev.yml - JWT Filter增加UsernameNotFoundException异常处理 - 用户列表接口返回UserVO排除密码字段 - 密码复杂度校验(至少6位,包含字母和数字) - 字典/服务商删除前检查资源引用 - CORS配置可配置化 - 日期合理性校验(到期日期不能早于开通日期) - 操作日志userId空值安全处理 - 登录失败锁定机制(5次失败锁定15分钟) - 枚举类管理状态码,消除魔法数字 - 前端statusClass抽取公共模块,表单字段修复 - 操作日志增加筛选功能(模块/操作/时间范围) - 费用统计改用SQL聚合优化性能 - 字典管理写操作权限收紧为管理员 - Vite host配置限制为localhost

C 2 months ago
parent
commit
7b778b00de
34 changed files with 522 additions and 115 deletions
  1. 5 0
      backend/.gitignore
  2. 22 0
      backend/src/main/java/com/example/resourceplatform/common/CostCycle.java
  3. 21 0
      backend/src/main/java/com/example/resourceplatform/common/ProjectStatus.java
  4. 30 0
      backend/src/main/java/com/example/resourceplatform/common/ResourceStatus.java
  5. 21 0
      backend/src/main/java/com/example/resourceplatform/common/UserRole.java
  6. 15 1
      backend/src/main/java/com/example/resourceplatform/config/SecurityConfig.java
  7. 17 4
      backend/src/main/java/com/example/resourceplatform/controller/OperationLogController.java
  8. 10 0
      backend/src/main/java/com/example/resourceplatform/controller/ProviderController.java
  9. 10 0
      backend/src/main/java/com/example/resourceplatform/controller/ResourceTypeController.java
  10. 5 5
      backend/src/main/java/com/example/resourceplatform/controller/UserController.java
  11. 2 0
      backend/src/main/java/com/example/resourceplatform/dto/PasswordUpdateDTO.java
  12. 2 0
      backend/src/main/java/com/example/resourceplatform/dto/UserDTO.java
  13. 24 0
      backend/src/main/java/com/example/resourceplatform/dto/UserVO.java
  14. 9 0
      backend/src/main/java/com/example/resourceplatform/mapper/ResourceMapper.java
  15. 15 7
      backend/src/main/java/com/example/resourceplatform/security/JwtAuthenticationFilter.java
  16. 2 1
      backend/src/main/java/com/example/resourceplatform/security/LoginUser.java
  17. 6 1
      backend/src/main/java/com/example/resourceplatform/service/SysUserService.java
  18. 4 3
      backend/src/main/java/com/example/resourceplatform/service/impl/DashboardServiceImpl.java
  19. 5 1
      backend/src/main/java/com/example/resourceplatform/service/impl/OperationLogServiceImpl.java
  20. 3 2
      backend/src/main/java/com/example/resourceplatform/service/impl/ProjectServiceImpl.java
  21. 14 51
      backend/src/main/java/com/example/resourceplatform/service/impl/ResourceServiceImpl.java
  22. 94 6
      backend/src/main/java/com/example/resourceplatform/service/impl/SysUserServiceImpl.java
  23. 2 1
      backend/src/main/java/com/example/resourceplatform/task/ResourceExpireTask.java
  24. 22 17
      backend/src/main/resources/application.yml
  25. 29 0
      backend/src/main/resources/mapper/ResourceMapper.xml
  26. 13 0
      frontend/src/utils/status.js
  27. 8 5
      frontend/src/views/CostStat.vue
  28. 2 1
      frontend/src/views/Dashboard.vue
  29. 2 1
      frontend/src/views/project/ProjectDetail.vue
  30. 12 5
      frontend/src/views/project/ProjectList.vue
  31. 2 1
      frontend/src/views/resource/ResourceList.vue
  32. 92 0
      frontend/src/views/system/OperationLog.vue
  33. 1 1
      frontend/vite.config.js
  34. 1 1
      sql/schema.sql

+ 5 - 0
backend/.gitignore

@@ -2,3 +2,8 @@ target/
 *.class
 .idea/
 *.iml
+application-dev.yml
+target/
+*.class
+.idea/
+*.iml

+ 22 - 0
backend/src/main/java/com/example/resourceplatform/common/CostCycle.java

@@ -0,0 +1,22 @@
+package com.example.resourceplatform.common;
+
+import lombok.Getter;
+
+/**
+ * 计费周期枚举
+ */
+@Getter
+public enum CostCycle {
+
+    MONTHLY(1, "月付"),
+    YEARLY(2, "年付"),
+    ONETIME(3, "一次性");
+
+    private final int code;
+    private final String text;
+
+    CostCycle(int code, String text) {
+        this.code = code;
+        this.text = text;
+    }
+}

+ 21 - 0
backend/src/main/java/com/example/resourceplatform/common/ProjectStatus.java

@@ -0,0 +1,21 @@
+package com.example.resourceplatform.common;
+
+import lombok.Getter;
+
+/**
+ * 项目状态枚举
+ */
+@Getter
+public enum ProjectStatus {
+
+    OFFLINE(0, "已下线"),
+    ACTIVE(1, "进行中");
+
+    private final int code;
+    private final String text;
+
+    ProjectStatus(int code, String text) {
+        this.code = code;
+        this.text = text;
+    }
+}

+ 30 - 0
backend/src/main/java/com/example/resourceplatform/common/ResourceStatus.java

@@ -0,0 +1,30 @@
+package com.example.resourceplatform.common;
+
+import lombok.Getter;
+
+/**
+ * 资源状态枚举
+ */
+@Getter
+public enum ResourceStatus {
+
+    DISABLED(0, "已停用"),
+    ACTIVE(1, "使用中"),
+    EXPIRING(2, "即将到期"),
+    EXPIRED(3, "已过期");
+
+    private final int code;
+    private final String text;
+
+    ResourceStatus(int code, String text) {
+        this.code = code;
+        this.text = text;
+    }
+
+    public static String getTextByCode(int code) {
+        for (ResourceStatus s : values()) {
+            if (s.code == code) return s.text;
+        }
+        return "";
+    }
+}

+ 21 - 0
backend/src/main/java/com/example/resourceplatform/common/UserRole.java

@@ -0,0 +1,21 @@
+package com.example.resourceplatform.common;
+
+import lombok.Getter;
+
+/**
+ * 用户角色枚举
+ */
+@Getter
+public enum UserRole {
+
+    ADMIN(1, "管理员"),
+    USER(2, "普通用户");
+
+    private final int code;
+    private final String text;
+
+    UserRole(int code, String text) {
+        this.code = code;
+        this.text = text;
+    }
+}

+ 15 - 1
backend/src/main/java/com/example/resourceplatform/config/SecurityConfig.java

@@ -2,6 +2,7 @@ package com.example.resourceplatform.config;
 
 import com.example.resourceplatform.security.JwtAuthenticationFilter;
 import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.authentication.AuthenticationManager;
@@ -17,6 +18,7 @@ import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.cors.CorsConfigurationSource;
 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 
+import java.util.Arrays;
 import java.util.List;
 
 @Configuration
@@ -26,6 +28,9 @@ public class SecurityConfig {
 
     private final JwtAuthenticationFilter jwtAuthenticationFilter;
 
+    @Value("${cors.allowed-origins:http://localhost:5173}")
+    private String allowedOrigins;
+
     @Bean
     public PasswordEncoder passwordEncoder() {
         return new BCryptPasswordEncoder();
@@ -49,6 +54,9 @@ public class SecurityConfig {
                         .requestMatchers("/user/enabled-list").authenticated()
                         // 用户管理仅管理员可操作
                         .requestMatchers("/user/**").hasRole("ADMIN")
+                        // 字典管理写操作仅管理员可操作
+                        .requestMatchers(org.springframework.http.HttpMethod.POST, "/resource-type/**", "/provider/**").hasRole("ADMIN")
+                        .requestMatchers(org.springframework.http.HttpMethod.DELETE, "/resource-type/**", "/provider/**").hasRole("ADMIN")
                         // 其余接口登录即可访问
                         .anyRequest().authenticated()
                 )
@@ -60,7 +68,12 @@ public class SecurityConfig {
     @Bean
     public CorsConfigurationSource corsConfigurationSource() {
         CorsConfiguration config = new CorsConfiguration();
-        config.setAllowedOriginPatterns(List.of("*"));
+        // 从配置文件读取允许的来源,多个来源用逗号分隔
+        List<String> origins = Arrays.stream(allowedOrigins.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .toList();
+        config.setAllowedOriginPatterns(origins);
         config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
         config.setAllowedHeaders(List.of("*"));
         config.setAllowCredentials(true);
@@ -70,3 +83,4 @@ public class SecurityConfig {
         return source;
     }
 }
+

+ 17 - 4
backend/src/main/java/com/example/resourceplatform/controller/OperationLogController.java

@@ -6,11 +6,14 @@ import com.example.resourceplatform.common.Result;
 import com.example.resourceplatform.entity.OperationLog;
 import com.example.resourceplatform.service.OperationLogService;
 import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.time.LocalDateTime;
+
 @RestController
 @RequestMapping("/operation-log")
 @RequiredArgsConstructor
@@ -21,10 +24,20 @@ public class OperationLogController {
     @GetMapping("/page")
     public Result<Page<OperationLog>> page(
             @RequestParam(defaultValue = "1") int pageNum,
-            @RequestParam(defaultValue = "10") int pageSize) {
-        Page<OperationLog> page = operationLogService.page(
-                new Page<>(pageNum, pageSize),
-                new LambdaQueryWrapper<OperationLog>().orderByDesc(OperationLog::getCreateTime));
+            @RequestParam(defaultValue = "10") int pageSize,
+            @RequestParam(required = false) String module,
+            @RequestParam(required = false) String action,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<OperationLog>()
+                .like(module != null && !module.isBlank(), OperationLog::getModule, module)
+                .like(action != null && !action.isBlank(), OperationLog::getAction, action)
+                .eq(userId != null, OperationLog::getUserId, userId)
+                .ge(startTime != null, OperationLog::getCreateTime, startTime)
+                .le(endTime != null, OperationLog::getCreateTime, endTime)
+                .orderByDesc(OperationLog::getCreateTime);
+        Page<OperationLog> page = operationLogService.page(new Page<>(pageNum, pageSize), wrapper);
         return Result.success(page);
     }
 }

+ 10 - 0
backend/src/main/java/com/example/resourceplatform/controller/ProviderController.java

@@ -1,7 +1,11 @@
 package com.example.resourceplatform.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.example.resourceplatform.common.BusinessException;
 import com.example.resourceplatform.common.Result;
 import com.example.resourceplatform.entity.Provider;
+import com.example.resourceplatform.entity.Resource;
+import com.example.resourceplatform.mapper.ResourceMapper;
 import com.example.resourceplatform.service.ProviderService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
@@ -14,6 +18,7 @@ import java.util.List;
 public class ProviderController {
 
     private final ProviderService providerService;
+    private final ResourceMapper resourceMapper;
 
     @GetMapping("/list")
     public Result<List<Provider>> list() {
@@ -28,6 +33,11 @@ public class ProviderController {
 
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
+        long count = resourceMapper.selectCount(
+                new LambdaQueryWrapper<Resource>().eq(Resource::getProviderId, id));
+        if (count > 0) {
+            throw new BusinessException("该服务商下还有 " + count + " 个资源,无法删除");
+        }
         providerService.removeById(id);
         return Result.success();
     }

+ 10 - 0
backend/src/main/java/com/example/resourceplatform/controller/ResourceTypeController.java

@@ -1,7 +1,11 @@
 package com.example.resourceplatform.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.example.resourceplatform.common.BusinessException;
 import com.example.resourceplatform.common.Result;
+import com.example.resourceplatform.entity.Resource;
 import com.example.resourceplatform.entity.ResourceType;
+import com.example.resourceplatform.mapper.ResourceMapper;
 import com.example.resourceplatform.service.ResourceTypeService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
@@ -14,6 +18,7 @@ import java.util.List;
 public class ResourceTypeController {
 
     private final ResourceTypeService resourceTypeService;
+    private final ResourceMapper resourceMapper;
 
     @GetMapping("/list")
     public Result<List<ResourceType>> list() {
@@ -28,6 +33,11 @@ public class ResourceTypeController {
 
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
+        long count = resourceMapper.selectCount(
+                new LambdaQueryWrapper<Resource>().eq(Resource::getTypeId, id));
+        if (count > 0) {
+            throw new BusinessException("该类型下还有 " + count + " 个资源,无法删除");
+        }
         resourceTypeService.removeById(id);
         return Result.success();
     }

+ 5 - 5
backend/src/main/java/com/example/resourceplatform/controller/UserController.java

@@ -2,7 +2,7 @@ package com.example.resourceplatform.controller;
 
 import com.example.resourceplatform.common.Result;
 import com.example.resourceplatform.dto.UserDTO;
-import com.example.resourceplatform.entity.SysUser;
+import com.example.resourceplatform.dto.UserVO;
 import com.example.resourceplatform.service.SysUserService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -21,14 +21,14 @@ public class UserController {
     private final SysUserService sysUserService;
 
     @GetMapping("/list")
-    public Result<List<SysUser>> list() {
-        return Result.success(sysUserService.list());
+    public Result<List<UserVO>> list() {
+        return Result.success(sysUserService.listAllVO());
     }
 
     /** 启用状态的用户列表,供前端"负责人"下拉选择使用,不要求管理员权限,所以单独放在一个公开路径 */
     @GetMapping("/enabled-list")
-    public Result<List<SysUser>> enabledList() {
-        return Result.success(sysUserService.listAllEnabled());
+    public Result<List<UserVO>> enabledList() {
+        return Result.success(sysUserService.listAllEnabledVO());
     }
 
     @PostMapping

+ 2 - 0
backend/src/main/java/com/example/resourceplatform/dto/PasswordUpdateDTO.java

@@ -1,6 +1,7 @@
 package com.example.resourceplatform.dto;
 
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 @Data
@@ -10,5 +11,6 @@ public class PasswordUpdateDTO {
     private String oldPassword;
 
     @NotBlank(message = "新密码不能为空")
+    @Size(min = 6, message = "密码长度不能少于6位")
     private String newPassword;
 }

+ 2 - 0
backend/src/main/java/com/example/resourceplatform/dto/UserDTO.java

@@ -2,6 +2,7 @@ package com.example.resourceplatform.dto;
 
 import jakarta.validation.constraints.Email;
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 @Data
@@ -13,6 +14,7 @@ public class UserDTO {
     private String username;
 
     /** 新增时必填,编辑时为空表示不修改密码 */
+    @Size(min = 6, message = "密码长度不能少于6位")
     private String password;
 
     @NotBlank(message = "姓名不能为空")

+ 24 - 0
backend/src/main/java/com/example/resourceplatform/dto/UserVO.java

@@ -0,0 +1,24 @@
+package com.example.resourceplatform.dto;
+
+import lombok.Data;
+
+/**
+ * 用户视图对象,排除密码等敏感字段
+ */
+@Data
+public class UserVO {
+
+    private Long id;
+
+    private String username;
+
+    private String realName;
+
+    private String email;
+
+    /** 角色: 1-管理员 2-普通用户 */
+    private Integer role;
+
+    /** 状态: 1-启用 0-禁用 */
+    private Integer status;
+}

+ 9 - 0
backend/src/main/java/com/example/resourceplatform/mapper/ResourceMapper.java

@@ -1,9 +1,18 @@
 package com.example.resourceplatform.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.example.resourceplatform.dto.CostStatVO;
 import com.example.resourceplatform.entity.Resource;
 import org.apache.ibatis.annotations.Mapper;
 
+import java.util.List;
+
 @Mapper
 public interface ResourceMapper extends BaseMapper<Resource> {
+
+    /** 按项目维度统计费用(JOIN project_resource + project) */
+    List<CostStatVO> costStatByProject();
+
+    /** 按资源类型维度统计费用(JOIN resource_type) */
+    List<CostStatVO> costStatByType();
 }

+ 15 - 7
backend/src/main/java/com/example/resourceplatform/security/JwtAuthenticationFilter.java

@@ -6,9 +6,11 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
 import org.springframework.stereotype.Component;
 import org.springframework.web.filter.OncePerRequestFilter;
@@ -19,6 +21,7 @@ import java.io.IOException;
  * 每个请求进来时,从Header中取出token,校验通过后把用户信息塞进SecurityContext,
  * 后续Controller/Service可以通过SecurityUtils拿到当前登录人
  */
+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
@@ -34,14 +37,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         if (header != null && header.startsWith("Bearer ")) {
             String token = header.substring(7);
             if (jwtUtil.isValid(token)) {
-                Claims claims = jwtUtil.parseToken(token);
-                String username = claims.getSubject();
-                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
+                try {
+                    Claims claims = jwtUtil.parseToken(token);
+                    String username = claims.getSubject();
+                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
 
-                UsernamePasswordAuthenticationToken authentication =
-                        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
-                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
-                SecurityContextHolder.getContext().setAuthentication(authentication);
+                    UsernamePasswordAuthenticationToken authentication =
+                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+                    SecurityContextHolder.getContext().setAuthentication(authentication);
+                } catch (UsernameNotFoundException e) {
+                    // 用户已被删除,Token 虽然签名有效但用户已不存在,跳过认证设置
+                    log.debug("Token 有效但用户不存在: {}", e.getMessage());
+                }
             }
         }
         filterChain.doFilter(request, response);

+ 2 - 1
backend/src/main/java/com/example/resourceplatform/security/LoginUser.java

@@ -1,5 +1,6 @@
 package com.example.resourceplatform.security;
 
+import com.example.resourceplatform.common.UserRole;
 import com.example.resourceplatform.entity.SysUser;
 import lombok.Getter;
 import org.springframework.security.core.GrantedAuthority;
@@ -24,7 +25,7 @@ public class LoginUser implements UserDetails {
     /** 角色: 1-管理员 ROLE_ADMIN  2-普通用户 ROLE_USER */
     @Override
     public Collection<? extends GrantedAuthority> getAuthorities() {
-        String role = sysUser.getRole() != null && sysUser.getRole() == 1 ? "ROLE_ADMIN" : "ROLE_USER";
+        String role = sysUser.getRole() != null && sysUser.getRole() == UserRole.ADMIN.getCode() ? "ROLE_ADMIN" : "ROLE_USER";
         return List.of(new SimpleGrantedAuthority(role));
     }
 

+ 6 - 1
backend/src/main/java/com/example/resourceplatform/service/SysUserService.java

@@ -5,6 +5,7 @@ import com.example.resourceplatform.dto.LoginDTO;
 import com.example.resourceplatform.dto.LoginVO;
 import com.example.resourceplatform.dto.PasswordUpdateDTO;
 import com.example.resourceplatform.dto.UserDTO;
+import com.example.resourceplatform.dto.UserVO;
 import com.example.resourceplatform.entity.SysUser;
 
 import java.util.List;
@@ -20,7 +21,11 @@ public interface SysUserService extends IService<SysUser> {
 
     void deleteUser(Long id);
 
-    List<SysUser> listAllEnabled();
+    /** 全部用户 VO 列表(不含密码),管理员用户管理页面使用 */
+    List<UserVO> listAllVO();
+
+    /** 启用状态用户 VO 列表(不含密码),负责人下拉选择使用 */
+    List<UserVO> listAllEnabledVO();
 
     /** 批量获取id->姓名 映射,供资源/项目列表拼装负责人姓名时使用 */
     Map<Long, String> getUserNameMap(List<Long> userIds);

+ 4 - 3
backend/src/main/java/com/example/resourceplatform/service/impl/DashboardServiceImpl.java

@@ -1,6 +1,7 @@
 package com.example.resourceplatform.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.example.resourceplatform.common.ResourceStatus;
 import com.example.resourceplatform.dto.DashboardVO;
 import com.example.resourceplatform.dto.ResourceVO;
 import com.example.resourceplatform.entity.Resource;
@@ -35,14 +36,14 @@ public class DashboardServiceImpl implements DashboardService {
         vo.setProjectCount(projectMapper.selectCount(null));
         vo.setResourceCount(resourceMapper.selectCount(null));
         vo.setExpiringCount(resourceMapper.selectCount(
-                new LambdaQueryWrapper<Resource>().eq(Resource::getStatus, 2)));
+                new LambdaQueryWrapper<Resource>().eq(Resource::getStatus, ResourceStatus.EXPIRING.getCode())));
         vo.setExpiredCount(resourceMapper.selectCount(
-                new LambdaQueryWrapper<Resource>().eq(Resource::getStatus, 3)));
+                new LambdaQueryWrapper<Resource>().eq(Resource::getStatus, ResourceStatus.EXPIRED.getCode())));
 
         // 即将到期 + 已过期的资源,按到期日期升序,取前N条用于首页高亮展示
         List<Resource> attention = resourceMapper.selectList(
                 new LambdaQueryWrapper<Resource>()
-                        .in(Resource::getStatus, List.of(2, 3))
+                        .in(Resource::getStatus, List.of(ResourceStatus.EXPIRING.getCode(), ResourceStatus.EXPIRED.getCode()))
                         .orderByAsc(Resource::getExpireDate));
         List<Long> attentionIds = attention.stream().limit(ATTENTION_LIMIT).map(Resource::getId).toList();
         List<ResourceVO> attentionVOs = resourceService.listByIds(attentionIds);

+ 5 - 1
backend/src/main/java/com/example/resourceplatform/service/impl/OperationLogServiceImpl.java

@@ -12,10 +12,14 @@ import java.time.LocalDateTime;
 @Service
 public class OperationLogServiceImpl extends ServiceImpl<OperationLogMapper, OperationLog> implements OperationLogService {
 
+    /** 系统操作用户ID,用于定时任务等非HTTP请求上下文场景 */
+    private static final Long SYSTEM_USER_ID = 0L;
+
     @Override
     public void record(String module, String action, Long targetId, String content) {
         OperationLog log = new OperationLog();
-        log.setUserId(SecurityUtils.getCurrentUserId());
+        Long userId = SecurityUtils.getCurrentUserId();
+        log.setUserId(userId != null ? userId : SYSTEM_USER_ID);
         log.setModule(module);
         log.setAction(action);
         log.setTargetId(targetId);

+ 3 - 2
backend/src/main/java/com/example/resourceplatform/service/impl/ProjectServiceImpl.java

@@ -3,6 +3,7 @@ package com.example.resourceplatform.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.example.resourceplatform.common.BusinessException;
+import com.example.resourceplatform.common.ProjectStatus;
 import com.example.resourceplatform.dto.ProjectDTO;
 import com.example.resourceplatform.dto.ProjectQuery;
 import com.example.resourceplatform.dto.ProjectVO;
@@ -107,7 +108,7 @@ public class ProjectServiceImpl implements ProjectService {
         }
         project.setName(dto.getName());
         project.setOwnerId(dto.getOwnerId());
-        project.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
+        project.setStatus(dto.getStatus() != null ? dto.getStatus() : ProjectStatus.ACTIVE.getCode());
         project.setRemark(dto.getRemark());
 
         if (isNew) {
@@ -130,6 +131,6 @@ public class ProjectServiceImpl implements ProjectService {
 
     @Override
     public List<Project> listActive() {
-        return projectMapper.selectList(new LambdaQueryWrapper<Project>().eq(Project::getStatus, 1));
+        return projectMapper.selectList(new LambdaQueryWrapper<Project>().eq(Project::getStatus, ProjectStatus.ACTIVE.getCode()));
     }
 }

+ 14 - 51
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceServiceImpl.java

@@ -3,6 +3,7 @@ package com.example.resourceplatform.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.example.resourceplatform.common.BusinessException;
+import com.example.resourceplatform.common.ResourceStatus;
 import com.example.resourceplatform.dto.*;
 import com.example.resourceplatform.entity.*;
 import com.example.resourceplatform.mapper.*;
@@ -162,13 +163,7 @@ public class ResourceServiceImpl implements ResourceService {
 
     private String statusText(Integer status) {
         if (status == null) return "";
-        return switch (status) {
-            case 1 -> "使用中";
-            case 2 -> "即将到期";
-            case 3 -> "已过期";
-            case 0 -> "已停用";
-            default -> "";
-        };
+        return ResourceStatus.getTextByCode(status);
     }
 
     @Override
@@ -186,12 +181,17 @@ public class ResourceServiceImpl implements ResourceService {
         resource.setAccountInfo(dto.getAccountInfo());
         resource.setStartDate(dto.getStartDate());
         resource.setExpireDate(dto.getExpireDate());
+        // 日期合理性校验:到期日期不能早于开通日期
+        if (dto.getExpireDate() != null && dto.getStartDate() != null
+                && dto.getExpireDate().isBefore(dto.getStartDate())) {
+            throw new BusinessException("到期日期不能早于开通日期");
+        }
         resource.setCost(dto.getCost() != null ? dto.getCost() : BigDecimal.ZERO);
         resource.setCostCycle(dto.getCostCycle());
         resource.setOwnerId(dto.getOwnerId());
         resource.setRemark(dto.getRemark());
         if (isNew) {
-            resource.setStatus(1);
+            resource.setStatus(ResourceStatus.ACTIVE.getCode());
             resourceMapper.insert(resource);
         } else {
             resourceMapper.updateById(resource);
@@ -249,56 +249,19 @@ public class ResourceServiceImpl implements ResourceService {
 
     @Override
     public List<CostStatVO> costStatByProject() {
-        List<Resource> allResources = resourceMapper.selectList(null);
-        Map<Long, BigDecimal> costMap = allResources.stream()
-                .collect(Collectors.toMap(Resource::getId, r -> r.getCost() != null ? r.getCost() : BigDecimal.ZERO));
-
-        List<ProjectResource> relations = projectResourceMapper.selectList(null);
-        Map<Long, List<Long>> projectResourceIdsMap = relations.stream()
-                .collect(Collectors.groupingBy(ProjectResource::getProjectId,
-                        Collectors.mapping(ProjectResource::getResourceId, Collectors.toList())));
-
-        Map<Long, String> projectNameMap = projectMapper.selectList(null).stream()
-                .collect(Collectors.toMap(Project::getId, Project::getName));
-
-        return projectResourceIdsMap.entrySet().stream().map(entry -> {
-            CostStatVO vo = new CostStatVO();
-            vo.setDimensionName(projectNameMap.getOrDefault(entry.getKey(), "未知项目"));
-            vo.setResourceCount(entry.getValue().size());
-            BigDecimal total = entry.getValue().stream()
-                    .map(rid -> costMap.getOrDefault(rid, BigDecimal.ZERO))
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            vo.setTotalCost(total);
-            return vo;
-        }).sorted(Comparator.comparing(CostStatVO::getTotalCost).reversed()).toList();
+        return resourceMapper.costStatByProject();
     }
 
     @Override
     public List<CostStatVO> costStatByType() {
-        List<Resource> allResources = resourceMapper.selectList(null);
-        Map<Long, String> typeNameMap = resourceTypeMapper.selectList(null).stream()
-                .collect(Collectors.toMap(ResourceType::getId, ResourceType::getName));
-
-        Map<String, List<Resource>> grouped = allResources.stream()
-                .collect(Collectors.groupingBy(r -> typeNameMap.getOrDefault(r.getTypeId(), "未知类型")));
-
-        return grouped.entrySet().stream().map(entry -> {
-            CostStatVO vo = new CostStatVO();
-            vo.setDimensionName(entry.getKey());
-            vo.setResourceCount(entry.getValue().size());
-            BigDecimal total = entry.getValue().stream()
-                    .map(r -> r.getCost() != null ? r.getCost() : BigDecimal.ZERO)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            vo.setTotalCost(total);
-            return vo;
-        }).sorted(Comparator.comparing(CostStatVO::getTotalCost).reversed()).toList();
+        return resourceMapper.costStatByType();
     }
 
     @Override
     public void refreshExpireStatus() {
         LocalDate today = LocalDate.now();
         List<Resource> resources = resourceMapper.selectList(
-                new LambdaQueryWrapper<Resource>().ne(Resource::getStatus, 0)); // 已停用的不自动改状态
+                new LambdaQueryWrapper<Resource>().ne(Resource::getStatus, ResourceStatus.DISABLED.getCode())); // 已停用的不自动改状态
 
         for (Resource r : resources) {
             if (r.getExpireDate() == null) {
@@ -306,11 +269,11 @@ public class ResourceServiceImpl implements ResourceService {
             }
             int newStatus;
             if (r.getExpireDate().isBefore(today)) {
-                newStatus = 3; // 已过期
+                newStatus = ResourceStatus.EXPIRED.getCode();
             } else if (!r.getExpireDate().isAfter(today.plusDays(remindDaysFar))) {
-                newStatus = 2; // 即将到期
+                newStatus = ResourceStatus.EXPIRING.getCode();
             } else {
-                newStatus = 1; // 使用中
+                newStatus = ResourceStatus.ACTIVE.getCode();
             }
             if (r.getStatus() == null || r.getStatus() != newStatus) {
                 r.setStatus(newStatus);

+ 94 - 6
backend/src/main/java/com/example/resourceplatform/service/impl/SysUserServiceImpl.java

@@ -3,16 +3,24 @@ package com.example.resourceplatform.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.example.resourceplatform.common.BusinessException;
+import com.example.resourceplatform.common.UserRole;
 import com.example.resourceplatform.dto.LoginDTO;
 import com.example.resourceplatform.dto.LoginVO;
 import com.example.resourceplatform.dto.PasswordUpdateDTO;
 import com.example.resourceplatform.dto.UserDTO;
+import com.example.resourceplatform.dto.UserVO;
+import com.example.resourceplatform.entity.Project;
+import com.example.resourceplatform.entity.Resource;
 import com.example.resourceplatform.entity.SysUser;
+import com.example.resourceplatform.mapper.ProjectMapper;
+import com.example.resourceplatform.mapper.ResourceMapper;
 import com.example.resourceplatform.mapper.SysUserMapper;
 import com.example.resourceplatform.security.JwtUtil;
 import com.example.resourceplatform.service.SysUserService;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.stereotype.Service;
@@ -21,8 +29,10 @@ import org.springframework.util.CollectionUtils;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
@@ -30,12 +40,49 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
     private final AuthenticationManager authenticationManager;
     private final JwtUtil jwtUtil;
     private final PasswordEncoder passwordEncoder;
+    private final ResourceMapper resourceMapper;
+    private final ProjectMapper projectMapper;
+
+    /** 登录失败计数 key=用户名 */
+    private final ConcurrentHashMap<String, int[]> loginFailCount = new ConcurrentHashMap<>();
+    /** 登录锁定截止时间 key=用户名 */
+    private final ConcurrentHashMap<String, Long> loginLockUntil = new ConcurrentHashMap<>();
+
+    private static final int MAX_FAIL_COUNT = 5;
+    private static final long LOCK_DURATION_MS = 15 * 60 * 1000L; // 15分钟
 
     @Override
     public LoginVO login(LoginDTO dto) {
-        // 交给Spring Security校验账号密码,失败会抛BadCredentialsException,由全局异常处理器统一返回
-        authenticationManager.authenticate(
-                new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
+        // 检查是否被锁定
+        Long lockUntil = loginLockUntil.get(dto.getUsername());
+        if (lockUntil != null && System.currentTimeMillis() < lockUntil) {
+            long remainSeconds = (lockUntil - System.currentTimeMillis()) / 1000;
+            throw new BusinessException(403, "账号已被临时锁定,请 " + remainSeconds + " 秒后再试");
+        }
+        // 锁定已过期,清理
+        if (lockUntil != null) {
+            loginLockUntil.remove(dto.getUsername());
+            loginFailCount.remove(dto.getUsername());
+        }
+
+        try {
+            authenticationManager.authenticate(
+                    new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
+        } catch (BadCredentialsException e) {
+            // 登录失败计数
+            int[] count = loginFailCount.computeIfAbsent(dto.getUsername(), k -> new int[]{0});
+            count[0]++;
+            if (count[0] >= MAX_FAIL_COUNT) {
+                loginLockUntil.put(dto.getUsername(), System.currentTimeMillis() + LOCK_DURATION_MS);
+                loginFailCount.remove(dto.getUsername());
+                throw new BusinessException(403, "连续登录失败 " + MAX_FAIL_COUNT + " 次,账号已被锁定 15 分钟");
+            }
+            throw new BusinessException(401, "账号或密码错误(已失败 " + count[0] + " 次," + MAX_FAIL_COUNT + " 次后锁定)");
+        }
+
+        // 登录成功,清除失败计数
+        loginFailCount.remove(dto.getUsername());
+        loginLockUntil.remove(dto.getUsername());
 
         SysUser user = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, dto.getUsername()));
 
@@ -56,6 +103,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
         if (!passwordEncoder.matches(dto.getOldPassword(), user.getPassword())) {
             throw new BusinessException("原密码不正确");
         }
+        validatePasswordComplexity(dto.getNewPassword());
         user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
         this.updateById(user);
     }
@@ -69,10 +117,11 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
         user.setUsername(dto.getUsername());
         user.setRealName(dto.getRealName());
         user.setEmail(dto.getEmail());
-        user.setRole(dto.getRole() != null ? dto.getRole() : 2);
+        user.setRole(dto.getRole() != null ? dto.getRole() : UserRole.USER.getCode());
         user.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
         // 新增必须传密码;编辑时如果传了密码就更新,不传则保留原密码
         if (dto.getPassword() != null && !dto.getPassword().isBlank()) {
+            validatePasswordComplexity(dto.getPassword());
             user.setPassword(passwordEncoder.encode(dto.getPassword()));
         } else if (dto.getId() == null) {
             throw new BusinessException("新增用户必须设置初始密码");
@@ -85,12 +134,27 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
 
     @Override
     public void deleteUser(Long id) {
+        // 检查是否为资源或项目负责人
+        long resourceCount = resourceMapper.selectCount(
+                new LambdaQueryWrapper<Resource>().eq(Resource::getOwnerId, id));
+        long projectCount = projectMapper.selectCount(
+                new LambdaQueryWrapper<Project>().eq(Project::getOwnerId, id));
+        if (resourceCount > 0 || projectCount > 0) {
+            throw new BusinessException("该用户是 " + resourceCount + " 个资源和 " + projectCount
+                    + " 个项目的负责人,请先转移负责人后再删除");
+        }
         this.removeById(id);
     }
 
     @Override
-    public List<SysUser> listAllEnabled() {
-        return this.list(new LambdaQueryWrapper<SysUser>().eq(SysUser::getStatus, 1));
+    public List<UserVO> listAllVO() {
+        return this.list().stream().map(this::toVO).toList();
+    }
+
+    @Override
+    public List<UserVO> listAllEnabledVO() {
+        return this.list(new LambdaQueryWrapper<SysUser>().eq(SysUser::getStatus, 1))
+                .stream().map(this::toVO).toList();
     }
 
     @Override
@@ -101,4 +165,28 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
         return this.listByIds(userIds).stream()
                 .collect(Collectors.toMap(SysUser::getId, SysUser::getRealName));
     }
+
+    private UserVO toVO(SysUser user) {
+        UserVO vo = new UserVO();
+        vo.setId(user.getId());
+        vo.setUsername(user.getUsername());
+        vo.setRealName(user.getRealName());
+        vo.setEmail(user.getEmail());
+        vo.setRole(user.getRole());
+        vo.setStatus(user.getStatus());
+        return vo;
+    }
+
+    /** 密码复杂度校验:至少6位,必须同时包含字母和数字 */
+    private void validatePasswordComplexity(String password) {
+        if (password == null || password.length() < 6) {
+            throw new BusinessException("密码长度不能少于6位");
+        }
+        boolean hasLetter = password.chars().anyMatch(Character::isLetter);
+        boolean hasDigit = password.chars().anyMatch(Character::isDigit);
+        if (!hasLetter || !hasDigit) {
+            throw new BusinessException("密码必须同时包含字母和数字");
+        }
+    }
 }
+

+ 2 - 1
backend/src/main/java/com/example/resourceplatform/task/ResourceExpireTask.java

@@ -1,6 +1,7 @@
 package com.example.resourceplatform.task;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.example.resourceplatform.common.ResourceStatus;
 import com.example.resourceplatform.entity.ExpireNotifyLog;
 import com.example.resourceplatform.entity.Resource;
 import com.example.resourceplatform.entity.SysUser;
@@ -60,7 +61,7 @@ public class ResourceExpireTask {
         List<Resource> resources = resourceMapper.selectList(
                 new LambdaQueryWrapper<Resource>()
                         .isNotNull(Resource::getExpireDate)
-                        .ne(Resource::getStatus, 0)); // 已停用的资源不提醒
+                        .ne(Resource::getStatus, ResourceStatus.DISABLED.getCode())); // 已停用的资源不提醒
 
         int sentCount = 0;
         for (Resource resource : resources) {

+ 22 - 17
backend/src/main/resources/application.yml

@@ -4,18 +4,19 @@ server:
     context-path: /api
 
 spring:
+  profiles:
+    active: dev
   datasource:
-#  外网地址    url: jdbc:mysql://rm-uf64611sxm4qe438g8o.mysql.rds.aliyuncs.com:33450/resource_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
-    url: jdbc:mysql://rm-uf64611sxm4qe438g.mysql.rds.aliyuncs.com:3306/resource_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
-    username: root
-    password: Cmt123456
+    url: ${DB_URL:jdbc:mysql://localhost:3306/resource_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false}
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
     driver-class-name: com.mysql.cj.jdbc.Driver
 
   mail:
-    host: smtp.example.com
-    port: 465
-    username: notificaiton@bosind.com
-    password: Bosind2023   # 建议改用环境变量,不要把真实密码提交到代码库
+    host: ${MAIL_HOST:smtp.example.com}
+    port: ${MAIL_PORT:465}
+    username: ${MAIL_USERNAME:}
+    password: ${MAIL_PASSWORD:}
     properties:
       mail:
         smtp:
@@ -24,9 +25,9 @@ spring:
             enable: true
 
 mybatis-plus:
+  mapper-locations: classpath:mapper/*.xml
   configuration:
     map-underscore-to-camel-case: true
-    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
   global-config:
     db-config:
       logic-delete-field: deleted
@@ -35,17 +36,21 @@ mybatis-plus:
 
 # JWT配置
 jwt:
-  secret: HKG2fedqr1BmynJl29a0IBV9hqlQK0j92j3ktQ531oj3GgmdxUOA/3NefoPSnv1k1wkC+eALeHmrjli6+P7JCw==
-  expire-seconds: 86400   # token有效期,单位秒,默认24小时
+  secret: ${JWT_SECRET:please-change-this-secret-key-in-production-environment-must-be-at-least-256-bits}
+  expire-seconds: ${JWT_EXPIRE:86400}
 
 # 到期提醒配置
 notify:
-  mail-from: your-account@example.com
-  mail-from-name: 资源登记平台
-  remind-days-far: 30   # 提前30天提醒
-  remind-days-near: 7   # 提前7天提醒
-  cron: "0 0 8 * * ?"   # 每天8点执行扫描
+  mail-from: ${NOTIFY_MAIL_FROM:notify@example.com}
+  mail-from-name: ${NOTIFY_MAIL_NAME:资源登记平台}
+  remind-days-far: 30
+  remind-days-near: 7
+  cron: "0 0 8 * * ?"
+
+# CORS配置
+cors:
+  allowed-origins: ${CORS_ORIGINS:http://localhost:5173}
 
 logging:
   level:
-    com.example.resourceplatform: debug
+    com.example.resourceplatform: info

+ 29 - 0
backend/src/main/resources/mapper/ResourceMapper.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.example.resourceplatform.mapper.ResourceMapper">
+
+    <!-- 按项目维度统计费用 -->
+    <select id="costStatByProject" resultType="com.example.resourceplatform.dto.CostStatVO">
+        SELECT p.name          AS dimension_name,
+               COUNT(r.id)     AS resource_count,
+               IFNULL(SUM(r.cost), 0) AS total_cost
+        FROM project_resource pr
+                 JOIN project p ON p.id = pr.project_id AND p.deleted = 0
+                 LEFT JOIN resource r ON r.id = pr.resource_id AND r.deleted = 0
+        GROUP BY p.id, p.name
+        ORDER BY total_cost DESC
+    </select>
+
+    <!-- 按资源类型维度统计费用 -->
+    <select id="costStatByType" resultType="com.example.resourceplatform.dto.CostStatVO">
+        SELECT IFNULL(rt.name, '未知类型') AS dimension_name,
+               COUNT(r.id)                AS resource_count,
+               IFNULL(SUM(r.cost), 0)    AS total_cost
+        FROM resource r
+                 LEFT JOIN resource_type rt ON rt.id = r.type_id AND rt.deleted = 0
+        WHERE r.deleted = 0
+        GROUP BY r.type_id, rt.name
+        ORDER BY total_cost DESC
+    </select>
+
+</mapper>

+ 13 - 0
frontend/src/utils/status.js

@@ -0,0 +1,13 @@
+/**
+ * 资源状态对应的 CSS class 映射
+ * @param {number} status - 资源状态码: 1-使用中 2-即将到期 3-已过期 0-已停用
+ * @returns {string} CSS class 名称
+ */
+export function resourceStatusClass(status) {
+  return {
+    1: 'status-badge--success',
+    2: 'status-badge--warning',
+    3: 'status-badge--danger',
+    0: 'status-badge--disabled'
+  }[status] || ''
+}

+ 8 - 5
frontend/src/views/CostStat.vue

@@ -41,11 +41,14 @@ const loadingType = ref(false)
 onMounted(async () => {
   loadingProject.value = true
   loadingType.value = true
-  const [p, t] = await Promise.all([costStatByProjectApi(), costStatByTypeApi()])
-  byProject.value = p.data
-  byType.value = t.data
-  loadingProject.value = false
-  loadingType.value = false
+  try {
+    const [p, t] = await Promise.all([costStatByProjectApi(), costStatByTypeApi()])
+    byProject.value = p.data
+    byType.value = t.data
+  } finally {
+    loadingProject.value = false
+    loadingType.value = false
+  }
 })
 </script>
 

+ 2 - 1
frontend/src/views/Dashboard.vue

@@ -63,6 +63,7 @@
 <script setup>
 import { ref, onMounted, computed } from 'vue'
 import { dashboardApi } from '@/api/dashboard'
+import { resourceStatusClass } from '@/utils/status'
 
 const data = ref({})
 
@@ -76,7 +77,7 @@ function barWidth(count) {
 }
 
 function statusClass(status) {
-  return { 1: 'status-badge--success', 2: 'status-badge--warning', 3: 'status-badge--danger', 0: 'status-badge--disabled' }[status]
+  return resourceStatusClass(status)
 }
 
 onMounted(async () => {

+ 2 - 1
frontend/src/views/project/ProjectDetail.vue

@@ -63,6 +63,7 @@ import { useRoute } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { projectDetailApi } from '@/api/project'
 import { resourcePageApi, resourceRelateApi, resourceUnrelateApi } from '@/api/resource'
+import { resourceStatusClass } from '@/utils/status'
 
 const route = useRoute()
 const detail = reactive({})
@@ -77,7 +78,7 @@ const availableResources = computed(() => {
 })
 
 function statusClass(status) {
-  return { 1: 'status-badge--success', 2: 'status-badge--warning', 3: 'status-badge--danger', 0: 'status-badge--disabled' }[status]
+  return resourceStatusClass(status)
 }
 
 async function loadDetail() {

+ 12 - 5
frontend/src/views/project/ProjectList.vue

@@ -130,7 +130,10 @@ function handleSearch() { query.pageNum = 1; loadList() }
 function handleReset() { Object.assign(query, { pageNum: 1, pageSize: 10, name: '', status: null }); loadList() }
 
 function openCreate() { resetForm(); formVisible.value = true }
-function openEdit(row) { Object.assign(form, row); formVisible.value = true }
+function openEdit(row) {
+  Object.assign(form, { id: row.id, name: row.name, ownerId: row.ownerId, status: row.status, remark: row.remark })
+  formVisible.value = true
+}
 
 function goDetail(id) { router.push(`/project/${id}`) }
 
@@ -143,10 +146,14 @@ async function handleDelete(id) {
 async function handleSubmit() {
   const valid = await formRef.value.validate().catch(() => false)
   if (!valid) return
-  await projectSaveApi(form)
-  ElMessage.success('保存成功')
-  formVisible.value = false
-  loadList()
+  try {
+    await projectSaveApi(form)
+    ElMessage.success('保存成功')
+    formVisible.value = false
+    loadList()
+  } catch (e) {
+    // 错误已由全局拦截器处理
+  }
 }
 
 onMounted(async () => {

+ 2 - 1
frontend/src/views/resource/ResourceList.vue

@@ -94,6 +94,7 @@ import ResourceForm from './ResourceForm.vue'
 import { resourcePageApi, resourceDeleteApi, resourceExportApi } from '@/api/resource'
 import { resourceTypeListApi } from '@/api/dict'
 import { userEnabledListApi } from '@/api/user'
+import { resourceStatusClass } from '@/utils/status'
 
 const query = reactive({ pageNum: 1, pageSize: 10, name: '', typeId: null, ownerId: null, status: null })
 const list = ref([])
@@ -107,7 +108,7 @@ const formVisible = ref(false)
 const editingId = ref(null)
 
 function statusClass(status) {
-  return { 1: 'status-badge--success', 2: 'status-badge--warning', 3: 'status-badge--danger', 0: 'status-badge--disabled' }[status]
+  return resourceStatusClass(status)
 }
 
 async function loadList() {

+ 92 - 0
frontend/src/views/system/OperationLog.vue

@@ -1,6 +1,98 @@
 <template>
   <div class="page-container">
     <div class="page-card">
+      <el-form :model="query" inline class="filter-form">
+        <el-form-item label="模块">
+          <el-input v-model="query.module" placeholder="模块名称" clearable @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="操作">
+          <el-input v-model="query.action" placeholder="操作类型" clearable @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="datetimerange"
+            start-placeholder="开始时间"
+            end-placeholder="结束时间"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 360px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="list" v-loading="loading">
+        <el-table-column prop="createTime" label="时间" width="180" class-name="mono-num" />
+        <el-table-column prop="module" label="模块" width="100" />
+        <el-table-column prop="action" label="操作" width="100" />
+        <el-table-column prop="content" label="详情" min-width="240" show-overflow-tooltip />
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 16px; justify-content: flex-end"
+        v-model:current-page="query.pageNum"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        layout="total, prev, pager, next"
+        @current-change="loadList"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { operationLogPageApi } from '@/api/log'
+
+const query = reactive({ pageNum: 1, pageSize: 15, module: '', action: '' })
+const dateRange = ref(null)
+const list = ref([])
+const total = ref(0)
+const loading = ref(false)
+
+async function loadList() {
+  loading.value = true
+  try {
+    const params = { pageNum: query.pageNum, pageSize: query.pageSize }
+    if (query.module) params.module = query.module
+    if (query.action) params.action = query.action
+    if (dateRange.value && dateRange.value.length === 2) {
+      params.startTime = dateRange.value[0]
+      params.endTime = dateRange.value[1]
+    }
+    const res = await operationLogPageApi(params)
+    list.value = res.data.records
+    total.value = res.data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  query.pageNum = 1
+  loadList()
+}
+
+function handleReset() {
+  Object.assign(query, { pageNum: 1, pageSize: 15, module: '', action: '' })
+  dateRange.value = null
+  loadList()
+}
+
+onMounted(loadList)
+</script>
+
+<style scoped>
+.filter-form {
+  margin-bottom: 4px;
+}
+</style>
+<template>
+  <div class="page-container">
+    <div class="page-card">
       <el-table :data="list" v-loading="loading">
         <el-table-column prop="createTime" label="时间" width="180" class-name="mono-num" />
         <el-table-column prop="module" label="模块" width="100" />

+ 1 - 1
frontend/vite.config.js

@@ -11,7 +11,7 @@ export default defineConfig({
   },
   server: {
     port: 5173,
-    host: true, // 监听0.0.0.0,局域网内其他设备才能通过你的IP访问,不加这个默认只能本机访问
+    host: 'localhost',
     proxy: {
       '/api': {
         target: 'http://localhost:8080',

+ 1 - 1
sql/schema.sql

@@ -129,7 +129,7 @@ CREATE TABLE `expire_notify_log` (
 DROP TABLE IF EXISTS `operation_log`;
 CREATE TABLE `operation_log` (
   `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
-  `user_id`       BIGINT UNSIGNED NOT NULL COMMENT '操作人',
+  `user_id`       BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人(0=系统操作,定时任务等非 HTTP 场景)',
   `module`        VARCHAR(50)     NOT NULL COMMENT '模块: 资源/项目/用户/字典',
   `action`        VARCHAR(50)     NOT NULL COMMENT '操作: 新增/编辑/删除/关联变更',
   `target_id`     BIGINT UNSIGNED          DEFAULT NULL COMMENT '操作对象ID',