Compare commits

..

141 Commits

Author SHA1 Message Date
Gsh
2e4f520dac fix: 更改版本号 2025-11-02 01:27:03 +08:00
ccnetcore
067b25b9af Merge remote-tracking branch 'origin/invitation' into invitation 2025-11-02 01:21:33 +08:00
ccnetcore
36370c215d fix: 修复周邀请次数统计时使用错误的用户ID字段 2025-11-02 01:21:28 +08:00
Gsh
e24731acfe fix: 分享词修改 2025-11-02 01:17:22 +08:00
Gsh
927e9df7de fix: 分享地址固定为海外地址 2025-11-02 00:55:47 +08:00
Gsh
114b41144e fix: 增加邀请链接逻辑 2025-11-02 00:51:14 +08:00
ccnetcore
5019a36138 fix: 优化邀请码不足提示文案 2025-11-02 00:32:04 +08:00
Gsh
e15eb6149b fix: 翻牌样式优化,动画效果完善 2025-11-01 18:48:17 +08:00
Gsh
9d401a9c93 fix: 翻牌样式优化,动画效果完善 2025-11-01 17:56:19 +08:00
Gsh
eacf86e118 fix: 翻牌样式优化,动画效果待完善 2025-10-31 00:41:21 +08:00
chenchun
c4b631c815 feat: 新增翻牌幸运值悬浮球及相关逻辑
- .claude/settings.local.json:新增 Read 权限路径(Read(//e/code/github/Yi/Yi.Ai.Vue3/**))
- Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue:
  - 新增 luckyValue 响应式状态与 updateLuckyValue() 方法,并在获取任务状态后更新幸运值
  - 新增悬浮球 UI(SVG 进度环、图标、百分比文本)及样式和动画
  - 调整了 v-loading 为 false,并注释了部分错误提示(可能为调试遗留)
- 说明:样式使用嵌套写法(scss/sass 风格),请确认构建流程支持;建议确认 v-loading 与错误提示变更是否为预期并视情况修正。
2025-10-30 21:16:19 +08:00
chenchun
fb25e75a3a feat: 完成邀请机制 2025-10-30 20:17:14 +08:00
chenchun
e9099bbe04 feat: 增加基于本周填写邀请码数量的邀请翻牌校验
- 注入 ISqlSugarRepository<InvitationRecordAggregateRoot> 到 CardFlipManager 并更新构造函数。
- 在邀请类型(FlipType.Invite)翻牌时,改为校验用户本周已填写的邀请码数量是否满足本次翻牌所需(根据 InviteFlipsUsed 计算所需数量),不足则抛出友好异常提示。
- 保持原有错误处理与日志逻辑不变。
2025-10-30 20:13:49 +08:00
chenchun
f02fb91175 feat: 增加邀请码每周使用上限并调整翻牌规则(扩展免费次数、移除赠送翻牌与翻倍提示) 2025-10-30 19:51:56 +08:00
chenchun
efd917d184 style: 全部样式更新2.0 2025-10-30 11:21:11 +08:00
chenchun
e906208f4a feat: 新增邀请翻牌验证及相关文案与界面调整
- CardFlipManager:注入 InviteCodeManager,新增对 Invite 类型翻牌的邀请校验(未使用邀请码则抛出异常),防止未被邀请的用户使用邀请类型翻牌。
- CardFlipService:调整提示文案,统一使用“本周”前缀,并在邀请解锁提示中强调必定中奖且每次中奖最大额度翻倍。
- 前端:
  - CardFlipActivity.vue:注释掉翻牌失败的全局提示,调整统计文案为“本周已翻/本周剩余/本周邀请”,并在邀请弹窗文案中说明必定中奖且奖励翻倍。
  - Avatar.vue:更新菜单项标签为“每日任务(限时)”和“每周邀请(限时)”。
2025-10-30 11:19:22 +08:00
chenchun
e6b991fe86 feat: 调整翻牌与邀请码逻辑,增加第8次奖励及前端骨架屏 2025-10-29 21:55:17 +08:00
Gsh
dd3f6325bb fix: 个人中心优化 2025-10-29 00:17:36 +08:00
chenchun
c6425ca206 fix: 优化对话异常提示信息
将抛出异常的消息从 "OpenAI对话异常{StatusCode}" 修改为更详细的中文提示,包含 StatusCode 与 Response 内容,便于排查。未改变逻辑,仅调整异常文本。
2025-10-28 16:02:01 +08:00
chenchun
acb359ec33 style: 删除多余的 SqlSugar InitTables 注释并调整注释格式
在 Yi.Abp.Web/YiAbpWebModule.cs 中移除两行多余的注释,调整剩余注释的空格格式,清理代码注释,不影响程序逻辑。
2025-10-27 22:01:57 +08:00
chenchun
a1395d9a33 feat: 新增翻牌顺序追踪并重构翻牌/邀请码逻辑到 Manager,更新前端
- 在 CardFlipStatusOutput 与前端 types 添加 FlipOrderIndex 字段以记录牌在翻牌顺序中的位置
- 在域实体 CardFlipTaskAggregateRoot 增加 FlippedOrder(Json 列)以保存用户实际翻牌顺序
- 将 CardFlipService 重构为调用 CardFlipManager 与 InviteCodeManager,移除大量内聚的业务实现与常量(职责下沉到 Manager)
- 调整翻牌、使用邀请码和查询相关流程为 Manager 驱动,更新返回结构与提示文本
- 更新前端 CardFlipActivity 组件与 types,允许任意未翻的卡片被点击并显示翻牌顺序位置
- 若干文案、格式与日志细节修正
2025-10-27 21:57:26 +08:00
chenchun
aec90ec9d6 feat: 新增翻牌活动入口与全局组件声明
- 在 Header Avatar 菜单新增翻牌活动(cardFlip)入口,并添加对应插槽 <card-flip-activity/>
- 在 types/components.d.ts 中添加 CardFlipActivity 与 ElCollapseTransition 类型声明
- 在 .eslintrc-auto-import.json 中新增 ElMessage 与 ElMessageBox 自动导入
- 从 import_meta.d.ts 中移除 VITE_BUILD_COMPRESS 环境声明
- 在 YiAbpWebModule.cs 中添加相关 using 并保留数据库建表初始化的注释(CodeFirst.InitTables)
2025-10-23 21:58:47 +08:00
chenchun
1aaff2942d fix: 调整 Anthropic DTO 属性为可空类型以避免反序列化错误 2025-10-21 16:55:05 +08:00
chenchun
cdbfc5383d feat: 为充值记录新增订单类型字段并区分VIP与套餐逻辑 2025-10-20 10:18:24 +08:00
ccnetcore
f302555e0c feat: 完善描述 2025-10-18 17:40:46 +08:00
ccnetcore
86c5890476 feat: 用户中心新增每日任务组件并在头像菜单中集成 2025-10-18 17:34:46 +08:00
ccnetcore
a13ee395c7 feat: 支持 x-api-key 认证并扩展 Anthropic 响应字段,优化工具调用处理 2025-10-18 13:23:54 +08:00
Gsh
9abcd72aca fix: 增加教程导航 2025-10-16 22:50:10 +08:00
ccnetcore
4ddea6d468 Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-10-16 22:12:36 +08:00
ccnetcore
867a2dc861 fix: 修正Claude聊天响应的Token统计逻辑并优化AiGateWayManager使用条件,同时移除前端无用环境变量定义 2025-10-16 22:11:09 +08:00
chenchun
4a72e3fa0d Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-10-16 09:35:56 +08:00
chenchun
8b4371aabb feat: 尊享包购买流程新增充值记录保存功能 2025-10-16 09:35:25 +08:00
Gsh
799dd08ec0 feat: 模型提示词、剩余额度、对话状态优化 2025-10-16 01:20:11 +08:00
Gsh
c5c22224cf feat: 2.0发布 2025-10-15 23:44:18 +08:00
ccnetcore
2dae47e85c feat: 修复价格 2025-10-15 23:18:26 +08:00
ccnetcore
375dd4f797 fix: 修复支付3位数问题 2025-10-15 23:04:09 +08:00
ccnetcore
acb2db8397 fix: 商品类型返回值 2025-10-15 23:00:42 +08:00
ccnetcore
b7a3e76d0b Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-10-15 19:51:28 +08:00
ccnetcore
48150b712a refactor: 会话ID为空时不存储消息内容,并移除无用注释 2025-10-15 19:49:33 +08:00
chenchun
6db9dfc308 Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-10-15 11:54:06 +08:00
chenchun
2d6c1f3c46 fix: 验证交易状态仅在成功时执行充值逻辑 2025-10-15 11:53:54 +08:00
Gsh
161e10d2d1 feat: 产品样式调整 2025-10-15 00:16:57 +08:00
Gsh
a9a2a91183 feat: 产品样式调整 2025-10-15 00:05:10 +08:00
Gsh
1c9a6f108e feat: 产品样式调整 2025-10-15 00:05:10 +08:00
ccnetcore
d6adf9b736 feat: 增加 Claude 模型 Token 使用量倍数调整功能 2025-10-14 23:41:26 +08:00
ccnetcore
959eb3f782 fix: 优化服务号与支付逻辑,增加AccessToken为空校验及优惠描述完善 2025-10-14 23:02:44 +08:00
ccnetcore
7a53e0c90c refactor: 简化尊享包Token扣减逻辑,移除多包分配与校验流程 2025-10-14 22:34:05 +08:00
ccnetcore
533b87fc5b fix: 修复统计近7天token消耗时角色过滤条件错误 2025-10-14 22:22:35 +08:00
ccnetcore
15713cf7fe feat: 支持Claude模型API类型及尊享包校验与扣减逻辑 2025-10-14 22:17:21 +08:00
Gsh
31dc756868 feat: 尊享模型效果 2025-10-14 21:29:20 +08:00
ccnetcore
52f6b6130f feat: 为 HttpClient 添加默认 User-Agent 请求头 2025-10-13 23:08:15 +08:00
ccnetcore
16945b3d5b fix: 修复剩余令牌统计逻辑,增加过期时间判断 2025-10-13 22:09:47 +08:00
Gsh
bdc664fc44 feat: 增加尊享token包产品 2025-10-13 01:14:40 +08:00
Gsh
9555ef10e0 feat: 增加尊享token包产品 2025-10-13 01:03:41 +08:00
Gsh
49e6cb26fc feat: 增加尊享产品 2025-10-12 23:00:08 +08:00
ccnetcore
3ace29e692 fix: 修复无会话时仍存储消息内容的问题 2025-10-12 22:46:20 +08:00
ccnetcore
aa9dd0129b refactor: 将尊享包Token统计逻辑从AiAccountService迁移至UsageStatisticsService,并移除AiUserRoleMenuDto相关字段 2025-10-12 21:51:51 +08:00
ccnetcore
1464271fbd Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-10-12 21:12:37 +08:00
ccnetcore
754f145559 fix: 允许尊享包扣减到负数并优化Token统计逻辑 2025-10-12 21:12:21 +08:00
Gsh
6afd0cb955 feat: 个人中心优化 2025-10-12 21:08:23 +08:00
ccnetcore
d32906702a feat: 商品枚举与支付服务优化,支持中文名称、参考价格及类别筛选 2025-10-12 21:04:08 +08:00
ccnetcore
9bcdaf6bd8 fix: 更新尊享包折扣规则为每10元减2.5元,最多减50元,并同步修改提示文案 2025-10-12 20:14:07 +08:00
ccnetcore
db82a8cf08 Merge branch 'premium' into ai-hub 2025-10-12 20:10:23 +08:00
ccnetcore
a9e8b2b01f feat: 增加尊享包商品及折扣逻辑,完善VIP与尊享包相关接口和数据返回
- 新增尊享包商品类型,支持 5000W 和 10000W Tokens
- 增加尊享包折扣计算与折扣后价格获取方法
- PayService 新增获取商品列表接口,支持尊享包折扣展示
- PayManager 支持尊享包订单金额按折扣计算,并新增获取用户累计充值金额方法
- OpenApiService Anthropic接口增加VIP与尊享包用量校验
- AiGateWayManager 增加尊享包Token扣减逻辑
- AiAccountService 返回用户VIP状态、到期时间及尊享包Token统计信息
2025-10-12 20:07:58 +08:00
Gsh
85bd1ce8d6 feat: 个人中心新增尊享服务、模型列表区分 2025-10-12 18:30:34 +08:00
ccnetcore
4d09243efd feat: 完成尊享服务 2025-10-12 16:42:26 +08:00
ccnetcore
5934056fe6 fix: 修复Anthropic接口TokenUsage序列化及HttpClient创建方式问题 2025-10-12 14:38:26 +08:00
chenchun
2a81062fa3 perf: 优化 HttpClient 配置,增加 10 分钟超时设置 2025-10-12 00:02:34 +08:00
chenchun
fdc868323f fix: 修正 TokenUsage 计算逻辑,使用 CacheReadInputTokens 替代重复的 CacheCreationInputTokens 2025-10-11 23:36:26 +08:00
chenchun
593b3a4cdd fix: 修正消息与Anthropic返回的Token统计逻辑,避免零值覆盖并支持缓存Token计算 2025-10-11 23:27:46 +08:00
chenchun
2b12e18e6c fix: 为AnthropicHandles添加MaxTokens有效性校验 2025-10-11 20:32:41 +08:00
chenchun
345ed80ec8 feat: 新增claude接口转换支持 2025-10-11 15:25:43 +08:00
chenchun
29dc1ae250 fix: 限制 Azure OpenAI 请求最大 tokens 并优化响应处理空行格式 2025-10-10 15:16:16 +08:00
ccnetcore
9fdd41b134 fix: 更新会员套餐为8个月并调整价格信息 2025-10-08 21:24:29 +08:00
ccnetcore
31ee5e8ffb feat: 新增 YiXinVip 8 个月商品类型并移除 9 个月配置 2025-10-08 21:18:42 +08:00
ccnetcore
d7922bb71d fix: 修复 tokenUsage 为空时的空引用问题 2025-09-27 17:40:31 +08:00
chenchun
fa3ac91ba4 fix: 修正文件整理大师Vip数量限制提示错误 2025-09-18 16:17:10 +08:00
ccnetcore
e7c152e955 fix: 修复文件整理大师文件数量限制及模型名称错误 2025-09-13 21:19:54 +08:00
ccnetcore
0223b5c104 fix: 更新客服联系方式和产品价格信息
- 统一修改客服支持提示信息为"备注ai获取专属客服支持"
- 更新会员套餐价格和描述信息
- 替换模型排行榜iframe为openrouter链接
- 调整内容截断长度从2000到10000字符
2025-09-07 18:49:58 +08:00
ccnetcore
9e41a7c446 fix: 调整YiXinVip商品价格及有效期配置 2025-09-07 18:29:41 +08:00
ccnetcore
2ba0ffc1b7 Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-09-07 17:16:13 +08:00
ccnetcore
7d038e1266 style: 优化样式1.3 2025-09-07 17:16:07 +08:00
Gsh
b98285f314 fix: 处理二维码接口过多调用问题 2025-09-07 13:30:03 +08:00
ccnetcore
73438da666 fix: 将 VerifyNext 接口由 GET 改为 POST 请求 2025-09-07 01:34:52 +08:00
ccnetcore
85f2e1b579 feat: 新增文件整理大师服务及校验与对话接口 2025-09-07 01:34:25 +08:00
ccnetcore
ece89ebad0 refactor: 移除自定义 HttpClientFactory,改用依赖注入的 IHttpClientFactory 2025-09-04 22:26:06 +08:00
Gsh
6e2dd39246 fix: 未登录支付按钮改登录 2025-09-03 11:43:40 +08:00
Gsh
a61286e534 fix: 非会员模型选择跳转修改 2025-09-03 10:56:44 +08:00
Gsh
4f944a5466 fix: 优化二维码加载错误显示 2025-08-31 23:56:42 +08:00
ccnetcore
d29aac088a feat: 全面优化ai-hub前端细节 2025-08-31 01:37:36 +08:00
Gsh
8abd122773 feat: 重新登录逻辑更改 2025-08-30 23:58:57 +08:00
Gsh
08084aa0bc feat: 默认展示二维码登录 2025-08-30 23:13:46 +08:00
Gsh
e69cd5a73c feat: 用户头像路径固定 2025-08-30 23:05:14 +08:00
Gsh
76aa3bdc64 feat: 用户头像路径固定 2025-08-30 22:39:27 +08:00
Gsh
93251104af Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-08-30 22:29:21 +08:00
Gsh
3cae477f3e feat: 增加扫码登录功能 2025-08-30 22:28:38 +08:00
ccnetcore
25c736dc0a fix: 修复扫码回调在非等待状态下仍被处理的问题 2025-08-30 22:07:09 +08:00
ccnetcore
96a09d8980 fix: 修复绑定用户时返回值未包含用户ID的问题 2025-08-30 21:58:43 +08:00
ccnetcore
72387235a0 refactor: 移除分布式锁获取时的超时参数 2025-08-30 21:17:25 +08:00
ccnetcore
1b00e505b7 fix: 修复绑定微信二维码生成时未登录用户的异常提示 2025-08-30 18:02:46 +08:00
ccnetcore
1c54e47b9e feat: 新增AI账户服务及扩展用户信息获取功能,支持通过userId查询用户信息 2025-08-30 17:55:13 +08:00
ccnetcore
ba07e2c905 feat: 新增服务号注册授权页面并优化注册提示信息 2025-08-30 00:02:27 +08:00
ccnetcore
bde4611a50 fix: 修复服务号注册缓存时间及锁定范围问题 2025-08-29 23:32:40 +08:00
ccnetcore
e7326fea7b Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-08-29 22:21:11 +08:00
ccnetcore
d13b23ad2e fix: 修复服务号扫码场景缓存未保存用户ID且场景结果错误的问题 2025-08-29 22:20:52 +08:00
Gsh
8b1830a711 Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2025-08-29 22:02:14 +08:00
Gsh
b70c530754 fix: 增加扫码 2025-08-29 22:01:44 +08:00
ccnetcore
d90e24f9ed fix: 修复服务号回调处理返回用户ID及缓存更新逻辑 2025-08-29 21:55:07 +08:00
chenchun
1fbd521d1a fix: 注册接口加分布式锁防止并发重复注册 2025-08-29 14:33:35 +08:00
chenchun
2ae6183e7f fix: 防止重复注册意社区账号 2025-08-29 14:31:04 +08:00
chenchun
7905911624 feat: 注册用户时支持传入头像参数 2025-08-29 14:11:50 +08:00
chenchun
c5b6b33d8e feat: 注册公众号用户时保存额外用户信息到数据库 2025-08-29 13:54:02 +08:00
chenchun
5d29fd6d3b feat: 为微信服务号用户信息响应模型添加JsonPropertyName映射并移除无用字段 2025-08-29 13:41:50 +08:00
chenchun
ad8f48f36b feat: 服务号用户信息获取增加日志记录 2025-08-29 11:53:36 +08:00
chenchun
f9843c13d4 fix: 修复场景缓存为空时的处理逻辑并调整注册成功缓存写入方式 2025-08-29 11:34:57 +08:00
chenchun
6bd561b094 feat: 新增微信公众号扫码注册功能及幂等处理
- 新增 `FuwuhaoConst` 常量类,统一缓存 Key 前缀管理
- `FuwuhaoOptions` 增加 FromUser、RedirectUri、PicUrl 配置项
- `FuwuhaoManager` 新增 `BuildRegisterMessage` 方法,构建注册引导图文消息
- `FuwuhaoService`
  - 增加 OpenId 与 Scene 绑定缓存,支持扫码注册有效期管理
  - 回调处理支持注册场景,返回图文消息引导用户注册
  - 新增注册接口 `RegisterByCodeAsync`,根据微信授权信息自动注册账号并更新场景状态
- `AccountManager` 注册方法增加分布式锁,防止重复注册,并校验用户名唯一性
2025-08-29 11:01:09 +08:00
chenchun
d2c6238df1 feat: 启用AI股票生成与新闻生成任务并切换至OpenAI接口配置 2025-08-28 15:40:59 +08:00
chenchun
1d108983e8 feat: 增加服务号回调签名校验及扫码回调幂等处理
- `FuwuhaoManager` 新增 `ValidateCallback` 方法,用于校验微信回调签名
- `FuwuhaoOptions` 增加 `CallbackToken` 配置项
- `QrCodeResponse` 属性添加 `JsonPropertyName` 标注,支持 JSON 序列化映射
- `FuwuhaoService` 在回调接口中增加签名校验,并通过分布式锁实现幂等处理
- 调整场景值解析逻辑,过滤非扫码/关注事件
- 优化缓存过期时间设置
2025-08-28 15:20:15 +08:00
ccnetcore
b768bca638 feat: 完成支持微信扫码功能 2025-08-27 23:42:46 +08:00
chenchun
28fcd6c9ce feat: 新增服务号回调处理服务及数据模型 2025-08-27 17:46:39 +08:00
ccnetcore
10559a925c style: 优化样式1.2 2025-08-25 00:39:16 +08:00
ccnetcore
942e218a9e feat: 新增 FileMaster 发送消息接口 2025-08-23 13:15:28 +08:00
ccnetcore
f6af9edc38 feat: 删除文件 2025-08-23 12:23:17 +08:00
ccnetcore
e0f6331ec3 feat:构建 2025-08-23 12:21:16 +08:00
ccnetcore
06f0c6caa7 style: 整体调整 2025-08-23 12:14:09 +08:00
ccnetcore
56ec260e3a perf: 优化已完成状态下的输出等待时间,加快响应速度 2025-08-21 21:50:06 +08:00
ccnetcore
176cf84369 fix: 修复 AiGateWayManager 中可能的空引用异常 2025-08-21 01:16:57 +08:00
Gsh
6de3b722ed fix: 新增文件助手目录 2025-08-18 23:07:34 +08:00
Gsh
2cf6326764 fix: 更新组件库 2025-08-18 21:02:55 +08:00
Gsh
ec27ee58b4 fix: 充值成功与记录页面增加联系客服,apikey教程更改 2025-08-17 22:08:03 +08:00
ccnetcore
4e42e2202e refactor: 移除代码补全兼容逻辑并优化 DTO 可空类型处理 2025-08-16 19:20:58 +08:00
ccnetcore
e60d8eceb7 feat: 新增 YiXinVip 商品价格配置及前端构建压缩环境变量定义 2025-08-16 00:08:03 +08:00
Gsh
08fb939b38 fix: 动态支付响应页面 2025-08-15 23:49:18 +08:00
ccnetcore
482dd73afd feat: 支持创建订单时自定义支付宝回调地址 2025-08-15 23:41:01 +08:00
Gsh
f09a9fee75 fix: 产品相关页面样式优化 2025-08-15 23:28:09 +08:00
chenchun
9a31d14b41 feat: 优化CORS配置支持通配符和代码格式化
- 支持CORS配置使用通配符"*"允许所有来源访问
- 优化CORS策略配置逻辑,区分通配符和具体域名处理
- 格式化代码,移除多余空行和统一代码风格
- 修复Hangfire配置中的变量赋值格式
- 更新默认CORS配置为通配符模式
2025-08-14 17:04:27 +08:00
chenchun
2fd7f88f04 feat: 新增海外站点流量限制和CORS配置优化
- 新增yxai.chat域名到CORS白名单
- 为海外站点yxai.chat添加大流量接口访问限制
- 修复Azure OpenAI图像生成服务默认尺寸设置
2025-08-14 15:14:30 +08:00
chenchun
9d4cc802e9 fix: 添加CodeGeeX跨域支持
在CORS配置中新增http://codegeex域名,支持CodeGeeX工具的跨域访问请求
2025-08-14 14:24:03 +08:00
Gsh
ee6b4827fa feat: 增加支付宝在线支付、套餐订购弹窗、会员权益、支持模型展示等 2025-08-14 00:26:39 +08:00
ccnetcore
48d8c528f6 fix: 调整YiXinVip各档价格为0.01方便测试 2025-08-13 22:21:03 +08:00
ccnetcore
40c0a5ac64 feat: 支付完成后自动为用户充值VIP并支持按商品类型计算有效期 2025-08-13 22:19:31 +08:00
156 changed files with 15877 additions and 668 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:www.donet5.com)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(dotnet build \"E:\\code\\github\\Yi\\Yi.Abp.Net8\\module\\ai-hub\\Yi.Framework.AiHub.Application\\Yi.Framework.AiHub.Application.csproj\" --no-restore)",
"Read(//e/code/github/Yi/Yi.Ai.Vue3/**)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,21 @@
using Yi.Framework.Rbac.Domain.Shared.Dtos;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
public class AiUserRoleMenuDto:UserRoleMenuDto
{
/// <summary>
/// 是否绑定服务号
/// </summary>
public bool IsBindFuwuhao { get; set; }
/// <summary>
/// 是否为VIP用户
/// </summary>
public bool IsVip { get; set; }
/// <summary>
/// VIP到期时间
/// </summary>
public DateTime? VipExpireTime { get; set; }
}

View File

@@ -0,0 +1,93 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
/// <summary>
/// 翻牌任务状态输出
/// </summary>
public class CardFlipStatusOutput
{
/// <summary>
/// 本周总翻牌次数
/// </summary>
public int TotalFlips { get; set; }
/// <summary>
/// 剩余免费次数
/// </summary>
public int RemainingFreeFlips { get; set; }
/// <summary>
/// 剩余赠送次数
/// </summary>
public int RemainingBonusFlips { get; set; }
/// <summary>
/// 剩余邀请解锁次数
/// </summary>
public int RemainingInviteFlips { get; set; }
/// <summary>
/// 是否可以翻牌
/// </summary>
public bool CanFlip { get; set; }
/// <summary>
/// 用户的邀请码
/// </summary>
public string? MyInviteCode { get; set; }
/// <summary>
/// 本周邀请人数
/// </summary>
public int InvitedCount { get; set; }
/// <summary>
/// 是否已被邀请(被邀请后不可再提供邀请码)
/// </summary>
public bool IsInvited { get; set; }
/// <summary>
/// 翻牌记录
/// </summary>
public List<CardFlipRecord> FlipRecords { get; set; } = new();
/// <summary>
/// 下次可翻牌提示
/// </summary>
public string? NextFlipTip { get; set; }
}
/// <summary>
/// 翻牌记录
/// </summary>
public class CardFlipRecord
{
/// <summary>
/// 翻牌序号1-10
/// </summary>
public int FlipNumber { get; set; }
/// <summary>
/// 是否已翻
/// </summary>
public bool IsFlipped { get; set; }
/// <summary>
/// 是否中奖
/// </summary>
public bool IsWin { get; set; }
/// <summary>
/// 奖励金额token数
/// </summary>
public long? RewardAmount { get; set; }
/// <summary>
/// 翻牌类型描述
/// </summary>
public string? FlipTypeDesc { get; set; }
/// <summary>
/// 在翻牌顺序中的位置1-10表示第几个翻
/// </summary>
public int FlipOrderIndex { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
/// <summary>
/// 翻牌输入
/// </summary>
public class FlipCardInput
{
/// <summary>
/// 翻牌序号1-10
/// </summary>
public int FlipNumber { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
/// <summary>
/// 翻牌输出
/// </summary>
public class FlipCardOutput
{
/// <summary>
/// 翻牌序号1-10
/// </summary>
public int FlipNumber { get; set; }
/// <summary>
/// 是否中奖
/// </summary>
public bool IsWin { get; set; }
/// <summary>
/// 奖励金额token数
/// </summary>
public long? RewardAmount { get; set; }
/// <summary>
/// 奖励描述
/// </summary>
public string? RewardDesc { get; set; }
/// <summary>
/// 剩余可翻次数
/// </summary>
public int RemainingFlips { get; set; }
}

View File

@@ -0,0 +1,48 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
/// <summary>
/// 邀请码信息输出
/// </summary>
public class InviteCodeOutput
{
/// <summary>
/// 我的邀请码
/// </summary>
public string? MyInviteCode { get; set; }
/// <summary>
/// 本周邀请人数
/// </summary>
public int InvitedCount { get; set; }
/// <summary>
/// 是否已被邀请
/// </summary>
public bool IsInvited { get; set; }
/// <summary>
/// 邀请历史记录
/// </summary>
public List<InvitationHistoryItem> InvitationHistory { get; set; } = new();
}
/// <summary>
/// 邀请历史记录项
/// </summary>
public class InvitationHistoryItem
{
/// <summary>
/// 被邀请人昵称(脱敏)
/// </summary>
public string InvitedUserName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间
/// </summary>
public DateTime InvitationTime { get; set; }
/// <summary>
/// 本周所在
/// </summary>
public string WeekDescription { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
/// <summary>
/// 使用邀请码输入
/// </summary>
public class UseInviteCodeInput
{
/// <summary>
/// 邀请码
/// </summary>
public string InviteCode { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.DailyTask;
/// <summary>
/// 领取任务奖励输入
/// </summary>
public class ClaimTaskRewardInput
{
/// <summary>
/// 任务等级1=1000w任务2=3000w任务
/// </summary>
public int TaskLevel { get; set; }
}

View File

@@ -0,0 +1,58 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.DailyTask;
/// <summary>
/// 每日任务状态输出
/// </summary>
public class DailyTaskStatusOutput
{
/// <summary>
/// 今日消耗的尊享包Token数
/// </summary>
public long TodayConsumedTokens { get; set; }
/// <summary>
/// 任务列表
/// </summary>
public List<DailyTaskItem> Tasks { get; set; } = new();
}
/// <summary>
/// 每日任务项
/// </summary>
public class DailyTaskItem
{
/// <summary>
/// 任务等级1=1000w任务2=3000w任务
/// </summary>
public int Level { get; set; }
/// <summary>
/// 任务名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 任务描述
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 任务要求的Token消耗量
/// </summary>
public long RequiredTokens { get; set; }
/// <summary>
/// 奖励的Token数量
/// </summary>
public long RewardTokens { get; set; }
/// <summary>
/// 任务状态0=未完成1=可领取2=已领取
/// </summary>
public int Status { get; set; }
/// <summary>
/// 任务进度百分比0-100
/// </summary>
public decimal Progress { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.FileMaster;
public class VerifyNextInput
{
/// <summary>
/// 文件数
/// </summary>
public int FileCount { get; set; }
/// <summary>
/// 文件夹数
/// </summary>
public int DirectoryCount { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao;
public class QrCodeOutput
{
/// <summary>
/// Qrcode url
/// </summary>
public string QrCodeUrl { get; set; }
/// <summary>
/// 场景值
/// </summary>
public string Scene { get; set; }
}

View File

@@ -0,0 +1,21 @@
using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao;
public class QrCodeResultOutput
{
/// <summary>
/// 返回状态
/// </summary>
public SceneResultEnum SceneResult { get; set; } = SceneResultEnum.Wait;
/// <summary>
/// 如果是已登录返回token
/// </summary>
public string? Token { get; set; }
/// <summary>
/// 刷新token
/// </summary>
public string? RefreshToken { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao;
public class SceneCacheDto
{
public SceneResultEnum SceneResult { get; set; } = SceneResultEnum.Wait;
public SceneTypeEnum SceneType { get; set; }
/// <summary>
/// 如果是绑定类型需要用户id
/// </summary>
public Guid? UserId { get; set; }
}

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Pay;
@@ -10,5 +11,8 @@ public class CreateOrderInput
/// <summary>
/// 商品类型
/// </summary>
[Required]
public GoodsTypeEnum GoodsType { get; set; }
public string? ReturnUrl{ get; set; }
}

View File

@@ -0,0 +1,16 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Pay;
/// <summary>
/// 获取商品列表输入DTO
/// </summary>
public class GetGoodsListInput
{
/// <summary>
/// 商品类别(可选)
/// 如果不传,则返回所有商品
/// 如果传了则只返回指定类别的商品VIP服务或尊享包
/// </summary>
public GoodsCategoryType? GoodsCategoryType { get; set; }
}

View File

@@ -0,0 +1,55 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Pay;
/// <summary>
/// 商品列表输出DTO
/// </summary>
public class GoodsListOutput
{
/// <summary>
/// 商品名称
/// </summary>
public string GoodsName { get; set; }
/// <summary>
/// 商品原价
/// </summary>
public decimal OriginalPrice { get; set; }
/// <summary>
/// 商品参考价格
/// </summary>
public decimal ReferencePrice { get; set; }
/// <summary>
/// 商品实际价格(折扣后的价格)
/// </summary>
public decimal GoodsPrice { get; set; }
/// <summary>
/// 折扣金额(仅尊享包)
/// </summary>
public decimal? DiscountAmount { get; set; }
/// <summary>
/// 商品类别
/// </summary>
public string GoodsCategory { get; set; }
/// <summary>
/// 商品备注
/// </summary>
public string Remark { get; set; }
/// <summary>
/// 折扣说明(仅尊享包)
/// </summary>
public string? DiscountDescription { get; set; }
/// <summary>
/// 商品类型
/// </summary>
public GoodsTypeEnum GoodsType { get; set; }
}

View File

@@ -25,9 +25,10 @@ public class RechargeCreateInput
public string Content { get; set; } = string.Empty;
/// <summary>
/// 到期时间为空表示永久VIP
/// VIP月数(为空或0表示永久VIP
/// </summary>
public DateTime? ExpireDateTime { get; set; }
[Range(0, int.MaxValue, ErrorMessage = "月数必须大于等于0")]
public int? Months { get; set; }
/// <summary>
/// 备注

View File

@@ -0,0 +1,22 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
/// <summary>
/// 尊享服务Token用量统计DTO
/// </summary>
public class PremiumTokenUsageDto
{
/// <summary>
/// 总Token数
/// </summary>
public long PremiumTotalTokens { get; set; }
/// <summary>
/// 已使用Token数
/// </summary>
public long PremiumUsedTokens { get; set; }
/// <summary>
/// 剩余Token数
/// </summary>
public long PremiumRemainingTokens { get; set; }
}

View File

@@ -0,0 +1,41 @@
using Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
/// <summary>
/// 翻牌服务接口
/// </summary>
public interface ICardFlipService
{
/// <summary>
/// 获取本周翻牌任务状态
/// </summary>
/// <returns></returns>
Task<CardFlipStatusOutput> GetWeeklyTaskStatusAsync();
/// <summary>
/// 翻牌
/// </summary>
/// <param name="input">翻牌输入</param>
/// <returns></returns>
Task<FlipCardOutput> FlipCardAsync(FlipCardInput input);
/// <summary>
/// 使用邀请码解锁翻牌次数
/// </summary>
/// <param name="input">邀请码输入</param>
/// <returns></returns>
Task UseInviteCodeAsync(UseInviteCodeInput input);
/// <summary>
/// 获取我的邀请码信息
/// </summary>
/// <returns></returns>
Task<InviteCodeOutput> GetMyInviteCodeAsync();
/// <summary>
/// 生成我的邀请码(如果没有)
/// </summary>
/// <returns></returns>
Task<string> GenerateMyInviteCodeAsync();
}

View File

@@ -30,4 +30,11 @@ public interface IPayService : IApplicationService
/// <param name="input">查询订单状态输入</param>
/// <returns>订单状态信息</returns>
Task<QueryOrderStatusOutput> QueryOrderStatusAsync([FromQuery] QueryOrderStatusInput input);
/// <summary>
/// 获取商品列表
/// </summary>
/// <param name="input">获取商品列表输入</param>
/// <returns>商品列表</returns>
Task<List<GoodsListOutput>> GetGoodsListAsync([FromQuery] GetGoodsListInput input);
}

View File

@@ -1,4 +1,6 @@
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Recharge;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
public interface IRechargeService
{
@@ -6,4 +8,11 @@ public interface IRechargeService
/// 移除用户vip及角色
/// </summary>
Task RemoveVipRoleByExpireAsync();
/// <summary>
/// 给用户充值VIP
/// </summary>
/// <param name="input">充值输入参数</param>
/// <returns></returns>
Task RechargeVipAsync(RechargeCreateInput input);
}

View File

@@ -18,4 +18,10 @@ public interface IUsageStatisticsService
/// </summary>
/// <returns>模型Token使用量列表</returns>
Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync();
/// <summary>
/// 获取当前用户尊享服务Token用量统计
/// </summary>
/// <returns>尊享服务Token用量统计</returns>
Task<PremiumTokenUsageDto> GetPremiumTokenUsageAsync();
}

View File

@@ -0,0 +1,75 @@
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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.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;
public AiAccountService(
IAccountService accountService,
ISqlSugarRepository<AiUserExtraInfoEntity> userRepository,
ISqlSugarRepository<AiRechargeAggregateRoot> rechargeRepository)
{
_accountService = accountService;
_userRepository = userRepository;
_rechargeRepository = rechargeRepository;
}
/// <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;
}
}

View File

@@ -16,6 +16,7 @@ using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Enums;
@@ -35,17 +36,19 @@ public class AiChatService : ApplicationService
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly ILogger<AiChatService> _logger;
private readonly AiGateWayManager _aiGateWayManager;
private readonly PremiumPackageManager _premiumPackageManager;
public AiChatService(IHttpContextAccessor httpContextAccessor,
AiBlacklistManager aiBlacklistManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository,
ILogger<AiChatService> logger, AiGateWayManager aiGateWayManager)
ILogger<AiChatService> logger, AiGateWayManager aiGateWayManager, PremiumPackageManager premiumPackageManager)
{
_httpContextAccessor = httpContextAccessor;
_aiBlacklistManager = aiBlacklistManager;
_aiModelRepository = aiModelRepository;
_logger = logger;
_aiGateWayManager = aiGateWayManager;
_premiumPackageManager = premiumPackageManager;
}
@@ -118,8 +121,56 @@ public class AiChatService : ApplicationService
}
}
//如果是尊享包服务,需要校验是是否尊享包足够
if (CurrentUser.IsAuthenticated && PremiumPackageConst.ModeIds.Contains(input.Model))
{
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId());
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
}
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, sessionId, cancellationToken);
}
/// <summary>
/// 发送消息
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
[HttpPost("ai-chat/FileMaster/send")]
public async Task PostFileMasterSendAsync([FromBody] ThorChatCompletionsRequest input,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(input.Model))
{
throw new BusinessException("当前接口不支持第三方使用");
}
if (CurrentUser.IsAuthenticated)
{
await _aiBlacklistManager.VerifiyAiBlacklist(CurrentUser.GetId());
if (CurrentUser.IsAiVip())
{
input.Model = "gpt-5-chat";
}
else
{
input.Model = "gpt-4.1-mini";
}
}
else
{
input.Model = "DeepSeek-R1-0528";
}
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, null, cancellationToken);
}
}

View File

@@ -0,0 +1,286 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.CardFlip;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
/// <summary>
/// 翻牌服务 - 应用层组合服务
/// </summary>
[Authorize]
public class CardFlipService : ApplicationService, ICardFlipService
{
private readonly CardFlipManager _cardFlipManager;
private readonly InviteCodeManager _inviteCodeManager;
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
private readonly ILogger<CardFlipService> _logger;
public CardFlipService(
CardFlipManager cardFlipManager,
InviteCodeManager inviteCodeManager,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
ILogger<CardFlipService> logger)
{
_cardFlipManager = cardFlipManager;
_inviteCodeManager = inviteCodeManager;
_premiumPackageRepository = premiumPackageRepository;
_logger = logger;
}
/// <summary>
/// 获取本周翻牌任务状态
/// </summary>
public async Task<CardFlipStatusOutput> GetWeeklyTaskStatusAsync()
{
var userId = CurrentUser.GetId();
var weekStart = CardFlipManager.GetWeekStartDate(DateTime.Now);
// 获取本周任务
var task = await _cardFlipManager.GetOrCreateWeeklyTaskAsync(userId, weekStart, createIfNotExists: false);
// 获取邀请码信息
var inviteCode = await _inviteCodeManager.GetUserInviteCodeAsync(userId);
// 统计本周邀请人数
var invitedCount = await _inviteCodeManager.GetWeeklyInvitationCountAsync(userId, weekStart);
// 检查用户是否已被邀请
var isInvited = await _inviteCodeManager.IsUserInvitedAsync(userId);
var output = new CardFlipStatusOutput
{
TotalFlips = task?.TotalFlips ?? 0,
RemainingFreeFlips = CardFlipManager.MAX_FREE_FLIPS - (task?.FreeFlipsUsed ?? 0),
RemainingBonusFlips = 0, // 已废弃
RemainingInviteFlips = CardFlipManager.MAX_INVITE_FLIPS - (task?.InviteFlipsUsed ?? 0),
CanFlip = _cardFlipManager.CanFlipCard(task),
MyInviteCode = inviteCode?.Code,
InvitedCount = invitedCount,
IsInvited = isInvited,
FlipRecords = BuildFlipRecords(task)
};
// 生成提示信息
output.NextFlipTip = GenerateNextFlipTip(output);
return output;
}
/// <summary>
/// 翻牌
/// </summary>
public async Task<FlipCardOutput> FlipCardAsync(FlipCardInput input)
{
var userId = CurrentUser.GetId();
var weekStart = CardFlipManager.GetWeekStartDate(DateTime.Now);
// 执行翻牌逻辑(由Manager处理验证和翻牌)
var result = await _cardFlipManager.ExecuteFlipAsync(userId, input.FlipNumber, weekStart);
// 如果中奖,发放奖励
if (result.IsWin)
{
await GrantRewardAsync(userId, result.RewardAmount, $"翻牌活动第{input.FlipNumber}次中奖");
}
// 构建输出
var output = new FlipCardOutput
{
FlipNumber = result.FlipNumber,
IsWin = result.IsWin,
RewardAmount = result.RewardAmount,
RewardDesc = result.RewardDesc,
RemainingFlips = CardFlipManager.TOTAL_MAX_FLIPS - input.FlipNumber
};
return output;
}
/// <summary>
/// 使用邀请码解锁翻牌次数
/// </summary>
public async Task UseInviteCodeAsync(UseInviteCodeInput input)
{
var userId = CurrentUser.GetId();
var weekStart = CardFlipManager.GetWeekStartDate(DateTime.Now);
// 获取本周任务
var task = await _cardFlipManager.GetOrCreateWeeklyTaskAsync(userId, weekStart, createIfNotExists: true);
// 验证是否已经使用了所有邀请解锁次数
if (task.InviteFlipsUsed >= CardFlipManager.MAX_INVITE_FLIPS)
{
throw new UserFriendlyException("本周邀请解锁次数已用完");
}
// 使用邀请码(由Manager处理验证和邀请逻辑)
await _inviteCodeManager.UseInviteCodeAsync(userId, input.InviteCode);
_logger.LogInformation($"用户 {userId} 使用邀请码 {input.InviteCode} 解锁翻牌次数成功");
}
/// <summary>
/// 获取我的邀请码信息
/// </summary>
public async Task<InviteCodeOutput> GetMyInviteCodeAsync()
{
var userId = CurrentUser.GetId();
var weekStart = CardFlipManager.GetWeekStartDate(DateTime.Now);
// 获取我的邀请码
var inviteCode = await _inviteCodeManager.GetUserInviteCodeAsync(userId);
// 统计本周邀请人数
var invitedCount = await _inviteCodeManager.GetWeeklyInvitationCountAsync(userId, weekStart);
// 获取邀请历史
var invitationHistory = await _inviteCodeManager.GetInvitationHistoryAsync(userId, 10);
return new InviteCodeOutput
{
MyInviteCode = inviteCode?.Code,
InvitedCount = invitedCount,
IsInvited = inviteCode?.IsUserInvited ?? false,
InvitationHistory = invitationHistory.Select(x => new InvitationHistoryItem
{
InvitedUserName = x.InvitedUserName,
InvitationTime = x.InvitationTime,
WeekDescription = GetWeekDescription(x.InvitationTime)
}).ToList()
};
}
/// <summary>
/// 生成我的邀请码
/// </summary>
public async Task<string> GenerateMyInviteCodeAsync()
{
var userId = CurrentUser.GetId();
// 生成邀请码(由Manager处理)
var code = await _inviteCodeManager.GenerateInviteCodeForUserAsync(userId);
return code;
}
#region
/// <summary>
/// 构建翻牌记录列表
/// </summary>
private List<CardFlipRecord> BuildFlipRecords(CardFlipTaskAggregateRoot? task)
{
var records = new List<CardFlipRecord>();
// 获取已翻牌的顺序
var flippedOrder = task != null ? _cardFlipManager.GetFlippedOrder(task) : new List<int>();
var flippedNumbers = new HashSet<int>(flippedOrder);
// 构建记录按照原始序号1-10排列
for (int i = 1; i <= CardFlipManager.TOTAL_MAX_FLIPS; i++)
{
var record = new CardFlipRecord
{
FlipNumber = i,
IsFlipped = flippedNumbers.Contains(i),
IsWin = false,
FlipTypeDesc = CardFlipManager.GetFlipTypeDesc(i),
// 设置在翻牌顺序中的位置0表示未翻>0表示第几个翻的
FlipOrderIndex = flippedOrder.IndexOf(i) >= 0 ? flippedOrder.IndexOf(i) + 1 : 0
};
// 设置中奖信息
// 判断这张卡是第几次翻的
if (task != null && flippedNumbers.Contains(i))
{
var flipOrderIndex = flippedOrder.IndexOf(i) + 1; // 第几次翻的1-based
// 第8次翻的卡中奖
if (flipOrderIndex == 8)
{
record.IsWin = true;
record.RewardAmount = task.EighthRewardAmount;
}
// 第9次翻的卡中奖
else if (flipOrderIndex == 9)
{
record.IsWin = true;
record.RewardAmount = task.NinthRewardAmount;
}
// 第10次翻的卡中奖
else if (flipOrderIndex == 10)
{
record.IsWin = true;
record.RewardAmount = task.TenthRewardAmount;
}
}
records.Add(record);
}
return records;
}
/// <summary>
/// 生成下次翻牌提示
/// </summary>
private string GenerateNextFlipTip(CardFlipStatusOutput status)
{
if (status.TotalFlips >= CardFlipManager.TOTAL_MAX_FLIPS)
{
return "本周翻牌次数已用完,请下周再来!";
}
if (status.RemainingFreeFlips > 0)
{
return $"本周您还有{status.RemainingFreeFlips}次免费翻牌机会";
}
else if (status.RemainingInviteFlips > 0)
{
if (status.TotalFlips >= 7)
{
return $"本周使用他人邀请码可解锁{status.RemainingInviteFlips}次翻牌,且必中大奖!每次中奖最大额度将翻倍!";
}
return $"本周使用他人邀请码可解锁{status.RemainingInviteFlips}次翻牌,必中大奖!每次中奖最大额度将翻倍!";
}
return "继续加油!";
}
/// <summary>
/// 发放奖励
/// </summary>
private async Task GrantRewardAsync(Guid userId, long amount, string description)
{
var premiumPackage = new PremiumPackageAggregateRoot(userId, amount, description)
{
PurchaseAmount = 0, // 奖励不需要付费
Remark = $"翻牌活动奖励:{amount / 10000}w tokens"
};
await _premiumPackageRepository.InsertAsync(premiumPackage);
_logger.LogInformation($"用户 {userId} 获得翻牌奖励 {amount / 10000}w tokens");
}
/// <summary>
/// 获取周描述
/// </summary>
private string GetWeekDescription(DateTime date)
{
var weekStart = CardFlipManager.GetWeekStartDate(date);
var weekEnd = weekStart.AddDays(6);
return $"{weekStart:MM-dd} 至 {weekEnd:MM-dd}";
}
#endregion
}

View File

@@ -0,0 +1,185 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using SqlSugar;
using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.DailyTask;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
/// <summary>
/// 每日任务服务
/// </summary>
[Authorize]
public class DailyTaskService : ApplicationService
{
private readonly ISqlSugarRepository<DailyTaskRewardRecordAggregateRoot> _dailyTaskRepository;
private readonly ISqlSugarRepository<MessageAggregateRoot> _messageRepository;
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
private readonly ILogger<DailyTaskService> _logger;
// 任务配置
private readonly Dictionary<int, (long RequiredTokens, long RewardTokens, string Name, string Description)>
_taskConfigs = new()
{
{ 1, (10000000, 2000000, "尊享包1000w token任务", "累积使用尊享包 1000w token") }, // 1000w消耗 -> 200w奖励
{ 2, (30000000, 4000000, "尊享包3000w token任务", "累积使用尊享包 3000w token") } // 3000w消耗 -> 600w奖励
};
public DailyTaskService(
ISqlSugarRepository<DailyTaskRewardRecordAggregateRoot> dailyTaskRepository,
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
ILogger<DailyTaskService> logger)
{
_dailyTaskRepository = dailyTaskRepository;
_messageRepository = messageRepository;
_premiumPackageRepository = premiumPackageRepository;
_logger = logger;
}
/// <summary>
/// 获取今日任务状态
/// </summary>
/// <returns></returns>
public async Task<DailyTaskStatusOutput> GetTodayTaskStatusAsync()
{
var userId = CurrentUser.GetId();
var today = DateTime.Today;
// 1. 统计今日尊享包Token消耗量
var todayConsumed = await GetTodayPremiumTokenConsumptionAsync(userId, today);
// 2. 查询今日已领取的任务
var claimedTasks = await _dailyTaskRepository._DbQueryable
.Where(x => x.UserId == userId && x.TaskDate == today)
.Select(x => new { x.TaskLevel, x.IsRewarded })
.ToListAsync();
// 3. 构建任务列表
var tasks = new List<DailyTaskItem>();
foreach (var (level, config) in _taskConfigs)
{
var claimed = claimedTasks.FirstOrDefault(x => x.TaskLevel == level);
int status;
if (claimed != null && claimed.IsRewarded)
{
status = 2; // 已领取
}
else if (todayConsumed >= config.RequiredTokens)
{
status = 1; // 可领取
}
else
{
status = 0; // 未完成
}
var progress = todayConsumed >= config.RequiredTokens
? 100
: Math.Round((decimal)todayConsumed / config.RequiredTokens * 100, 2);
tasks.Add(new DailyTaskItem
{
Level = level,
Name = config.Name,
Description = config.Description,
RequiredTokens = config.RequiredTokens,
RewardTokens = config.RewardTokens,
Status = status,
Progress = progress
});
}
return new DailyTaskStatusOutput
{
TodayConsumedTokens = todayConsumed,
Tasks = tasks
};
}
/// <summary>
/// 领取任务奖励
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public async Task ClaimTaskRewardAsync(ClaimTaskRewardInput input)
{
var userId = CurrentUser.GetId();
var today = DateTime.Today;
// 1. 验证任务等级
if (!_taskConfigs.TryGetValue(input.TaskLevel, out var taskConfig))
{
throw new UserFriendlyException($"无效的任务等级: {input.TaskLevel}");
}
// 2. 检查是否已领取
var existingRecord = await _dailyTaskRepository._DbQueryable
.Where(x => x.UserId == userId && x.TaskDate == today && x.TaskLevel == input.TaskLevel)
.FirstAsync();
if (existingRecord != null)
{
throw new UserFriendlyException("今日该任务奖励已领取,请明天再来!");
}
// 3. 验证今日Token消耗是否达标
var todayConsumed = await GetTodayPremiumTokenConsumptionAsync(userId, today);
if (todayConsumed < taskConfig.RequiredTokens)
{
throw new UserFriendlyException(
$"Token消耗未达标需要 {taskConfig.RequiredTokens / 10000}w当前 {todayConsumed / 10000}w");
}
// 4. 创建奖励包(使用 PremiumPackageManager
var premiumPackage =
new PremiumPackageAggregateRoot(userId, taskConfig.RewardTokens, $"每日任务:{taskConfig.Name}")
{
PurchaseAmount = 0, // 奖励不需要付费
Remark = $"{today:yyyy-MM-dd} 每日任务奖励"
};
await _premiumPackageRepository.InsertAsync(premiumPackage);
// 5. 记录领取记录
var record = new DailyTaskRewardRecordAggregateRoot(userId, input.TaskLevel, today, taskConfig.RewardTokens)
{
Remark = $"完成任务{input.TaskLevel},名称:{taskConfig.Name},消耗 {todayConsumed / 10000}w token"
};
await _dailyTaskRepository.InsertAsync(record);
_logger.LogInformation(
$"用户 {userId} 领取每日任务 {input.TaskLevel} 奖励成功,获得 {taskConfig.RewardTokens / 10000}w tokens");
}
/// <summary>
/// 获取今日尊享包Token消耗量
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="today">今日日期</param>
/// <returns>消耗的Token总数</returns>
private async Task<long> GetTodayPremiumTokenConsumptionAsync(Guid userId, DateTime today)
{
var tomorrow = today.AddDays(1);
// 查询今日所有使用尊享包模型的消息role=system 表示消耗)
var totalTokens = await _messageRepository._DbQueryable
.Where(x => x.UserId == userId)
.Where(x => x.Role == "system") // system角色表示实际消耗
.Where(x => PremiumPackageConst.ModeIds.Contains(x.ModelId)) // 尊享包模型
.Where(x => x.CreationTime >= today && x.CreationTime < tomorrow)
.SumAsync(x => x.TokenUsage.TotalTokenCount);
return totalTokens;
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.FileMaster;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Application.Services.FileMaster;
public class FileMasterService : ApplicationService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly AiGateWayManager _aiGateWayManager;
private readonly AiBlacklistManager _aiBlacklistManager;
public FileMasterService(IHttpContextAccessor httpContextAccessor, AiGateWayManager aiGateWayManager,
AiBlacklistManager aiBlacklistManager)
{
_httpContextAccessor = httpContextAccessor;
_aiGateWayManager = aiGateWayManager;
_aiBlacklistManager = aiBlacklistManager;
}
/// <summary>
/// 校验下一步
/// </summary>
/// <returns></returns>
[HttpPost("FileMaster/VerifyNext")]
public Task<string> VerifyNextAsync(VerifyNextInput input)
{
if (!CurrentUser.IsAuthenticated)
{
if (input.DirectoryCount + input.FileCount >= 20)
{
throw new UserFriendlyException("未登录用户文件夹与文件数量不能大于20个请登录后解锁全部功能");
}
}
else
{
if (input.DirectoryCount + input.FileCount >= 100)
{
throw new UserFriendlyException("为防止无限制暴力使用当前文件整理大师Vip最多支持100文件与文件夹数量");
}
}
return Task.FromResult("success");
}
/// <summary>
/// 对话
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
[HttpPost("FileMaster/chat/completions")]
public async Task ChatCompletionsAsync([FromBody] ThorChatCompletionsRequest input,
CancellationToken cancellationToken)
{
if (CurrentUser.IsAuthenticated)
{
input.Model = "gpt-5-chat";
}
else
{
input.Model = "gpt-5-chat";
}
Guid? userId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : null;
if (userId is not null)
{
await _aiBlacklistManager.VerifiyAiBlacklist(userId.Value);
}
//ai网关代理httpcontext
if (input.Stream == true)
{
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, cancellationToken);
}
else
{
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null,
cancellationToken);
}
}
}

View File

@@ -0,0 +1,308 @@
using System.Text;
using System.Xml.Serialization;
using Medallion.Threading;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Volo.Abp.Application.Services;
using Volo.Abp.Caching;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
using Yi.Framework.Rbac.Application.Contracts.IServices;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services.Fuwuhao;
/// <summary>
/// 服务号服务
/// </summary>
public class FuwuhaoService : ApplicationService
{
private readonly ILogger<FuwuhaoService> _logger;
private readonly IHttpContextAccessor _accessor;
private readonly FuwuhaoManager _fuwuhaoManager;
private IDistributedCache<SceneCacheDto> _sceneCache;
private IDistributedCache<string> _openIdToSceneCache;
private ISqlSugarRepository<AiUserExtraInfoEntity> _userRepository;
private IAccountService _accountService;
private IFileService _fileService;
public IDistributedLockProvider DistributedLock => LazyServiceProvider.LazyGetService<IDistributedLockProvider>();
public FuwuhaoService(ILogger<FuwuhaoService> logger, IHttpContextAccessor accessor, FuwuhaoManager fuwuhaoManager,
IDistributedCache<SceneCacheDto> sceneCache, IAccountService accountService, IFileService fileService,
IDistributedCache<string> openIdToSceneCache, ISqlSugarRepository<AiUserExtraInfoEntity> userRepositroy)
{
_logger = logger;
_accessor = accessor;
_fuwuhaoManager = fuwuhaoManager;
_sceneCache = sceneCache;
_accountService = accountService;
_fileService = fileService;
_openIdToSceneCache = openIdToSceneCache;
_userRepository = userRepositroy;
}
/// <summary>
/// 服务器号测试回调
/// </summary>
/// <returns></returns>
[HttpGet("fuwuhao/callback")]
public async Task<string> GetCallbackAsync([FromQuery] string signature, [FromQuery] string timestamp,
[FromQuery] string nonce, [FromQuery] string echostr)
{
return echostr;
}
/// <summary>
/// 服务号关注回调
/// </summary>
/// <param name="signature"></param>
/// <param name="timestamp"></param>
/// <param name="nonce"></param>
/// <returns></returns>
[HttpPost("fuwuhao/callback")]
public async Task<string> PostCallbackAsync([FromQuery] string signature, [FromQuery] string timestamp,
[FromQuery] string nonce)
{
_fuwuhaoManager.ValidateCallback(signature, timestamp, nonce);
var request = _accessor.HttpContext.Request;
// 1. 读取原始 XML 内容
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var xmlString = await reader.ReadToEndAsync();
var serializer = new XmlSerializer(typeof(FuwuhaoCallModel));
using var stringReader = new StringReader(xmlString);
var body = (FuwuhaoCallModel)serializer.Deserialize(stringReader);
//获取场景值,后续通过场景值设置缓存状态,前端轮询这个场景值用户是否已操作即可
var scene = body.EventKey.Replace("qrscene_", "");
if (!(body.Event is "SCAN" or "subscribe"))
{
throw new UserFriendlyException("当前回调只处理扫码 与 关注");
}
if (scene is null)
{
throw new UserFriendlyException("服务号返回无场景值");
}
//制作幂等
await using (var handle =
await DistributedLock.TryAcquireLockAsync($"Yi:fuwuhao:callbacklock:{scene}"))
{
if (handle == null)
{
return "success"; // 跳过直接返回成功
}
var cache = await _sceneCache.GetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}");
if (cache == null)
{
return "success"; // 跳过直接返回成功
}
if (cache.SceneResult != SceneResultEnum.Wait)
{
return "success"; // 跳过直接返回成功
}
//根据操作类型,进行业务处理,返回处理结果,再写入缓存,10s过去相当于用户10s扫完app后轮询要在10秒内完成
var scenResult =
await _fuwuhaoManager.CallBackHandlerAsync(cache.SceneType, body.FromUserName, cache.UserId);
cache.SceneResult = scenResult.SceneResult;
cache.UserId = scenResult.UserId;
await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}", cache,
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(120) });
//如果是注册将OpenId与Scene进行绑定代表用户有30分钟进行注册
if (scenResult.SceneResult == SceneResultEnum.Register)
{
await _openIdToSceneCache.SetAsync($"{FuwuhaoConst.OpenIdToSceneCacheKey}{body.FromUserName}", scene,
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) });
var replyMessage =
_fuwuhaoManager.BuildRegisterMessage(body.FromUserName);
return replyMessage;
}
}
return "success";
}
/// <summary>
/// 创建带参数的二维码
/// </summary>
/// <returns>二维码URL</returns>
[HttpPost("fuwuhao/qrcode")]
public async Task<QrCodeOutput> GetQrCodeAsync([FromQuery] SceneTypeEnum sceneType)
{
if (sceneType == SceneTypeEnum.Bind && CurrentUser.Id is null)
{
throw new UserFriendlyException("绑定微信,需登录用户,请重新登录后重试");
}
//生成一个随机场景值
var scene = Guid.NewGuid().ToString("N");
var qrCodeUrl = await _fuwuhaoManager.CreateQrCodeAsync(scene);
await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}", new SceneCacheDto()
{
UserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : null,
SceneType = sceneType
}, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
return new QrCodeOutput()
{
QrCodeUrl = qrCodeUrl,
Scene = scene
};
}
/// <summary>
/// 扫码登录/注册/绑定,轮询接口
/// </summary>
/// <param name="scene"></param>
/// <returns></returns>
[HttpGet("fuwuhao/qrcode/result")]
public async Task<QrCodeResultOutput> GetQrCodeResultAsync([FromQuery] string scene)
{
var output = new QrCodeResultOutput();
var cache = await _sceneCache.GetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}");
if (cache is null)
{
output.SceneResult = SceneResultEnum.Expired;
return output;
}
output.SceneResult = cache.SceneResult;
switch (output.SceneResult)
{
case SceneResultEnum.Login:
if (cache.UserId is null)
{
throw new ApplicationException("获取用户id异常请重试");
}
var loginInfo = await _accountService.PostLoginAsync(cache.UserId!.Value);
output.Token = loginInfo.Token;
output.RefreshToken = loginInfo.RefreshToken;
break;
}
return output;
}
/// <summary>
/// 注册账号需要微信code
/// </summary>
/// <param name="code"></param>
/// <returns></returns>
[HttpGet("fuwuhao/register")]
public async Task<IActionResult> RegisterByCodeAsync([FromQuery] string code)
{
var message = await RegisterByCodeForMessageAsync(code);
//var message = "恭喜注册";
var filePath = Path.Combine("wwwroot", "aihub", "auth.html");
var html = await File.ReadAllTextAsync(filePath);
var result = new ContentResult
{
Content = html.Replace("{{message}}", message),
ContentType = "text/html",
StatusCode = 200
};
return result;
}
private async Task<string> RegisterByCodeForMessageAsync(string code)
{
//根据code获取到openid、微信用户昵称、头像
var userInfo = await _fuwuhaoManager.GetUserInfoByCodeAsync(code);
if (userInfo is null)
{
return "当前注册已经失效,请重新扫码注册!";
}
var scene = await _openIdToSceneCache.GetAsync($"{FuwuhaoConst.OpenIdToSceneCacheKey}{userInfo.OpenId}");
if (scene is null)
{
return "当前注册已经过期,请重新扫码注册!";
}
var files = new FormFileCollection();
// 下载头像并添加到系统文件中
if (!string.IsNullOrEmpty(userInfo.HeadImgUrl))
{
using var httpClient = new HttpClient();
var imageBytes = await httpClient.GetByteArrayAsync(userInfo.HeadImgUrl);
var imageStream = new MemoryStream(imageBytes);
// 从URL中提取文件扩展名默认为png
var fileName = $"avatar_{userInfo.OpenId}.png";
var formFile = new FormFile(imageStream, 0, imageBytes.Length, "avatar", fileName)
{
Headers = new HeaderDictionary(),
ContentType = "image/png"
};
files.Add(formFile);
}
var result = await _fileService.Post(files);
//由于存在查询/编辑在同一个事务操作,上锁防止并发
await using (await DistributedLock.AcquireLockAsync($"fuwuhao:RegisterLock:{userInfo.OpenId}",
TimeSpan.FromMinutes(1)))
{
if (await _userRepository.IsAnyAsync(x => x.FuwuhaoOpenId == userInfo.OpenId))
{
return "你已注册过意社区账号!";
}
Guid userId;
try
{
userId = await _accountService.PostSystemRegisterAsync(new RegisterDto
{
UserName = $"wx{Random.Shared.Next(100000, 999999)}",
Password = Guid.NewGuid().ToString("N"),
Phone = null,
Email = null,
Nick = userInfo.Nickname,
Icon = result.FirstOrDefault()?.Id.ToString()
});
}
catch (UserFriendlyException e)
{
return e.Message;
}
await _userRepository.InsertAsync(new AiUserExtraInfoEntity(userId, userInfo.OpenId));
await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}", new SceneCacheDto
{
UserId = userId,
SceneResult = SceneResultEnum.Login
},
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(120) });
}
return "恭喜你成功注册意社区账号!";
}
}
[XmlRoot("xml")]
public class FuwuhaoCallModel
{
[XmlElement("ToUserName")] public string ToUserName { get; set; }
[XmlElement("FromUserName")] public string FromUserName { get; set; }
[XmlElement("CreateTime")] public string CreateTime { get; set; }
[XmlElement("MsgType")] public string MsgType { get; set; }
[XmlElement("Event")] public string Event { get; set; }
[XmlElement("EventKey")] public string EventKey { get; set; }
[XmlElement("Ticket")] public string Ticket { get; set; }
}

