Browse Source

赠送API额度

zoujiajian 3 months ago
parent
commit
c45fb02cc9

+ 113 - 0
docs/manager-statistics-ai-usage-data.md

@@ -0,0 +1,113 @@
+# 用户 AI 用量每日统计接口
+
+## 接口信息
+
+- 接口地址:`GET /manager/statistics/get/aiUsageData`
+- 用途:查询用户 AI 用量的每日统计列表
+- 数据来源:`daily_usage_statistics_record`
+- 默认排序:按 `usageDate` 倒序
+- 返回类型:分页列表
+
+## 请求参数
+
+该接口使用 Bean Searcher 查询参数,支持分页和按返回字段筛选。
+
+### 分页参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- | --- |
+| `start` | number | 否 | `1` | 页码,从 1 开始 |
+| `limit` | number | 否 | `10` | 每页条数 |
+
+### 常用筛选参数
+
+| 参数名 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `type` | string | 否 | AI 类型 |
+| `usageDate` | string | 否 | 使用日期,精确匹配 |
+| `usageDate-op` | string | 否 | `usageDate` 的查询操作符,例如 `bt` 表示区间查询 |
+| `usageDate-0` | string | 否 | 区间开始日期,配合 `usageDate-op=bt` 使用 |
+| `usageDate-1` | string | 否 | 区间结束日期,配合 `usageDate-op=bt` 使用 |
+
+可筛选字段与返回记录字段一致:`id`、`createdTime`、`updateTime`、`type`、`usageDate`、`userCount`、`dailyLimitTotal`、`usageUserCount`、`usedUsd`、`requestCount`。
+
+## 请求示例
+
+```http
+GET /manager/statistics/get/aiUsageData?start=1&limit=10&type=codex
+```
+
+按日期区间查询:
+
+```http
+GET /manager/statistics/get/aiUsageData?start=1&limit=10&usageDate-op=bt&usageDate-0=2026-05-01&usageDate-1=2026-05-13
+```
+
+## 响应字段
+
+### 外层结构
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `code` | number | 状态码 |
+| `msg` | string | 状态描述 |
+| `flag` | boolean | 是否成功 |
+| `data` | object | 查询结果 |
+
+### `data`
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `totalCount` | number | 总记录数 |
+| `dataList` | array | 当前页数据 |
+| `summaries` | array | 汇总数据;当前接口未配置汇总时通常为空数组 |
+
+### `data.dataList[]`
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | number | 记录 ID |
+| `createdTime` | string | 创建时间 |
+| `updateTime` | string | 更新时间 |
+| `type` | string | AI 类型 |
+| `usageDate` | string | 统计日期 |
+| `userCount` | number | 用户总数 |
+| `dailyLimitTotal` | number | 当日可用额度合计 |
+| `usageUserCount` | number | 当日使用用户数 |
+| `usedUsd` | number | 当日用量,单位 USD |
+| `requestCount` | number | 请求次数 |
+
+## 响应示例
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "flag": true,
+  "data": {
+    "totalCount": 1,
+    "dataList": [
+      {
+        "id": 1,
+        "createdTime": "2026-05-13 00:00:00",
+        "updateTime": "2026-05-13 00:00:00",
+        "type": "codex",
+        "usageDate": "2026-05-13 00:00:00",
+        "userCount": 100,
+        "dailyLimitTotal": 500.00,
+        "usageUserCount": 80,
+        "usedUsd": 123.45,
+        "requestCount": 300
+      }
+    ],
+    "summaries": []
+  }
+}
+```
+
+## 前端对接备注
+
+- 列表默认已按 `usageDate` 倒序,前端不传排序参数即可。
+- `start` 是页码,不是偏移量。
+- 金额/用量字段建议按数字处理,展示时再格式化小数位。
+- 日期字段由后端 `Date` 序列化输出,实际格式以当前项目 Jackson 配置为准。

+ 10 - 0
netflix-common/src/main/java/com/cyksj/common/constant/Constant.java

@@ -858,4 +858,14 @@ public interface Constant {
 	 * Adobe 全家桶
 	 */
 	Long ADOBE_GOODS_ID = 45L;
+
+    /**
+     * API额度兑换码车票ID
+     */
+    Long GPT_ACTIVITY_GIFT_SKU_ID = 817L;
+
+    /**
+     * AI API调度域名
+     */
+    String AI_API_SVC_DOMAIN = "https://svc.apinebula.com";
 }

+ 2 - 0
netflix-common/src/main/java/com/cyksj/dto/RedisKey.java

@@ -74,4 +74,6 @@ public class RedisKey {
     public static String CHATGPT_USER_RESTRICTION ="chatgpt:user:restriction:";
 
     public static String GOODS_SUBSCRIBE_ALERT_KEY = "goods_subscribe_alert_key:";
+
+    public static String GPT_API_QUOTA_KEY = "gpt_pay";
 }

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

@@ -0,0 +1,17 @@
+package com.cyksj.model.request;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotNull;
+
+@Getter
+@Setter
+public class ReceiveGptActivityTicketReq {
+
+    /**
+     * 用于校验领取资格的车票 ID
+     */
+    @NotNull
+    private Long relationId;
+}

+ 19 - 0
netflix-dao/src/main/java/com/cyksj/model/response/ReceiveGptActivityTicketResp.java

@@ -0,0 +1,19 @@
+package com.cyksj.model.response;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ReceiveGptActivityTicketResp {
+
+    /**
+     * 领取后发放的 817 规格车票 ID
+     */
+    private Long relationId;
+
+    /**
+     * 第三方兑换码
+     */
+    private String redeemCode;
+}

+ 156 - 0
netflix-web/src/main/java/com/cyksj/web/controller/group/GroupRelationController.java

@@ -9,6 +9,7 @@ import cn.hutool.core.lang.Assert;
 import cn.hutool.core.thread.ThreadUtil;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.extra.servlet.ServletUtil;
+import cn.hutool.http.HttpUtil;
 import cn.hutool.json.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -21,6 +22,7 @@ import com.cyksj.common.task.GlobalThreadPoolTaskExecutor;
 import com.cyksj.common.util.GoogleGenerator;
 import com.cyksj.common.util.Jsons;
 import com.cyksj.common.util.StringUtil;
+import com.cyksj.dto.RedisKey;
 import com.cyksj.dto.Result;
 import com.cyksj.enums.GatewayResponse;
 import com.cyksj.mapper.*;
@@ -37,9 +39,11 @@ import com.cyksj.model.response.GptStandbyResp;
 import com.cyksj.model.response.NetflixErrorAccountStatus;
 import com.cyksj.model.response.NetflixRecoverResp;
 import com.cyksj.model.response.NetflixRefundInfoResp;
+import com.cyksj.model.response.ReceiveGptActivityTicketResp;
 import com.cyksj.model.views.*;
 import com.cyksj.redis.RedisService;
 import com.cyksj.service.chatgpt.ChatGptAuthService;
+import com.cyksj.service.exchange.ExchangeCodeService;
 import com.cyksj.service.mange.CmsOrderDonService;
 import com.cyksj.service.mange.coupon.CouponCommonService;
 import com.cyksj.service.mange.ticket.TicketAdvertiseConfigClickRecordService;
@@ -83,11 +87,14 @@ import java.util.stream.Collectors;
 @RestController
 @RequestMapping("/applets/group/relation")
 public class GroupRelationController {
+    private static final Date GPT_ACTIVITY_PAY_TIME_BEGIN = DateUtil.parse("2026-05-03 00:00:00");
 
     private final BeanSearcher beanSearcher;
 
     private final OrderDonService orderDonService;
 
+    private final ExchangeCodeService exchangeCodeService;
+
     private final GroupsRelationMapper relationMapper;
 
     private final TicketRedemptionCodeRecordMapper ticketRedemptionCodeRecordMapper;
@@ -265,6 +272,106 @@ public class GroupRelationController {
         return GatewayResponse.SUCCESS.newBuilder().toResult(record.getRedeemCode());
     }
 
+    /**
+     * 购买GPT活动API兑换码额度
+     */
+    @PostMapping("/receive/code/quota")
+    @NoSubmit
+    public Result<ReceiveGptActivityTicketResp> receiveGptActivityTicket(@RequestBody @Validated ReceiveGptActivityTicketReq req) throws Exception {
+        long userId = StpUserUtil.getLoginIdAsLong();
+        List<Long> userIds = userBindRelationService.getRelationUserIdList(userId, null);
+        if (CollUtil.isEmpty(userIds)) {
+            userIds = List.of(userId);
+        }
+        Long sourceRelationId = req.getRelationId();
+        String lockKey = String.format("%s:%s", RedisKey.GPT_API_QUOTA_KEY, Collections.min(userIds));
+        if (!redisService.setNx(lockKey, userId, 60L)) {
+            throw BusinessRuntimeException.getInstance("领取中,请稍后再试");
+        }
+        try {
+
+            GroupsRelationView sourceRelation = beanSearcher.searchFirst(GroupsRelationView.class, MapUtils.builder()
+                    .field(GroupsRelationView::getId, sourceRelationId)
+                    .field(GroupsRelationView::getUserId, userIds).op(Operator.InList)
+                    .field(GroupsRelationView::getStatus, List.of(GroupsRelation.Status.validity, GroupsRelation.Status.outside)).op(Operator.InList)
+                    .build());
+            if (sourceRelation == null || (sourceRelation.getExpiryTime() != null && sourceRelation.getExpiryTime().before(DateTime.now()))) {
+                throw BusinessRuntimeException.getInstance("车票不存在或已过期");
+            }
+
+            GoodsDonSku sourceSku = goodsDonSkuMapper.selectById(sourceRelation.getSkuId());
+            if (sourceSku == null) {
+                throw BusinessRuntimeException.getInstance("车票规格不存在");
+            }
+            if (!Constant.GPT_RECHARGE_GOODS_ID.equals(sourceRelation.getGoodsId())
+                    && !(Constant.GPT_PLUS_GID.equals(sourceRelation.getGoodsId()) && Objects.equals(sourceSku.getNum(), 1))) {
+                throw BusinessRuntimeException.getInstance("当前车票不符合领取条件");
+            }
+
+            OrderDon sourceOrder = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+                    .eq(OrderDon::getRelationId, sourceRelationId)
+                    .in(OrderDon::getUserId, userIds)
+                    .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+                    .and(wrapper -> wrapper.ge(OrderDon::getPayTime, GPT_ACTIVITY_PAY_TIME_BEGIN)
+                            .or(sub -> sub.isNull(OrderDon::getPayTime).ge(OrderDon::getCreatedTime, GPT_ACTIVITY_PAY_TIME_BEGIN)))
+                    .orderByDesc(OrderDon::getId)
+                    .last("limit 1"));
+            if (sourceOrder == null) {
+                throw BusinessRuntimeException.getInstance("仅限 2026-05-03 后购买的用户领取");
+            }
+            Integer receivedCount = ticketRedemptionCodeRecordMapper.selectCount(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
+                    .in(TicketRedemptionCodeRecord::getUserId, userIds)
+                    .eq(TicketRedemptionCodeRecord::getSkuId, Constant.GPT_ACTIVITY_GIFT_SKU_ID)
+                    .eq(TicketRedemptionCodeRecord::getSourceType, RedisKey.GPT_API_QUOTA_KEY));
+            if (receivedCount > 0) {
+                throw BusinessRuntimeException.getInstance("您已领取过该权益");
+            }
+
+            TicketRedemptionCodeRecord record = new TicketRedemptionCodeRecord();
+            record.setUserId(userId);
+            record.setRelationId(sourceRelationId);
+            record.setGoodsId(sourceRelation.getGoodsId());
+            record.setSkuId(Constant.GPT_ACTIVITY_GIFT_SKU_ID);
+            record.setSourceType(RedisKey.GPT_API_QUOTA_KEY);
+            record.setRedeemName("GPT代充/独立赠送");
+            record.setStatus("processing");
+            record.setRelationExpiryTime(sourceRelation.getExpiryTime());
+            ticketRedemptionCodeRecordMapper.insert(record);
+            //兑换码API车票规格ID
+            GoodsDonSku giftSku = goodsDonSkuMapper.selectById(Constant.GPT_ACTIVITY_GIFT_SKU_ID);
+
+            GroupsRelation giftRelation = null;
+            try {
+                giftRelation = exchangeCodeService.getGroupTripsTicket(null, giftSku, userId, null, OrderDon.Type.EX_CODE, null);
+                GptActivityRedeemResult redeemResult = requestGptActivityRedeemCode(Constant.AI_API_SVC_DOMAIN, userId, sourceRelationId, giftRelation, giftSku);
+                record.setRelationId(giftRelation.getId());
+                record.setGoodsId(giftSku.getGoodsId());
+                record.setRedeemCode(redeemResult.getRedeemCode());
+                record.setStatus("success");
+                record.setRelationExpiryTime(giftRelation.getExpiryTime());
+                record.setApiResponse(redeemResult.getApiResponse());
+                ticketRedemptionCodeRecordMapper.updateById(record);
+
+                ReceiveGptActivityTicketResp resp = new ReceiveGptActivityTicketResp();
+                resp.setRelationId(giftRelation.getId());
+                resp.setRedeemCode(redeemResult.getRedeemCode());
+                return GatewayResponse.SUCCESS.newBuilder().toResult(resp);
+            } catch (Exception e) {
+                record.setGoodsId(giftSku.getGoodsId());
+                record.setStatus("error");
+                record.setErrorMsg(StringUtil.getErrorText(e));
+                if (giftRelation != null) {
+                    record.setRelationId(giftRelation.getId());
+                    record.setRelationExpiryTime(giftRelation.getExpiryTime());
+                }
+                ticketRedemptionCodeRecordMapper.updateById(record);
+                throw e;
+            }
+        } finally {
+            redisService.del(lockKey);
+        }
+    }
+
     /**
      * 记录查看ChatGPT车票密码
      */
@@ -1655,4 +1762,53 @@ public class GroupRelationController {
         OrderRefundDto orderRefundDto = groupRelationFrontService.getYoutubeRefundInfo(userId, relationId);
         return GatewayResponse.SUCCESS.newBuilder().toResult(orderRefundDto);
     }
+
+    private GptActivityRedeemResult requestGptActivityRedeemCode(String apiUrl, Long userId, Long sourceRelationId, GroupsRelation giftRelation, GoodsDonSku giftSku) throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("userId", userId);
+        params.put("sourceRelationId", sourceRelationId);
+        params.put("relationId", giftRelation.getId());
+        params.put("goodsId", giftSku.getGoodsId());
+        params.put("skuId", giftSku.getId());
+        String response = HttpUtil.post(apiUrl, params, 15000);
+        if (StrUtil.isBlank(response)) {
+            throw BusinessRuntimeException.getInstance("第三方兑换码接口返回为空");
+        }
+        Map<String, Object> responseMap = Jsons.toMap(response);
+        Object success = responseMap.get("success");
+        Object flag = responseMap.get("flag");
+        Object code = responseMap.get("code");
+        boolean failed = Boolean.FALSE.equals(success)
+                || Boolean.FALSE.equals(flag)
+                || (code != null && !"0".equals(code.toString()) && !"200".equals(code.toString()));
+        if (failed) {
+            Object msg = Optional.ofNullable(responseMap.get("msg")).orElse(responseMap.get("message"));
+            throw BusinessRuntimeException.getInstance(msg == null ? "第三方兑换码接口调用失败" : msg.toString());
+        }
+        Object redeemCode = Optional.ofNullable(responseMap.get("redeemCode"))
+                .orElse(Optional.ofNullable(responseMap.get("redeem_code")).orElse(responseMap.get("redemptionCode")));
+        Object data = responseMap.get("data");
+        if (redeemCode == null && data instanceof Map) {
+            Map<?, ?> dataMap = (Map<?, ?>) data;
+            redeemCode = Optional.ofNullable(dataMap.get("redeemCode"))
+                    .orElse(Optional.ofNullable(dataMap.get("redeem_code"))
+                            .orElse(Optional.ofNullable(dataMap.get("redemptionCode")).orElse(dataMap.get("code"))));
+        } else if (redeemCode == null && data instanceof String) {
+            redeemCode = data;
+        }
+        if (redeemCode == null || StrUtil.isBlank(redeemCode.toString())) {
+            throw BusinessRuntimeException.getInstance("第三方兑换码接口未返回兑换码");
+        }
+        GptActivityRedeemResult result = new GptActivityRedeemResult();
+        result.setRedeemCode(redeemCode.toString());
+        result.setApiResponse(response);
+        return result;
+    }
+
+    @lombok.Getter
+    @lombok.Setter
+    private static class GptActivityRedeemResult {
+        private String redeemCode;
+        private String apiResponse;
+    }
 }