- 在 AiAccountService 中新增 TokenStatisticsInput DTO 与 POST /account/token-statistics 接口(GetTokenStatisticsAsync),用于按模型统计指定日期的 token 使用量、调用次数并计算成本,返回文本摘要。 - 注入 MessageAggregateRoot 仓储(_messageRepository),使用 SqlSugar 聚合查询(Sum/Count),按 modelId 与日期范围过滤,并只统计 role == "system" 的记录。 - 成本计算逻辑:根据输入的模型 1 亿 token 成本与实际 token 数计算每 1 亿 token 成本;同时输出调用次数与 token(单位万)。 - 接口权限与入参校验:仅允许 CurrentUser.UserName 为 "Guo" 或 "cc" 访问;必须提供 ModelCosts 配置。 - 添加的引用:SqlSugar、System.Globalization、System.Text、Yi.Framework.AiHub.Domain.Entities.Chat。
243 lines
8.5 KiB
C#
243 lines
8.5 KiB
C#
using Mapster;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using SqlSugar;
|
||
using System.Globalization;
|
||
using System.Text;
|
||
using Volo.Abp.Application.Services;
|
||
using Volo.Abp.Users;
|
||
using Yi.Framework.AiHub.Application.Contracts.Dtos;
|
||
using Yi.Framework.AiHub.Domain.Entities;
|
||
using Yi.Framework.AiHub.Domain.Entities.Chat;
|
||
using Yi.Framework.Rbac.Application.Contracts.IServices;
|
||
using Yi.Framework.Rbac.Domain.Shared.Dtos;
|
||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||
using Yi.Framework.AiHub.Domain.Extensions;
|
||
|
||
namespace Yi.Framework.AiHub.Application.Services;
|
||
|
||
public class AiAccountService : ApplicationService
|
||
{
|
||
private IAccountService _accountService;
|
||
private ISqlSugarRepository<AiUserExtraInfoEntity> _userRepository;
|
||
private ISqlSugarRepository<AiRechargeAggregateRoot> _rechargeRepository;
|
||
private ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
|
||
private ISqlSugarRepository<MessageAggregateRoot> _messageRepository;
|
||
public AiAccountService(
|
||
IAccountService accountService,
|
||
ISqlSugarRepository<AiUserExtraInfoEntity> userRepository,
|
||
ISqlSugarRepository<AiRechargeAggregateRoot> rechargeRepository,
|
||
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository, ISqlSugarRepository<MessageAggregateRoot> messageRepository)
|
||
{
|
||
_accountService = accountService;
|
||
_userRepository = userRepository;
|
||
_rechargeRepository = rechargeRepository;
|
||
_premiumPackageRepository = premiumPackageRepository;
|
||
_messageRepository = messageRepository;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取ai用户信息
|
||
/// </summary>
|
||
/// <returns></returns>
|
||
[Authorize]
|
||
[HttpGet("account/ai")]
|
||
public async Task<AiUserRoleMenuDto> GetAsync()
|
||
{
|
||
var userId = CurrentUser.GetId();
|
||
var userAccount = await _accountService.GetAsync(null, null, userId: CurrentUser.GetId());
|
||
var output = userAccount.Adapt<AiUserRoleMenuDto>();
|
||
|
||
// 是否绑定服务号
|
||
output.IsBindFuwuhao = await _userRepository.IsAnyAsync(x => userId == x.UserId);
|
||
|
||
// 是否为VIP用户
|
||
output.IsVip = CurrentUser.IsAiVip();
|
||
|
||
// 获取VIP到期时间
|
||
if (output.IsVip)
|
||
{
|
||
var recharges = await _rechargeRepository._DbQueryable
|
||
.Where(x => x.UserId == userId)
|
||
.ToListAsync();
|
||
|
||
if (recharges.Any())
|
||
{
|
||
// 如果有任何一个充值记录的过期时间为null,说明是永久VIP
|
||
if (recharges.Any(x => !x.ExpireDateTime.HasValue))
|
||
{
|
||
output.VipExpireTime = null; // 永久VIP
|
||
}
|
||
else
|
||
{
|
||
// 取最大的过期时间
|
||
output.VipExpireTime = recharges
|
||
.Where(x => x.ExpireDateTime.HasValue)
|
||
.Max(x => x.ExpireDateTime);
|
||
}
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取利润统计数据
|
||
/// </summary>
|
||
/// <param name="currentCost">当前成本(RMB)</param>
|
||
/// <returns></returns>
|
||
[Authorize]
|
||
[HttpGet("account/profit-statistics")]
|
||
public async Task<string> GetProfitStatisticsAsync([FromQuery] decimal currentCost)
|
||
{
|
||
if (CurrentUser.UserName != "Guo" && CurrentUser.UserName != "cc")
|
||
{
|
||
throw new UserFriendlyException("您暂无权限访问");
|
||
}
|
||
|
||
// 1. 获取尊享包总消耗和剩余库存
|
||
var premiumPackages = await _premiumPackageRepository._DbQueryable.ToListAsync();
|
||
long totalUsedTokens = premiumPackages.Sum(p => p.UsedTokens);
|
||
long totalRemainingTokens = premiumPackages.Sum(p => p.RemainingTokens);
|
||
|
||
// 2. 计算1亿Token成本
|
||
decimal costPerHundredMillion = totalUsedTokens > 0
|
||
? currentCost / (totalUsedTokens / 100000000m)
|
||
: 0;
|
||
|
||
// 3. 计算总成本(剩余+已使用的总成本)
|
||
long totalTokens = totalUsedTokens + totalRemainingTokens;
|
||
decimal totalCost = totalTokens > 0
|
||
? (totalTokens / 100000000m) * costPerHundredMillion
|
||
: 0;
|
||
|
||
// 4. 获取总收益(RechargeType=PremiumPackage的充值金额总和)
|
||
decimal totalRevenue = await _rechargeRepository._DbQueryable
|
||
.Where(x => x.RechargeType == Domain.Shared.Enums.RechargeTypeEnum.PremiumPackage)
|
||
.SumAsync(x => x.RechargeAmount);
|
||
|
||
// 5. 计算利润率
|
||
decimal profitRate = totalCost > 0
|
||
? (totalRevenue / totalCost - 1) * 100
|
||
: 0;
|
||
|
||
// 6. 按200售价计算成本
|
||
decimal costAt200Price = totalRevenue > 0
|
||
? (totalCost / totalRevenue) * 200
|
||
: 0;
|
||
|
||
// 7. 格式化输出
|
||
var today = DateTime.Now;
|
||
string dayOfWeek = today.ToString("dddd", new System.Globalization.CultureInfo("zh-CN"));
|
||
string weekDay = dayOfWeek switch
|
||
{
|
||
"星期一" => "周1",
|
||
"星期二" => "周2",
|
||
"星期三" => "周3",
|
||
"星期四" => "周4",
|
||
"星期五" => "周5",
|
||
"星期六" => "周6",
|
||
"星期日" => "周日",
|
||
_ => dayOfWeek
|
||
};
|
||
|
||
var result = $@"{today:M月d日} {weekDay}
|
||
尊享包已消耗({totalUsedTokens / 100000000m:F2}亿){totalUsedTokens}
|
||
尊享包剩余库存({totalRemainingTokens / 100000000m:F2}亿){totalRemainingTokens}
|
||
当前成本:{currentCost:F2}RMB
|
||
1亿Token成本:{costPerHundredMillion:F2} RMB=1亿 Token
|
||
总成本:{totalCost:F2} RMB
|
||
总收益:{totalRevenue:F2}RMB
|
||
利润率: {profitRate:F1}%
|
||
按200售价来算,成本在{costAt200Price:F2}";
|
||
|
||
return result;
|
||
}
|
||
|
||
public class TokenStatisticsInput
|
||
{
|
||
/// <summary>
|
||
/// 指定日期(当天零点)
|
||
/// </summary>
|
||
public DateTime Date { get; set; }
|
||
|
||
/// <summary>
|
||
/// 模型Id -> 1亿Token成本(RMB)
|
||
/// </summary>
|
||
public Dictionary<string, decimal> ModelCosts { get; set; } = new();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取指定日期各模型Token统计
|
||
/// </summary>
|
||
[Authorize]
|
||
[HttpPost("account/token-statistics")]
|
||
public async Task<string> GetTokenStatisticsAsync([FromBody] TokenStatisticsInput input)
|
||
{
|
||
if (CurrentUser.UserName != "Guo" && CurrentUser.UserName != "cc")
|
||
{
|
||
throw new UserFriendlyException("您暂无权限访问");
|
||
}
|
||
|
||
if (input.ModelCosts is null || input.ModelCosts.Count == 0)
|
||
{
|
||
throw new UserFriendlyException("请提供模型成本配置");
|
||
}
|
||
|
||
var day = input.Date.Date;
|
||
var nextDay = day.AddDays(1);
|
||
var modelIds = input.ModelCosts.Keys.ToList();
|
||
|
||
var modelStats = await _messageRepository._DbQueryable
|
||
.Where(x => modelIds.Contains(x.ModelId))
|
||
.Where(x => x.CreationTime >= day && x.CreationTime < nextDay)
|
||
.Where(x => x.Role == "system")
|
||
.GroupBy(x => x.ModelId)
|
||
.Select(x => new
|
||
{
|
||
ModelId = x.ModelId,
|
||
Tokens = SqlFunc.AggregateSum(x.TokenUsage.TotalTokenCount),
|
||
Count = SqlFunc.AggregateCount(x.Id)
|
||
})
|
||
.ToListAsync();
|
||
|
||
var modelStatDict = modelStats.ToDictionary(x => x.ModelId, x => x);
|
||
|
||
string weekDay = day.ToString("dddd", new CultureInfo("zh-CN")) switch
|
||
{
|
||
"星期一" => "周1",
|
||
"星期二" => "周2",
|
||
"星期三" => "周3",
|
||
"星期四" => "周4",
|
||
"星期五" => "周5",
|
||
"星期六" => "周6",
|
||
"星期日" => "周日",
|
||
_ => day.ToString("dddd", new CultureInfo("zh-CN"))
|
||
};
|
||
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine($"{day:M月d日} {weekDay}");
|
||
|
||
foreach (var kvp in input.ModelCosts)
|
||
{
|
||
var modelId = kvp.Key;
|
||
var cost = kvp.Value;
|
||
|
||
modelStatDict.TryGetValue(modelId, out var stat);
|
||
long tokens = stat?.Tokens ?? 0;
|
||
long count = stat?.Count ?? 0;
|
||
|
||
decimal costPerHundredMillion = tokens > 0
|
||
? cost / (tokens / 100000000m)
|
||
: 0;
|
||
|
||
decimal tokensInWan = tokens / 10000m;
|
||
|
||
sb.AppendLine();
|
||
sb.AppendLine($"{modelId} 成本:【{cost:F2}RMB】 次数:【{count}次】 token:【{tokensInWan:F0}w】 1亿token成本:【{costPerHundredMillion:F2}RMB】");
|
||
}
|
||
|
||
return sb.ToString().TrimEnd();
|
||
}
|
||
}
|