1
0

2 Commits b0831f7de3 ... ad31bc1b9b

Autor SHA1 Nachricht Datum
  C ad31bc1b9b refactor: 合并后端构建脚本为统一入口 vor 1 Monat
  C dc4b720453 test: 建立前后端最小验证路径 vor 1 Monat

+ 123 - 0
AGENTS.md

@@ -0,0 +1,123 @@
+# AGENTS.md
+
+## 项目概述
+
+团队内部云资源与服务套餐台账系统,前后端分离架构。
+
+## 项目结构
+
+```
+├── backend/          # Spring Boot 3 + MyBatis-Plus 后端服务
+├── frontend/         # Vue 3 + Vite 前端应用
+├── sql/              # 数据库 schema 与迁移脚本
+└── deploy/           # 部署配置(Nginx、systemd)
+```
+
+## 核心模块
+
+| 模块 | 职责 |
+|------|------|
+| resource | 资源管理(增删改查、到期刷新、邮件提醒) |
+| project | 项目管理(多项目关联) |
+| provider | 供应商管理 |
+| dashboard | 数据看板(工作台统计、费用分析) |
+| user | 用户管理(权限分级:管理员/普通用户、JWT 鉴权) |
+
+## 技术栈
+
+- **后端**: Java 17 · Spring Boot 3 · MyBatis-Plus · Spring Security (JWT) · MySQL 8+
+- **前端**: Vue 3 · Vite · Element Plus · Pinia
+- **数据库**: MySQL 8.0+
+
+## 构建与启动命令
+
+### 方式一:Maven(推荐,需 JDK 17 + Maven)
+
+```bash
+# 数据库初始化
+# 执行 sql/schema.sql
+
+# 后端(开发模式,热重载)
+cd backend
+mvn spring-boot:run
+
+# 或打包后运行
+mvn clean package -DskipTests
+java -jar target/resource-platform.jar
+```
+
+### 方式二:离线构建脚本(无 Maven 环境时使用)
+
+```bash
+cd backend
+
+# 构建(自动基于最新 jar 递增版本号)
+python build.py
+
+# 指定版本号
+python build.py --version 12
+
+# 启动(自动检测最新版本)
+bash start.sh
+
+# 启动指定版本
+bash start.sh 11
+```
+
+> **构建脚本说明:** `build.py` 是统一入口,合并了历史版本 build_v6~v11 的全部逻辑(javac 编译 + fat jar 重打包 + 资源注入 + 新增类补打)。`start.sh` 是统一启动脚本,支持 `PORT` 环境变量自定义端口。
+
+### 前端
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+## 测试与验证
+
+变更代码后应运行测试确认无回归:
+
+```bash
+# 前端单元测试(Vitest)——覆盖 API 层和路由守卫
+cd frontend
+npx vitest run          # 或 npm test
+
+# 后端单元测试(JUnit 5 + Mockito)——覆盖 ResourceServiceImpl 核心业务逻辑
+cd backend
+# 注意:需要 JAVA_HOME 指向 JDK 17,当前环境 JAVA_HOME 默认为 1.8,需临时切换
+$env:JAVA_HOME = "D:\Program Files\Java\jdk-17"; mvn test
+```
+
+**测试文件位置:**
+- 前端:`frontend/src/api/__tests__/`、`frontend/src/router/__tests__/`
+- 后端:`backend/src/test/java/com/example/resourceplatform/service/impl/ResourceServiceImplTest.java`
+
+**Agent 变更后可自动执行的验证命令:**
+```bash
+# 前端
+cd frontend && npx vitest run
+# 后端(Windows PowerShell,需先设置 JAVA_HOME)
+$env:JAVA_HOME = "D:\Program Files\Java\jdk-17"; cd backend; mvn test
+```
+
+## 关键约定
+
+- **Java 包路径**: `com.example.resourceplatform`
+- **后端源码**: `backend/src/main/java/com/example/resourceplatform/`
+  - `controller/` — REST 接口层
+  - `service/` — 业务逻辑层
+  - `mapper/` — MyBatis-Plus Mapper 层
+  - `entity/` — 实体类
+  - `dto/` — 数据传输对象 / 视图对象
+  - `config/` — 配置类(Security、MyBatis-Plus)
+  - `security/` — JWT 与认证相关
+  - `common/` — 通用枚举、异常处理、分页
+  - `task/` — 定时任务
+- **前端 API 层**: `frontend/src/api/` — 按模块拆分的接口调用
+- **前端路由**: `frontend/src/router/index.js`
+- **前端状态管理**: `frontend/src/store/`
+- **前端页面**: `frontend/src/views/`
+- **前端布局**: `frontend/src/layout/`
+- **前端公共组件**: `frontend/src/components/`
+- **SQL 脚本**: `sql/schema.sql`(建表)、`sql/migration_*.sql`(增量迁移)

+ 1 - 5
backend/.gitignore

@@ -3,10 +3,6 @@ target/
 .idea/
 *.iml
 application-dev.yml
-target/
-*.class
-.idea/
-*.iml
-# 离线重编译的依赖缓存与编译产物(非源码, 由 build_v*.py 生成)
+# 离线重编译的依赖缓存与编译产物(非源码, 由 build.py 生成)
 lib/
 build_tmp_classes/

