Explorar o código

Merge branch 'master' into pre

zoujiajian hai 1 día
pai
achega
5af12a83b2

+ 17 - 0
netflix-dao/src/main/java/com/cyksj/model/request/codex/CodexChannelEnabledReq.java

@@ -0,0 +1,17 @@
+package com.cyksj.model.request.codex;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * Codex 上游渠道开关请求。
+ */
+@Getter
+@Setter
+public class CodexChannelEnabledReq {
+
+    @NotNull(message = "enabled 字段不能为空")
+    private Boolean enabled;
+}

+ 11 - 0
netflix-service/src/main/java/com/cyksj/service/api/ClaudeCodexApiService.java

@@ -1,6 +1,7 @@
 package com.cyksj.service.api;
 
 import com.cyksj.model.request.ClaudeCodeApiKeysReq;
+import com.cyksj.model.request.codex.CodexChannelEnabledReq;
 import com.cyksj.model.response.claudecode.ClaudeCodeResp;
 import com.cyksj.model.views.ClaudeCodeUserApiKeysView;
 
@@ -58,4 +59,14 @@ public interface ClaudeCodexApiService {
 	 * codex用户使用量统计
 	 */
 	ClaudeCodeResp getCodexUserAnalytics(Long userId, String start, String end, Integer page, Integer limit, String order);
+
+	/**
+	 * 查询全部 Codex 上游渠道(包含已禁用渠道)
+	 */
+	ClaudeCodeResp getCodexChannels();
+
+	/**
+	 * 启用或禁用指定 Codex 上游渠道
+	 */
+	ClaudeCodeResp updateCodexChannelEnabled(Long channelId, CodexChannelEnabledReq req) throws Exception;
 }

+ 55 - 0
netflix-service/src/main/java/com/cyksj/service/api/impl/ClaudeCodexApiServiceImpl.java

@@ -10,6 +10,7 @@ import com.cyksj.common.exception.BusinessRuntimeException;
 import com.cyksj.common.util.Jsons;
 import com.cyksj.common.util.StringUtil;
 import com.cyksj.model.request.ClaudeCodeApiKeysReq;
+import com.cyksj.model.request.codex.CodexChannelEnabledReq;
 import com.cyksj.model.response.claudecode.ClaudeCodeResp;
 import com.cyksj.model.views.ClaudeCodeUserApiKeysView;
 import com.cyksj.service.api.ClaudeCodexApiService;
@@ -132,6 +133,31 @@ public class ClaudeCodexApiServiceImpl implements ClaudeCodexApiService {
 		return codexResp;
 	}
 
+	@Override
+	public ClaudeCodeResp getCodexChannels() {
+		String url = Constant.CLAUDE_CODE_API_PREFIX + "api/codex/channels";
+		return checkCodexChannelResponse(executeGetApi(url));
+	}
+
+	@Override
+	public ClaudeCodeResp updateCodexChannelEnabled(Long channelId, CodexChannelEnabledReq req) throws Exception {
+		String url = String.format(Constant.CLAUDE_CODE_API_PREFIX + "api/codex/channels/%s/enabled", channelId);
+		Map<String, Object> params = new HashMap<>();
+		params.put("enabled", req.getEnabled());
+		return checkCodexChannelResponse(executePutApi(url, Jsons.toJson(params)));
+	}
+
+	private ClaudeCodeResp checkCodexChannelResponse(ClaudeCodeResp resp) {
+		if (resp == null) {
+			throw BusinessRuntimeException.getInstance("Codex 渠道服务无响应");
+		}
+		if (!Boolean.TRUE.equals(resp.getSuccess()) || !Integer.valueOf(0).equals(resp.getCode())) {
+			String message = StringUtil.isEmpty(resp.getMessage()) ? "Codex 渠道服务请求失败" : resp.getMessage();
+			throw BusinessRuntimeException.getInstance(message);
+		}
+		return resp;
+	}
+
 	/**
 	 * GET请求
 	 */
@@ -187,6 +213,35 @@ public class ClaudeCodexApiServiceImpl implements ClaudeCodexApiService {
 		}
 	}
 
