Junjie
昨天 71fcaf8628e10b57e848a8b4b633513df78ecd63
#AI LLM路由
2个文件已修改
343 ■■■■■ 已修改文件
src/main/java/com/zy/ai/controller/LlmRouteConfigController.java 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/llm_config.html 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/LlmRouteConfigController.java
@@ -3,17 +3,24 @@
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zy.ai.entity.LlmRouteConfig;
import com.zy.ai.service.LlmRouteConfigService;
import com.zy.ai.service.LlmRoutingService;
import com.zy.common.web.BaseController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/ai/llm/config")
@RequiredArgsConstructor
@@ -21,6 +28,7 @@
    private final LlmRouteConfigService llmRouteConfigService;
    private final LlmRoutingService llmRoutingService;
    private final ObjectMapper objectMapper;
    @GetMapping("/list/auth")
    @ManagerAuth
@@ -125,6 +133,197 @@
        return R.ok(data);
    }
    @GetMapping("/export/auth")
    @ManagerAuth
    public R exportConfig() {
        EntityWrapper<LlmRouteConfig> wrapper = new EntityWrapper<>();
        wrapper.orderBy("priority", true).orderBy("id", true);
        List<LlmRouteConfig> list = llmRouteConfigService.selectList(wrapper);
        List<Map<String, Object>> routes = new ArrayList<>();
        if (list != null) {
            for (LlmRouteConfig cfg : list) {
                routes.add(exportRow(cfg));
            }
        }
        HashMap<String, Object> result = new HashMap<>();
        result.put("version", "1.0");
        result.put("exportTime", new Date());
        result.put("count", routes.size());
        result.put("routes", routes);
        return R.ok(result);
    }
    @PostMapping("/import/auth")
    @ManagerAuth
    @Transactional(rollbackFor = Exception.class)
    public R importConfig(@RequestBody Object body) {
        boolean replace = false;
        List<?> rawRoutes = null;
        if (body instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) body;
            replace = parseBoolean(map.get("replace"));
            Object routesObj = map.get("routes");
            if (routesObj instanceof List) {
                rawRoutes = (List<?>) routesObj;
            }
        } else if (body instanceof List) {
            rawRoutes = (List<?>) body;
        }
        if (rawRoutes == null || rawRoutes.isEmpty()) {
            return R.error("导入数据为空或格式不正确,必须包含 routes 数组");
        }
        int inserted = 0;
        int updated = 0;
        int skipped = 0;
        List<String> errors = new ArrayList<>();
        List<LlmRouteConfig> validRoutes = new ArrayList<>();
        for (int i = 0; i < rawRoutes.size(); i++) {
            Object row = rawRoutes.get(i);
            LlmRouteConfig cfg;
            try {
                cfg = objectMapper.convertValue(row, LlmRouteConfig.class);
            } catch (Exception e) {
                skipped++;
                errors.add("第" + (i + 1) + "条解析失败: " + safeMsg(e.getMessage()));
                continue;
            }
            if (cfg == null) {
                skipped++;
                errors.add("第" + (i + 1) + "条为空");
                continue;
            }
            cfg.setName(trim(cfg.getName()));
            cfg.setBaseUrl(trim(cfg.getBaseUrl()));
            cfg.setApiKey(trim(cfg.getApiKey()));
            cfg.setModel(trim(cfg.getModel()));
            cfg.setMemo(trim(cfg.getMemo()));
            if (isBlank(cfg.getBaseUrl()) || isBlank(cfg.getApiKey()) || isBlank(cfg.getModel())) {
                skipped++;
                errors.add("第" + (i + 1) + "条缺少必填字段 baseUrl/apiKey/model");
                continue;
            }
            validRoutes.add(cfg);
        }
        if (validRoutes.isEmpty()) {
            String firstError = errors.isEmpty() ? "" : (",首条原因:" + errors.get(0));
            return R.error("导入失败:没有可用配置" + firstError);
        }
        if (replace) {
            llmRouteConfigService.delete(new EntityWrapper<LlmRouteConfig>());
        }
        HashMap<Long, LlmRouteConfig> dbById = new HashMap<>();
        if (!replace) {
            List<LlmRouteConfig> current = llmRouteConfigService.selectList(new EntityWrapper<>());
            if (current != null) {
                for (LlmRouteConfig item : current) {
                    if (item != null && item.getId() != null) {
                        dbById.put(item.getId(), item);
                    }
                }
            }
        }
        for (LlmRouteConfig cfg : validRoutes) {
            if (!replace && cfg.getId() != null && dbById.containsKey(cfg.getId())) {
                LlmRouteConfig db = dbById.get(cfg.getId());
                Long keepId = db.getId();
                Date createTime = db.getCreateTime();
                Integer failCount = db.getFailCount();
                Integer successCount = db.getSuccessCount();
                Integer consecutiveFailCount = db.getConsecutiveFailCount();
                Date lastFailTime = db.getLastFailTime();
                Date lastUsedTime = db.getLastUsedTime();
                String lastError = db.getLastError();
                Date cooldownUntil = db.getCooldownUntil();
                llmRoutingService.fillAndNormalize(cfg, false);
                cfg.setId(keepId);
                cfg.setCreateTime(createTime);
                cfg.setFailCount(failCount);
                cfg.setSuccessCount(successCount);
                cfg.setConsecutiveFailCount(consecutiveFailCount);
                cfg.setLastFailTime(lastFailTime);
                cfg.setLastUsedTime(lastUsedTime);
                cfg.setLastError(lastError);
                cfg.setCooldownUntil(cooldownUntil);
                llmRouteConfigService.updateById(cfg);
                updated++;
                continue;
            }
            cfg.setId(null);
            cfg.setCooldownUntil(null);
            cfg.setFailCount(0);
            cfg.setSuccessCount(0);
            cfg.setConsecutiveFailCount(0);
            cfg.setLastFailTime(null);
            cfg.setLastUsedTime(null);
            cfg.setLastError(null);
            llmRoutingService.fillAndNormalize(cfg, true);
            llmRouteConfigService.insert(cfg);
            inserted++;
        }
        llmRoutingService.evictCache();
        HashMap<String, Object> result = new HashMap<>();
        result.put("replace", replace);
        result.put("total", rawRoutes.size());
        result.put("inserted", inserted);
        result.put("updated", updated);
        result.put("skipped", skipped);
        result.put("errorCount", errors.size());
        if (!errors.isEmpty()) {
            int max = Math.min(errors.size(), 20);
            result.put("errors", errors.subList(0, max));
        }
        log.info("LLM路由导入完成, replace={}, total={}, inserted={}, updated={}, skipped={}",
                replace, rawRoutes.size(), inserted, updated, skipped);
        return R.ok(result);
    }
    private Map<String, Object> exportRow(LlmRouteConfig cfg) {
        LinkedHashMap<String, Object> row = new LinkedHashMap<>();
        row.put("id", cfg.getId());
        row.put("name", cfg.getName());
        row.put("baseUrl", cfg.getBaseUrl());
        row.put("apiKey", cfg.getApiKey());
        row.put("model", cfg.getModel());
        row.put("thinking", cfg.getThinking());
        row.put("priority", cfg.getPriority());
        row.put("status", cfg.getStatus());
        row.put("switchOnQuota", cfg.getSwitchOnQuota());
        row.put("switchOnError", cfg.getSwitchOnError());
        row.put("cooldownSeconds", cfg.getCooldownSeconds());
        row.put("memo", cfg.getMemo());
        return row;
    }
    private boolean parseBoolean(Object x) {
        if (x instanceof Boolean) return (Boolean) x;
        if (x == null) return false;
        String s = String.valueOf(x).trim();
        return "1".equals(s) || "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s);
    }
    private String trim(String s) {
        return s == null ? null : s.trim();
    }
    private String safeMsg(String s) {
        if (s == null) return "";
        return s.length() > 200 ? s.substring(0, 200) : s;
    }
    private boolean isBlank(String s) {
        return s == null || s.trim().isEmpty();
    }