View File

@@ -2,13 +2,18 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Rbac.Application.Contracts.IServices;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
@@ -21,9 +26,13 @@ public class OpenApiService : ApplicationService
private readonly AiGateWayManager _aiGateWayManager;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly IAccountService _accountService;
private readonly PremiumPackageManager _premiumPackageManager;
public OpenApiService(IHttpContextAccessor httpContextAccessor, ILogger<OpenApiService> logger,
TokenManager tokenManager, AiGateWayManager aiGateWayManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository, AiBlacklistManager aiBlacklistManager)
ISqlSugarRepository<AiModelEntity> aiModelRepository, AiBlacklistManager aiBlacklistManager,
IAccountService accountService, PremiumPackageManager premiumPackageManager)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
@@ -31,6 +40,8 @@ public class OpenApiService : ApplicationService
_aiGateWayManager = aiGateWayManager;
_aiModelRepository = aiModelRepository;
_aiBlacklistManager = aiBlacklistManager;
_accountService = accountService;
_premiumPackageManager = premiumPackageManager;
}
/// <summary>
@@ -46,6 +57,18 @@ public class OpenApiService : ApplicationService
var httpContext = this._httpContextAccessor.HttpContext;
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
//如果是尊享包服务,需要校验是是否尊享包足够
if (PremiumPackageConst.ModeIds.Contains(input.Model))
{
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
}
//ai网关代理httpcontext
if (input.Stream == true)
{
@@ -69,11 +92,12 @@ public class OpenApiService : ApplicationService
public async Task ImagesGenerationsAsync([FromBody] ImageCreateRequest input, CancellationToken cancellationToken)
{
var httpContext = this._httpContextAccessor.HttpContext;
Intercept(httpContext);
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input);
}
/// <summary>
/// 向量生成
/// </summary>
@@ -83,6 +107,7 @@ public class OpenApiService : ApplicationService
public async Task EmbeddingAsync([FromBody] ThorEmbeddingInput input, CancellationToken cancellationToken)
{
var httpContext = this._httpContextAccessor.HttpContext;
Intercept(httpContext);
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
await _aiGateWayManager.EmbeddingForStatisticsAsync(httpContext, userId, null, input);
@@ -114,17 +139,85 @@ public class OpenApiService : ApplicationService
};
}
/// <summary>
/// Anthropic对话尊享服务专用
/// </summary>
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
[HttpPost("openApi/v1/messages")]
public async Task MessagesAsync([FromBody] AnthropicInput input,
CancellationToken cancellationToken)
{
//前面都是校验,后面才是真正的调用
var httpContext = this._httpContextAccessor.HttpContext;
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
// 验证用户是否为VIP
var userInfo = await _accountService.GetAsync(null, null, userId);
if (userInfo == null)
{
throw new UserFriendlyException("用户信息不存在");
}
// 检查是否为VIP使用RoleCodes判断
if (!userInfo.RoleCodes.Contains(AiHubConst.VipRole) && userInfo.User.UserName != "cc")
{
throw new UserFriendlyException("该接口为尊享服务专用需要VIP权限才能使用");
}
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
//ai网关代理httpcontext
if (input.Stream)
{
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, cancellationToken);
}
else
{
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null,
cancellationToken);
}
}
#region
private string? GetTokenByHttpContext(HttpContext httpContext)
{
// 获取Authorization头
string authHeader = httpContext.Request.Headers["Authorization"];
// 优先从 x-api-key 获取
string apiKeyHeader = httpContext.Request.Headers["x-api-key"];
if (!string.IsNullOrWhiteSpace(apiKeyHeader))
{
return apiKeyHeader.Trim();
}
// 检查是否有Bearer token
if (authHeader != null && authHeader.StartsWith("Bearer "))
// 检查 Authorization 头
string authHeader = httpContext.Request.Headers["Authorization"];
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return authHeader.Substring("Bearer ".Length).Trim();
}
return null;
}
private void Intercept(HttpContext httpContext)
{
if (httpContext.Request.Host.Value == "yxai.chat")
{
throw new UserFriendlyException("当前海外站点不支持大流量接口请使用转发站点https://ai.ccnetcore.com");
}
}
#endregion
}

