Przeglądaj źródła

feat(renew): 支持删除续费记录并按剩余续费回滚资源到期日

- ResourceRenewalService 增加 deleteById, 删除续费行后按剩余续费记录
  回滚资源到期日: 取最新剩余续费的新到期日, 若无剩余则回退到被删记录旧到期日
- 回滚到空到期日时状态置为使用中(ACTIVE), 避免 NPE 并与新建资源一致
- ResourceController 增加 DELETE /{id}/renewals/{renewalId}
- 前端详情页续费历史增加删除按钮(带确认弹窗)
- 离线重编译重打包为 v11 并重启
C 2 miesięcy temu
rodzic
commit
d6e2636f88

+ 126 - 0
backend/build_v11.py

@@ -0,0 +1,126 @@
+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-v10.jar")
+DST_JAR = os.path.join(BASE, "target", "resource-platform-v11.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 60 lines) ===")
+    print("\n".join(err.splitlines()[:60]))
+    sys.exit(1)
+print("compiled classes:", len(glob.glob(os.path.join(OUT, "**", "*.class"), recursive=True)))
+
+# 3) 重打包: 以 v10 为基底, 替换最新编译的 class, 并注入 src/main/resources 中的资源(xml等)
+with zipfile.ZipFile(SRC_JAR) as zin:
+    infos = zin.infolist()
+
+    def local_for(entry_name):
+        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
+        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())
+    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 里原本不存在, 需补打到新 jar
+    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:
+        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)
+        for entry, full in extra_classes:
+            with open(full, "rb") as lf:
+                data = lf.read()
+            zout.writestr(zipfile.ZipInfo(entry), data)
+
+print("built:", DST_JAR, "exists:", os.path.exists(DST_JAR))
+zm = zipfile.ZipFile(DST_JAR)
+print("manifest first:", zm.namelist()[0] == manifest_name)
+
+# 校验关键改动类已打入
+for cls in ["ResourceRenewalService.class", "ResourceRenewalServiceImpl.class",
+            "ResourceController.class"]:
+    if cls == "ResourceController.class":
+        entry = "BOOT-INF/classes/com/example/resourceplatform/controller/" + cls
+    elif cls == "ResourceRenewalServiceImpl.class":
+        entry = "BOOT-INF/classes/com/example/resourceplatform/service/impl/" + cls
+    else:
+        entry = "BOOT-INF/classes/com/example/resourceplatform/service/" + cls
+    print("contains", entry, ":", entry in zm.namelist())

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

@@ -106,6 +106,13 @@ public class ResourceController {
         return Result.success(resourceRenewalService.listByResource(id));
     }
 