+	/**
+	 * PUT请求
+	 */
+	public ClaudeCodeResp executePutApi(String url, String body) {
+		Retryer<ClaudeCodeResp> build = getApiRetryer(1, 3);
+		try {
+			return build.call(() -> {
+				try {
+					HttpResponse execute = HttpUtil.createRequest(Method.PUT, url)
+							.header(Constant.CLAUDE_CODE_HEARD_KEY, Constant.CLAUDE_CODE_API_ADMIN_KEY)
+							.header("Content-Type", "application/json")
+							.setConnectionTimeout(Constant.CONNECT_MILLISECONDS)
+							.body(body)
+							.execute();
+					ClaudeCodeResp resp = Jsons.parseObject(execute.body(), ClaudeCodeResp.class);
+					if (!Constant.SUCCESS.equals(resp.getMessage())) {
+						log.error("PUT URL:{}接口返回msg:{}", url, resp.getMessage());
+					}
+					return resp;
+				} catch (Exception e) {
+					return null;
+				}
+			});
+		} catch (ExecutionException | RetryException e) {
+			log.error("重试调用PUT请求url->{}失败,msg->{}", url, StringUtil.getErrorText(e));
+			throw BusinessRuntimeException.getInstance("接口请求失败");
+		}
+	}
+
 	/**
 	 * claude code DELETE请求
 	 */

+ 46 - 0
netflix-web/src/main/java/com/cyksj/web/controller/manage/codex/CmsCodexChannelController.java

@@ -0,0 +1,46 @@
+package com.cyksj.web.controller.manage.codex;
+
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.dto.Result;
+import com.cyksj.enums.GatewayResponse;
+import com.cyksj.model.request.codex.CodexChannelEnabledReq;
+import com.cyksj.model.response.claudecode.ClaudeCodeResp;
+import com.cyksj.service.api.ClaudeCodexApiService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 管理端 - Codex 上游渠道管理。
+ */
+@Validated
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/manage/codex/channels")
+public class CmsCodexChannelController {
+
+    private final ClaudeCodexApiService claudeCodexApiService;
+
+    /** 查询全部 Codex 渠道,包含已启用和已禁用渠道。 */
+    @GetMapping
+    public Result<Object> getChannels() {
+        ClaudeCodeResp resp = claudeCodexApiService.getCodexChannels();
+        return GatewayResponse.SUCCESS.newBuilder().toResult(resp.getData());
+    }
+
+    /** 幂等地设置指定 Codex 渠道的启用状态。 */
+    @PutMapping("/{id}/enabled")
+    public Result<Object> updateEnabled(@PathVariable Long id,
+                                        @Validated @RequestBody CodexChannelEnabledReq req) throws Exception {
+        if (id == null || id <= 0) {
+            throw BusinessRuntimeException.getInstance("无效的 Codex 渠道 ID");
+        }
+        ClaudeCodeResp resp = claudeCodexApiService.updateCodexChannelEnabled(id, req);
+        return GatewayResponse.SUCCESS.newBuilder().toResult(resp.getData());
+    }
+}

+ 86 - 27
netflix-web/src/main/java/com/cyksj/web/controller/manage/group/CmsGroupRelationController.java

