diff --git a/apps/api/src/common/workspace-context.service.ts b/apps/api/src/common/workspace-context.service.ts index 21042a4..22c45a3 100644 --- a/apps/api/src/common/workspace-context.service.ts +++ b/apps/api/src/common/workspace-context.service.ts @@ -138,8 +138,16 @@ export class WorkspaceContextService { } async getDefaultWorkspaceId(userId: string, options?: { autoBootstrap?: boolean }): Promise { + const membership = await this.getDefaultMembership(userId, options); + return membership.workspaceId; + } + + async getDefaultMembership( + userId: string, + options?: { autoBootstrap?: boolean } + ): Promise<{ workspaceId: string; role: WorkspaceRole }> { const autoBootstrap = options?.autoBootstrap ?? true; - const member = await this.resolveMembership(userId); + let member = await this.resolveMembership(userId); if (!member) { if (!autoBootstrap) { throw new NotFoundException({ @@ -151,8 +159,14 @@ export class WorkspaceContextService { }); } - const bootstrapped = await this.bootstrapDefaultWorkspace(userId); - return bootstrapped.workspaceId; + await this.bootstrapDefaultWorkspace(userId); + member = await this.resolveMembership(userId); + if (!member) { + throw new NotFoundException({ + code: 'WORKSPACE_NOT_FOUND', + message: '默认工作区初始化后仍未找到成员关系' + }); + } } if (!member.isDefault) { @@ -162,6 +176,9 @@ export class WorkspaceContextService { }); } - return member.workspaceId; + return { + workspaceId: member.workspaceId, + role: member.role + }; } } diff --git a/apps/api/src/modules/audit/audit-visibility.ts b/apps/api/src/modules/audit/audit-visibility.ts new file mode 100644 index 0000000..2638621 --- /dev/null +++ b/apps/api/src/modules/audit/audit-visibility.ts @@ -0,0 +1,121 @@ +import type { + AuditLogEntity, + AuditPayloadAccess, + AuditVisibility, + AuditVisibilityDomain, + WorkspaceRoleValue +} from '@draftorbit/shared'; + +const AUDIT_RESOURCE_DOMAIN: Record = { + draft: 'CONTENT', + topic: 'CONTENT', + playbook: 'CONTENT', + voice_profile: 'CONTENT', + learning_source: 'LEARNING', + media_asset: 'MEDIA', + publish_job: 'PUBLISHING', + reply_job: 'REPLY', + reply_candidate: 'REPLY', + workflow_template: 'WORKFLOW', + workflow_run: 'WORKFLOW', + provider_connection: 'INTEGRATIONS', + x_account: 'INTEGRATIONS', + billing_account: 'BILLING', + workspace: 'WORKSPACE_ADMIN' +}; + +const ALL_AUDIT_DOMAINS: AuditVisibilityDomain[] = [ + 'CONTENT', + 'LEARNING', + 'MEDIA', + 'PUBLISHING', + 'REPLY', + 'WORKFLOW', + 'INTEGRATIONS', + 'BILLING', + 'WORKSPACE_ADMIN', + 'UNKNOWN' +]; + +const EDITOR_VISIBLE_DOMAINS: AuditVisibilityDomain[] = [ + 'CONTENT', + 'LEARNING', + 'MEDIA', + 'PUBLISHING', + 'REPLY', + 'WORKFLOW' +]; + +function sortDomains(domains: AuditVisibilityDomain[]) { + return [...domains].sort((a, b) => ALL_AUDIT_DOMAINS.indexOf(a) - ALL_AUDIT_DOMAINS.indexOf(b)); +} + +function payloadToRecord(payload: unknown): Record | null { + if (payload == null) return null; + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + return payload as Record; + } + return { value: payload }; +} + +export function classifyAuditDomain(resourceType: string): AuditVisibilityDomain { + return AUDIT_RESOURCE_DOMAIN[resourceType] ?? 'UNKNOWN'; +} + +export function buildAuditVisibility(role: WorkspaceRoleValue): AuditVisibility { + if (role === 'OWNER' || role === 'ADMIN') { + return { + role, + scope: 'FULL_WORKSPACE', + payloadAccess: 'FULL', + visibleDomains: [...ALL_AUDIT_DOMAINS], + hiddenDomains: [] + }; + } + + const visibleDomains = sortDomains(EDITOR_VISIBLE_DOMAINS); + const hiddenDomains = sortDomains(ALL_AUDIT_DOMAINS.filter((domain) => !visibleDomains.includes(domain))); + + return { + role, + scope: 'OPERATIONS_ONLY', + payloadAccess: role === 'EDITOR' ? 'FULL' : 'NONE', + visibleDomains, + hiddenDomains + }; +} + +export function getVisibleAuditResourceTypes(role: WorkspaceRoleValue): string[] | null { + const visibility = buildAuditVisibility(role); + if (visibility.scope === 'FULL_WORKSPACE') { + return null; + } + + return Object.entries(AUDIT_RESOURCE_DOMAIN) + .filter(([, domain]) => visibility.visibleDomains.includes(domain)) + .map(([resourceType]) => resourceType) + .sort(); +} + +export function sanitizeAuditLog( + log: { + id: string; + action: string; + resourceType: string; + resourceId: string | null; + payload: unknown; + createdAt: Date | string; + }, + payloadAccess: AuditPayloadAccess +): AuditLogEntity { + return { + id: log.id, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceId ?? null, + payload: payloadAccess === 'FULL' ? payloadToRecord(log.payload) : null, + createdAt: log.createdAt instanceof Date ? log.createdAt.toISOString() : log.createdAt, + visibilityDomain: classifyAuditDomain(log.resourceType), + payloadRedacted: payloadAccess !== 'FULL' && log.payload != null + }; +} diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts index acf8b3a..5224314 100644 --- a/apps/api/src/modules/audit/audit.service.ts +++ b/apps/api/src/modules/audit/audit.service.ts @@ -1,7 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { AuditActionType } from '@draftorbit/db'; +import type { AuditLogsResponse, AuditSummaryEntity } from '@draftorbit/shared'; import { PrismaService } from '../../common/prisma.service'; import { WorkspaceContextService } from '../../common/workspace-context.service'; +import { buildAuditVisibility, getVisibleAuditResourceTypes, sanitizeAuditLog } from './audit-visibility'; @Injectable() export class AuditService { @@ -17,39 +19,97 @@ export class AuditService { resourceType?: string; limit?: number; } - ) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); - - return this.prisma.db.auditLog.findMany({ - where: { - workspaceId, - ...(filters.action ? { action: filters.action } : {}), - ...(filters.resourceType ? { resourceType: filters.resourceType } : {}) - }, - orderBy: { createdAt: 'desc' }, - take: Math.min(Math.max(filters.limit ?? 100, 1), 500) - }); + ): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildAuditVisibility(membership.role); + const visibleResourceTypes = getVisibleAuditResourceTypes(membership.role); + const safeLimit = Math.min(Math.max(filters.limit ?? 100, 1), 500); + + const baseWhere = { + workspaceId: membership.workspaceId, + ...(filters.action ? { action: filters.action } : {}) + }; + + const visibleResourceTypeFilter = filters.resourceType + ? visibleResourceTypes && !visibleResourceTypes.includes(filters.resourceType) + ? { in: [] as string[] } + : filters.resourceType + : visibleResourceTypes + ? { in: visibleResourceTypes } + : undefined; + + const visibleWhere = { + ...baseWhere, + ...(visibleResourceTypeFilter ? { resourceType: visibleResourceTypeFilter } : {}) + }; + + const [workspaceMatchingCount, visibleMatchingCount, logs] = await Promise.all([ + this.prisma.db.auditLog.count({ + where: { + ...baseWhere, + ...(filters.resourceType ? { resourceType: filters.resourceType } : {}) + } + }), + this.prisma.db.auditLog.count({ where: visibleWhere }), + this.prisma.db.auditLog.findMany({ + where: visibleWhere, + orderBy: { createdAt: 'desc' }, + take: safeLimit + }) + ]); + + return { + items: logs.map((log) => sanitizeAuditLog(log, visibility.payloadAccess)), + hiddenCount: Math.max(workspaceMatchingCount - visibleMatchingCount, 0), + visibility, + limit: safeLimit + }; } - async summary(userId: string) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); + async summary(userId: string): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildAuditVisibility(membership.role); + const visibleResourceTypes = getVisibleAuditResourceTypes(membership.role); + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000); - const [total, last24h] = await Promise.all([ - this.prisma.db.auditLog.count({ where: { workspaceId } }), + const visibleFilter = visibleResourceTypes ? { resourceType: { in: visibleResourceTypes } } : {}; + + const [workspaceTotal, workspaceLast24h, visibleTotal, visibleLast24h] = await Promise.all([ + this.prisma.db.auditLog.count({ where: { workspaceId: membership.workspaceId } }), this.prisma.db.auditLog.count({ where: { - workspaceId, + workspaceId: membership.workspaceId, createdAt: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000) + gte: last24h } } + }), + this.prisma.db.auditLog.count({ + where: { + workspaceId: membership.workspaceId, + ...visibleFilter + } + }), + this.prisma.db.auditLog.count({ + where: { + workspaceId: membership.workspaceId, + createdAt: { + gte: last24h + }, + ...visibleFilter + } }) ]); return { - workspaceId, - total, - last24h + workspaceId: membership.workspaceId, + total: visibleTotal, + last24h: visibleLast24h, + workspaceTotal, + workspaceLast24h, + hiddenTotal: Math.max(workspaceTotal - visibleTotal, 0), + hiddenLast24h: Math.max(workspaceLast24h - visibleLast24h, 0), + visibility }; } } diff --git a/apps/api/src/modules/ops/ops-visibility.ts b/apps/api/src/modules/ops/ops-visibility.ts new file mode 100644 index 0000000..74c04c5 --- /dev/null +++ b/apps/api/src/modules/ops/ops-visibility.ts @@ -0,0 +1,108 @@ +import type { OpsQueueStats, OpsQueuesResponse, OpsVisibility, WorkspaceRoleValue } from '@draftorbit/shared'; + +function sanitizeQueueStats( + stats: { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; + }, + visibility: OpsVisibility +): OpsQueueStats { + return { + waiting: stats.waiting, + active: stats.active, + completed: stats.completed, + failed: visibility.canViewFailureDetails ? stats.failed : null, + delayed: visibility.canViewFailureDetails ? stats.delayed : null, + paused: visibility.canViewFailureDetails ? stats.paused : null + }; +} + +export function buildOpsVisibility(role: WorkspaceRoleValue): OpsVisibility { + if (role === 'OWNER' || role === 'ADMIN') { + return { + role, + accessTier: 'FULL', + canViewPerQueue: true, + canViewFailureDetails: true, + redactedFields: [] + }; + } + + if (role === 'EDITOR') { + return { + role, + accessTier: 'LIMITED', + canViewPerQueue: true, + canViewFailureDetails: false, + redactedFields: ['queues.*.failed', 'queues.*.delayed', 'queues.*.paused', 'summary.failed', 'summary.delayed', 'summary.paused'] + }; + } + + return { + role, + accessTier: 'OVERVIEW', + canViewPerQueue: false, + canViewFailureDetails: false, + redactedFields: ['queues', 'summary.failed', 'summary.delayed', 'summary.paused'] + }; +} + +export function sanitizeOpsQueues( + queues: Record< + string, + { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; + } + >, + visibility: OpsVisibility +): OpsQueuesResponse { + const entries = Object.entries(queues); + + const summary = entries.reduce( + (acc, [, stats]) => { + acc.waiting += stats.waiting; + acc.active += stats.active; + acc.completed += stats.completed; + acc.failed = (acc.failed ?? 0) + stats.failed; + acc.delayed = (acc.delayed ?? 0) + stats.delayed; + acc.paused = (acc.paused ?? 0) + stats.paused; + return acc; + }, + { + waiting: 0, + active: 0, + completed: 0, + failed: 0, + delayed: 0, + paused: 0 + } + ); + + return { + visibility, + summary: sanitizeQueueStats( + { + waiting: summary.waiting, + active: summary.active, + completed: summary.completed, + failed: summary.failed ?? 0, + delayed: summary.delayed ?? 0, + paused: summary.paused ?? 0 + }, + visibility + ), + queues: visibility.canViewPerQueue + ? Object.fromEntries(entries.map(([name, stats]) => [name, sanitizeQueueStats(stats, visibility)])) + : null, + hiddenQueueCount: visibility.canViewPerQueue ? 0 : entries.length + }; +} diff --git a/apps/api/src/modules/ops/ops.controller.ts b/apps/api/src/modules/ops/ops.controller.ts index f1e341d..b80a687 100644 --- a/apps/api/src/modules/ops/ops.controller.ts +++ b/apps/api/src/modules/ops/ops.controller.ts @@ -1,19 +1,19 @@ -import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { Controller, Get, Inject, Req, UseGuards } from '@nestjs/common'; +import type { AuthUser } from '@draftorbit/shared'; import { AuthGuard } from '../../common/auth.guard'; -import { QueueService } from '../../common/queue.service'; +import { OpsService } from './ops.service'; + +interface RequestWithUser { + user?: AuthUser; +} @Controller('ops') @UseGuards(AuthGuard) export class OpsController { - constructor(@Inject(QueueService) private readonly queue: QueueService) {} + constructor(@Inject(OpsService) private readonly service: OpsService) {} @Get('queues') - async queues() { - const queues = await this.queue.getQueueStats(); - return { - ok: true, - queues, - now: new Date().toISOString() - }; + async queues(@Req() req: RequestWithUser) { + return this.service.queues((req.user as AuthUser).userId); } } diff --git a/apps/api/src/modules/ops/ops.module.ts b/apps/api/src/modules/ops/ops.module.ts index 306e4dc..ce2f483 100644 --- a/apps/api/src/modules/ops/ops.module.ts +++ b/apps/api/src/modules/ops/ops.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { OpsController } from './ops.controller'; +import { OpsService } from './ops.service'; @Module({ - controllers: [OpsController] + controllers: [OpsController], + providers: [OpsService] }) export class OpsModule {} diff --git a/apps/api/src/modules/ops/ops.service.ts b/apps/api/src/modules/ops/ops.service.ts new file mode 100644 index 0000000..a2440bd --- /dev/null +++ b/apps/api/src/modules/ops/ops.service.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { OpsQueuesResponse } from '@draftorbit/shared'; +import { QueueService } from '../../common/queue.service'; +import { WorkspaceContextService } from '../../common/workspace-context.service'; +import { buildOpsVisibility, sanitizeOpsQueues } from './ops-visibility'; + +@Injectable() +export class OpsService { + constructor( + @Inject(QueueService) private readonly queue: QueueService, + @Inject(WorkspaceContextService) private readonly workspaceContext: WorkspaceContextService + ) {} + + async queues(userId: string): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildOpsVisibility(membership.role); + const queues = await this.queue.getQueueStats(); + + return { + ok: true, + now: new Date().toISOString(), + ...sanitizeOpsQueues(queues, visibility) + }; + } +} diff --git a/apps/api/src/modules/usage/usage-visibility.ts b/apps/api/src/modules/usage/usage-visibility.ts new file mode 100644 index 0000000..98914ae --- /dev/null +++ b/apps/api/src/modules/usage/usage-visibility.ts @@ -0,0 +1,158 @@ +import type { + CreditLedgerSnapshot, + UsageBillingSnapshot, + UsageEventEntity, + UsageTrendPoint, + UsageVisibility, + WorkspaceRoleValue +} from '@draftorbit/shared'; + +function toIso(value: Date | string | null | undefined): string | null { + if (!value) return null; + return value instanceof Date ? value.toISOString() : value; +} + +function toNumber(value: unknown): number { + return Number(value ?? 0); +} + +export function buildUsageVisibility(role: WorkspaceRoleValue): UsageVisibility { + if (role === 'OWNER' || role === 'ADMIN') { + return { + role, + accessTier: 'FULL', + canViewCosts: true, + canViewLedgerDetails: true, + canManageCredits: true, + redactedFields: [] + }; + } + + if (role === 'EDITOR') { + return { + role, + accessTier: 'LIMITED', + canViewCosts: true, + canViewLedgerDetails: false, + canManageCredits: false, + redactedFields: ['billing.stripeCustomerId', 'latestLedgers'] + }; + } + + return { + role, + accessTier: 'OVERVIEW', + canViewCosts: false, + canViewLedgerDetails: false, + canManageCredits: false, + redactedFields: [ + 'billing.stripeCustomerId', + 'tokenCost', + 'latestLedgers', + 'events.model', + 'events.inputTokens', + 'events.outputTokens', + 'events.costUsd', + 'trends.costUsd' + ] + }; +} + +export function sanitizeUsageBilling( + billing: + | { + plan: string; + status: string; + monthlyQuota: number; + remainingCredits: number; + cycleStart: Date | string | null; + cycleEnd: Date | string | null; + stripeCustomerId?: string | null; + } + | null, + visibility: UsageVisibility +): UsageBillingSnapshot | null { + if (!billing) return null; + + const snapshot: UsageBillingSnapshot = { + plan: billing.plan, + status: billing.status, + monthlyQuota: billing.monthlyQuota, + remainingCredits: billing.remainingCredits, + cycleStart: toIso(billing.cycleStart), + cycleEnd: toIso(billing.cycleEnd) + }; + + if (visibility.accessTier === 'FULL') { + snapshot.stripeCustomerId = billing.stripeCustomerId ?? null; + } + + return snapshot; +} + +export function sanitizeCreditLedger( + ledger: { + id: string; + direction: string; + amount: number; + balanceAfter: number | null; + reason: string; + createdAt: Date | string; + metadata?: unknown; + }, + visibility: UsageVisibility +): CreditLedgerSnapshot { + const snapshot: CreditLedgerSnapshot = { + id: ledger.id, + direction: ledger.direction, + amount: ledger.amount, + balanceAfter: ledger.balanceAfter ?? null, + reason: ledger.reason, + createdAt: toIso(ledger.createdAt) ?? new Date(0).toISOString() + }; + + if (visibility.canViewLedgerDetails) { + snapshot.metadata = + ledger.metadata && typeof ledger.metadata === 'object' && !Array.isArray(ledger.metadata) + ? (ledger.metadata as Record) + : null; + } + + return snapshot; +} + +export function sanitizeUsageEvent( + event: { + id: string; + eventType: string; + model: string; + inputTokens: number; + outputTokens: number; + costUsd: unknown; + createdAt: Date | string; + }, + visibility: UsageVisibility +): UsageEventEntity { + const canViewDetails = visibility.canViewCosts; + + return { + id: event.id, + eventType: event.eventType, + model: canViewDetails ? event.model : null, + inputTokens: canViewDetails ? event.inputTokens : null, + outputTokens: canViewDetails ? event.outputTokens : null, + costUsd: canViewDetails ? toNumber(event.costUsd) : null, + createdAt: toIso(event.createdAt) ?? new Date(0).toISOString(), + detailsRedacted: !canViewDetails + }; +} + +export function sanitizeUsageTrendPoint( + point: UsageTrendPoint, + visibility: UsageVisibility +): UsageTrendPoint { + return { + ...point, + costUsd: visibility.canViewCosts ? toNumber(point.costUsd) : null + }; +} diff --git a/apps/api/src/modules/usage/usage.service.ts b/apps/api/src/modules/usage/usage.service.ts index 8766fa2..4c569e3 100644 --- a/apps/api/src/modules/usage/usage.service.ts +++ b/apps/api/src/modules/usage/usage.service.ts @@ -1,7 +1,15 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { CreditDirection } from '@draftorbit/db'; +import type { UsageEventEntity, UsageSummaryEntity, UsageTrendsEntity } from '@draftorbit/shared'; import { PrismaService } from '../../common/prisma.service'; import { WorkspaceContextService } from '../../common/workspace-context.service'; +import { + buildUsageVisibility, + sanitizeCreditLedger, + sanitizeUsageBilling, + sanitizeUsageEvent, + sanitizeUsageTrendPoint +} from './usage-visibility'; @Injectable() export class UsageService { @@ -10,8 +18,10 @@ export class UsageService { @Inject(WorkspaceContextService) private readonly workspaceContext: WorkspaceContextService ) {} - async summary(userId: string) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); + async summary(userId: string): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildUsageVisibility(membership.role); + const workspaceId = membership.workspaceId; const monthStart = new Date(); monthStart.setDate(1); @@ -31,51 +41,77 @@ export class UsageService { this.prisma.db.publishJob.count({ where: { workspaceId, createdAt: { gte: monthStart } } }), this.prisma.db.replyJob.count({ where: { workspaceId, createdAt: { gte: monthStart } } }), this.prisma.db.generation.count({ where: { workspaceId, createdAt: { gte: monthStart } } }), - this.prisma.db.tokenCostLog.aggregate({ - where: { workspaceId, createdAt: { gte: monthStart } }, - _sum: { inputTokens: true, outputTokens: true, costUsd: true } - }), - this.prisma.db.creditLedger.findMany({ - where: { workspaceId }, - orderBy: { createdAt: 'desc' }, - take: 20 - }) + visibility.canViewCosts + ? this.prisma.db.tokenCostLog.aggregate({ + where: { workspaceId, createdAt: { gte: monthStart } }, + _sum: { inputTokens: true, outputTokens: true, costUsd: true } + }) + : Promise.resolve({ + _sum: { + inputTokens: 0, + outputTokens: 0, + costUsd: 0 + } + }), + visibility.canViewLedgerDetails + ? this.prisma.db.creditLedger.findMany({ + where: { workspaceId }, + orderBy: { createdAt: 'desc' }, + take: 20 + }) + : Promise.resolve([]) ]); return { workspaceId, periodStart: monthStart.toISOString(), - billing, + billing: sanitizeUsageBilling(billing, visibility), counters: { usageEvents: usageLogs, generations: generationCount, publishJobs: publishCount, replyJobs: replyCount }, - tokenCost: { - inputTokens: tokenCost._sum.inputTokens ?? 0, - outputTokens: tokenCost._sum.outputTokens ?? 0, - costUsd: tokenCost._sum.costUsd ?? 0 - }, - latestLedgers + tokenCost: visibility.canViewCosts + ? { + inputTokens: tokenCost._sum.inputTokens ?? 0, + outputTokens: tokenCost._sum.outputTokens ?? 0, + costUsd: Number(tokenCost._sum.costUsd ?? 0) + } + : null, + latestLedgers: latestLedgers.map((ledger) => sanitizeCreditLedger(ledger, visibility)), + visibility }; } - async listEvents(userId: string, limit = 100) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); + async listEvents(userId: string, limit = 100): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildUsageVisibility(membership.role); + const workspaceId = membership.workspaceId; + const safeLimit = Math.min(Math.max(limit, 1), 500); - return this.prisma.db.usageLog.findMany({ + const events = await this.prisma.db.usageLog.findMany({ where: { workspaceId }, - include: { - tokenCosts: true + select: { + id: true, + eventType: true, + model: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + createdAt: true }, orderBy: { createdAt: 'desc' }, - take: Math.min(Math.max(limit, 1), 500) + take: safeLimit }); + + return events.map((event) => sanitizeUsageEvent(event, visibility)); } - async trends(userId: string, days = 14) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); + async trends(userId: string, days = 14): Promise { + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildUsageVisibility(membership.role); + const workspaceId = membership.workspaceId; const safeDays = Math.min(Math.max(days, 3), 90); const start = new Date(); start.setHours(0, 0, 0, 0); @@ -177,12 +213,26 @@ export class UsageService { workspaceId, days: safeDays, from: start.toISOString(), - points: [...buckets.values()] + visibility, + points: [...buckets.values()].map((point) => sanitizeUsageTrendPoint(point, visibility)) }; } async addCredits(userId: string, amount: number, reason: string) { - const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId); + const membership = await this.workspaceContext.getDefaultMembership(userId); + const visibility = buildUsageVisibility(membership.role); + const workspaceId = membership.workspaceId; + + if (!visibility.canManageCredits) { + throw new ForbiddenException({ + code: 'USAGE_SNAPSHOT_FORBIDDEN', + message: '当前角色无权调整额度', + details: { + currentRole: membership.role, + requiredRoles: ['OWNER', 'ADMIN'] + } + }); + } const account = await this.prisma.db.billingAccount.upsert({ where: { workspaceId }, diff --git a/apps/api/test/audit-visibility.test.ts b/apps/api/test/audit-visibility.test.ts new file mode 100644 index 0000000..f70d61c --- /dev/null +++ b/apps/api/test/audit-visibility.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAuditVisibility, + getVisibleAuditResourceTypes, + sanitizeAuditLog +} from '../src/modules/audit/audit-visibility'; + +test('owner sees full workspace audit domains', () => { + const visibility = buildAuditVisibility('OWNER'); + + assert.equal(visibility.scope, 'FULL_WORKSPACE'); + assert.equal(visibility.payloadAccess, 'FULL'); + assert.equal(visibility.hiddenDomains.length, 0); +}); + +test('editor audit visibility hides billing and integration domains', () => { + const visibility = buildAuditVisibility('EDITOR'); + const resourceTypes = getVisibleAuditResourceTypes('EDITOR') ?? []; + + assert.equal(visibility.scope, 'OPERATIONS_ONLY'); + assert.equal(visibility.payloadAccess, 'FULL'); + assert.ok(resourceTypes.includes('draft')); + assert.ok(!resourceTypes.includes('billing_account')); + assert.ok(!resourceTypes.includes('provider_connection')); +}); + +test('viewer audit payload is redacted even for visible domains', () => { + const log = sanitizeAuditLog( + { + id: 'audit_1', + action: 'UPDATE', + resourceType: 'draft', + resourceId: 'draft_1', + payload: { title: 'Hello' }, + createdAt: new Date('2026-04-08T00:00:00.000Z') + }, + 'NONE' + ); + + assert.equal(log.visibilityDomain, 'CONTENT'); + assert.equal(log.payload, null); + assert.equal(log.payloadRedacted, true); +}); diff --git a/apps/api/test/ops-visibility.test.ts b/apps/api/test/ops-visibility.test.ts new file mode 100644 index 0000000..9df29ec --- /dev/null +++ b/apps/api/test/ops-visibility.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildOpsVisibility, sanitizeOpsQueues } from '../src/modules/ops/ops-visibility'; + +const queues = { + publish: { waiting: 2, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 }, + reply: { waiting: 1, active: 0, completed: 8, failed: 2, delayed: 1, paused: 0 } +}; + +test('owner sees full per-queue ops metrics', () => { + const visibility = buildOpsVisibility('OWNER'); + const result = sanitizeOpsQueues(queues, visibility); + + assert.equal(result.visibility.accessTier, 'FULL'); + assert.equal(result.queues?.publish.failed, 1); + assert.equal(result.summary.failed, 3); +}); + +test('editor keeps per-queue view but loses failure details', () => { + const visibility = buildOpsVisibility('EDITOR'); + const result = sanitizeOpsQueues(queues, visibility); + + assert.equal(result.visibility.accessTier, 'LIMITED'); + assert.equal(result.queues?.publish.failed, null); + assert.equal(result.summary.failed, null); +}); + +test('viewer only gets summary overview', () => { + const visibility = buildOpsVisibility('VIEWER'); + const result = sanitizeOpsQueues(queues, visibility); + + assert.equal(result.visibility.accessTier, 'OVERVIEW'); + assert.equal(result.queues, null); + assert.equal(result.hiddenQueueCount, 2); + assert.equal(result.summary.waiting, 3); +}); diff --git a/apps/api/test/usage-visibility.test.ts b/apps/api/test/usage-visibility.test.ts new file mode 100644 index 0000000..eb822cd --- /dev/null +++ b/apps/api/test/usage-visibility.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildUsageVisibility, + sanitizeUsageBilling, + sanitizeUsageEvent, + sanitizeUsageTrendPoint +} from '../src/modules/usage/usage-visibility'; + +test('owner has full usage visibility and credit management', () => { + const visibility = buildUsageVisibility('OWNER'); + + assert.equal(visibility.accessTier, 'FULL'); + assert.equal(visibility.canViewCosts, true); + assert.equal(visibility.canManageCredits, true); +}); + +test('editor billing snapshot hides stripe customer id', () => { + const visibility = buildUsageVisibility('EDITOR'); + const billing = sanitizeUsageBilling( + { + plan: 'PRO', + status: 'ACTIVE', + monthlyQuota: 100, + remainingCredits: 42, + cycleStart: new Date('2026-04-01T00:00:00.000Z'), + cycleEnd: new Date('2026-05-01T00:00:00.000Z'), + stripeCustomerId: 'cus_secret' + }, + visibility + ); + + assert.equal(visibility.accessTier, 'LIMITED'); + assert.equal(billing?.remainingCredits, 42); + assert.equal('stripeCustomerId' in (billing ?? {}), false); +}); + +test('viewer usage events and trends redact cost-sensitive details', () => { + const visibility = buildUsageVisibility('VIEWER'); + const event = sanitizeUsageEvent( + { + id: 'evt_1', + eventType: 'GENERATION', + model: 'gpt-5.4', + inputTokens: 123, + outputTokens: 456, + costUsd: '0.0135', + createdAt: new Date('2026-04-08T00:00:00.000Z') + }, + visibility + ); + const point = sanitizeUsageTrendPoint( + { + date: '2026-04-08', + generation: 1, + naturalization: 0, + image: 0, + reply: 0, + publish: 0, + totalEvents: 1, + costUsd: 0.25 + }, + visibility + ); + + assert.equal(event.detailsRedacted, true); + assert.equal(event.model, null); + assert.equal(event.costUsd, null); + assert.equal(point.costUsd, null); +}); diff --git a/apps/web/app/audit/page.tsx b/apps/web/app/audit/page.tsx index 7be97be..c18de9c 100644 --- a/apps/web/app/audit/page.tsx +++ b/apps/web/app/audit/page.tsx @@ -1,15 +1,15 @@ 'use client'; import { useEffect, useState } from 'react'; -import type { AuditLogEntity } from '@draftorbit/shared'; +import type { AuditLogsResponse, AuditSummaryEntity, AuditVisibilityDomain, AuditVisibilityScope, WorkspaceRoleValue } from '@draftorbit/shared'; import { WorkbenchShell } from '../../components/shell/workbench-shell'; import { EmptyState, ErrorState, LoadingState } from '../../components/ui/page-states'; import { WorkspaceRecovery, isWorkspaceMissing, normalizeErrorMessage } from '../../components/ui/workspace-recovery'; import { fetchAuditLogs, fetchAuditSummary } from '../../lib/queries'; export default function AuditPage() { - const [summary, setSummary] = useState | null>(null); - const [logs, setLogs] = useState([]); + const [summary, setSummary] = useState(null); + const [logsResponse, setLogsResponse] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -19,11 +19,11 @@ export default function AuditPage() { try { const [s, l] = await Promise.all([fetchAuditSummary(), fetchAuditLogs(100)]); setSummary(s); - setLogs(l); + setLogsResponse(l); } catch (e) { setError(e); setSummary(null); - setLogs([]); + setLogsResponse(null); } finally { setLoading(false); } @@ -33,6 +33,8 @@ export default function AuditPage() { void load(); }, []); + const logs = logsResponse?.items ?? []; + return ( {loading ? : null} @@ -47,30 +49,109 @@ export default function AuditPage() { ) : null} {summary ? ( -
- 总计 {summary?.total ?? 0} · 24h {summary?.last24h ?? 0} +
+ 0 + ? `当前还有 ${summary.hiddenTotal} 条审计记录位于受限域,工作区总计 ${summary.workspaceTotal} 条。` + : '当前角色可见工作区内全部审计记录。' + } + /> + +
+ 总计 {summary.total} · 24h {summary.last24h} + {summary.hiddenLast24h > 0 ? ( +

+ 过去 24 小时内另有 {summary.hiddenLast24h} 条记录因权限分级未展示。 +

+ ) : null} +
) : null} {!loading && !error && logs.length === 0 ? ( - + ) : null}
{logs.map((log) => (
-

- {log.action} · {log.resourceType} -

+
+

+ {log.action} · {log.resourceType} +

+ + {domainLabel(log.visibilityDomain)} + +

{new Date(log.createdAt).toLocaleString('zh-CN')} · resourceId={log.resourceId || '-'}

-
-              {JSON.stringify(log.payload, null, 2)}
-            
+ {log.payload ? ( +
+                {JSON.stringify(log.payload, null, 2)}
+              
+ ) : log.payloadRedacted ? ( +

+ 当前角色仅可查看该记录的元信息,详细 payload 已按可见域策略隐藏。 +

+ ) : null}
))}
); } + +function VisibilityPanel(props: { title: string; description: string; extra?: string }) { + return ( +
+

{props.title}

+

{props.description}

+ {props.extra ?

{props.extra}

: null} +
+ ); +} + +function roleLabel(role: WorkspaceRoleValue) { + const labels: Record = { + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer' + }; + return labels[role] ?? role; +} + +function scopeLabel(scope: AuditVisibilityScope) { + return scope === 'FULL_WORKSPACE' ? '全工作区可见' : '仅可见运营域'; +} + +function domainLabel(domain?: AuditVisibilityDomain) { + const labels: Record = { + CONTENT: '内容', + LEARNING: '学习', + MEDIA: '媒体', + PUBLISHING: '发布', + REPLY: '回复', + WORKFLOW: '工作流', + INTEGRATIONS: '集成', + BILLING: '计费', + WORKSPACE_ADMIN: '工作区管理', + UNKNOWN: '未分类' + }; + + if (!domain) return '未分类'; + return labels[domain] ?? domain; +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index ca1b1b4..30803f3 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; +import type { AuditSummaryEntity, OpsQueuesResponse, UsageSummaryEntity } from '@draftorbit/shared'; import { WorkbenchShell } from '../../components/shell/workbench-shell'; import { EmptyState, ErrorState, LoadingState } from '../../components/ui/page-states'; import { WorkspaceRecovery, isWorkspaceMissing, normalizeErrorMessage } from '../../components/ui/workspace-recovery'; @@ -23,9 +24,9 @@ type DashboardData = { draftsCount: number; publishCount: number; replyCount: number; - usage: Record; - audit: Record; - queue: Record; + usage: UsageSummaryEntity; + audit: AuditSummaryEntity; + queue: OpsQueuesResponse & { ok: true; now: string }; }; export default function DashboardPage() { @@ -114,18 +115,42 @@ export default function DashboardPage() {

队列健康

+

+ {roleLabel(data.queue.visibility.role)} · {tierLabel(data.queue.visibility.accessTier)} + {data.queue.visibility.redactedFields.length + ? ` · 已隐藏 ${data.queue.visibility.redactedFields.join('、')}` + : ' · 当前角色可查看完整队列健康数据'} +

- {Object.entries((data.queue?.queues ?? {}) as Record).map(([name, stats]) => ( -
-

{name}

-

waiting {stats.waiting} · active {stats.active}

-

failed {stats.failed} · delayed {stats.delayed}

+ {data.queue.queues ? ( + Object.entries(data.queue.queues).map(([name, stats]) => ( +
+

{name}

+

waiting {stats.waiting} · active {stats.active}

+

+ completed {stats.completed} + {stats.failed != null ? ` · failed ${stats.failed}` : ''} + {stats.delayed != null ? ` · delayed ${stats.delayed}` : ''} + {stats.paused != null ? ` · paused ${stats.paused}` : ''} +

+
+ )) + ) : ( +
+

队列总览

+

+ waiting {data.queue.summary.waiting} · active {data.queue.summary.active} · completed {data.queue.summary.completed} +

+

+ 当前角色仅可查看总览,{data.queue.hiddenQueueCount} 个队列明细已隐藏。 +

- ))} + )}
+ @@ -134,6 +159,7 @@ export default function DashboardPage() { +
) : null} @@ -161,3 +187,19 @@ function QuickLink(props: { href: string; label: string }) { ); } + +function roleLabel(role: 'OWNER' | 'ADMIN' | 'EDITOR' | 'VIEWER') { + const labels = { + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer' + } as const; + return labels[role] ?? role; +} + +function tierLabel(tier: 'FULL' | 'LIMITED' | 'OVERVIEW') { + if (tier === 'FULL') return '完整队列视图'; + if (tier === 'LIMITED') return '受限队列视图'; + return '概览队列视图'; +} diff --git a/apps/web/app/ops/page.tsx b/apps/web/app/ops/page.tsx new file mode 100644 index 0000000..08f371e --- /dev/null +++ b/apps/web/app/ops/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { OpsQueuesResponse } from '@draftorbit/shared'; +import { WorkbenchShell } from '../../components/shell/workbench-shell'; +import { EmptyState, ErrorState, LoadingState } from '../../components/ui/page-states'; +import { WorkspaceRecovery, isWorkspaceMissing, normalizeErrorMessage } from '../../components/ui/workspace-recovery'; +import { fetchQueueHealth } from '../../lib/queries'; + +type QueueHealthResponse = OpsQueuesResponse & { ok: true; now: string }; + +export default function OpsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + setData(await fetchQueueHealth()); + } catch (err) { + setError(err); + setData(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + return ( + + {loading ? : null} + + {!loading && error ? ( + void load()} + extra={isWorkspaceMissing(error) ? : undefined} + /> + ) : null} + + {!loading && !error && !data ? ( + + ) : null} + + {data ? ( +
+ + +
+ + + + + + +
+ + {data.queues ? ( +
+ {Object.entries(data.queues).map(([name, stats]) => ( +
+

{name}

+

waiting {stats.waiting} · active {stats.active}

+

+ completed {stats.completed} + {stats.failed != null ? ` · failed ${stats.failed}` : ''} + {stats.delayed != null ? ` · delayed ${stats.delayed}` : ''} + {stats.paused != null ? ` · paused ${stats.paused}` : ''} +

+
+ ))} +
+ ) : ( +
+ 当前角色仅可查看运维总览,{data.hiddenQueueCount} 个队列的逐队列明细已隐藏。 +
+ )} + +

快照时间:{new Date(data.now).toLocaleString('zh-CN')}

+
+ ) : null} +
+ ); +} + +function VisibilityPanel(props: { title: string; description: string; extra?: string }) { + return ( +
+

{props.title}

+

{props.description}

+ {props.extra ?

{props.extra}

: null} +
+ ); +} + +function MetricCard(props: { title: string; value: string }) { + return ( +
+

{props.title}

+

{props.value}

+
+ ); +} + +function roleLabel(role: 'OWNER' | 'ADMIN' | 'EDITOR' | 'VIEWER') { + const labels = { + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer' + } as const; + return labels[role] ?? role; +} + +function tierLabel(tier: 'FULL' | 'LIMITED' | 'OVERVIEW') { + if (tier === 'FULL') return '完整队列视图'; + if (tier === 'LIMITED') return '受限队列视图'; + return '概览队列视图'; +} diff --git a/apps/web/app/snapshot/page.tsx b/apps/web/app/snapshot/page.tsx new file mode 100644 index 0000000..28b479d --- /dev/null +++ b/apps/web/app/snapshot/page.tsx @@ -0,0 +1,442 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import type { + AuditSummaryEntity, + OpsQueuesResponse, + UsageSummaryEntity +} from '@draftorbit/shared'; +import { WorkbenchShell } from '../../components/shell/workbench-shell'; +import { EmptyState, ErrorState, LoadingState } from '../../components/ui/page-states'; +import { WorkspaceRecovery, isWorkspaceMissing, normalizeErrorMessage } from '../../components/ui/workspace-recovery'; +import { fetchAuditSummary, fetchQueueHealth, fetchUsageSummary, fetchWorkspace } from '../../lib/queries'; + +type SnapshotData = { + workspace: Record | null; + usage: UsageSummaryEntity; + audit: AuditSummaryEntity; + ops: OpsQueuesResponse & { ok: true; now: string }; +}; + +export default function SnapshotPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const [workspace, usage, audit, ops] = await Promise.all([ + fetchWorkspace(), + fetchUsageSummary(), + fetchAuditSummary(), + fetchQueueHealth() + ]); + setData({ workspace, usage, audit, ops }); + } catch (err) { + setError(err); + setData(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const posture = useMemo(() => { + if (!data) return null; + return { + role: data.usage.visibility.role, + usageTier: data.usage.visibility.accessTier, + auditScope: data.audit.visibility.scope, + opsTier: data.ops.visibility.accessTier + }; + }, [data]); + + const workspace = useMemo(() => { + if (!data?.workspace) return null; + const raw = data.workspace as Record; + const members = Array.isArray(raw.members) ? raw.members : []; + return { + name: typeof raw.name === 'string' ? raw.name : '当前工作区', + slug: typeof raw.slug === 'string' ? raw.slug : '-', + memberCount: members.length, + ownerId: typeof raw.ownerId === 'string' ? raw.ownerId : null + }; + }, [data]); + + const redactionSummary = useMemo(() => { + if (!data) return []; + const items: string[] = []; + + if (data.usage.visibility.redactedFields.length > 0) { + items.push(`Usage 隐藏 ${data.usage.visibility.redactedFields.length} 个字段`); + } + if (data.audit.hiddenTotal > 0) { + items.push(`Audit 隐藏 ${data.audit.hiddenTotal} 条记录`); + } + if (data.audit.visibility.payloadAccess === 'NONE') { + items.push('Audit payload 已隐藏'); + } + if (data.ops.visibility.redactedFields.length > 0) { + items.push(`Ops 隐藏 ${data.ops.visibility.redactedFields.length} 类字段`); + } + if (data.ops.hiddenQueueCount > 0) { + items.push(`Ops 隐藏 ${data.ops.hiddenQueueCount} 个队列明细`); + } + + return items; + }, [data]); + + const anomalies = useMemo(() => { + if (!data) return []; + const items: Array<{ + title: string; + detail: string; + severity: 'neutral' | 'amber' | 'red'; + href?: string; + }> = []; + + const remainingCredits = data.usage.billing?.remainingCredits ?? 0; + const monthlyQuota = data.usage.billing?.monthlyQuota ?? 0; + const lowCreditThreshold = Math.max(10, Math.ceil(monthlyQuota * 0.25)); + + if (remainingCredits <= lowCreditThreshold) { + items.push({ + title: '额度偏低', + detail: `当前仅剩 ${remainingCredits} credits,已低于管理阈值 ${lowCreditThreshold}。`, + severity: remainingCredits <= 5 ? 'red' : 'amber', + href: '/usage' + }); + } + + const failed = data.ops.summary.failed ?? 0; + const delayed = data.ops.summary.delayed ?? 0; + if (failed > 0 || delayed > 0) { + items.push({ + title: '队列存在异常积压', + detail: `failed ${failed} · delayed ${delayed}。建议进入队列健康页进一步排查。`, + severity: failed > 0 ? 'red' : 'amber', + href: '/ops' + }); + } + + if (data.audit.hiddenLast24h > 0 || data.audit.hiddenTotal > 0) { + items.push({ + title: '审计可见域存在受限记录', + detail: `24h 内隐藏 ${data.audit.hiddenLast24h} 条,累计隐藏 ${data.audit.hiddenTotal} 条。`, + severity: 'amber', + href: '/audit' + }); + } + + if (items.length === 0) { + items.push({ + title: '暂无显著异常', + detail: '当前没有检测到额度告警、队列失败或新的受限审计异常。', + severity: 'neutral' + }); + } + + return items.slice(0, 3); + }, [data]); + + return ( + + {loading ? : null} + + {!loading && error ? ( + void load()} + extra={isWorkspaceMissing(error) ? : undefined} + /> + ) : null} + + {!loading && !error && !data ? ( + + ) : null} + + {data && posture ? ( +
+
+
+
+
+

{workspace?.name ?? '当前工作区'}

+ +
+

+ slug: {workspace?.slug ?? '-'} + {workspace?.memberCount ? ` · 成员 ${workspace.memberCount}` : ''} + {workspace?.ownerId ? ` · owner ${workspace.ownerId.slice(0, 8)}` : ''} +

+

+ 快照时间:{new Date(data.ops.now).toLocaleString('zh-CN')} · 该页面向管理层,用于快速判断工作区是否需要介入。 +

+
+ +
+ + + + +
+
+
+ + + + + +
+ + + + +
+ +
+
+

最近异常点

+

按当前角色可见域聚合

+
+
+ {anomalies.map((item) => ( + + ))} +
+
+ +
+ + + 0 ? [`hiddenTotal=${data.audit.hiddenTotal}`] : []} + href="/audit" + hrefLabel="查看审计详情" + /> + + +
+ +
+

下一步建议

+
    +
  • 需要完整账本或成本细节时,切到具备更高角色的工作区成员。
  • +
  • 如果 snapshot 与详细页不一致,优先检查对应详细页的 visibility 标记。
  • +
  • 快照时间:{new Date(data.ops.now).toLocaleString('zh-CN')}
  • +
+
+
+ ) : null} +
+ ); +} + +function RedactionBanner(props: { items: string[] }) { + if (props.items.length === 0) { + return ( +
+ 当前角色可查看完整管理快照,没有额外字段被 Batch-17 策略隐藏。 +
+ ); + } + + return ( +
+

Redaction summary

+

+ 当前管理视图受角色分级控制,以下信息已被裁剪或限制: +

+
    + {props.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); +} + +function VisibilityPanel(props: { title: string; description: string; extra?: string }) { + return ( +
+

{props.title}

+

{props.description}

+ {props.extra ?

{props.extra}

: null} +
+ ); +} + +function MiniStat(props: { label: string; value: string }) { + return ( +
+

{props.label}

+

{props.value}

+
+ ); +} + +function MetricCard(props: { title: string; value: string }) { + return ( +
+

{props.title}

+

{props.value}

+
+ ); +} + +function RoleBadge(props: { role: 'OWNER' | 'ADMIN' | 'EDITOR' | 'VIEWER' }) { + const tone = + props.role === 'OWNER' + ? 'bg-purple-100 text-purple-800 border-purple-200' + : props.role === 'ADMIN' + ? 'bg-blue-100 text-blue-800 border-blue-200' + : props.role === 'EDITOR' + ? 'bg-amber-100 text-amber-800 border-amber-200' + : 'bg-gray-100 text-gray-700 border-gray-200'; + + return ( + + {roleLabel(props.role)} + + ); +} + +function SnapshotCard(props: { + title: string; + tier: string; + summary: string[]; + redactedFields: string[]; + href: string; + hrefLabel: string; +}) { + return ( +
+
+
+

{props.title}

+

{props.tier}

+
+ + {props.hrefLabel} + +
+ +
    + {props.summary.map((line) => ( +
  • • {line}
  • + ))} +
+ +

+ {props.redactedFields.length > 0 + ? `隐藏字段:${props.redactedFields.join('、')}` + : '当前卡片对应视图无额外字段隐藏'} +

+
+ ); +} + +function AnomalyCard(props: { + title: string; + detail: string; + severity: 'neutral' | 'amber' | 'red'; + href?: string; +}) { + const tone = + props.severity === 'red' + ? 'border-red-200 bg-red-50 text-red-900' + : props.severity === 'amber' + ? 'border-amber-200 bg-amber-50 text-amber-900' + : 'border-emerald-200 bg-emerald-50 text-emerald-900'; + + return ( +
+
+
+

{props.title}

+

{props.detail}

+
+ {props.href ? ( + + 查看 + + ) : null} +
+
+ ); +} + +function roleLabel(role: 'OWNER' | 'ADMIN' | 'EDITOR' | 'VIEWER') { + const labels = { + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer' + } as const; + return labels[role] ?? role; +} + +function tierLabel(tier: 'FULL' | 'LIMITED' | 'OVERVIEW') { + if (tier === 'FULL') return '完整快照'; + if (tier === 'LIMITED') return '受限快照'; + return '概览快照'; +} + +function auditScopeLabel(scope: 'FULL_WORKSPACE' | 'OPERATIONS_ONLY') { + return scope === 'FULL_WORKSPACE' ? '全工作区可见' : '仅可见运营域'; +} diff --git a/apps/web/app/usage/page.tsx b/apps/web/app/usage/page.tsx index e631105..ebda2ed 100644 --- a/apps/web/app/usage/page.tsx +++ b/apps/web/app/usage/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; +import type { UsageEventEntity, UsageSummaryEntity, UsageTrendPoint, UsageVisibility, WorkspaceRoleValue } from '@draftorbit/shared'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { WorkbenchShell } from '../../components/shell/workbench-shell'; import { EmptyState, ErrorState, LoadingState } from '../../components/ui/page-states'; @@ -8,9 +9,9 @@ import { WorkspaceRecovery, isWorkspaceMissing, normalizeErrorMessage } from '.. import { fetchUsageEvents, fetchUsageSummary, fetchUsageTrends } from '../../lib/queries'; export default function UsagePage() { - const [summary, setSummary] = useState | null>(null); - const [events, setEvents] = useState[]>([]); - const [points, setPoints] = useState>>([]); + const [summary, setSummary] = useState(null); + const [events, setEvents] = useState([]); + const [points, setPoints] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -44,6 +45,7 @@ export default function UsagePage() { () => points.reduce((sum, item) => sum + Number(item.costUsd ?? 0), 0), [points] ); + const usageVisibility = summary?.visibility; return ( @@ -66,14 +68,34 @@ export default function UsagePage() { {summary ? ( <>
- - - - + + + +
+ {usageVisibility ? ( + 0 + ? `已隐藏字段:${usageVisibility.redactedFields.join('、')}` + : '当前角色可查看完整用量与计费快照。' + } + /> + ) : null} +

近 14 日用量趋势

+ {!usageVisibility?.canViewCosts ? ( +

当前角色仅可查看趋势数量,成本曲线已隐藏。

+ ) : null}
@@ -94,11 +116,19 @@ export default function UsagePage() { {events.map((event) => (

- {String(event.eventType)} · {String(event.model ?? 'N/A')} -

-

- input={String(event.inputTokens ?? 0)} output={String(event.outputTokens ?? 0)} cost={String(event.costUsd ?? 0)} + {String(event.eventType)} · {event.model ?? '明细已隐藏'}

+

{new Date(event.createdAt).toLocaleString('zh-CN')}

+ {event.detailsRedacted ? ( +

+ 当前角色仅可查看事件概览,模型、token 与成本明细已隐藏。 +

+ ) : ( +

+ input={String(event.inputTokens ?? 0)} output={String(event.outputTokens ?? 0)} cost= + {String(event.costUsd ?? 0)} +

+ )}
))} {events.length === 0 ?

暂无用量事件

: null} @@ -117,3 +147,29 @@ function MetricCard(props: { title: string; value: string }) {
); } + +function VisibilityPanel(props: { title: string; description: string; extra?: string }) { + return ( +
+

{props.title}

+

{props.description}

+ {props.extra ?

{props.extra}

: null} +
+ ); +} + +function roleLabel(role: WorkspaceRoleValue) { + const labels: Record = { + OWNER: 'Owner', + ADMIN: 'Admin', + EDITOR: 'Editor', + VIEWER: 'Viewer' + }; + return labels[role] ?? role; +} + +function tierLabel(visibility: UsageVisibility) { + if (visibility.accessTier === 'FULL') return '完整快照'; + if (visibility.accessTier === 'LIMITED') return '受限快照'; + return '概览快照'; +} diff --git a/apps/web/components/shell/workbench-shell.tsx b/apps/web/components/shell/workbench-shell.tsx index 8ab94c8..e400d8d 100644 --- a/apps/web/components/shell/workbench-shell.tsx +++ b/apps/web/components/shell/workbench-shell.tsx @@ -40,7 +40,9 @@ const NAV_GROUPS = [ { label: '系统', items: [ + { href: '/snapshot', label: '系统快照' }, { href: '/dashboard', label: '运营总览' }, + { href: '/ops', label: '队列健康' }, { href: '/providers', label: '模型服务中心' }, { href: '/usage', label: '用量与计费' }, { href: '/audit', label: '审计日志' } diff --git a/apps/web/lib/queries.ts b/apps/web/lib/queries.ts index 6a2b283..aed7ce5 100644 --- a/apps/web/lib/queries.ts +++ b/apps/web/lib/queries.ts @@ -1,5 +1,10 @@ import type { - AuditLogEntity, + AuditLogsResponse, + AuditSummaryEntity, + OpsQueuesResponse, + UsageEventEntity, + UsageSummaryEntity, + UsageTrendsEntity, DraftEntity, ProviderEntity, PublishJobEntity, @@ -288,37 +293,23 @@ export async function routeProviderText(input: { } export async function fetchUsageSummary() { - return apiFetch>('/usage/summary'); + return apiFetch('/usage/summary'); } export async function fetchUsageEvents(limit = 100) { - return apiFetch[]>(`/usage/events?limit=${limit}`); + return apiFetch(`/usage/events?limit=${limit}`); } export async function fetchUsageTrends(days = 14) { - return apiFetch<{ - workspaceId: string; - days: number; - from: string; - points: Array<{ - date: string; - generation: number; - naturalization: number; - image: number; - reply: number; - publish: number; - totalEvents: number; - costUsd: number; - }>; - }>(`/usage/trends?days=${days}`); + return apiFetch(`/usage/trends?days=${days}`); } export async function fetchAuditLogs(limit = 100) { - return apiFetch(`/audit/logs?limit=${limit}`); + return apiFetch(`/audit/logs?limit=${limit}`); } export async function fetchAuditSummary() { - return apiFetch>('/audit/summary'); + return apiFetch('/audit/summary'); } export async function fetchMediaAssets() { @@ -399,5 +390,5 @@ export async function naturalizePreview(input: { } export async function fetchQueueHealth() { - return apiFetch>('/ops/queues'); + return apiFetch('/ops/queues'); } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f323646..081c677 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -83,6 +83,31 @@ export interface ProviderEntity { baseUrl?: string | null; } +export type WorkspaceRoleValue = 'OWNER' | 'ADMIN' | 'EDITOR' | 'VIEWER'; + +export type AuditVisibilityDomain = + | 'CONTENT' + | 'LEARNING' + | 'MEDIA' + | 'PUBLISHING' + | 'REPLY' + | 'WORKFLOW' + | 'INTEGRATIONS' + | 'BILLING' + | 'WORKSPACE_ADMIN' + | 'UNKNOWN'; + +export type AuditVisibilityScope = 'FULL_WORKSPACE' | 'OPERATIONS_ONLY'; +export type AuditPayloadAccess = 'FULL' | 'NONE'; + +export interface AuditVisibility { + role: WorkspaceRoleValue; + scope: AuditVisibilityScope; + payloadAccess: AuditPayloadAccess; + visibleDomains: AuditVisibilityDomain[]; + hiddenDomains: AuditVisibilityDomain[]; +} + export interface AuditLogEntity { id: string; action: string; @@ -90,6 +115,132 @@ export interface AuditLogEntity { resourceId: string | null; payload: Record | null; createdAt: string; + visibilityDomain?: AuditVisibilityDomain; + payloadRedacted?: boolean; +} + +export interface AuditLogsResponse { + items: AuditLogEntity[]; + hiddenCount: number; + visibility: AuditVisibility; + limit: number; +} + +export interface AuditSummaryEntity { + workspaceId: string; + total: number; + last24h: number; + workspaceTotal: number; + workspaceLast24h: number; + hiddenTotal: number; + hiddenLast24h: number; + visibility: AuditVisibility; +} + +export type UsageSnapshotAccessTier = 'FULL' | 'LIMITED' | 'OVERVIEW'; + +export interface UsageVisibility { + role: WorkspaceRoleValue; + accessTier: UsageSnapshotAccessTier; + canViewCosts: boolean; + canViewLedgerDetails: boolean; + canManageCredits: boolean; + redactedFields: string[]; +} + +export interface UsageBillingSnapshot { + plan: string; + status: string; + monthlyQuota: number; + remainingCredits: number; + cycleStart: string | null; + cycleEnd: string | null; + stripeCustomerId?: string | null; +} + +export interface UsageTokenCostSnapshot { + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface CreditLedgerSnapshot { + id: string; + direction: string; + amount: number; + balanceAfter: number | null; + reason: string; + createdAt: string; + metadata?: Record | null; +} + +export interface UsageSummaryEntity { + workspaceId: string; + periodStart: string; + billing: UsageBillingSnapshot | null; + counters: { + usageEvents: number; + generations: number; + publishJobs: number; + replyJobs: number; + }; + tokenCost: UsageTokenCostSnapshot | null; + latestLedgers: CreditLedgerSnapshot[]; + visibility: UsageVisibility; +} + +export interface UsageEventEntity { + id: string; + eventType: string; + model: string | null; + inputTokens: number | null; + outputTokens: number | null; + costUsd: number | null; + createdAt: string; + detailsRedacted: boolean; +} + +export interface UsageTrendPoint { + date: string; + generation: number; + naturalization: number; + image: number; + reply: number; + publish: number; + totalEvents: number; + costUsd: number | null; +} + +export interface UsageTrendsEntity { + workspaceId: string; + days: number; + from: string; + visibility: UsageVisibility; + points: UsageTrendPoint[]; +} + +export interface OpsQueueStats { + waiting: number; + active: number; + completed: number; + failed: number | null; + delayed: number | null; + paused: number | null; +} + +export interface OpsVisibility { + role: WorkspaceRoleValue; + accessTier: UsageSnapshotAccessTier; + canViewPerQueue: boolean; + canViewFailureDetails: boolean; + redactedFields: string[]; +} + +export interface OpsQueuesResponse { + visibility: OpsVisibility; + summary: OpsQueueStats; + queues: Record | null; + hiddenQueueCount: number; } export interface XAccountEntity {