Bläddra i källkod

feat(cost): 费用统计本月/本季/本年筛选生效(按资源开通日期过滤)

C 2 månader sedan
förälder
incheckning
72cff05d6c

+ 3 - 0
backend/.gitignore

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

+ 106 - 0
backend/build_v7.py

@@ -0,0 +1,106 @@
+import zipfile, os, subprocess, glob, sys, shutil
+
+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")
+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"
+SRC_JAR = os.path.join(BASE, "target", "resource-platform-v6.jar")
+DST_JAR = os.path.join(BASE, "target", "resource-platform-v7.jar")
+
+# 1) 准备依赖 classpath: 从 fat jar 抽取 BOOT-INF/lib 到 lib/(若缺失), 并放入 lombok
+os.makedirs(LIB, exist_ok=True)
+if not glob.glob(os.path.join(LIB, "*.jar")):
+    with zipfile.ZipFile(SRC_JAR) 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))
+if not os.path.exists(os.path.join(LIB, "lombok-1.18.32.jar")):
+    shutil.copy(LOMBOK, os.path.join(LIB, "lombok-1.18.32.jar"))
+
+dep_jars = sorted(glob.glob(os.path.join(LIB, "*.jar")))
+cp = ";".join(dep_jars)
+print("classpath entries:", len(dep_jars))
+
+# 2) 编译全部源码
+sources = []
+for root, _, files in os.walk(SRC):
+    for f in files:
+        if f.endswith(".java"):
+            sources.append(os.path.join(root, f))
+print("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("javac returncode:", r.returncode)
+if r.returncode != 0:
+    err = (r.stderr or b"").decode("utf-8", "replace")
+    print("=== STDERR (first 40 lines) ===")
+    print("\n".join(err.splitlines()[:40]))
+    sys.exit(1)
+print("compiled classes:", len(glob.glob(os.path.join(OUT, "**", "*.class"), recursive=True)))
+
+# 3) 重打包: 以 v6 为基底, 替换最新编译的 class, 并注入 src/main/resources 中的资源(xml等)
+with zipfile.ZipFile(SRC_JAR) as zin:
+    infos = zin.infolist()
+
+    def local_for(entry_name):
+        # 已编译 class: 来自 OUT
+        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"
+    all_names = set(zin.namelist())
+    # 收集 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))
+
+    with zipfile.ZipFile(DST_JAR, "w", compression=zipfile.ZIP_DEFLATED) as zout:
+        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()
+            zi = zipfile.ZipInfo(entry)
+            zout.writestr(zi, data)
+
+print("built:", DST_JAR, "exists:", os.path.exists(DST_JAR))
+zm = zipfile.ZipFile(DST_JAR)
+print("manifest first:", zm.namelist()[0] == manifest_name)
+xml_name = "BOOT-INF/classes/mapper/ResourceMapper.xml"
+print("mapper xml present:", xml_name in zm.namelist())

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

@@ -58,13 +58,13 @@ public class ResourceController {
     }
 
     @GetMapping("/cost-stat/project")
-    public Result<List<CostStatVO>> costStatByProject() {
-        return Result.success(resourceService.costStatByProject());
+    public Result<List<CostStatVO>> costStatByProject(@RequestParam(required = false) String range) {
+        return Result.success(resourceService.costStatByProject(range));
     }
 
     @GetMapping("/cost-stat/type")
-    public Result<List<CostStatVO>> costStatByType() {
-        return Result.success(resourceService.costStatByType());
+    public Result<List<CostStatVO>> costStatByType(@RequestParam(required = false) String range) {
+        return Result.success(resourceService.costStatByType(range));
     }
 
     /** 导出Excel,使用EasyExcel直接写到响应流 */

+ 6 - 4
backend/src/main/java/com/example/resourceplatform/mapper/ResourceMapper.java

@@ -4,15 +4,17 @@ 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 org.apache.ibatis.annotations.Param;
 
+import java.time.LocalDate;
 import java.util.List;
 
 @Mapper
 public interface ResourceMapper extends BaseMapper<Resource> {
 
-    /** 按项目维度统计费用(JOIN project_resource + project) */
-    List<CostStatVO> costStatByProject();
+    /** 按项目维度统计费用(JOIN project_resource + project),按资源开通日期过滤 */
+    List<CostStatVO> costStatByProject(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate);
 
-    /** 按资源类型维度统计费用(JOIN resource_type) */
-    List<CostStatVO> costStatByType();
+    /** 按资源类型维度统计费用(JOIN resource_type),按资源开通日期过滤 */
+    List<CostStatVO> costStatByType(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate);
 }

+ 4 - 2
backend/src/main/java/com/example/resourceplatform/service/ResourceService.java

@@ -28,9 +28,11 @@ public interface ResourceService {
     /** 从项目下移除某个资源的关联(不删除资源本身) */
     void unrelate(Long projectId, Long resourceId);
 
-    List<CostStatVO> costStatByProject();
+    /** 按项目维度统计费用, range 为 month/quarter/year(按资源开通日期过滤, 为空则不限时间) */
+    List<CostStatVO> costStatByProject(String range);
 
-    List<CostStatVO> costStatByType();
+    /** 按资源类型维度统计费用, range 为 month/quarter/year(按资源开通日期过滤, 为空则不限时间) */
+    List<CostStatVO> costStatByType(String range);
 
     /** 根据资源真实到期日期,刷新所有资源的status字段,定时任务调用 */
     void refreshExpireStatus();

+ 41 - 4
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceServiceImpl.java

@@ -252,13 +252,50 @@ public class ResourceServiceImpl implements ResourceService {
     }
 
     @Override
-    public List<CostStatVO> costStatByProject() {
-        return resourceMapper.costStatByProject();
+    public List<CostStatVO> costStatByProject(String range) {
+        LocalDate[] rangeDates = resolveRange(range);
+        return resourceMapper.costStatByProject(
+                rangeDates == null ? null : rangeDates[0],
+                rangeDates == null ? null : rangeDates[1]);
     }
 
     @Override
-    public List<CostStatVO> costStatByType() {
-        return resourceMapper.costStatByType();
+    public List<CostStatVO> costStatByType(String range) {
+        LocalDate[] rangeDates = resolveRange(range);
+        return resourceMapper.costStatByType(
+                rangeDates == null ? null : rangeDates[0],
+                rangeDates == null ? null : rangeDates[1]);
+    }
+
+    /**
+     * 把前端传入的 range(month/quarter/year) 解析为本周期的时间边界。
+     * 返回 [start, endExclusive),按资源开通日期(start_date)过滤;range 为空或未知时返回 null(不过滤)。
+     */
+    private LocalDate[] resolveRange(String range) {
+        if (range == null || range.isBlank()) {
+            return null;
+        }
+        LocalDate now = LocalDate.now();
+        LocalDate start;
+        LocalDate end;
+        switch (range) {
+            case "month":
+                start = now.withDayOfMonth(1);
+                end = start.plusMonths(1);
+                break;
+            case "quarter":
+                int q = (now.getMonthValue() - 1) / 3;
+                start = now.withMonth(q * 3 + 1).withDayOfMonth(1);
+                end = start.plusMonths(3);
+                break;
+            case "year":
+                start = now.withMonth(1).withDayOfMonth(1);
+                end = start.plusYears(1);
+                break;
+            default:
+                return null;
+        }
+        return new LocalDate[]{start, end};
     }
 
     @Override

+ 13 - 1
backend/src/main/resources/mapper/ResourceMapper.xml

@@ -10,6 +10,12 @@
         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
+        <where>
+            <if test="startDate != null and endDate != null">
+                AND r.start_date &gt;= #{startDate}
+                AND r.start_date &lt; #{endDate}
+            </if>
+        </where>
         GROUP BY p.id, p.name
         ORDER BY total_cost DESC
     </select>
@@ -21,7 +27,13 @@
                IFNULL(SUM(r.cost), 0)    AS total_cost
         FROM resource r
                  LEFT JOIN resource_type rt ON rt.id = r.type_id
-        WHERE r.deleted = 0
+        <where>
+            r.deleted = 0
+            <if test="startDate != null and endDate != null">
+                AND r.start_date &gt;= #{startDate}
+                AND r.start_date &lt; #{endDate}
+            </if>
+        </where>
         GROUP BY r.type_id, rt.name
         ORDER BY total_cost DESC
     </select>

+ 9 - 0
backend/start_v7.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+cd /d/demo/resource-platform/backend
+"D:/Program Files/Java/jdk-17/bin/java.exe" \
+  -Dspring.profiles.active=dev \
+  -Dserver.port=8080 \
+  -DDB_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=root \
+  -DDB_PASSWORD=Cmt123456 \
+  -jar target/resource-platform-v7.jar

+ 2 - 2
frontend/src/api/resource.js

@@ -7,6 +7,6 @@ export const resourceDeleteApi = (id) => request.delete(`/resource/${id}`)
 export const resourceRelateApi = (data) => request.post('/resource/relate', data)
 export const resourceUnrelateApi = (projectId, resourceId) =>
   request.delete('/resource/unrelate', { params: { projectId, resourceId } })
-export const costStatByProjectApi = () => request.get('/resource/cost-stat/project')
-export const costStatByTypeApi = () => request.get('/resource/cost-stat/type')
+export const costStatByProjectApi = (range) => request.get('/resource/cost-stat/project', { params: { range } })
+export const costStatByTypeApi = (range) => request.get('/resource/cost-stat/type', { params: { range } })
 export const resourceExportApi = (params) => request.get('/resource/export', { params, responseType: 'blob' })

+ 12 - 4
frontend/src/views/CostStat.vue

@@ -70,7 +70,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
 import { costStatByProjectApi, costStatByTypeApi } from '@/api/resource'
 import { useChart } from '@/composables/useChart'
 import { downloadCsv } from '@/utils/exportCsv'
@@ -217,15 +217,23 @@ const { render: renderProjectRes } = useChart(projectRes, (c) => {
   }
 })
 
-onMounted(async () => {
-  const [p, t] = await Promise.all([costStatByProjectApi(), costStatByTypeApi()])
+// 拉取指定时间范围(range)的费用统计并渲染图表; 切换本月/本季/本年时调用
+async function load() {
+  const [p, t] = await Promise.all([
+    costStatByProjectApi(range.value),
+    costStatByTypeApi(range.value)
+  ])
   byProject.value = p.data || []
   byType.value = t.data || []
   renderDonut()
   renderProjectCost()
   renderTypeCost()
   renderProjectRes()
-})
+}
+
+onMounted(load)
+// 切换时间范围时重新拉取并渲染
+watch(range, load)
 </script>
 
 <style scoped>