@@ -54,9 +54,13 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -196,36 +200,89 @@ public class CmsGroupRelationController {
         SearchResult<GroupsRelationRechargeView> search = beanSearcher.search(GroupsRelationRechargeView.class, builder
                 .field(GroupsRelationRechargeView::getStatus, List.of(GroupsRelation.Status.validity.name(), GroupsRelation.Status.outside.name())).op(Operator.InList)
                 .build());
-        search.getDataList().forEach(data -> {
-            // Ticket-slot ownership is authoritative for multi-quantity orders. Resolve it
-            // before the legacy relation_id lookup, otherwise an old/generated relation order
-            // can mask the real multi-quantity parent order.
-            OrderDon orderDon = null;
-            OrderDonTicketRecord ticketRecord = orderDonTicketRecordMapper.selectOne(
-                    Wrappers.lambdaQuery(OrderDonTicketRecord.class)
-                            .eq(OrderDonTicketRecord::getRelationId, data.getRelationId())
-                            .eq(OrderDonTicketRecord::getStatus, OrderDonTicketRecord.Status.success)
-                            .orderByDesc(OrderDonTicketRecord::getId)
-                            .last("limit 1"));
-            if (ticketRecord != null) {
-                OrderDon slotOrder = orderDonMapper.selectById(ticketRecord.getOrderId());
-                if (slotOrder != null && !Constant.noOrderAllStatus.contains(slotOrder.getStatus())) {
-                    orderDon = slotOrder;
-                }
+        fillRechargeOrderNos(search.getDataList());
+        return GatewayResponse.SUCCESS.newBuilder().toResult(search);
+    }
+
+    /**
+     * Fill order numbers in batches. The old implementation issued up to three SQL statements
+     * for every row on the page, which made a ten-row page perform 20-30 database round trips.
+     */
+    private void fillRechargeOrderNos(List<GroupsRelationRechargeView> dataList) {
+        if (CollUtil.isEmpty(dataList)) {
+            return;
+        }
+
+        List<Long> relationIds = dataList.stream()
+                .map(GroupsRelationRechargeView::getRelationId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (relationIds.isEmpty()) {
+            return;
+        }
+
+        // Ticket-slot ownership is authoritative. Keep only the latest successful slot per relation,
+        // matching the previous "order by id desc limit 1" behavior.
+        Map<Long, OrderDonTicketRecord> latestTicketByRelation = new HashMap<>();
+        orderDonTicketRecordMapper.selectList(Wrappers.lambdaQuery(OrderDonTicketRecord.class)
+                        .select(OrderDonTicketRecord::getId, OrderDonTicketRecord::getRelationId,
+                                OrderDonTicketRecord::getOrderId)
+                        .in(OrderDonTicketRecord::getRelationId, relationIds)
+                        .eq(OrderDonTicketRecord::getStatus, OrderDonTicketRecord.Status.success)
+                        .orderByDesc(OrderDonTicketRecord::getId))
+                .forEach(record -> latestTicketByRelation.putIfAbsent(record.getRelationId(), record));
+
+        Set<Long> ticketOrderIds = latestTicketByRelation.values().stream()
+                .map(OrderDonTicketRecord::getOrderId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, OrderDon> ticketOrderById = ticketOrderIds.isEmpty()
+                ? new HashMap<>()
+                : orderDonMapper.selectBatchIds(ticketOrderIds).stream()
+                .collect(Collectors.toMap(OrderDon::getId, order -> order, (left, right) -> left));
+
+        Set<Long> resolvedRelationIds = new HashSet<>();
+        dataList.forEach(data -> {
+            OrderDonTicketRecord ticket = latestTicketByRelation.get(data.getRelationId());
+            OrderDon order = ticket == null ? null : ticketOrderById.get(ticket.getOrderId());
+            if (order != null && !Constant.noOrderAllStatus.contains(order.getStatus())) {
+                data.setOrderNo(order.getOrderNo());
+                resolvedRelationIds.add(data.getRelationId());
             }
-            if (orderDon == null) {
-                orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-                        .eq(OrderDon::getUserId, data.getUserId())
-                        .eq(OrderDon::getRelationId, data.getRelationId())
+        });
+
+        List<GroupsRelationRechargeView> unresolved = dataList.stream()
+                .filter(data -> data.getRelationId() != null && !resolvedRelationIds.contains(data.getRelationId()))
+                .collect(Collectors.toList());
+        if (unresolved.isEmpty()) {
+            return;
+        }
+
+        List<Long> unresolvedRelationIds = unresolved.stream()
+                .map(GroupsRelationRechargeView::getRelationId).distinct().collect(Collectors.toList());
+        List<Long> userIds = unresolved.stream()
+                .map(GroupsRelationRechargeView::getUserId).filter(Objects::nonNull)
+                .distinct().collect(Collectors.toList());
+        if (userIds.isEmpty()) {
+            return;
+        }
+
+        Map<String, OrderDon> latestLegacyOrder = new HashMap<>();
+        orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class)
+                        .in(OrderDon::getRelationId, unresolvedRelationIds)
+                        .in(OrderDon::getUserId, userIds)
                         .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-                        .orderByDesc(OrderDon::getId)
-                        .last("limit 1"));
-            }
-            if (orderDon != null) {
-                data.setOrderNo(orderDon.getOrderNo());
+                        .orderByDesc(OrderDon::getId))
+                .forEach(order -> latestLegacyOrder.putIfAbsent(
+                        order.getRelationId() + ":" + order.getUserId(), order));
+
+        unresolved.forEach(data -> {
+            OrderDon order = latestLegacyOrder.get(data.getRelationId() + ":" + data.getUserId());
+            if (order != null) {
+                data.setOrderNo(order.getOrderNo());
             }
         });
-        return GatewayResponse.SUCCESS.newBuilder().toResult(search);
     }
 
     /**
@@ -360,7 +417,9 @@ public class CmsGroupRelationController {
             }
             //扣除次数
             Integer rechargeRemainNum = relation.getRechargeRemainNum();
-            if (isYoutubeIndividualMember && (rechargeRemainNum == null || rechargeRemainNum < subRechargeNum)) {
+            // Historical tickets did not track recharge counts. A null value means quota
+            // management is not enabled for this ticket, so keep the legacy shipping behavior.
+            if (isYoutubeIndividualMember && rechargeRemainNum != null && rechargeRemainNum < subRechargeNum) {
                 throw BusinessRuntimeException.getInstance("YouTube个人会员剩余代充次数不足");
             }
             if (rechargeRemainNum != null && rechargeRemainNum > 0) {