+ 7 - 0
backend/README.md

@@ -20,6 +20,13 @@
    ```
    或者直接用IDE运行 `ResourcePlatformApplication`。
 
+   **无 Maven 环境时**可用离线构建脚本:
+   ```bash
+   python build.py          # 构建(自动递增版本号)
+   bash start.sh            # 启动(自动检测最新版本)
+   ```
+   详见根目录 `AGENTS.md`。
+
 4. **默认账号**
    - 账号:`admin`
    - 密码:`admin123`

+ 250 - 0
backend/build.py

@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""
+统一构建脚本 — 合并 build_v6~v11 为单入口。
+
+用法:
+    python build.py              # 基于最新 jar 自动递增版本号
+    python build.py --version 12 # 指定输出版本号
+    python build.py --base path/to/base.jar  # 指定基底 jar
+
+功能:
+    1. 收集 lib/ 下所有 jar 作为 classpath(首次运行自动从基底 jar 解压)
+    2. 用 javac 编译 src/main/java 下全部源码
+    3. 以基底 fat jar 为模板,替换 BOOT-INF/classes 下的 class 和资源文件,
+       并补打基底中不存在的新增类 / 资源
+    4. 输出 target/resource-platform-v{N}.jar
+"""
+
+import zipfile, os, subprocess, glob, sys, shutil, argparse, re
+
+# ── 路径常量 ──────────────────────────────────────────────
+BASE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(BASE, "src", "main", "java")
+RES = os.path.join(BASE, "src", "main", "resources")
+OUT = os.path.join(BASE, "build_tmp_classes")
+LIB = os.path.join(BASE, "lib")
+TARGET = os.path.join(BASE, "target")
+
+JAVAC = r"D:\Program Files\Java\jdk-17\bin\javac.exe"
+LOMBOK = r"C:\Users\BXD-XTYW\.m2\repository\org\projectlombok\lombok\1.18.32\lombok-1.18.32.jar"
+
+# ── 辅助函数 ──────────────────────────────────────────────
+
+def find_latest_version():
+    """扫描 target/ 目录,返回已有的最高版本号;无则返回 0。"""
+    versions = []
+    pattern = re.compile(r"resource-platform-v(\d+)\.jar$")
+    if os.path.isdir(TARGET):
+        for f in os.listdir(TARGET):
+            m = pattern.match(f)
+            if m:
+                versions.append(int(m.group(1)))
+    return max(versions) if versions else 0
+
+
+def find_base_jar(current_version):
+    """
+    按优先级查找基底 jar:
+      1. target/resource-platform-v{N}.jar(最高版本)
+      2. target/resource-platform.jar(Maven 产出,无版本号)
+    """
+    if current_version > 0:
+        versioned = os.path.join(TARGET, f"resource-platform-v{current_version}.jar")
+        if os.path.isfile(versioned):
+            return versioned
+    # 回退到 Maven 产出的无版本号 jar
+    maven_jar = os.path.join(TARGET, "resource-platform.jar")
+    if os.path.isfile(maven_jar):
+        return maven_jar
+    return None
+
+
+def extract_libs_from_jar(jar_path):
+    """从 fat jar 解压 BOOT-INF/lib/*.jar 到 lib/ 目录。"""
+    os.makedirs(LIB, exist_ok=True)
+    with zipfile.ZipFile(jar_path) as z:
+        for name in z.namelist():
+            if name.startswith("BOOT-INF/lib/") and name.endswith(".jar"):
+                out = os.path.join(LIB, os.path.basename(name))
+                with open(out, "wb") as f:
+                    f.write(z.read(name))
+
+
+def ensure_lombok():
+    """确保 lombok jar 存在于 lib/ 目录。"""
+    dst = os.path.join(LIB, "lombok-1.18.32.jar")
+    if not os.path.exists(dst) and os.path.exists(LOMBOK):
+        shutil.copy(LOMBOK, dst)
+
+
+def collect_classpath():
+    """收集 lib/ 下所有 jar,拼成 classpath 字符串。"""
+    dep_jars = sorted(glob.glob(os.path.join(LIB, "*.jar")))
+    print(f"classpath entries: {len(dep_jars)}")
+    return ";".join(dep_jars)
+
+
+def compile_sources(cp):
+    """编译 src/main/java 下全部 .java 到 build_tmp_classes/。"""
+    sources = []
+    for root, _, files in os.walk(SRC):
+        for f in files:
+            if f.endswith(".java"):
+                sources.append(os.path.join(root, f))
+    print(f"source files: {len(sources)}")
+
+    os.makedirs(OUT, exist_ok=True)
+    cmd = [JAVAC, "-parameters", "-encoding", "UTF-8", "-cp", cp, "-d", OUT] + sources
+    r = subprocess.run(cmd, capture_output=True)
+    print(f"javac returncode: {r.returncode}")
+    if r.returncode != 0:
+        err = (r.stderr or b"").decode("utf-8", "replace")
+        print("=== STDERR (first 60 lines) ===")
+        print("\n".join(err.splitlines()[:60]))
+        sys.exit(1)
+    class_count = len(glob.glob(os.path.join(OUT, "**", "*.class"), recursive=True))
+    print(f"compiled classes: {class_count}")
+    return class_count
+
+
+def repackage(base_jar, dst_jar):
+    """
+    以 base_jar 为基底,替换 / 补打 class 和资源文件,生成 dst_jar。
+    逻辑合并自 build_v7(资源注入)+ build_v8(新增类补打)。
+    """
+    with zipfile.ZipFile(base_jar) as zin:
+        infos = zin.infolist()
+        all_names = set(zin.namelist())
+
+        def local_for(entry_name):
+            # 已编译 class: 优先用本地编译产物
+            if entry_name.startswith("BOOT-INF/classes/") and entry_name.endswith(".class"):
+                rel = entry_name[len("BOOT-INF/classes/"):]
+                local = os.path.join(OUT, rel)
+                if os.path.isfile(local):
+                    return local
+                return None
+            # 资源文件: 用 src/main/resources 中的版本
+            if entry_name.startswith("BOOT-INF/classes/"):
+                rel = entry_name[len("BOOT-INF/classes/"):]
+                local = os.path.join(RES, rel)
+                if os.path.isfile(local):
+                    return local
+            return None
+
+        manifest_name = "META-INF/MANIFEST.MF"
+
+        # 收集 src/main/resources 中新增的、基底 jar 里没有的资源条目
+        extra_resources = []
+        for root, _, files in os.walk(RES):
+            for f in files:
+                full = os.path.join(root, f)
+                rel = os.path.relpath(full, RES).replace("\\", "/")
+                entry = "BOOT-INF/classes/" + rel
+                if entry not in all_names:
+                    extra_resources.append((entry, full))
+
+        # 收集本地编译产物中新增的、基底 jar 里不存在的 class
+        extra_classes = []
+        for root, _, files in os.walk(OUT):
+            for f in files:
+                if f.endswith(".class"):
+                    full = os.path.join(root, f)
+                    rel = os.path.relpath(full, OUT).replace("\\", "/")
+                    entry = "BOOT-INF/classes/" + rel
+                    if entry not in all_names:
+                        extra_classes.append((entry, full))
+
+        with zipfile.ZipFile(dst_jar, "w", compression=zipfile.ZIP_DEFLATED) as zout:
+            # MANIFEST 必须第一个写入
+            mi = next(i for i in infos if i.filename == manifest_name)
+            zout.writestr(mi, zin.read(manifest_name))
+            for i in infos:
+                if i.filename == manifest_name:
+                    continue
+                local = local_for(i.filename)
+                if local:
+                    with open(local, "rb") as lf:
+                        data = lf.read()
+                    zi = zipfile.ZipInfo(i.filename)
+                    zi.external_attr = i.external_attr
+                    zi.date_time = i.date_time
+                    zout.writestr(zi, data)
+                else:
+                    zout.writestr(i, zin.read(i.filename))
+            # 补打新增资源
+            for entry, full in extra_resources:
+                with open(full, "rb") as lf:
+                    data = lf.read()
+                zout.writestr(zipfile.ZipInfo(entry), data)
+            # 补打新增 class
+            for entry, full in extra_classes:
+                with open(full, "rb") as lf:
+                    data = lf.read()
+                zout.writestr(zipfile.ZipInfo(entry), data)
+
+    # 校验
+    zm = zipfile.ZipFile(dst_jar)
+    print(f"manifest first: {zm.namelist()[0] == manifest_name}")
+    print(f"extra resources injected: {len(extra_resources)}")
+    print(f"extra classes injected: {len(extra_classes)}")
+    return zm
+
+
+# ── 主流程 ────────────────────────────────────────────────
+
+def main():
+    parser = argparse.ArgumentParser(description="统一构建脚本")
+    parser.add_argument("--version", type=int, help="指定输出版本号(默认自动递增)")
+    parser.add_argument("--base", help="指定基底 jar 路径(默认使用最新版本)")
+    args = parser.parse_args()
+
+    # 1. 确定版本号
+    latest = find_latest_version()
+    if args.version:
+        new_version = args.version
+    else:
+        new_version = latest + 1
+
+    # 2. 确定基底 jar
+    if args.base:
+        base_jar = args.base
+    else:
+        base_jar = find_base_jar(latest)
+
+    if not base_jar or not os.path.isfile(base_jar):
+        print(f"ERROR: 基底 jar 不存在: {base_jar}")
+        print("请先通过 'mvn clean package -DskipTests' 生成初始 jar,")
+        print("或使用 --base 参数指定基底 jar 路径。")
+        sys.exit(1)
+
+    dst_jar = os.path.join(TARGET, f"resource-platform-v{new_version}.jar")
+
+    print(f"base jar:  {base_jar}")
+    print(f"output:    {dst_jar}")
+    print(f"version:   {new_version}")
+    print()
+
+    # 3. 准备 classpath
+    os.makedirs(LIB, exist_ok=True)
+    if not glob.glob(os.path.join(LIB, "*.jar")):
+        print("lib/ 为空, 从基底 jar 解压依赖...")
+        extract_libs_from_jar(base_jar)
+    ensure_lombok()
+    cp = collect_classpath()
+    print()
+
+    # 4. 编译
+    compile_sources(cp)
+    print()
+
+    # 5. 重打包
+    zm = repackage(base_jar, dst_jar)
+    print()
+
+    print(f"built: {dst_jar}")
+    print(f"exists: {os.path.exists(dst_jar)}")
+
+
+if __name__ == "__main__":
+    main()

+ 389 - 0
backend/src/test/java/com/example/resourceplatform/service/impl/ResourceServiceImplTest.java

@@ -0,0 +1,389 @@
+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.ResourceDTO;
+import com.example.resourceplatform.dto.ResourceVO;
+import com.example.resourceplatform.entity.ProjectResource;
+import com.example.resourceplatform.entity.Resource;
+import com.example.resourceplatform.entity.ResourceType;
+import com.example.resourceplatform.entity.Provider;
+import com.example.resourceplatform.entity.Project;
+import com.example.resourceplatform.mapper.*;
+import com.example.resourceplatform.service.OperationLogService;
+import com.example.resourceplatform.service.SysUserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ResourceServiceImplTest {
+
+    @Mock
+    private ResourceMapper resourceMapper;
+    @Mock
+    private ResourceTypeMapper resourceTypeMapper;
+    @Mock
+    private ProviderMapper providerMapper;
+    @Mock
+    private ProjectMapper projectMapper;
+    @Mock
+    private ProjectResourceMapper projectResourceMapper;
+    @Mock
+    private ResourceRenewalMapper renewalMapper;
+    @Mock
+    private SysUserService sysUserService;
+    @Mock
+    private OperationLogService operationLogService;
+
+    @InjectMocks
+    private ResourceServiceImpl resourceService;
+
+    @BeforeEach
+    void setUp() {
+        // 设置 @Value 注入的配置属性
+        ReflectionTestUtils.setField(resourceService, "remindDaysFar", 30);
+    }
+
+    // ========== getDetail ==========
+
+    @Test
+    @DisplayName("getDetail: 资源存在时返回VO")
+    void getDetail_resourceExists_returnsVO() {
+        Resource resource = buildResource(1L, "test-resource", 1L, 2L, 10L);
+        when(resourceMapper.selectById(1L)).thenReturn(resource);
+        when(resourceTypeMapper.selectBatchIds(anyCollection()))
+                .thenReturn(List.of(buildResourceType(1L, "云服务器")));
+        when(providerMapper.selectBatchIds(anyCollection()))
+                .thenReturn(List.of(buildProvider(2L, "阿里云")));
+        when(sysUserService.getUserNameMap(anyList()))
+                .thenReturn(Map.of(10L, "张三"));
+        when(projectResourceMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+
+        ResourceVO vo = resourceService.getDetail(1L);
+
+        assertThat(vo).isNotNull();
+        assertThat(vo.getId()).isEqualTo(1L);
+        assertThat(vo.getName()).isEqualTo("test-resource");
+        assertThat(vo.getTypeName()).isEqualTo("云服务器");
+        assertThat(vo.getProviderName()).isEqualTo("阿里云");
+        assertThat(vo.getOwnerName()).isEqualTo("张三");
+    }
+
+    @Test
+    @DisplayName("getDetail: 资源不存在时抛出BusinessException")
+    void getDetail_resourceNotFound_throwsException() {
+        when(resourceMapper.selectById(999L)).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> resourceService.getDetail(999L));
+        assertThat(ex.getMessage()).isEqualTo("资源不存在");
+    }
+
+    // ========== listByIds ==========
+
+    @Test
+    @DisplayName("listByIds: 空列表返回空结果")
+    void listByIds_emptyList_returnsEmpty() {
+        List<ResourceVO> result = resourceService.listByIds(Collections.emptyList());
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    @DisplayName("listByIds: null列表返回空结果")
+    void listByIds_nullList_returnsEmpty() {
+        List<ResourceVO> result = resourceService.listByIds(null);
+        assertThat(result).isEmpty();
+    }
+
+    // ========== saveOrUpdate ==========
+
+    @Test
+    @DisplayName("saveOrUpdate: 到期日期早于开通日期时抛出异常")
+    void saveOrUpdate_expireBeforeStart_throwsException() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setStartDate(LocalDate.of(2026, 8, 15));
+        dto.setExpireDate(LocalDate.of(2026, 8, 10)); // 到期日期早于开通日期
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> resourceService.saveOrUpdate(dto));
+        assertThat(ex.getMessage()).isEqualTo("到期日期不能早于开通日期");
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 新增资源时设置状态为ACTIVE并调用insert")
+    void saveOrUpdate_newResource_insertsWithActiveStatus() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("new-resource");
+        dto.setTypeId(1L);
+        dto.setProviderId(2L);
+        dto.setOwnerId(10L);
+        dto.setCost(new BigDecimal("100.00"));
+        dto.setStartDate(LocalDate.of(2026, 1, 1));
+        dto.setExpireDate(LocalDate.of(2026, 12, 31));
+        // id为null -> 新增
+
+        when(resourceMapper.insert(any(Resource.class))).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).insert(argThat(r -> {
+            assertThat(r.getName()).isEqualTo("new-resource");
+            assertThat(r.getStatus()).isEqualTo(ResourceStatus.ACTIVE.getCode());
+            assertThat(r.getCost()).isEqualByComparingTo(new BigDecimal("100.00"));
+            return true;
+        }));
+        verify(operationLogService).record(eq("资源"), eq("新增"), any(), contains("new-resource"));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 编辑已有资源时调用updateById")
+    void saveOrUpdate_existingResource_callsUpdateById() {
+        Resource existing = buildResource(1L, "old-name", 1L, 2L, 10L);
+        when(resourceMapper.selectById(1L)).thenReturn(existing);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        ResourceDTO dto = new ResourceDTO();
+        dto.setId(1L);
+        dto.setName("new-name");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).updateById(argThat(r -> {
+            assertThat(r.getName()).isEqualTo("new-name");
+            return true;
+        }));
+        verify(operationLogService).record(eq("资源"), eq("编辑"), eq(1L), contains("new-name"));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 编辑不存在的资源抛出异常")
+    void saveOrUpdate_editNonExistent_throwsException() {
+        when(resourceMapper.selectById(999L)).thenReturn(null);
+
+        ResourceDTO dto = new ResourceDTO();
+        dto.setId(999L);
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+
+        assertThrows(BusinessException.class, () -> resourceService.saveOrUpdate(dto));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 新增资源带项目关联,创建ProjectResource记录")
+    void saveOrUpdate_withProjectIds_createsProjectResources() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setProjectIds(List.of(100L, 200L));
+
+        when(resourceMapper.insert(any(Resource.class))).thenAnswer(inv -> {
+            Resource r = inv.getArgument(0);
+            r.setId(1L); // 模拟自增id
+            return 1;
+        });
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        // 应该为两个项目各创建一条关联记录
+        verify(projectResourceMapper, times(2)).insert(any(ProjectResource.class));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: cost为null时默认为ZERO")
+    void saveOrUpdate_nullCost_defaultsToZero() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setCost(null);
+
+        when(resourceMapper.insert(any(Resource.class))).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).insert(argThat(r -> {
+            assertThat(r.getCost()).isEqualByComparingTo(BigDecimal.ZERO);
+            return true;
+        }));
+    }
+
+    // ========== delete ==========
+
+    @Test
+    @DisplayName("delete: 删除资源并清理项目关联")
+    void delete_deletesResourceAndRelations() {
+        when(resourceMapper.deleteById(1L)).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2);
+
+        resourceService.delete(1L);
+
+        verify(resourceMapper).deleteById(1L);
+        verify(projectResourceMapper).delete(any(LambdaQueryWrapper.class));
+        verify(operationLogService).record("资源", "删除", 1L, "资源id: 1");
+    }
+
+    // ========== refreshExpireStatus ==========
+
+    @Test
+    @DisplayName("refreshExpireStatus: 已过期资源状态更新为EXPIRED")
+    void refreshExpireStatus_expiredResource_setsExpiredStatus() {
+        Resource r = buildResource(1L, "expired", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().minusDays(5));
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper).updateById(argThat(res -> {
+            assertThat(res.getStatus()).isEqualTo(ResourceStatus.EXPIRED.getCode());
+            return true;
+        }));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: 即将到期资源状态更新为EXPIRING")
+    void refreshExpireStatus_expiringSoon_setsExpiringStatus() {
+        Resource r = buildResource(1L, "expiring", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().plusDays(10)); // 10天后到期,在30天提醒范围内
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper).updateById(argThat(res -> {
+            assertThat(res.getStatus()).isEqualTo(ResourceStatus.EXPIRING.getCode());
+            return true;
+        }));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: 正常资源状态保持ACTIVE")
+    void refreshExpireStatus_activeResource_staysActive() {
+        Resource r = buildResource(1L, "active", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().plusDays(60)); // 60天后到期,超出30天提醒范围
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        // 状态未变,不应调用updateById
+        verify(resourceMapper, never()).updateById(any(Resource.class));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: expireDate为null时跳过该资源")
+    void refreshExpireStatus_nullExpireDate_skips() {
+        Resource r = buildResource(1L, "no-expire", 1L, 2L, 10L);
+        r.setExpireDate(null);
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper, never()).updateById(any(Resource.class));
+    }
+
+    // ========== costStat ==========
+
+    @Test
+    @DisplayName("costStatByProject: range为null时不过滤时间")
+    void costStatByProject_nullRange_noDateFilter() {
+        when(resourceMapper.costStatByProject(isNull(), isNull()))
+                .thenReturn(Collections.emptyList());
+
+        var result = resourceService.costStatByProject(null);
+
+        assertThat(result).isEmpty();
+        verify(resourceMapper).costStatByProject(null, null);
+    }
+
+    @Test
+    @DisplayName("costStatByProject: range=month时传入本月时间范围")
+    void costStatByProject_monthRange_passesMonthDates() {
+        when(resourceMapper.costStatByProject(any(), any()))
+                .thenReturn(Collections.emptyList());
+
+        resourceService.costStatByProject("month");
+
+        verify(resourceMapper).costStatByProject(
+                argThat(d -> d != null && d.equals(LocalDate.now().withDayOfMonth(1))),
+                argThat(d -> d != null));
+    }
+
+    @Test
+    @DisplayName("costStatByType: range=year时传入本年时间范围")
+    void costStatByType_yearRange_passesYearDates() {
+        when(resourceMapper.costStatByType(any(), any()))
+                .thenReturn(Collections.emptyList());
+
+        resourceService.costStatByType("year");
+
+        verify(resourceMapper).costStatByType(
+                argThat(d -> d != null && d.getMonthValue() == 1 && d.getDayOfMonth() == 1),
+                argThat(d -> d != null));
+    }
+
+    // ========== 辅助方法 ==========
+
+    private Resource buildResource(Long id, String name, Long typeId, Long providerId, Long ownerId) {
+        Resource r = new Resource();
+        r.setId(id);
+        r.setName(name);
+        r.setTypeId(typeId);
+        r.setProviderId(providerId);
+        r.setOwnerId(ownerId);
+        r.setCost(new BigDecimal("500.00"));
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+        r.setStartDate(LocalDate.of(2026, 1, 1));
+        r.setExpireDate(LocalDate.of(2026, 12, 31));
+        return r;
+    }
+
+    private ResourceType buildResourceType(Long id, String name) {
+        ResourceType rt = new ResourceType();
+        rt.setId(id);
+        rt.setName(name);
+        return rt;
+    }
+
+    private Provider buildProvider(Long id, String name) {
+        Provider p = new Provider();
+        p.setId(id);
+        p.setName(name);
+        return p;
+    }
+}

+ 40 - 0
backend/start.sh

@@ -0,0 +1,40 @@
+#!/bin/bash
+# 统一启动脚本 — 合并 start_v7~v11
+# 用法:
+#   ./start.sh              # 启动最新版本
+#   ./start.sh 11           # 启动指定版本
+#   PORT=9090 ./start.sh    # 自定义端口
+
+cd /d/demo/resource-platform/backend
+
+# 自动检测最新版本
+if [ -z "$1" ]; then
+  VERSION=$(ls target/resource-platform-v*.jar 2>/dev/null \
+    | sed 's/.*-v\([0-9]*\)\.jar/\1/' \
+    | sort -n | tail -1)
+  if [ -z "$VERSION" ]; then
+    echo "ERROR: target/ 下未找到任何版本 jar,请先运行 python build.py"
+    exit 1
+  fi
+else
+  VERSION=$1
+fi
+
+JAR="target/resource-platform-v${VERSION}.jar"
+
+if [ ! -f "$JAR" ]; then
+  echo "ERROR: $JAR 不存在"
+  exit 1
+fi
+
+PORT=${PORT:-8080}
+
+echo "Starting resource-platform v${VERSION} on port ${PORT}..."
+
+"D:/Program Files/Java/jdk-17/bin/java.exe" \
+  -Dspring.profiles.active=dev \
+  -Dserver.port="${PORT}" \
+  -DDB_URL="${DB_URL:-jdbc:mysql://rm-uf64611sxm4qe438g8o.mysql.rds.aliyuncs.com:33450/resource_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false}" \
+  -DDB_USERNAME="${DB_USERNAME:-root}" \
+  -DDB_PASSWORD="${DB_PASSWORD:-Cmt123456}" \
+  -jar "$JAR"

Datei-Diff unterdrückt, da er zu groß ist
+ 767 - 91
frontend/package-lock.json


+ 7 - 2
frontend/package.json

@@ -5,7 +5,9 @@
   "scripts": {
     "dev": "vite",
     "build": "vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
@@ -19,6 +21,9 @@
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^5.0.4",
-    "vite": "^5.1.6"
+    "@vue/test-utils": "^2.4.11",
+    "jsdom": "^29.1.1",
+    "vite": "^5.1.6",
+    "vitest": "^4.1.10"
   }
 }

+ 43 - 0
frontend/src/api/__tests__/auth.test.js

@@ -0,0 +1,43 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const { mockGet, mockPost, mockPut } = vi.hoisted(() => ({
+  mockGet: vi.fn(),
+  mockPost: vi.fn(),
+  mockPut: vi.fn()
+}))
+
+vi.mock('../request', () => ({
+  default: {
+    get: mockGet,
+    post: mockPost,
+    put: mockPut
+  }
+}))
+
+import { loginApi, getMeApi, updatePasswordApi } from '../auth'
+
+describe('auth API', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('loginApi 调用 POST /auth/login', async () => {
+    mockPost.mockResolvedValue({ code: 200, data: { token: 'abc' } })
+    const data = { username: 'admin', password: '123' }
+    await loginApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/auth/login', data)
+  })
+
+  it('getMeApi 调用 GET /auth/me', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: { id: 1 } })
+    await getMeApi()
+    expect(mockGet).toHaveBeenCalledWith('/auth/me')
+  })
+
+  it('updatePasswordApi 调用 PUT /auth/password', async () => {
+    mockPut.mockResolvedValue({ code: 200 })
+    const data = { oldPassword: 'old', newPassword: 'new' }
+    await updatePasswordApi(data)
+    expect(mockPut).toHaveBeenCalledWith('/auth/password', data)
+  })
+})

+ 132 - 0
frontend/src/api/__tests__/resource.test.js

@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// vi.hoisted 确保 mock 函数在 vi.mock 提升后可用
+const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
+  mockGet: vi.fn(),
+  mockPost: vi.fn(),
+  mockPut: vi.fn(),
+  mockDelete: vi.fn()
+}))
+
+vi.mock('../request', () => ({
+  default: {
+    get: mockGet,
+    post: mockPost,
+    put: mockPut,
+    delete: mockDelete
+  }
+}))
+
+import {
+  resourcePageApi,
+  resourceDetailApi,
+  resourceSaveApi,
+  resourceDeleteApi,
+  resourceRelateApi,
+  resourceUnrelateApi,
+  costStatByProjectApi,
+  costStatByTypeApi,
+  resourceExportApi,
+  resourceRenewApi,
+  resourceRenewalsApi,
+  resourceDeleteRenewalApi,
+  procurementCostApi,
+  procurementCostExportApi
+} from '../resource'
+
+describe('resource API', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('resourcePageApi 调用 GET /resource/page 并传入分页参数', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: { records: [], total: 0 } })
+    const params = { pageNum: 1, pageSize: 10 }
+    await resourcePageApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/page', { params })
+  })
+
+  it('resourceDetailApi 调用 GET /resource/:id', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: {} })
+    await resourceDetailApi(42)
+    expect(mockGet).toHaveBeenCalledWith('/resource/42')
+  })
+
+  it('resourceSaveApi 调用 POST /resource', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { name: 'test-resource' }
+    await resourceSaveApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/resource', data)
+  })
+
+  it('resourceDeleteApi 调用 DELETE /resource/:id', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceDeleteApi(7)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/7')
+  })
+
+  it('resourceRelateApi 调用 POST /resource/relate', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { projectId: 1, resourceIds: [10, 20] }
+    await resourceRelateApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/resource/relate', data)
+  })
+
+  it('resourceUnrelateApi 调用 DELETE /resource/unrelate 带查询参数', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceUnrelateApi(1, 10)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/unrelate', { params: { projectId: 1, resourceId: 10 } })
+  })
+
+  it('costStatByProjectApi 调用 GET /resource/cost-stat/project', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await costStatByProjectApi('month')
+    expect(mockGet).toHaveBeenCalledWith('/resource/cost-stat/project', { params: { range: 'month' } })
+  })
+
+  it('costStatByTypeApi 调用 GET /resource/cost-stat/type', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await costStatByTypeApi('quarter')
+    expect(mockGet).toHaveBeenCalledWith('/resource/cost-stat/type', { params: { range: 'quarter' } })
+  })
+
+  it('resourceExportApi 调用 GET /resource/export 并设置 responseType blob', async () => {
+    mockGet.mockResolvedValue(new Blob())
+    const params = { name: 'test' }
+    await resourceExportApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/export', { params, responseType: 'blob' })
+  })
+
+  it('resourceRenewApi 调用 POST /resource/:id/renew', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { cost: 100, durationCode: 5 }
+    await resourceRenewApi(42, data)
+    expect(mockPost).toHaveBeenCalledWith('/resource/42/renew', data)
+  })
+
+  it('resourceRenewalsApi 调用 GET /resource/:id/renewals', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await resourceRenewalsApi(42)
+    expect(mockGet).toHaveBeenCalledWith('/resource/42/renewals')
+  })
+
+  it('resourceDeleteRenewalApi 调用 DELETE /resource/:id/renewals/:renewalId', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceDeleteRenewalApi(42, 99)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/42/renewals/99')
+  })
+
+  it('procurementCostApi 调用 GET /resource/procurement-cost', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: {} })
+    const params = { pageNum: 1, pageSize: 20 }
+    await procurementCostApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/procurement-cost', { params })
+  })
+
+  it('procurementCostExportApi 调用 GET /resource/procurement-cost/export 带 blob', async () => {
+    mockGet.mockResolvedValue(new Blob())
+    const params = { month: '2026-08' }
+    await procurementCostExportApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/procurement-cost/export', { params, responseType: 'blob' })
+  })
+})

+ 161 - 0
frontend/src/router/__tests__/router.test.js

@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { createRouter, createWebHistory } from 'vue-router'
+
+// 测试路由定义 — 直接导入路由数组的配置,避免触发懒加载组件
+// 由于 router/index.js 使用了 () => import() 懒加载, 这里直接验证路由配置结构
+
+// 提取路由配置用于测试(与 router/index.js 保持一致)
+const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    meta: { public: true }
+  },
+  {
+    path: '/',
+    redirect: '/dashboard',
+    children: [
+      { path: 'dashboard', name: 'Dashboard', meta: { title: '工作台' } },
+      { path: 'resource', name: 'ResourceList', meta: { title: '资源管理' } },
+      { path: 'project', name: 'ProjectList', meta: { title: '项目管理' } },
+      { path: 'project/:id', name: 'ProjectDetail', meta: { title: '项目详情' } },
+      { path: 'cost-stat', name: 'CostStat', meta: { title: '费用统计' } },
+      { path: 'procurement-cost', name: 'ProcurementCost', meta: { title: '采购费用' } },
+      { path: 'resource/:id', name: 'ResourceDetail', meta: { title: '资源详情' } },
+      { path: 'dict', name: 'DictManage', meta: { title: '字典管理' } },
+      { path: 'user', name: 'UserManage', meta: { title: '用户管理', adminOnly: true } },
+      { path: 'log', name: 'OperationLog', meta: { title: '操作日志' } }
+    ]
+  }
+]
+
+describe('路由配置', () => {
+  let router
+
+  beforeEach(() => {
+    router = createRouter({
+      history: createWebHistory(),
+      routes
+    })
+  })
+
+  it('包含登录路由 /login 且标记为 public', () => {
+    const loginRoute = router.getRoutes().find(r => r.name === 'Login')
+    expect(loginRoute).toBeDefined()
+    expect(loginRoute.path).toBe('/login')
+    expect(loginRoute.meta.public).toBe(true)
+  })
+
+  it('根路径 / 重定向到 /dashboard', () => {
+    const rootRoute = router.getRoutes().find(r => r.path === '/')
+    // 根路由重定向在匹配时生效
+    expect(rootRoute).toBeDefined()
+  })
+
+  it('包含资源管理路由 ResourceList', () => {
+    const route = router.getRoutes().find(r => r.name === 'ResourceList')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/resource')
+    expect(route.meta.title).toBe('资源管理')
+  })
+
+  it('包含项目管理路由 ProjectList', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProjectList')
+    expect(route).toBeDefined()
+    expect(route.meta.title).toBe('项目管理')
+  })
+
+  it('包含项目详情路由 ProjectDetail 且支持 :id 参数', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProjectDetail')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/project/:id')
+  })
+
+  it('包含费用统计路由 CostStat', () => {
+    const route = router.getRoutes().find(r => r.name === 'CostStat')
+    expect(route).toBeDefined()
+    expect(route.meta.title).toBe('费用统计')
+  })
+
+  it('包含采购费用路由 ProcurementCost', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProcurementCost')
+    expect(route).toBeDefined()
+  })
+
+  it('包含资源详情路由 ResourceDetail 且支持 :id 参数', () => {
+    const route = router.getRoutes().find(r => r.name === 'ResourceDetail')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/resource/:id')
+  })
+
+  it('包含字典管理路由 DictManage', () => {
+    const route = router.getRoutes().find(r => r.name === 'DictManage')
+    expect(route).toBeDefined()
+  })
+
+  it('包含用户管理路由 UserManage 且标记为 adminOnly', () => {
+    const route = router.getRoutes().find(r => r.name === 'UserManage')
+    expect(route).toBeDefined()
+    expect(route.meta.adminOnly).toBe(true)
+  })
+
+  it('包含操作日志路由 OperationLog', () => {
+    const route = router.getRoutes().find(r => r.name === 'OperationLog')
+    expect(route).toBeDefined()
+  })
+
+  it('路由总数符合预期(11条命名路由 + 1条根路由)', () => {
+    // getRoutes() 返回所有扁平化路由(含根路由)
+    const allRoutes = router.getRoutes()
+    expect(allRoutes.length).toBeGreaterThanOrEqual(11)
+  })
+})
+
+describe('路由守卫', () => {
+  let router
+
+  beforeEach(() => {
+    localStorage.clear()
+    router = createRouter({
+      history: createWebHistory(),
+      routes
+    })
+    // 注册与 router/index.js 一致的守卫逻辑
+    router.beforeEach((to) => {
+      const token = localStorage.getItem('token')
+      if (!to.meta.public && !token) {
+        return { name: 'Login', query: { redirect: to.fullPath } }
+      }
+      if (to.name === 'Login' && token) {
+        return { path: '/dashboard' }
+      }
+      return true
+    })
+  })
+
+  it('未登录时访问受保护路由,重定向到 Login', async () => {
+    await router.push('/dashboard')
+    await router.isReady()
+    // 应该被导航守卫拦截并重定向到 Login
+    expect(router.currentRoute.value.name).toBe('Login')
+  })
+
+  it('未登录时访问 /login 允许通过(public路由)', async () => {
+    await router.push('/login')
+    await router.isReady()
+    expect(router.currentRoute.value.name).toBe('Login')
+  })
+
+  it('已登录时访问 /login,重定向到 /dashboard', async () => {
+    localStorage.setItem('token', 'fake-token')
+    await router.push('/login')
+    await router.isReady()
+    expect(router.currentRoute.value.name).toBe('Dashboard')
+  })
+
+  it('未登录访问 /dashboard 时,redirect 查询参数保留原始路径', async () => {
+    await router.push('/dashboard')
+    await router.isReady()
+    expect(router.currentRoute.value.query.redirect).toBe('/dashboard')
+  })
+})

+ 17 - 0
frontend/vitest.config.js

@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  test: {
+    environment: 'jsdom',
+    globals: true,
+    include: ['src/**/*.{test,spec}.js']
+  }
+})

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.