Commit 4f0c132c by renyizhao

赠送金額相关修改

parent 913503d3
package com.luhu.computility.module.apihub.api.newapi;
import java.math.BigDecimal;
/**
* New API 用户相关 API 接口
*
* <p>供其他模块(member 等)通过 RPC 调用 apihub 模块查询 New API 网关用户信息。
*
* @author luhu
*/
public interface NewApiUserApi {
/**
* 获取 New API 网关上指定用户的当前余额(单位:元,保留 2 位小数)。
*
* <p>失败时(如 token 失效、网络异常)返回 {@code null},由调用方自行决定 fallback。
*
* @param accessToken New API 网关用户 access_token
* @param newApiUserId New API 网关用户 ID
* @return 余额(元),失败返回 {@code null}
*/
BigDecimal getQuotaYuan(String accessToken, Integer newApiUserId);
}
package com.luhu.computility.module.apihub.api;
import com.luhu.computility.module.apihub.api.newapi.NewApiUserApi;
import com.luhu.computility.module.apihub.controller.admin.newapi.NewApiResponse;
import com.luhu.computility.module.apihub.service.newapi.NewApiClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Map;
/**
* {@link NewApiUserApi} 实现,封装 New API 网关用户余额查询。
*
* <p>复用现有 {@link NewApiClient#getUserInfo}(quota 单位 1元 = 500000),
* 在此层把 quota 转换成元(保留 2 位小数)。
*
* @author luhu
*/
@Slf4j
@Service
public class NewApiUserApiServiceImpl implements NewApiUserApi {
/** New API 配额单位:1 元 = 500000 quota */
private static final BigDecimal QUOTA_PER_YUAN = new BigDecimal("500000");
@Resource
private NewApiClient newApiClient;
@Override
public BigDecimal getQuotaYuan(String accessToken, Integer newApiUserId) {
if (accessToken == null || accessToken.isEmpty() || newApiUserId == null) {
return null;
}
try {
NewApiResponse<Map<String, Object>> resp = newApiClient.getUserInfo(accessToken, newApiUserId);
if (resp == null || !Boolean.TRUE.equals(resp.getSuccess()) || resp.getData() == null) {
log.warn("[NewApiUserApi.getQuotaYuan] New API 返回失败 newApiUserId={} message={}",
newApiUserId, resp == null ? "null" : resp.getMessage());
return null;
}
Object quotaObj = resp.getData().get("quota");
if (!(quotaObj instanceof Number)) {
log.warn("[NewApiUserApi.getQuotaYuan] quota 字段缺失或非数值 newApiUserId={} quota={}",
newApiUserId, quotaObj);
return null;
}
long quota = ((Number) quotaObj).longValue();
return BigDecimal.valueOf(quota)
.divide(QUOTA_PER_YUAN, 2, RoundingMode.HALF_UP);
} catch (Exception e) {
log.error("[NewApiUserApi.getQuotaYuan] 查询 New API 余额异常 newApiUserId={}", newApiUserId, e);
return null;
}
}
}
...@@ -71,6 +71,7 @@ public class AiTokenStatsController { ...@@ -71,6 +71,7 @@ public class AiTokenStatsController {
vo.setStatsCount(result.getStatsCount()); vo.setStatsCount(result.getStatsCount());
vo.setStatsQuota(result.getStatsQuota()); vo.setStatsQuota(result.getStatsQuota());
vo.setStatsTokens(result.getStatsTokens()); vo.setStatsTokens(result.getStatsTokens());
vo.setStatsGiftAmount(result.getStatsGiftAmount());
return CommonResult.success(vo); return CommonResult.success(vo);
} }
...@@ -85,5 +86,8 @@ public class AiTokenStatsController { ...@@ -85,5 +86,8 @@ public class AiTokenStatsController {
@io.swagger.v3.oas.annotations.media.Schema(description = "统计Tokens") @io.swagger.v3.oas.annotations.media.Schema(description = "统计Tokens")
private Long statsTokens; private Long statsTokens;
@io.swagger.v3.oas.annotations.media.Schema(description = "赠送金额(元)")
private java.math.BigDecimal statsGiftAmount;
} }
} }
...@@ -12,8 +12,11 @@ import org.springframework.stereotype.Service; ...@@ -12,8 +12,11 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j @Slf4j
@Service @Service
...@@ -248,6 +251,7 @@ public class AiTokenService { ...@@ -248,6 +251,7 @@ public class AiTokenService {
.statsCount(0) .statsCount(0)
.statsQuota(BigDecimal.ZERO) .statsQuota(BigDecimal.ZERO)
.statsTokens(0L) .statsTokens(0L)
.statsGiftAmount(safeGetGiftAmount(startTimestamp, endTimestamp))
.build(); .build();
} }
...@@ -263,6 +267,7 @@ public class AiTokenService { ...@@ -263,6 +267,7 @@ public class AiTokenService {
.statsCount(0) .statsCount(0)
.statsQuota(BigDecimal.ZERO) .statsQuota(BigDecimal.ZERO)
.statsTokens(0L) .statsTokens(0L)
.statsGiftAmount(safeGetGiftAmount(startTimestamp, endTimestamp))
.build(); .build();
} }
...@@ -293,7 +298,7 @@ public class AiTokenService { ...@@ -293,7 +298,7 @@ public class AiTokenService {
// quota 转换为元(除以 500000) // quota 转换为元(除以 500000)
BigDecimal statsQuota = BigDecimal.valueOf(totalQuota) BigDecimal statsQuota = BigDecimal.valueOf(totalQuota)
.divide(BigDecimal.valueOf(500000), 2, BigDecimal.ROUND_HALF_UP); .divide(BigDecimal.valueOf(500000), 2, RoundingMode.HALF_UP);
log.info("[AiTokenService.getUserStats] 汇总结果: count={}, tokens={}, quota={}", totalCount, totalTokens, statsQuota); log.info("[AiTokenService.getUserStats] 汇总结果: count={}, tokens={}, quota={}", totalCount, totalTokens, statsQuota);
...@@ -302,6 +307,7 @@ public class AiTokenService { ...@@ -302,6 +307,7 @@ public class AiTokenService {
.statsCount((int) totalCount) .statsCount((int) totalCount)
.statsTokens(totalTokens) .statsTokens(totalTokens)
.statsQuota(statsQuota) .statsQuota(statsQuota)
.statsGiftAmount(safeGetGiftAmount(startTimestamp, endTimestamp))
.build(); .build();
} catch (Exception e) { } catch (Exception e) {
...@@ -313,6 +319,19 @@ public class AiTokenService { ...@@ -313,6 +319,19 @@ public class AiTokenService {
} }
} }
/**
* 安全调用 getAdminGiftAmount,失败时返回 0
* 主统计失败时不应被 gift 调用拖垮
*/
private BigDecimal safeGetGiftAmount(Long startTimestamp, Long endTimestamp) {
try {
return getAdminGiftAmount(startTimestamp, endTimestamp);
} catch (Exception e) {
log.error("[safeGetGiftAmount] 赠送金额查询失败,降级返回 0", e);
return BigDecimal.ZERO;
}
}
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
...@@ -320,8 +339,81 @@ public class AiTokenService { ...@@ -320,8 +339,81 @@ public class AiTokenService {
public static class AiTokenStatsResult { public static class AiTokenStatsResult {
private Boolean success; private Boolean success;
private String message; private String message;
private Integer statsCount; // 统计次数 private Integer statsCount; // 统计次数
private BigDecimal statsQuota; // 统计额度(元) private BigDecimal statsQuota; // 统计额度(元)
private Long statsTokens; // 统计Tokens private Long statsTokens; // 统计Tokens
private BigDecimal statsGiftAmount; // 赠送金额(元,管理员增加的用户额度)
}
/**
* 统计全平台"管理员增加用户额度"的总金额(元)
* 通过 admin 调 New API /api/log?type=3,按 content 匹配 "管理员增加用户额度 ¥xxx 额度" 累加
* 注意:type=3 的记录 quota 字段为 0,金额必须从 content 提取
*
* @param startTimestamp 起始时间戳(秒)
* @param endTimestamp 截止时间戳(秒)
* @return 赠送总额(元,保留 2 位小数)
*/
public BigDecimal getAdminGiftAmount(Long startTimestamp, Long endTimestamp) {
final int pageSize = 100;
final int maxPages = 200; // 安全上限,防止 total 异常时死循环
// 严格匹配 "管理员增加用户额度 ¥金额 额度",避免误匹配其他 type=3 记录
final Pattern pattern = Pattern.compile("^管理员增加用户额度\\s+¥(\\d+(?:\\.\\d+)?)\\s+额度$");
BigDecimal totalGiftYuan = BigDecimal.ZERO;
for (int page = 1; page <= maxPages; page++) {
NewApiResponse<Map<String, Object>> resp = newApiClient.getAdminLog(
page, pageSize, 3, startTimestamp, endTimestamp);
if (!Boolean.TRUE.equals(resp.getSuccess()) || resp.getData() == null) {
log.warn("[getAdminGiftAmount] 第 {} 页请求失败: {}", page, resp.getMessage());
break;
}
// /api/log 响应 data 结构:{page, page_size, total, items: [...]}
Map<String, Object> data = resp.getData();
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
int total = 0;
Object totalObj = data.get("total");
if (totalObj instanceof Number) {
total = ((Number) totalObj).intValue();
}
if (items == null || items.isEmpty()) {
log.info("[getAdminGiftAmount] 第 {} 页无数据,结束", page);
break;
}
for (Map<String, Object> item : items) {
Object contentObj = item.get("content");
if (!(contentObj instanceof String)) continue;
Matcher matcher = pattern.matcher((String) contentObj);
if (matcher.matches()) {
try {
BigDecimal amount = new BigDecimal(matcher.group(1));
totalGiftYuan = totalGiftYuan.add(amount);
} catch (NumberFormatException nfe) {
log.warn("[getAdminGiftAmount] 金额解析失败: {}", matcher.group(1));
}
}
}
log.info("[getAdminGiftAmount] 第 {} 页: items={}, total={}, 当前累加={} 元",
page, items.size(), total, totalGiftYuan);
// 终止条件 1:已拉满 total
if (total > 0 && page * pageSize >= total) {
break;
}
// 终止条件 2:本次返回不足一页(最后一页)
if (items.size() < pageSize) {
break;
}
}
log.info("[getAdminGiftAmount] 赠送总额: {} 元", totalGiftYuan);
return totalGiftYuan.setScale(2, RoundingMode.HALF_UP);
} }
} }
\ No newline at end of file
...@@ -609,6 +609,54 @@ public class NewApiClient { ...@@ -609,6 +609,54 @@ public class NewApiClient {
} }
} }
/**
* 获取全平台日志(管理员专用)
* GET /api/log?p=1&page_size=10&type=0&model_name=&start_timestamp=&end_timestamp=
*
* 响应 data 结构:{page, page_size, total, items: [...]}
* 与 /api/log/self 的区别:用 admin token + admin userId 头;可查看所有用户的日志
*
* @param page 页数
* @param pageSize 每页条数
* @param type 0: 全部 1: 充值 2: 消费 3: 管理
* @param startTimestamp 起始时间戳(可空)
* @param endTimestamp 截止时间戳(可空)
*/
public NewApiResponse<Map<String, Object>> getAdminLog(Integer page, Integer pageSize, Integer type,
Long startTimestamp, Long endTimestamp) {
StringBuilder url = new StringBuilder(newApiProperties.getBaseUrl())
.append("/api/log/?")
.append("p=").append(page == null ? 1 : page)
.append("&page_size=").append(pageSize == null ? 10 : pageSize)
.append("&type=").append(type == null ? 0 : type)
.append("&model_name=");
if (startTimestamp != null) {
url.append("&start_timestamp=").append(startTimestamp);
}
if (endTimestamp != null) {
url.append("&end_timestamp=").append(endTimestamp);
}
log.info("[NewApiClient.getAdminLog] 请求URL: {}", url);
try {
HttpResponse response = HttpRequest.get(url.toString())
.header(HEADER_USER, newApiProperties.getAdminUserId())
.header(HEADER_AUTH, "Bearer " + newApiProperties.getAdminToken())
.timeout(30000)
.execute();
log.info("[NewApiClient.getAdminLog] 响应状态: {}", response.getStatus());
return parseMapResponse(response);
} catch (Exception e) {
log.error("[NewApiClient.getAdminLog] 请求异常", e);
return NewApiResponse.<Map<String, Object>>builder()
.success(false)
.message("请求异常: " + e.getMessage())
.httpResponse(null)
.build();
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private NewApiResponse<Map<String, Object>> parseMapResponse(HttpResponse response) { private NewApiResponse<Map<String, Object>> parseMapResponse(HttpResponse response) {
try { try {
......
package com.luhu.computility.module.member.service.recharge; package com.luhu.computility.module.member.service.recharge;
import com.luhu.computility.module.apihub.api.newapi.NewApiUserApi;
import com.luhu.computility.module.infra.api.file.FileApi; import com.luhu.computility.module.infra.api.file.FileApi;
import com.luhu.computility.module.member.dal.dataobject.recharge.MemberRechargeDO; import com.luhu.computility.module.member.dal.dataobject.recharge.MemberRechargeDO;
import com.luhu.computility.module.member.dal.dataobject.user.MemberUserDO; import com.luhu.computility.module.member.dal.dataobject.user.MemberUserDO;
...@@ -17,6 +18,8 @@ import java.io.ByteArrayOutputStream; ...@@ -17,6 +18,8 @@ import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
...@@ -43,6 +46,10 @@ public class RechargeAgreementPdfService { ...@@ -43,6 +46,10 @@ public class RechargeAgreementPdfService {
private static final String PLACEHOLDER_AGREEMENT_DATE = "${agreementDate}"; private static final String PLACEHOLDER_AGREEMENT_DATE = "${agreementDate}";
/** HTML 模板里字体栈占位符(实际加载的字体 family name 在首位) */ /** HTML 模板里字体栈占位符(实际加载的字体 family name 在首位) */
private static final String PLACEHOLDER_FONT_FAMILY = "${fontFamilyStack}"; private static final String PLACEHOLDER_FONT_FAMILY = "${fontFamilyStack}";
/** HTML 模板里本次充值金额占位符(单位:元,保留 2 位小数) */
private static final String PLACEHOLDER_RECHARGE_AMOUNT = "${rechargeAmount}";
/** HTML 模板里余额合计占位符(单位:元,保留 2 位小数) */
private static final String PLACEHOLDER_TOTAL_BALANCE = "${totalBalance}";
/** 兜底字体栈(实际加载的字体 family name 会被插入到第一位) */ /** 兜底字体栈(实际加载的字体 family name 会被插入到第一位) */
private static final String FALLBACK_FONT_STACK = private static final String FALLBACK_FONT_STACK =
...@@ -53,6 +60,7 @@ public class RechargeAgreementPdfService { ...@@ -53,6 +60,7 @@ public class RechargeAgreementPdfService {
private final MemberRechargeMapper rechargeMapper; private final MemberRechargeMapper rechargeMapper;
private final MemberUserService memberUserService; private final MemberUserService memberUserService;
private final FileApi fileApi; private final FileApi fileApi;
private final NewApiUserApi newApiUserApi;
/** /**
* 生成并保存协议 PDF。 * 生成并保存协议 PDF。
...@@ -88,6 +96,20 @@ public class RechargeAgreementPdfService { ...@@ -88,6 +96,20 @@ public class RechargeAgreementPdfService {
? recharge.getCreateTime().format(DATE_FORMATTER) ? recharge.getCreateTime().format(DATE_FORMATTER)
: LocalDate.now().format(DATE_FORMATTER); : LocalDate.now().format(DATE_FORMATTER);
// 本次充值金额(元,保留 2 位小数)
BigDecimal rechargeAmount = recharge.getAmount() != null
? recharge.getAmount().setScale(2, RoundingMode.HALF_UP)
: BigDecimal.ZERO.setScale(2);
// 余额合计 = 充值前 New API 余额 + 本次充值金额
// 协议是在点击"确认充值"那一刻签署的(此时支付未完成、New API 余额也未到账),
// 这里的"余额合计"按用户口径取预期到账后总额。
// New API 余额查询失败时降级为只显示本次充值金额。
BigDecimal currentBalance = queryNewApiBalanceYuan(user);
BigDecimal totalBalance = (currentBalance != null ? currentBalance : BigDecimal.ZERO)
.add(rechargeAmount)
.setScale(2, RoundingMode.HALF_UP);
// 2. 尝试加载中文字体,读出真实 family name 用于 HTML font-family // 2. 尝试加载中文字体,读出真实 family name 用于 HTML font-family
LoadedFont loadedFont = loadChineseFont(); LoadedFont loadedFont = loadChineseFont();
String fontFamilyStack = loadedFont == null String fontFamilyStack = loadedFont == null
...@@ -98,7 +120,9 @@ public class RechargeAgreementPdfService { ...@@ -98,7 +120,9 @@ public class RechargeAgreementPdfService {
String html = loadTemplate() String html = loadTemplate()
.replace(PLACEHOLDER_USER_NICKNAME, escapeHtml(userNickname)) .replace(PLACEHOLDER_USER_NICKNAME, escapeHtml(userNickname))
.replace(PLACEHOLDER_AGREEMENT_DATE, escapeHtml(agreementDate)) .replace(PLACEHOLDER_AGREEMENT_DATE, escapeHtml(agreementDate))
.replace(PLACEHOLDER_FONT_FAMILY, fontFamilyStack); .replace(PLACEHOLDER_FONT_FAMILY, fontFamilyStack)
.replace(PLACEHOLDER_RECHARGE_AMOUNT, rechargeAmount.toPlainString())
.replace(PLACEHOLDER_TOTAL_BALANCE, totalBalance.toPlainString());
// 4. HTML → PDF // 4. HTML → PDF
byte[] pdfBytes = renderHtmlToPdf(html, loadedFont); byte[] pdfBytes = renderHtmlToPdf(html, loadedFont);
...@@ -126,6 +150,27 @@ public class RechargeAgreementPdfService { ...@@ -126,6 +150,27 @@ public class RechargeAgreementPdfService {
// ============ 私有工具方法 ============ // ============ 私有工具方法 ============
/**
* 查询用户在 New API 网关上的当前余额(元)。
*
* <p>用户未拿到 New API access_token(如 newapi_user_id / newapi_access_token 为空)
* 或查询失败时返回 {@code null},由调用方降级处理。
*/
private BigDecimal queryNewApiBalanceYuan(MemberUserDO user) {
if (user == null
|| user.getNewapiAccessToken() == null || user.getNewapiAccessToken().isEmpty()
|| user.getNewapiUserId() == null) {
return null;
}
try {
Integer newApiUserId = Integer.parseInt(user.getNewapiUserId().toString());
return newApiUserApi.getQuotaYuan(user.getNewapiAccessToken(), newApiUserId);
} catch (NumberFormatException e) {
log.warn("[充值协议PDF] newapiUserId 不是合法数字: {}", user.getNewapiUserId());
return null;
}
}
/** 读取 classpath 下的 HTML 协议模板 */ /** 读取 classpath 下的 HTML 协议模板 */
private String loadTemplate() { private String loadTemplate() {
ClassPathResource resource = new ClassPathResource("templates/recharge-agreement.html"); ClassPathResource resource = new ClassPathResource("templates/recharge-agreement.html");
......
...@@ -163,7 +163,9 @@ ...@@ -163,7 +163,9 @@
<p>12.5 甲方确认:在点击"同意"前,已仔细阅读并充分理解本协议全部条款,特别是涉及甲方权利义务限制、责任免除及争议解决的条款,乙方已对相关条款进行了充分提示和说明。</p> <p>12.5 甲方确认:在点击"同意"前,已仔细阅读并充分理解本协议全部条款,特别是涉及甲方权利义务限制、责任免除及争议解决的条款,乙方已对相关条款进行了充分提示和说明。</p>
<div class="signature"> <div class="signature">
<p>甲方(客户)确认:我已仔细阅读并完全同意本协议全部条款,自愿充值并使用平台服务。</p> <p>甲方(客户)确认:</p>
<p>本次充值金額${rechargeAmount}元,余额合计${totalBalance}元</p>
<p>我已仔细阅读并完全同意本协议全部条款,自愿充值并使用平台服务。</p>
<p>乙方(平台运营方):面向科创园区的普惠算力公共服务平台</p> <p>乙方(平台运营方):面向科创园区的普惠算力公共服务平台</p>
<p>协议生效日期:${agreementDate}</p> <p>协议生效日期:${agreementDate}</p>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment