diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f8ce035 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# xj-oss-syn 项目说明 + +## 项目目标 +1. **OSS 同步**:将业务系统中存储在本地的文件批量上传到新疆油田 IOSP OSS 平台,并将业务表中的文件路径更新为 OSS 路径。 +2. **镜像管理**:通过 Harbor API + skopeo CLI 查询镜像仓库列表、最新 tag,并支持将 tar 包推送到 Harbor。 +3. **控制台实例管理**:通过 Choerodon DevOps API 查询实例列表、Pod 状态、YAML 配置,并支持更新配置。 + +## 技术栈 +- 框架:Solon 3.10.0 +- ORM:MyBatis-Plus(solon 插件) +- 数据库:MySQL 8.x(HikariCP 连接池) +- HTTP 客户端:Java 内置 `java.net.http.HttpClient`(trust-all SSLContext,支持 NEVER/NORMAL 重定向策略) +- JSON:Fastjson2 +- 认证:Sa-Token(单账号 HTTP Basic,凭证来自 `sa-token.http-basic` 配置) +- 模板引擎:FreeMarker(`src/main/resources/templates/`) +- 构建:Maven + solon-maven-plugin + +## 包结构 +``` +com.yxtech.ossyn +├── App.java # 启动入口 +├── config/ +│ ├── BizDataSourceConfig.java # 业务库连接公共参数 +│ ├── BizFileMappingConfig.java # 业务表映射配置(来自 yml) +│ ├── ConsoleConfig.java # Choerodon 控制台参数 +│ ├── HarborConfig.java # Harbor 镜像仓库参数 +│ ├── LocalFileConfig.java # 本地文件根路径 +│ ├── MybatisPlusConfig.java # MyBatis-Plus 分页插件等 +│ ├── OssConfig.java # OSS 连接参数 +│ ├── SaTokenConfig.java # Sa-Token 路由白名单 +│ └── SkopeoConfig.java # skopeo 可执行文件及镜像目录 +├── controller/ +│ ├── AuthController.java # 登录/登出页面(/auth/*) +│ ├── BizFileMappingController.java # 映射配置 CRUD 接口 +│ ├── DevopsController.java # 镜像管理 + 控制台实例接口(/devops/*) +│ └── OssSyncController.java # OSS 同步触发接口(/oss-sync/*) +├── entity/ +│ ├── BizFileMappingEntity.java # 业务表映射配置实体 +│ ├── QhOssFileLog.java # 文件转换日志实体 +│ └── SyncTask.java # 同步任务实体 +├── filter/ +│ └── GlobalExceptionFilter.java # 全局异常处理(404 DEBUG / 其他 ERROR) +├── mapper/ +│ ├── BizFileMappingMapper.java +│ ├── QhOssFileLogMapper.java +│ └── SyncTaskMapper.java +├── service/ +│ ├── ConsoleService.java # Choerodon 控制台 API(token 缓存 + 401 重试) +│ ├── HarborService.java # Harbor API + skopeo push(trust-all SSL) +│ └── OssSyncService.java # OSS 同步核心逻辑 +└── util/ + ├── DynamicDataSourceUtil.java # 动态数据源(按 dbName 创建连接池) + └── OssUtil.java # OSS 上传工具(内存缓存 token) +``` + +## 数据库 +- **数据源1(本项目)**:存储 `QH_OSS_FILE_LOG` 日志表,建表脚本见 `sql/init.sql` +- **数据源2(业务库)**:动态创建,根据 `biz-file-mappings` 配置的 dbName 连接 + +## 配置说明(app.yml) +| 配置项 | 说明 | +|--------|------| +| `solon.dataSources.db1!` | 本项目数据库连接 | +| `server.request-maxBodySize` | 请求体大小上限(当前 1GB,支持大镜像上传) | +| `biz-datasource` | 业务库公共连接参数(host/port/user/pass) | +| `biz-file-mappings` | 业务表映射列表(dbName/tableName/idCol/fileCol) | +| `local-file.root-path` | 本地文件根路径 | +| `xj-oss.*` | OSS 网关、鉴权、目录等参数 | +| `harbor.*` | Harbor 地址、项目名、账号密码 | +| `skopeo.exe-path` | skopeo.exe 可执行文件路径 | +| `skopeo.images-path` | 镜像 tar 包存放目录 | +| `console.api-base` | Choerodon DevOps API 网关地址 | +| `console.project-id` | 控制台项目 ID | +| `console.env-id` | 控制台环境 ID | +| `console.login-username/password` | 控制台登录账号 | +| `console.public-key` | 控制台 RSA 公钥(用于密码加密) | +| `sa-token.http-basic` | 本系统登录凭证(格式:用户名:密码) | + +> 敏感配置(密码、密钥等)可通过 `app-env.yml` 覆盖,该文件不纳入版本管理。 + +## HTTP 接口 +### 认证(AuthController) +| 接口 | 说明 | +|------|------| +| GET /auth/login | 显示登录页 | +| POST /auth/doLogin | 处理登录(凭证来自 sa-token.http-basic) | +| GET /auth/logout | 退出登录 | + +### OSS 同步(OssSyncController) +| 接口 | 说明 | +|------|------| +| POST /oss-sync/all | 一键全量同步(扫描+上传+回写) | +| POST /oss-sync/scan | 仅扫描业务表,写入日志 | +| POST /oss-sync/upload | 仅上传待处理文件 | +| POST /oss-sync/write-back | 仅回写业务表 | + +### DevOps(DevopsController) +| 接口 | 说明 | +|------|------| +| GET /devops/harbor/repositories | 查询 Harbor 镜像仓库列表 | +| GET /devops/harbor/latest-tag?repo= | 查询指定仓库最新 tag | +| POST /devops/harbor/push | 上传 tar 包并推送到 Harbor(multipart,自动计算下一个 tag) | +| POST /devops/console/instances | 查询所有实例(全量 size=500) | +| POST /devops/console/pods | 查询实例 Pod 列表 | +| GET /devops/console/yaml | 查询实例 YAML 配置 | +| PUT /devops/console/yaml | 更新实例 YAML 配置 | + +## OSS 处理状态说明 +| 值 | 含义 | +|----|------| +| 0 | 待处理 | +| 1 | 上传成功 | +| 2 | 上传失败 | +| 3 | 文件不存在 | +| 4 | 已回写业务表 | + +## 关键设计说明 + +### Choerodon 控制台登录流程 +登录需要两步(均需要 `NEVER` 重定向策略才能拿到 302 响应头): +1. `POST /oauth/choerodon/login` → 收集响应中所有 `Set-Cookie`(`allValues`),拼成完整 Cookie 字符串 +2. `GET /oauth/oauth/authorize` 携带 Cookie → 从 302 响应的 `Location` URL fragment(`#` 后)提取 `access_token` + +- `authClient`:`NEVER` 重定向(登录/授权专用) +- `apiClient`:`NORMAL` 重定向(业务请求专用) +- token 内存缓存,有效期 ~24h;收到 401 自动清除缓存并重试一次 + +### SSL 证书 +内部域名使用自签证书,`HarborService.buildTrustAllClient()` 构造 trust-all `SSLContext`, +所有 `HttpClient` 实例均通过该方法创建,跳过证书验证。 + +### 镜像上传(skopeo) +``` +skopeo copy --dest-creds user:pass --dest-tls-verify=false \ + docker-archive:{tarAbsPath} \ + docker://{harborHost}/{project}/{repo}:{tag} +``` +- tar 包按日期子目录存放(`imagesPath/yyyyMMdd/filename.tar`) +- tag 自动在最新 tag 基础上末位 +1(如 `1.0.10` → `1.0.11`) + +### 注意事项 +- 扫描阶段幂等,同一业务表+ID+列已存在日志则跳过 +- OSS token 使用内存缓存,无需 Redis +- 动态数据源按 dbName 缓存连接池,避免重复创建 +- 业务库列名/表名来自内部配置,不存在 SQL 注入风险 +- 文件上传暂不支持 JSON 类型(TXX-19,待排查) + +## Linear 任务追踪 +所有功能任务和 Bug 统一记录到 Linear `tar-update` 项目(团队 `txxz`)。 diff --git a/src/main/java/com/yxtech/ossyn/App.java b/src/main/java/com/yxtech/ossyn/App.java index 059669c..ca5887d 100644 --- a/src/main/java/com/yxtech/ossyn/App.java +++ b/src/main/java/com/yxtech/ossyn/App.java @@ -3,6 +3,16 @@ package com.yxtech.ossyn; import org.noear.solon.Solon; import org.noear.solon.annotation.SolonMain; +import javax.crypto.Cipher; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + /** * 应用程序入口 */ @@ -11,4 +21,149 @@ public class App { public static void main(String[] args) { Solon.start(App.class, args); } + + +// private static final String BASE_URL = "http://api.dev.xjyt.petrochina/oauth/choerodon/login"; // 替换为实际域名 +// private static final String PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ=="; +// +// /** +// * RSA 加密密码 +// */ +// public static String encryptPassword(String password) throws Exception { +// byte[] keyBytes = Base64.getDecoder().decode(PUBLIC_KEY); +// X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); +// PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); +// +// Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); +// cipher.init(Cipher.ENCRYPT_MODE, publicKey); +// byte[] encrypted = cipher.doFinal(password.getBytes("UTF-8")); +// return Base64.getEncoder().encodeToString(encrypted); +// } +// +// /** +// * 登录,返回 Cookie(后续请求需携带) +// */ +// public static String login(String username, String password) throws Exception { +// String encryptedPassword = encryptPassword(password); +// +// // 构建 form 表单体 +// String formBody = "username=" + java.net.URLEncoder.encode(username, "UTF-8") +// + "&password=" + java.net.URLEncoder.encode(encryptedPassword, "UTF-8"); +// +// // 打印等效的 curl 命令(Windows 格式) +// System.out.println("curl \"" + BASE_URL + "\" ^"); +// System.out.println(" -H \"Origin: http://api.dev.xjyt.petrochina\" ^"); +// System.out.println(" -H \"Content-Type: application/x-www-form-urlencoded\" ^"); +// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^"); +// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\" ^"); +// System.out.println(" -H \"Referer: http://api.dev.xjyt.petrochina/oauth/choerodon/login\" ^"); +// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^"); +// System.out.println(" -H \"Cookie: SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb\" ^"); +// System.out.println(" --data-raw \"" + formBody + "\""); +// +// HttpClient client = HttpClient.newBuilder() +// .followRedirects(HttpClient.Redirect.NEVER) +// .build(); +// +// HttpRequest request = HttpRequest.newBuilder() +// .uri(URI.create(BASE_URL)) +// .header("Origin", "http://api.dev.xjyt.petrochina") +// .header("Content-Type", "application/x-www-form-urlencoded") +// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36") +// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9") +// .header("Referer", "http://api.dev.xjyt.petrochina/oauth/choerodon/login") +// .header("Accept-Language", "zh-CN,zh;q=0.9") +// .header("Cookie", "SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb") +// .POST(HttpRequest.BodyPublishers.ofString(formBody)) +// .build(); +// +// HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); +// +// System.out.println("状态码: " + response.statusCode()); +// System.out.println("响应体: " + response.body()); +// +// // 登录成功后服务器返回 302,Set-Cookie 中携带新 Session +// String cookie = response.headers().firstValue("Set-Cookie").orElse(null); +// System.out.println("Cookie: " + cookie); +// +// return cookie; +// } +// +// /** +// * OAuth 授权,返回重定向地址(含 access_token) +// */ +// public static String authorize(String cookie) throws Exception { +// String url = "http://api.dev.xjyt.petrochina/oauth/oauth/authorize" + +// "?response_type=token&client_id=choerodon&state=&redirect_uri=" + +// java.net.URLEncoder.encode("http://dev.xjyt.petrochina/#/devops/resource" + +// "?activeKey=instance&category=AGILE&id=748209321615708160" + +// "&name=%E5%85%A5%E5%BA%B7%E7%AE%A1%E7%90%86%E5%B9%B3%E5%8F%B0" + +// "&organizationId=1&type=project&redirectFlag", "UTF-8"); +// +// // 打印等效的 curl 命令(Windows 格式) +// System.out.println("curl -v \"" + url + "\" ^"); +// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^"); +// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\" ^"); +// System.out.println(" -H \"Referer: http://dev.xjyt.petrochina/\" ^"); +// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^"); +// System.out.println(" -H \"Cookie: " + cookie + "\" ^"); +// System.out.println(" -L"); +// +// HttpClient client = HttpClient.newBuilder() +// .followRedirects(HttpClient.Redirect.NEVER) +// .build(); +// +// HttpRequest request = HttpRequest.newBuilder() +// .uri(URI.create(url)) +// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36") +// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9") +// .header("Referer", "http://dev.xjyt.petrochina/") +// .header("Accept-Language", "zh-CN,zh;q=0.9") +// .header("Cookie", cookie) +// .GET() +// .build(); +// +// HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); +// +// System.out.println("authorize 状态码: " + response.statusCode()); +// System.out.println("authorize 响应体: " + response.body()); +// +// // 授权成功返回 302,Location 中含 access_token +// String location = response.headers().firstValue("Location").orElse(null); +// System.out.println("Location: " + location); +// +// return location; +// } +// +// public static void main(String[] args) throws Exception { +// String cookie = login("jy-lianlonggang", "UserPwdLlgA@123!"); +// System.out.println("登录成功,Cookie: " + cookie); +// +// if (cookie != null) { +// String location = authorize(cookie); +// System.out.println("授权跳转地址: " + location); +// +// String token = extractToken(location); +// System.out.println("access_token: " + token); +// } +// } +// +// /** +// * 从 Location URL 的 fragment(# 后)中提取 access_token +// */ +// public static String extractToken(String location) { +// if (location == null) return null; +// int hashIdx = location.indexOf('#'); +// if (hashIdx < 0) return null; +// String fragment = location.substring(hashIdx + 1); +// // fragment 格式:/path?key=val&access_token=xxx&... +// int queryIdx = fragment.indexOf('?'); +// String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment; +// for (String param : query.split("&")) { +// if (param.startsWith("access_token=")) { +// return param.substring("access_token=".length()); +// } +// } +// return null; +// } } diff --git a/src/main/java/com/yxtech/ossyn/config/ConsoleConfig.java b/src/main/java/com/yxtech/ossyn/config/ConsoleConfig.java new file mode 100644 index 0000000..0bbab0e --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/ConsoleConfig.java @@ -0,0 +1,32 @@ +package com.yxtech.ossyn.config; + +import lombok.Data; +import org.noear.solon.annotation.Configuration; +import org.noear.solon.annotation.Inject; + +/** + * 控制台(Choerodon DevOps)配置 + */ +@Data +@Configuration +public class ConsoleConfig { + + @Inject("${console.api-base}") + private String apiBase; + + @Inject("${console.project-id}") + private String projectId; + + @Inject("${console.env-id}") + private String envId; + + @Inject("${console.login-username}") + private String loginUsername; + + @Inject("${console.login-password}") + private String loginPassword; + + /** RSA 公钥(用于密码加密) */ + @Inject("${console.public-key}") + private String publicKey; +} diff --git a/src/main/java/com/yxtech/ossyn/config/HarborConfig.java b/src/main/java/com/yxtech/ossyn/config/HarborConfig.java new file mode 100644 index 0000000..6ed41a9 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/HarborConfig.java @@ -0,0 +1,25 @@ +package com.yxtech.ossyn.config; + +import lombok.Data; +import org.noear.solon.annotation.Configuration; +import org.noear.solon.annotation.Inject; + +/** + * Harbor 镜像仓库配置 + */ +@Data +@Configuration +public class HarborConfig { + + @Inject("${harbor.url}") + private String url; + + @Inject("${harbor.project}") + private String project; + + @Inject("${harbor.username}") + private String username; + + @Inject("${harbor.password}") + private String password; +} diff --git a/src/main/java/com/yxtech/ossyn/config/SkopeoConfig.java b/src/main/java/com/yxtech/ossyn/config/SkopeoConfig.java new file mode 100644 index 0000000..524e5af --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/SkopeoConfig.java @@ -0,0 +1,21 @@ +package com.yxtech.ossyn.config; + +import lombok.Data; +import org.noear.solon.annotation.Configuration; +import org.noear.solon.annotation.Inject; + +/** + * Skopeo 工具配置 + */ +@Data +@Configuration +public class SkopeoConfig { + + /** skopeo.exe 可执行文件路径 */ + @Inject("${skopeo.exe-path}") + private String exePath; + + /** 镜像 tar 包存放目录 */ + @Inject("${skopeo.images-path}") + private String imagesPath; +} diff --git a/src/main/java/com/yxtech/ossyn/controller/DevopsController.java b/src/main/java/com/yxtech/ossyn/controller/DevopsController.java new file mode 100644 index 0000000..ad8b503 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/controller/DevopsController.java @@ -0,0 +1,180 @@ +package com.yxtech.ossyn.controller; + +import com.alibaba.fastjson2.JSONObject; +import com.yxtech.ossyn.service.ConsoleService; +import com.yxtech.ossyn.service.HarborService; +import lombok.extern.slf4j.Slf4j; +import org.noear.solon.annotation.*; +import org.noear.solon.core.handle.UploadedFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * 镜像管理 + 控制台实例管理接口 + */ +@Slf4j +@Controller +@Mapping("/devops") +public class DevopsController { + + @Inject + private HarborService harborService; + + @Inject + private ConsoleService consoleService; + + @Inject("${skopeo.images-path}") + private String imagesPath; + + // ===================== Harbor 镜像 ===================== + + /** 查询镜像仓库列表 */ + @Get + @Mapping("/harbor/repositories") + public Map listRepositories() { + try { + return ok(harborService.listRepositories()); + } catch (Exception e) { + log.error("[Devops] listRepositories error", e); + return fail(e.getMessage()); + } + } + + /** 查询指定镜像最新 tag */ + @Get + @Mapping("/harbor/latest-tag") + public Map latestTag(@Param String repo) { + try { + return ok(harborService.latestTag(repo)); + } catch (Exception e) { + log.error("[Devops] latestTag error", e); + return fail(e.getMessage()); + } + } + + /** + * 上传 tar 包并推送到 Harbor + * 参数:file(multipart)、repo(目标仓库名)、tag(可选,不传则自动+1) + */ + @Post + @Mapping("/harbor/push") + public Map pushImage( + @Param String repo, + @Param(required = false) String tag, + UploadedFile file) { + try { + // 保存 tar 到 images 目录 + String subDir = new java.text.SimpleDateFormat("yyyyMMdd") + .format(new java.util.Date()); + File dir = Paths.get(imagesPath, subDir).toFile(); + if (!dir.exists()) dir.mkdirs(); + File tarFile = new File(dir, file.getName()); + try (FileOutputStream fos = new FileOutputStream(tarFile)) { + file.getContent().transferTo(fos); + } + + // 自动计算 tag + if (tag == null || tag.isBlank()) { + JSONObject latest = harborService.latestTag(repo); + tag = nextTag(latest); + } + + String result = harborService.pushImage(subDir + "/" + tarFile.getName(), repo, tag); + Map data = new HashMap<>(); + data.put("tag", tag); + data.put("output", result); + return ok(data); + } catch (Exception e) { + log.error("[Devops] pushImage error", e); + return fail(e.getMessage()); + } + } + + /** 在最新 tag 基础上 +1(支持 1.0.10 → 1.0.11 格式) */ + private String nextTag(JSONObject latestArtifact) { + if (latestArtifact == null) return "1.0.1"; + var tags = latestArtifact.getJSONArray("tags"); + if (tags == null || tags.isEmpty()) return "1.0.1"; + String name = tags.getJSONObject(0).getString("name"); + if (name == null) return "1.0.1"; + String[] parts = name.split("\\."); + try { + int last = Integer.parseInt(parts[parts.length - 1]) + 1; + parts[parts.length - 1] = String.valueOf(last); + return String.join(".", parts); + } catch (NumberFormatException e) { + return name + "-1"; + } + } + + // ===================== 控制台实例 ===================== + + /** 查询所有实例 */ + @Post + @Mapping("/console/instances") + public Map listInstances() { + try { + return ok(consoleService.listInstances()); + } catch (Exception e) { + log.error("[Devops] listInstances error", e); + return fail(e.getMessage()); + } + } + + /** 查询实例的 Pod 列表 */ + @Post + @Mapping("/console/pods") + public Map listPods(@Param String instanceId) { + try { + return ok(consoleService.listPods(instanceId)); + } catch (Exception e) { + log.error("[Devops] listPods error", e); + return fail(e.getMessage()); + } + } + + /** 查询实例 YAML 配置 */ + @Get + @Mapping("/console/yaml") + public Map getYaml(@Param String instanceId, @Param String versionId) { + try { + return ok(consoleService.getInstanceYaml(instanceId, versionId)); + } catch (Exception e) { + log.error("[Devops] getYaml error", e); + return fail(e.getMessage()); + } + } + + /** 更新实例 YAML 配置 */ + @Put + @Mapping("/console/yaml") + public Map updateYaml(@Body JSONObject payload) { + try { + return ok(consoleService.updateInstanceYaml(payload)); + } catch (Exception e) { + log.error("[Devops] updateYaml error", e); + return fail(e.getMessage()); + } + } + + // ===================== 工具方法 ===================== + + private Map ok(Object data) { + Map r = new HashMap<>(3); + r.put("code", 200); + r.put("msg", "ok"); + r.put("data", data); + return r; + } + + private Map fail(String msg) { + Map r = new HashMap<>(2); + r.put("code", 500); + r.put("msg", msg); + return r; + } +} diff --git a/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java b/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java index 3dac955..9debcf3 100644 --- a/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java +++ b/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java @@ -5,6 +5,7 @@ import org.noear.solon.annotation.Component; import org.noear.solon.core.handle.Context; import org.noear.solon.core.handle.Filter; import org.noear.solon.core.handle.FilterChain; +import org.noear.solon.core.exception.StatusException; import java.util.HashMap; import java.util.Map; @@ -22,11 +23,21 @@ public class GlobalExceptionFilter implements Filter { try { chain.doFilter(ctx); } catch (Throwable e) { - log.error("[GlobalFilter] 请求异常 {} {} params={}", - ctx.method(), ctx.path(), ctx.paramMap(), e); - ctx.status(500); + int status = 500; + // StatusException 直接用其状态码,避免 404 被当成 500 处理 + if (e instanceof StatusException se) { + status = se.getCode(); + } + // 静态资源 404 不打 ERROR,避免刷屏 + if (status == 404) { + log.debug("[GlobalFilter] 404 {} {}", ctx.method(), ctx.path()); + } else { + log.error("[GlobalFilter] 请求异常 {} {} params={}", + ctx.method(), ctx.path(), ctx.paramMap(), e); + } + ctx.status(status); Map result = new HashMap<>(2); - result.put("code", 500); + result.put("code", status); result.put("msg", e.getMessage()); ctx.render(result); } diff --git a/src/main/java/com/yxtech/ossyn/service/ConsoleService.java b/src/main/java/com/yxtech/ossyn/service/ConsoleService.java new file mode 100644 index 0000000..27fbbf7 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/service/ConsoleService.java @@ -0,0 +1,247 @@ +package com.yxtech.ossyn.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.yxtech.ossyn.config.ConsoleConfig; +import lombok.extern.slf4j.Slf4j; +import org.noear.solon.annotation.Component; +import org.noear.solon.annotation.Inject; + +import javax.crypto.Cipher; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 控制台(Choerodon DevOps)服务 + * token 内存缓存,401 时自动重新登录 + */ +@Slf4j +@Component +public class ConsoleService { + + @Inject + private ConsoleConfig cfg; + + /** 缓存的 access_token */ + private volatile String cachedToken; + /** token 过期时间戳(毫秒) */ + private final AtomicLong tokenExpireAt = new AtomicLong(0); + + /** 登录/授权专用:不跟随重定向,才能拿到 302 的 Set-Cookie 和 Location */ + private final HttpClient authClient = HarborService.buildTrustAllClientNoRedirect(); + + /** 业务请求专用:跟随重定向 */ + private final HttpClient apiClient = HarborService.buildTrustAllClient(); + + // ===================== token 获取 ===================== + + /** 强制清除缓存 token,下次调用 getToken() 时重新登录 */ + public void invalidateToken() { + cachedToken = null; + tokenExpireAt.set(0); + log.info("[Console] token 缓存已清除,下次请求将重新登录"); + } + + /** 获取有效 token(自动刷新) */ + public String getToken() throws Exception { + if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) { + return cachedToken; + } + synchronized (this) { + if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) { + return cachedToken; + } + String cookie = login(); + String location = authorize(cookie); + String token = extractToken(location); + if (token == null) throw new RuntimeException("授权成功但未能从 Location 中提取 access_token: " + location); + cachedToken = token; + tokenExpireAt.set(System.currentTimeMillis() + 86399_000L); + log.info("[Console] token 已刷新"); + } + return cachedToken; + } + + private String login() throws Exception { + String encPwd = encryptPassword(cfg.getLoginPassword(), cfg.getPublicKey()); + String formBody = "username=" + java.net.URLEncoder.encode(cfg.getLoginUsername(), "UTF-8") + + "&password=" + java.net.URLEncoder.encode(encPwd, "UTF-8"); + String loginUrl = cfg.getApiBase() + "/oauth/choerodon/login"; + + log.info("[Console][login] curl \"{}\" ^\n -H \"Origin: {}\" ^\n -H \"Content-Type: application/x-www-form-urlencoded\" ^\n --data-raw \"{}\"", + loginUrl, cfg.getApiBase(), formBody); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(loginUrl)) + .header("Origin", cfg.getApiBase()) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", "Mozilla/5.0") + .header("Referer", loginUrl) + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + + // 使用 authClient(NEVER 重定向),确保能拿到 302 响应中的 Set-Cookie + HttpResponse resp = authClient.send(req, HttpResponse.BodyHandlers.ofString()); + log.info("[Console][login] status={}", resp.statusCode()); + log.info("[Console][login] headers={}", resp.headers().map()); + log.info("[Console][login] body={}", resp.body()); + // 收集所有 Set-Cookie,拼成 Cookie 字符串传给 authorize + java.util.List cookies = resp.headers().allValues("Set-Cookie"); + if (cookies.isEmpty()) { + throw new RuntimeException("登录失败,未获取到 Set-Cookie,状态码: " + resp.statusCode()); + } + String cookie = cookies.stream() + .map(c -> c.split(";")[0]) // 每条只取 name=value 部分 + .collect(java.util.stream.Collectors.joining("; ")); + log.info("[Console][login] 拼装 Cookie: {}", cookie); + return cookie; + } + + private String authorize(String cookie) throws Exception { + String redirectUri = java.net.URLEncoder.encode( + "http://dev.xjyt.petrochina/#/devops/resource?activeKey=instance&category=AGILE" + + "&id=" + cfg.getProjectId() + + "&organizationId=1&type=project&redirectFlag", "UTF-8"); + String url = cfg.getApiBase() + "/oauth/oauth/authorize" + + "?response_type=token&client_id=choerodon&state=&redirect_uri=" + redirectUri; + + log.info("[Console][authorize] curl -v \"{}\" ^\n -H \"Cookie: {}\"", url, cookie); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("User-Agent", "Mozilla/5.0") + .header("Referer", "http://dev.xjyt.petrochina/") + .header("Cookie", cookie) + .GET().build(); + + // 使用 authClient(NEVER 重定向),确保能拿到 302 的 Location 头 + HttpResponse resp = authClient.send(req, HttpResponse.BodyHandlers.ofString()); + log.info("[Console][authorize] status={}", resp.statusCode()); + log.info("[Console][authorize] headers={}", resp.headers().map()); + log.info("[Console][authorize] body={}", resp.body()); + + String location = resp.headers().firstValue("Location").orElse(null); + log.info("[Console][authorize] Location={}", location); + + if (location == null) { + throw new RuntimeException("授权失败,未获取到 Location,状态码: " + resp.statusCode()); + } + return location; + } + + /** 从 Location URL 的 fragment(# 后)中提取 access_token */ + private String extractToken(String location) { + int hashIdx = location.indexOf('#'); + if (hashIdx < 0) return null; + String fragment = location.substring(hashIdx + 1); + int queryIdx = fragment.indexOf('?'); + String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment; + for (String param : query.split("&")) { + if (param.startsWith("access_token=")) return param.substring("access_token=".length()); + } + return null; + } + + private String encryptPassword(String password, String publicKeyBase64) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); + PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes)); + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + return Base64.getEncoder().encodeToString(cipher.doFinal(password.getBytes("UTF-8"))); + } + + // ===================== 通用请求工具 ===================== + + private HttpRequest.Builder baseRequest(String url) throws Exception { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "bearer " + getToken()) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("H-Tenant-Id", "1") + .header("H-Menu-Id", "1968") + .header("User-Agent", "Mozilla/5.0") + .header("Origin", "http://dev.xjyt.petrochina") + .header("Referer", "http://dev.xjyt.petrochina/"); + } + + /** + * 发送请求,401 时清除 token 缓存并重试一次 + */ + private HttpResponse sendWithRetry(HttpRequest.Builder reqBuilder, String body, String method) throws Exception { + HttpRequest req = buildReq(reqBuilder, body, method); + HttpResponse resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() == 401) { + log.warn("[Console] 收到 401,清除 token 缓存后重试"); + invalidateToken(); + // 重新构建请求(带新 token) + String url = req.uri().toString(); + HttpRequest.Builder newBuilder = baseRequest(url); + req = buildReq(newBuilder, body, method); + resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString()); + } + return resp; + } + + private HttpRequest buildReq(HttpRequest.Builder builder, String body, String method) { + return switch (method) { + case "POST" -> builder.POST(body != null + ? HttpRequest.BodyPublishers.ofString(body) + : HttpRequest.BodyPublishers.noBody()).build(); + case "PUT" -> builder.PUT(body != null + ? HttpRequest.BodyPublishers.ofString(body) + : HttpRequest.BodyPublishers.noBody()).build(); + default -> builder.GET().build(); + }; + } + + // ===================== 实例列表 ===================== + + /** 查询所有实例(全量,size=500) */ + public JSONObject listInstances() throws Exception { + String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId() + + "/app_service_instances/info/page_by_options?page=0&size=500&env_id=" + cfg.getEnvId(); + String body = "{\"params\":[],\"searchParam\":{}}"; + HttpResponse resp = sendWithRetry(baseRequest(url), body, "POST"); + log.info("[Console][listInstances] status={}", resp.statusCode()); + return JSON.parseObject(resp.body()); + } + + // ===================== Pod 列表 ===================== + + /** 查询指定实例的 Pod 列表 */ + public JSONObject listPods(String instanceId) throws Exception { + String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId() + + "/pods/page_by_options?page=0&size=50&env_id=" + cfg.getEnvId() + + "&instance_id=" + instanceId; + String body = "{\"params\":[],\"searchParam\":{}}"; + HttpResponse resp = sendWithRetry(baseRequest(url), body, "POST"); + return JSON.parseObject(resp.body()); + } + + // ===================== YAML 配置 ===================== + + /** 查询实例的 YAML 配置 */ + public JSONObject getInstanceYaml(String instanceId, String versionId) throws Exception { + String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId() + + "/app_service_instances/" + instanceId + + "/appServiceVersion/" + versionId + "/upgrade_value"; + HttpResponse resp = sendWithRetry(baseRequest(url), null, "GET"); + return JSON.parseObject(resp.body()); + } + + /** 更新实例的 YAML 配置 */ + public JSONObject updateInstanceYaml(JSONObject payload) throws Exception { + String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId() + + "/app_service_instances"; + HttpResponse resp = sendWithRetry(baseRequest(url), payload.toJSONString(), "PUT"); + return JSON.parseObject(resp.body()); + } +} diff --git a/src/main/java/com/yxtech/ossyn/service/HarborService.java b/src/main/java/com/yxtech/ossyn/service/HarborService.java new file mode 100644 index 0000000..2b8fcbd --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/service/HarborService.java @@ -0,0 +1,144 @@ +package com.yxtech.ossyn.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.yxtech.ossyn.config.HarborConfig; +import com.yxtech.ossyn.config.SkopeoConfig; +import lombok.extern.slf4j.Slf4j; +import org.noear.solon.annotation.Component; +import org.noear.solon.annotation.Inject; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.cert.X509Certificate; +import java.util.Base64; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * Harbor 镜像仓库服务 + */ +@Slf4j +@Component +public class HarborService { + + @Inject + private HarborConfig harborConfig; + + @Inject + private SkopeoConfig skopeoConfig; + + private final HttpClient httpClient = buildTrustAllClient(); + + /** 构建忽略 SSL 证书验证的 HttpClient(内部域名自签证书) */ + public static HttpClient buildTrustAllClient() { + return buildTrustAllClient(HttpClient.Redirect.NORMAL); + } + + /** 构建忽略 SSL 证书验证、不跟随重定向的 HttpClient(用于登录/授权,需拿 302 头信息) */ + public static HttpClient buildTrustAllClientNoRedirect() { + return buildTrustAllClient(HttpClient.Redirect.NEVER); + } + + private static HttpClient buildTrustAllClient(HttpClient.Redirect redirect) { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] c, String a) {} + public void checkServerTrusted(X509Certificate[] c, String a) {} + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + }}, null); + return HttpClient.newBuilder() + .followRedirects(redirect) + .sslContext(sslContext) + .build(); + } catch (Exception e) { + throw new RuntimeException("初始化 TrustAll SSLContext 失败", e); + } + } + + /** 构建 Basic Auth 头 */ + private String basicAuth() { + String cred = harborConfig.getUsername() + ":" + harborConfig.getPassword(); + return "Basic " + Base64.getEncoder().encodeToString(cred.getBytes()); + } + + /** + * 查询项目下所有镜像仓库列表 + */ + public JSONArray listRepositories() throws Exception { + String url = harborConfig.getUrl() + "/api/v2.0/projects/" + + harborConfig.getProject() + "/repositories?page=1&page_size=100"; + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", basicAuth()) + .header("Accept", "application/json") + .GET().build(); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + log.info("[Harbor] listRepositories status={}", resp.statusCode()); + return JSON.parseArray(resp.body()); + } + + /** + * 查询指定镜像仓库的最新 tag + * + * @param repoName 仓库名(不含项目前缀,如 health-bank-admin) + */ + public JSONObject latestTag(String repoName) throws Exception { + String url = harborConfig.getUrl() + "/api/v2.0/projects/" + + harborConfig.getProject() + "/repositories/" + + repoName + "/artifacts?page_size=1&sort=-push_time&with_tag=true"; + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", basicAuth()) + .header("Accept", "application/json") + .GET().build(); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + JSONArray arr = JSON.parseArray(resp.body()); + if (arr == null || arr.isEmpty()) return null; + return arr.getJSONObject(0); + } + + /** + * 使用 skopeo 将 tar 包推送到 Harbor + * + * @param tarRelPath tar 包相对于 images-path 的路径(如 20260417/xxx.tar) + * @param repoName 目标仓库名(不含项目前缀) + * @param tag 目标 tag + */ + public String pushImage(String tarRelPath, String repoName, String tag) throws Exception { + String tarAbsPath = skopeoConfig.getImagesPath().replace("\\", "/") + "/" + tarRelPath; + String dest = harborConfig.getUrl().replace("https://", "") + .replace("http://", ""); + dest = harborConfig.getUrl().startsWith("https") ? dest : dest; + String destImage = "docker://" + dest.replace("https://", "").replace("http://", "") + + "/" + harborConfig.getProject() + "/" + repoName + ":" + tag; + // 修正:直接拼完整 harbor host + String harborHost = harborConfig.getUrl().replaceFirst("https?://", ""); + destImage = "docker://" + harborHost + "/" + harborConfig.getProject() + "/" + repoName + ":" + tag; + + String[] cmd = { + skopeoConfig.getExePath(), + "copy", + "--dest-creds", harborConfig.getUsername() + ":" + harborConfig.getPassword(), + "--dest-tls-verify=false", + "docker-archive:" + tarAbsPath, + destImage + }; + log.info("[Harbor] skopeo push: {} -> {}", tarAbsPath, destImage); + Process process = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + log.info("[Harbor] skopeo exit={} output={}", exitCode, output); + if (exitCode != 0) { + throw new RuntimeException("skopeo 推送失败: " + output); + } + return output; + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml index b8e1320..bae2f9a 100644 --- a/src/main/resources/app.yml +++ b/src/main/resources/app.yml @@ -1,4 +1,7 @@ server.port: 8080 +server.request-maxBodySize: 1073741824 +solon.config.load: + - "app-env.yml" #可以是环境相关的 solon.app: name: 'xj-oss-syn' @@ -68,7 +71,31 @@ xj-oss: file-type-white-list: dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx # ===================================== -# Sa-Token 鉴权配置 +# Harbor 镜像仓库配置 +# ===================================== +harbor: + url: https://harbor.dev.xjyt.petrochina + project: operation-jkglpt + username: jkglpt + password: Jkglpt@123 + +# ===================================== +# Skopeo 配置 +# ===================================== +skopeo: + exe-path: "E:/skopeo/skopeo.exe" + images-path: "E:/skopeo/images" + +# ===================================== +# 控制台(Choerodon DevOps)配置 +# ===================================== +console: + api-base: http://api.dev.xjyt.petrochina + project-id: "748209321615708160" + env-id: "766633041995898880" + login-username: jy-lianlonggang + login-password: "UserPwdLlgA@123!" + public-key: "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ==" # ===================================== sa-token: # token 名称(同时也是 cookie 名称) diff --git a/src/main/resources/templates/index.ftl b/src/main/resources/templates/index.ftl index 39f8e13..a07d0e4 100644 --- a/src/main/resources/templates/index.ftl +++ b/src/main/resources/templates/index.ftl @@ -115,6 +115,8 @@
+ +
@@ -241,6 +243,107 @@ + +
+
+
+
Harbor 镜像仓库
+
+ + +
+
+
+ + + + + + + +
仓库名镜像数最新 Tag最后更新
加载中...
+
+
+
+ + +
+
+
+
控制台实例
+ +
+
+ + + + + + + +
服务名Code状态版本最后更新操作
加载中...
+
+
+
+ + + + + + + + + + + @@ -519,11 +622,13 @@ // ===================== Tab 切换 ===================== function switchTab(name) { document.querySelectorAll('.tab-btn').forEach((b, i) => { - b.classList.toggle('active', ['sync','mapping'][i] === name); + b.classList.toggle('active', ['sync','mapping','harbor','console'][i] === name); }); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); document.getElementById('tab-' + name).classList.add('active'); if (name === 'mapping') loadMappings(); + if (name === 'harbor') loadHarbor(); + if (name === 'console') loadInstances(); } // ===================== 映射配置 ===================== @@ -617,6 +722,188 @@ } catch(e) {} } + // ===================== Harbor 镜像 ===================== + async function loadHarbor() { + try { + const res = await fetch('/devops/harbor/repositories'); + const json = await res.json(); + if (json.code !== 200) return; + const tbody = document.getElementById('harborTableBody'); + const list = json.data || []; + if (!list.length) { tbody.innerHTML = '暂无数据'; return; } + tbody.innerHTML = list.map(r => { + const shortName = (r.name || '').replace(/^[^/]+\//, ''); + return ` + ${shortName} + ${r.artifact_count ?? '-'} + 加载中... + ${(r.update_time||'').replace('T',' ').substring(0,19)} + `; + }).join(''); + // 渲染完列表后异步逐条加载最新 tag + list.forEach(r => { + const shortName = (r.name || '').replace(/^[^/]+\//, ''); + loadLatestTag(shortName); + }); + } catch(e) {} + } + + async function loadLatestTag(repo) { + try { + const res = await fetch('/devops/harbor/latest-tag?repo=' + encodeURIComponent(repo)); + const json = await res.json(); + const el = document.getElementById('tag-' + repo); + if (!el) return; + if (json.code === 200 && json.data) { + const tags = json.data.tags; + el.textContent = tags && tags.length ? tags[0].name : '-'; + el.style.color = '#1677ff'; + } + } catch(e) {} + } + + function openPushModal() { + document.getElementById('push-repo').value = ''; + document.getElementById('push-tag').value = ''; + document.getElementById('push-file').value = ''; + document.getElementById('push-result').textContent = ''; + document.getElementById('pushModal').classList.add('show'); + } + + async function doPushImage() { + const repo = document.getElementById('push-repo').value.trim(); + const tag = document.getElementById('push-tag').value.trim(); + const fileInput = document.getElementById('push-file'); + if (!repo) { alert('请填写目标仓库名'); return; } + if (!fileInput.files.length) { alert('请选择 tar 包文件'); return; } + const btn = document.getElementById('pushBtn'); + btn.disabled = true; + btn.textContent = '上传中...'; + document.getElementById('push-result').textContent = '正在上传,请稍候...'; + try { + const fd = new FormData(); + fd.append('repo', repo); + if (tag) fd.append('tag', tag); + fd.append('file', fileInput.files[0]); + const res = await fetch('/devops/harbor/push', { method: 'POST', body: fd }); + const json = await res.json(); + if (json.code === 200) { + document.getElementById('push-result').textContent = '✅ 推送成功!Tag: ' + json.data.tag + '\n' + (json.data.output || ''); + loadHarbor(); + } else { + document.getElementById('push-result').textContent = '❌ 失败:' + json.msg; + } + } catch(e) { + document.getElementById('push-result').textContent = '❌ 请求失败:' + e.message; + } finally { + btn.disabled = false; + btn.textContent = '开始上传'; + } + } + + // ===================== 控制台实例 ===================== + async function loadInstances() { + try { + const res = await fetch('/devops/console/instances', { method: 'POST' }); + const json = await res.json(); + if (json.code !== 200) return; + const list = (json.data && json.data.content) || []; + const tbody = document.getElementById('instanceTableBody'); + if (!list.length) { tbody.innerHTML = '暂无数据'; return; } + tbody.innerHTML = list.map(inst => { + const statusColor = inst.status === 'running' ? '#52c41a' : inst.status === 'failed' ? '#f5222d' : '#fa8c16'; + return ` + ${inst.appServiceName || '-'} + ${inst.code || '-'} + ${inst.status || '-'} + ${inst.commandVersion || inst.versionName || '-'} + ${(inst.lastUpdateDate||'').substring(0,16)} + + + + + `; + }).join(''); + } catch(e) {} + } + + async function openYamlModal(instanceId, versionId, appServiceId) { + document.getElementById('yaml-instanceId').value = instanceId; + document.getElementById('yaml-versionId').value = versionId; + document.getElementById('yaml-appServiceId').value = appServiceId; + document.getElementById('yaml-content').value = '加载中...'; + document.getElementById('yamlModal').classList.add('show'); + try { + const res = await fetch('/devops/console/yaml?instanceId=' + instanceId + '&versionId=' + versionId); + const json = await res.json(); + if (json.code === 200 && json.data) { + document.getElementById('yaml-content').value = json.data.yaml || ''; + document.getElementById('yaml-envId').value = json.data.envId || ''; + document.getElementById('yaml-appServiceVersionId').value = json.data.id || versionId; + document.getElementById('yaml-objectVersionNumber').value = json.data.objectVersionNumber || 1; + } else { + document.getElementById('yaml-content').value = '加载失败:' + (json.msg || ''); + } + } catch(e) { document.getElementById('yaml-content').value = '加载失败:' + e.message; } + } + + async function doUpdateYaml() { + const payload = { + values: document.getElementById('yaml-content').value, + instanceId: document.getElementById('yaml-instanceId').value, + type: 'update', + environmentId: document.getElementById('yaml-envId').value, + appServiceId: document.getElementById('yaml-appServiceId').value, + appServiceVersionId: document.getElementById('yaml-appServiceVersionId').value, + }; + try { + const res = await fetch('/devops/console/yaml', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const json = await res.json(); + if (json.code === 200) { + alert('更新成功!'); + document.getElementById('yamlModal').classList.remove('show'); + loadInstances(); + } else { + alert('更新失败:' + json.msg); + } + } catch(e) { alert('请求失败:' + e.message); } + } + + async function openPodModal(instanceId, name) { + document.getElementById('podModalTitle').textContent = 'Pod 列表 - ' + name; + document.getElementById('podTableBody').innerHTML = '加载中...'; + document.getElementById('podModal').classList.add('show'); + try { + const res = await fetch('/devops/console/pods', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'instanceId=' + encodeURIComponent(instanceId) + }); + const json = await res.json(); + const list = (json.data && json.data.content) || []; + if (!list.length) { + document.getElementById('podTableBody').innerHTML = '暂无 Pod'; + return; + } + document.getElementById('podTableBody').innerHTML = list.map(p => { + const sc = p.status === 'Running' ? '#52c41a' : p.status === 'Pending' ? '#fa8c16' : '#f5222d'; + return ` + ${p.name||'-'} + ${p.ip||'-'} + ${p.status||'-'} + ${p.ready ? '✅' : '❌'} + ${(p.creationDate||'').substring(0,16)} + `; + }).join(''); + } catch(e) { + document.getElementById('podTableBody').innerHTML = '加载失败:' + e.message + ''; + } + } + // ===================== 初始化 ===================== loadLogs(1); loadTaskHistory(); diff --git a/src/test/java/features/HelloTest.java b/src/test/java/features/HelloTest.java index 050dc6e..ce1e9c6 100644 --- a/src/test/java/features/HelloTest.java +++ b/src/test/java/features/HelloTest.java @@ -1,6 +1,6 @@ package features; -import com.yxtech.watch.App; +import com.yxtech.ossyn.App; import org.junit.jupiter.api.Test;