View File

@@ -14,6 +14,8 @@ using Yi.Framework.AiHub.Domain.Entities.Pay;
using Yi.Framework.SqlSugarCore.Abstractions;
using System.ComponentModel;
using System.Reflection;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Recharge;
namespace Yi.Framework.AiHub.Application.Services;
@@ -26,16 +28,23 @@ public class PayService : ApplicationService, IPayService
private readonly PayManager _payManager;
private readonly ILogger<PayService> _logger;
private readonly ISqlSugarRepository<PayOrderAggregateRoot, Guid> _payOrderRepository;
private readonly IRechargeService _rechargeService;
private readonly PremiumPackageManager _premiumPackageManager;
public PayService(
AlipayManager alipayManager,
PayManager payManager,
ILogger<PayService> logger, ISqlSugarRepository<PayOrderAggregateRoot, Guid> payOrderRepository)
ILogger<PayService> logger,
ISqlSugarRepository<PayOrderAggregateRoot, Guid> payOrderRepository,
IRechargeService rechargeService,
PremiumPackageManager premiumPackageManager)
{
_alipayManager = alipayManager;
_payManager = payManager;
_logger = logger;
_payOrderRepository = payOrderRepository;
_rechargeService = rechargeService;
_premiumPackageManager = premiumPackageManager;
}
/// <summary>
@@ -47,14 +56,15 @@ public class PayService : ApplicationService, IPayService
[HttpPost("pay/Order")]
public async Task<CreateOrderOutput> CreateOrderAsync(CreateOrderInput input)
{
// 1. 通过PayManager创建订单
// 1. 通过PayManager创建订单内部会验证VIP资格
var order = await _payManager.CreateOrderAsync(input.GoodsType);
// 2. 通过AlipayManager发起页面支付
var paymentPageHtml = await _alipayManager.PaymentPageAsync(
order.GoodsName,
order.OutTradeNo,
order.TotalAmount);
order.TotalAmount,
input.ReturnUrl);
// 3. 返回结果
return new CreateOrderOutput
@@ -87,9 +97,8 @@ public class PayService : ApplicationService, IPayService
// 2. 验证签名
await _alipayManager.VerifyNotifyAsync(notifyData);
// 3. 记录支付通知
await _payManager.RecordPayNoticeAsync(notifyData,signStr);
await _payManager.RecordPayNoticeAsync(notifyData, signStr);
// 4. 更新订单状态
var outTradeNo = notifyData.GetValueOrDefault("out_trade_no", string.Empty);
@@ -99,9 +108,51 @@ public class PayService : ApplicationService, IPayService
if (!string.IsNullOrEmpty(outTradeNo) && !string.IsNullOrEmpty(tradeStatus))
{
var status = ParseTradeStatus(tradeStatus);
await _payManager.UpdateOrderStatusAsync(outTradeNo, status, tradeNo);
var order = await _payManager.UpdateOrderStatusAsync(outTradeNo, status, tradeNo);
_logger.LogInformation("订单状态更新成功,订单号:{OutTradeNo},状态:{TradeStatus}", outTradeNo, tradeStatus);
// 验证交易状态,只有交易成功才执行充值逻辑
if (status != TradeStatusEnum.TRADE_SUCCESS)
{
_logger.LogError($"订单 {outTradeNo} 状态为 {tradeStatus},不执行充值逻辑");
return "success";
}
// 5. 根据商品类型进行不同的处理
if (order.GoodsType.IsPremiumPackage())
{
// 处理尊享包商品:创建尊享包记录
await _premiumPackageManager.CreatePremiumPackageAsync(
order.UserId,
order.GoodsType,
order.TotalAmount,
expireMonths: null // 尊享包不设置过期时间,或者可以根据需求设置
);
_logger.LogInformation(
$"用户 {order.UserId} 购买尊享包成功,订单号:{outTradeNo},商品:{order.GoodsName}");
}
else if (order.GoodsType.IsVipService())
{
// 处理VIP服务商品充值VIP
await _rechargeService.RechargeVipAsync(new RechargeCreateInput
{
UserId = order.UserId,
RechargeAmount = order.TotalAmount,
Content = order.GoodsName,
Months = order.GoodsType.GetValidMonths(),
Remark = "自助充值",
ContactInfo = null
});
_logger.LogInformation(
$"用户 {order.UserId} 充值VIP成功订单号{outTradeNo},月数:{order.GoodsType.GetValidMonths()}");
}
else
{
_logger.LogWarning($"未知的商品类型:{order.GoodsType},订单号:{outTradeNo}");
}
}
else
{
@@ -142,6 +193,85 @@ public class PayService : ApplicationService, IPayService
};
}
/// <summary>
/// 获取商品列表
/// </summary>
/// <param name="input">获取商品列表输入</param>
/// <returns>商品列表</returns>
[HttpGet("pay/GoodsList")]
public async Task<List<GoodsListOutput>> GetGoodsListAsync([FromQuery] GetGoodsListInput input)
{
var goodsList = new List<GoodsListOutput>();
// 获取当前用户的累加充值金额(仅已登录用户)
decimal totalRechargeAmount = 0m;
if (CurrentUser.IsAuthenticated)
{
totalRechargeAmount = await _payManager.GetUserTotalRechargeAmountAsync(CurrentUser.GetId());
}
// 遍历所有商品枚举
foreach (GoodsTypeEnum goodsType in Enum.GetValues(typeof(GoodsTypeEnum)))
{
// 如果指定了商品类别,则过滤
if (input.GoodsCategoryType.HasValue)
{
var goodsCategory = goodsType.GetGoodsCategory();
if (goodsCategory != input.GoodsCategoryType.Value)
{
continue; // 跳过不匹配的商品
}
}
var originalPrice = goodsType.GetTotalAmount();
decimal actualPrice = originalPrice;
decimal? discountAmount = null;
string? discountDescription = null;
// 如果是尊享包商品,计算折扣
if (goodsType.IsPremiumPackage())
{
if (CurrentUser.IsAuthenticated)
{
discountAmount = goodsType.CalculateDiscount(totalRechargeAmount);
actualPrice = goodsType.GetDiscountedPrice(totalRechargeAmount);
if (discountAmount > 0)
{
discountDescription = $"根据累积充值已优惠 ¥{discountAmount:F2}";
}
else
{
discountDescription = $"累积充值过低,暂无优惠";
}
}
else
{
discountDescription = $"登录后查看优惠";
}
}
var goodsItem = new GoodsListOutput
{
GoodsName = goodsType.GetChineseName(),
OriginalPrice = originalPrice,
ReferencePrice = goodsType.GetReferencePrice(),
GoodsPrice = actualPrice,
GoodsCategory = goodsType.GetGoodsCategory().ToString(),
Remark = goodsType.GetRemark(),
DiscountAmount = discountAmount,
DiscountDescription = discountDescription,
GoodsType = goodsType
};
goodsList.Add(goodsItem);
}
return goodsList;
}
/// <summary>
/// 获取交易状态描述
/// </summary>
@@ -165,6 +295,7 @@ public class PayService : ApplicationService, IPayService
{
return result;
}
return TradeStatusEnum.WAIT_TRADE;
}
}

View File

@@ -8,12 +8,13 @@ using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Rbac.Application.Contracts.IServices;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services
{
public class RechargeService : ApplicationService,IRechargeService
public class RechargeService : ApplicationService, IRechargeService
{
private readonly ISqlSugarRepository<AiRechargeAggregateRoot> _repository;
private readonly ICurrentUser _currentUser;
@@ -57,15 +58,37 @@ namespace Yi.Framework.AiHub.Application.Services
[HttpPost("recharge/vip")]
public async Task RechargeVipAsync(RechargeCreateInput input)
{
DateTime? expireDateTime = null;
// 如果传入了月数,计算过期时间
if (input.Months.HasValue && input.Months.Value > 0)
{
// 直接查询该用户最大的过期时间
var maxExpireTime = await _repository._DbQueryable
.Where(x => x.RechargeType == RechargeTypeEnum.Vip)
.Where(x => x.UserId == input.UserId && x.ExpireDateTime.HasValue)
.MaxAsync(x => x.ExpireDateTime);
// 如果最大过期时间大于现在时间,从最大过期时间开始计算
// 否则从当天开始计算
DateTime baseDateTime = maxExpireTime.HasValue && maxExpireTime.Value > DateTime.Now
? maxExpireTime.Value
: DateTime.Now;
// 计算新的过期时间
expireDateTime = baseDateTime.AddMonths(input.Months.Value);
}
// 如果月数为空或0表示永久VIPExpireDateTime保持为null
// 创建充值记录
var rechargeRecord = new AiRechargeAggregateRoot
{
UserId = input.UserId,
RechargeAmount = input.RechargeAmount,
Content = input.Content,
ExpireDateTime = input.ExpireDateTime,
ExpireDateTime = expireDateTime,
Remark = input.Remark,
ContactInfo = input.ContactInfo
ContactInfo = input.ContactInfo,
RechargeType = RechargeTypeEnum.Vip
};
// 保存充值记录到数据库

View File

@@ -6,6 +6,7 @@ using Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
@@ -18,13 +19,16 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
{
private readonly ISqlSugarRepository<MessageAggregateRoot> _messageRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
public UsageStatisticsService(
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository)
{
_messageRepository = messageRepository;
_usageStatisticsRepository = usageStatisticsRepository;
_premiumPackageRepository = premiumPackageRepository;
}
/// <summary>
@@ -40,6 +44,7 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
// 从Message表统计近7天的token消耗
var dailyUsage = await _messageRepository._DbQueryable
.Where(x => x.UserId == userId)
.Where(x => x.Role == "assistant" || x.Role == "system")
.Where(x => x.CreationTime >= startDate && x.CreationTime < endDate.AddDays(1))
.GroupBy(x => x.CreationTime.Date)
.Select(g => new
@@ -102,4 +107,34 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
return result;
}
/// <summary>
/// 获取当前用户尊享服务Token用量统计
/// </summary>
/// <returns>尊享服务Token用量统计</returns>
public async Task<PremiumTokenUsageDto> GetPremiumTokenUsageAsync()
{
var userId = CurrentUser.GetId();
// 获取尊享包Token信息
var premiumPackages = await _premiumPackageRepository._DbQueryable
.Where(x => x.UserId == userId && x.IsActive)
.ToListAsync();
var result = new PremiumTokenUsageDto();
if (premiumPackages.Any())
{
// 过滤掉已过期的包
var validPackages = premiumPackages
.Where(p => p.IsAvailable())
.ToList();
result.PremiumTotalTokens = validPackages.Sum(x => x.TotalTokens);
result.PremiumUsedTokens = validPackages.Sum(x => x.UsedTokens);
result.PremiumRemainingTokens = validPackages.Sum(x => x.RemainingTokens);
}
return result;
}
}

View File

@@ -0,0 +1,7 @@
namespace Yi.Framework.AiHub.Domain.Shared.Consts;
public class FuwuhaoConst
{
public const string SceneCacheKey = "fuwuhao:scene:";
public const string OpenIdToSceneCacheKey = "fuwuhao:OpenIdToScene:";
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
namespace Yi.Framework.AiHub.Domain.Shared.Consts;
public class PremiumPackageConst
{
public static List<string> ModeIds = ["claude-sonnet-4-5-20250929"];
}

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public sealed class AnthropicCacheControl
{
[JsonPropertyName("type")]
public string Type { get; set; }
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json.Serialization;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public class AnthropicStreamDto
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("index")] public int? Index { get; set; }
[JsonPropertyName("content_block")] public AnthropicChatCompletionDtoContentBlock? ContentBlock { get; set; }
[JsonPropertyName("delta")] public AnthropicChatCompletionDtoDelta? Delta { get; set; }
[JsonPropertyName("message")] public AnthropicChatCompletionDto? Message { get; set; }
[JsonPropertyName("usage")] public AnthropicCompletionDtoUsage? Usage { get; set; }
[JsonPropertyName("error")] public AnthropicStreamErrorDto? Error { get; set; }
[JsonIgnore]
public ThorUsageResponse TokenUsage => new ThorUsageResponse
{
PromptTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens,
InputTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens,
OutputTokens = Usage?.OutputTokens,
InputTokensDetails = null,
CompletionTokens = Usage?.OutputTokens,
TotalTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens +
Usage?.OutputTokens,
PromptTokensDetails = null,
CompletionTokensDetails = null
};
public void SupplementalMultiplier(double multiplier)
{
if (this.Usage is not null)
{
this.Usage.CacheCreationInputTokens =
(int)Math.Round((this.Usage.CacheCreationInputTokens ?? 0) * multiplier);
this.Usage.CacheReadInputTokens =
(int)Math.Round((this.Usage.CacheReadInputTokens ?? 0) * multiplier);
this.Usage.InputTokens =
(int)Math.Round((this.Usage.InputTokens ?? 0) * multiplier);
this.Usage.OutputTokens =
(int)Math.Round((this.Usage.OutputTokens ?? 0) * multiplier);
}
}
}
public class AnthropicStreamErrorDto
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
}
public class AnthropicChatCompletionDtoDelta
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("text")] public string? Text { get; set; }
[JsonPropertyName("thinking")] public string? Thinking { get; set; }
[JsonPropertyName("partial_json")] public string? PartialJson { get; set; }
[JsonPropertyName("stop_reason")] public string? StopReason { get; set; }
}
public class AnthropicChatCompletionDtoContentBlock
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("thinking")] public string? Thinking { get; set; }
[JsonPropertyName("signature")] public string? Signature { get; set; }
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("input")] public object? Input { get; set; }
[JsonPropertyName("server_name")] public string? ServerName { get; set; }
[JsonPropertyName("is_error")] public bool? IsError { get; set; }
[JsonPropertyName("tool_use_id")] public string? ToolUseId { get; set; }
[JsonPropertyName("content")] public object? Content { get; set; }
[JsonPropertyName("text")] public string? Text { get; set; }
}
public class AnthropicChatCompletionDto
{
public string id { get; set; }
public string type { get; set; }
public string role { get; set; }
public AnthropicChatCompletionDtoContent[] content { get; set; }
public string model { get; set; }
public string stop_reason { get; set; }
public object stop_sequence { get; set; }
public AnthropicCompletionDtoUsage? Usage { get; set; }
[JsonIgnore]
public ThorUsageResponse TokenUsage => new ThorUsageResponse
{
PromptTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens,
InputTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens,
OutputTokens = Usage?.OutputTokens,
InputTokensDetails = null,
CompletionTokens = Usage?.OutputTokens,
TotalTokens = Usage?.InputTokens + Usage?.CacheCreationInputTokens + Usage?.CacheReadInputTokens +
Usage?.OutputTokens,
PromptTokensDetails = null,
CompletionTokensDetails = null
};
public void SupplementalMultiplier(double multiplier)
{
if (this.Usage is not null)
{
this.Usage.CacheCreationInputTokens =
(int)Math.Round((this.Usage?.CacheCreationInputTokens ?? 0) * multiplier);
this.Usage.CacheReadInputTokens =
(int)Math.Round((this.Usage?.CacheReadInputTokens ?? 0) * multiplier);
this.Usage.InputTokens =
(int)Math.Round((this.Usage?.InputTokens ?? 0) * multiplier);
this.Usage.OutputTokens =
(int)Math.Round((this.Usage?.OutputTokens ?? 0) * multiplier);
}
}
}
public class AnthropicChatCompletionDtoContent
{
public string type { get; set; }
public string? text { get; set; }
public string? id { get; set; }
public string? name { get; set; }
public object? input { get; set; }
[JsonPropertyName("thinking")] public string? Thinking { get; set; }
[JsonPropertyName("partial_json")] public string? PartialJson { get; set; }
public string? signature { get; set; }
}
public class AnthropicCompletionDtoUsage
{
[JsonPropertyName("input_tokens")] public int? InputTokens { get; set; }
[JsonPropertyName("cache_creation_input_tokens")]
public int? CacheCreationInputTokens { get; set; }
[JsonPropertyName("cache_read_input_tokens")]
public int? CacheReadInputTokens { get; set; }
[JsonPropertyName("output_tokens")] public int? OutputTokens { get; set; }
[JsonPropertyName("server_tool_use")] public AnthropicServerToolUse? ServerToolUse { get; set; }
}
public class AnthropicServerToolUse
{
[JsonPropertyName("web_search_requests")]
public int? WebSearchRequests { get; set; }
}

View File

@@ -0,0 +1,153 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public sealed class AnthropicInput
{
[JsonPropertyName("stream")] public bool Stream { get; set; }
[JsonPropertyName("model")] public string Model { get; set; }
[JsonPropertyName("max_tokens")] public int? MaxTokens { get; set; }
[JsonPropertyName("messages")] public IList<AnthropicMessageInput> Messages { get; set; }
[JsonPropertyName("tools")] public IList<AnthropicMessageTool>? Tools { get; set; }
[JsonPropertyName("tool_choice")]
public object? ToolChoiceCalculated
{
get
{
if (string.IsNullOrEmpty(ToolChoiceString))
{
return ToolChoiceString;
}
if (ToolChoice?.Type == "function")
{
return ToolChoice;
}
return ToolChoice?.Type;
}
set
{
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.String)
{
ToolChoiceString = jsonElement.GetString();
}
else if (jsonElement.ValueKind == JsonValueKind.Object)
{
ToolChoice = jsonElement.Deserialize<AnthropicTooChoiceInput>(ThorJsonSerializer.DefaultOptions);
}
}
else
{
ToolChoice = (AnthropicTooChoiceInput)value;
}
}
}
[JsonIgnore] public string? ToolChoiceString { get; set; }
[JsonIgnore] public AnthropicTooChoiceInput? ToolChoice { get; set; }
[JsonIgnore] public IList<AnthropicMessageContent>? Systems { get; set; }
[JsonIgnore] public string? System { get; set; }
[JsonPropertyName("system")]
public object? SystemCalculated
{
get
{
if (System is not null && Systems is not null)
{
throw new ValidationException("System 和 Systems 字段不能同时有值");
}
if (System is not null)
{
return System;
}
return Systems!;
}
set
{
if (value is JsonElement str)
{
if (str.ValueKind == JsonValueKind.String)
{
System = value?.ToString();
}
else if (str.ValueKind == JsonValueKind.Array)
{
Systems = JsonSerializer.Deserialize<IList<AnthropicMessageContent>>(value?.ToString(),
ThorJsonSerializer.DefaultOptions);
}
}
else
{
System = value?.ToString();
}
}
}
[JsonPropertyName("thinking")] public AnthropicThinkingInput? Thinking { get; set; }
[JsonPropertyName("temperature")] public double? Temperature { get; set; }
[JsonPropertyName("metadata")] public Dictionary<string, object>? Metadata { get; set; }
}
public class AnthropicThinkingInput
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("budget_tokens")] public int? BudgetTokens { get; set; }
}
public class AnthropicTooChoiceInput
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
}
public class AnthropicMessageTool
{
[JsonPropertyName("name")] public string? name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("input_schema")] public Input_schema? InputSchema { get; set; }
}
public class Input_schema
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("properties")] public Dictionary<string, InputSchemaValue>? Properties { get; set; }
[JsonPropertyName("required")] public string[]? Required { get; set; }
}
public class InputSchemaValue
{
public string? type { get; set; }
public string? description { get; set; }
public InputSchemaValueItems? items { get; set; }
}
public class InputSchemaValueItems
{
public string? type { get; set; }
}

View File

