✨ Add billing
This commit is contained in:
21
DysonNetwork.Insight/Controllers/BillingController.cs
Normal file
21
DysonNetwork.Insight/Controllers/BillingController.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using DysonNetwork.Insight.Thought;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Insight.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/billing")]
|
||||
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("settle")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "insight.billing.settle")]
|
||||
public async Task<IActionResult> ProcessTokenBilling()
|
||||
{
|
||||
await thoughtService.SettleThoughtBills(logger);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" Version="1.66.0-alpha" />
|
||||
<PackageReference Include="Quartz" Version="3.15.0" />
|
||||
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
146
DysonNetwork.Insight/Migrations/20251026134218_AddBilling.Designer.cs
generated
Normal file
146
DysonNetwork.Insight/Migrations/20251026134218_AddBilling.Designer.cs
generated
Normal file
@@ -0,0 +1,146 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Insight;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Insight.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20251026134218_AddBilling")]
|
||||
partial class AddBilling
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<long>("PaidToken")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("paid_token");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.Property<long>("TotalToken")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("total_token");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_thinking_sequences");
|
||||
|
||||
b.ToTable("thinking_sequences", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<List<SnThinkingChunk>>("Chunks")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("chunks");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<List<SnCloudFileReferenceObject>>("Files")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("files");
|
||||
|
||||
b.Property<string>("ModelName")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("model_name");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid>("SequenceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sequence_id");
|
||||
|
||||
b.Property<long>("TokenCount")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("token_count");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_thinking_thoughts");
|
||||
|
||||
b.HasIndex("SequenceId")
|
||||
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
|
||||
|
||||
b.ToTable("thinking_thoughts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
|
||||
.WithMany()
|
||||
.HasForeignKey("SequenceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
|
||||
|
||||
b.Navigation("Sequence");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
62
DysonNetwork.Insight/Migrations/20251026134218_AddBilling.cs
Normal file
62
DysonNetwork.Insight/Migrations/20251026134218_AddBilling.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Insight.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBilling : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "model_name",
|
||||
table: "thinking_thoughts",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "token_count",
|
||||
table: "thinking_thoughts",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "paid_token",
|
||||
table: "thinking_sequences",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "total_token",
|
||||
table: "thinking_sequences",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "model_name",
|
||||
table: "thinking_thoughts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "token_count",
|
||||
table: "thinking_thoughts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "paid_token",
|
||||
table: "thinking_sequences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "total_token",
|
||||
table: "thinking_sequences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,19 @@ namespace DysonNetwork.Insight.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<long>("PaidToken")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("paid_token");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.Property<long>("TotalToken")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("total_token");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -88,6 +96,11 @@ namespace DysonNetwork.Insight.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("files");
|
||||
|
||||
b.Property<string>("ModelName")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("model_name");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("role");
|
||||
@@ -96,6 +109,10 @@ namespace DysonNetwork.Insight.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sequence_id");
|
||||
|
||||
b.Property<long>("TokenCount")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("token_count");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
@@ -16,6 +16,7 @@ builder.Services.AddAppServices();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
builder.Services.AddAppBusinessServices();
|
||||
builder.Services.AddAppScheduledJobs();
|
||||
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddAccountService();
|
||||
|
||||
25
DysonNetwork.Insight/Startup/ScheduledJobsConfiguration.cs
Normal file
25
DysonNetwork.Insight/Startup/ScheduledJobsConfiguration.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Insight.Startup;
|
||||
|
||||
public static class ScheduledJobsConfiguration
|
||||
{
|
||||
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
|
||||
{
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var tokenBillingJob = new JobKey("TokenBilling");
|
||||
q.AddJob<TokenBillingJob>(opts => opts.WithIdentity(tokenBillingJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(tokenBillingJob)
|
||||
.WithIdentity("TokenBillingTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(5)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ThoughtProvider>();
|
||||
services.AddScoped<ThoughtService>();
|
||||
|
||||
// Add gRPC clients for ThoughtService
|
||||
services.AddGrpcClient<Shared.Proto.PaymentService.PaymentServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
|
||||
services.AddGrpcClient<Shared.Proto.WalletService.WalletServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
12
DysonNetwork.Insight/Startup/TokenBillingJob.cs
Normal file
12
DysonNetwork.Insight/Startup/TokenBillingJob.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using DysonNetwork.Insight.Thought;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Insight.Startup;
|
||||
|
||||
public class TokenBillingJob(ThoughtService thoughtService, ILogger<TokenBillingJob> logger) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await thoughtService.SettleThoughtBills(logger);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,8 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
|
||||
sequence,
|
||||
accumulatedContent.ToString(),
|
||||
ThinkingThoughtRole.Assistant,
|
||||
thinkingChunks
|
||||
thinkingChunks,
|
||||
provider.ModelDefault
|
||||
);
|
||||
|
||||
// Write the topic if it was newly set, then the thought object as JSON to the stream
|
||||
|
||||
@@ -26,7 +26,7 @@ public class ThoughtProvider
|
||||
public Kernel Kernel { get; }
|
||||
|
||||
private string? ModelProviderType { get; set; }
|
||||
private string? ModelDefault { get; set; }
|
||||
public string? ModelDefault { get; set; }
|
||||
|
||||
[Experimental("SKEXP0050")]
|
||||
public ThoughtProvider(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PaymentService = DysonNetwork.Shared.Proto.PaymentService;
|
||||
using TransactionType = DysonNetwork.Shared.Proto.TransactionType;
|
||||
using WalletService = DysonNetwork.Shared.Proto.WalletService;
|
||||
|
||||
namespace DysonNetwork.Insight.Thought;
|
||||
|
||||
public class ThoughtService(AppDatabase db, ICacheService cache)
|
||||
public class ThoughtService(AppDatabase db, ICacheService cache, PaymentService.PaymentServiceClient paymentService, WalletService.WalletServiceClient walletService)
|
||||
{
|
||||
public async Task<SnThinkingSequence?> GetOrCreateSequenceAsync(Guid accountId, Guid? sequenceId,
|
||||
string? topic = null)
|
||||
@@ -28,17 +32,28 @@ public class ThoughtService(AppDatabase db, ICacheService cache)
|
||||
SnThinkingSequence sequence,
|
||||
string content,
|
||||
ThinkingThoughtRole role,
|
||||
List<SnThinkingChunk>? chunks = null
|
||||
List<SnThinkingChunk>? chunks = null,
|
||||
string? model = null
|
||||
)
|
||||
{
|
||||
// Approximate token count (1 token ≈ 4 characters for GPT-like models)
|
||||
var tokenCount = content?.Length / 4 ?? 0;
|
||||
|
||||
var thought = new SnThinkingThought
|
||||
{
|
||||
SequenceId = sequence.Id,
|
||||
Content = content,
|
||||
Role = role,
|
||||
Chunks = chunks ?? []
|
||||
TokenCount = tokenCount,
|
||||
ModelName = model,
|
||||
Chunks = chunks ?? new List<SnThinkingChunk>()
|
||||
};
|
||||
db.ThinkingThoughts.Add(thought);
|
||||
|
||||
// Update sequence total tokens only for assistant responses
|
||||
if (role == ThinkingThoughtRole.Assistant)
|
||||
sequence.TotalToken += tokenCount;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate cache for this sequence's thoughts
|
||||
@@ -80,4 +95,58 @@ public class ThoughtService(AppDatabase db, ICacheService cache)
|
||||
|
||||
return (totalCount, sequences);
|
||||
}
|
||||
|
||||
public async Task SettleThoughtBills(ILogger logger)
|
||||
{
|
||||
var sequences = await db.ThinkingSequences
|
||||
.Where(s => s.PaidToken < s.TotalToken)
|
||||
.ToListAsync();
|
||||
|
||||
if (sequences.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No unpaid sequences found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by account
|
||||
var groupedByAccount = sequences.GroupBy(s => s.AccountId);
|
||||
|
||||
foreach (var accountGroup in groupedByAccount)
|
||||
{
|
||||
var accountId = accountGroup.Key;
|
||||
var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken);
|
||||
var cost = (long)Math.Ceiling(totalUnpaidTokens / 1000.0);
|
||||
|
||||
if (cost == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var walletResponse = await walletService.GetWalletAsync(new GetWalletRequest { AccountId = accountId.ToString() });
|
||||
var walletId = Guid.Parse(walletResponse.Id);
|
||||
|
||||
var date = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
await paymentService.CreateTransactionAsync(new CreateTransactionRequest
|
||||
{
|
||||
PayerWalletId = walletId.ToString(),
|
||||
PayeeWalletId = null,
|
||||
Currency = WalletCurrency.SourcePoint,
|
||||
Amount = cost.ToString(),
|
||||
Remarks = $"Wage for SN-chan on {date}",
|
||||
Type = TransactionType.System
|
||||
});
|
||||
|
||||
// Mark all sequences for this account as paid
|
||||
foreach (var sequence in accountGroup)
|
||||
sequence.PaidToken = sequence.TotalToken;
|
||||
|
||||
logger.LogInformation("Billed {cost} points for account {accountId}", cost, accountId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error billing for account {accountId}", accountId);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,6 @@
|
||||
"Thinking": {
|
||||
"Provider": "deepseek",
|
||||
"Model": "deepseek-chat",
|
||||
"ApiKey": "sk-cd709f9f1b96432e99d2d992392b4220"
|
||||
"ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
public class WalletCurrency
|
||||
public abstract class WalletCurrency
|
||||
{
|
||||
public const string SourcePoint = "points";
|
||||
public const string GoldenPoint = "golds";
|
||||
|
||||
@@ -9,6 +9,9 @@ public class SnThinkingSequence : ModelBase
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(4096)] public string? Topic { get; set; }
|
||||
|
||||
public long TotalToken { get; set; }
|
||||
public long PaidToken { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,11 +41,13 @@ public class SnThinkingThought : ModelBase
|
||||
public string? Content { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Files { get; set; } = [];
|
||||
|
||||
[Column(TypeName = "jsonb")] public List<SnThinkingChunk> Chunks { get; set; } = [];
|
||||
|
||||
public ThinkingThoughtRole Role { get; set; }
|
||||
|
||||
public long TokenCount { get; set; }
|
||||
[MaxLength(4096)] public string? ModelName { get; set; }
|
||||
|
||||
public Guid SequenceId { get; set; }
|
||||
[JsonIgnore] public SnThinkingSequence Sequence { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ public static class ServiceInjectionHelper
|
||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||
);
|
||||
|
||||
services.AddGrpcClient<WalletService.WalletServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
|
||||
);
|
||||
|
||||
services
|
||||
.AddGrpcClient<RealmService.RealmServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
|
||||
|
||||
Reference in New Issue
Block a user