Add billing

This commit is contained in:
2025-10-26 21:42:53 +08:00
parent 50133684c7
commit 43e50a00ce
16 changed files with 385 additions and 10 deletions

View 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();
}
}

View File

@@ -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>

View 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
}
}
}

View 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");
}
}
}

View File

@@ -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");

View File

@@ -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();

View 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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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();
}
}

View File

@@ -22,6 +22,6 @@
"Thinking": {
"Provider": "deepseek",
"Model": "deepseek-chat",
"ApiKey": "sk-cd709f9f1b96432e99d2d992392b4220"
"ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09"
}
}

View File

@@ -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";

View File

@@ -8,6 +8,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!;
}

View File

@@ -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()
@@ -113,4 +118,4 @@ public static class ServiceInjectionHelper
return services;
}
}
}