@@ -0,0 +1,76 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public class AnthropicMessageContent
{
[JsonPropertyName("cache_control")] public AnthropicCacheControl? CacheControl { get; set; }
[JsonPropertyName("type")] public string Type { get; set; }
[JsonPropertyName("text")] public string? Text { get; set; }
[JsonPropertyName("tool_use_id")] public string? ToolUseId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("thinking")] public string? Thinking { get; set; }
[JsonPropertyName("input")] public object? Input { get; set; }
[JsonPropertyName("content")]
public object? Content
{
get
{
if (_content is not null && _contents is not null)
{
throw new ValidationException("Messages 中 Content 和 Contents 字段不能同时有值");
}
if (_content is not null)
{
return _content;
}
return _contents;
}
set
{
if (value is JsonElement str)
{
if (str.ValueKind == JsonValueKind.String)
{
_content = value?.ToString();
}
else if (str.ValueKind == JsonValueKind.Array)
{
_contents = JsonSerializer.Deserialize<List<AnthropicMessageContent>>(value?.ToString());
}
}
else
{
_content = value?.ToString();
}
}
}
private string? _content;
private List<AnthropicMessageContent>? _contents;
public class AnthropicMessageContentSource
{
[JsonPropertyName("type")] public string Type { get; set; }
[JsonPropertyName("media_type")] public string? MediaType { get; set; }
[JsonPropertyName("data")] public string? Data { get; set; }
}
[JsonPropertyName("source")] public AnthropicMessageContentSource? Source { get; set; }
}

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public class AnthropicMessageInput
{
[JsonPropertyName("role")]
public string Role { get; set; }
[JsonIgnore]
public string? Content;
[JsonPropertyName("content")]
public object? ContentCalculated
{
get
{
if (Content is not null && Contents is not null)
{
throw new ValidationException("Messages 中 Content 和 Contents 字段不能同时有值");
}
if (Content is not null)
{
return Content;
}
return Contents!;
}
set
{
if (value is JsonElement str)
{
if (str.ValueKind == JsonValueKind.String)
{
Content = value?.ToString();
}
else if (str.ValueKind == JsonValueKind.Array)
{
Contents = JsonSerializer.Deserialize<IList<AnthropicMessageContent>>(value?.ToString(),ThorJsonSerializer.DefaultOptions);
}
}
else
{
Content = value?.ToString();
}
}
}
[JsonIgnore]
public IList<AnthropicMessageContent>? Contents;
}

View File

@@ -0,0 +1,648 @@
using System.Text.Json;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public static class AnthropicToOpenAi
{
/// <summary>
/// 将AnthropicInput转换为ThorChatCompletionsRequest
/// </summary>
public static ThorChatCompletionsRequest ConvertAnthropicToOpenAi(AnthropicInput anthropicInput)
{
var openAiRequest = new ThorChatCompletionsRequest
{
Model = anthropicInput.Model,
MaxTokens = anthropicInput.MaxTokens,
Stream = anthropicInput.Stream,
Messages = new List<ThorChatMessage>(anthropicInput.Messages.Count)
};
// high medium minimal low
if (openAiRequest.Model.EndsWith("-high") ||
openAiRequest.Model.EndsWith("-medium") ||
openAiRequest.Model.EndsWith("-minimal") ||
openAiRequest.Model.EndsWith("-low"))
{
openAiRequest.ReasoningEffort = openAiRequest.Model switch
{
var model when model.EndsWith("-high") => "high",
var model when model.EndsWith("-medium") => "medium",
var model when model.EndsWith("-minimal") => "minimal",
var model when model.EndsWith("-low") => "low",
_ => "medium"
};
openAiRequest.Model = openAiRequest.Model.Replace("-high", "")
.Replace("-medium", "")
.Replace("-minimal", "")
.Replace("-low", "");
}
if (anthropicInput.Thinking != null &&
anthropicInput.Thinking.Type.Equals("enabled", StringComparison.OrdinalIgnoreCase))
{
openAiRequest.Thinking = new ThorChatClaudeThinking()
{
BudgetToken = anthropicInput.Thinking.BudgetTokens,
Type = "enabled",
};
openAiRequest.EnableThinking = true;
}
if (openAiRequest.Model.EndsWith("-thinking"))
{
openAiRequest.EnableThinking = true;
openAiRequest.Model = openAiRequest.Model.Replace("-thinking", "");
}
if (openAiRequest.Stream == true)
{
openAiRequest.StreamOptions = new ThorStreamOptions()
{
IncludeUsage = true,
};
}
if (!string.IsNullOrEmpty(anthropicInput.System))
{
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(anthropicInput.System));
}
if (anthropicInput.Systems?.Count > 0)
{
foreach (var systemContent in anthropicInput.Systems)
{
openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(systemContent.Text ?? string.Empty));
}
}
// 处理messages
if (anthropicInput.Messages != null)
{
foreach (var message in anthropicInput.Messages)
{
var thorMessages = ConvertAnthropicMessageToThor(message);
// 需要过滤 空消息
if (thorMessages.Count == 0)
{
continue;
}
openAiRequest.Messages.AddRange(thorMessages);
}
openAiRequest.Messages = openAiRequest.Messages
.Where(m => !string.IsNullOrEmpty(m.Content) || m.Contents?.Count > 0 || m.ToolCalls?.Count > 0 ||
!string.IsNullOrEmpty(m.ToolCallId))
.ToList();
}
// 处理tools
if (anthropicInput.Tools is { Count: > 0 })
{
openAiRequest.Tools = anthropicInput.Tools.Where(x => x.name != "web_search")
.Select(ConvertAnthropicToolToThor).ToList();
}
// 判断是否存在web_search
if (anthropicInput.Tools?.Any(x => x.name == "web_search") == true)
{
openAiRequest.WebSearchOptions = new ThorChatWebSearchOptions()
{
};
}
// 处理tool_choice
if (anthropicInput.ToolChoice != null)
{
openAiRequest.ToolChoice = ConvertAnthropicToolChoiceToThor(anthropicInput.ToolChoice);
}
return openAiRequest;
}
/// <summary>
/// 根据最后的内容块类型和OpenAI的完成原因确定Claude的停止原因
/// </summary>
public static string GetStopReasonByLastContentType(string? openAiFinishReason, string lastContentBlockType)
{
// 如果最后一个内容块是工具调用优先返回tool_use
if (lastContentBlockType == "tool_use")
{
return "tool_use";
}
// 否则使用标准的转换逻辑
return GetClaudeStopReason(openAiFinishReason);
}
/// <summary>
/// 创建message_start事件
/// </summary>
public static AnthropicStreamDto CreateMessageStartEvent(string messageId, string model)
{
return new AnthropicStreamDto
{
Type = "message_start",
Message = new AnthropicChatCompletionDto
{
id = messageId,
type = "message",
role = "assistant",
model = model,
content = new AnthropicChatCompletionDtoContent[0],
Usage = new AnthropicCompletionDtoUsage
{
InputTokens = 0,
OutputTokens = 0,
CacheCreationInputTokens = 0,
CacheReadInputTokens = 0
}
}
};
}
/// <summary>
/// 创建content_block_start事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockStartEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "text",
Id = null,
Name = null
}
};
}
/// <summary>
/// 创建thinking block start事件
/// </summary>
public static AnthropicStreamDto CreateThinkingBlockStartEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "thinking",
Id = null,
Name = null
}
};
}
/// <summary>
/// 创建content_block_delta事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockDeltaEvent(string text)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "text_delta",
Text = text
}
};
}
/// <summary>
/// 创建thinking delta事件
/// </summary>
public static AnthropicStreamDto CreateThinkingBlockDeltaEvent(string thinking)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "thinking",
Thinking = thinking
}
};
}
/// <summary>
/// 创建content_block_stop事件
/// </summary>
public static AnthropicStreamDto CreateContentBlockStopEvent()
{
return new AnthropicStreamDto
{
Type = "content_block_stop",
Index = 0
};
}
/// <summary>
/// 创建message_delta事件
/// </summary>
public static AnthropicStreamDto CreateMessageDeltaEvent(string finishReason, AnthropicCompletionDtoUsage usage)
{
return new AnthropicStreamDto
{
Type = "message_delta",
Usage = usage,
Delta = new AnthropicChatCompletionDtoDelta
{
StopReason = finishReason
}
};
}
/// <summary>
/// 创建message_stop事件
/// </summary>
public static AnthropicStreamDto CreateMessageStopEvent()
{
return new AnthropicStreamDto
{
Type = "message_stop"
};
}
/// <summary>
/// 创建tool block start事件
/// </summary>
public static AnthropicStreamDto CreateToolBlockStartEvent(string? toolId, string? toolName)
{
return new AnthropicStreamDto
{
Type = "content_block_start",
Index = 0,
ContentBlock = new AnthropicChatCompletionDtoContentBlock
{
Type = "tool_use",
Id = toolId,
Name = toolName
}
};
}
/// <summary>
/// 创建tool delta事件
/// </summary>
public static AnthropicStreamDto CreateToolBlockDeltaEvent(string partialJson)
{
return new AnthropicStreamDto
{
Type = "content_block_delta",
Index = 0,
Delta = new AnthropicChatCompletionDtoDelta
{
Type = "input_json_delta",
PartialJson = partialJson
}
};
}
/// <summary>
/// 转换Anthropic消息为Thor消息列表
/// </summary>
public static List<ThorChatMessage> ConvertAnthropicMessageToThor(AnthropicMessageInput anthropicMessage)
{
var results = new List<ThorChatMessage>();
// 处理简单的字符串内容
if (anthropicMessage.Content != null)
{
var thorMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Content = anthropicMessage.Content
};
results.Add(thorMessage);
return results;
}
// 处理多模态内容
if (anthropicMessage.Contents is { Count: > 0 })
{
var currentContents = new List<ThorChatMessageContent>();
var currentToolCalls = new List<ThorToolCall>();
foreach (var content in anthropicMessage.Contents)
{
switch (content.Type)
{
case "text":
currentContents.Add(ThorChatMessageContent.CreateTextContent(content.Text ?? string.Empty));
break;
case "thinking" when !string.IsNullOrEmpty(content.Thinking):
results.Add(new ThorChatMessage()
{
ReasoningContent = content.Thinking
});
break;
case "image":
{
if (content.Source != null)
{
var imageUrl = content.Source.Type == "base64"
? $"data:{content.Source.MediaType};base64,{content.Source.Data}"
: content.Source.Data;
currentContents.Add(ThorChatMessageContent.CreateImageUrlContent(imageUrl ?? string.Empty));
}
break;
}
case "tool_use":
{
// 如果有普通内容,先创建内容消息
if (currentContents.Count > 0)
{
if (currentContents.Count == 1 && currentContents.Any(x => x.Type == "text"))
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ContentCalculated = currentContents.FirstOrDefault()?.Text ?? string.Empty
};
results.Add(contentMessage);
}
else
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
}
currentContents = new List<ThorChatMessageContent>();
}
// 收集工具调用
var toolCall = new ThorToolCall
{
Id = content.Id,
Type = "function",
Function = new ThorChatMessageFunction
{
Name = content.Name,
Arguments = JsonSerializer.Serialize(content.Input)
}
};
currentToolCalls.Add(toolCall);
break;
}
case "tool_result":
{
// 如果有普通内容,先创建内容消息
if (currentContents.Count > 0)
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
currentContents = [];
}
// 如果有工具调用,先创建工具调用消息
if (currentToolCalls.Count > 0)
{
var toolCallMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ToolCalls = currentToolCalls
};
results.Add(toolCallMessage);
currentToolCalls = new List<ThorToolCall>();
}
// 创建工具结果消息
var toolMessage = new ThorChatMessage
{
Role = "tool",
ToolCallId = content.ToolUseId,
Content = content.Content?.ToString() ?? string.Empty
};
results.Add(toolMessage);
break;
}
}
}
// 处理剩余的内容
if (currentContents.Count > 0)
{
var contentMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
Contents = currentContents
};
results.Add(contentMessage);
}
// 处理剩余的工具调用
if (currentToolCalls.Count > 0)
{
var toolCallMessage = new ThorChatMessage
{
Role = anthropicMessage.Role,
ToolCalls = currentToolCalls
};
results.Add(toolCallMessage);
}
}
// 如果没有任何内容,返回一个空的消息
if (results.Count == 0)
{
results.Add(new ThorChatMessage
{
Role = anthropicMessage.Role,
Content = string.Empty
});
}
// 如果只有一个text则使用content字段
if (results is [{ Contents.Count: 1 }] &&
results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Type == "text" &&
!string.IsNullOrEmpty(results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text))
{
return
[
new ThorChatMessage
{
Role = results[0].Role,
Content = results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text ?? string.Empty
}
];
}
return results;
}
/// <summary>
/// 转换Anthropic工具为Thor工具
/// </summary>
public static ThorToolDefinition ConvertAnthropicToolToThor(AnthropicMessageTool anthropicTool)
{
IDictionary<string, ThorToolFunctionPropertyDefinition> values =
new Dictionary<string, ThorToolFunctionPropertyDefinition>();
if (anthropicTool.InputSchema?.Properties != null)
{
foreach (var property in anthropicTool.InputSchema.Properties)
{
if (property.Value?.description != null)
{
var definitionType = new ThorToolFunctionPropertyDefinition()
{
Description = property.Value.description,
Type = property.Value.type
};
if (property.Value?.items?.type != null)
{
definitionType.Items = new ThorToolFunctionPropertyDefinition()
{
Type = property.Value.items.type
};
}
values.Add(property.Key, definitionType);
}
}
}
return new ThorToolDefinition
{
Type = "function",
Function = new ThorToolFunctionDefinition
{
Name = anthropicTool.name,
Description = anthropicTool.Description,
Parameters = new ThorToolFunctionPropertyDefinition
{
Type = anthropicTool.InputSchema?.Type ?? "object",
Properties = values,
Required = anthropicTool.InputSchema?.Required
}
}
};
}
/// <summary>
/// 将OpenAI的完成原因转换为Claude的停止原因
/// </summary>
public static string GetClaudeStopReason(string? openAIFinishReason)
{
return openAIFinishReason switch
{
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
"content_filter" => "stop_sequence",
_ => "end_turn"
};
}
/// <summary>
/// 将OpenAI响应转换为Claude响应格式
/// </summary>
public static AnthropicChatCompletionDto ConvertOpenAIToClaude(ThorChatCompletionsResponse openAIResponse,
AnthropicInput originalRequest)
{
var claudeResponse = new AnthropicChatCompletionDto
{
id = openAIResponse.Id,
type = "message",
role = "assistant",
model = openAIResponse.Model ?? originalRequest.Model,
stop_reason = GetClaudeStopReason(openAIResponse.Choices?.FirstOrDefault()?.FinishReason),
stop_sequence = "",
content = []
};
if (openAIResponse.Choices is { Count: > 0 })
{
var choice = openAIResponse.Choices.First();
var contents = new List<AnthropicChatCompletionDtoContent>();
if (!string.IsNullOrEmpty(choice.Message.Content) && !string.IsNullOrEmpty(choice.Message.ReasoningContent))
{
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "thinking",
Thinking = choice.Message.ReasoningContent
});
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "text",
text = choice.Message.Content
});
}
else
{
// 处理思维内容
if (!string.IsNullOrEmpty(choice.Message.ReasoningContent))
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "thinking",
Thinking = choice.Message.ReasoningContent
});
// 处理文本内容
if (!string.IsNullOrEmpty(choice.Message.Content))
contents.Add(new AnthropicChatCompletionDtoContent
{
type = "text",
text = choice.Message.Content
});
}
// 处理工具调用
if (choice.Message.ToolCalls is { Count: > 0 })
contents.AddRange(choice.Message.ToolCalls.Select(toolCall => new AnthropicChatCompletionDtoContent
{
type = "tool_use", id = toolCall.Id, name = toolCall.Function?.Name,
input = JsonSerializer.Deserialize<object>(toolCall.Function?.Arguments ?? "{}")
}));
claudeResponse.content = contents.ToArray();
}
// 处理使用情况统计 - 确保始终提供Usage信息
claudeResponse.Usage = new AnthropicCompletionDtoUsage
{
InputTokens = openAIResponse.Usage?.PromptTokens ?? 0,
OutputTokens = (int?)(openAIResponse.Usage?.CompletionTokens ?? 0),
CacheCreationInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0,
CacheReadInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0
};
return claudeResponse;
}
/// <summary>
/// 转换Anthropic工具选择为Thor工具选择
/// </summary>
public static ThorToolChoice ConvertAnthropicToolChoiceToThor(AnthropicTooChoiceInput anthropicToolChoice)
{
return new ThorToolChoice
{
Type = anthropicToolChoice.Type ?? "auto",
Function = anthropicToolChoice.Name != null
? new ThorToolChoiceFunctionTool { Name = anthropicToolChoice.Name }
: null
};
}
}

View File

@@ -0,0 +1,14 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
public static class ThorJsonSerializer
{
public static JsonSerializerOptions DefaultOptions => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}

View File

@@ -28,64 +28,7 @@ public class ThorChatCompletionsRequest
/// </summary>
[JsonPropertyName("messages")]
public List<ThorChatMessage>? Messages { get; set; }
/// <summary>
/// 兼容-代码补全
/// </summary>
[JsonPropertyName("suffix")]
public string? Suffix { get; set; }
/// <summary>
/// 兼容-代码补全
/// </summary>
[JsonPropertyName("prompt")]
public string? Prompt { get; set; }
private const string CodeCompletionPrompt = """
You are a code modification assistant. Your task is to modify the provided code based on the user's instructions.
Rules:
1. Return only the modified code, with no additional text or explanations.
2. The first character of your response must be the first character of the code.
3. The last character of your response must be the last character of the code.
4. NEVER use triple backticks (```) or any other markdown formatting in your response.
5. Do not use any code block indicators, syntax highlighting markers, or any other formatting characters.
6. Present the code exactly as it would appear in a plain text editor, preserving all whitespace, indentation, and line breaks.
7. Maintain the original code structure and only make changes as specified by the user's instructions.
8. Ensure that the modified code is syntactically and semantically correct for the given programming language.
9. Use consistent indentation and follow language-specific style guidelines.
10. If the user's request cannot be translated into code changes, respond only with the word NULL (without quotes or any formatting).
11. Do not include any comments or explanations within the code unless specifically requested.
12. Assume that any necessary dependencies or libraries are already imported or available.
IMPORTANT: Your response must NEVER begin or end with triple backticks, single backticks, or any other formatting characters.
The relevant context before the current editing content is: {0}.
After the current editing content is: {1}.
""";
/// <summary>
/// 兼容代码补全
/// </summary>
public void CompatibleCodeCompletion()
{
if (Messages is null || !Messages.Any())
{
//兼容代码补全模式Prompt为当前代码前内容Suffix为当前代码后内容
Messages = new List<ThorChatMessage>()
{
new ThorChatMessage
{
Role = "system",
Content = string.Format(CodeCompletionPrompt, Prompt, Suffix)
}
};
}
Suffix = null;
Prompt = null;
}
/// <summary>
/// 模型唯一编码值,如 gpt-4gpt-3.5-turbo,moonshot-v1-8k看底层具体平台定义
/// </summary>
@@ -336,13 +279,10 @@ public class ThorChatCompletionsRequest
[JsonPropertyName("thinking")] public ThorChatClaudeThinking? Thinking { get; set; }
/// <summary>
/// 参数验证
/// </summary>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public IEnumerable<ValidationResult> Validate()
{
throw new NotImplementedException();
}
[JsonPropertyName("enable_thinking")] public bool? EnableThinking { get; set; }
[JsonPropertyName("web_search_options")]
public ThorChatWebSearchOptions? WebSearchOptions { get; set; } = null;
[JsonPropertyName("reasoning_effort")] public string? ReasoningEffort { get; set; }
}

View File

@@ -60,4 +60,19 @@ public record ThorChatCompletionsResponse
/// </summary>
[JsonPropertyName("error")]
public ThorError? Error { get; set; }
public void SupplementalMultiplier(double multiplier)
{
if (this.Usage is not null)
{
this.Usage.InputTokens =
(int)Math.Round((this.Usage.InputTokens ?? 0) * multiplier);
this.Usage.OutputTokens =
(int)Math.Round((this.Usage.OutputTokens ?? 0) * multiplier);
this.Usage.CompletionTokens =
(int)Math.Round((this.Usage.CompletionTokens ?? 0) * multiplier);
this.Usage.PromptTokens =
(int)Math.Round((this.Usage.PromptTokens ?? 0) * multiplier);
}
}
}

View File

@@ -51,16 +51,17 @@ public class ThorChatMessage
/// <summary>
/// 发出的消息内容计算用于json序列号和反序列化Content 和 Contents 不能同时赋值,只能二选一
/// 如果是工具调用,还真可能为空
/// </summary>
[JsonPropertyName("content")]
public object ContentCalculated
public object? ContentCalculated
{
get
{
if (Content is not null && Contents is not null)
{
throw new ValidationException("Messages 中 Content 和 Contents 字段不能同时有值");
}
// if (Content is not null && Contents is not null)
// {
// throw new ValidationException("Messages 中 Content 和 Contents 字段不能同时有值");
// }
if (Content is not null)
{

View File

@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
public class ThorChatWebSearchOptions
{
[JsonPropertyName("search_context_size")]
public string? SearchContextSize { get; set; }
[JsonPropertyName("user_location")]
public ThorUserLocation? UserLocation { get; set; }
}
public sealed class ThorUserLocation
{
[JsonPropertyName("type")] public required string Type { get; set; }
[JsonPropertyName("approximate")]
public ThorUserLocationApproximate? Approximate { get; set; }
}
public sealed class ThorUserLocationApproximate
{
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("country")]
public string? Country { get; set; }
[JsonPropertyName("region")]
public string? Region { get; set; }
[JsonPropertyName("timezone")]
public string? Timezone { get; set; }
}

View File

@@ -30,5 +30,5 @@ public class ThorToolFunctionDefinition
/// documentation about the format.
/// </summary>
[JsonPropertyName("parameters")]
public ThorToolFunctionPropertyDefinition Parameters { get; set; }
public ThorToolFunctionPropertyDefinition? Parameters { get; set; }
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
@@ -25,37 +26,77 @@ public class ThorToolFunctionPropertyDefinition
/// 表示字符串类型的函数对象
/// </summary>
String,
/// <summary>
/// 表示整数类型的函数对象
/// </summary>
Integer,
/// <summary>
/// 表示数字(包括浮点数等)类型的函数对象
/// </summary>
Number,
/// <summary>
/// 表示对象类型的函数对象
/// </summary>
Object,
/// <summary>
/// 表示数组类型的函数对象
/// </summary>
Array,
/// <summary>
/// 表示布尔类型的函数对象
/// </summary>
Boolean,
/// <summary>
/// 表示空值类型的函数对象
/// </summary>
Null
}
public string typeStr = "object";
public string[] Types;
/// <summary>
/// 必填的。函数参数对象类型。默认值为“object”。
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "object";
public object Type
{
get
{
if (Types is { Length: > 0 })
{
return Types;
}
return typeStr;
}
set
{
if (value is JsonElement str)
{
switch (str.ValueKind)
{
case JsonValueKind.String:
typeStr = value?.ToString();
break;
case JsonValueKind.Array:
Types = JsonSerializer.Deserialize<string[]>(value?.ToString());
break;
}
}
else
{
typeStr = value?.ToString();
}
}
}
/// <summary>
/// 可选。“函数参数”列表,作为从参数名称映射的字典
@@ -68,13 +109,13 @@ public class ThorToolFunctionPropertyDefinition
/// 可选。列出必需的“function arguments”列表。
/// </summary>
[JsonPropertyName("required")]
public List<string>? Required { get; set; }
public string[]? Required { get; set; }
/// <summary>
/// 可选。是否允许附加属性。默认值为true。
/// </summary>
[JsonPropertyName("additionalProperties")]
public bool? AdditionalProperties { get; set; }
public object? AdditionalProperties { get; set; }
/// <summary>
/// 可选。参数描述。
@@ -219,11 +260,12 @@ public class ThorToolFunctionPropertyDefinition
/// <param name="description"></param>
/// <param name="enum"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineObject(IDictionary<string, ThorToolFunctionPropertyDefinition>? properties,
List<string>? required,
bool? additionalProperties,
string? description,
List<string>? @enum)
public static ThorToolFunctionPropertyDefinition DefineObject(
IDictionary<string, ThorToolFunctionPropertyDefinition>? properties,
string[]? required,
object? additionalProperties,
string? description,
List<string>? @enum)
{
return new ThorToolFunctionPropertyDefinition
{
@@ -242,7 +284,6 @@ public class ThorToolFunctionPropertyDefinition
/// </summary>
/// <param name="type">要转换的类型</param>
/// <returns>给定类型的字符串表示形式</returns>
public static string ConvertTypeToString(FunctionObjectTypes type)
{
return type switch

View File

@@ -0,0 +1,29 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
public enum SceneResultEnum
{
/// <summary>
/// 等待,用户未扫码
/// </summary>
Wait = 0,
/// <summary>
/// 已扫码完成登录
/// </summary>
Login = 1,
/// <summary>
/// 已扫码完成注册
/// </summary>
Register = 2,
/// <summary>
/// 已扫码完成绑定
/// </summary>
Bind = 3,
/// <summary>
/// 已过期
/// </summary>
Expired = 10
}

View File

@@ -0,0 +1,7 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
public enum SceneTypeEnum
{
LoginOrRegister,
Bind
}

View File

@@ -10,10 +10,16 @@ namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public class PriceAttribute : Attribute
{
public decimal Price { get; }
public PriceAttribute(double price)
public decimal ReferencePrice { get; }
public int ValidMonths { get; }
public PriceAttribute(double price, int validMonths, double referencePrice)
{
Price = (decimal)price;
ValidMonths = validMonths;
ReferencePrice = (decimal)referencePrice;
}
}
@@ -24,10 +30,60 @@ public class PriceAttribute : Attribute
public class DisplayNameAttribute : Attribute
{
public string DisplayName { get; }
public DisplayNameAttribute(string displayName)
public string ChineseName { get; }
public string Remark { get; }
public DisplayNameAttribute(string displayName, string chineseName = "", string remark = "")
{
DisplayName = displayName;
ChineseName = chineseName;
Remark = remark;
}
}
/// <summary>
/// 商品类型特性
/// 用于标识商品是VIP服务还是尊享包服务
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class GoodsCategoryAttribute : Attribute
{
public GoodsCategoryType Category { get; }
public GoodsCategoryAttribute(GoodsCategoryType category)
{
Category = category;
}
}
/// <summary>
/// 商品类别类型
/// </summary>
public enum GoodsCategoryType
{
/// <summary>
/// VIP服务
/// </summary>
Vip = 1,
/// <summary>
/// 尊享包服务
/// </summary>
PremiumPackage = 2
}
/// <summary>
/// Token数量特性
/// 用于标识尊享包的token数量
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class TokenAmountAttribute : Attribute
{
public long TokenAmount { get; }
public TokenAmountAttribute(long tokenAmount)
{
TokenAmount = tokenAmount;
}
}
@@ -36,25 +92,33 @@ public class DisplayNameAttribute : Attribute
/// </summary>
public enum GoodsTypeEnum
{
[Price(0.01)]
[DisplayName("YiXinVip Test")]
YiXinVipTest = 0,
[Price(29.9)]
[DisplayName("YiXinVip 1 month")]
// VIP服务
[Price(29.9, 1, 29.9)] [DisplayName("YiXinVip 1 month", "1个月", "灵活选择")] [GoodsCategory(GoodsCategoryType.Vip)]
YiXinVip1 = 1,
[Price(80.7)]
[DisplayName("YiXinVip 3 month")]
[Price(83.7, 3, 27.9)] [DisplayName("YiXinVip 3 month", "3个月", "短期体验")] [GoodsCategory(GoodsCategoryType.Vip)]
YiXinVip3 = 3,
[Price(143.9)]
[DisplayName("YiXinVip 6 month")]
[Price(155.4, 6, 25.9)] [DisplayName("YiXinVip 6 month", "6个月", "年度热销")] [GoodsCategory(GoodsCategoryType.Vip)]
YiXinVip6 = 6,
[Price(199.9)]
[DisplayName("YiXinVip 10 month")]
YiXinVip10 = 10
[Price(183.2, 8, 22.9)]
[DisplayName("YiXinVip 8 month", "8个月推荐", "限时活动,超高性价比")]
[GoodsCategory(GoodsCategoryType.Vip)]
YiXinVip8 = 8,
// 尊享包服务 - 需要VIP资格才能购买
[Price(188.9, 0, 1750)]
[DisplayName("YiXinPremiumPackage 5000W Tokens", "5000万Tokens", "简单尝试")]
[GoodsCategory(GoodsCategoryType.PremiumPackage)]
[TokenAmount(50000000)]
PremiumPackage5000W = 101,
[Price(248.9, 0, 3500)]
[DisplayName("YiXinPremiumPackage 10000W Tokens", "1亿Tokens推荐", "极致性价比")]
[GoodsCategory(GoodsCategoryType.PremiumPackage)]
[TokenAmount(100000000)]
PremiumPackage10000W = 102,
}
public static class GoodsTypeEnumExtensions
@@ -70,7 +134,7 @@ public static class GoodsTypeEnumExtensions
var priceAttribute = fieldInfo?.GetCustomAttribute<PriceAttribute>();
return priceAttribute?.Price ?? 0m;
}
/// <summary>
/// 获取商品价格描述
/// </summary>
@@ -81,7 +145,7 @@ public static class GoodsTypeEnumExtensions
var price = goodsType.GetTotalAmount();
return $"¥{price:F1}";
}
/// <summary>
/// 获取商品名称
/// </summary>
@@ -93,4 +157,146 @@ public static class GoodsTypeEnumExtensions
var displayNameAttribute = fieldInfo?.GetCustomAttribute<DisplayNameAttribute>();
return displayNameAttribute?.DisplayName ?? goodsType.ToString();
}
}
/// <summary>
/// 获取商品有效月份
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>有效月份</returns>
public static int GetValidMonths(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var priceAttribute = fieldInfo?.GetCustomAttribute<PriceAttribute>();
return priceAttribute?.ValidMonths ?? 1;
}
/// <summary>
/// 获取商品月均价格
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>月均价格</returns>
public static decimal GetMonthlyPrice(this GoodsTypeEnum goodsType)
{
var totalPrice = goodsType.GetTotalAmount();
var validMonths = goodsType.GetValidMonths();
return validMonths > 0 ? totalPrice / validMonths : 0m;
}
/// <summary>
/// 获取商品参考价格
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>参考价格</returns>
public static decimal GetReferencePrice(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var priceAttribute = fieldInfo?.GetCustomAttribute<PriceAttribute>();
return priceAttribute?.ReferencePrice ?? 0m;
}
/// <summary>
/// 获取商品类别
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>商品类别</returns>
public static GoodsCategoryType GetGoodsCategory(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var categoryAttribute = fieldInfo?.GetCustomAttribute<GoodsCategoryAttribute>();
return categoryAttribute?.Category ?? GoodsCategoryType.Vip;
}
/// <summary>
/// 是否为尊享包商品
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>是否为尊享包</returns>
public static bool IsPremiumPackage(this GoodsTypeEnum goodsType)
{
return goodsType.GetGoodsCategory() == GoodsCategoryType.PremiumPackage;
}
/// <summary>
/// 是否为VIP服务商品
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>是否为VIP服务</returns>
public static bool IsVipService(this GoodsTypeEnum goodsType)
{
return goodsType.GetGoodsCategory() == GoodsCategoryType.Vip;
}
/// <summary>
/// 获取尊享包Token数量
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>Token数量</returns>
public static long GetTokenAmount(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var tokenAttribute = fieldInfo?.GetCustomAttribute<TokenAmountAttribute>();
return tokenAttribute?.TokenAmount ?? 0;
}
/// <summary>
/// 获取商品中文名称
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>中文名称</returns>
public static string GetChineseName(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var displayNameAttribute = fieldInfo?.GetCustomAttribute<DisplayNameAttribute>();
return displayNameAttribute?.ChineseName ?? goodsType.ToString();
}
/// <summary>
/// 获取商品备注
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <returns>备注信息</returns>
public static string GetRemark(this GoodsTypeEnum goodsType)
{
var fieldInfo = goodsType.GetType().GetField(goodsType.ToString());
var displayNameAttribute = fieldInfo?.GetCustomAttribute<DisplayNameAttribute>();
return displayNameAttribute?.Remark ?? string.Empty;
}
/// <summary>
/// 计算折扣金额(仅用于尊享包)
/// 规则每累加充值10元减少2.5元最多减少50元
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <param name="totalRechargeAmount">用户累加充值金额</param>
/// <returns>折扣金额</returns>
public static decimal CalculateDiscount(this GoodsTypeEnum goodsType, decimal totalRechargeAmount)
{
// 只有尊享包才有折扣
if (!goodsType.IsPremiumPackage())
{
return 0m;
}
// 每10元减2.5元
var discountAmount = Math.Floor(totalRechargeAmount / 2.5m);
// 最多减少50元
return Math.Min(discountAmount, 50m);
}
/// <summary>
/// 获取折扣后的价格(仅用于尊享包)
/// </summary>
/// <param name="goodsType">商品类型</param>
/// <param name="totalRechargeAmount">用户累加充值金额</param>
/// <returns>折扣后的价格</returns>
public static decimal GetDiscountedPrice(this GoodsTypeEnum goodsType, decimal totalRechargeAmount)
{
var originalPrice = goodsType.GetTotalAmount();
var discount = goodsType.CalculateDiscount(totalRechargeAmount);
var discountedPrice = originalPrice - discount;
// 确保价格不为负数至少为0.01元
return Math.Round(discountedPrice, 2);
}
}