src/main/webapp/views/ai/llm_config.html
@@ -47,6 +47,13 @@
      font-size: 12px;
      opacity: 0.9;
    }
    .hero-actions {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
      justify-content: flex-end;
    }
    .summary-grid {
      margin-top: 10px;
      display: grid;
@@ -260,8 +267,10 @@
          <div class="sub">支持多API、多模型、多Key,额度耗尽或故障自动切换</div>
        </div>
      </div>
      <div>
      <div class="hero-actions">
        <el-button type="primary" size="mini" @click="addRoute">新增路由</el-button>
        <el-button size="mini" @click="exportRoutes">导出JSON</el-button>
        <el-button size="mini" @click="triggerImport">导入JSON</el-button>
        <el-button size="mini" @click="loadRoutes">刷新</el-button>
        <el-button size="mini" @click="openLogDialog">调用日志</el-button>
      </div>
@@ -289,6 +298,7 @@
      </div>
    </div>
  </div>
  <input ref="importFileInput" type="file" accept="application/json,.json" style="display:none;" @change="handleImportFileChange" />
  <div class="route-board" v-loading="loading">
    <div v-if="!routes || routes.length === 0" class="empty-shell">
@@ -597,6 +607,138 @@
      authHeaders: function() {
        return { 'token': localStorage.getItem('token') };
      },
      exportRoutes: function() {
        var self = this;
        fetch(baseUrl + '/ai/llm/config/export/auth', { headers: self.authHeaders() })
          .then(function(r){ return r.json(); })
          .then(function(res){
            if (!res || res.code !== 200) {
              self.$message.error((res && res.msg) ? res.msg : '导出失败');
              return;
            }
            var payload = res.data || {};
            var text = JSON.stringify(payload, null, 2);
            var name = 'llm_routes_' + self.buildExportTimestamp() + '.json';
            var blob = new Blob([text], { type: 'application/json;charset=utf-8' });
            var a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = name;
            document.body.appendChild(a);
            a.click();
            setTimeout(function() {
              URL.revokeObjectURL(a.href);
              document.body.removeChild(a);
            }, 0);
            self.$message.success('导出成功');
          })
          .catch(function(){
            self.$message.error('导出失败');
          });
      },
      buildExportTimestamp: function() {
        var d = new Date();
        var pad = function(n) { return n < 10 ? ('0' + n) : String(n); };
        return d.getFullYear()
          + pad(d.getMonth() + 1)
          + pad(d.getDate())
          + '_'
          + pad(d.getHours())
          + pad(d.getMinutes())
          + pad(d.getSeconds());
      },
      triggerImport: function() {
        var input = this.$refs.importFileInput;
        if (!input) return;
        input.value = '';
        input.click();
      },
      handleImportFileChange: function(evt) {
        var self = this;
        var files = evt && evt.target && evt.target.files ? evt.target.files : null;
        var file = files && files.length > 0 ? files[0] : null;
        if (!file) return;
        var reader = new FileReader();
        reader.onload = function(e) {
          var text = e && e.target ? e.target.result : '';
          var parsed;
          try {
            parsed = JSON.parse(text || '{}');
          } catch (err) {
            self.$message.error('JSON 格式不正确');
            return;
          }
          var routes = self.extractImportRoutes(parsed);
          if (!routes || routes.length === 0) {
            self.$message.warning('未找到可导入的 routes');
            return;
          }
          self.$confirm(
            '请选择导入方式:覆盖导入会先清空现有路由;点击“合并导入”则按ID更新或新增。',
            '导入确认',
            {
              type: 'warning',
              distinguishCancelAndClose: true,
              confirmButtonText: '覆盖导入',
              cancelButtonText: '合并导入',
              closeOnClickModal: false
            }
          ).then(function() {
            self.doImportRoutes(routes, true);
          }).catch(function(action) {
            if (action === 'cancel') {
              self.doImportRoutes(routes, false);
            }
          });
        };
        reader.onerror = function() {
          self.$message.error('读取文件失败');
        };
        reader.readAsText(file, 'utf-8');
      },
      extractImportRoutes: function(parsed) {
        if (Array.isArray(parsed)) return parsed;
        if (!parsed || typeof parsed !== 'object') return [];
        if (Array.isArray(parsed.routes)) return parsed.routes;
        if (parsed.data && Array.isArray(parsed.data.routes)) return parsed.data.routes;
        if (Array.isArray(parsed.data)) return parsed.data;
        return [];
      },
      doImportRoutes: function(routes, replace) {
        var self = this;
        fetch(baseUrl + '/ai/llm/config/import/auth', {
          method: 'POST',
          headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()),
          body: JSON.stringify({
            replace: replace === true,
            routes: routes
          })
        })
          .then(function(r){ return r.json(); })
          .then(function(res){
            if (!res || res.code !== 200) {
              self.$message.error((res && res.msg) ? res.msg : '导入失败');
              return;
            }
            var d = res.data || {};
            var msg = '导入完成:新增 ' + (d.inserted || 0)
              + ',更新 ' + (d.updated || 0)
              + ',跳过 ' + (d.skipped || 0);
            if (d.errorCount && d.errorCount > 0) {
              msg += ',异常 ' + d.errorCount;
            }
            self.$message.success(msg);
            if (Array.isArray(d.errors) && d.errors.length > 0) {
              self.$alert(d.errors.join('\n'), '导入异常明细(最多20条)', {
                confirmButtonText: '确定',
                type: 'warning'
              });
            }
            self.loadRoutes();
          })
          .catch(function(){
            self.$message.error('导入失败');
          });
      },
      handleRouteCommand: function(command, route, idx) {
        if (command === 'test') return this.testRoute(route);
        if (command === 'save') return this.saveRoute(route);