Browse Source

feat(prompt): 系统提示词版本管理 - 修改前自动备份历史版本,支持查看版本列表与回滚

Xiaogang Liao 6 days ago
parent
commit
d07b3cb65a

+ 27 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSystemPromptController.java

@@ -5,14 +5,10 @@ import com.etotem.cfc.entity.SystemPrompt;
 import com.etotem.cfc.service.SystemPromptService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestAttribute;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 
@@ -75,4 +71,28 @@ public class AdminSystemPromptController {
         systemPromptService.removeById(id);
         return Result.success();
     }
-}
+
+    @Operation(summary = "查询提示词版本历史")
+    @GetMapping("/versions/{promptKey}")
+    public Result<List<Map<String, Object>>> listVersions(
+            @PathVariable String promptKey,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+        return Result.success(systemPromptService.listVersions(promptKey, page, size));
+    }
+
+    @Operation(summary = "回滚到指定版本")
+    @PostMapping("/rollback")
+    public Result<SystemPrompt> rollback(@RequestBody Map<String, Object> params) {
+        String promptKey = (String) params.get("promptKey");
+        Integer targetVersion = (Integer) params.get("targetVersion");
+        if (promptKey == null || promptKey.isEmpty()) {
+            return Result.error("promptKey不能为空");
+        }
+        if (targetVersion == null) {
+            return Result.error("targetVersion不能为空");
+        }
+        String operator = "admin";
+        return Result.success(systemPromptService.rollback(promptKey, targetVersion, operator));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SystemPromptVersion.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("system_prompt_versions")
+public class SystemPromptVersion implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String promptKey;
+
+    private Integer version;
+
+    private String title;
+
+    private String content;
+
+    private String operator;
+
+    private String remark;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SystemPromptVersionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SystemPromptVersion;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SystemPromptVersionMapper extends BaseMapper<SystemPromptVersion> {
+}

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/SystemPromptService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.etotem.cfc.entity.SystemPrompt;
 
+import java.util.List;
 import java.util.Map;
 
 public interface SystemPromptService extends IService<SystemPrompt> {
@@ -14,4 +15,14 @@ public interface SystemPromptService extends IService<SystemPrompt> {
     SystemPrompt savePrompt(SystemPrompt prompt, String operator);
 
     SystemPrompt resetToDefault(String promptKey);
-}
+
+    /**
+     * 查询提示词版本历史
+     */
+    List<Map<String, Object>> listVersions(String promptKey, int page, int size);
+
+    /**
+     * 回滚到指定版本
+     */
+    SystemPrompt rollback(String promptKey, int targetVersion, String operator);
+}

+ 91 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/SystemPromptServiceImpl.java

@@ -4,17 +4,26 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.SystemPrompt;
+import com.etotem.cfc.entity.SystemPromptVersion;
 import com.etotem.cfc.mapper.SystemPromptMapper;
+import com.etotem.cfc.mapper.SystemPromptVersionMapper;
 import com.etotem.cfc.service.SystemPromptService;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
+import javax.annotation.Resource;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 @Service
 public class SystemPromptServiceImpl extends ServiceImpl<SystemPromptMapper, SystemPrompt> implements SystemPromptService {
 
+    @Resource
+    private SystemPromptVersionMapper versionMapper;
+
     @Override
     public Map<String, Object> list(String keyword, int page, int size) {
         Page<SystemPrompt> p = new Page<>(page, size);
@@ -41,8 +50,15 @@ public class SystemPromptServiceImpl extends ServiceImpl<SystemPromptMapper, Sys
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public SystemPrompt savePrompt(SystemPrompt prompt, String operator) {
         if (prompt.getId() != null) {
+            // 更新时先保存历史版本
+            SystemPrompt existing = this.getById(prompt.getId());
+            if (existing != null && !existing.getContent().equals(prompt.getContent())) {
+                saveVersion(existing, prompt.getVersion(), operator, prompt.getRemark());
+                prompt.setVersion(existing.getVersion() + 1);
+            }
             prompt.setUpdatedBy(operator);
             this.updateById(prompt);
         } else {
@@ -50,6 +66,8 @@ public class SystemPromptServiceImpl extends ServiceImpl<SystemPromptMapper, Sys
             prompt.setVersion(1);
             prompt.setStatus(1);
             this.save(prompt);
+            // 新增时记录第一版
+            saveVersion(prompt, 1, operator, prompt.getRemark());
         }
         return prompt;
     }
@@ -59,10 +77,82 @@ public class SystemPromptServiceImpl extends ServiceImpl<SystemPromptMapper, Sys
         SystemPrompt prompt = this.getOne(new LambdaQueryWrapper<SystemPrompt>()
                 .eq(SystemPrompt::getPromptKey, promptKey));
         if (prompt != null && prompt.getDefaultContent() != null) {
+            saveVersion(prompt, prompt.getVersion(), "system", "重置为默认内容");
             prompt.setContent(prompt.getDefaultContent());
             prompt.setVersion(prompt.getVersion() + 1);
+            prompt.setUpdatedBy("system");
             this.updateById(prompt);
         }
         return prompt;
     }
-}
+
+    @Override
+    public List<Map<String, Object>> listVersions(String promptKey, int page, int size) {
+        LambdaQueryWrapper<SystemPromptVersion> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SystemPromptVersion::getPromptKey, promptKey)
+               .orderByDesc(SystemPromptVersion::getVersion);
+        
+        int start = (page - 1) * size;
+        List<SystemPromptVersion> versions = versionMapper.selectList(wrapper);
+        if (start > 0 && start < versions.size()) {
+            versions = versions.subList(start, Math.min(start + size, versions.size()));
+        }
+        
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (SystemPromptVersion v : versions) {
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("id", v.getId());
+            map.put("promptKey", v.getPromptKey());
+            map.put("version", v.getVersion());
+            map.put("title", v.getTitle());
+            map.put("content", v.getContent());
+            map.put("operator", v.getOperator());
+            map.put("remark", v.getRemark());
+            map.put("createdAt", v.getCreatedAt());
+            result.add(map);
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public SystemPrompt rollback(String promptKey, int targetVersion, String operator) {
+        SystemPromptVersion target = versionMapper.selectOne(
+            new LambdaQueryWrapper<SystemPromptVersion>()
+                .eq(SystemPromptVersion::getPromptKey, promptKey)
+                .eq(SystemPromptVersion::getVersion, targetVersion));
+        
+        if (target == null) {
+            throw new RuntimeException("版本不存在: " + targetVersion);
+        }
+        
+        SystemPrompt prompt = this.getOne(new LambdaQueryWrapper<SystemPrompt>()
+                .eq(SystemPrompt::getPromptKey, promptKey));
+        if (prompt == null) {
+            throw new RuntimeException("提示词不存在: " + promptKey);
+        }
+        
+        // 保存当前版本(回滚前的版本)
+        saveVersion(prompt, prompt.getVersion(), operator, "回滚前备份");
+        
+        // 恢复到目标版本
+        prompt.setContent(target.getContent());
+        prompt.setVersion(prompt.getVersion() + 1);
+        prompt.setUpdatedBy("rollback_" + operator);
+        this.updateById(prompt);
+        
+        return prompt;
+    }
+
+    private void saveVersion(SystemPrompt prompt, int version, String operator, String remark) {
+        SystemPromptVersion versionRecord = new SystemPromptVersion();
+        versionRecord.setPromptKey(prompt.getPromptKey());
+        versionRecord.setVersion(version);
+        versionRecord.setTitle(prompt.getTitle());
+        versionRecord.setContent(prompt.getContent());
+        versionRecord.setOperator(operator);
+        versionRecord.setRemark(remark);
+        versionRecord.setCreatedAt(new Date());
+        versionMapper.insert(versionRecord);
+    }
+}

+ 13 - 1
cfc-backend/src/main/resources/schema.sql

@@ -5226,7 +5226,19 @@ CREATE TABLE IF NOT EXISTS system_prompts (
     updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_prompt_key (prompt_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统提示词管理';
-
+-- 系统提示词版本历史表(版本管理:修改前自动备份,支持回滚)
+CREATE TABLE IF NOT EXISTS system_prompt_versions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    prompt_key VARCHAR(64) NOT NULL COMMENT '提示词键',
+    version INT NOT NULL COMMENT '版本号',
+    title VARCHAR(100) NOT NULL COMMENT '提示词标题',
+    content TEXT NOT NULL COMMENT '提示词内容',
+    operator VARCHAR(100) COMMENT '操作人',
+    remark VARCHAR(500) COMMENT '变更说明',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_prompt_key (prompt_key),
+    INDEX idx_version (prompt_key, version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统提示词版本历史';
 -- 套餐商品组成明细表(套餐 = 商品 productType=bundle,子商品在 product_bundle_items 中配置)
 CREATE TABLE IF NOT EXISTS product_bundle_items (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 25 - 9
cfc-web/src/api/systemPrompt.js

@@ -1,34 +1,50 @@
-import request from '@/utils/request'
+import request from "@/utils/request"
 
 // 系统提示词管理 API
 export function getPromptList(params) {
   return request({
-    url: '/api/admin/system-prompt/list',
-    method: 'post',
+    url: "/api/admin/system-prompt/list",
+    method: "post",
     data: params
   })
 }
 
 export function savePrompt(data) {
   return request({
-    url: '/api/admin/system-prompt/save',
-    method: 'post',
+    url: "/api/admin/system-prompt/save",
+    method: "post",
     data
   })
 }
 
 export function resetPrompt(promptKey) {
   return request({
-    url: '/api/admin/system-prompt/reset',
-    method: 'post',
+    url: "/api/admin/system-prompt/reset",
+    method: "post",
     data: { promptKey }
   })
 }
 
 export function deletePrompt(id) {
   return request({
-    url: '/api/admin/system-prompt/delete',
-    method: 'post',
+    url: "/api/admin/system-prompt/delete",
+    method: "post",
     data: { id }
   })
 }
+
+export function listVersions(promptKey, page = 1, size = 20) {
+  return request({
+    url: `/api/admin/system-prompt/versions/${promptKey}`,
+    method: "get",
+    params: { page, size }
+  })
+}
+
+export function rollbackPrompt(promptKey, targetVersion) {
+  return request({
+    url: "/api/admin/system-prompt/rollback",
+    method: "post",
+    data: { promptKey, targetVersion }
+  })
+}

+ 85 - 45
cfc-web/src/views/admin/SystemPromptManagement.vue

@@ -22,23 +22,21 @@
         <el-table-column prop="version" label="版本" width="60" />
         <el-table-column prop="status" label="状态" width="70">
           <template slot-scope="{ row }">
-            <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
-              {{ row.status === 1 ? '启用' : '禁用' }}
+            <el-tag :type="row.status === 1 ? "success" : "info"" size="small">
+              {{ row.status === 1 ? "启用" : "禁用" }}
             </el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="updatedAt" label="更新时间" width="160">
           <template slot-scope="{ row }">
-            {{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
-          </template>
-          <template slot="header">
-            <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+            {{ row.updatedAt ? formatTime(row.updatedAt) : "-" }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="180" fixed="right">
+        <el-table-column label="操作" width="240" fixed="right">
           <template slot-scope="{ row }">
             <el-button type="text" size="small" @click="showEdit(row)">编辑</el-button>
             <el-button type="text" size="small" @click="showPreview(row)">预览</el-button>
+            <el-button type="text" size="small" @click="showVersions(row)">版本</el-button>
             <el-button type="text" size="small" @click="resetToDefault(row)">重置</el-button>
             <el-button type="text" size="small" style="color:#f56c6c" @click="deleteRow(row)">删除</el-button>
           </template>
@@ -56,7 +54,7 @@
     </el-card>
 
     <!-- 编辑弹窗 -->
-    <el-dialog :visible.sync="dialog.visible" :title="dialog.isEdit ? '编辑提示词' : '新增提示词'" width="700px" :close-on-click-modal="false">
+    <el-dialog :visible.sync="dialog.visible" :title="dialog.isEdit ? "编辑提示词" : "新增提示词"" width="700px" :close-on-click-modal="false">
       <el-form :model="dialog.form" label-width="100px" size="small">
         <el-form-item label="提示词键" :required="!dialog.isEdit">
           <el-input v-model="dialog.form.promptKey" placeholder="如 health_coach(英文下划线)" :disabled="dialog.isEdit" />
@@ -74,7 +72,7 @@
           <el-input v-model="dialog.form.content" type="textarea" :rows="18" placeholder="输入系统提示词内容..." />
         </el-form-item>
         <el-form-item label="备注">
-          <el-input v-model="dialog.form.remark" placeholder="用途说明" />
+          <el-input v-model="dialog.form.remark" placeholder="本次变更说明(可选)" />
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -92,49 +90,78 @@
         <el-button size="small" @click="previewVisible = false">关闭</el-button>
       </div>
     </el-dialog>
+
+    <!-- 版本历史弹窗 -->
+    <el-dialog title="版本历史" :visible.sync="versionDialog.visible" width="900px">
+      <div style="margin-bottom:12px; color:#666; font-size:13px;">
+        当前提示词: <strong>{{ versionDialog.promptKey }}</strong> — {{ versionDialog.title }}
+      </div>
+      <el-table :data="versionDialog.versions" border stripe size="small" max-height="400">
+        <el-table-column prop="version" label="版本号" width="80" align="center">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.version === currentVersion ? "primary" : "default"" size="mini">
+              v{{ row.version }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="operator" label="操作人" width="100" />
+        <el-table-column prop="remark" label="说明" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="createdAt" label="时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button 
+              v-if="row.version !== currentVersion"
+              type="text" size="small" 
+              @click="doRollback(versionDialog.promptKey, row.version, row.title)"
+              :disabled="row.version === currentVersion">
+              回滚到 v{{ row.version }}
+            </el-button>
+            <span v-else style="color:#909399; font-size:12px;">当前版本</span>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div slot="footer">
+        <el-button size="small" @click="versionDialog.visible = false">关闭</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import SortMixin from '@/mixins/SortMixin'
-import { getPromptList, savePrompt, resetPrompt, deletePrompt } from '@/api/systemPrompt'
+import { getPromptList, savePrompt, resetPrompt, deletePrompt, listVersions, rollbackPrompt } from "@/api/systemPrompt"
 
 export default {
-  name: 'SystemPromptManagement',
-  mixins: [SortMixin],
+  name: "SystemPromptManagement",
   data() {
     return {
       list: [],
       loading: false,
-      filters: { keyword: '' },
+      filters: { keyword: "" },
       page: 1,
       size: 20,
       total: 0,
       dialog: { visible: false, isEdit: false, form: {} },
       saving: false,
       previewVisible: false,
-      previewContent: '',
-      sortableColumns: { updatedAt: '更新时间' }
+      previewContent: "",
+      versionDialog: { visible: false, promptKey: "", title: "", versions: [], currentVersion: 0 }
     }
   },
   created() {
     this.fetchList()
   },
   methods: {
-    loadList() {
-      this.fetchList()
-    },
     formatTime(ts) {
-      if (!ts) return ''
+      if (!ts) return ""
       const d = new Date(ts)
-      return d.toLocaleString('zh-CN', { hour12: false })
+      return d.toLocaleString("zh-CN", { hour12: false })
     },
     async fetchList() {
       this.loading = true
       try {
-        var params = { keyword: this.filters.keyword, page: this.page, size: this.size }
-        if (this.sortState.length > 0) params.sort = this.sortState
-        const res = await getPromptList(params)
+        const res = await getPromptList({ keyword: this.filters.keyword, page: this.page, size: this.size })
         if (res && res.data) {
           this.list = res.data.records || []
           this.total = res.data.total || 0
@@ -145,19 +172,19 @@ export default {
     },
     showEdit(row) {
       this.dialog.isEdit = !!row
-      this.dialog.form = row ? Object.assign({}, row) : { promptKey: '', title: '', content: '', status: 1, remark: '' }
+      this.dialog.form = row ? Object.assign({}, row) : { promptKey: "", title: "", content: "", status: 1, remark: "" }
       this.dialog.visible = true
     },
     async savePrompt() {
       const form = this.dialog.form
       if (!form.promptKey || !form.title || !form.content) {
-        this.$message.warning('请填写完整')
+        this.$message.warning("请填写完整")
         return
       }
       this.saving = true
       try {
         await savePrompt(form)
-        this.$message.success('保存成功')
+        this.$message.success("保存成功")
         this.dialog.visible = false
         this.fetchList()
       } finally {
@@ -165,16 +192,16 @@ export default {
       }
     },
     async resetToDefault(row) {
-      this.$confirm('确定要重置为出厂默认内容吗?', '提示', { type: 'warning' }).then(async () => {
+      this.$confirm("确定要重置为出厂默认内容吗?", "提示", { type: "warning" }).then(async () => {
         await resetPrompt(row.promptKey)
-        this.$message.success('已重置')
+        this.$message.success("已重置")
         this.fetchList()
       }).catch(() => {})
     },
     deleteRow(row) {
-      this.$confirm('确定要删除该提示词吗?', '提示', { type: 'warning' }).then(async () => {
+      this.$confirm("确定要删除该提示词吗?", "提示", { type: "warning" }).then(async () => {
         await deletePrompt(row.id)
-        this.$message.success('删除成功')
+        this.$message.success("删除成功")
         this.fetchList()
       }).catch(() => {})
     },
@@ -182,6 +209,33 @@ export default {
       this.previewContent = row.content
       this.previewVisible = true
     },
+    async showVersions(row) {
+      this.versionDialog.promptKey = row.promptKey
+      this.versionDialog.title = row.title
+      this.versionDialog.currentVersion = row.version
+      this.versionDialog.versions = []
+      this.versionDialog.visible = true
+      try {
+        const res = await listVersions(row.promptKey)
+        if (res && res.data) {
+          this.versionDialog.versions = res.data
+        }
+      } catch (e) {
+        this.$message.error("加载版本历史失败")
+      }
+    },
+    async doRollback(promptKey, targetVersion, targetTitle) {
+      this.$confirm(`确定要回滚到 v${targetVersion}(${targetTitle})吗?当前版本将被备份后恢复。`, "确认回滚", { type: "warning" }).then(async () => {
+        try {
+          await rollbackPrompt(promptKey, targetVersion)
+          this.$message.success("回滚成功")
+          this.versionDialog.visible = false
+          this.fetchList()
+        } catch (e) {
+          this.$message.error("回滚失败")
+        }
+      }).catch(() => {})
+    },
     handlePageChange(page) {
       this.page = page
       this.fetchList()
@@ -194,18 +248,4 @@ export default {
 .system-prompt-page {
   padding: 0;
 }
-.sort-header {
-  cursor: pointer;
-  user-select: none;
-  font-weight: 500;
-  font-size: 12px;
-}
-.sort-header:hover {
-  color: #409eff;
-}
-.sort-tag {
-  margin-left: 8px;
-  cursor: pointer;
-  white-space: nowrap;
-}
 </style>