View File

@@ -0,0 +1,7 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum ModelApiTypeEnum
{
OpenAi,
Claude
}

View File

@@ -4,5 +4,6 @@ public enum ModelTypeEnum
{
Chat = 0,
Image = 1,
Embedding = 2
Embedding = 2,
PremiumChat = 3
}

View File

@@ -0,0 +1,7 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum RechargeTypeEnum
{
Vip = 10,
PremiumPackage = 20
}

View File

@@ -39,6 +39,12 @@ public static class HttpClientFactory
private static readonly ConcurrentDictionary<string, Lazy<List<HttpClient>>> HttpClientPool = new();
/// <summary>
/// 高并发下有问题
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
[Obsolete]
public static HttpClient GetHttpClient(string key)
{
return HttpClientPool.GetOrAdd(key, k => new Lazy<List<HttpClient>>(() =>
@@ -70,4 +76,4 @@ public static class HttpClientFactory
return clients;
})).Value[new Random().Next(0, PoolSize)];
}
}
}

View File

@@ -0,0 +1,29 @@
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
namespace Yi.Framework.AiHub.Domain.AiGateWay;
public interface IAnthropicChatCompletionService
{
/// <summary>
/// 非流式对话补全
/// </summary>
/// <param name="request">对话补全请求参数对象</param>
/// <param name="aiModelDescribe">平台参数对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns></returns>
Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default);
/// <summary>
/// 流式对话补全
/// </summary>
/// <param name="request">对话补全请求参数对象</param>
/// <param name="aiModelDescribe">平台参数对象</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns></returns>
IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default);
}

View File

@@ -1,8 +1,10 @@
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay;
public interface ISpecialCompatible
{
public void Compatible(ThorChatCompletionsRequest request);
public void AnthropicCompatible(AnthropicInput request);
}

View File

@@ -10,7 +10,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureDatabricks.Chats;
public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCompletionsService> logger)
public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCompletionsService> logger,IHttpClientFactory httpClientFactory)
: IChatCompletionService
{
private string GetAddress(AiModelDescribe? options, string model)
@@ -31,7 +31,7 @@ public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCo
chatCompletionCreate.StreamOptions = null;
var response = await HttpClientFactory.GetHttpClient(address).HttpRequestRaw(
var response = await httpClientFactory.CreateClient().HttpRequestRaw(
address,
chatCompletionCreate, options.ApiKey);
@@ -149,7 +149,7 @@ public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCo
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
var response = await HttpClientFactory.GetHttpClient(address).PostJsonAsync(
var response = await httpClientFactory.CreateClient().PostJsonAsync(
address,
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);

View File

@@ -10,7 +10,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Chats;
public class AzureOpenAiChatCompletionCompletionsService(ILogger<AzureOpenAiChatCompletionCompletionsService> logger)
public class AzureOpenAiChatCompletionCompletionsService(ILogger<AzureOpenAiChatCompletionCompletionsService> logger,IHttpClientFactory httpClientFactory)
: IChatCompletionService
{
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
@@ -21,12 +21,12 @@ public class AzureOpenAiChatCompletionCompletionsService(ILogger<AzureOpenAiChat
Activity.Current?.Source.StartActivity("Azure OpenAI 对话流式补全");
var url = AzureOpenAIFactory.GetAddress(options, chatCompletionCreate.Model);
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).HttpRequestRaw(url,
var response = await httpClientFactory.CreateClient().HttpRequestRaw(url,
chatCompletionCreate, options.ApiKey, "Api-Key");
openai?.SetTag("Model", chatCompletionCreate.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync();
@@ -86,7 +86,7 @@ public class AzureOpenAiChatCompletionCompletionsService(ILogger<AzureOpenAiChat
var url = AzureOpenAIFactory.GetAddress(options, chatCompletionCreate.Model);
var response =
await HttpClientFactory.GetHttpClient(options.Endpoint)
await httpClientFactory.CreateClient()
.PostJsonAsync(url, chatCompletionCreate, options.ApiKey, "Api-Key");
openai?.SetTag("Model", chatCompletionCreate.Model);

View File

@@ -5,7 +5,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Images;
public class AzureOpenAIServiceImageService : IImageService
public class AzureOpenAIServiceImageService(IHttpClientFactory httpClientFactory) : IImageService
{
public async Task<ImageCreateResponse> CreateImage(ImageCreateRequest imageCreate, AiModelDescribe? options = null,
CancellationToken cancellationToken = default(CancellationToken))
@@ -13,7 +13,7 @@ public class AzureOpenAIServiceImageService : IImageService
var createClient = AzureOpenAIFactory.CreateClient(options);
var client = createClient.GetImageClient(imageCreate.Model);
imageCreate.Size??="1024x1024";
// 将size字符串拆分为宽度和高度
var size = imageCreate.Size.Split('x');
if (size.Length != 2)
@@ -98,7 +98,7 @@ public class AzureOpenAIServiceImageService : IImageService
multipartContent.Add(new ByteArrayContent(imageEditCreateRequest.Image), "image",
imageEditCreateRequest.ImageName);
return await HttpClientFactory.GetHttpClient(url).PostFileAndReadAsAsync<ImageCreateResponse>(
return await httpClientFactory.CreateClient().PostFileAndReadAsAsync<ImageCreateResponse>(
url,
multipartContent, cancellationToken);
}

View File

@@ -0,0 +1,177 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorClaude.Chats;
public class AnthropicChatCompletionsService(
IHttpClientFactory httpClientFactory,
ILogger<AnthropicChatCompletionsService> logger)
: IAnthropicChatCompletionService
{
public const double ClaudeMultiplier = 1.3d;
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe options, AnthropicInput input,
CancellationToken cancellationToken = default)
{
using var openai =
Activity.Current?.Source.StartActivity("Claudia 对话补全");
if (string.IsNullOrEmpty(options.Endpoint))
{
options.Endpoint = "https://api.anthropic.com/";
}
var client = httpClientFactory.CreateClient();
var headers = new Dictionary<string, string>
{
{ "x-api-key", options.ApiKey },
{ "authorization", "Bearer " + options.ApiKey },
{ "anthropic-version", "2023-06-01" }
};
bool isThink = input.Model.EndsWith("-thinking");
input.Model = input.Model.Replace("-thinking", string.Empty);
if (input.MaxTokens is < 2048)
{
input.MaxTokens = 2048;
}
if (isThink && input.Thinking is null)
{
input.Thinking = new AnthropicThinkingInput()
{
Type = "enabled",
BudgetTokens = 4000
};
}
if (input.Thinking is not null && input.Thinking.BudgetTokens > 0 && input.MaxTokens != null)
{
if (input.Thinking.BudgetTokens > input.MaxTokens)
{
input.Thinking.BudgetTokens = input.MaxTokens.Value - 1;
if (input.Thinking.BudgetTokens > 63999)
{
input.Thinking.BudgetTokens = 63999;
}
}
}
var response =
await client.PostJsonAsync(options.Endpoint.TrimEnd('/') + "/v1/messages", input, string.Empty, headers);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new Exception( $"恭喜你运气爆棚遇到了错误尊享包对话异常StatusCode【{response.StatusCode}】Response【{error}】");
}
var value =
await response.Content.ReadFromJsonAsync<AnthropicChatCompletionDto>(ThorJsonSerializer.DefaultOptions,
cancellationToken: cancellationToken);
value.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
return value;
}
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe options,
AnthropicInput input,
CancellationToken cancellationToken = default)
{
using var openai =
Activity.Current?.Source.StartActivity("Claudia 对话补全");
if (string.IsNullOrEmpty(options.Endpoint))
{
options.Endpoint = "https://api.anthropic.com/";
}
var client = httpClientFactory.CreateClient();
var headers = new Dictionary<string, string>
{
{ "x-api-key", options.ApiKey },
{ "authorization", options.ApiKey },
{ "anthropic-version", "2023-06-01" }
};
var isThinking = input.Model.EndsWith("thinking");
input.Model = input.Model.Replace("-thinking", string.Empty);
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/v1/messages", input, string.Empty,
headers);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new Exception("OpenAI对话异常" + response.StatusCode);
}
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
string? line = string.Empty;
string? data = null;
string eventType = string.Empty;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
{
line += Environment.NewLine;
if (line.StartsWith('{'))
{
logger.LogInformation("OpenAI对话异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
line);
throw new Exception("OpenAI对话异常" + line);
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith("event:"))
{
eventType = line;
continue;
}
if (!line.StartsWith(OpenAIConstant.Data)) continue;
data = line[OpenAIConstant.Data.Length..].Trim();
var result = JsonSerializer.Deserialize<AnthropicStreamDto>(data,
ThorJsonSerializer.DefaultOptions);
result.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
yield return (eventType, result);
}
}
}

View File

@@ -0,0 +1,879 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorClaude.Chats;
public sealed class ClaudiaChatCompletionsService(
IHttpClientFactory httpClientFactory,
ILogger<ClaudiaChatCompletionsService> logger)
: IChatCompletionService
{
public List<ThorChatChoiceResponse> CreateResponse(AnthropicChatCompletionDto completionDto)
{
var response = new ThorChatChoiceResponse();
var chatMessage = new ThorChatMessage();
if (completionDto == null)
{
return new List<ThorChatChoiceResponse>();
}
if (completionDto.content.Any(x => x.type.Equals("thinking", StringComparison.OrdinalIgnoreCase)))
{
// 将推理字段合并到返回对象去
chatMessage.ReasoningContent = completionDto.content
.First(x => x.type.Equals("thinking", StringComparison.OrdinalIgnoreCase)).Thinking;
chatMessage.Role = completionDto.role;
chatMessage.Content = completionDto.content
.First(x => x.type.Equals("text", StringComparison.OrdinalIgnoreCase)).text;
}
else
{
chatMessage.Role = completionDto.role;
chatMessage.Content = completionDto.content
.FirstOrDefault()?.text;
}
response.Delta = chatMessage;
response.Message = chatMessage;
if (completionDto.content.Any(x => x.type.Equals("tool_use", StringComparison.OrdinalIgnoreCase)))
{
var toolUse = completionDto.content
.First(x => x.type.Equals("tool_use", StringComparison.OrdinalIgnoreCase));
chatMessage.ToolCalls =
[
new()
{
Id = toolUse.id,
Function = new ThorChatMessageFunction()
{
Name = toolUse.name,
Arguments = JsonSerializer.Serialize(toolUse.input,
ThorJsonSerializer.DefaultOptions),
},
Index = 0,
}
];
return
[
response
];
}
return new List<ThorChatChoiceResponse> { response };
}
private object CreateMessage(List<ThorChatMessage> messages, AiModelDescribe options)
{
var list = new List<object>();
foreach (var message in messages)
{
// 如果是图片
if (message.ContentCalculated is IList<ThorChatMessageContent> contentCalculated)
{
list.Add(new
{
role = message.Role,
content = (List<object>)contentCalculated.Select<ThorChatMessageContent, object>(x =>
{
if (x.Type == "text")
{
if ("true".Equals(options.ModelExtraInfo, StringComparison.OrdinalIgnoreCase))
{
return new
{
type = "text",
text = x.Text,
cache_control = new
{
type = "ephemeral"
}
};
}
return new
{
type = "text",
text = x.Text
};
}
var isBase64 = x.ImageUrl?.Url.StartsWith("http") == true;
if ("true".Equals(options.ModelExtraInfo, StringComparison.OrdinalIgnoreCase))
{
return new
{
type = "image",
source = new
{
type = isBase64 ? "base64" : "url",
media_type = "image/png",
data = x.ImageUrl?.Url,
},
cache_control = new
{
type = "ephemeral"
}
};
}
return new
{
type = "image",
source = new
{
type = isBase64 ? "base64" : "url",
media_type = "image/png",
data = x.ImageUrl?.Url,
}
};
})
});
}
else
{
if ("true".Equals(options.ModelExtraInfo, StringComparison.OrdinalIgnoreCase))
{
if (message.Role == "system")
{
list.Add(new
{
type = "text",
text = message.Content,
cache_control = new
{
type = "ephemeral"
}
});
}
else
{
list.Add(new
{
role = message.Role,
content = message.Content
});
}
}
else
{
if (message.Role == "system")
{
list.Add(new
{
type = "text",
text = message.Content
});
}
else if (message.Role == "tool")
{
list.Add(new
{
role = "user",
content = new List<object>
{
new
{
type = "tool_result",
tool_use_id = message.ToolCallId,
content = message.Content
}
}
});
}
else if (message.Role == "assistant")
{
// {
// "role": "assistant",
// "content": [
// {
// "type": "text",
// "text": "<thinking>I need to use get_weather, and the user wants SF, which is likely San Francisco, CA.</thinking>"
// },
// {
// "type": "tool_use",
// "id": "toolu_01A09q90qw90lq917835lq9",
// "name": "get_weather",
// "input": {
// "location": "San Francisco, CA",
// "unit": "celsius"
// }
// }
// ]
// },
if (message.ToolCalls?.Count > 0)
{
var content = new List<object>();
if (!string.IsNullOrEmpty(message.Content))
{
content.Add(new
{
type = "text",
text = message.Content
});
}
foreach (var toolCall in message.ToolCalls)
{
content.Add(new
{
type = "tool_use",
id = toolCall.Id,
name = toolCall.Function?.Name,
input = JsonSerializer.Deserialize<Dictionary<string, object>>(
toolCall.Function?.Arguments, ThorJsonSerializer.DefaultOptions)
});
}
list.Add(new
{
role = "assistant",
content
});
}
else
{
list.Add(new
{
role = "assistant",
content = new List<object>
{
new
{
type = "text",
text = message.Content
}
}
});
}
}
else
{
list.Add(new
{
role = message.Role,
content = message.Content
});
}
}
}
}
return list;
}
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
ThorChatCompletionsRequest input,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("Claudia 对话补全");
if (string.IsNullOrEmpty(options.Endpoint))
{
options.Endpoint = "https://api.anthropic.com/";
}
var client = httpClientFactory.CreateClient();
var headers = new Dictionary<string, string>
{
{ "x-api-key", options.ApiKey },
{ "anthropic-version", "2023-06-01" }
};
var isThinking = input.Model.EndsWith("thinking");
input.Model = input.Model.Replace("-thinking", string.Empty);
var budgetTokens = 1024;
if (input.MaxTokens is < 2048)
{
input.MaxTokens = 2048;
}
if (input.MaxTokens != null && input.MaxTokens / 2 < 1024)
{
budgetTokens = input.MaxTokens.Value / (4 * 3);
}
// budgetTokens最大4096
budgetTokens = Math.Min(budgetTokens, 4096);
object tool_choice;
if (input.ToolChoice is not null && input.ToolChoice.Type == "auto")
{
tool_choice = new
{
type = "auto",
disable_parallel_tool_use = false,
};
}
else if (input.ToolChoice is not null && input.ToolChoice.Type == "any")
{
tool_choice = new
{
type = "any",
disable_parallel_tool_use = false,
};
}
else if (input.ToolChoice is not null && input.ToolChoice.Type == "tool")
{
tool_choice = new
{
type = "tool",
name = input.ToolChoice.Function?.Name,
disable_parallel_tool_use = false,
};
}
else
{
tool_choice = null;
}
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/v1/messages", new
{
model = input.Model,
max_tokens = input.MaxTokens ?? 2048,
stream = true,
tool_choice,
system = CreateMessage(input.Messages.Where(x => x.Role == "system").ToList(), options),
messages = CreateMessage(input.Messages.Where(x => x.Role != "system").ToList(), options),
top_p = isThinking ? null : input.TopP,
thinking = isThinking
? new
{
type = "enabled",
budget_tokens = budgetTokens,
}
: null,
tools = input.Tools?.Select(x => new
{
name = x.Function?.Name,
description = x.Function?.Description,
input_schema = new
{
type = x.Function?.Parameters?.Type,
required = x.Function?.Parameters?.Required,
properties = x.Function?.Parameters?.Properties?.ToDictionary(y => y.Key, y => new
{
description = y.Value.Description,
type = y.Value.Type,
@enum = y.Value.Enum
})
}
}).ToArray(),
temperature = isThinking ? null : input.Temperature
}, string.Empty, headers);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new Exception("OpenAI对话异常" + response.StatusCode);
}
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
string? line = string.Empty;
var first = true;
var isThink = false;
string? toolId = null;
string? toolName = null;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
{
line += Environment.NewLine;
if (line.StartsWith('{'))
{
logger.LogInformation("OpenAI对话异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
line);
throw new Exception("OpenAI对话异常" + line);
}
if (line.StartsWith(OpenAIConstant.Data))
line = line[OpenAIConstant.Data.Length..];
line = line.Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
if (line == OpenAIConstant.Done)
{
break;
}
if (line.StartsWith(':'))
{
continue;
}
if (line.StartsWith("event: ", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var result = JsonSerializer.Deserialize<AnthropicStreamDto>(line,
ThorJsonSerializer.DefaultOptions);
if (result?.Type == "content_block_delta")
{
if (result.Delta.Type is "text" or "text_delta")
{
yield return new ThorChatCompletionsResponse()
{
Choices =
[
new()
{
Message = new ThorChatMessage()
{
Content = result.Delta.Text,
Role = "assistant",
}
}
],
Model = input.Model,
Id = result?.Message?.id,
Usage = new ThorUsageResponse()
{
CompletionTokens = result?.Message?.Usage?.OutputTokens,
PromptTokens = result?.Message?.Usage?.InputTokens,
}
};
}
else if (result.Delta.Type == "input_json_delta")
{
yield return new ThorChatCompletionsResponse()
{
Choices =
[
new ThorChatChoiceResponse()
{
Message = new ThorChatMessage()
{
ToolCalls =
[
new ThorToolCall()
{
Id = toolId,
Function = new ThorChatMessageFunction()
{
Name = toolName,
Arguments = result.Delta.PartialJson
}
}
],
Role = "tool",
}
}
],
Model = input.Model,
Usage = new ThorUsageResponse()
{
PromptTokens = result?.Message?.Usage?.InputTokens,
}
};
}
else
{
yield return new ThorChatCompletionsResponse()
{
Choices = new List<ThorChatChoiceResponse>()
{
new()
{
Message = new ThorChatMessage()
{
ReasoningContent = result.Delta.Thinking,
Role = "assistant",
}
}
},
Model = input.Model,
Id = result?.Message?.id,
Usage = new ThorUsageResponse()
{
CompletionTokens = result?.Message?.Usage?.OutputTokens,
PromptTokens = result?.Message?.Usage?.InputTokens
}
};
}
continue;
}
if (result?.Type == "content_block_start")
{
if (result?.ContentBlock?.Id is not null)
{
toolId = result.ContentBlock.Id;
}
if (result?.ContentBlock?.Name is not null)
{
toolName = result.ContentBlock.Name;
}
if (toolId is null)
{
continue;
}
yield return new ThorChatCompletionsResponse()
{
Choices =
[
new ThorChatChoiceResponse()
{
Message = new ThorChatMessage()
{
ToolCalls =
[
new ThorToolCall()
{
Id = toolId,
Function = new ThorChatMessageFunction()
{
Name = toolName
}
}
],
Role = "tool",
}
}
],
Model = input.Model,
Usage = new ThorUsageResponse()
{
PromptTokens = result?.Message?.Usage?.InputTokens,
}
};
}
if (result.Type == "content_block_delta")
{
yield return new ThorChatCompletionsResponse()
{
Choices =
[
new ThorChatChoiceResponse()
{
Message = new ThorChatMessage()
{
ToolCallId = result?.ContentBlock?.Id,
FunctionCall = new ThorChatMessageFunction()
{
Name = result?.ContentBlock?.Name,
Arguments = result?.Delta?.PartialJson
},
Role = "tool",
}
}
],
Model = input.Model,
Usage = new ThorUsageResponse()
{
PromptTokens = result?.Message?.Usage?.InputTokens
}
};
continue;
}
if (result.Type == "message_start")
{
yield return new ThorChatCompletionsResponse()
{
Choices =
[
new ThorChatChoiceResponse()
{
Message = new ThorChatMessage()
{
Content = result?.Delta?.Text,
Role = "assistant",
}
}
],
Model = input.Model,
Usage = new ThorUsageResponse()
{
PromptTokens = result?.Message?.Usage?.InputTokens,
}
};
continue;
}
if (result.Type == "message_delta")
{
var deltaOutput = new ThorChatCompletionsResponse()
{
Choices =
[
new ThorChatChoiceResponse()
{
Message = new ThorChatMessage()
{
Content = result.Delta?.Text,
Role = "assistant",
}
}
],
Model = input.Model,
Usage = new ThorUsageResponse
{
InputTokens = result.Usage?.InputTokens + result.Usage?.CacheCreationInputTokens +
result.Usage?.CacheReadInputTokens,
OutputTokens = result.Usage?.OutputTokens,
}
};
deltaOutput.Usage.PromptTokens = deltaOutput.Usage.InputTokens;
deltaOutput.Usage.CompletionTokens = deltaOutput.Usage.OutputTokens;
deltaOutput.Usage.TotalTokens = deltaOutput.Usage.InputTokens + deltaOutput.Usage.OutputTokens;
yield return deltaOutput;
continue;
}
if (result.Message == null)
{
continue;
}
var chat = CreateResponse(result.Message);
var content = chat?.FirstOrDefault()?.Delta;
if (first && string.IsNullOrWhiteSpace(content?.Content) && string.IsNullOrEmpty(content?.ReasoningContent))
{
continue;
}
if (first && content.Content == OpenAIConstant.ThinkStart)
{
isThink = true;
continue;
// 需要将content的内容转换到其他字段
}
if (isThink && content.Content.Contains(OpenAIConstant.ThinkEnd))
{
isThink = false;
// 需要将content的内容转换到其他字段
continue;
}
if (isThink)
{
// 需要将content的内容转换到其他字段
foreach (var choice in chat)
{
choice.Delta.ReasoningContent = choice.Delta.Content;
choice.Delta.Content = string.Empty;
}
}
first = false;
var output = new ThorChatCompletionsResponse()
{
Choices = chat,
Model = input.Model,
Id = result.Message.id,
Usage = new ThorUsageResponse()
{
InputTokens = result.Message.Usage?.InputTokens + result.Message.Usage?.CacheCreationInputTokens +
result.Message.Usage?.CacheReadInputTokens,
OutputTokens = result.Message.Usage?.OutputTokens,
}
};
output.Usage.PromptTokens = output.Usage.InputTokens;
output.Usage.CompletionTokens = output.Usage.OutputTokens;
output.Usage.TotalTokens = output.Usage.InputTokens + output.Usage.OutputTokens;
output.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
yield return output;
}
}
public async Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe options,
ThorChatCompletionsRequest input,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("Claudia 对话补全");
if (string.IsNullOrEmpty(options.Endpoint))
{
options.Endpoint = "https://api.anthropic.com/";
}
var client = httpClientFactory.CreateClient();
var headers = new Dictionary<string, string>
{
{ "x-api-key", options.ApiKey },
{ "anthropic-version", "2023-06-01" }
};
bool isThink = input.Model.EndsWith("-thinking");
input.Model = input.Model.Replace("-thinking", string.Empty);
var budgetTokens = 1024;
if (input.MaxTokens is < 2048)
{
input.MaxTokens = 2048;
}
if (input.MaxTokens != null && input.MaxTokens / 2 < 1024)
{
budgetTokens = input.MaxTokens.Value / (4 * 3);
}
object tool_choice;
if (input.ToolChoice is not null && input.ToolChoice.Type == "auto")
{
tool_choice = new
{
type = "auto",
disable_parallel_tool_use = false,
};
}
else if (input.ToolChoice is not null && input.ToolChoice.Type == "any")
{
tool_choice = new
{
type = "any",
disable_parallel_tool_use = false,
};
}
else if (input.ToolChoice is not null && input.ToolChoice.Type == "tool")
{
tool_choice = new
{
type = "tool",
name = input.ToolChoice.Function?.Name,
disable_parallel_tool_use = false,
};
}
else
{
tool_choice = null;
}
// budgetTokens最大4096
budgetTokens = Math.Min(budgetTokens, 4096);
var response = await client.PostJsonAsync(options.Endpoint.TrimEnd('/') + "/v1/messages", new
{
model = input.Model,
max_tokens = input.MaxTokens ?? 2000,
system = CreateMessage(input.Messages.Where(x => x.Role == "system").ToList(), options),
messages = CreateMessage(input.Messages.Where(x => x.Role != "system").ToList(), options),
top_p = isThink ? null : input.TopP,
tool_choice,
thinking = isThink
? new
{
type = "enabled",
budget_tokens = budgetTokens,
}
: null,
tools = input.Tools?.Select(x => new
{
name = x.Function?.Name,
description = x.Function?.Description,
input_schema = new
{
type = x.Function?.Parameters?.Type,
required = x.Function?.Parameters?.Required,
properties = x.Function?.Parameters?.Properties?.ToDictionary(y => y.Key, y => new
{
description = y.Value.Description,
type = y.Value.Type,
@enum = y.Value.Enum
})
}
}).ToArray(),
temperature = isThink ? null : input.Temperature
}, string.Empty, headers);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new Exception("OpenAI对话异常" + response.StatusCode.ToString());
}
var value =
await response.Content.ReadFromJsonAsync<AnthropicChatCompletionDto>(ThorJsonSerializer.DefaultOptions,
cancellationToken: cancellationToken);
var thor = new ThorChatCompletionsResponse()
{
Choices = CreateResponse(value),
Model = input.Model,
Id = value.id,
Usage = new ThorUsageResponse()
{
CompletionTokens = value.Usage.OutputTokens,
PromptTokens = value.Usage.InputTokens
}
};
if (value.Usage.CacheReadInputTokens != null)
{
thor.Usage.PromptTokensDetails ??= new ThorUsageResponsePromptTokensDetails()
{
CachedTokens = value.Usage.CacheReadInputTokens.Value,
};
if (value.Usage.InputTokens > 0)
{
thor.Usage.InputTokens = value.Usage.InputTokens;
}
if (value.Usage.OutputTokens > 0)
{
thor.Usage.CompletionTokens = value.Usage.OutputTokens;
thor.Usage.OutputTokens = value.Usage.OutputTokens;
}
}
thor.Usage.TotalTokens = thor.Usage.InputTokens + thor.Usage.OutputTokens;
thor.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
return thor;
}
}

