forked from gongxuegit/tiku-backend.net
feat: 更新前端静态演示数据处理
This commit is contained in:
@@ -90,6 +90,18 @@ public sealed class PlatformSaasController(
|
|||||||
public Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
public Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||||
billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||||
|
|
||||||
|
[HttpGet("usage")]
|
||||||
|
[EndpointSummary("查询租户 SaaS 用量")]
|
||||||
|
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||||
|
public Task<IReadOnlyCollection<TenantFeatureUsage>> 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<IReadOnlyCollection<PlatformBillingInvoiceReminder>> InvoiceReminders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||||
|
billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||||
|
|
||||||
[HttpGet("subscriptions")]
|
[HttpGet("subscriptions")]
|
||||||
[EndpointSummary("查询租户 SaaS 订阅")]
|
[EndpointSummary("查询租户 SaaS 订阅")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||||
|
|||||||
@@ -343,13 +343,13 @@ function saveData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function backendStatusLabel() {
|
function backendStatusLabel() {
|
||||||
if (!platformApi?.isEnabled()) return '演示模式可用';
|
if (!platformApi?.isEnabled()) return '静态演示数据';
|
||||||
if (state.apiConnection === 'connecting') return '正在连接后端';
|
if (state.apiConnection === 'connecting') return '正在连接后端';
|
||||||
if (state.apiConnection === 'connected') {
|
if (state.apiConnection === 'connected') {
|
||||||
const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项无权限/失败` : '';
|
const partial = state.apiFailedOperations ? ` · ${state.apiFailedOperations} 项加载失败` : '';
|
||||||
return `后端已连接${partial}`;
|
return `后端已连接${partial}`;
|
||||||
}
|
}
|
||||||
return '演示模式可用';
|
return '后端不可用 · 已保留静态演示';
|
||||||
}
|
}
|
||||||
function backendErrorText(error, fallback = '平台接口请求失败') {
|
function backendErrorText(error, fallback = '平台接口请求失败') {
|
||||||
if (!error) return fallback;
|
if (!error) return fallback;
|
||||||
@@ -424,10 +424,14 @@ async function hydrateFromBackend({ silent = false } = {}) {
|
|||||||
if (!silent) showToast(`后端数据已刷新 · ${successful}/${result.requested} 项成功`);
|
if (!silent) showToast(`后端数据已刷新 · ${successful}/${result.requested} 项成功`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
window.GongxuePlatformAuth?.requireReauthentication();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
state.apiConnection = 'degraded';
|
state.apiConnection = 'degraded';
|
||||||
state.apiFailedOperations = 1;
|
state.apiFailedOperations = 1;
|
||||||
render();
|
render();
|
||||||
if (!silent) showToast(`${backendErrorText(error, '后端连接失败')} · 已切换至演示模式`, 'error');
|
if (!silent) showToast(`${backendErrorText(error, '后端连接失败')} · 已保留静态演示`, 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
<link rel="icon" href="./assets/logo.png" />
|
<link rel="icon" href="./assets/logo.png" />
|
||||||
<link rel="stylesheet" href="./styles.css?v=20260728-api2" />
|
<link rel="stylesheet" href="./styles.css?v=20260728-api2" />
|
||||||
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer onerror="document.documentElement.classList.add('icons-unavailable')"></script>
|
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer onerror="document.documentElement.classList.add('icons-unavailable')"></script>
|
||||||
<script src="./runtime-config.js?v=20260730-demo1" defer></script>
|
<script src="./runtime-config.js?v=20260730-question-bank1" defer></script>
|
||||||
<script src="./platform-auth.js?v=20260730-question-bank1" defer></script>
|
<script src="./platform-auth.js?v=20260730-api-fix1" defer></script>
|
||||||
<script src="./spec-contract.js?v=20260730-question-bank1" defer></script>
|
<script src="./spec-contract.js?v=20260730-question-bank1" defer></script>
|
||||||
<script src="./platform-api.js?v=20260730-question-bank1" defer></script>
|
<script src="./platform-api.js?v=20260730-api-fix1" defer></script>
|
||||||
<script src="./app.js?v=20260730-demo1" defer></script>
|
<script src="./app.js?v=20260730-api-fix1" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>
|
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>
|
||||||
|
|||||||
@@ -1081,8 +1081,8 @@
|
|||||||
storage_gb: 'storageGB',
|
storage_gb: 'storageGB',
|
||||||
traffic_tb: 'trafficTB',
|
traffic_tb: 'trafficTB',
|
||||||
};
|
};
|
||||||
const field = metricMap[item.metricKey];
|
const field = metricMap[item.metricKey || item.metricCode];
|
||||||
if (field) record[field] = Number(item.metricValue || 0);
|
if (field) record[field] = Number(item.metricValue ?? item.usedValue ?? 0);
|
||||||
records.set(key, record);
|
records.set(key, record);
|
||||||
});
|
});
|
||||||
return Array.from(records.values());
|
return Array.from(records.values());
|
||||||
|
|||||||
@@ -116,6 +116,12 @@
|
|||||||
return new Promise(resolve => { pendingResolve = resolve; });
|
return new Promise(resolve => { pendingResolve = resolve; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireReauthentication() {
|
||||||
|
clearSession();
|
||||||
|
renderLogin();
|
||||||
|
pendingResolve = () => location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
|
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. */ }
|
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; }
|
try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
||||||
},
|
},
|
||||||
logout,
|
logout,
|
||||||
|
requireReauthentication,
|
||||||
requireSession,
|
requireSession,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* 平台端公开运行时配置。
|
* 平台端公开运行时配置。
|
||||||
*
|
*
|
||||||
* 默认使用可交互演示数据。其他部署环境可在本文件之前注入同名对象,
|
* 默认连接同源后端。其他部署环境可在本文件之前注入同名对象,
|
||||||
* 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥
|
* 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥
|
||||||
* 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。
|
* 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。
|
||||||
*
|
*
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
* };
|
* };
|
||||||
*/
|
*/
|
||||||
window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {
|
window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {
|
||||||
mode: 'mock',
|
mode: 'api',
|
||||||
apiBaseUrl: '',
|
apiBaseUrl: '',
|
||||||
fallbackToMock: true,
|
fallbackToMock: false,
|
||||||
timeoutMs: 10000,
|
timeoutMs: 10000,
|
||||||
getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '',
|
getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ public interface IPlatformBillingAdminService
|
|||||||
Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyCollection<TenantFeatureUsage>> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyCollection<PlatformBillingInvoiceReminder>> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformBillingPayment> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
|
Task<PlatformBillingPayment> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
|
Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -53,6 +53,25 @@ internal sealed class PlatformBillingAdminService(
|
|||||||
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
|
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyCollection<TenantFeatureUsage>> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
|
||||||
|
ExecuteAsync<IReadOnlyCollection<TenantFeatureUsage>>("list SaaS usage", async (services, token) =>
|
||||||
|
{
|
||||||
|
var db = services.GetRequiredService<TikuDbContext>();
|
||||||
|
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<IReadOnlyCollection<PlatformBillingInvoiceReminder>> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
|
||||||
|
ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoiceReminder>>("list SaaS invoice reminders", async (services, token) =>
|
||||||
|
{
|
||||||
|
var db = services.GetRequiredService<TikuDbContext>();
|
||||||
|
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<PlatformBillingInvoiceReminderStatus>(query.Status));
|
||||||
|
return await values.OrderByDescending(value => value.ReminderDate).ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
public Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
|
public Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
|
||||||
ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions", async (services, token) =>
|
ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions", async (services, token) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,78 @@ namespace Tiku.IntegrationTests.Api;
|
|||||||
|
|
||||||
public sealed class PlatformAdminEndpointTests
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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]
|
[Fact]
|
||||||
public async Task Platform_admin_can_publish_immutable_saas_offering_and_manage_tenant_operations()
|
public async Task Platform_admin_can_publish_immutable_saas_offering_and_manage_tenant_operations()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user