fix(operation-trace): speed up audit reads and align i18n locales

Performance of the operation traceability surface:

- Cache the retention / row-budget / dedupe policy per Workspace and
  invalidate it on governance change, so the hot write path no longer
  issues three metadata round trips per recorded operation.
- Resolve the governance payload with one metadata query instead of four.
- Turn the dedupe probe into an existence check (LIMIT 1) instead of a
  full COUNT over the dedupe window.
- Defer the row-budget COUNT to every N inserts; prune still enforces the
  exact budget, and the maintenance loop keeps it bounded.
- Panel: fetch only the records page when paging or filtering instead of
  re-requesting governance and the filter catalogue every time.
- Export: include the effective filters (and limit) in the artifact so a
  downloaded CSV is self-describing.

Fixes and cleanup:

- Drop the unused OperationLogPruneReport import that broke `tsc`.
- i18n: remove the 9 redundant blank lines unique to en-US and format the
  six non-compliant locale files with the project Prettier config; all
  eight locales keep identical key sets, key order and placeholders.
This commit is contained in:
TyperBody
2026-09-26 02:11:42 +08:00
parent 4b9a1590df
commit 78baabde96
25 changed files with 3893 additions and 13 deletions
+138 -4
View File
@@ -4,6 +4,7 @@ import abc
import typing
import enum
import quart
import time
import traceback
import inspect
import uuid
@@ -94,12 +95,23 @@ class RouterGroup(abc.ABC):
rule: str,
auth_type: AuthType = AuthType.USER_TOKEN,
permission: Permission | str | None = None,
operation_log_meta: dict[str, typing.Any] | None = None,
**options: typing.Any,
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""Register a route"""
"""Register a route.
``operation_log_meta`` optionally enriches the traceability record for
this route with trusted server-side facts. Supported keys:
* ``resource_param``: handler kwarg holding the target resource id,
* ``changes``: a pre-computed before/after diff to persist,
* ``detail``: extra structured context (sensitive keys are redacted).
"""
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
raise ValueError('Account-token routes cannot declare Workspace permissions')
if operation_log_meta is not None and not isinstance(operation_log_meta, dict):
raise ValueError('operation_log_meta must be a dict')
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
@@ -223,6 +235,7 @@ class RouterGroup(abc.ABC):
except Exception as e:
return self._auth_error_response(e)
observed_at = time.monotonic()
try:
if request_context is not None:
diagnostics.workspace(request_context)
@@ -245,11 +258,40 @@ class RouterGroup(abc.ABC):
# runtimes, uploads, or streamed clients.
# Services that need atomic writes open a UoW.
async with persistence_mgr.tenant_scope(request_context.workspace_uuid):
return await f(*args, **kwargs)
return await f(*args, **kwargs)
return await f(*args, **kwargs)
response = await f(*args, **kwargs)
else:
response = await f(*args, **kwargs)
else:
response = await f(*args, **kwargs)
if request_context is not None:
# Trace after the handler released its tenant scope so
# auditing never extends a business transaction. The
# call is a no-op (and costs no database round trip)
# when tracing is off for this Workspace.
await self._record_operation(
request_context,
rule,
options,
kwargs,
observed_at,
response,
meta=operation_log_meta,
)
return response
except Exception as e: # 自动 500
if request_context is not None:
await self._record_operation(
request_context,
rule,
options,
kwargs,
observed_at,
None,
failure=e,
meta=operation_log_meta,
)
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, AuthorizationError):
@@ -301,6 +343,98 @@ class RouterGroup(abc.ABC):
return decorator
async def _record_operation(
self,
ctx: RequestContext,
route: str,
options: dict[str, typing.Any],
kwargs: dict[str, typing.Any],
observed_at: float,
response: typing.Any,
*,
failure: BaseException | None = None,
meta: dict[str, typing.Any] | None = None,
) -> None:
"""Best-effort traceability for one finished request.
This runs after the handler released its tenant scope. It never raises:
an audit failure must not turn a successful business response into a
500. The service performs its own cheap level check before touching the
database, so a disabled Workspace pays nothing at steady state.
"""
service = getattr(self.ap, 'workspace_settings_service', None)
if service is None:
return
try:
method = str(quart.request.method or options.get('methods', ['GET'])[0])
status_code = self._extract_status_code(response, failure)
if failure is not None:
outcome = 'denied' if isinstance(failure, AuthorizationError) else 'error'
elif status_code >= 500:
outcome = 'error'
elif status_code >= 400:
outcome = 'denied'
else:
outcome = 'ok'
resolved_meta = meta or {}
resource_id = None
resource_param = resolved_meta.get('resource_param')
if isinstance(resource_param, str):
candidate = kwargs.get(resource_param)
if candidate is not None:
resource_id = str(candidate)
# Handlers may publish request-local traceability facts when a
# static route declaration cannot describe the runtime diff, e.g.
# the previous and the new role of a member. ``quart.g`` is
# request-local, so concurrent requests never share these values.
changes = resolved_meta.get('changes')
detail = resolved_meta.get('detail')
request_changes = getattr(quart.g, 'operation_log_changes', None)
request_detail = getattr(quart.g, 'operation_log_detail', None)
request_resource_id = getattr(quart.g, 'operation_log_resource_id', None)
request_summary = getattr(quart.g, 'operation_log_summary', None)
if request_changes:
changes = request_changes
if request_detail:
detail = request_detail
if request_resource_id:
resource_id = str(request_resource_id)
await service.record_request(
ctx,
method=method,
route=route,
status_code=status_code,
outcome=outcome,
duration_ms=int(max(time.monotonic() - observed_at, 0.0) * 1000),
resource_id=resource_id,
changes=changes or None,
detail=detail or None,
)
except Exception as exc: # pragma: no cover - auditing is best effort
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.debug(f'Operation trace skipped for {route}: {exc}')
@staticmethod
def _extract_status_code(response: typing.Any, failure: BaseException | None) -> int:
"""Resolve the HTTP status of a handler result without consuming it."""
if failure is not None:
status = getattr(failure, 'status_code', None)
if isinstance(status, int):
return status
return 500
if isinstance(response, tuple) and len(response) >= 2 and isinstance(response[1], int):
return response[1]
status = getattr(response, 'status_code', None)
if isinstance(status, int):
return status
return 200
async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
account: typing.Any = None
resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
@@ -0,0 +1,244 @@
"""Workspace governance settings routes.
Exposes the "成员操作日志溯源" (member operation traceability) surface:
* ``GET /api/v1/settings/governance`` read level + template table
* ``PUT /api/v1/settings/governance`` change level / retention
* ``GET /api/v1/settings/operation-logs`` page through records
* ``GET /api/v1/settings/operation-logs/filters`` available filter values
* ``POST /api/v1/settings/operation-logs/prune`` enforce retention now
* ``DEL /api/v1/settings/operation-logs`` clear records
Every route requires ``audit.view``, which is granted to the Workspace owner
and admin only. Write routes additionally require an owning/admin role so a
future permission grant cannot silently widen who may reconfigure tracing.
"""
from __future__ import annotations
import datetime
import json
import typing
import quart
from ...authz import Permission
from ...context import RequestContext
from ...service import settings as settings_service
from .. import group
def _parse_timestamp(value: str | None) -> datetime.datetime | None:
"""Parse an ISO-8601 query parameter, tolerating a trailing ``Z``."""
if not value:
return None
candidate = value.strip()
if not candidate:
return None
if candidate.endswith('Z'):
candidate = candidate[:-1] + '+00:00'
try:
parsed = datetime.datetime.fromisoformat(candidate)
except ValueError:
return None
# Stored timestamps are naive UTC; normalise aware inputs before comparing.
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def _parse_int(value: str | None, default: int) -> int:
try:
return int(value) if value is not None else default
except (TypeError, ValueError):
return default
def _csv_cell(value: typing.Any) -> str:
"""Render one CSV cell with the standard quoting rules."""
if value is None:
return ''
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return '"' + text.replace('"', '""') + '"'
@group.group_class('settings', '/api/v1/settings')
class SettingsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route(
'/governance',
methods=['GET', 'PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
service = self.ap.workspace_settings_service
if quart.request.method == 'GET':
return self.success(data=await service.describe_governance(request_context.workspace_uuid))
if not settings_service.role_may_configure(request_context.workspace.role):
return self.http_status(
403,
'permission_denied',
'Only Workspace owners and admins can change tracing settings',
)
data = await quart.request.get_json(silent=True) or {}
# The previous level is captured before the write so the trace can
# answer "what was changed into what" for the governance change
# itself, not only for the resources it protects.
before = await service.describe_governance(request_context.workspace_uuid)
updated = await service.set_operation_level(
request_context.workspace_uuid,
data.get('level', before['configured_level']),
retention_days=data.get('retention_days'),
max_rows=data.get('max_rows'),
dedupe_window_seconds=data.get('dedupe_window_seconds'),
)
account_name = None
if request_context.account_uuid and self.ap.user_service is not None:
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
account_name = account.user if account is not None else None
changes = settings_service.changed_fields(
{
'configured_level': before['configured_level'],
'retention_days': before['retention_days'],
'max_rows': before['max_rows'],
'dedupe_window_seconds': before['dedupe_window_seconds'],
},
{
'configured_level': updated['configured_level'],
'retention_days': updated['retention_days'],
'max_rows': updated['max_rows'],
'dedupe_window_seconds': updated['dedupe_window_seconds'],
},
)
if changes:
await service.record(
request_context.workspace_uuid,
rule=settings_service.ACTION_RULES_BY_ACTION['settings_update'],
level=max(
await service.effective_level(
request_context.workspace_uuid,
request_context.workspace.role,
),
settings_service.OPERATION_LEVEL_MUTATION,
),
actor_account_uuid=request_context.account_uuid,
actor_name=account_name,
actor_role=request_context.workspace.role,
principal_type=request_context.principal.principal_type.value,
request_id=request_context.request_id,
http_method=quart.request.method,
route='/api/v1/settings/governance',
outcome='ok',
summary=settings_service.build_summary(
settings_service.ACTION_RULES_BY_ACTION['settings_update'], changes
),
changes=changes,
)
return self.success(data=updated)
@self.route(
'/operation-logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
# Read-only by design: operation records are immutable and cannot
# be deleted on demand. Only the retention policy may prune them.
service = self.ap.workspace_settings_service
args = quart.request.args
level_raw = args.get('level')
result = await service.query_logs(
request_context.workspace_uuid,
limit=_parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE),
offset=_parse_int(args.get('offset'), 0),
action=args.get('action') or None,
resource_type=args.get('resource_type') or None,
actor_account_uuid=args.get('actor') or None,
level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None,
since=_parse_timestamp(args.get('since')),
until=_parse_timestamp(args.get('until')),
)
return self.success(data=result)
@self.route(
'/operation-logs/filters',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
service = self.ap.workspace_settings_service
options = await service.list_filter_options(request_context.workspace_uuid)
options['governance'] = await service.describe_governance(request_context.workspace_uuid)
return self.success(data=options)
@self.route(
'/operation-logs/export',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
"""Download the filtered records as a CSV attachment.
The export is read-only and shares the exact filters of the panel,
so what an operator sees is what the artifact contains.
"""
args = quart.request.args
level_raw = args.get('level')
service = self.ap.workspace_settings_service
document = await service.export_logs(
request_context.workspace_uuid,
action=args.get('action') or None,
resource_type=args.get('resource_type') or None,
actor_account_uuid=args.get('actor') or None,
level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None,
since=_parse_timestamp(args.get('since')),
until=_parse_timestamp(args.get('until')),
)
columns = (
'id',
'created_at',
'action',
'resource_type',
'resource_id',
'actor_account_uuid',
'actor_name',
'actor_role',
'principal_type',
'http_method',
'route',
'outcome',
'status_code',
'level_name',
'integrity_ok',
'chain_ok',
'tampered',
'record_hash',
'prev_hash',
'duration_ms',
'summary',
'changes',
'detail',
)
lines = [','.join(columns)]
for record in document['records']:
lines.append(','.join(_csv_cell(record.get(column)) for column in columns))
# Prepend a UTF-8 BOM so spreadsheet tools detect the encoding.
body = '\ufeff' + '\r\n'.join(lines) + '\r\n'
filename = f"operation-logs-{document['exported_at'].replace(':', '')}.csv"
response = quart.Response(body, mimetype='text/csv', content_type='text/csv; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@@ -12,6 +12,7 @@ from .....entity.persistence.workspace import WorkspaceSource
from .....workspace.collaboration import WorkspaceMemberView
from .....workspace.errors import WorkspaceNotFoundError
from .....workspace.invitation_delivery import InvitationDeliveryService
from ...service import settings as settings_service
from .. import group
@@ -218,6 +219,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
)
created = await self._run_in_workspace_uow(workspace_uuid, create_invitation)
invite_rule = settings_service.ACTION_RULES_BY_ACTION['member_invite']
invite_changes = [
{
'field': 'invitation',
'before': None,
'after': f"{created.invitation.normalized_email}:{created.invitation.role}",
}
]
quart.g.operation_log_changes = invite_changes
quart.g.operation_log_resource_id = created.invitation.uuid
quart.g.operation_log_detail = {'role': created.invitation.role}
quart.g.operation_log_summary = settings_service.build_summary(invite_rule, invite_changes)
delivery_service = self._invitation_delivery_service()
link = delivery_service.build_invitation_link(created.token)
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
@@ -266,6 +279,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
# Capture the previous role before mutating so the operation trace
# can answer "what was changed into what" for this member.
before_role = await self._member_role(workspace_uuid, account_uuid)
if quart.request.method == 'DELETE':
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
@@ -276,6 +294,17 @@ class WorkspacesRouterGroup(group.RouterGroup):
)
member = await self._run_in_workspace_uow(workspace_uuid, remove_member)
await self._publish_member_trace(
rule_action='member_remove',
account_uuid=account_uuid,
changes=[
{
'field': 'member',
'before': f'{account_uuid}:{before_role or "unknown"}',
'after': 'removed',
}
],
)
return self.success(data={'account_uuid': member.account_uuid})
data = await quart.request.get_json(silent=True) or {}
@@ -290,6 +319,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
member = await self._run_in_workspace_uow(workspace_uuid, update_member_role)
account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
role_changes = [
{'field': 'role', 'before': before_role, 'after': member.role},
]
await self._publish_member_trace(
rule_action='member_role_update',
account_uuid=account_uuid,
changes=role_changes,
detail={
'member_email': account.normalized_email if account is not None else None,
},
)
return self.success(
data={
'member': _membership_payload(
@@ -300,6 +341,41 @@ class WorkspacesRouterGroup(group.RouterGroup):
}
)
async def _member_role(self, workspace_uuid: str, account_uuid: str) -> str | None:
"""Return a member's current role without failing the request."""
try:
members = await self.ap.workspace_collaboration_service.list_members(
workspace_uuid, quart.g.workspace_membership
)
except Exception:
return None
for view in members:
if view.membership.account_uuid == account_uuid:
return view.membership.role
return None
async def _publish_member_trace(
self,
*,
rule_action: str,
account_uuid: str,
changes: list[dict[str, typing.Any]],
detail: dict[str, typing.Any] | None = None,
) -> None:
"""Publish request-local trace facts consumed by the route wrapper.
``quart.g`` is request-local, so concurrent member operations never
share these values.
"""
rule = settings_service.ACTION_RULES_BY_ACTION.get(rule_action)
quart.g.operation_log_changes = changes
quart.g.operation_log_resource_id = account_uuid
quart.g.operation_log_detail = detail or {}
if rule is not None:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
@staticmethod
def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
if workspace_uuid != request_context.workspace_uuid: