Insight proper payment validation

This commit is contained in:
2025-11-16 01:06:33 +08:00
parent 5418489f77
commit b29f4fce4d
7 changed files with 347 additions and 12 deletions

View File

@@ -12,6 +12,7 @@ public class AppDatabase(
{ {
public DbSet<SnThinkingSequence> ThinkingSequences { get; set; } public DbSet<SnThinkingSequence> ThinkingSequences { get; set; }
public DbSet<SnThinkingThought> ThinkingThoughts { get; set; } public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
public DbSet<SnUnpaidAccount> UnpaidAccounts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View File

@@ -1,21 +1,49 @@
using DysonNetwork.Insight.Thought; using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Insight.Controllers; namespace DysonNetwork.Insight.Controllers;
[ApiController] [ApiController]
[Route("/api/billing")] [Route("api/billing")]
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase public class BillingController(AppDatabase db, ThoughtService thoughtService, ILogger<BillingController> logger)
: ControllerBase
{ {
[HttpPost("settle")] [HttpGet("status")]
[Authorize] public async Task<IActionResult> GetBillingStatus()
[RequiredPermission("maintenance", "insight.billing.settle")]
public async Task<IActionResult> ProcessTokenBilling()
{ {
await thoughtService.SettleThoughtBills(logger); if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Ok(); return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMarked = await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId);
return Ok(isMarked ? new { status = "unpaid" } : new { status = "ok" });
}
[HttpPost("retry")]
public async Task<IActionResult> RetryBilling()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var (success, cost) = await thoughtService.RetryBillingForAccountAsync(accountId, logger);
if (success)
{
if (cost > 0)
{
return Ok(new { message = $"Billing retry successful. Billed {cost} points." });
}
else
{
return Ok(new { message = "No outstanding payment found." });
}
}
else
{
return BadRequest(new { message = "Billing retry failed. Please check your balance and try again." });
}
} }
} }

View File

@@ -0,0 +1,159 @@
// <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("20251115165833_AddUnpaidAccounts")]
partial class AddUnpaidAccounts
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.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<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<List<SnThinkingMessagePart>>("Parts")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parts");
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.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (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,34 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddUnpaidAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "unpaid_accounts",
columns: table => new
{
account_id = table.Column<Guid>(type: "uuid", nullable: false),
marked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_unpaid_accounts", x => x.account_id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "unpaid_accounts");
}
}
}

View File

@@ -122,6 +122,23 @@ namespace DysonNetwork.Insight.Migrations
b.ToTable("thinking_thoughts", (string)null); b.ToTable("thinking_thoughts", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")

View File

@@ -133,6 +133,13 @@ public class ThoughtService(
foreach (var accountGroup in groupedByAccount) foreach (var accountGroup in groupedByAccount)
{ {
var accountId = accountGroup.Key; var accountId = accountGroup.Key;
if (await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId))
{
logger.LogWarning("Skipping billing for marked account {accountId}", accountId);
continue;
}
var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken); var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0); var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0);
@@ -166,9 +173,86 @@ public class ThoughtService(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error billing for account {accountId}", accountId); logger.LogError(ex, "Error billing for account {accountId}", accountId);
if (!await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId))
{
db.UnpaidAccounts.Add(new SnUnpaidAccount { AccountId = accountId, MarkedAt = DateTime.UtcNow });
}
} }
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task<(bool success, long cost)> RetryBillingForAccountAsync(Guid accountId, ILogger logger)
{
var isMarked = await db.UnpaidAccounts.FirstOrDefaultAsync(u => u.AccountId == accountId);
if (isMarked == null)
{
logger.LogInformation("Account {accountId} is not marked for unpaid bills.", accountId);
return (true, 0);
}
var sequences = await db
.ThinkingSequences.Where(s => s.AccountId == accountId && s.PaidToken < s.TotalToken)
.ToListAsync();
if (!sequences.Any())
{
logger.LogInformation("No unpaid sequences found for account {accountId}. Unmarking.", accountId);
db.UnpaidAccounts.Remove(isMarked);
await db.SaveChangesAsync();
return (true, 0);
}
var totalUnpaidTokens = sequences.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0);
if (cost == 0)
{
logger.LogInformation("Unpaid tokens for {accountId} resulted in zero cost. Marking as paid and unmarking.", accountId);
foreach (var sequence in sequences)
{
sequence.PaidToken = sequence.TotalToken;
}
db.UnpaidAccounts.Remove(isMarked);
await db.SaveChangesAsync();
return (true, 0);
}
try
{
var date = DateTime.Now.ToString("yyyy-MM-dd");
await paymentService.CreateTransactionWithAccountAsync(
new CreateTransactionWithAccountRequest
{
PayerAccountId = accountId.ToString(),
Currency = WalletCurrency.SourcePoint,
Amount = cost.ToString(),
Remarks = $"Wage for SN-chan on {date} (Retry)",
Type = TransactionType.System,
}
);
foreach (var sequence in sequences)
{
sequence.PaidToken = sequence.TotalToken;
}
db.UnpaidAccounts.Remove(isMarked);
logger.LogInformation(
"Successfully billed {cost} points for account {accountId} on retry.",
cost,
accountId
);
await db.SaveChangesAsync();
return (true, cost);
}
catch (Exception ex)
{
logger.LogError(ex, "Error retrying billing for account {accountId}", accountId);
return (false, cost);
}
}
} }

View File

@@ -0,0 +1,12 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Shared.Models
{
public class SnUnpaidAccount
{
[Key]
public Guid AccountId { get; set; }
public DateTime MarkedAt { get; set; }
}
}