ids) {
+ if (appVersionService.removeByIds(ids)) {
+ return success("删除成功");
+ }
+ return fail("删除失败");
+ }
+
+}
diff --git a/jczxw-java/src/main/java/com/gxwebsoft/app/controller/AppWxPayController.java b/jczxw-java/src/main/java/com/gxwebsoft/app/controller/AppWxPayController.java
new file mode 100644
index 0000000..b1786a9
--- /dev/null
+++ b/jczxw-java/src/main/java/com/gxwebsoft/app/controller/AppWxPayController.java
@@ -0,0 +1,205 @@
+package com.gxwebsoft.app.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.gxwebsoft.app.config.AppPayProperties;
+import com.gxwebsoft.app.entity.AppSubscription;
+import com.gxwebsoft.app.mapper.AppSubscriptionMapper;
+import com.gxwebsoft.common.core.utils.RedisUtil;
+import com.wechat.pay.java.core.cipher.AeadAesCipher;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.util.Base64Utils;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * 微信支付回调通知处理
+ * 路径:POST /api/app/subscription/wx-notify
+ * 由微信支付服务器主动调用(Native 扫码支付成功后会回调此地址)
+ *
+ * 注意:前端同时在轮询 check-status 接口,回调只是加速状态更新。
+ * 即使回调失败,前端轮询也能在 3s 内查到已支付状态。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/app/subscription")
+@RequiredArgsConstructor
+@Tag(name = "微信支付回调")
+public class AppWxPayController {
+
+ @Resource
+ private AppSubscriptionMapper subscriptionMapper;
+ @Resource
+ private RedisUtil redisUtil;
+ @Resource
+ private AppPayProperties appPayProperties;
+
+ @Value("${spring.profiles.active:dev}")
+ private String active;
+
+ /**
+ * 微信 Native 支付回调通知
+ * 微信支付成功后会 POST JSON 到此地址
+ */
+ @PostMapping("/wx-notify")
+ @Operation(summary = "微信支付回调通知")
+ public String wxNotify(
+ @RequestHeader(value = "Wechatpay-Serial", required = false) String serialNumber,
+ @RequestHeader(value = "Wechatpay-Nonce", required = false) String nonce,
+ @RequestHeader(value = "Wechatpay-Signature", required = false) String signature,
+ @RequestHeader(value = "Wechatpay-Timestamp", required = false) String timestamp,
+ @RequestBody String body
+ ) {
+ log.info("收到微信支付回调 — headers: serial={}, timestamp={}, body={}", serialNumber, timestamp, body);
+
+ try {
+ // 1. 解析通知(解密报文体)
+ JSONObject notification = parseNotification(body);
+ if (notification == null) {
+ log.error("通知解析失败");
+ return failResult("notification parse failed");
+ }
+
+ // 2. 提取关键字段
+ String eventType = notification.getString("event_type");
+ JSONObject resource = notification.getJSONObject("resource");
+ if (resource == null) {
+ log.error("通知报文体中无 resource 字段");
+ return failResult("no resource");
+ }
+
+ // 解密资源
+ String ciphertext = resource.getString("ciphertext");
+ String nonceStr = resource.getString("nonce");
+ String associatedData = resource.getString("associated_data");
+ String apiV3Key = getApiV3Key();
+
+ String plainText;
+ try {
+ plainText = decryptResource(ciphertext, apiV3Key, nonceStr, associatedData);
+ } catch (Exception e) {
+ log.error("资源解密失败: {}", e.getMessage());
+ return failResult("decrypt failed");
+ }
+
+ JSONObject tradeData = JSON.parseObject(plainText);
+ String outTradeNo = tradeData.getString("out_trade_no");
+ String tradeState = tradeData.getString("trade_state");
+ String transactionId = tradeData.getString("transaction_id");
+ BigDecimal totalAmount = tradeData.getBigDecimal("amount") != null
+ ? tradeData.getJSONObject("amount").getBigDecimal("payer_total") : BigDecimal.ZERO;
+
+ log.info("解密后 — outTradeNo={}, tradeState={}, transactionId={}, amount={}",
+ outTradeNo, tradeState, transactionId, totalAmount);
+
+ // 3. 仅处理支付成功事件
+ if (!"TRANSACTION.SUCCESS".equals(eventType) && !"SUCCESS".equals(tradeState)) {
+ log.warn("非成功回调,忽略 — eventType: {}, tradeState: {}", eventType, tradeState);
+ return successResult();
+ }
+
+ // 4. 查询并更新订阅记录
+ LambdaQueryWrapper query = new LambdaQueryWrapper<>();
+ query.eq(AppSubscription::getSubscriptionNo, outTradeNo);
+ AppSubscription sub = subscriptionMapper.selectOne(query);
+
+ if (sub == null) {
+ log.warn("未找到订阅记录 outTradeNo: {}", outTradeNo);
+ return successResult(); // 返回成功,避免微信重复回调
+ }
+
+ if (sub.getPayStatus() != null && sub.getPayStatus() == 1) {
+ log.info("订阅 {} 已支付,跳过重复处理", outTradeNo);
+ return successResult();
+ }
+
+ // 5. 更新状态
+ sub.setPayStatus(1);
+ sub.setStatus("active");
+ sub.setPayTime(LocalDateTime.now());
+ sub.setTransactionId(transactionId);
+ sub.setPayType(1); // 1=微信支付
+ sub.setStartTime(LocalDateTime.now());
+
+ // 设置到期时间(订阅型)
+ if ("subscription".equals(sub.getPriceType())) {
+ int months = "year".equals(sub.getSubscriptionPeriod()) ? 12 : 1;
+ sub.setExpireTime(LocalDateTime.now().plusMonths(months));
+ }
+
+ subscriptionMapper.updateById(sub);
+ log.info("订阅 {} 支付成功,状态已更新", outTradeNo);
+
+ // 6. 写入 Redis,加速前端轮询感知
+ redisUtil.set("wxpay:paid:" + outTradeNo, "1", Duration.ofHours(24));
+
+ return successResult();
+
+ } catch (Exception e) {
+ log.error("微信支付回调处理异常: {}", e.getMessage(), e);
+ return failResult("internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 解析通知(V3 API 使用 AES-256-GCM 解密 resource 字段)
+ */
+ private JSONObject parseNotification(String body) {
+ try {
+ return JSON.parseObject(body);
+ } catch (Exception e) {
+ log.error("JSON 解析失败: {}", body);
+ return null;
+ }
+ }
+
+ /**
+ * AES-256-GCM 解密微信 V3 通知 resource.ciphertext
+ * - resource.nonce:Base64 编码的 12 字节随机数
+ * - resource.ciphertext:Base64(AEAD_AES_256_GCM(nonce + plaintext))
+ * 其中 ciphertext 末尾 16 字节为 tag
+ */
+ private String decryptResource(String ciphertext, String apiV3Key, String nonce, String associatedData)
+ throws Exception {
+ byte[] key = apiV3Key.getBytes(StandardCharsets.UTF_8);
+ // nonce 是 Base64 编码的 12 字节随机数,需要解码
+ byte[] nonceBytes = Base64Utils.decodeFromString(nonce);
+ // associated_data 原样 UTF-8 编码
+ byte[] aad = (associatedData == null ? "" : associatedData).getBytes(StandardCharsets.UTF_8);
+ // ciphertext = Base64(ciphertext_bytes),其中末尾 16 字节为 auth tag
+ byte[] cipherBytes = Base64Utils.decodeFromString(ciphertext);
+
+ // 新版 SDK:AeadAesCipher.decrypt(nonce, associatedData, ciphertextWithTag)
+ AeadAesCipher cipher = new AeadAesCipher(key);
+ return cipher.decrypt(nonceBytes, aad, cipherBytes);
+ }
+
+ /**
+ * 获取 APIv3 密钥(根据环境选择正式/测试配置)
+ */
+ private String getApiV3Key() {
+ if ("dev".equals(active) && appPayProperties.isTestMode()) {
+ String key = appPayProperties.getTestApiV3Key();
+ return key != null ? key : appPayProperties.getApiV3Key();
+ }
+ return appPayProperties.getApiV3Key();
+ }
+
+ private String successResult() {
+ return "{\"code\":\"SUCCESS\",\"message\":\"OK\"}";
+ }
+
+ private String failResult(String msg) {
+ return "{\"code\":\"FAIL\",\"message\":\"" + msg + "\"}";
+ }
+}
diff --git a/jczxw-java/src/main/java/com/gxwebsoft/app/controller/CICDController.java b/jczxw-java/src/main/java/com/gxwebsoft/app/controller/CICDController.java
new file mode 100644
index 0000000..e641c99
--- /dev/null
+++ b/jczxw-java/src/main/java/com/gxwebsoft/app/controller/CICDController.java
@@ -0,0 +1,256 @@
+package com.gxwebsoft.app.controller;
+
+import com.gxwebsoft.app.entity.AppBuild;
+import com.gxwebsoft.app.entity.AppPipeline;
+import com.gxwebsoft.app.param.AppBuildParam;
+import com.gxwebsoft.app.param.AppPipelineParam;
+import com.gxwebsoft.app.service.AppBuildService;
+import com.gxwebsoft.app.service.AppPipelineService;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.entity.User;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CI/CD 控制器
+ * 支持 Jenkins / GitHub Actions / Gitea CI
+ *
+ * @author 科技小王子
+ * @since 2026-04-03
+ */
+@Slf4j
+@Tag(name = "CI/CD管理")
+@RestController
+@RequestMapping("/api/app/cicd")
+public class CICDController extends BaseController {
+
+ @Resource
+ private AppBuildService appBuildService;
+
+ @Resource
+ private AppPipelineService appPipelineService;
+
+ // ========== 流水线接口 ==========
+
+ @Operation(summary = "分页查询流水线")
+ @GetMapping("/pipeline/page")
+ public ApiResult> pagePipeline(AppPipelineParam param) {
+ User loginUser = getLoginUser();
+ if (loginUser != null) {
+ param.setUserId(loginUser.getUserId());
+ }
+ return success(appPipelineService.pagePipeline(param));
+ }
+
+ @Operation(summary = "查询应用的所有流水线")
+ @GetMapping("/pipeline/app/{appId}")
+ public ApiResult> listByApp(@PathVariable Long appId) {
+ return success(appPipelineService.getByAppId(appId));
+ }
+
+ @Operation(summary = "查询流水线详情")
+ @GetMapping("/pipeline/{id}")
+ public ApiResult getPipeline(@PathVariable Long id) {
+ return success(appPipelineService.getPipelineDetail(id));
+ }
+
+ @PreAuthorize("hasAuthority('app:cicd:pipeline:save')")
+ @Operation(summary = "创建流水线")
+ @PostMapping("/pipeline")
+ public ApiResult> createPipeline(@RequestBody AppPipeline pipeline) {
+ User loginUser = getLoginUser();
+ if (loginUser != null) {
+ pipeline.setUserId(loginUser.getUserId());
+ pipeline.setTenantId(loginUser.getTenantId());
+ }
+ if (appPipelineService.createPipeline(pipeline)) {
+ return success("创建成功");
+ }
+ return fail("创建失败");
+ }
+
+ @PreAuthorize("hasAuthority('app:cicd:pipeline:update')")
+ @Operation(summary = "更新流水线")
+ @PutMapping("/pipeline")
+ public ApiResult> updatePipeline(@RequestBody AppPipeline pipeline) {
+ if (appPipelineService.updatePipeline(pipeline)) {
+ return success("更新成功");
+ }
+ return fail("更新失败");
+ }
+
+ @PreAuthorize("hasAuthority('app:cicd:pipeline:remove')")
+ @Operation(summary = "删除流水线")
+ @DeleteMapping("/pipeline/{id}")
+ public ApiResult> deletePipeline(@PathVariable Long id) {
+ if (appPipelineService.deletePipeline(id)) {
+ return success("删除成功");
+ }
+ return fail("删除失败");
+ }
+
+ @PreAuthorize("hasAuthority('app:cicd:pipeline:update')")
+ @Operation(summary = "启用/禁用流水线")
+ @PostMapping("/pipeline/{id}/toggle")
+ public ApiResult> togglePipeline(@PathVariable Long id, @RequestParam boolean enabled) {
+ if (appPipelineService.togglePipeline(id, enabled)) {
+ return success(enabled ? "已启用" : "已禁用");
+ }
+ return fail("操作失败");
+ }
+
+ @Operation(summary = "获取流水线状态")
+ @GetMapping("/pipeline/{id}/status")
+ public ApiResult