+    /** 删除某条续费记录, 并回滚资源到期日 */
+    @DeleteMapping("/{id}/renewals/{renewalId}")
+    public Result<Void> deleteRenewal(@PathVariable("id") Long id, @PathVariable("renewalId") Long renewalId) {
+        resourceRenewalService.deleteById(renewalId);
+        return Result.success();
+    }
+
     /** 导出Excel,使用EasyExcel直接写到响应流 */
     @GetMapping("/export")
     public void export(ResourceQuery query, HttpServletResponse response) throws IOException {

+ 3 - 0
backend/src/main/java/com/example/resourceplatform/service/ResourceRenewalService.java

@@ -12,4 +12,7 @@ public interface ResourceRenewalService {
 
     /** 查询某资源的续费历史(按续费时间倒序) */
     List<ResourceRenewal> listByResource(Long resourceId);
+
+    /** 删除某条续费记录, 并按剩余续费回滚资源的到期日与状态 */
+    void deleteById(Long id);
 }

+ 34 - 0
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceRenewalServiceImpl.java

@@ -71,6 +71,40 @@ public class ResourceRenewalServiceImpl implements ResourceRenewalService {
     }
 
     @Override
+    @Transactional
+    public void deleteById(Long id) {
+        ResourceRenewal renewal = renewalMapper.selectById(id);
+        if (renewal == null) {
+            throw new BusinessException("续费记录不存在");
+        }
+        Long resourceId = renewal.getResourceId();
+        Resource resource = resourceMapper.selectById(resourceId);
+        if (resource == null) {
+            throw new BusinessException("资源不存在");
+        }
+        renewalMapper.deleteById(id);
+
+        // 回滚资源到期日: 取剩余续费中最新一条的新到期日; 若已无续费, 回退到本次删除记录的旧到期日
+        List<ResourceRenewal> remaining = renewalMapper.selectList(
+                new LambdaQueryWrapper<ResourceRenewal>()
+                        .eq(ResourceRenewal::getResourceId, resourceId)
+                        .orderByDesc(ResourceRenewal::getCreateTime));
+        LocalDate rolledExpire = remaining.isEmpty() ? renewal.getOldExpireDate() : remaining.get(0).getNewExpireDate();
+        resource.setExpireDate(rolledExpire);
+        if (rolledExpire != null) {
+            resource.setStatus(computeStatus(rolledExpire));
+        } else {
+            // 回滚到空到期日: 与新建资源(无到期日)保持一致, 默认使用中
+            resource.setStatus(ResourceStatus.ACTIVE.getCode());
+        }
+        resourceMapper.updateById(resource);
+
+        operationLogService.record("资源", "删除续费",
+                resource.getId(),
+                "删除续费记录 id=" + id + ",原续费至" + renewal.getNewExpireDate() + ",金额:" + renewal.getCost());
+    }
+
+    @Override
     public List<ResourceRenewal> listByResource(Long resourceId) {
         return renewalMapper.selectList(
                 new LambdaQueryWrapper<ResourceRenewal>()

+ 9 - 0
backend/start_v11.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-v11.jar

+ 1 - 0
frontend/src/api/resource.js

@@ -12,5 +12,6 @@ export const costStatByTypeApi = (range) => request.get('/resource/cost-stat/typ
 export const resourceExportApi = (params) => request.get('/resource/export', { params, responseType: 'blob' })
 export const resourceRenewApi = (id, data) => request.post(`/resource/${id}/renew`, data)
 export const resourceRenewalsApi = (id) => request.get(`/resource/${id}/renewals`)
+export const resourceDeleteRenewalApi = (id, renewalId) => request.delete(`/resource/${id}/renewals/${renewalId}`)
 export const procurementCostApi = (params) => request.get('/resource/procurement-cost', { params })
 export const procurementCostExportApi = (params) => request.get('/resource/procurement-cost/export', { params, responseType: 'blob' })

+ 25 - 1
frontend/src/views/resource/ResourceDetail.vue

@@ -66,6 +66,11 @@
         <el-table-column label="操作人" width="100">
           <template #default="{ row }">{{ operatorName(row.operatorId) }}</template>
         </el-table-column>
+        <el-table-column label="操作" width="80" fixed="right">
+          <template #default="{ row }">
+            <el-button type="danger" link size="small" @click="deleteRenewal(row)">删除</el-button>
+          </template>
+        </el-table-column>
       </el-table>
       <el-empty v-if="!renewLoading && renewals.length === 0" description="暂无续费记录" :image-size="80" />
     </div>
@@ -108,7 +113,7 @@
 import { ref, reactive, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { resourceDetailApi, resourceRenewApi, resourceRenewalsApi } from '@/api/resource'
+import { resourceDetailApi, resourceRenewApi, resourceRenewalsApi, resourceDeleteRenewalApi } from '@/api/resource'
 import { userEnabledListApi } from '@/api/user'
 import { resourceStatusClass } from '@/utils/status'
 
@@ -214,6 +219,25 @@ async function submitRenew() {
   }
 }
 
+async function deleteRenewal(row) {
+  try {
+    await ElMessageBox.confirm(
+      `确定删除这条续费记录吗?\n续费至 ${row.newExpireDate} ,金额 ¥${fmtMoney(row.cost)}。\n删除后资源的到期日将按剩余续费记录回滚。`,
+      '删除续费记录',
+      { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
+    )
+  } catch {
+    return
+  }
+  try {
+    await resourceDeleteRenewalApi(resourceId, row.id)
+    ElMessage.success('已删除续费记录')
+    await Promise.all([loadDetail(), loadRenewals()])
+  } catch (e) {
+    ElMessage.error(e?.response?.data?.message || '删除失败')
+  }
+}
+
 onMounted(async () => {
   await Promise.all([loadDetail(), loadRenewals()])
   const res = await userEnabledListApi().catch(() => ({ data: [] }))