diff --git a/Tiku.Api/Controllers/PlatformSaasController.cs b/Tiku.Api/Controllers/PlatformSaasController.cs index 9189c74..004fdc2 100644 --- a/Tiku.Api/Controllers/PlatformSaasController.cs +++ b/Tiku.Api/Controllers/PlatformSaasController.cs @@ -90,6 +90,18 @@ public sealed class PlatformSaasController( public Task> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + [HttpGet("usage")] + [EndpointSummary("查询租户 SaaS 用量")] + [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] + public Task> Usage(Guid? tenantId, int limit = 100, CancellationToken cancellationToken = default) => + billingService.GetUsageAsync(Actor(), new PlatformBillingAdminQuery(tenantId, null, limit), cancellationToken); + + [HttpGet("invoices/reminders")] + [EndpointSummary("查询平台 SaaS 账单提醒")] + [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] + public Task> InvoiceReminders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => + billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + [HttpGet("subscriptions")] [EndpointSummary("查询租户 SaaS 订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] diff --git a/Tiku.Api/wwwroot/platform-admin/app.js b/Tiku.Api/wwwroot/platform-admin/app.js index c390722..58a7bfd 100644 --- a/Tiku.Api/wwwroot/platform-admin/app.js +++ b/Tiku.Api/wwwroot/platform-admin/app.js @@ -343,13 +343,13 @@ function saveData() { } } function backendStatusLabel() { - if (!platformApi?.isEnabled()) return '演示模式可用'; + if (!platformApi?.isEnabled()) return '静态演示数据'; if (state.apiConnection === 'connecting') return '正在连接后端'; if (state.apiConnection === 'connected') { - const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项无权限/失败` : ''; + const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项加载失败` : ''; return `后端已连接${partial}`; } - return '演示模式可用'; + return '后端不可用 · 已保留静态演示'; } function backendErrorText(error, fallback = '平台接口请求失败') { if (!error) return fallback; @@ -424,10 +424,14 @@ async function hydrateFromBackend({ silent = false } = {}) { if (!silent) showToast(`后端数据已刷新 · ${successful}/${result.requested} 项成功`); return true; } catch (error) { + if (error?.status === 401) { + window.GongxuePlatformAuth?.requireReauthentication(); + return false; + } state.apiConnection = 'degraded'; state.apiFailedOperations = 1; render(); - if (!silent) showToast(`${backendErrorText(error, '后端连接失败')} · 已切换至演示模式`, 'error'); + if (!silent) showToast(`${backendErrorText(error, '后端连接失败')} · 已保留静态演示`, 'error'); return false; } } diff --git a/Tiku.Api/wwwroot/platform-admin/index.html b/Tiku.Api/wwwroot/platform-admin/index.html index 8eb5e75..497d823 100644 --- a/Tiku.Api/wwwroot/platform-admin/index.html +++ b/Tiku.Api/wwwroot/platform-admin/index.html @@ -8,11 +8,11 @@ - - + + - - + + diff --git a/Tiku.Api/wwwroot/platform-admin/platform-api.js b/Tiku.Api/wwwroot/platform-admin/platform-api.js index 30fa44e..991bdf3 100644 --- a/Tiku.Api/wwwroot/platform-admin/platform-api.js +++ b/Tiku.Api/wwwroot/platform-admin/platform-api.js @@ -1081,8 +1081,8 @@ storage_gb: 'storageGB', traffic_tb: 'trafficTB', }; - const field = metricMap[item.metricKey]; - if (field) record[field] = Number(item.metricValue || 0); + const field = metricMap[item.metricKey || item.metricCode]; + if (field) record[field] = Number(item.metricValue ?? item.usedValue ?? 0); records.set(key, record); }); return Array.from(records.values()); diff --git a/Tiku.Api/wwwroot/platform-admin/platform-auth.js b/Tiku.Api/wwwroot/platform-admin/platform-auth.js index a06039a..6697a43 100644 --- a/Tiku.Api/wwwroot/platform-admin/platform-auth.js +++ b/Tiku.Api/wwwroot/platform-admin/platform-auth.js @@ -116,6 +116,12 @@ return new Promise(resolve => { pendingResolve = resolve; }); } + function requireReauthentication() { + clearSession(); + renderLogin(); + pendingResolve = () => location.reload(); + } + async function logout() { const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY); try { if (refreshToken) await post('/api/auth/logout', { refreshToken }); } catch { /* Clear the browser session even when revocation cannot be reached. */ } @@ -129,6 +135,7 @@ try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; } }, logout, + requireReauthentication, requireSession, }); })(); diff --git a/Tiku.Api/wwwroot/platform-admin/runtime-config.js b/Tiku.Api/wwwroot/platform-admin/runtime-config.js index 2f872bc..cc4d436 100644 --- a/Tiku.Api/wwwroot/platform-admin/runtime-config.js +++ b/Tiku.Api/wwwroot/platform-admin/runtime-config.js @@ -1,7 +1,7 @@ /* * 平台端公开运行时配置。 * - * 默认使用可交互演示数据。其他部署环境可在本文件之前注入同名对象, + * 默认连接同源后端。其他部署环境可在本文件之前注入同名对象, * 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥 * 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。 * @@ -16,9 +16,9 @@ * }; */ window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || { - mode: 'mock', + mode: 'api', apiBaseUrl: '', - fallbackToMock: true, + fallbackToMock: false, timeoutMs: 10000, getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '', }; diff --git a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs index dcf8f59..6b3d534 100644 --- a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs +++ b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs @@ -187,6 +187,8 @@ public interface IPlatformBillingAdminService Task> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); Task> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); Task> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); Task ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default); Task UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default); diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs index 04a6d37..691adb0 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs @@ -53,6 +53,25 @@ internal sealed class PlatformBillingAdminService( return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); }, cancellationToken); + public Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => + ExecuteAsync>("list SaaS usage", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.TenantFeatureUsages.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode).Take(Limit(query.Limit)).ToArrayAsync(token); + }, cancellationToken); + + public Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => + ExecuteAsync>("list SaaS invoice reminders", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformBillingInvoiceReminders.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.ReminderDate).ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + }, cancellationToken); + public Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => ExecuteAsync>("list SaaS subscriptions", async (services, token) => { diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs index 1fb62b5..7ec262a 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -19,6 +19,78 @@ namespace Tiku.IntegrationTests.Api; public sealed class PlatformAdminEndpointTests { + [Fact] + public async Task Platform_super_admin_can_load_every_platform_console_bootstrap_endpoint() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }); + var platform = await SeedPlatformAdminAsync(factory); + await factory.SeedAsync(new Tenant + { + Id = Guid.NewGuid(), + Slug = "platform-console-content", + Name = "Platform Console Content", + Mode = TenantMode.PlatformOwned, + Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active + }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); + string[] endpoints = + [ + "/api/backoffice/platform/bootstrap", + "/api/platform-admin/overview", + "/api/platform-admin/domains?limit=200", + "/api/platform-admin/saas/catalog", + "/api/platform-admin/tenants?limit=200", + "/api/platform-admin/saas/subscriptions?limit=200", + "/api/platform-admin/saas/orders?limit=200", + "/api/platform-admin/saas/refunds?limit=200", + "/api/platform-admin/saas/invoices?limit=200", + "/api/platform-admin/saas/payments?limit=200", + "/api/platform-admin/saas/usage?limit=200", + "/api/platform-admin/saas/invoices/reminders?limit=100", + "/api/platform-admin/question-banks?status=all", + "/api/platform-admin/staff?limit=200", + "/api/platform-admin/saas/dunning/channels?limit=100", + "/api/platform-admin/saas/dunning/events?limit=100", + "/api/platform-admin/audit-logs?limit=200", + "/api/platform-admin/audit-alerts?limit=100", + "/api/platform-admin/tenant-capabilities/crm/configs?limit=200", + "/api/platform-admin/tenant-capabilities/crm/leads?limit=200", + "/api/platform-admin/tenant-capabilities/crm/logs?limit=200", + "/api/platform-admin/tenant-capabilities/sms/channels?limit=200", + "/api/platform-admin/tenant-capabilities/sms/templates?limit=200", + "/api/platform-admin/tenant-capabilities/sms/logs?limit=200", + "/api/platform-admin/payment-settings/apps?limit=200", + "/api/platform-admin/payment-settings/channels?limit=200", + "/api/platform-admin/payment-settings/rebates/summary", + "/api/platform-admin/tenant-capabilities/payments/apps?limit=200", + "/api/platform-admin/payment-settings/events?limit=100", + "/api/platform-admin/tenant-capabilities/payments/events?limit=100" + ]; + + var responses = await Task.WhenAll(endpoints.Select(async endpoint => + { + using var response = await client.GetAsync(endpoint); + return new + { + Endpoint = endpoint, + response.StatusCode, + Body = await response.Content.ReadAsStringAsync() + }; + })); + var failures = responses.Where(response => response.StatusCode != HttpStatusCode.OK).ToArray(); + + Assert.True( + failures.Length == 0, + string.Join(Environment.NewLine, failures.Select(failure => + $"{failure.Endpoint}: {(int)failure.StatusCode} {failure.StatusCode} {failure.Body}"))); + } + [Fact] public async Task Platform_admin_can_publish_immutable_saas_offering_and_manage_tenant_operations() {