View File

@@ -0,0 +1,313 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
/// <summary>
/// OpenAI到Claude适配器服务
/// 将Claude格式的请求转换为OpenAI格式然后将OpenAI的响应转换为Claude格式
/// </summary>
public class CustomOpenAIAnthropicChatCompletionsService(
IAbpLazyServiceProvider serviceProvider,
ILogger<CustomOpenAIAnthropicChatCompletionsService> logger)
: IAnthropicChatCompletionService
{
private IChatCompletionService GetChatCompletionService()
{
return serviceProvider.GetRequiredKeyedService<IChatCompletionService>(nameof(OpenAiChatCompletionsService));
}
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default)
{
// 转换请求格式Claude -> OpenAI
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
if (openAIRequest.Model.StartsWith("gpt-5"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
}
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
openAIRequest.Temperature = null;
}
// 调用OpenAI服务
var openAIResponse =
await GetChatCompletionService().CompleteChatAsync(aiModelDescribe,openAIRequest, cancellationToken);
// 转换响应格式OpenAI -> Claude
var claudeResponse = AnthropicToOpenAi.ConvertOpenAIToClaude(openAIResponse, request);
return claudeResponse;
}
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe,
AnthropicInput request,
CancellationToken cancellationToken = default)
{
var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request);
openAIRequest.Stream = true;
if (openAIRequest.Model.StartsWith("gpt-5"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
}
else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini"))
{
openAIRequest.MaxCompletionTokens = request.MaxTokens;
openAIRequest.MaxTokens = null;
openAIRequest.Temperature = null;
}
var messageId = Guid.NewGuid().ToString();
var hasStarted = false;
var hasTextContentBlockStarted = false;
var hasThinkingContentBlockStarted = false;
var toolBlocksStarted = new Dictionary<int, bool>(); // 使用索引而不是ID
var toolCallIds = new Dictionary<int, string>(); // 存储每个索引对应的ID
var toolCallIndexToBlockIndex = new Dictionary<int, int>(); // 工具调用索引到块索引的映射
var accumulatedUsage = new AnthropicCompletionDtoUsage();
var isFinished = false;
var currentContentBlockType = ""; // 跟踪当前内容块类型
var currentBlockIndex = 0; // 跟踪当前块索引
var lastContentBlockType = ""; // 跟踪最后一个内容块类型,用于确定停止原因
await foreach (var openAIResponse in GetChatCompletionService().CompleteChatStreamAsync(aiModelDescribe,openAIRequest,
cancellationToken))
{
// 发送message_start事件
if (!hasStarted && openAIResponse.Choices?.Count > 0 &&
openAIResponse.Choices.Any(x => x.Delta.ToolCalls?.Count > 0) == false)
{
hasStarted = true;
var messageStartEvent = AnthropicToOpenAi.CreateMessageStartEvent(messageId, request.Model);
yield return ("message_start", messageStartEvent);
}
// 更新使用情况统计
if (openAIResponse.Usage != null)
{
// 使用最新的token计数OpenAI通常在最后的响应中提供完整的统计
if (openAIResponse.Usage.PromptTokens.HasValue)
{
accumulatedUsage.InputTokens = openAIResponse.Usage.PromptTokens.Value;
}
if (openAIResponse.Usage.CompletionTokens.HasValue)
{
accumulatedUsage.OutputTokens = (int)openAIResponse.Usage.CompletionTokens.Value;
}
if (openAIResponse.Usage.PromptTokensDetails?.CachedTokens.HasValue == true)
{
accumulatedUsage.CacheReadInputTokens =
openAIResponse.Usage.PromptTokensDetails.CachedTokens.Value;
}
// 记录调试信息
logger.LogDebug("OpenAI Usage更新: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
accumulatedUsage.CacheReadInputTokens);
}
if (openAIResponse.Choices is { Count: > 0 })
{
var choice = openAIResponse.Choices.First();
// 处理内容
if (!string.IsNullOrEmpty(choice.Delta?.Content))
{
// 如果当前有其他类型的内容块在运行,先结束它们
if (currentContentBlockType != "text" && !string.IsNullOrEmpty(currentContentBlockType))
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 切换内容块时增加索引
currentContentBlockType = "";
}
// 发送content_block_start事件仅第一次
if (!hasTextContentBlockStarted || currentContentBlockType != "text")
{
hasTextContentBlockStarted = true;
currentContentBlockType = "text";
lastContentBlockType = "text";
var contentBlockStartEvent = AnthropicToOpenAi.CreateContentBlockStartEvent();
contentBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
contentBlockStartEvent);
}
// 发送content_block_delta事件
var contentDeltaEvent = AnthropicToOpenAi.CreateContentBlockDeltaEvent(choice.Delta.Content);
contentDeltaEvent.Index = currentBlockIndex;
yield return ("content_block_delta",
contentDeltaEvent);
}
// 处理工具调用
if (choice.Delta?.ToolCalls is { Count: > 0 })
{
foreach (var toolCall in choice.Delta.ToolCalls)
{
var toolCallIndex = toolCall.Index; // 使用索引来标识工具调用
// 发送tool_use content_block_start事件
if (toolBlocksStarted.TryAdd(toolCallIndex, true))
{
// 如果当前有文本或thinking内容块在运行先结束它们
if (currentContentBlockType == "text" || currentContentBlockType == "thinking")
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
}
// 如果当前有其他工具调用在运行,也需要结束它们
else if (currentContentBlockType == "tool_use")
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
}
currentContentBlockType = "tool_use";
lastContentBlockType = "tool_use";
// 为此工具调用分配一个新的块索引
toolCallIndexToBlockIndex[toolCallIndex] = currentBlockIndex;
// 保存工具调用的ID如果有的话
if (!string.IsNullOrEmpty(toolCall.Id))
{
toolCallIds[toolCallIndex] = toolCall.Id;
}
else if (!toolCallIds.ContainsKey(toolCallIndex))
{
// 如果没有ID且之前也没有保存过生成一个新的ID
toolCallIds[toolCallIndex] = Guid.NewGuid().ToString();
}
var toolBlockStartEvent = AnthropicToOpenAi.CreateToolBlockStartEvent(
toolCallIds[toolCallIndex],
toolCall.Function?.Name);
toolBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
toolBlockStartEvent);
}
// 如果有增量的参数发送content_block_delta事件
if (!string.IsNullOrEmpty(toolCall.Function?.Arguments))
{
var toolDeltaEvent =
AnthropicToOpenAi.CreateToolBlockDeltaEvent(toolCall.Function.Arguments);
// 使用该工具调用对应的块索引
toolDeltaEvent.Index = toolCallIndexToBlockIndex[toolCallIndex];
yield return ("content_block_delta",
toolDeltaEvent);
}
}
}
// 处理推理内容
if (!string.IsNullOrEmpty(choice.Delta?.ReasoningContent))
{
// 如果当前有其他类型的内容块在运行,先结束它们
if (currentContentBlockType != "thinking" && !string.IsNullOrEmpty(currentContentBlockType))
{
var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
stopEvent.Index = currentBlockIndex;
yield return ("content_block_stop", stopEvent);
currentBlockIndex++; // 增加块索引
currentContentBlockType = "";
}
// 对于推理内容,也需要发送对应的事件
if (!hasThinkingContentBlockStarted || currentContentBlockType != "thinking")
{
hasThinkingContentBlockStarted = true;
currentContentBlockType = "thinking";
lastContentBlockType = "thinking";
var thinkingBlockStartEvent = AnthropicToOpenAi.CreateThinkingBlockStartEvent();
thinkingBlockStartEvent.Index = currentBlockIndex;
yield return ("content_block_start",
thinkingBlockStartEvent);
}
var thinkingDeltaEvent =
AnthropicToOpenAi.CreateThinkingBlockDeltaEvent(choice.Delta.ReasoningContent);
thinkingDeltaEvent.Index = currentBlockIndex;
yield return ("content_block_delta",
thinkingDeltaEvent);
}
// 处理结束
if (!string.IsNullOrEmpty(choice.FinishReason) && !isFinished)
{
isFinished = true;
// 发送content_block_stop事件如果有活跃的内容块
if (!string.IsNullOrEmpty(currentContentBlockType))
{
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
contentBlockStopEvent.Index = currentBlockIndex;
yield return ("content_block_stop",
contentBlockStopEvent);
}
// 发送message_delta事件
var messageDeltaEvent = AnthropicToOpenAi.CreateMessageDeltaEvent(
AnthropicToOpenAi.GetStopReasonByLastContentType(choice.FinishReason, lastContentBlockType),
accumulatedUsage);
// 记录最终Usage统计
logger.LogDebug(
"流式响应结束最终Usage: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}",
accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens,
accumulatedUsage.CacheReadInputTokens);
yield return ("message_delta",
messageDeltaEvent);
// 发送message_stop事件
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
yield return ("message_stop",
messageStopEvent);
}
}
}
// 确保流正确结束
if (!isFinished)
{
if (!string.IsNullOrEmpty(currentContentBlockType))
{
var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent();
contentBlockStopEvent.Index = currentBlockIndex;
yield return ("content_block_stop",
contentBlockStopEvent);
}
var messageDeltaEvent =
AnthropicToOpenAi.CreateMessageDeltaEvent(
AnthropicToOpenAi.GetStopReasonByLastContentType("end_turn", lastContentBlockType),
accumulatedUsage);
yield return ("message_delta", messageDeltaEvent);
var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent();
yield return ("message_stop",
messageStopEvent);
}
}
}

View File

@@ -0,0 +1,167 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsService> logger,IHttpClientFactory httpClientFactory)
: IChatCompletionService
{
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
ThorChatCompletionsRequest chatCompletionCreate,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话流式补全");
var response = await httpClientFactory.CreateClient().HttpRequestRaw(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
chatCompletionCreate, options.ApiKey);
openai?.SetTag("Model", chatCompletionCreate.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException();
}
// 如果限流则抛出限流异常
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
throw new ThorRateLimitException();
}
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync();
logger.LogError("OpenAI对话异常 , StatusCode: {StatusCode} 错误响应内容:{Content}", response.StatusCode,
error);
throw new BusinessException("OpenAI对话异常" + error, response.StatusCode.ToString());
}
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
string? line = string.Empty;
var first = true;
var isThink = false;
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
line += Environment.NewLine;
if (line.StartsWith('{'))
{
logger.LogInformation("OpenAI对话异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
line);
throw new BusinessException("OpenAI对话异常", line);
}
if (line.StartsWith(OpenAIConstant.Data))
line = line[OpenAIConstant.Data.Length..];
line = line.Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
if (line == OpenAIConstant.Done)
{
break;
}
if (line.StartsWith(':'))
{
continue;
}
var result = JsonSerializer.Deserialize<ThorChatCompletionsResponse>(line,
ThorJsonSerializer.DefaultOptions);
if (result == null)
{
continue;
}
var content = result?.Choices?.FirstOrDefault()?.Delta;
if (first && content?.Content == OpenAIConstant.ThinkStart)
{
isThink = true;
continue;
// 需要将content的内容转换到其他字段
}
if (isThink && content?.Content?.Contains(OpenAIConstant.ThinkEnd) == true)
{
isThink = false;
// 需要将content的内容转换到其他字段
continue;
}
if (isThink && result?.Choices != null)
{
// 需要将content的内容转换到其他字段
foreach (var choice in result.Choices)
{
choice.Delta.ReasoningContent = choice.Delta.Content;
choice.Delta.Content = string.Empty;
}
}
first = false;
yield return result;
}
}
public async Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe options,
ThorChatCompletionsRequest chatCompletionCreate,
CancellationToken cancellationToken)
{
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
openai?.SetTag("Model", chatCompletionCreate.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new BusinessException("渠道未登录,请联系管理人员", "401");
}
// 如果限流则抛出限流异常
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
throw new ThorRateLimitException();
}
// 大于等于400的状态码都认为是异常
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
response.StatusCode, error);
throw new BusinessException("OpenAI对话异常", response.StatusCode.ToString());
}
var result =
await response.Content.ReadFromJsonAsync<ThorChatCompletionsResponse>(
cancellationToken: cancellationToken).ConfigureAwait(false);
return result;
}
}

View File

@@ -9,7 +9,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorDeepSeek.Chats;
public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletionsService> logger)
public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletionsService> logger,IHttpClientFactory httpClientFactory)
: IChatCompletionService
{
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
@@ -24,7 +24,7 @@ public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletio
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话流式补全");
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).HttpRequestRaw(
var response = await httpClientFactory.CreateClient().HttpRequestRaw(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
chatCompletionCreate, options.ApiKey);
@@ -142,7 +142,7 @@ public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletio
options.Endpoint = "https://api.deepseek.com/v1";
}
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync(
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);

View File

@@ -4,7 +4,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorSiliconFlow.Embeddings;
public sealed class SiliconFlowTextEmbeddingService
public sealed class SiliconFlowTextEmbeddingService(IHttpClientFactory httpClientFactory)
: ITextEmbeddingService
{
public async Task<EmbeddingCreateResponse> EmbeddingAsync(
@@ -12,7 +12,7 @@ public sealed class SiliconFlowTextEmbeddingService
AiModelDescribe? options = null,
CancellationToken cancellationToken = default)
{
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync(
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/v1/embeddings",
createEmbeddingModel, options!.ApiKey);

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay;
@@ -20,4 +21,12 @@ public class SpecialCompatible : ISpecialCompatible,ISingletonDependency
handle(request);
}
}
public void AnthropicCompatible(AnthropicInput request)
{
foreach (var handle in _options.Value.AnthropicHandles)
{
handle(request);
}
}
}

View File

@@ -1,8 +1,10 @@
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay;
public class SpecialCompatibleOptions
{
public List<Action<ThorChatCompletionsRequest>> Handles { get; set; } = new();
public List<Action<AnthropicInput>> AnthropicHandles { get; set; } = new();
}

View File

@@ -1,6 +1,7 @@
using Alipay.EasySDK.Factory;
using Alipay.EasySDK.Kernel.Util;
using Alipay.EasySDK.Payment.Page.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Volo.Abp.Domain.Services;
@@ -21,13 +22,13 @@ public class AlipayManager : DomainService
/// <returns></returns>
/// <exception cref="AlipayException"></exception>
public Task<AlipayTradePagePayResponse> PaymentPageAsync(string productName, string orderNumber,
decimal totalAmount)
decimal totalAmount, string? returnUrl)
{
try
{
// 2. 发起API调用以创建当面付收款二维码为例
var response = Factory.Payment.Page()
.Pay(productName, orderNumber, totalAmount.ToString(), "https://ccnetcore.com/pay/sucess");
.Pay(productName, orderNumber, totalAmount.ToString(), returnUrl ?? string.Empty);
// 3. 处理响应或异常
if (ResponseChecker.Success(response))
{

View File

@@ -1,5 +1,6 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Domain.Entities;
@@ -13,7 +14,7 @@ public class AiRechargeAggregateRoot : FullAuditedAggregateRoot<Guid>
/// 充值金额
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 用户
/// </summary>
@@ -33,9 +34,14 @@ public class AiRechargeAggregateRoot : FullAuditedAggregateRoot<Guid>
/// 到期时间
/// </summary>
public DateTime? ExpireDateTime { get; set; }
/// <summary>
/// 联系方式
/// </summary>
public string? ContactInfo { get; set; }
/// <summary>
/// 订单类型
/// </summary>
public RechargeTypeEnum RechargeType { get; set; }
}

View File

@@ -0,0 +1,40 @@
using SqlSugar;
using Volo.Abp.Auditing;
using Volo.Abp.Domain.Entities;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// ai用户表
/// </summary>
[SugarTable("Ai_UserExtraInfo")]
[SugarIndex($"index_{nameof(UserId)}", nameof(UserId), OrderByType.Asc)]
public class AiUserExtraInfoEntity : Entity<Guid>, IHasCreationTime, ISoftDelete
{
public AiUserExtraInfoEntity()
{
}
public AiUserExtraInfoEntity(Guid userId, string fuwuhaoOpenId)
{
this.UserId = userId;
this.FuwuhaoOpenId = fuwuhaoOpenId;
}
/// <summary>
/// 用户id
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 服务号openid
/// </summary>
public string FuwuhaoOpenId { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreationTime { get; set; }
public bool IsDeleted { get; set; }
}

View File

@@ -0,0 +1,187 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// 翻牌任务记录
/// </summary>
[SugarTable("Ai_CardFlipTask")]
[SugarIndex($"index_{nameof(UserId)}_{nameof(WeekStartDate)}",
nameof(UserId), OrderByType.Asc,
nameof(WeekStartDate), OrderByType.Desc)]
public class CardFlipTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public CardFlipTaskAggregateRoot()
{
}
public CardFlipTaskAggregateRoot(Guid userId, DateTime weekStartDate)
{
UserId = userId;
WeekStartDate = weekStartDate.Date; // 确保只存储日期部分
TotalFlips = 0;
FreeFlipsUsed = 0;
BonusFlipsUsed = 0;
InviteFlipsUsed = 0;
IsFirstFlipDone = false;
HasNinthReward = false;
HasTenthReward = false;
FlippedOrder = new List<int>();
}
/// <summary>
/// 用户ID
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 本周开始日期(每周一)
/// </summary>
public DateTime WeekStartDate { get; set; }
/// <summary>
/// 总共已翻牌次数
/// </summary>
public int TotalFlips { get; set; }
/// <summary>
/// 已使用的免费次数最多7次
/// </summary>
public int FreeFlipsUsed { get; set; }
/// <summary>
/// 已使用的赠送次数已废弃保持为0
/// </summary>
public int BonusFlipsUsed { get; set; }
/// <summary>
/// 已使用的邀请解锁次数最多3次
/// </summary>
public int InviteFlipsUsed { get; set; }
/// <summary>
/// 是否已完成首次翻牌(用于判断是否创建任务)
/// </summary>
public bool IsFirstFlipDone { get; set; }
/// <summary>
/// 是否已获得第8次奖励
/// </summary>
public bool HasEighthReward { get; set; }
/// <summary>
/// 第8次奖励金额100-300w
/// </summary>
public long? EighthRewardAmount { get; set; }
/// <summary>
/// 是否已获得第9次奖励
/// </summary>
public bool HasNinthReward { get; set; }
/// <summary>
/// 第9次奖励金额100-500w
/// </summary>
public long? NinthRewardAmount { get; set; }
/// <summary>
/// 是否已获得第10次奖励
/// </summary>
public bool HasTenthReward { get; set; }
/// <summary>
/// 第10次奖励金额100-1000w
/// </summary>
public long? TenthRewardAmount { get; set; }
/// <summary>
/// 备注信息
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string? Remark { get; set; }
/// <summary>
/// 已翻牌的顺序(存储用户实际翻牌的序号列表,如[3,7,1,5]表示依次翻了3号、7号、1号、5号牌
/// </summary>
[SugarColumn(IsJson = true, IsNullable = true)]
public List<int>? FlippedOrder { get; set; }
/// <summary>
/// 增加翻牌次数
/// </summary>
/// <param name="flipType">翻牌类型</param>
public void IncrementFlip(FlipType flipType)
{
TotalFlips++;
switch (flipType)
{
case FlipType.Free:
FreeFlipsUsed++;
break;
case FlipType.Bonus:
BonusFlipsUsed++;
break;
case FlipType.Invite:
InviteFlipsUsed++;
break;
}
if (!IsFirstFlipDone)
{
IsFirstFlipDone = true;
}
}
/// <summary>
/// 记录第8次奖励
/// </summary>
/// <param name="amount">奖励金额</param>
public void SetEighthReward(long amount)
{
HasEighthReward = true;
EighthRewardAmount = amount;
}
/// <summary>
/// 记录第9次奖励
/// </summary>
/// <param name="amount">奖励金额</param>
public void SetNinthReward(long amount)
{
HasNinthReward = true;
NinthRewardAmount = amount;
}
/// <summary>
/// 记录第10次奖励
/// </summary>
/// <param name="amount">奖励金额</param>
public void SetTenthReward(long amount)
{
HasTenthReward = true;
TenthRewardAmount = amount;
}
}
/// <summary>
/// 翻牌类型枚举
/// </summary>
public enum FlipType
{
/// <summary>
/// 免费翻牌1-7次
/// </summary>
Free = 0,
/// <summary>
/// 赠送翻牌(已废弃)
/// </summary>
Bonus = 1,
/// <summary>
/// 邀请解锁翻牌8-10次
/// </summary>
Invite = 2
}

View File

@@ -24,18 +24,26 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
{
UserId = userId;
SessionId = sessionId;
Content = content;
//如果没有会话,不存储对话内容
Content = sessionId is null ? null : content;
Role = role;
ModelId = modelId;
if (tokenUsage is not null)
{
long inputTokenCount = tokenUsage.PromptTokens
?? tokenUsage.InputTokens
?? 0;
long inputTokenCount =
(tokenUsage.PromptTokens.HasValue && tokenUsage.PromptTokens.Value != 0)
? tokenUsage.PromptTokens.Value
: (tokenUsage.InputTokens.HasValue && tokenUsage.InputTokens.Value != 0)
? tokenUsage.InputTokens.Value
: 0;
long outputTokenCount =
(tokenUsage.CompletionTokens.HasValue && tokenUsage.CompletionTokens.Value != 0)
? tokenUsage.CompletionTokens.Value
: (tokenUsage.OutputTokens.HasValue && tokenUsage.OutputTokens.Value != 0)
? tokenUsage.OutputTokens.Value
: 0;
long outputTokenCount = tokenUsage.CompletionTokens
?? tokenUsage.OutputTokens
?? 0;
this.TokenUsage = new TokenUsageValueObject
{

View File

@@ -0,0 +1,57 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// 每日任务奖励领取记录
/// </summary>
[SugarTable("Ai_DailyTaskRewardRecord")]
[SugarIndex($"index_{nameof(UserId)}_{nameof(TaskDate)}",
nameof(UserId), OrderByType.Asc,
nameof(TaskDate), OrderByType.Desc)]
public class DailyTaskRewardRecordAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public DailyTaskRewardRecordAggregateRoot()
{
}
public DailyTaskRewardRecordAggregateRoot(Guid userId, int taskLevel, DateTime taskDate, long rewardTokens)
{
UserId = userId;
TaskLevel = taskLevel;
TaskDate = taskDate.Date; // 确保只存储日期部分
RewardTokens = rewardTokens;
IsRewarded = true;
}
/// <summary>
/// 用户ID
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 任务等级1=1000w任务2=3000w任务
/// </summary>
public int TaskLevel { get; set; }
/// <summary>
/// 任务日期(只包含日期,不包含时间)
/// </summary>
public DateTime TaskDate { get; set; }
/// <summary>
/// 奖励的Token数量
/// </summary>
public long RewardTokens { get; set; }
/// <summary>
/// 是否已发放奖励
/// </summary>
public bool IsRewarded { get; set; }
/// <summary>
/// 备注信息
/// </summary>
public string? Remark { get; set; }
}

View File

@@ -0,0 +1,76 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// 邀请记录
/// </summary>
[SugarTable("Ai_InvitationRecord")]
[SugarIndex($"index_{nameof(InviterId)}_{nameof(InvitedUserId)}",
nameof(InviterId), OrderByType.Asc,
nameof(InvitedUserId), OrderByType.Asc)]
[SugarIndex($"index_{nameof(InvitedUserId)}", nameof(InvitedUserId), OrderByType.Asc)]
public class InvitationRecordAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public InvitationRecordAggregateRoot()
{
}
public InvitationRecordAggregateRoot(Guid inviterId, Guid invitedUserId, string inviteCode)
{
InviterId = inviterId;
InvitedUserId = invitedUserId;
InviteCode = inviteCode;
InvitationTime = DateTime.Now;
Status = InvitationStatus.Valid;
}
/// <summary>
/// 邀请人ID
/// </summary>
public Guid InviterId { get; set; }
/// <summary>
/// 被邀请人ID
/// </summary>
public Guid InvitedUserId { get; set; }
/// <summary>
/// 使用的邀请码
/// </summary>
[SugarColumn(Length = 50)]
public string InviteCode { get; set; } = string.Empty;
/// <summary>
/// 邀请时间
/// </summary>
public DateTime InvitationTime { get; set; }
/// <summary>
/// 邀请状态0=有效1=已撤销)
/// </summary>
public InvitationStatus Status { get; set; }
/// <summary>
/// 备注信息
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string? Remark { get; set; }
}
/// <summary>
/// 邀请状态枚举
/// </summary>
public enum InvitationStatus
{
/// <summary>
/// 有效
/// </summary>
Valid = 0,
/// <summary>
/// 已撤销
/// </summary>
Revoked = 1
}

View File

@@ -0,0 +1,88 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// 用户邀请码
/// </summary>
[SugarTable("Ai_InviteCode")]
[SugarIndex($"index_{nameof(UserId)}", nameof(UserId), OrderByType.Asc, true)]
[SugarIndex($"index_{nameof(Code)}", nameof(Code), OrderByType.Asc, true)]
public class InviteCodeAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public InviteCodeAggregateRoot()
{
}
public InviteCodeAggregateRoot(Guid userId, string code)
{
UserId = userId;
Code = code;
IsUsed = false;
IsUserInvited = false;
UsedCount = 0;
}
/// <summary>
/// 用户ID邀请码拥有者
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 邀请码(唯一)
/// </summary>
[SugarColumn(Length = 50)]
public string Code { get; set; } = string.Empty;
/// <summary>
/// 是否已被使用(一个邀请码只能被使用一次)
/// </summary>
public bool IsUsed { get; set; }
/// <summary>
/// 邀请码拥有者是否已被他人邀请(被邀请后不可再提供邀请码)
/// </summary>
public bool IsUserInvited { get; set; }
/// <summary>
/// 被使用次数(统计用)
/// </summary>
public int UsedCount { get; set; }
/// <summary>
/// 使用时间
/// </summary>
public DateTime? UsedTime { get; set; }
/// <summary>
/// 使用人ID
/// </summary>
public Guid? UsedByUserId { get; set; }
/// <summary>
/// 备注信息
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string? Remark { get; set; }
/// <summary>
/// 标记邀请码已被使用
/// </summary>
/// <param name="usedByUserId">使用者ID</param>
public void MarkAsUsed(Guid usedByUserId)
{
IsUsed = true;
UsedTime = DateTime.Now;
UsedByUserId = usedByUserId;
UsedCount++;
}
/// <summary>
/// 标记用户已被邀请
/// </summary>
public void MarkUserAsInvited()
{
IsUserInvited = true;
}
}

View File

@@ -52,7 +52,12 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
public string? ExtraInfo { get; set; }
/// <summary>
/// 模型类型
/// 模型类型(聊天/图片等)
/// </summary>
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型Api类型现支持同一个模型id多种接口格式
/// </summary>
public ModelApiTypeEnum ModelApiType { get; set; }
}

View File

@@ -0,0 +1,133 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities;
/// <summary>
/// 尊享包聚合根
/// 用于给VIP扩展额外购买尊享token包
/// </summary>
[SugarTable("Ai_PremiumPackage")]
[SugarIndex($"index_{nameof(UserId)}", nameof(UserId), OrderByType.Asc)]
public class PremiumPackageAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public PremiumPackageAggregateRoot()
{
}
public PremiumPackageAggregateRoot(Guid userId, long totalTokens, string packageName)
{
UserId = userId;
TotalTokens = totalTokens;
RemainingTokens = totalTokens;
PackageName = packageName;
IsActive = true;
}
/// <summary>
/// 用户ID
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 包名称
/// </summary>
public string PackageName { get; set; }
/// <summary>
/// 总用量总token数
/// </summary>
public long TotalTokens { get; set; }
/// <summary>
/// 剩余用量剩余token数
/// </summary>
public long RemainingTokens { get; set; }
/// <summary>
/// 已使用token数
/// </summary>
public long UsedTokens { get; set; }
/// <summary>
/// 到期时间
/// </summary>
public DateTime? ExpireDateTime { get; set; }
/// <summary>
/// 是否激活
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// 购买金额
/// </summary>
public decimal PurchaseAmount { get; set; }
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 消耗token
/// </summary>
/// <param name="tokenCount">消耗的token数量</param>
/// <returns>是否消耗成功</returns>
public bool ConsumeTokens(long tokenCount)
{
RemainingTokens -= tokenCount;
UsedTokens += tokenCount;
return true;
}
/// <summary>
/// 检查是否可用
/// </summary>
/// <returns>是否可用</returns>
public bool IsAvailable()
{
if (!IsActive)
{
return false;
}
if (RemainingTokens <= 0)
{
return false;
}
if (ExpireDateTime.HasValue && ExpireDateTime.Value < DateTime.Now)
{
return false;
}
return true;
}
/// <summary>
/// 停用尊享包
/// </summary>
public void Deactivate()
{
IsActive = false;
}
/// <summary>
/// 激活尊享包
/// </summary>
public void Activate()
{
IsActive = true;
}
/// <summary>
/// 设置到期时间
/// </summary>
/// <param name="expireDateTime">到期时间</param>
public void SetExpireDateTime(DateTime expireDateTime)
{
ExpireDateTime = expireDateTime;
}
}

View File

@@ -12,48 +12,60 @@ using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.AiGateWay;
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Core.Extensions;
using Yi.Framework.SqlSugarCore.Abstractions;
using JsonSerializer = System.Text.Json.JsonSerializer;
using ThorJsonSerializer = Yi.Framework.AiHub.Domain.AiGateWay.ThorJsonSerializer;
namespace Yi.Framework.AiHub.Domain.Managers;
public class AiGateWayManager : DomainService
{
private readonly ISqlSugarRepository<AiAppAggregateRoot> _aiAppRepository;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
private readonly ILogger<AiGateWayManager> _logger;
private readonly AiMessageManager _aiMessageManager;
private readonly UsageStatisticsManager _usageStatisticsManager;
private readonly ISpecialCompatible _specialCompatible;
private PremiumPackageManager? _premiumPackageManager;
public AiGateWayManager(ISqlSugarRepository<AiAppAggregateRoot> aiAppRepository, ILogger<AiGateWayManager> logger,
AiMessageManager aiMessageManager, UsageStatisticsManager usageStatisticsManager,
ISpecialCompatible specialCompatible)
ISpecialCompatible specialCompatible, ISqlSugarRepository<AiModelEntity> aiModelRepository)
{
_aiAppRepository = aiAppRepository;
_logger = logger;
_aiMessageManager = aiMessageManager;
_usageStatisticsManager = usageStatisticsManager;
_specialCompatible = specialCompatible;
_aiModelRepository = aiModelRepository;
}
private PremiumPackageManager PremiumPackageManager =>
_premiumPackageManager ??= LazyServiceProvider.LazyGetRequiredService<PremiumPackageManager>();
/// <summary>
/// 获取模型
/// </summary>
/// <param name="modelApiType"></param>
/// <param name="modelId"></param>
/// <returns></returns>
private async Task<AiModelDescribe> GetModelAsync(string modelId)
private async Task<AiModelDescribe> GetModelAsync(ModelApiTypeEnum modelApiType, string modelId)
{
var allApp = await _aiAppRepository._DbQueryable.Includes(x => x.AiModels).ToListAsync();
foreach (var app in allApp)
{
var model = app.AiModels.FirstOrDefault(x => x.ModelId == modelId);
if (model is not null)
{
return new AiModelDescribe
var aiModelDescribe = await _aiModelRepository._DbQueryable
.LeftJoin<AiAppAggregateRoot>((model, app) => model.AiAppId == app.Id)
.Where((model, app) => model.ModelId == modelId)
.Where((model, app) => model.ModelApiType == modelApiType)
.Select((model, app) =>
new AiModelDescribe
{
AppId = app.Id,
AppName = app.Name,
@@ -66,11 +78,14 @@ public class AiGateWayManager : DomainService
Description = model.Description,
AppExtraUrl = app.ExtraUrl,
ModelExtraInfo = model.ExtraInfo
};
}
})
.FirstAsync();
if (aiModelDescribe is null)
{
throw new UserFriendlyException($"【{modelId}】模型当前版本【{modelApiType}】格式不支持");
}
throw new UserFriendlyException($"{modelId}模型当前版本不支持");
return aiModelDescribe;
}
@@ -85,7 +100,7 @@ public class AiGateWayManager : DomainService
[EnumeratorCancellation] CancellationToken cancellationToken)
{
_specialCompatible.Compatible(request);
var modelDescribe = await GetModelAsync(request.Model);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
@@ -115,7 +130,7 @@ public class AiGateWayManager : DomainService
var response = httpContext.Response;
// 设置响应头,声明是 json
//response.ContentType = "application/json; charset=UTF-8";
var modelDescribe = await GetModelAsync(request.Model);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
@@ -124,7 +139,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = request.Messages?.LastOrDefault().Content ?? string.Empty,
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault().Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.Usage,
});
@@ -132,7 +147,8 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = data.Choices.FirstOrDefault()?.Delta.Content,
Content =
sessionId is null ? "不予存储" : data.Choices?.FirstOrDefault()?.Delta.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.Usage
});
@@ -195,6 +211,11 @@ public class AiGateWayManager : DomainService
// 如果没有完成,才等待,已完成,全部输出
await Task.Delay(outputInterval, cancellationToken).ConfigureAwait(false);
}
else
{
//已经完成了,也等待,但是速度可以放快
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
}
}
}, cancellationToken);
@@ -204,7 +225,7 @@ public class AiGateWayManager : DomainService
{
await foreach (var data in completeChatResponse)
{
if (data.Usage is not null)
if (data.Usage is not null&&(data.Usage.CompletionTokens>0||data.Usage.OutputTokens>0))
{
tokenUsage = data.Usage;
}
@@ -251,7 +272,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = request.Messages?.LastOrDefault()?.Content ?? string.Empty,
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = tokenUsage,
});
@@ -259,12 +280,22 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = backupSystemContent.ToString(),
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
ModelId = request.Model,
TokenUsage = tokenUsage
});
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
{
var totalTokens = tokenUsage.TotalTokens ?? 0;
if (totalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
@@ -285,7 +316,7 @@ public class AiGateWayManager : DomainService
var model = request.Model;
if (string.IsNullOrEmpty(model)) model = "dall-e-2";
var modelDescribe = await GetModelAsync(model);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, model);
// 获取渠道指定的实现类型的服务
var imageService =
@@ -303,7 +334,7 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = request.Prompt,
Content = sessionId is null ? "不予存储" : request.Prompt,
ModelId = model,
TokenUsage = response.Usage,
});
@@ -311,12 +342,22 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = response.Results?.FirstOrDefault()?.Url,
Content = sessionId is null ? "不予存储" : response.Results?.FirstOrDefault()?.Url,
ModelId = model,
TokenUsage = response.Usage
});
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
{
var totalTokens = response.Usage.TotalTokens ?? 0;
if (totalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
catch (Exception e)
{
@@ -345,7 +386,7 @@ public class AiGateWayManager : DomainService
using var embedding =
Activity.Current?.Source.StartActivity("向量模型调用");
var modelDescribe = await GetModelAsync(input.Model);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.OpenAi, input.Model);
// 获取渠道指定的实现类型的服务
var embeddingService =
@@ -389,7 +430,7 @@ public class AiGateWayManager : DomainService
var usage = new ThorUsageResponse()
{
PromptTokens = stream.Usage?.PromptTokens??0,
PromptTokens = stream.Usage?.PromptTokens ?? 0,
InputTokens = stream.Usage?.InputTokens ?? 0,
CompletionTokens = 0,
TotalTokens = stream.Usage?.InputTokens ?? 0
@@ -436,4 +477,210 @@ public class AiGateWayManager : DomainService
throw new UserFriendlyException(errorContent);
}
}
/// <summary>
/// Anthropic聊天完成-流式
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async IAsyncEnumerable<(string, AnthropicStreamDto?)> AnthropicCompleteChatStreamAsync(
AnthropicInput request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
_specialCompatible.AnthropicCompatible(request);
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
await foreach (var result in chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken))
{
yield return result;
}
}
/// <summary>
/// Anthropic聊天完成-非流式
/// </summary>
/// <param name="httpContext"></param>
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AnthropicCompleteChatForStatisticsAsync(HttpContext httpContext,
AnthropicInput request,
Guid? userId = null,
Guid? sessionId = null,
CancellationToken cancellationToken = default)
{
_specialCompatible.AnthropicCompatible(request);
var response = httpContext.Response;
// 设置响应头,声明是 json
//response.ContentType = "application/json; charset=UTF-8";
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Claude, request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
if (userId is not null)
{
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : request.Messages?.FirstOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.TokenUsage,
});
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : data.content?.FirstOrDefault()?.text,
ModelId = request.Model,
TokenUsage = data.TokenUsage
});
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage);
// 扣减尊享token包用量
var totalTokens = data.TokenUsage.TotalTokens ?? 0;
if (totalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
await response.WriteAsJsonAsync(data, cancellationToken);
}
/// <summary>
/// Anthropic聊天完成-缓存处理
/// </summary>
/// <param name="httpContext"></param>
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AnthropicCompleteChatStreamForStatisticsAsync(
HttpContext httpContext,
AnthropicInput request,
Guid? userId = null,
Guid? sessionId = null,
CancellationToken cancellationToken = default)
{
var response = httpContext.Response;
// 设置响应头,声明是 SSE 流
response.ContentType = "text/event-stream;charset=utf-8;";
response.Headers.TryAdd("Cache-Control", "no-cache");
response.Headers.TryAdd("Connection", "keep-alive");
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
var completeChatResponse = gateWay.AnthropicCompleteChatStreamAsync(request, cancellationToken);
ThorUsageResponse? tokenUsage = null;
StringBuilder backupSystemContent = new StringBuilder();
try
{
await foreach (var responseResult in completeChatResponse)
{
//message_start是为了保底机制
if (responseResult.Item1.Contains("message_delta") || responseResult.Item1.Contains("message_start"))
{
tokenUsage = responseResult.Item2?.TokenUsage;
}
backupSystemContent.Append(responseResult.Item2?.Delta?.Text);
await WriteAsEventStreamDataAsync(httpContext, responseResult.Item1, responseResult.Item2,
cancellationToken);
}
}
catch (Exception e)
{
_logger.LogError(e, $"Ai对话异常");
var errorContent = $"对话Ai异常异常信息\n当前Ai模型{request.Model}\n异常信息{e.Message}\n异常堆栈:{e}";
throw new UserFriendlyException(errorContent);
}
await _aiMessageManager.CreateUserMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = tokenUsage,
});
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
{
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
ModelId = request.Model,
TokenUsage = tokenUsage
});
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
// 扣减尊享token包用量
if (userId.HasValue && tokenUsage is not null)
{
var totalTokens = tokenUsage.TotalTokens ?? 0;
if (tokenUsage.TotalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
#region Anthropic格式Http响应
private static readonly byte[] EventPrefix = "event: "u8.ToArray();
private static readonly byte[] DataPrefix = "data: "u8.ToArray();
private static readonly byte[] NewLine = "\n"u8.ToArray();
private static readonly byte[] DoubleNewLine = "\n\n"u8.ToArray();
/// <summary>
/// 使用 JsonSerializer.SerializeAsync 直接序列化到响应流
/// </summary>
private static async ValueTask WriteAsEventStreamDataAsync<T>(
HttpContext context,
string @event,
T value,
CancellationToken cancellationToken = default)
where T : class
{
var response = context.Response;
var bodyStream = response.Body;
// 确保 SSE Header 已经设置好
// e.g. Content-Type: text/event-stream; charset=utf-8
await response.StartAsync(cancellationToken).ConfigureAwait(false);
// 写事件类型
await bodyStream.WriteAsync(EventPrefix, cancellationToken).ConfigureAwait(false);
await WriteUtf8StringAsync(bodyStream, @event.Trim(), cancellationToken).ConfigureAwait(false);
await bodyStream.WriteAsync(NewLine, cancellationToken).ConfigureAwait(false);
// 写 data: + JSON
await bodyStream.WriteAsync(DataPrefix, cancellationToken).ConfigureAwait(false);
await JsonSerializer.SerializeAsync(
bodyStream,
value,
ThorJsonSerializer.DefaultOptions,
cancellationToken
).ConfigureAwait(false);
// 事件结束 \n\n
await bodyStream.WriteAsync(DoubleNewLine, cancellationToken).ConfigureAwait(false);
// 及时把数据发送给客户端
await bodyStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async ValueTask WriteUtf8StringAsync(Stream stream, string value, CancellationToken token)
{
if (string.IsNullOrEmpty(value))
return;
var buffer = Encoding.UTF8.GetBytes(value);
await stream.WriteAsync(buffer, token).ConfigureAwait(false);
}
#endregion
}

View File

@@ -2,6 +2,7 @@
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
@@ -28,7 +29,7 @@ public class AiRechargeManager : DomainService
var currentTime = DateTime.Now;
// 查找所有充值记录,按用户分组
var allRecharges = await _rechargeRepository._DbQueryable
var allRecharges = await _rechargeRepository._DbQueryable.Where(x => x.RechargeType == RechargeTypeEnum.Vip)
.ToListAsync();
if (!allRecharges.Any())
@@ -48,7 +49,7 @@ public class AiRechargeManager : DomainService
// 找到用户最大的过期时间
var maxExpireTime = group.Max(x => x.ExpireDateTime);
// 如果最大过期时间小于当前时间,说明用户已过期(比较日期,满足用户最后一天)
return maxExpireTime.HasValue && maxExpireTime.Value.Date < currentTime.Date;
})

View File

@@ -0,0 +1,346 @@
using Microsoft.Extensions.Logging;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
/// <summary>
/// 翻牌管理器 - 负责翻牌核心业务逻辑
/// </summary>
public class CardFlipManager : DomainService
{
private readonly ISqlSugarRepository<CardFlipTaskAggregateRoot> _cardFlipTaskRepository;
private readonly ISqlSugarRepository<InvitationRecordAggregateRoot> _invitationRecordRepository;
private readonly InviteCodeManager _inviteCodeManager;
private readonly ILogger<CardFlipManager> _logger;
// 翻牌规则配置
public const int MAX_FREE_FLIPS = 7; // 免费翻牌次数
public const int MAX_INVITE_FLIPS = 3; // 邀请解锁翻牌次数
public const int TOTAL_MAX_FLIPS = 10; // 总最大翻牌次数
private const int EIGHTH_FLIP = 8; // 第8次翻牌
private const int NINTH_FLIP = 9; // 第9次翻牌
private const int TENTH_FLIP = 10; // 第10次翻牌
private const long EIGHTH_MIN_REWARD = 1000000; // 第8次最小奖励 100w
private const long EIGHTH_MAX_REWARD = 3000000; // 第8次最大奖励 300w
private const long NINTH_MIN_REWARD = 1000000; // 第9次最小奖励 100w
private const long NINTH_MAX_REWARD = 5000000; // 第9次最大奖励 500w
private const long TENTH_MIN_REWARD = 1000000; // 第10次最小奖励 100w
private const long TENTH_MAX_REWARD = 10000000; // 第10次最大奖励 1000w
public CardFlipManager(
ISqlSugarRepository<CardFlipTaskAggregateRoot> cardFlipTaskRepository,
ISqlSugarRepository<InvitationRecordAggregateRoot> invitationRecordRepository,
InviteCodeManager inviteCodeManager,
ILogger<CardFlipManager> logger)
{
_cardFlipTaskRepository = cardFlipTaskRepository;
_invitationRecordRepository = invitationRecordRepository;
_inviteCodeManager = inviteCodeManager;
_logger = logger;
}
/// <summary>
/// 获取或创建本周任务
/// </summary>
public async Task<CardFlipTaskAggregateRoot?> GetOrCreateWeeklyTaskAsync(
Guid userId,
DateTime weekStart,
bool createIfNotExists)
{
var task = await _cardFlipTaskRepository._DbQueryable
.Where(x => x.UserId == userId && x.WeekStartDate == weekStart)
.FirstAsync();
if (task == null && createIfNotExists)
{
task = new CardFlipTaskAggregateRoot(userId, weekStart);
await _cardFlipTaskRepository.InsertAsync(task);
}
return task;
}
/// <summary>
/// 获取已翻牌的顺序列表
/// </summary>
public List<int> GetFlippedOrder(CardFlipTaskAggregateRoot task)
{
return task.FlippedOrder ?? new List<int>();
}
/// <summary>
/// 执行翻牌逻辑
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="flipNumber">翻牌序号</param>
/// <param name="weekStart">本周开始日期</param>
/// <returns>翻牌结果</returns>
public async Task<FlipResult> ExecuteFlipAsync(Guid userId, int flipNumber, DateTime weekStart)
{
// 验证翻牌序号
if (flipNumber < 1 || flipNumber > TOTAL_MAX_FLIPS)
{
throw new UserFriendlyException($"翻牌序号必须在1-{TOTAL_MAX_FLIPS}之间");
}
// 获取或创建本周任务
var task = await GetOrCreateWeeklyTaskAsync(userId, weekStart, createIfNotExists: true);
// 验证翻牌次数
if (task.TotalFlips >= TOTAL_MAX_FLIPS)
{
throw new UserFriendlyException("本周翻牌次数已用完,请下周再来!");
}
// 验证该牌是否已经翻过
var flippedOrder = GetFlippedOrder(task);
if (flippedOrder.Contains(flipNumber))
{
throw new UserFriendlyException($"第 {flipNumber} 号牌已经翻过了!");
}
// 判断翻牌类型
var flipType = DetermineFlipType(task);
// 验证是否有足够的次数
if (!CanUseFlipType(task, flipType))
{
throw new UserFriendlyException(GetFlipTypeErrorMessage(flipType));
}
// 如果是邀请类型翻牌,必须验证用户本周填写的邀请码数量足够
if (flipType == FlipType.Invite)
{
// 查询本周已使用的邀请码数量
var weeklyInviteCodeUsedCount = await _invitationRecordRepository._DbQueryable
.Where(x => x.InvitedUserId == userId)
.Where(x => x.InvitationTime >= weekStart)
.CountAsync();
// 本周填写的邀请码数量必须 >= 即将使用的邀请翻牌次数
// 例如: 要翻第8次(InviteFlipsUsed=0->1), 需要至少填写了1个邀请码
// 要翻第9次(InviteFlipsUsed=1->2), 需要至少填写了2个邀请码
// 要翻第10次(InviteFlipsUsed=2->3), 需要至少填写了3个邀请码
var requiredInviteCodeCount = task.InviteFlipsUsed + 1;
if (weeklyInviteCodeUsedCount < requiredInviteCodeCount)
{
throw new UserFriendlyException($"需本周累积使用{requiredInviteCodeCount}个他人邀请码才能解锁第{task.TotalFlips + 1}次翻牌,您还差一个~");
}
}
// 计算翻牌结果(基于当前是第几次翻牌,而不是卡片序号)
var flipCount = task.TotalFlips + 1; // 当前这次翻牌是第几次
var result = CalculateFlipResult(flipCount);
// 将卡片序号信息也返回
result.FlipNumber = flipNumber;
// 更新翻牌次数(必须在记录奖励之前,因为需要先确定是第几次)
task.IncrementFlip(flipType);
// 记录翻牌顺序
if (task.FlippedOrder == null)
{
task.FlippedOrder = new List<int>();
}
task.FlippedOrder.Add(flipNumber);
// 如果中奖,记录奖励金额(用于后续查询显示)
if (result.IsWin)
{
if (flipCount == EIGHTH_FLIP)
{
task.SetEighthReward(result.RewardAmount);
}
else if (flipCount == NINTH_FLIP)
{
task.SetNinthReward(result.RewardAmount);
}
else if (flipCount == TENTH_FLIP)
{
task.SetTenthReward(result.RewardAmount);
}
}
await _cardFlipTaskRepository.UpdateAsync(task);
_logger.LogInformation($"用户 {userId} 完成第 {flipNumber} 次翻牌,中奖:{result.IsWin}");
return result;
}
/// <summary>
/// 判断是否可以翻牌
/// </summary>
public bool CanFlipCard(CardFlipTaskAggregateRoot? task)
{
if (task == null) return true; // 没有任务记录,可以开始翻牌
return task.TotalFlips < TOTAL_MAX_FLIPS;
}
/// <summary>
/// 判断翻牌类型
/// </summary>
public FlipType DetermineFlipType(CardFlipTaskAggregateRoot task)
{
if (task.FreeFlipsUsed < MAX_FREE_FLIPS)
{
return FlipType.Free;
}
else
{
return FlipType.Invite;
}
}
/// <summary>
/// 判断是否可以使用该翻牌类型
/// </summary>
public bool CanUseFlipType(CardFlipTaskAggregateRoot task, FlipType flipType)
{
return flipType switch
{
FlipType.Free => task.FreeFlipsUsed < MAX_FREE_FLIPS,
FlipType.Invite => task.InviteFlipsUsed < MAX_INVITE_FLIPS,
_ => false
};
}
/// <summary>
/// 计算翻牌结果
/// </summary>
/// <param name="flipCount">第几次翻牌1-10</param>
private FlipResult CalculateFlipResult(int flipCount)
{
var result = new FlipResult
{
FlipNumber = 0, // 稍后会被设置为实际的卡片序号
IsWin = false
};
// 前7次固定失败
if (flipCount <= 7)
{
result.IsWin = false;
result.RewardDesc = "很遗憾,未中奖";
}
// 第8次中奖 (邀请码解锁)
else if (flipCount == EIGHTH_FLIP)
{
var rewardAmount = GenerateRandomReward(EIGHTH_MIN_REWARD, EIGHTH_MAX_REWARD);
result.IsWin = true;
result.RewardAmount = rewardAmount;
result.RewardDesc = $"恭喜获得尊享包 {rewardAmount / 10000}w tokens!";
}
// 第9次中奖 (邀请码解锁)
else if (flipCount == NINTH_FLIP)
{
var rewardAmount = GenerateRandomReward(NINTH_MIN_REWARD, NINTH_MAX_REWARD);
result.IsWin = true;
result.RewardAmount = rewardAmount;
result.RewardDesc = $"恭喜获得尊享包 {rewardAmount / 10000}w tokens!";
}
// 第10次中奖 (邀请码解锁)
else if (flipCount == TENTH_FLIP)
{
var rewardAmount = GenerateRandomReward(TENTH_MIN_REWARD, TENTH_MAX_REWARD);
result.IsWin = true;
result.RewardAmount = rewardAmount;
result.RewardDesc = $"恭喜获得尊享包 {rewardAmount / 10000}w tokens!";
}
return result;
}
/// <summary>
/// 获取翻牌类型错误提示
/// </summary>
private string GetFlipTypeErrorMessage(FlipType flipType)
{
return flipType switch
{
FlipType.Free => "免费翻牌次数已用完",
FlipType.Invite => "需要使用邀请码解锁更多次数",
_ => "无法翻牌"
};
}
/// <summary>
/// 生成随机奖励金额 (最小单位100w)
/// </summary>
private long GenerateRandomReward(long min, long max)
{
var random = new Random();
const long unit = 1000000; // 100w的单位
// 将min和max转换为100w的倍数
long minUnits = min / unit;
long maxUnits = max / unit;
// 在倍数范围内随机
long randomUnits = random.Next((int)minUnits, (int)maxUnits + 1);
// 返回100w的倍数
return randomUnits * unit;
}
/// <summary>
/// 获取本周开始日期(周一)
/// </summary>
public static DateTime GetWeekStartDate(DateTime date)
{
var dayOfWeek = (int)date.DayOfWeek;
// 将周日(0)转换为7
if (dayOfWeek == 0) dayOfWeek = 7;
// 计算本周一的日期
var monday = date.Date.AddDays(-(dayOfWeek - 1));
return monday;
}
/// <summary>
/// 获取翻牌类型描述
/// </summary>
public static string GetFlipTypeDesc(int flipNumber)
{
if (flipNumber <= MAX_FREE_FLIPS)
{
return "免费";
}
else
{
return "邀请解锁";
}
}
}
/// <summary>
/// 翻牌结果
/// </summary>
public class FlipResult
{
/// <summary>
/// 翻牌序号
/// </summary>
public int FlipNumber { get; set; }
/// <summary>
/// 是否中奖
/// </summary>
public bool IsWin { get; set; }
/// <summary>
/// 奖励金额
/// </summary>
public long RewardAmount { get; set; }
/// <summary>
/// 奖励描述
/// </summary>
public string RewardDesc { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
/// <summary>
/// AccessToken响应对象
/// </summary>
public class AccessTokenResponse
{
/// <summary>
/// 访问令牌
/// </summary>
public string AccessToken { get; set; }
/// <summary>
/// 过期时间(秒)
/// </summary>
public int ExpiresIn { get; set; }
}

View File

@@ -0,0 +1,257 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.Caching;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
public class FuwuhaoManager : DomainService
{
private readonly FuwuhaoOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private IDistributedCache<AccessTokenResponse> _accessTokenCache;
private ISqlSugarRepository<AiUserExtraInfoEntity> _userRepository;
private readonly ILogger<FuwuhaoManager> _logger;
public FuwuhaoManager(IOptions<FuwuhaoOptions> options, IHttpClientFactory httpClientFactory,
ISqlSugarRepository<AiUserExtraInfoEntity> userRepository,
IDistributedCache<AccessTokenResponse> accessTokenCache, ILogger<FuwuhaoManager> logger)
{
_options = options.Value;
_httpClientFactory = httpClientFactory;
_userRepository = userRepository;
_accessTokenCache = accessTokenCache;
_logger = logger;
}
/// <summary>
/// 获取微信公众号AccessToken
/// </summary>
/// <returns>AccessToken响应对象</returns>
private async Task<string> GetAccessTokenAsync()
{
var output = await _accessTokenCache.GetOrAddAsync("Fuwuhao", async () =>
{
var url =
$"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={_options.AppId}&secret={_options.Secret}";
var response = await _httpClientFactory.CreateClient().GetAsync(url);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<AccessTokenResponse>(jsonContent, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (result is null || string.IsNullOrEmpty(result.AccessToken))
{
throw new UserFriendlyException("微信服务号AccessToken为空");
}
return result;
}, () => new DistributedCacheEntryOptions()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3600)
});
return output.AccessToken;
}
/// <summary>
/// 创建带参数的二维码
/// </summary>
/// <param name="scene">场景值</param>
/// <returns>二维码URL</returns>
public async Task<string> CreateQrCodeAsync(string scene)
{
var accessToken = await GetAccessTokenAsync();
var url = $"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={accessToken}";
var requestBody = new
{
action_name = "QR_STR_SCENE",
expire_seconds = 600,
action_info = new
{
scene = new
{
scene_str = scene
}
}
};
var jsonContent = JsonSerializer.Serialize(requestBody);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await _httpClientFactory.CreateClient().PostAsync(url, content);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<QrCodeResponse>(responseContent);
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={result.Ticket}";
}
/// <summary>
/// 通过code获取用户基础信息接口为了获取用户access_token和openid
/// </summary>
/// <param name="code">授权码</param>
/// <returns>用户基础信息响应对象</returns>
private async Task<UserBaseInfoResponse> GetBaseUserInfoByCodeAsync(string code)
{
var url =
$"https://api.weixin.qq.com/sns/oauth2/access_token?appid={_options.AppId}&secret={_options.Secret}&code={code}&grant_type=authorization_code";
var response = await _httpClientFactory.CreateClient().GetAsync(url);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"服务号code获取用户基础信息{jsonContent}");
var result = JsonSerializer.Deserialize<UserBaseInfoResponse>(jsonContent);
return result;
}
/// <summary>
/// 通过code获取用户信息接口
/// </summary>
/// <param name="code">授权码</param>
/// <returns>用户信息响应对象</returns>
public async Task<UserInfoResponse?> GetUserInfoByCodeAsync(string code)
{
var baseUserInfo = await GetBaseUserInfoByCodeAsync(code);
var url =
$"https://api.weixin.qq.com/sns/userinfo?access_token={baseUserInfo.AccessToken}&openid={baseUserInfo.OpenId}&lang=zh_CN";
var response = await _httpClientFactory.CreateClient().GetAsync(url);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"服务号code获取用户详细信息{jsonContent}");
var result = JsonSerializer.Deserialize<UserInfoResponse>(jsonContent);
return result;
}
/// <summary>
/// 校验微信服务器回调参数是否正确
/// </summary>
/// <param name="signature">微信加密签名</param>
/// <param name="timestamp">时间戳</param>
/// <param name="nonce">随机数</param>
/// <returns>true表示验证通过false表示验证失败</returns>
public void ValidateCallback(string signature, string timestamp, string nonce)
{
var token = _options.CallbackToken; // 您设置的token
// 将token、timestamp、nonce三个参数进行字典序排序
var parameters = new[] { token, timestamp, nonce };
Array.Sort(parameters);
// 将三个参数字符串拼接成一个字符串
var concatenated = string.Join("", parameters);
// 进行SHA1计算签名
using (var sha1 = SHA1.Create())
{
var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(concatenated));
var calculatedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();
// 与URL链接中的signature参数进行对比
var result = calculatedSignature.Equals(signature, StringComparison.OrdinalIgnoreCase);
if (!result)
{
throw new UserFriendlyException("服务号回调签名异常");
}
}
}
/// <summary>
/// 构建引导注册图文消息体
/// </summary>
/// <param name="toUser">接收用户的OpenID</param>
/// <param name="title">图文消息标题</param>
/// <param name="description">图文消息描述</param>
/// <returns>XML格式的图文消息体</returns>
public string BuildRegisterMessage(string toUser, string title = "意社区点击一键注册账号",
string description = "来自意社区SSO统一注册安全中心")
{
var createTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var fromUser = _options.FromUser;
var url =
$"https://open.weixin.qq.com/connect/oauth2/authorize?appid={_options.AppId}&redirect_uri={_options.RedirectUri}&response_type=code&scope=snsapi_userinfo&state={createTime}#wechat_redirect";
var xml = $@"<xml>
<ToUserName><![CDATA[{toUser}]]></ToUserName>
<FromUserName><![CDATA[{fromUser}]]></FromUserName>
<CreateTime>{createTime}</CreateTime>
<MsgType><![CDATA[news]]></MsgType>
<ArticleCount>1</ArticleCount>
<Articles>
<item>
<Title><![CDATA[{title}]]></Title>
<Description><![CDATA[{description}]]></Description>
<PicUrl><![CDATA[{_options.PicUrl}]]></PicUrl>
<Url><![CDATA[{url}]]></Url>
</item>
</Articles>
</xml>";
return xml;
}
/// <summary>
/// 处理回调逻辑
/// </summary>
/// <param name="sceneType"></param>
/// <param name="openId"></param>
/// <param name="bindUserId"></param>
/// <returns></returns>
public async Task<(SceneResultEnum SceneResult, Guid? UserId)> CallBackHandlerAsync(SceneTypeEnum sceneType,
string openId, Guid? bindUserId)
{
var aiUserInfo = await _userRepository._DbQueryable.Where(x => x.FuwuhaoOpenId == openId).FirstAsync();
switch (sceneType)
{
case SceneTypeEnum.LoginOrRegister:
//有openid说明登录成功
if (aiUserInfo is not null)
{
return (SceneResultEnum.Login, aiUserInfo.UserId);
}
//无openid说明需要进行注册
else
{
return (SceneResultEnum.Register, null);
}
break;
case SceneTypeEnum.Bind:
//说明已经有微信号,直接换绑
if (aiUserInfo is not null)
{
await _userRepository.DeleteByIdAsync(aiUserInfo.Id);
}
if (bindUserId is null)
{
throw new UserFriendlyException("绑定用户需要传入绑定的用户id");
}
//说明没有绑定过,直接绑定
await _userRepository.InsertAsync(new AiUserExtraInfoEntity(bindUserId.Value, openId));
return (SceneResultEnum.Bind, bindUserId);
break;
default:
throw new ArgumentOutOfRangeException(nameof(sceneType), sceneType, null);
}
}
}

View File

@@ -0,0 +1,34 @@
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
public class FuwuhaoOptions
{
/// <summary>
/// 微信公众号AppId
/// </summary>
public string AppId { get; set; }
/// <summary>
/// 微信公众号AppSecret
/// </summary>
public string Secret { get; set; }
/// <summary>
/// 回调token
/// </summary>
public string CallbackToken { get; set; }
/// <summary>
/// 微信公众号原始ID用于FromUser
/// </summary>
public string FromUser { get; set; }
/// <summary>
/// 微信网页授权跳转地址
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// 图片地址
/// </summary>
public string PicUrl { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
/// <summary>
/// 二维码响应对象
/// </summary>
public class QrCodeResponse
{
/// <summary>
/// 二维码票据
/// </summary>
[JsonPropertyName("ticket")]
public string Ticket { get; set; }
/// <summary>
/// 过期时间(秒)
/// </summary>
[JsonPropertyName("expire_seconds")]
public int ExpireSeconds { get; set; }
/// <summary>
/// 二维码URL
/// </summary>
[JsonPropertyName("url")]
public string Url { get; set; }
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
/// <summary>
/// 用户基础信息响应对象
/// </summary>
public class UserBaseInfoResponse
{
/// <summary>
/// 访问令牌
/// </summary>
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
/// <summary>
/// 过期时间(秒)
/// </summary>
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
/// <summary>
/// 刷新令牌
/// </summary>
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
/// <summary>
/// 用户OpenID
/// </summary>
[JsonPropertyName("openid")]
public string OpenId { get; set; }
/// <summary>
/// 授权作用域
/// </summary>
[JsonPropertyName("scope")]
public string Scope { get; set; }
}

View File

@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
/// <summary>
/// 用户信息响应对象
/// </summary>
public class UserInfoResponse
{
/// <summary>
/// 用户OpenID
/// </summary>
[JsonPropertyName("openid")]
public string OpenId { get; set; }
/// <summary>
/// 用户昵称
/// </summary>
[JsonPropertyName("nickname")]
public string Nickname { get; set; }
/// <summary>
/// 用户性别值为1时是男性值为2时是女性值为0时是未知
/// </summary>
[JsonPropertyName("sex")]
public int Sex { get; set; }
/// <summary>
/// 用户个人资料填写的省份
/// </summary>
[JsonPropertyName("province")]
public string Province { get; set; }
/// <summary>
/// 普通用户个人资料填写的城市
/// </summary>
[JsonPropertyName("city")]
public string City { get; set; }
/// <summary>
/// 国家如中国为CN
/// </summary>
[JsonPropertyName("country")]
public string Country { get; set; }
/// <summary>
/// 用户头像最后一个数值代表正方形头像大小有0、46、64、96、132数值可选0代表640*640正方形头像
/// </summary>
[JsonPropertyName("headimgurl")]
public string HeadImgUrl { get; set; }
}

View File

@@ -0,0 +1,224 @@
using Microsoft.Extensions.Logging;
using SqlSugar;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
/// <summary>
/// 邀请码管理器 - 负责邀请码核心业务逻辑
/// </summary>
public class InviteCodeManager : DomainService
{
private readonly ISqlSugarRepository<InviteCodeAggregateRoot> _inviteCodeRepository;
private readonly ISqlSugarRepository<InvitationRecordAggregateRoot> _invitationRecordRepository;
private readonly ILogger<InviteCodeManager> _logger;
public InviteCodeManager(
ISqlSugarRepository<InviteCodeAggregateRoot> inviteCodeRepository,
ISqlSugarRepository<InvitationRecordAggregateRoot> invitationRecordRepository,
ILogger<InviteCodeManager> logger)
{
_inviteCodeRepository = inviteCodeRepository;
_invitationRecordRepository = invitationRecordRepository;
_logger = logger;
}
/// <summary>
/// 生成用户的邀请码
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>邀请码</returns>
public async Task<string> GenerateInviteCodeForUserAsync(Guid userId)
{
// 检查是否已有邀请码
var existingCode = await _inviteCodeRepository._DbQueryable
.Where(x => x.UserId == userId)
.FirstAsync();
if (existingCode != null)
{
return existingCode.Code;
}
// 生成新邀请码
var code = GenerateUniqueInviteCode();
var inviteCode = new InviteCodeAggregateRoot(userId, code);
await _inviteCodeRepository.InsertAsync(inviteCode);
_logger.LogInformation($"用户 {userId} 生成邀请码 {code}");
return code;
}
/// <summary>
/// 获取用户的邀请码信息
/// </summary>
public async Task<InviteCodeAggregateRoot?> GetUserInviteCodeAsync(Guid userId)
{
return await _inviteCodeRepository._DbQueryable
.Where(x => x.UserId == userId)
.FirstAsync();
}
/// <summary>
/// 统计用户本周邀请人数
/// </summary>
public async Task<int> GetWeeklyInvitationCountAsync(Guid userId, DateTime weekStart)
{
return await _invitationRecordRepository._DbQueryable
.Where(x => x.InvitedUserId == userId)
.Where(x => x.InvitationTime >= weekStart)
.CountAsync();
}
/// <summary>
/// 获取邀请历史记录
/// </summary>
public async Task<List<InvitationHistoryDto>> GetInvitationHistoryAsync(Guid userId, int limit = 10)
{
return await _invitationRecordRepository._DbQueryable
.Where(x => x.InviterId == userId)
.OrderBy(x => x.InvitationTime, OrderByType.Desc)
.Take(limit)
.Select(x => new InvitationHistoryDto
{
InvitedUserName = "用户***", // 脱敏处理
InvitationTime = x.InvitationTime
})
.ToListAsync();
}
/// <summary>
/// 使用邀请码
/// </summary>
/// <param name="userId">使用者ID</param>
/// <param name="inviteCode">邀请码</param>
/// <returns>邀请人ID</returns>
public async Task<Guid> UseInviteCodeAsync(Guid userId, string inviteCode)
{
if (string.IsNullOrWhiteSpace(inviteCode))
{
throw new UserFriendlyException("邀请码不能为空");
}
// 查找邀请码
var inviteCodeEntity = await _inviteCodeRepository._DbQueryable
.Where(x => x.Code == inviteCode)
.FirstAsync();
if (inviteCodeEntity == null)
{
throw new UserFriendlyException("邀请码不存在");
}
// 验证不能使用自己的邀请码
if (inviteCodeEntity.UserId == userId)
{
throw new UserFriendlyException("不能使用自己的邀请码");
}
// 验证邀请码是否已被使用
if (inviteCodeEntity.IsUsed)
{
throw new UserFriendlyException("该邀请码已被使用");
}
// 验证邀请码拥有者是否已被邀请
if (inviteCodeEntity.IsUserInvited)
{
throw new UserFriendlyException("该用户已被邀请,邀请码无效");
}
// 验证本周邀请码使用次数
var weekStart = CardFlipManager.GetWeekStartDate(DateTime.Now);
var weeklyUseCount = await _invitationRecordRepository._DbQueryable
.Where(x => x.InvitedUserId == userId)
.Where(x => x.InvitationTime >= weekStart)
.CountAsync();
if (weeklyUseCount >= CardFlipManager.MAX_INVITE_FLIPS)
{
throw new UserFriendlyException($"本周邀请码使用次数已达上限({CardFlipManager.MAX_INVITE_FLIPS}次),请下周再来");
}
// 检查当前用户的邀请码信息
var myInviteCode = await _inviteCodeRepository._DbQueryable
.Where(x => x.UserId == userId)
.FirstAsync();
// 标记邀请码为已使用
inviteCodeEntity.MarkAsUsed(userId);
await _inviteCodeRepository.UpdateAsync(inviteCodeEntity);
// 标记当前用户已被邀请(仅第一次使用邀请码时标记)
if (myInviteCode == null)
{
myInviteCode = new InviteCodeAggregateRoot(userId, GenerateUniqueInviteCode());
myInviteCode.MarkUserAsInvited();
await _inviteCodeRepository.InsertAsync(myInviteCode);
}
else if (!myInviteCode.IsUserInvited)
{
myInviteCode.MarkUserAsInvited();
await _inviteCodeRepository.UpdateAsync(myInviteCode);
}
// 创建邀请记录
var invitationRecord = new InvitationRecordAggregateRoot(
inviteCodeEntity.UserId,
userId,
inviteCode);
await _invitationRecordRepository.InsertAsync(invitationRecord);
_logger.LogInformation($"用户 {userId} 使用邀请码 {inviteCode} 成功");
return inviteCodeEntity.UserId;
}
/// <summary>
/// 检查用户是否已被邀请
/// </summary>
public async Task<bool> IsUserInvitedAsync(Guid userId)
{
var inviteCode = await _inviteCodeRepository._DbQueryable
.Where(x => x.UserId == userId)
.FirstAsync();
return inviteCode?.IsUserInvited ?? false;
}
/// <summary>
/// 生成唯一邀请码
/// </summary>
private string GenerateUniqueInviteCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var random = new Random();
var code = new char[8];
for (int i = 0; i < code.Length; i++)
{
code[i] = chars[random.Next(chars.Length)];
}
return new string(code);
}
}
/// <summary>
/// 邀请历史记录DTO
/// </summary>
public class InvitationHistoryDto
{
/// <summary>
/// 被邀请人名称(脱敏)
/// </summary>
public string InvitedUserName { get; set; } = string.Empty;
/// <summary>
/// 邀请时间
/// </summary>
public DateTime InvitationTime { get; set; }
}

View File

@@ -1,9 +1,11 @@
using System.Text.Json;
using Volo.Abp.Domain.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Pay;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.SqlSugarCore.Abstractions;
using Yi.Framework.AiHub.Domain.Extensions;
namespace Yi.Framework.AiHub.Domain.Managers;
@@ -15,14 +17,18 @@ public class PayManager : DomainService
private readonly ISqlSugarRepository<PayNoticeRecordAggregateRoot, Guid> _payNoticeRepository;
private readonly ICurrentUser _currentUser;
private readonly ISqlSugarRepository<PayOrderAggregateRoot, Guid> _payOrderRepository;
private readonly ISqlSugarRepository<AiRechargeAggregateRoot, Guid> _rechargeRepository;
public PayManager(
ISqlSugarRepository<PayNoticeRecordAggregateRoot, Guid> payNoticeRepository,
ICurrentUser currentUser, ISqlSugarRepository<PayOrderAggregateRoot, Guid> payOrderRepository)
ICurrentUser currentUser,
ISqlSugarRepository<PayOrderAggregateRoot, Guid> payOrderRepository,
ISqlSugarRepository<AiRechargeAggregateRoot, Guid> rechargeRepository)
{
_payNoticeRepository = payNoticeRepository;
_currentUser = currentUser;
_payOrderRepository = payOrderRepository;
_rechargeRepository = rechargeRepository;
}
/// <summary>
@@ -38,18 +44,43 @@ public class PayManager : DomainService
throw new UserFriendlyException("用户未登录");
}
var userId = _currentUser.GetId();
// 如果是尊享包商品需要验证用户是否为VIP
if (goodsType.IsPremiumPackage())
{
if (!_currentUser.IsAiVip())
{
throw new UserFriendlyException("购买尊享包需要VIP资格请先开通VIP");
}
}
// 生成订单号
var outTradeNo = GenerateOutTradeNo();
// 获取商品信息
var goodsName = goodsType.GetDisplayName();
var totalAmount = goodsType.GetTotalAmount();
// 计算订单金额尊享包使用折扣价格VIP服务使用原价
decimal totalAmount;
if (goodsType.IsPremiumPackage())
{
// 获取用户累加充值金额
var totalRechargeAmount = await GetUserTotalRechargeAmountAsync(userId);
// 使用折扣后的价格
totalAmount = goodsType.GetDiscountedPrice(totalRechargeAmount);
}
else
{
// VIP服务使用原价
totalAmount = goodsType.GetTotalAmount();
}
// 创建订单实体
var payOrder = new PayOrderAggregateRoot
{
OutTradeNo = outTradeNo,
UserId = _currentUser.GetId(),
UserId = userId,
UserName = _currentUser.UserName ?? string.Empty,
TotalAmount = totalAmount,
GoodsName = goodsName,
@@ -69,7 +100,7 @@ public class PayManager : DomainService
/// <param name="tradeStatus">交易状态</param>
/// <param name="tradeNo">支付宝交易号</param>
/// <returns></returns>
public async Task UpdateOrderStatusAsync(string outTradeNo, TradeStatusEnum tradeStatus, string? tradeNo = null)
public async Task<PayOrderAggregateRoot> UpdateOrderStatusAsync(string outTradeNo, TradeStatusEnum tradeStatus, string? tradeNo = null)
{
var order = await _payOrderRepository.GetFirstAsync(x => x.OutTradeNo == outTradeNo);
if (order == null)
@@ -84,6 +115,7 @@ public class PayManager : DomainService
}
await _payOrderRepository.UpdateAsync(order);
return order;
}
@@ -128,13 +160,25 @@ public class PayManager : DomainService
/// <returns></returns>
private TradeStatusEnum ParseTradeStatus(string tradeStatus)
{
return tradeStatus switch
if (Enum.TryParse<TradeStatusEnum>(tradeStatus, out var result))
{
"WAIT_BUYER_PAY" => TradeStatusEnum.WAIT_BUYER_PAY,
"TRADE_SUCCESS" => TradeStatusEnum.TRADE_SUCCESS,
"TRADE_FINISHED" => TradeStatusEnum.TRADE_FINISHED,
"TRADE_CLOSED" => TradeStatusEnum.TRADE_CLOSED,
_ => TradeStatusEnum.WAIT_TRADE
};
return result;
}
return TradeStatusEnum.WAIT_TRADE;
}
}
/// <summary>
/// 获取用户累加充值金额
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>累加充值金额</returns>
public async Task<decimal> GetUserTotalRechargeAmountAsync(Guid userId)
{
var totalAmount = await _rechargeRepository
._DbQueryable
.Where(x => x.UserId == userId )
.SumAsync(x => x.RechargeAmount);
return totalAmount;
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.Extensions.Logging;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
/// <summary>
/// 尊享包管理器
/// </summary>
public class PremiumPackageManager : DomainService
{
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot, Guid> _premiumPackageRepository;
private readonly ILogger<PremiumPackageManager> _logger;
private readonly ISqlSugarRepository<AiRechargeAggregateRoot> _rechargeRepository;
public PremiumPackageManager(
ISqlSugarRepository<PremiumPackageAggregateRoot, Guid> premiumPackageRepository,
ILogger<PremiumPackageManager> logger, ISqlSugarRepository<AiRechargeAggregateRoot> rechargeRepository)
{
_premiumPackageRepository = premiumPackageRepository;
_logger = logger;
_rechargeRepository = rechargeRepository;
}
/// <summary>
/// 为用户创建尊享包
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="goodsType">商品类型</param>
/// <param name="totalAmount">支付金额</param>
/// <param name="expireMonths">过期月数0或null表示永久</param>
/// <returns></returns>
public async Task<PremiumPackageAggregateRoot> CreatePremiumPackageAsync(
Guid userId,
GoodsTypeEnum goodsType,
decimal totalAmount,
int? expireMonths = null)
{
if (!goodsType.IsPremiumPackage())
{
throw new UserFriendlyException($"商品类型 {goodsType} 不是尊享包商品");
}
var tokenAmount = goodsType.GetTokenAmount();
var packageName = goodsType.GetDisplayName();
var premiumPackage = new PremiumPackageAggregateRoot(userId, tokenAmount, packageName)
{
PurchaseAmount = totalAmount
};
// 设置到期时间
if (expireMonths.HasValue && expireMonths.Value > 0)
{
premiumPackage.SetExpireDateTime(DateTime.Now.AddMonths(expireMonths.Value));
}
await _premiumPackageRepository.InsertAsync(premiumPackage);
// 创建充值记录
var rechargeRecord = new AiRechargeAggregateRoot
{
UserId = userId,
RechargeAmount = totalAmount,
Content = packageName,
ExpireDateTime = premiumPackage.ExpireDateTime,
Remark = "自助充值",
ContactInfo = null,
RechargeType = RechargeTypeEnum.PremiumPackage
};
// 保存充值记录到数据库
await _rechargeRepository.InsertAsync(rechargeRecord);
_logger.LogInformation(
$"用户 {userId} 购买尊享包成功: {packageName}, Token数量: {tokenAmount}, 金额: {totalAmount}");
return premiumPackage;
}
/// <summary>
/// 消耗用户尊享包的Token
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="tokenCount">需要消耗的Token数量</param>
/// <returns>是否消耗成功</returns>
public async Task<bool> TryConsumeTokensAsync(Guid userId, long tokenCount)
{
// 获取用户所有可用的尊享包按剩余token升序排列优先消耗快用完的
var availablePackages = await _premiumPackageRepository._DbQueryable
.Where(x => x.UserId == userId && x.IsActive)
.OrderBy(x => x.CreationTime)
.ToListAsync();
if (!availablePackages.Any())
{
_logger.LogWarning($"用户 {userId} 没有可用的尊享包");
return false;
}
// 过滤掉已过期的包
var validPackages = availablePackages
.Where(p => p.IsAvailable())
.ToList();
if (!validPackages.Any())
{
_logger.LogWarning($"用户 {userId} 的尊享包已全部过期");
return false;
}
var firstPackage = validPackages.First();
// 直接扣除最早的token包需要消耗的token允许扣减到负数
firstPackage.ConsumeTokens(tokenCount);
await _premiumPackageRepository.UpdateAsync(firstPackage);
return true;
}
/// <summary>
/// 获取用户可用的尊享包总Token数
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>可用Token总数</returns>
public async Task<long> GetAvailableTokensAsync(Guid userId)
{
return await _premiumPackageRepository._DbQueryable
.Where(x => x.UserId == userId && x.IsActive && x.RemainingTokens > 0)
.Where(p => p.IsActive)
.Where(p => !p.ExpireDateTime.HasValue || p.ExpireDateTime.Value >= DateTime.Now)
.SumAsync(p => p.RemainingTokens);
}
}

View File

@@ -21,11 +21,11 @@ public class UsageStatisticsManager : DomainService
public async Task SetUsageAsync(Guid? userId, string modelId, ThorUsageResponse? tokenUsage)
{
long inputTokenCount = tokenUsage?.PromptTokens
?? tokenUsage.InputTokens
?? tokenUsage?.InputTokens
?? 0;
long outputTokenCount = tokenUsage?.CompletionTokens
?? tokenUsage.OutputTokens
?? tokenUsage?.OutputTokens
?? 0;
await using (await DistributedLock.AcquireLockAsync($"UsageStatistics:{userId?.ToString()}"))

View File

@@ -1,7 +1,5 @@
using Alipay.EasySDK.Factory;
using Alipay.EasySDK.Kernel;
using Alipay.EasySDK.Kernel.Util;
using Alipay.EasySDK.Payment.FaceToFace.Models;
using Dm.util;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -10,11 +8,15 @@ using Yi.Framework.AiHub.Domain.AiGateWay;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureDatabricks.Chats;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Chats;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Images;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorClaude.Chats;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorDeepSeek.Chats;
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorSiliconFlow.Embeddings;
using Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
using Yi.Framework.AiHub.Domain.Shared;
using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
using Yi.Framework.Mapster;
using Microsoft.Extensions.DependencyInjection;
namespace Yi.Framework.AiHub.Domain
{
@@ -30,26 +32,47 @@ namespace Yi.Framework.AiHub.Domain
var configuration = context.Services.GetConfiguration();
var services = context.Services;
// Configure<AiGateWayOptions>(configuration.GetSection("AiGateWay"));
#region OpenAi ChatCompletion
services.AddKeyedTransient<IChatCompletionService, AzureOpenAiChatCompletionCompletionsService>(
nameof(AzureOpenAiChatCompletionCompletionsService));
services.AddKeyedTransient<IChatCompletionService, AzureDatabricksChatCompletionsService>(
nameof(AzureDatabricksChatCompletionsService));
services.AddKeyedTransient<IChatCompletionService, DeepSeekChatCompletionsService>(
nameof(DeepSeekChatCompletionsService));
services.AddKeyedTransient<IChatCompletionService, OpenAiChatCompletionsService>(
nameof(OpenAiChatCompletionsService));
services.AddKeyedTransient<IChatCompletionService, ClaudiaChatCompletionsService>(
nameof(ClaudiaChatCompletionsService));
#endregion
#region Anthropic ChatCompletion
services.AddKeyedTransient<IAnthropicChatCompletionService, CustomOpenAIAnthropicChatCompletionsService>(
nameof(CustomOpenAIAnthropicChatCompletionsService));
services.AddKeyedTransient<IAnthropicChatCompletionService, AnthropicChatCompletionsService>(
nameof(AnthropicChatCompletionsService));
#endregion
#region Image
services.AddKeyedTransient<IImageService, AzureOpenAIServiceImageService>(
nameof(AzureOpenAIServiceImageService));
#endregion
#region Embedding
services.AddKeyedTransient<ITextEmbeddingService, SiliconFlowTextEmbeddingService>(
nameof(SiliconFlowTextEmbeddingService));
#endregion
//ai模型特殊性兼容处理
Configure<SpecialCompatibleOptions>(options =>
{
options.Handles.add(request => { request.CompatibleCodeCompletion(); });
options.Handles.Add(request =>
{
if (request.Model == "o1")
@@ -76,15 +99,39 @@ namespace Yi.Framework.AiHub.Domain
};
}
});
options.Handles.Add(request =>
{
if (request.MaxTokens >= 16384)
{
request.MaxTokens = 16384;
}
});
options.AnthropicHandles.add(request =>
{
if (request.MaxTokens is null || request.MaxTokens <= 0)
{
throw new UserFriendlyException("MaxTokens must be greater than or equal to 0");
}
});
});
//配置支付宝支付
var config = configuration.GetSection("Alipay").Get<Config>();
Factory.SetOptions(config);
}
public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
{
//配置服务号
Configure<FuwuhaoOptions>(configuration.GetSection("Fuwuhao"));
services.AddHttpClient()
.ConfigureHttpClientDefaults(builder =>
{
builder.ConfigureHttpClient(client =>
{
client.DefaultRequestHeaders.Add("User-Agent","Apifox/1.0.0 (https://apifox.com)");
client.Timeout = TimeSpan.FromMinutes(10);
});
});
}
}
}

View File

@@ -49,7 +49,7 @@ public class SemanticKernelClient : ITransientDependency
// MaxTokens =1000
};
var chatCompletionService = this.Kernel.GetRequiredService<IChatCompletionService>();
var chatCompletionService = this.Kernel.GetRequiredService<IChatCompletionService>("gpt-5-mini");
var results = await chatCompletionService.GetChatMessageContentsAsync(
question,

View File

@@ -32,18 +32,18 @@ namespace Yi.Framework.Stock.Domain
foreach (var optionsModelId in options.ModelIds)
{
services.AddKernel()
.AddAzureOpenAIChatCompletion(
deploymentName: optionsModelId,
endpoint: options.Endpoint,
apiKey: options.ApiKey,
serviceId: optionsModelId,
modelId: optionsModelId);
// .AddAzureOpenAIChatCompletion(
// deploymentName: optionsModelId,
// endpoint: options.Endpoint,
// apiKey: options.ApiKey,
// serviceId: optionsModelId,
// modelId: optionsModelId);
// .AddOpenAIChatCompletion(
// serviceId: optionsModelId,
// modelId: optionsModelId,
// endpoint: new Uri(options.Endpoint),
// apiKey: options.ApiKey);
.AddOpenAIChatCompletion(
serviceId: optionsModelId,
modelId: optionsModelId,
endpoint: new Uri(options.Endpoint),
apiKey: options.ApiKey);
}
// 添加插件
services.AddSingleton<KernelPlugin>(sp =>

View File

@@ -15,7 +15,8 @@ namespace Yi.Framework.Bbs.Domain.Entities.Integral
[SugarTable("SignIn")]
[SugarIndex($"index_{nameof(CreatorId)}", nameof(CreatorId), OrderByType.Asc)]
public class SignInAggregateRoot : AggregateRoot<Guid>, ICreationAuditedObject
public class
SignInAggregateRoot : AggregateRoot<Guid>, ICreationAuditedObject
{
[SugarColumn(IsPrimaryKey = true)]

View File

@@ -170,7 +170,7 @@ public class WeChatMiniProgramAccountService : ApplicationService
//走普通注册流程
//同时再加一个小程序绑定即可
var userName = GenerateRandomString(6);
await _accountService.PostTempRegisterAsync(new RegisterDto
await _accountService.PostSystemRegisterAsync(new RegisterDto
{
UserName = $"ls_{userName}",
Password = GenerateRandomString(20),

View File

@@ -36,6 +36,11 @@
/// 昵称
/// </summary>
public string? Nick{ get; set; }
/// <summary>
/// 头像
/// </summary>
public string? Icon { get; set; }
}
}

View File

@@ -24,8 +24,9 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices
/// </summary>
/// <param name="userName"></param>
/// <param name="phone"></param>
/// <param name="userId"></param>
/// <returns></returns>
Task<UserRoleMenuDto?> GetAsync(string? userName, long? phone);
Task<UserRoleMenuDto?> GetAsync(string? userName, long? phone,Guid? userId = null);
/// <summary>
/// 校验电话验证码,需要与电话号码绑定
@@ -38,6 +39,6 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices
/// 不需要验证,为了给第三方使用,例如微信小程序,后续可通过绑定操作,进行账号合并
/// </summary>
/// <param name="input"></param>
Task PostTempRegisterAsync(RegisterDto input);
Task<Guid> PostSystemRegisterAsync(RegisterDto input);
}
}

View File

@@ -246,20 +246,21 @@ namespace Yi.Framework.Rbac.Application.Services
//注册之后免再次登录直接给前端token
var userId = await _accountManager.RegisterAsync(input.UserName, input.Password, input.Phone, input.Email,
input.Nick);
input.Nick, null);
return await this.PostLoginAsync(userId);
}
/// <summary>
/// 临时注册
/// 系统直接注册用户
/// 不需要验证,为了给第三方使用,例如微信小程序,后续可通过绑定操作,进行账号合并
/// </summary>
/// <param name="input"></param>
[RemoteService(isEnabled: false)]
public async Task PostTempRegisterAsync(RegisterDto input)
public async Task<Guid> PostSystemRegisterAsync(RegisterDto input)
{
//注册领域逻辑
await _accountManager.RegisterAsync(input.UserName, input.Password, input.Phone, input.Email, input.Nick);
return await _accountManager.RegisterAsync(input.UserName, input.Password, input.Phone, input.Email,
input.Nick, input.Icon);
}
/// <summary>
@@ -283,11 +284,12 @@ namespace Yi.Framework.Rbac.Application.Services
}
[RemoteService(isEnabled: false)]
public async Task<UserRoleMenuDto?> GetAsync(string? userName, long? phone)
public async Task<UserRoleMenuDto?> GetAsync(string? userName, long? phone = null, Guid? userId = null)
{
var user = await _userRepository._DbQueryable
.WhereIF(userName is not null, x => x.UserName == userName)
.WhereIF(phone is not null, x => x.Phone == phone)
.WhereIF(userId is not null, x => x.Id == userId)
.Where(x => x.State == true)
.FirstAsync();

View File

@@ -19,12 +19,14 @@ namespace Yi.Framework.Rbac.Domain.Entities
{
}
public UserAggregateRoot(string userName, string password, long? phone, string? email, string? nick = null)
public UserAggregateRoot(string userName, string password, long? phone, string? email, string? nick = null,
string? icon = null)
{
UserName = userName;
EncryPassword.Password = password;
Phone = phone;
Email = email;
Icon = icon;
Nick = string.IsNullOrWhiteSpace(nick) ? "萌新-" + userName : nick.Trim();
BuildPassword();
}

Some files were not shown because too many files have changed in this diff Show More