From b8b2854aa60566bda340c1fbbce971f313714c82 Mon Sep 17 00:00:00 2001 From: luo Date: Fri, 7 Aug 2026 18:33:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A1=E8=AE=A1=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design.md | 53 ++ .../proposal.md | 38 ++ .../audit-chain-frontend-integration/spec.md | 207 ++++++++ .../tasks.md | 44 ++ .../design.md | 52 ++ .../implementation-audit.md | 41 ++ .../proposal.md | 40 ++ .../specs/audit-cross-view-navigation/spec.md | 124 +++++ .../tasks.md | 44 ++ scripts/tests/auditNavigation.test.mjs | 121 +++++ src/App.vue | 2 + src/api/modules/audit.ts | 124 +++++ src/api/modules/index.ts | 1 + .../business/audit/AuditDistributionChart.vue | 162 ++++++ .../business/audit/AuditEventTable.vue | 53 ++ .../audit/AuditInvestigationDrawer.vue | 462 ++++++++++++++++++ .../business/audit/AuditInvestigationHost.vue | 23 + .../audit/AuditInvestigationLinks.vue | 154 ++++++ .../audit/AuditResourceSearchDialog.vue | 269 ++++++++++ .../business/audit/investigationController.ts | 25 + src/components/business/audit/types.ts | 24 + src/config/constants/audit.ts | 27 + src/router/routes/asyncRoutes.ts | 67 +++ src/router/routesAlias.ts | 7 + src/types/api/audit.ts | 449 +++++++++++++++++ src/types/api/index.ts | 3 + src/types/components.d.ts | 6 + src/types/router/index.ts | 2 + src/utils/business/audit.ts | 137 ++++++ src/utils/business/auditNavigation.ts | 69 +++ src/utils/business/notificationNavigation.ts | 11 +- .../account-management/account/index.vue | 16 + .../enterprise-customer/index.vue | 18 + .../components/BasicInfoCard.vue | 87 +++- .../components/WalletTransactionCard.vue | 34 ++ .../asset-management/device-list/index.vue | 26 + .../exchange-management/detail.vue | 60 +++ .../exchange-management/index.vue | 46 +- .../iot-card-management/index.vue | 28 +- .../record-management/asset-assign/detail.vue | 46 +- .../record-management/asset-assign/index.vue | 41 ++ src/views/audit/events/detail.vue | 323 ++++++++++++ src/views/audit/events/index.vue | 310 ++++++++++++ src/views/audit/integrations/detail.vue | 399 +++++++++++++++ src/views/audit/integrations/index.vue | 457 +++++++++++++++++ src/views/audit/risks/index.vue | 405 +++++++++++++++ .../agent-fund-overview/index.vue | 19 + src/views/finance/agent-recharge/detail.vue | 57 +++ src/views/finance/agent-recharge/index.vue | 46 +- src/views/finance/refund/detail.vue | 55 +++ src/views/finance/refund/index.vue | 49 +- .../order-management/order-list/detail.vue | 34 ++ .../order-management/order-list/index.vue | 31 ++ src/views/shop-management/list/index.vue | 32 ++ 54 files changed, 5448 insertions(+), 12 deletions(-) create mode 100644 openspec/changes/add-audit-chain-frontend-integration/design.md create mode 100644 openspec/changes/add-audit-chain-frontend-integration/proposal.md create mode 100644 openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md create mode 100644 openspec/changes/add-audit-chain-frontend-integration/tasks.md create mode 100644 openspec/changes/align-audit-cross-view-navigation/design.md create mode 100644 openspec/changes/align-audit-cross-view-navigation/implementation-audit.md create mode 100644 openspec/changes/align-audit-cross-view-navigation/proposal.md create mode 100644 openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md create mode 100644 openspec/changes/align-audit-cross-view-navigation/tasks.md create mode 100644 scripts/tests/auditNavigation.test.mjs create mode 100644 src/api/modules/audit.ts create mode 100644 src/components/business/audit/AuditDistributionChart.vue create mode 100644 src/components/business/audit/AuditEventTable.vue create mode 100644 src/components/business/audit/AuditInvestigationDrawer.vue create mode 100644 src/components/business/audit/AuditInvestigationHost.vue create mode 100644 src/components/business/audit/AuditInvestigationLinks.vue create mode 100644 src/components/business/audit/AuditResourceSearchDialog.vue create mode 100644 src/components/business/audit/investigationController.ts create mode 100644 src/components/business/audit/types.ts create mode 100644 src/config/constants/audit.ts create mode 100644 src/types/api/audit.ts create mode 100644 src/utils/business/audit.ts create mode 100644 src/utils/business/auditNavigation.ts create mode 100644 src/views/audit/events/detail.vue create mode 100644 src/views/audit/events/index.vue create mode 100644 src/views/audit/integrations/detail.vue create mode 100644 src/views/audit/integrations/index.vue create mode 100644 src/views/audit/risks/index.vue diff --git a/openspec/changes/add-audit-chain-frontend-integration/design.md b/openspec/changes/add-audit-chain-frontend-integration/design.md new file mode 100644 index 0000000..4e949a4 --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/design.md @@ -0,0 +1,53 @@ +## Context + +本次变更横跨平台、代理和企业三类主体以及资产、订单、退款、钱包、通知等多个业务页面。OpenAPI 将关联关系明确建模为 `investigation_refs`、`linkage` 和稳定资源引用,并区分平台内部 ID 与代理/企业可见的业务标识。前端必须保持这些边界,不能为了补齐跳转而从名称、时间或摘要推断关联关系。 + +仓库目前没有已归档的基线 specs,但存在通知中心和旧资产操作日志的活跃变更。本提案建立独立的审计链路能力,通过明确的依赖点与它们集成,不复制或覆盖其已有要求。 + +## Goals / Non-Goals + +- Goals: 按 OpenAPI 实现 16 个只读接口的类型、查询、页面和跨视角跳转契约。 +- Goals: 为平台、代理和企业提供符合各自权限与数据投影的业务审计入口。 +- Goals: 统一分页、RFC3339 时间范围、枚举展示、在线保留窗口和错误处理。 +- Non-Goals: 不修改后端接口、数据库、日志采集或保留策略。 +- Non-Goals: 不提供恢复、重试、修改、删除、处置、封禁、任意全文搜索或导出。 +- Non-Goals: 不替换现有资产操作日志组件,也不修改 C/H5 端。 + +## Decisions + +- Decision: 新增独立审计 API 模块和共享类型,按平台审计、主体活动、资金、风险、Integration、链路时间线分组暴露查询函数;统一保留服务端响应包装和 nullable 字段。 +- Decision: 平台业务入口使用响应中的内部稳定 ID。卡、设备、店铺、企业、订单和退款分别使用对应 `resource_type` 与 `response.data.id`;钱包通过资金时间线的 `wallet_id` 进入。 +- Decision: 代理与企业入口使用响应中的稳定业务标识。卡使用 `iccid`,设备使用 `virtual_no`;代理的分配、换货、店铺和企业使用接口文档指定的业务编号。缺少稳定标识时隐藏入口。 +- Decision: 前端只转发后端返回的调查引用。事件的 `event_id`、`actor_ref`、`resource_refs`、`request_id`、`correlation_id`、`integration_refs`,以及 Integration 的 `linkage`/`resource` 是跨视角导航的唯一数据来源。 +- Decision: `request_id` 可以来自调查引用、Integration、或用户从 Access Log 明确粘贴;`correlation_id` 和 `integration_id` 只能来自接口返回的稳定字段。前端不使用 UUID 或其他方式生成这些 ID。 +- Decision: 对不存在、为空或 `fidelity=false` 的引用隐藏对应跳转,禁止按名称、时间、摘要或相邻记录猜测缺失关系。 +- Decision: 资源搜索严格使用 `resource_type + keyword` 精确查询,并把选中项的 `resource_type/resource_id` 原样传给资源时间线。`historical=true` 只作为历史快照命中提示。 +- Decision: 列表和时间线统一消费 `page`、`page_size`、`total` 并保留 `retention` 语义。时间筛选发送含时区的 RFC3339 值,`created_to` 按接口定义为不包含该时刻;页面不展示全局在线窗口提示,也不提供归档查询假入口。 +- Decision: 所有枚举筛选提交稳定 code,并优先展示后端 `*_name`。`action`、Integration `operation` 等开放编码必须直接来自响应,不能从中文文案反推。 +- Decision: 资金金额按分展示转换,权威性只认 `amount_authority.authoritative=true` 指向的业务字段;前端不得合并冲突金额或自行计算余额。 +- Decision: 代理与企业活动共用展示外壳但使用独立 API。企业页面只展示主体安全投影,不引入平台操作者、风险、内部原因或 before/after 字段。 +- Decision: 通知目标仅在 `available=true`、`target_type=integration_log` 且 `target_key` 非空时跳转,并将 `target_key` 原样作为 Integration 详情的 `integration_id`。其他情况保留通知正文并提示目标不可用。 +- Decision: 权限以最终菜单/按钮权限编码和服务端鉴权共同控制。平台、代理、企业不展示不属于其主体的审计入口,`401/403` 继续走共享认证与无权处理。 + +## Risks / Trade-offs + +- 最终菜单与按钮权限编码尚未体现在 OpenAPI 中;实施前必须确认,否则只能依赖服务端 `403`,无法做到准确的入口隐藏。 +- 审计事件和 Integration 模型字段较多;通过共享详情/时间线组件和严格类型避免各业务页重复实现,但不抽象为可以执行任意端点的动态页面。 +- 在线保留窗口外的数据不可由这些接口查询;空状态使用“当前查询无记录”的中性表达,避免将空数据误报为无历史事件。 +- 活跃通知中心变更也会修改通知导航;实施时需在其最新状态上追加 `integration_log` 规则,避免覆盖既有白名单映射。 +- 旧资产操作日志与新审计中心含义相邻但数据源不同;两个入口并存并清晰命名,避免误将旧日志能力删除。 + +## Migration Plan + +1. 确认平台、代理、企业的菜单和按钮权限编码,以及审计中心路由归属。 +2. 建立 OpenAPI 对齐的类型、API 模块和共享只读展示组件。 +3. 按平台审计、资金、风险、Integration、主体活动顺序接入页面。 +4. 在业务页面与通知导航中增加稳定引用入口。 +5. 完成角色、数据隔离、空引用、保留窗口、错误和跨视角跳转验收。 +6. 若上线异常,隐藏新增路由与入口;现有业务接口和旧操作日志不受影响。 + +## Open Questions + +- 平台审计中心、风险中心、Integration、代理资源活动和企业资源活动的最终菜单及按钮权限编码分别是什么? +- 审计中心是作为一级菜单,还是归入现有系统管理/运维菜单? +- OpenAPI 未包含 `GET /api/admin/notifications/{id}/target` 的完整响应 schema;是否继续以活跃通知中心变更定义的 `available/target_type/target_id/target_key` 为最终契约? diff --git a/openspec/changes/add-audit-chain-frontend-integration/proposal.md b/openspec/changes/add-audit-chain-frontend-integration/proposal.md new file mode 100644 index 0000000..d49df30 --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/proposal.md @@ -0,0 +1,38 @@ +# Change: 新增审计链路前端接入能力 + +## Why + +八月迭代新增了审计事件、请求与业务链路、资金调查、风险调查、外部集成交互以及代理/企业资源活动共 16 个只读接口。当前前端尚无统一的审计调查页面、接口类型和业务入口,无法可靠消费后端返回的稳定调查引用,也容易错误地由名称、时间或页面数据自行拼接链路标识。 + +## What Changes + +- 新增平台审计事件列表、详情、操作者时间线、资源精确搜索和资源时间线能力。 +- 新增请求链路与业务关联链路时间线,并只使用接口返回或用户从 Access Log 粘贴的稳定 `request_id`、`correlation_id`。 +- 新增资金调查时间线、风险总览与明细、Integration 总览/列表/详情页面。 +- 新增代理和企业资源活动入口,并按主体支持范围使用稳定业务标识和后端安全投影。 +- 在卡、设备、店铺、企业、订单、退款和钱包相关列表/详情页增加角色匹配的「审计记录」入口。 +- 接入通知目标到 Integration 详情的受控跳转:仅接受可用的 `integration_log` 目标。 +- 统一使用后端返回的名称、状态、保留窗口语义和调查引用;禁止前端猜测或生成 `request_id`、`correlation_id`、`integration_id`。 +- 所有新增能力均为只读调查能力,不增加恢复、修改、删除、处置、封禁或导出操作。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: + - `src/api/modules/` 下新增审计接口模块 + - `src/types/api/` 下新增审计接口类型 + - `src/views/` 下审计中心、风险中心、Integration 和时间线页面 + - 卡、设备、店铺、企业、订单、退款、钱包相关列表/详情页 + - 路由、菜单、权限控制及通知目标导航 +- API contracts: + - `GET /api/admin/audit/*` + - `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/notifications/{id}/target` +- Dependencies: + - 与活跃变更 `update-admin-notification-center-api` 的受控通知目标协议保持一致 + - 与活跃变更 `update-admin-asset-device-signal-and-audit-logs` 的旧资产操作日志展示并存;本变更不替换旧日志接口 +- Source of truth: + - `docs/产品迭代8月份/审计链路接口变更整理文档.md` + - `docs/产品迭代8月份/默认模块.openapi.json` +- Breaking changes: 无;现有业务接口路径保持不变 diff --git a/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..75d1dbc --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,207 @@ +## ADDED Requirements + +### Requirement: Platform Audit Event Investigation + +The admin frontend SHALL provide platform audit event list and detail views using `GET /api/admin/audit/events` and `GET /api/admin/audit/events/{event_id}`. It SHALL preserve the documented event, actor, scope, resource, result, risk, request and investigation-reference fields, use stable codes for filters, and prefer backend display names. + +#### Scenario: Filter and inspect audit events + +- **WHEN** an authorized platform user filters audit events by documented time, action, category, actor, source, result, risk, scope, resource or linkage parameters +- **THEN** the frontend MUST send the documented parameter names and stable code values +- **AND** it MUST render the paginated `items`, `total`, `page`, `page_size` and `retention` response + +#### Scenario: Open an event detail + +- **WHEN** the user opens an event returned by the list or another investigation reference +- **THEN** the frontend MUST pass its `event_id` unchanged to the detail endpoint +- **AND** it MUST display backend snapshots and names without replacing historical values with current account or resource names + +### Requirement: Stable Investigation Reference Navigation + +The frontend SHALL navigate between audit views only through stable identifiers returned by the APIs. `investigation_refs.actor_ref`, `resource_refs`, `request_id`, `correlation_id`, `integration_refs`, Integration `linkage`, and stable Integration `resource` references SHALL be the authoritative navigation sources. + +#### Scenario: Navigate through a returned reference + +- **WHEN** an audit or Integration response contains a non-empty supported investigation reference +- **THEN** the frontend MUST pass the returned identifier and type unchanged to the corresponding actor, resource, request, correlation or Integration view + +#### Scenario: A reference is unavailable or unreliable + +- **WHEN** the required reference is empty, absent, unsupported, or its linkage `fidelity` indicates that a relationship is unavailable +- **THEN** the frontend MUST hide or disable the related navigation action +- **AND** it MUST NOT infer a relationship from names, timestamps, summaries, adjacent rows or resource text + +#### Scenario: Link identifiers are not generated by the frontend + +- **WHEN** the frontend needs a `correlation_id` or `integration_id` +- **THEN** it MUST use a value returned by an API +- **AND** it MUST NOT generate, concatenate or guess the identifier + +### Requirement: Actor, Resource, and Link Timelines + +The admin frontend SHALL provide actor-event, resource-event, request-link and correlation-link timelines through the documented endpoints. It SHALL support an explicitly pasted Access Log `request_id`, while all other navigation values SHALL come from returned stable references. + +#### Scenario: Inspect actor behavior + +- **WHEN** a user follows `actor_ref.kind/id` or opens an account by its stable ID +- **THEN** the frontend MUST call `/api/admin/audit/actors/{kind}/{id}/events` +- **AND** action and resource filters MUST use stable values returned by audit data + +#### Scenario: Select an exact resource result + +- **WHEN** a user searches with a supported `resource_type` and exact `keyword` and selects a result +- **THEN** the frontend MUST pass `items[].resource_type/resource_id` unchanged to the resource timeline +- **AND** it MUST identify `historical=true` as a historical-snapshot match + +#### Scenario: Query a request copied from Access Log + +- **WHEN** a platform user explicitly pastes a request ID from Access Log +- **THEN** the frontend MAY query `/api/admin/audit/requests/{request_id}/timeline` with that exact value +- **AND** it MUST NOT claim that the endpoint scans Access Log or archived object storage + +### Requirement: Retention, Pagination, and Time Semantics + +All audit list and timeline views SHALL use the documented pagination and `retention` contract. Time filters SHALL be RFC3339 timestamps with timezone information, and `created_to` SHALL be treated as an exclusive upper bound. + +#### Scenario: Respect the online retention window without a global prompt + +- **WHEN** a response contains `retention.online_from`, `archived_before` and `timezone` +- **THEN** the frontend MUST preserve the retention semantics in its data contract without displaying a global online-window banner +- **AND** it MUST NOT interpret an empty online result as proof that no historical event exists + +#### Scenario: Page through a timeline + +- **WHEN** the user changes page or page size +- **THEN** the frontend MUST use `page` and `page_size`, respect the maximum page size of 100, and preserve the active filters + +### Requirement: Finance Investigation Timeline + +The admin frontend SHALL query `GET /api/admin/audit/finance/timeline` with any documented stable finance condition, including shop, wallet, order, payment, refund, recharge, approval, third-party trade, actor or correlation identifiers. Monetary values SHALL remain integer fen in application data, and authority SHALL follow `amount_authority`. + +#### Scenario: Open finance history from a business record + +- **WHEN** a user opens finance history from an order, refund, recharge, wallet or shop with a stable backend ID +- **THEN** the frontend MUST send the matching documented query parameter +- **AND** it MUST allow the server to complete related facts instead of assembling a local timeline + +#### Scenario: Display an authoritative amount + +- **WHEN** a finance node returns an amount and `amount_authority.authoritative=true` +- **THEN** the frontend MUST treat the referenced table and field as authoritative +- **AND** it MUST only convert integer fen for presentation and MUST NOT reconcile conflicting facts locally + +### Requirement: Risk Investigation + +The admin frontend SHALL provide risk overview and event-detail views using `/api/admin/audit/risks/overview` and `/api/admin/audit/risks/events`. Filters SHALL use codes returned in overview collections, and the selected time range SHALL not exceed 31 days. + +#### Scenario: Drill down from a risk summary + +- **WHEN** a user selects a risk, result, action, source or signal represented by the overview +- **THEN** the frontend MUST use the returned stable code for the supported event-list filter +- **AND** backend `name` values MUST be used only for display + +#### Scenario: Select a range longer than 31 days + +- **WHEN** the user attempts to query more than 31 days +- **THEN** the frontend MUST prevent submission and explain the maximum range + +### Requirement: External Integration Investigation + +The admin frontend SHALL provide Integration overview, list and detail views through the documented endpoints. It SHALL preserve provider, direction, operation, raw result, derived result category, duration, state-change, trigger, resource, linkage, attempt, content-summary, fidelity and retention fields. + +#### Scenario: Apply overview filters to the list + +- **WHEN** a user selects an overview provider, direction or result +- **THEN** the frontend MUST map `providers[].code` to `provider`, `directions[].code` to `direction`, `results[].code` to `result`, and `results[].category` to `result_category` +- **AND** it MUST use `name` only as display text + +#### Scenario: Display Integration trend categories + +- **WHEN** the overview returns trend points +- **THEN** the frontend MUST preserve the five categories `processing`, `succeeded`, `indeterminate`, `failed` and `not_sent` +- **AND** it MUST use only the documented `hour` or `day` bucket + +#### Scenario: Inspect an Integration detail + +- **WHEN** a user opens an `integration_id` returned by a list, event reference or notification target +- **THEN** the frontend MUST query the detail with that value unchanged +- **AND** it MUST present the capability as read-only without recovery, modification, deletion or export actions + +### Requirement: Role-Specific Resource Activity + +The frontend SHALL use the agent and enterprise resource-activity endpoints according to the authenticated subject. Agent activity SHALL support `iot_card`, `device`, `asset_allocation_record`, `exchange_order`, `shop` and `enterprise`; enterprise activity SHALL support only authorized `iot_card` and `device` resources. + +#### Scenario: Open an agent resource activity + +- **WHEN** an agent opens activity for a supported resource +- **THEN** the frontend MUST use ICCID for a card, VirtualNo for a device, or the documented stable business number for another supported resource +- **AND** it MUST NOT send or infer the agent identity or shop scope as query data + +#### Scenario: Open enterprise asset activity + +- **WHEN** an enterprise user opens an authorized card or device activity +- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise endpoint +- **AND** it MUST render only the subject-safe projection without platform actor, risk, internal reason or before/after fields + +#### Scenario: A stable subject identifier is missing + +- **WHEN** a business response does not contain the required ICCID, VirtualNo or stable business number +- **THEN** the frontend MUST hide the activity entry +- **AND** it MUST NOT substitute a display name or internal identifier intended for another subject + +### Requirement: Business Audit Entries + +The frontend SHALL add role-appropriate audit entries to card, device, shop, enterprise, order, refund and wallet list/detail contexts without changing existing business API URLs. Platform entries SHALL use backend internal IDs; agent and enterprise entries SHALL use their documented stable business identifiers. + +#### Scenario: Open platform asset or business history + +- **WHEN** a platform user opens audit history from a card, device, shop, enterprise, order or refund record +- **THEN** the frontend MUST use the corresponding `resource_type` and backend `response.data.id` with the resource timeline + +#### Scenario: Open wallet or transaction history + +- **WHEN** a user opens audit history for a wallet, order or refund with a stable ID +- **THEN** the frontend MUST offer the applicable finance timeline query +- **AND** the resource timeline MAY also be offered only when a stable resource reference is available + +#### Scenario: Preserve existing operation logs + +- **WHEN** new audit entries are introduced on asset pages +- **THEN** existing asset operation-log features MUST remain available +- **AND** the UI MUST distinguish the existing operation logs from the new cross-system audit investigation + +### Requirement: Controlled Notification Integration Target + +The frontend SHALL resolve a notification through `GET /api/admin/notifications/{id}/target` before opening an Integration detail. It SHALL open the detail only when the target is available, has `target_type=integration_log`, and contains a non-empty `target_key`. + +#### Scenario: Open an Integration notification + +- **WHEN** target resolution returns `available=true`, `target_type=integration_log`, and a non-empty `target_key` +- **THEN** the frontend MUST pass `target_key` unchanged as the Integration `integration_id` +- **AND** it MUST open the controlled internal Integration detail route + +#### Scenario: Notification target cannot be used + +- **WHEN** the target is unavailable, has another type, lacks `target_key`, or maps to no registered route +- **THEN** the frontend MUST not navigate or execute an arbitrary URL +- **AND** it MUST preserve the notification content and show a safe unavailable-target message + +### Requirement: Audit Authorization and Read-Only Boundary + +The frontend SHALL enforce the final platform, agent and enterprise route and action permissions, while preserving backend authentication, authorization and data-isolation enforcement. All capabilities in this change SHALL remain read-only. + +#### Scenario: A subject lacks audit permission + +- **WHEN** the current subject lacks the configured permission for an audit page or business entry +- **THEN** the frontend MUST hide or disable that page or entry and MUST NOT call the endpoint as a fallback + +#### Scenario: An audit request is rejected + +- **WHEN** an endpoint returns `400`, `401`, `403` or `500` +- **THEN** the frontend MUST use shared validation, authentication, authorization and server-error handling +- **AND** it MUST preserve the current investigation state when retrying would be unsafe or misleading + +#### Scenario: A user inspects an audit record + +- **WHEN** any audit, risk, finance, Integration or subject-activity view is displayed +- **THEN** the UI MUST NOT provide mutation, recovery, deletion, risk-disposition, blocking or export actions diff --git a/openspec/changes/add-audit-chain-frontend-integration/tasks.md b/openspec/changes/add-audit-chain-frontend-integration/tasks.md new file mode 100644 index 0000000..854480b --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract and Shared Infrastructure + +- [x] 1.1 根据 `默认模块.openapi.json` 建立审计事件、调查引用、资源、保留窗口、链路节点、资金节点、风险和 Integration 的 TypeScript 类型。 +- [x] 1.2 新增平台审计、主体资源活动、资金、风险、Integration 和链路时间线 API 方法,保持文档参数名、枚举和 nullable 语义。 +- [x] 1.3 建立共享的分页、RFC3339 时间范围、保留窗口、枚举名称和 API 错误展示能力。 +- [x] 1.4 建立只读事件详情、资源摘要、调查引用和时间线节点组件,不提供写操作或导出能力。 + +## 2. Platform Audit Center + +- [x] 2.1 实现审计事件列表及文档定义的时间、动作、类别、操作者、来源、结果、风险、范围、资源和链路筛选。 +- [x] 2.2 实现事件详情,展示事件、操作者、资源快照、结果、风险和元数据,并按非空 `investigation_refs` 提供跳转。 +- [x] 2.3 实现操作者行为时间线,使用 `actor_ref.kind/id` 和稳定 code 筛选。 +- [x] 2.4 实现资源精确搜索及资源时间线,原样使用选中项的 `resource_type/resource_id` 并提示历史快照命中。 +- [x] 2.5 实现请求和业务关联时间线,支持从返回引用进入,以及明确粘贴 Access Log `request_id` 的查询入口。 + +## 3. Specialized Investigations + +- [x] 3.1 实现资金调查时间线和全部文档筛选条件,以分为数据单位并展示金额权威来源。 +- [x] 3.2 实现风险总览和风险事件明细,按总览返回的稳定 code 回填筛选,并限制最长 31 天时间范围。 +- [x] 3.3 实现 Integration 总览、列表和详情,覆盖 provider、direction、result、result_category、趋势、尝试、内容摘要、保真度和关联字段。 +- [x] 3.4 实现各调查视角之间基于 `investigation_refs`、`linkage` 和稳定资源引用的受控跳转。 + +## 4. Subject Activity and Business Entries + +- [x] 4.1 实现代理资源活动页面,支持卡、设备、资产分配记录、换货单、店铺和企业的稳定业务标识。 +- [x] 4.2 实现企业资源活动页面,仅支持当前授权卡和设备并只渲染主体安全投影。 +- [x] 4.3 在卡、设备、店铺、企业、订单、退款和钱包列表/详情增加角色匹配的「审计记录」入口。 +- [x] 4.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏入口,不使用页面文本或其他字段推断。 +- [x] 4.5 保留现有资产操作日志入口,并通过命名与说明区分旧操作日志和新审计调查能力。 + +## 5. Notification, Routes, and Permissions + +- [x] 5.1 增加审计中心、风险中心、Integration 和共享时间线的路由与菜单配置。 +- [x] 5.2 接入最终确认的平台、代理、企业菜单/按钮权限编码,隐藏越权页面和业务入口。 +- [x] 5.3 在通知目标导航中仅对可用 `integration_log` 目标开放 Integration 详情,并原样使用 `target_key`。 +- [x] 5.4 对未知、不可用或缺失目标保留安全提示,不执行任意 URL 或推测跳转。 + +## 6. Verification + +- [ ] 6.1 为 API 参数序列化、枚举、nullable 字段、分页和 RFC3339 时间范围增加单元测试。 +- [ ] 6.2 为稳定引用跳转、空引用隐藏、`fidelity=false`、通知 Integration 目标和禁止生成链路 ID 增加测试。 +- [ ] 6.3 验证平台、代理、企业角色可见性、服务端数据隔离、企业安全投影以及 `401/403/400/500` 处理。 +- [ ] 6.4 验证保留窗口、空数据、最长 31 天风险查询、资金金额单位和 Integration 五类趋势状态。 +- [ ] 6.5 完成 16 个接口的联调回归,并确认现有业务 URL、通知中心和旧资产操作日志未被破坏。 diff --git a/openspec/changes/align-audit-cross-view-navigation/design.md b/openspec/changes/align-audit-cross-view-navigation/design.md new file mode 100644 index 0000000..079e893 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/design.md @@ -0,0 +1,52 @@ +## Context + +审计调查横跨平台、代理和企业三种身份,以及事件、资源、请求、业务链路、资金和外部交互等多种事实视角。后端通过内部资源 ID、主体安全 identifier、`investigation_refs`、Integration `linkage` 和 `fidelity` 明确表达可导航关系;前端的职责是原样传递这些稳定引用,而不是补全关系。 + +现有实现已经形成 `AuditInvestigationDrawer`、`AuditResourceSearchDialog` 和全局控制器,但业务页面仍需逐一核对入口覆盖、角色映射和降级规则。本设计将导航判断集中到共享目标解析层,展示仍由只读组件负责。 + +## Goals / Non-Goals + +- Goals: 用一个类型安全的目标模型表达所有允许的审计调查入口。 +- Goals: 完成文档中资产、组织、账号、交易、资金和调查节点的导航矩阵。 +- Goals: 保证平台、代理和企业仅调用各自允许的接口,并隐藏缺少稳定参数的入口。 +- Goals: 使用弹窗/右侧抽屉承载续查,锁定底层滚动并支持分页与空状态。 +- Non-Goals: 不修改后端链路生成、数据库、留存或授权规则。 +- Non-Goals: 不提供恢复、重试、处置、封禁、修改、删除或导出。 +- Non-Goals: 不把旧 operation log 合并进新审计链路。 +- Non-Goals: 不从 Access Log、历史队列、自由文本或时间邻近关系自动发现链路。 + +## Decisions + +- Decision: 使用判别联合类型描述 `event/actor/resource/request/correlation/finance/integration/agent/enterprise` 调查目标;调用方只能提交该目标模型,不能提交任意 URL。 +- Decision: 使用全局调查宿主承载共享抽屉和精确资源弹窗。业务页面操作只负责构造目标,不各自维护弹窗、加载和滚动状态。 +- Decision: 独立的审计中心列表与详情可以保留路由;跨视角时间线和关联详情使用抽屉。移除旧时间线路由、别名和所有指向它的跳转。 +- Decision: 平台资源入口优先使用业务响应中的内部稳定 ID。只有调用上下文没有资源 ID、但明确持有 `iot_card/device/shop/order/refund` 的业务 Key 时,才进入精确资源搜索。 +- Decision: 精确资源搜索不展示 `resource_id` 或身份快照中的内部关联 ID。候选项只展示按资源类型白名单选择的业务字段;选中后在内部原样使用返回的 `resource_type/resource_id`。 +- Decision: 代理使用 ICCID、VirtualNo、分配单号、换货单号、店铺编号或企业编号;企业只使用当前授权卡的 ICCID 或设备 VirtualNo。主体身份和范围不作为前端参数。 +- Decision: `investigation_refs` 是调查节点续查的权威来源。字段缺失、目标与当前详情相同或 `fidelity=false` 时隐藏对应操作。 +- Decision: 请求和业务链路 ID 默认不展示原值;仅在明确的超级管理员开发调查上下文中允许复制真实 request ID,且不得由前端生成。 +- Decision: 资金目标只提交一个已知稳定条件,其他订单、支付、退款、充值和钱包关联由服务端解析;金额始终按分保存在应用数据中。 +- Decision: `401/403`、授权撤销或主体资源不存在时显示中性“活动不可用”,不回退平台接口、精确资源搜索或旧 operation log。 +- Decision: 所有抽屉使用 `append-to-body`、modal 和 scroll lock,关闭后销毁内容;分页切换保留目标和筛选,切换目标时清空旧数据。 + +## Risks / Trade-offs + +- 业务页面数量多,容易出现入口漏接或使用错误字段。通过集中目标解析函数和表驱动测试覆盖文档矩阵。 +- 全局抽屉减少重复代码,但必须防止上一目标的数据闪现。打开新目标前先清空状态,并以当前请求标识忽略过期响应。 +- 精确资源搜索结果来自当前表或历史快照;仅展示白名单字段会减少调试信息,但可以避免泄露内部 ID 和不稳定结构。 +- 本变更与 `add-audit-chain-frontend-integration` 修改同一组件目录。实施前必须以其最新状态为基线,避免覆盖已完成的 UI 调整。 + +## Migration Plan + +1. 固化共享调查目标类型、控制器和角色解析函数,并为现有入口提供兼容适配。 +2. 将仍指向旧时间线路由的入口切换为全局调查宿主,确认无引用后删除旧页面、路由和别名。 +3. 按文档矩阵依次接入资产组织页、账号交易页、资金页和调查节点续查。 +4. 收紧精确搜索触发条件、主体视角和空标识降级规则。 +5. 完成角色矩阵、参数原样传递、禁止推断、弹窗滚动和过期请求测试。 +6. 若出现回归,可按业务模块隐藏新入口;不恢复任意 URL 跳转或 ID 推断逻辑。 + +## Open Questions + +- 账号、代理充值、资产分配、换货、店铺资金概况等尚未接入页面的最终按钮权限是否沿用资源时间线/资金时间线权限,还是增加业务入口专用权限? +- 超级管理员是否需要一个显式的 Access Log request ID 粘贴入口;若不需要,前端应完全移除手工 request 查询。 +- `historical=true` 在产品要求隐藏“匹配来源”后,是否仍需用非字段式的警告图标提示资源可能已删除? diff --git a/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md b/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md new file mode 100644 index 0000000..c25d855 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md @@ -0,0 +1,41 @@ +## 实施差异核对 + +### 已接入矩阵 + +| 来源 | 平台视角 | 代理视角 | 企业视角 | 稳定字段 | +| --- | --- | --- | --- | --- | +| IoT 卡列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `iccid` | +| 设备列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `virtual_no` | +| 设备绑定卡 | 卡资源时间线、绑定资源时间线 | 卡主体活动 | 卡主体活动 | `card_id` / `iccid` | +| 资产分配列表/详情 | 分配记录、资产资源时间线 | 分配活动 | 不提供 | `id`、`asset_id` / `allocation_no` | +| 换货列表/详情 | 换货记录、旧/新资产时间线 | 换货活动 | 不提供 | `id`、资产 ID / `exchange_no` | +| 店铺列表 | 店铺资源、店铺资金链路 | 店铺活动 | 不提供 | `id` / `shop_no` | +| 企业列表 | 企业资源 | 企业活动 | 不提供 | `id` / `enterprise_no` | +| 账号列表 | 账号资源 | 不提供 | 不提供 | `id` | +| 订单列表/详情 | 订单资源、订单资金链路 | 不提供 | 不提供 | `id`、`order_id` | +| 退款列表/详情 | 退款资源、退款资金链路、审批资源 | 不提供 | 不提供 | `id`、`refund_id`、`approval_instance_id` | +| 代理充值列表/详情 | 充值资源、充值资金链路、审批资源 | 不提供 | 不提供 | `id`、`recharge_id`、`approval_instance_id` | +| 资产钱包 | 钱包资金链路、资产资源 | 不提供 | 不提供 | `wallet_id`、`asset_type + asset_id` | +| 店铺资金概况 | 店铺资金链路 | 不提供 | 不提供 | `shop_id` | + +所有入口先独立校验自身权限和完整参数;一个入口缺字段不会隐藏同一行的其他有效入口。平台资源只使用非零内部 ID,代理和企业活动只使用文档规定的业务标识。 + +### 共享调查与降级 + +- 事件、操作者、资源、请求、业务链路、资金、Integration、代理活动和企业活动均由全局调查宿主打开。 +- 精确注册资源只在调查引用缺少 `resource_id`、但包含受支持类型及业务 Key 时出现;业务列表已有内部 ID 时不再提供搜索按钮。 +- 精确搜索限制为 `iot_card/device/shop/order/refund`,候选项不显示 Registry ID 或快照内部关联 ID。 +- 目标切换会清空旧内容并忽略过期响应;关闭会销毁内容,抽屉和弹窗锁定底层滚动。 +- 缺字段、空白值、零值、不支持的主体资源类型和撤销授权均不推断或回退到其他视角。 + +### 受响应字段限制的入口 + +- 钱包流水响应只有 `asset_identifier`,没有完整的 `asset_type + asset_id`,因此不从流水行推断资产审计目标。 +- 当前路由中没有独立的账号详情和店铺详情页面,对应入口保留在列表。 +- 风险/链路/资金节点抽屉内部不增加继续调查按钮:产品要求移除弹窗内无意义按钮;事件详情仍使用权威 `investigation_refs` 提供跨视角入口。 + +### 路由核对 + +- 独立的审计事件和外部交互列表/详情路由保留。 +- 操作者、资源、请求、业务链路、资金及主体活动不再使用独立时间线路由或任意 URL 跳转。 +- 审计中心顶级菜单排列在财务管理之后。 diff --git a/openspec/changes/align-audit-cross-view-navigation/proposal.md b/openspec/changes/align-audit-cross-view-navigation/proposal.md new file mode 100644 index 0000000..d80646d --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/proposal.md @@ -0,0 +1,40 @@ +# Change: 对齐审计跨视角调查与前端导航契约 + +## Why + +现有审计中心已经接入事件、资源、链路、资金、风险和外部交互等只读查询,但业务页面入口、调查节点续查、平台/代理/企业视角以及缺失标识时的降级行为仍缺少统一约束。若各页面继续自行拼装目标参数,容易把内部 ID、业务 Key、主体安全标识或链路 ID 混用,并产生越权回退、错误串链和重复页面。 + +本变更以《跨视角调查与前端导航契约.md》为导航事实来源,在现有 `add-audit-chain-frontend-integration` 能力之上建立统一的跨视角导航规范和复用组件。 + +## What Changes + +- 建立统一的审计调查目标模型和导航控制器,覆盖事件详情、操作者行为、资源审计、请求链路、业务链路、资金链路、外部交互以及代理/企业资源活动。 +- 调查时间线、链路和外部交互续查统一使用可复用抽屉或弹窗,不再新增独立时间线跳转页面;同一目标不重复打开自身详情。 +- 按“内部稳定 ID → 主体安全业务标识 → 返回的调查引用 → 精确资源搜索 → 隐藏入口”的顺序解析入口,禁止从名称、摘要、时间、编号前缀或邻近记录推断关联。 +- 对齐资产、组织、账号、订单、退款、充值、钱包和资金页面的逐行入口矩阵,并按平台、代理、企业身份选择不同接口和参数。 +- 平台仅在缺少内部资源 ID、但持有允许搜索的 Registry Key 时调用精确资源搜索;零命中或多命中均保留用户选择,不自动猜测。 +- 所有弹窗和抽屉只显示业务可读字段,内部资源 ID、request/correlation 等技术标识仅作为查询参数使用,除明确的开发调查场景外不直接展示。 +- 统一处理空引用、`fidelity=false`、历史快照、授权撤销、归档窗口和接口拒绝,不回退旧日志、平台接口或其他主体接口。 +- 补充跨页面入口矩阵、参数映射、角色隔离和禁止推断的自动化测试。 + +## Impact + +- Affected specs: `audit-cross-view-navigation` +- Depends on: `add-audit-chain-frontend-integration` +- Affected code: + - `src/components/business/audit/` 调查抽屉、精确资源弹窗和全局导航控制器 + - `src/utils/business/auditNavigation.ts` 角色与目标解析 + - 审计事件、风险、外部交互页面的调查入口 + - 卡、设备、设备卡槽、统一资产、分配、换货、店铺、企业、账号、订单、退款、充值、钱包及资金列表/详情 + - 路由、权限和通知目标导航 +- API contracts: + - `GET /api/admin/audit/*` + - `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` + - 现有业务列表与详情接口返回的稳定字段 +- Source of truth: + - `docs/产品迭代8月份/跨视角调查与前端导航契约.md` + - `docs/产品迭代8月份/默认模块.openapi.json` +- Breaking changes: + - 移除旧的独立审计时间线路由及其导航方式,统一改为抽屉/弹窗调查。 + - 入口缺少契约要求的稳定标识时将被隐藏,不再容忍前端推断或降级调用其他视角接口。 diff --git a/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md b/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md new file mode 100644 index 0000000..bb5a452 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: Shared Cross-View Investigation Surface + +The frontend SHALL open actor, resource, request, correlation, finance, Integration, agent-activity and enterprise-activity investigations through reusable modal or drawer components. It SHALL NOT require a standalone timeline route for these cross-view investigations. + +#### Scenario: Open an investigation from a business row + +- **WHEN** an authorized user selects an audit or activity action from a supported business row +- **THEN** the frontend MUST construct a typed investigation target from the row's documented stable fields +- **AND** it MUST open the shared investigation surface without navigating to a standalone timeline page + +#### Scenario: Replace an open investigation target + +- **WHEN** the user opens a second investigation after another target was loaded +- **THEN** the shared surface MUST clear stale content before loading the new target +- **AND** a late response from the previous target MUST NOT replace the current result + +### Requirement: Deterministic Investigation Target Resolution + +The frontend SHALL resolve investigation targets in this order: a platform internal stable ID for a platform resource timeline, a documented subject-safe business identifier for agent or enterprise activity, an explicit API investigation reference, an exact Registry resource-key search when no internal resource ID exists, or no entry. It SHALL NOT infer a target from display text or proximity. + +#### Scenario: A platform resource ID is available + +- **WHEN** a platform business response contains the documented non-zero internal resource ID +- **THEN** the frontend MUST call the resource timeline with the mapped resource type and that ID +- **AND** it MUST NOT search by the display name or business Key to replace the available ID + +#### Scenario: Only a supported Registry Key is available + +- **WHEN** a platform investigation reference contains a supported resource type and business Key but no resource ID +- **THEN** the frontend MUST use the exact resource search endpoint +- **AND** zero or multiple matches MUST remain unresolved until the user makes an explicit choice + +#### Scenario: No stable target exists + +- **WHEN** the required internal ID, subject identifier or explicit investigation reference is missing +- **THEN** the frontend MUST hide or disable the entry +- **AND** it MUST NOT infer a relationship from a name, summary, timestamp, identifier prefix, adjacent record or current-account profile + +### Requirement: Exact Registered Resource Selection + +The frontend SHALL support exact Registry lookup only for `iot_card`, `device`, `shop`, `order` and `refund`. It SHALL pass the selected candidate's returned `resource_type/resource_id` unchanged to the resource timeline while keeping internal identifiers out of the presentation. + +#### Scenario: Present an exact resource candidate + +- **WHEN** exact lookup returns a candidate with `identity_snapshot` +- **THEN** the frontend MUST display only a resource-type-specific whitelist of business-readable fields +- **AND** it MUST NOT display `resource_id` or snapshot fields that are internal IDs or internal foreign keys + +#### Scenario: Select a historical candidate + +- **WHEN** a user selects a candidate with `historical=true` and a stable returned resource ID +- **THEN** the frontend MUST use the returned type and ID without modification +- **AND** it MUST NOT claim that the resource still exists in the current business table + +### Requirement: Role-Isolated Resource Activity Navigation + +The frontend SHALL choose platform, agent or enterprise resource investigation APIs solely from the authenticated subject and the documented source-page fields. Agent and enterprise navigation SHALL NOT fall back to platform audit or Registry search. + +#### Scenario: Open agent activity + +- **WHEN** an agent opens a supported card, device, allocation, exchange, shop or enterprise activity +- **THEN** the frontend MUST use the documented ICCID, VirtualNo or business number with the agent activity endpoint +- **AND** it MUST NOT send agent identity or shop scope as proof of authorization + +#### Scenario: Open enterprise activity + +- **WHEN** an enterprise user opens activity for a currently authorized card or device +- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise activity endpoint +- **AND** it MUST NOT use the enterprise route parameter or a platform internal ID as authorization evidence + +#### Scenario: Subject activity becomes unavailable + +- **WHEN** authorization is revoked, the resource is out of scope, or the subject endpoint rejects access +- **THEN** the frontend MUST present an activity-unavailable state +- **AND** it MUST NOT retry through a platform endpoint, Registry search or legacy operation log + +### Requirement: Business and Finance Entry Matrix + +The frontend SHALL implement the documented row-level navigation matrix for asset, organization, account, order, refund, recharge, wallet and fund-summary contexts. Each action SHALL be independently visible only when its complete stable parameter set and required permission are available. + +#### Scenario: Open resource and finance investigations independently + +- **WHEN** a business record contains both a resource ID and a stable finance condition +- **THEN** the frontend MUST offer the applicable resource audit and finance investigation actions independently +- **AND** failure or absence of one parameter set MUST NOT suppress the other valid action + +#### Scenario: Let the server resolve finance relationships + +- **WHEN** the frontend opens finance history from an order, refund, recharge, wallet or shop +- **THEN** it MUST submit only the stable condition explicitly returned by the source business API +- **AND** it MUST NOT derive or supplement payment, refund, wallet, approval or third-party identifiers from text or number prefixes + +### Requirement: Investigation Reference Continuation + +The frontend SHALL treat `investigation_refs` and documented Integration references as the authoritative sources for continuing from audit, risk, finance and link nodes. It SHALL expose only actions whose complete required reference is present and reliable. + +#### Scenario: Continue from a complete reference + +- **WHEN** a node contains a complete actor, resource, request, correlation or Integration reference +- **THEN** the frontend MUST pass its type and identifier unchanged to the matching shared investigation surface + +#### Scenario: Suppress unavailable or self-referential navigation + +- **WHEN** a reference is incomplete, its fidelity is false, or its event target is the detail already being displayed +- **THEN** the frontend MUST hide the corresponding action +- **AND** it MUST NOT create a substitute identifier or reopen the same detail + +### Requirement: Read-Only Presentation and Scroll Isolation + +All shared investigation surfaces SHALL remain read-only, hide non-business internal identifiers by default, and isolate modal scrolling from the underlying page. + +#### Scenario: Display an investigation surface + +- **WHEN** a modal or drawer is open +- **THEN** it MUST use a modal overlay, append to the document body, and lock underlying-page scrolling +- **AND** it MUST remain usable within its own scroll container at supported viewport sizes + +#### Scenario: Inspect a read-only result + +- **WHEN** audit, activity, finance, link or Integration data is displayed +- **THEN** the UI MUST NOT expose mutation, recovery, retry, deletion, disposition, blocking or export actions +- **AND** raw internal IDs MUST be retained only as navigation parameters unless an explicitly authorized developer-investigation workflow requires display diff --git a/openspec/changes/align-audit-cross-view-navigation/tasks.md b/openspec/changes/align-audit-cross-view-navigation/tasks.md new file mode 100644 index 0000000..45e60c4 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract Audit and Shared Navigation + +- [ ] 1.1 建立文档导航矩阵与当前页面入口的差异清单,覆盖资产组织、账号交易、资金和调查节点。 +- [ ] 1.2 完善共享调查目标判别联合类型,覆盖事件、操作者、资源、请求、业务链路、资金、Integration、代理和企业活动。 +- [ ] 1.3 将共享导航控制器改为唯一弹窗/抽屉打开入口,并处理目标切换、请求竞态、分页和关闭清理。 +- [ ] 1.4 全局搜索并移除旧时间线路由、路由别名、页面和任意 URL 式审计跳转。 + +## 2. Stable Reference and Search Rules + +- [ ] 2.1 实现平台内部 ID、主体业务 identifier、调查引用、资源 Key 搜索和隐藏入口的优先级解析。 +- [ ] 2.2 收紧精确资源搜索,仅允许 `iot_card/device/shop/order/refund` 和文档规定的精确业务 Key。 +- [ ] 2.3 精确资源结果只展示资源类型白名单业务字段,隐藏 `resource_id` 及快照内部关联 ID,并原样使用返回目标打开资源时间线。 +- [ ] 2.4 对零命中、多命中、历史快照和缺少稳定资源 ID 分别提供不推断的降级状态。 +- [ ] 2.5 对空引用、同目标引用和 `fidelity=false` 隐藏对应调查操作。 + +## 3. Asset and Organization Entry Matrix + +- [ ] 3.1 对齐卡、设备、设备卡槽和统一资产详情的平台/代理/企业入口与稳定字段映射。 +- [ ] 3.2 对齐资产分配、换货、店铺和企业列表/详情入口及角色范围。 +- [ ] 3.3 对齐企业卡和企业设备列表入口,确保企业只调用主体活动接口且不使用路由企业 ID 证明授权。 +- [ ] 3.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏对应入口,不回退其他视角。 + +## 4. Account, Transaction, and Finance Entry Matrix + +- [ ] 4.1 接入账号、店铺、企业、订单和退款的资源审计入口。 +- [ ] 4.2 接入订单、退款、代理充值、资产钱包、店铺资金概况和钱包流水的资金链路入口。 +- [ ] 4.3 对审批实例、钱包资源和流水资产引用实行独立字段完整性判断,缺少一组字段不影响其他有效入口。 +- [ ] 4.4 资金查询仅传入当前业务返回的稳定条件,不由前端补齐 Payment、Refund、钱包或第三方流水关联。 + +## 5. Investigation Node Continuation + +- [ ] 5.1 从风险、事件、资源、链路、资金和 Integration 节点按 `investigation_refs` 打开共享调查抽屉。 +- [ ] 5.2 支持事件、操作者、资源、请求、业务链路和外部交互的非空引用,并抑制跳回当前详情的无意义入口。 +- [ ] 5.3 请求/业务链路节点保留 `record_source` 和 `reference_only` 事实边界,不把投递成功展示为业务成功。 +- [ ] 5.4 通知 Integration 目标继续使用受控 target 解析,不接受任意 URL 或数据库主键替代稳定 Integration ID。 + +## 6. Authorization, Presentation, and Verification + +- [ ] 6.1 按平台、代理、企业及按钮权限验证所有入口可见性和接口选择。 +- [ ] 6.2 验证抽屉 append-to-body、遮罩、底层滚动锁定、关闭销毁、分页和移动端布局。 +- [ ] 6.3 为目标解析、参数原样传递、空值隐藏、同目标抑制、`fidelity=false` 和禁止推断增加单元测试。 +- [ ] 6.4 为文档中资产组织和账号资金导航矩阵增加表驱动测试。 +- [ ] 6.5 验证 `400/401/403/404/500`、授权撤销、历史快照、归档窗口和过期异步响应处理。 +- [ ] 6.6 回归现有审计中心、风险中心、外部交互、通知导航和旧 operation log,确认只读边界未被破坏。 diff --git a/scripts/tests/auditNavigation.test.mjs b/scripts/tests/auditNavigation.test.mjs new file mode 100644 index 0000000..e5465f7 --- /dev/null +++ b/scripts/tests/auditNavigation.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import ts from 'typescript' + +async function importAuditNavigation() { + const tempDir = await mkdtemp(join(tmpdir(), 'audit-navigation-')) + const bundlePath = join(tempDir, 'auditNavigation.mjs') + + const source = await readFile('src/utils/business/auditNavigation.ts', 'utf8') + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022 + } + }) + await writeFile(bundlePath, transpiled.outputText, 'utf8') + + return { + module: await import(pathToFileURL(bundlePath).href), + cleanup: () => rm(tempDir, { recursive: true, force: true }) + } +} + +test('resource targets follow the platform, agent and enterprise stable-reference matrix', async () => { + const { module, cleanup } = await importAuditNavigation() + const { resolveAuditResourceTarget } = module + const cases = [ + { + name: 'platform uses the internal resource id', + input: { + userType: 2, + resourceType: 'iot_card', + internalId: 8020, + businessIdentifier: 'iccid' + }, + expected: { mode: 'resource', resourceType: 'iot_card', id: '8020' } + }, + { + name: 'agent uses the trimmed subject identifier', + input: { + userType: 3, + resourceType: 'iot_card', + internalId: 8020, + businessIdentifier: ' 8986112520903885262 ' + }, + expected: { mode: 'agent', resourceType: 'iot_card', id: '8986112520903885262' } + }, + { + name: 'enterprise uses a device identifier', + input: { + userType: 4, + resourceType: 'device', + internalId: 9, + businessIdentifier: ' SN-001 ' + }, + expected: { mode: 'enterprise', resourceType: 'device', id: 'SN-001' } + }, + { + name: 'enterprise cannot use organization resources', + input: { userType: 4, resourceType: 'shop', businessIdentifier: 'SHOP-1' }, + expected: null + }, + { + name: 'agent cannot infer an unsupported business resource', + input: { userType: 3, resourceType: 'refund', businessIdentifier: 'RF-1' }, + expected: null + }, + { + name: 'platform hides a missing internal id', + input: { userType: 2, resourceType: 'order', businessIdentifier: 'ORD-1' }, + expected: null + }, + { + name: 'platform hides zero ids', + input: { userType: 1, resourceType: 'device', internalId: 0 }, + expected: null + }, + { + name: 'platform hides whitespace ids', + input: { userType: 1, resourceType: 'device', internalId: ' ' }, + expected: null + } + ] + + try { + for (const scenario of cases) { + assert.deepEqual(resolveAuditResourceTarget(scenario.input), scenario.expected, scenario.name) + } + } finally { + await cleanup() + } +}) + +test('finance targets preserve only complete backend-provided conditions', async () => { + const { module, cleanup } = await importAuditNavigation() + const { resolveFinanceAuditTarget } = module + const cases = [ + ['numeric business id', 'order_id', 72, { mode: 'finance', field: 'order_id', value: 72 }], + [ + 'trimmed business number', + 'refund_no', + ' RF20260807171319801844 ', + { mode: 'finance', field: 'refund_no', value: 'RF20260807171319801844' } + ], + ['missing value', 'wallet_id', undefined, null], + ['zero value', 'payment_id', 0, null], + ['whitespace value', 'correlation_id', ' ', null] + ] + + try { + for (const [name, field, value, expected] of cases) { + assert.deepEqual(resolveFinanceAuditTarget(field, value), expected, name) + } + } finally { + await cleanup() + } +}) diff --git a/src/App.vue b/src/App.vue index 313a2ea..d9275f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ + @@ -17,6 +18,7 @@ import { checkStorageCompatibility } from '@/utils' import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue' import { ElMessageBox } from 'element-plus' + import AuditInvestigationHost from '@/components/business/audit/AuditInvestigationHost.vue' const userStore = useUserStore() const { language } = storeToRefs(userStore) diff --git a/src/api/modules/audit.ts b/src/api/modules/audit.ts new file mode 100644 index 0000000..f2db60a --- /dev/null +++ b/src/api/modules/audit.ts @@ -0,0 +1,124 @@ +import { BaseService } from '../BaseService' +import type { + AuditActorEventQuery, + AuditActorKind, + AuditEventDetail, + AuditEventPage, + AuditEventQuery, + AuditFinanceQuery, + AuditFinanceTimelinePage, + AuditLinkTimeline, + AuditResourceSearchPage, + AuditResourceSearchQuery, + AuditResourceTimelineQuery, + AuditRiskEventPage, + AuditRiskEventQuery, + AuditRiskOverview, + AuditRiskQuery, + AuditSubjectActivityPage, + AuditSubjectActivityQuery, + AuditSubjectResourceType, + BaseResponse, + IntegrationDetailResponse, + IntegrationListPage, + IntegrationOverview, + IntegrationQuery +} from '@/types/api' + +export class AuditService extends BaseService { + static getEvents(params?: AuditEventQuery): Promise> { + return this.get('/api/admin/audit/events', params) + } + + static getEventDetail(eventId: string): Promise> { + return this.get(`/api/admin/audit/events/${encodeURIComponent(eventId)}`) + } + + static getActorEvents( + kind: AuditActorKind, + id: string, + params?: AuditActorEventQuery + ): Promise> { + return this.get( + `/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`, + params + ) + } + + static searchResources( + params: AuditResourceSearchQuery + ): Promise> { + return this.get('/api/admin/audit/resources/search', params) + } + + static getResourceTimeline( + resourceType: string, + resourceId: string, + params?: AuditResourceTimelineQuery + ): Promise> { + return this.get( + `/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`, + params + ) + } + + static getRequestTimeline(requestId: string): Promise> { + return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`) + } + + static getCorrelationTimeline(correlationId: string): Promise> { + return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`) + } + + static getFinanceTimeline( + params: AuditFinanceQuery + ): Promise> { + return this.get('/api/admin/audit/finance/timeline', params) + } + + static getRiskOverview(params?: AuditRiskQuery): Promise> { + return this.get('/api/admin/audit/risks/overview', params) + } + + static getRiskEvents(params?: AuditRiskEventQuery): Promise> { + return this.get('/api/admin/audit/risks/events', params) + } + + static getIntegrationOverview( + params?: IntegrationQuery & { bucket?: 'hour' | 'day' } + ): Promise> { + return this.get('/api/admin/audit/integrations/overview', params) + } + + static getIntegrations(params?: IntegrationQuery): Promise> { + return this.get('/api/admin/audit/integrations', params) + } + + static getIntegrationDetail( + integrationId: string + ): Promise> { + return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`) + } + + static getAgentResourceActivities( + resourceType: AuditSubjectResourceType, + identifier: string, + params?: AuditSubjectActivityQuery + ): Promise> { + return this.get( + `/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`, + params + ) + } + + static getEnterpriseResourceActivities( + resourceType: Extract, + identifier: string, + params?: AuditSubjectActivityQuery + ): Promise> { + return this.get( + `/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`, + params + ) + } +} diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index b5e6ccc..90b4cb8 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -37,6 +37,7 @@ export { ExportTaskService } from './exportTask' export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask' export { BulkPurchaseService } from './bulkPurchase' export { NotificationService } from './notification' +export { AuditService } from './audit' export { WecomService } from './wecom' // TODO: 按需添加其他业务模块 diff --git a/src/components/business/audit/AuditDistributionChart.vue b/src/components/business/audit/AuditDistributionChart.vue new file mode 100644 index 0000000..f609c76 --- /dev/null +++ b/src/components/business/audit/AuditDistributionChart.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/components/business/audit/AuditEventTable.vue b/src/components/business/audit/AuditEventTable.vue new file mode 100644 index 0000000..0c24e46 --- /dev/null +++ b/src/components/business/audit/AuditEventTable.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/business/audit/AuditInvestigationDrawer.vue b/src/components/business/audit/AuditInvestigationDrawer.vue new file mode 100644 index 0000000..d6114c7 --- /dev/null +++ b/src/components/business/audit/AuditInvestigationDrawer.vue @@ -0,0 +1,462 @@ + + + + + + + diff --git a/src/components/business/audit/AuditInvestigationHost.vue b/src/components/business/audit/AuditInvestigationHost.vue new file mode 100644 index 0000000..4840a7b --- /dev/null +++ b/src/components/business/audit/AuditInvestigationHost.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/business/audit/AuditInvestigationLinks.vue b/src/components/business/audit/AuditInvestigationLinks.vue new file mode 100644 index 0000000..c1ee591 --- /dev/null +++ b/src/components/business/audit/AuditInvestigationLinks.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/components/business/audit/AuditResourceSearchDialog.vue b/src/components/business/audit/AuditResourceSearchDialog.vue new file mode 100644 index 0000000..7c2d8dc --- /dev/null +++ b/src/components/business/audit/AuditResourceSearchDialog.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/src/components/business/audit/investigationController.ts b/src/components/business/audit/investigationController.ts new file mode 100644 index 0000000..d00ffd7 --- /dev/null +++ b/src/components/business/audit/investigationController.ts @@ -0,0 +1,25 @@ +import { ref } from 'vue' +import type { AuditInvestigationTarget } from './types' +import type { AuditSearchResourceType } from '@/types/api' + +export const auditInvestigationVisible = ref(false) +export const auditInvestigationTarget = ref() +export const auditResourceSearchVisible = ref(false) +export const auditResourceSearchType = ref('iot_card') +export const auditResourceSearchKeyword = ref('') + +export const openAuditInvestigation = (target: AuditInvestigationTarget) => { + auditResourceSearchVisible.value = false + auditInvestigationTarget.value = target + auditInvestigationVisible.value = true +} + +export const openAuditResourceSearch = ( + resourceType: AuditSearchResourceType, + keyword?: string | null +) => { + auditInvestigationVisible.value = false + auditResourceSearchType.value = resourceType + auditResourceSearchKeyword.value = keyword?.trim() || '' + auditResourceSearchVisible.value = true +} diff --git a/src/components/business/audit/types.ts b/src/components/business/audit/types.ts new file mode 100644 index 0000000..bdc680e --- /dev/null +++ b/src/components/business/audit/types.ts @@ -0,0 +1,24 @@ +import type { AuditActorKind, AuditSubjectResourceType } from '@/types/api' + +export type AuditFinanceField = + | 'shop_id' + | 'wallet_id' + | 'order_id' + | 'order_no' + | 'payment_id' + | 'payment_no' + | 'refund_id' + | 'refund_no' + | 'recharge_id' + | 'recharge_no' + | 'approval_instance_id' + | 'third_party_trade_no' + | 'correlation_id' + +export type AuditInvestigationTarget = + | { mode: 'event'; id: string } + | { mode: 'actor'; id: string; actorKind: AuditActorKind } + | { mode: 'resource'; id: string; resourceType: string } + | { mode: 'agent' | 'enterprise'; id: string; resourceType: AuditSubjectResourceType } + | { mode: 'finance'; field: AuditFinanceField; value: string | number } + | { mode: 'request' | 'correlation' | 'integration'; id: string } diff --git a/src/config/constants/audit.ts b/src/config/constants/audit.ts new file mode 100644 index 0000000..d0e8426 --- /dev/null +++ b/src/config/constants/audit.ts @@ -0,0 +1,27 @@ +/** 八月审计链路页面与按钮权限。 */ +export const AUDIT_PERMISSIONS = { + centerPage: 'audit:center_view', + eventList: 'audit:event_list', + eventDetail: 'audit:event_detail', + actorTimeline: 'audit:actor_timeline', + resourceSearch: 'audit:resource_search', + resourceTimeline: 'audit:resource_timeline', + requestTimeline: 'audit:request_timeline', + correlationTimeline: 'audit:correlation_timeline', + financeTimeline: 'audit:finance_timeline', + riskPage: 'audit:risk_view', + riskEvents: 'audit:risk_events', + integrationPage: 'audit:integration_view', + integrationDetail: 'audit:integration_detail', + agentActivity: 'audit:agent_resource_activity', + enterpriseActivity: 'audit:enterprise_resource_activity', + cardEntry: 'audit:card_entry', + deviceEntry: 'audit:device_entry', + shopEntry: 'audit:shop_entry', + enterpriseEntry: 'audit:enterprise_entry', + orderEntry: 'audit:order_entry', + refundEntry: 'audit:refund_entry', + walletEntry: 'audit:wallet_entry' +} as const + +export type AuditPermission = (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS] diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index 3094cf0..cf8ff3b 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -2,6 +2,7 @@ import { RoutesAlias } from '../routesAlias' import { AppRouteRecord } from '@/types/router' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' +import { AUDIT_PERMISSIONS } from '@/config/constants/audit' /** * 菜单列表、异步路由 @@ -692,6 +693,72 @@ export const asyncRoutes: AppRouteRecord[] = [ ] }, + // 审计中心 + { + path: '/audit', + name: 'AuditCenter', + component: RoutesAlias.Home, + meta: { + title: '审计中心', + icon: '', + permissions: [AUDIT_PERMISSIONS.centerPage] + }, + children: [ + { + path: 'events', + name: 'AuditEvents', + component: RoutesAlias.AuditEvents, + meta: { + title: '审计事件', + permissions: [AUDIT_PERMISSIONS.eventList], + keepAlive: true + } + }, + { + path: 'events/:eventId', + name: 'AuditEventDetail', + component: RoutesAlias.AuditEventDetail, + meta: { + title: '审计事件详情', + permissions: [AUDIT_PERMISSIONS.eventDetail], + isHide: true, + keepAlive: false + } + }, + { + path: 'risks', + name: 'AuditRisks', + component: RoutesAlias.AuditRisks, + meta: { + title: '风险中心', + permissions: [AUDIT_PERMISSIONS.riskPage], + keepAlive: true + } + }, + { + path: 'integrations', + name: 'AuditIntegrations', + component: RoutesAlias.AuditIntegrations, + meta: { + title: '外部交互', + permissions: [AUDIT_PERMISSIONS.integrationPage], + keepAlive: true + } + }, + { + path: 'integrations/:integrationId', + name: 'AuditIntegrationDetail', + component: RoutesAlias.AuditIntegrationDetail, + meta: { + title: '外部交互详情', + permissions: [AUDIT_PERMISSIONS.integrationDetail], + isHide: true, + keepAlive: false + } + } + ] + }, + // 佣金管理 { path: '/commission', diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index fb73378..22ade5f 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -23,6 +23,13 @@ export enum RoutesAlias { CarrierManagement = '/system/carrier-management', // 运营商管理 UserCenter = '/system/user-center', // 用户中心 + // 审计中心 + AuditEvents = '/audit/events', + AuditEventDetail = '/audit/events/detail', + AuditRisks = '/audit/risks', + AuditIntegrations = '/audit/integrations', + AuditIntegrationDetail = '/audit/integrations/detail', + // 套餐管理 PackageList = '/package-management/package-list', // 套餐列表 PackageDetail = '/package-management/package-list/detail', // 套餐列表详情 diff --git a/src/types/api/audit.ts b/src/types/api/audit.ts new file mode 100644 index 0000000..d90c9b1 --- /dev/null +++ b/src/types/api/audit.ts @@ -0,0 +1,449 @@ +import type { PaginationParams } from './common' + +export type AuditActorKind = + | 'account' + | 'personal_customer' + | 'openapi' + | 'system_task' + | 'scheduled_job' + | 'external_system' +export type AuditResult = 'success' | 'failed' | 'denied' | 'partial' | 'unknown' +export type AuditRiskLevel = 'low' | 'normal' | 'high' | 'critical' +export type AuditCategory = + | 'configuration' + | 'reliability' + | 'asset' + | 'security' + | 'identity' + | 'business' +export type AuditSource = + | 'admin_api' + | 'personal_api' + | 'openapi' + | 'worker' + | 'scheduler' + | 'callback' +export type AuditScopeType = 'platform' | 'shop' | 'personal_customer' +export type IntegrationProvider = + | 'ctcc' + | 'cmcc' + | 'cucc' + | 'wechat_pay' + | 'alipay' + | 'fuiou' + | 'wecom' + | 'gateway' +export type IntegrationDirection = 'inbound' | 'outbound' +export type IntegrationResult = + | 'pending' + | 'success' + | 'failed' + | 'unknown' + | 'not_found' + | 'invalid_payload' + | 'conflict' + | 'ignored' + | 'merged' + | 'rate_limited' + | 'completed' + | 'cancelled' +export type IntegrationResultCategory = + | 'processing' + | 'succeeded' + | 'indeterminate' + | 'failed' + | 'not_sent' +export type AuditSubjectResourceType = + | 'iot_card' + | 'device' + | 'asset_allocation_record' + | 'exchange_order' + | 'shop' + | 'enterprise' + +export interface AuditRetentionInfo { + online_from: string + archived_before: string + timezone: string +} +export interface AuditActorRef { + kind: AuditActorKind + id: string +} +export interface AuditInvestigationResourceRef { + resource_type: string + resource_id?: string | null + resource_key?: string | null + display_name?: string | null +} +export interface AuditInvestigationRefs { + event_id?: string | null + actor_ref?: AuditActorRef | null + resource_refs?: AuditInvestigationResourceRef[] + request_id?: string | null + correlation_id?: string | null + integration_refs?: Array<{ integration_id: string }> +} +export interface AuditResourceView extends AuditInvestigationResourceRef { + relation: 'primary' | 'affected' | 'reference' + role: string + display_name: string + subject_summary?: string | null + subject_visibility?: 'internal_only' | 'subject_result' | 'subject_detail' + identity_snapshot?: Record | null + before_data?: Record | null + after_data?: Record | null + subject_data?: Record | null + sort_order?: number | null + created_at?: string | null +} +export interface AuditEventView { + event_id: string + parent_event_id?: string | null + action_code: string + action_name: string + category: AuditCategory + summary: string + result: AuditResult + risk_level: AuditRiskLevel + source: AuditSource + actor_kind: AuditActorKind + actor_id: string + actor_name: string + actor_shop_id?: number | null + actor_shop_name?: string | null + actor_enterprise_id?: number | null + actor_enterprise_name?: string | null + scope_type: AuditScopeType + scope_id?: string | null + scope_name?: string | null + request_id?: string | null + correlation_id?: string | null + request_method?: string | null + request_path?: string | null + ip_address?: string | null + user_agent?: string | null + success_count?: number | null + fail_count?: number | null + batch_total?: number | null + error_code?: string | null + error_summary?: string | null + content_hash?: string | null + occurred_at: string + created_at: string + resources: AuditResourceView[] + investigation_refs?: AuditInvestigationRefs | null + metadata?: Record | null +} +export interface AuditEventDetail extends AuditEventView { + retention: AuditRetentionInfo +} +export interface AuditPage { + items: T[] + page: number + page_size: number + total: number + retention: AuditRetentionInfo +} +export type AuditEventPage = AuditPage +export interface AuditEventQuery extends PaginationParams { + created_from?: string + created_to?: string + action?: string + category?: AuditCategory + actor_kind?: AuditActorKind + actor_id?: string + source?: AuditSource + result?: AuditResult + risk?: AuditRiskLevel + scope_type?: AuditScopeType + scope_id?: string + resource_type?: string + resource_id?: string + resource_key?: string + request_id?: string + correlation_id?: string +} +export interface AuditActorEventQuery extends PaginationParams { + action?: string + result?: AuditResult + risk?: AuditRiskLevel + resource_type?: string + resource_id?: string + created_from?: string + created_to?: string +} +export interface AuditResourceTimelineQuery extends PaginationParams { + created_from?: string + created_to?: string + action?: string + result?: AuditResult +} +export type AuditSearchResourceType = 'iot_card' | 'device' | 'shop' | 'order' | 'refund' +export interface AuditResourceSearchQuery extends PaginationParams { + resource_type: AuditSearchResourceType + keyword: string +} +export interface AuditResourceCandidate extends AuditInvestigationResourceRef { + resource_type: AuditSearchResourceType + display_name: string + historical: boolean + identity_snapshot?: Record | null +} +export type AuditResourceSearchPage = AuditPage + +export interface AuditLinkTimelineNode { + node_id: string + record_source: 'audit_event' | 'integration_log' | 'outbox_event' + code: string + title: string + summary: string + result: string + result_name: string + occurred_at: string + request_id?: string | null + correlation_id?: string | null + parent_event_id?: string | null + reference_only?: boolean + resources?: AuditInvestigationResourceRef[] + investigation_refs?: AuditInvestigationRefs | null + fidelity?: Record | null +} +export interface AuditLinkTimeline { + request_id?: string | null + correlation_id?: string | null + access_log_lookup_request_id?: string | null + nodes: AuditLinkTimelineNode[] + retention: AuditRetentionInfo +} + +export interface AuditFinanceQuery extends PaginationParams { + shop_id?: number + wallet_id?: number + order_id?: number + order_no?: string + payment_id?: number + payment_no?: string + refund_id?: number + refund_no?: string + recharge_id?: number + recharge_no?: string + approval_instance_id?: number + third_party_trade_no?: string + actor_kind?: AuditActorKind + actor_id?: string + correlation_id?: string + created_from?: string + created_to?: string +} +export interface AuditFinanceTimelineNode { + node_id: string + record_source: string + code: string + title: string + result: string + result_name: string + occurred_at: string + shop_id?: number | null + amount?: number | null + balance_before?: number | null + balance_after?: number | null + currency?: string | null + wallet?: { resource_type: 'agent_wallet' | 'asset_wallet'; wallet_id: number } | null + amount_authority?: { + authoritative: boolean + table?: string + field?: string + conflict_rule?: string + } | null + facts?: Record | null + investigation_refs?: AuditInvestigationRefs | null +} +export type AuditFinanceTimelinePage = AuditPage + +export interface AuditRiskQuery { + created_from?: string + created_to?: string + risk?: AuditRiskLevel + result?: AuditResult + action?: string + source?: AuditSource +} +export interface AuditRiskEventQuery extends AuditRiskQuery, PaginationParams {} +export interface AuditNamedCount { + code: string + name: string + count: number +} +export interface AuditRiskOverview { + total: number + bucket: 'hour' | 'day' + risks: AuditNamedCount[] + results: AuditNamedCount[] + actions: AuditNamedCount[] + sources: AuditNamedCount[] + signals: AuditNamedCount[] + trend: Array> + retention: AuditRetentionInfo +} +export type AuditRiskEventPage = AuditPage + +export interface IntegrationQuery extends PaginationParams { + created_from?: string + created_to?: string + integration_id?: string + provider?: IntegrationProvider + direction?: IntegrationDirection + operation?: string + result?: IntegrationResult + result_category?: IntegrationResultCategory + external_id?: string + resource_type?: string + resource_id?: string + resource_key?: string + trigger_source?: string + trigger_scene?: string + trigger_series?: string + state_changed?: boolean + http_status?: number + provider_code?: string + request_id?: string + correlation_id?: string +} +export interface IntegrationResourceView { + type?: string | null + id?: string | null + key?: string | null +} +export interface IntegrationIdentityView { + integration_id: string + provider: IntegrationProvider + provider_name: string + direction: IntegrationDirection + direction_name: string + operation: string + operation_name: string + external_id?: string | null +} +export interface IntegrationResultView { + category: IntegrationResultCategory + code: IntegrationResult + name: string + duration_ms: number + http_status?: number | null + provider_code?: string | null + provider_message?: string | null + recovery_strategy?: string | null + state_changed: boolean +} +export interface IntegrationLinkageView { + audit_event_id?: number | null + request_id?: string | null + correlation_id?: string | null +} +export interface IntegrationTriggerView { + attempt: number + scene?: string | null + series?: string | null + source?: string | null +} +export interface IntegrationTimestampView { + created_at: string + scheduled_at?: string | null + started_at?: string | null + updated_at: string +} +export interface IntegrationContentView { + content_hash: string + metadata?: Record | null + request_summary?: Record | null + response_summary?: Record | null +} +export interface IntegrationFidelityView { + attempt_sequence_reliable: boolean + correlation_available: boolean + provider_message_fidelity: string + resource_id_available: boolean + trigger_series_available: boolean +} +export interface IntegrationAttemptView { + attempt: number + created_at: string + duration_ms: number + integration_id: string + operation: string + operation_name: string + result: IntegrationResult + result_category: IntegrationResultCategory + result_name: string + sent: boolean + state_changed: boolean +} +export interface IntegrationListItem { + integration_id: string + provider: IntegrationProvider + provider_name: string + direction: IntegrationDirection + direction_name: string + operation: string + operation_name: string + result: IntegrationResult + result_name: string + result_category: IntegrationResultCategory + state_changed: boolean + duration_ms?: number | null + request_id?: string | null + correlation_id?: string | null + created_at: string + resource?: IntegrationResourceView | null +} +export type IntegrationListPage = AuditPage +export interface IntegrationOverview { + total: number + anomaly_count: number + stale_pending_count: number + state_changed_count: number + unknown_count: number + average_duration_ms: number + p95_duration_ms: number + providers: AuditNamedCount[] + directions: AuditNamedCount[] + results: Array + trend: Array> + retention: AuditRetentionInfo +} +export interface IntegrationDetailResponse { + identity: IntegrationIdentityView + result: IntegrationResultView + resource?: IntegrationResourceView | null + linkage: IntegrationLinkageView + trigger: IntegrationTriggerView + timestamps: IntegrationTimestampView + content: IntegrationContentView + fidelity: IntegrationFidelityView + attempts: IntegrationAttemptView[] + retention: AuditRetentionInfo +} + +export interface AuditSubjectActivityQuery extends PaginationParams { + created_from?: string + created_to?: string +} +export interface AuditSubjectResourceSummary { + resource_type?: string + identifier?: string + display_name?: string + [key: string]: unknown +} +export interface AuditSubjectActivity { + action_code: string + action_name: string + occurred_at: string + result: AuditResult + subject_summary: string + subject_data?: Record | null + related_resources?: AuditSubjectResourceSummary[] +} +export interface AuditSubjectActivityPage extends AuditPage { + resource: AuditSubjectResourceSummary +} diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 7455c26..fee2ff9 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -126,5 +126,8 @@ export * from './bulkPurchase' // 站内通知相关 export * from './notification' +// 审计链路与调查中心 +export * from './audit' + // 企业微信审批配置相关 export * from './wecom' diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 4e17e70..439fe0a 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -68,6 +68,12 @@ declare module 'vue' { ArtWangEditor: typeof import('./../components/core/forms/ArtWangEditor.vue')['default'] ArtWatermark: typeof import('./../components/core/others/ArtWatermark.vue')['default'] ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default'] + AuditDistributionChart: typeof import('./../components/business/audit/AuditDistributionChart.vue')['default'] + AuditEventTable: typeof import('./../components/business/audit/AuditEventTable.vue')['default'] + AuditInvestigationDrawer: typeof import('./../components/business/audit/AuditInvestigationDrawer.vue')['default'] + AuditInvestigationHost: typeof import('./../components/business/audit/AuditInvestigationHost.vue')['default'] + AuditInvestigationLinks: typeof import('./../components/business/audit/AuditInvestigationLinks.vue')['default'] + AuditResourceSearchDialog: typeof import('./../components/business/audit/AuditResourceSearchDialog.vue')['default'] BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default'] BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default'] BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default'] diff --git a/src/types/router/index.ts b/src/types/router/index.ts index a76b77f..28d4c0d 100644 --- a/src/types/router/index.ts +++ b/src/types/router/index.ts @@ -34,6 +34,8 @@ export interface RouteMeta extends Record { isFirstLevel?: boolean /** 角色权限 */ roles?: string[] + /** 页面访问权限,满足任意一个即可 */ + permissions?: string[] /** 导出任务固定场景 */ exportTaskScene?: ExportTaskScene /** 是否固定标签页 */ diff --git a/src/utils/business/audit.ts b/src/utils/business/audit.ts new file mode 100644 index 0000000..d386d01 --- /dev/null +++ b/src/utils/business/audit.ts @@ -0,0 +1,137 @@ +import type { + AuditActorKind, + AuditCategory, + AuditResult, + AuditRiskLevel, + AuditScopeType, + AuditSource, + IntegrationResultCategory +} from '@/types/api' + +export const auditCategoryLabels: Record = { + configuration: '配置', + reliability: '可靠性', + asset: '资产', + security: '安全', + identity: '身份', + business: '业务' +} + +export const auditActorKindLabels: Record = { + account: '人工账号', + personal_customer: '个人客户', + openapi: '开放接口账号', + system_task: '系统任务', + scheduled_job: '计划任务', + external_system: '外部系统' +} + +export const auditScopeTypeLabels: Record = { + platform: '平台', + shop: '店铺', + personal_customer: '个人客户' +} + +export const auditSourceLabels: Record = { + admin_api: '后台管理 API', + personal_api: '个人客户 API', + openapi: '代理 OpenAPI', + worker: '异步 Worker', + scheduler: '计划任务', + callback: '外部系统回调' +} + +export const auditResourceRelationLabels: Record = { + primary: '主要资源', + affected: '受影响资源', + reference: '引用资源' +} + +export const auditSubjectVisibilityLabels: Record = { + internal_only: '仅平台可见', + subject_result: '主体可见结论', + subject_detail: '主体可见安全详情' +} + +export const auditResourceTypeLabels: Record = { + iot_card: 'IoT 卡', + device: '设备', + asset_allocation_record: '资产分配记录', + exchange_order: '换卡订单', + shop: '店铺', + enterprise: '企业', + order: '订单', + refund: '退款', + integration_log: '外部集成记录' +} + +export const auditResultMeta: Record< + AuditResult, + { label: string; type: 'success' | 'danger' | 'warning' | 'info' } +> = { + success: { label: '成功', type: 'success' }, + failed: { label: '失败', type: 'danger' }, + denied: { label: '拒绝', type: 'danger' }, + partial: { label: '部分成功', type: 'warning' }, + unknown: { label: '未知', type: 'info' } +} + +const auditResultLabels: Record = { + success: '成功', + failed: '失败', + denied: '拒绝', + partial: '部分成功', + unknown: '未知', + pending: '处理中', + not_found: '未找到', + invalid_payload: '无效载荷', + conflict: '冲突', + ignored: '已忽略', + merged: '已合并', + rate_limited: '已限流', + completed: '已完成', + cancelled: '已取消' +} + +export function auditResultDisplay(result?: string | null, resultName?: string | null): string { + const name = resultName?.trim() + if (name) return name + return result ? auditResultLabels[result] || result : '-' +} + +export const auditRiskMeta: Record< + AuditRiskLevel, + { label: string; type: 'success' | 'danger' | 'warning' | 'info' } +> = { + low: { label: '低', type: 'success' }, + normal: { label: '普通', type: 'info' }, + high: { label: '高', type: 'warning' }, + critical: { label: '严重', type: 'danger' } +} + +export const integrationCategoryMeta: Record< + IntegrationResultCategory, + { label: string; type: 'success' | 'danger' | 'warning' | 'info' } +> = { + processing: { label: '处理中', type: 'warning' }, + succeeded: { label: '成功', type: 'success' }, + indeterminate: { label: '结果不确定', type: 'warning' }, + failed: { label: '失败', type: 'danger' }, + not_sent: { label: '未发送', type: 'info' } +} + +export function toRfc3339(value?: string | Date | null): string | undefined { + if (!value) return undefined + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? undefined : date.toISOString() +} + +export function fenDisplay(value?: number | null): string { + return typeof value === 'number' ? `¥${(value / 100).toFixed(2)}` : '-' +} + +export function auditJson(value: unknown): string { + if (value === null || value === undefined) return '-' + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) +} diff --git a/src/utils/business/auditNavigation.ts b/src/utils/business/auditNavigation.ts new file mode 100644 index 0000000..1324602 --- /dev/null +++ b/src/utils/business/auditNavigation.ts @@ -0,0 +1,69 @@ +import type { AuditSubjectResourceType } from '@/types/api' +import type { AuditFinanceField, AuditInvestigationTarget } from '@/components/business/audit/types' + +interface AuditResourceRouteOptions { + userType?: number + resourceType: AuditSubjectResourceType | string + internalId?: string | number | null + businessIdentifier?: string | null +} + +/** 根据当前主体选择平台内部 ID 或主体安全业务标识。 */ +export function resolveAuditResourceTarget( + options: AuditResourceRouteOptions +): AuditInvestigationTarget | null { + const { userType, resourceType, internalId, businessIdentifier } = options + const normalizedBusinessIdentifier = businessIdentifier?.trim() + if (userType === 3) { + if ( + !normalizedBusinessIdentifier || + ![ + 'iot_card', + 'device', + 'asset_allocation_record', + 'exchange_order', + 'shop', + 'enterprise' + ].includes(resourceType) + ) + return null + return { + mode: 'agent', + resourceType: resourceType as AuditSubjectResourceType, + id: normalizedBusinessIdentifier + } + } + if (userType === 4) { + if (!normalizedBusinessIdentifier || !['iot_card', 'device'].includes(resourceType)) return null + return { + mode: 'enterprise', + resourceType: resourceType as AuditSubjectResourceType, + id: normalizedBusinessIdentifier + } + } + if ( + internalId === undefined || + internalId === null || + internalId === '' || + (typeof internalId === 'string' && !internalId.trim()) || + (typeof internalId === 'number' && (!Number.isFinite(internalId) || internalId <= 0)) + ) + return null + return { mode: 'resource', resourceType, id: String(internalId).trim() } +} + +export function resolveFinanceAuditTarget( + field: AuditFinanceField, + value?: string | number | null +): AuditInvestigationTarget | null { + const normalizedValue = typeof value === 'string' ? value.trim() : value + if ( + normalizedValue === undefined || + normalizedValue === null || + normalizedValue === '' || + (typeof normalizedValue === 'number' && + (!Number.isFinite(normalizedValue) || normalizedValue <= 0)) + ) + return null + return { mode: 'finance', field, value: normalizedValue } +} diff --git a/src/utils/business/notificationNavigation.ts b/src/utils/business/notificationNavigation.ts index a2c5ebb..4bcf262 100644 --- a/src/utils/business/notificationNavigation.ts +++ b/src/utils/business/notificationNavigation.ts @@ -9,8 +9,16 @@ const resolveTargetRoute = ( target: NotificationTarget ): TargetRoute | null => { const targetId = target.target_id === null ? '' : String(target.target_id) + const targetType = target.target_type || refType - switch (refType) { + if (targetType === 'integration_log') { + const integrationId = target.target_key?.trim() + return integrationId + ? { path: `/audit/integrations/${encodeURIComponent(integrationId)}` } + : null + } + + switch (targetType) { case 'system_config': return { path: RoutesAlias.SystemConfigs } case 'refund': @@ -27,7 +35,6 @@ const resolveTargetRoute = ( return { path: RoutesAlias.ExpiringAssets } case 'shop_fund': return { path: RoutesAlias.AgentFundOverview } - case 'integration_log': case 'package': case 'asset': case 'wecom_approval': diff --git a/src/views/account-management/account/index.vue b/src/views/account-management/account/index.vue index cd25acd..c2a5af3 100644 --- a/src/views/account-management/account/index.vue +++ b/src/views/account-management/account/index.vue @@ -335,6 +335,9 @@ JULY_PERMISSIONS, STATUS_SELECT_OPTIONS } from '@/config/constants' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制 @@ -670,6 +673,19 @@ const getActions = (row: any) => { const actions: any[] = [] + if (hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) { + const auditTarget = resolveAuditResourceTarget({ + resourceType: 'account', + internalId: row.id + }) + if (auditTarget) + actions.push({ + label: '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + } + if (hasAuth('account:patch_role')) { actions.push({ label: '分配角色', diff --git a/src/views/account-management/enterprise-customer/index.vue b/src/views/account-management/enterprise-customer/index.vue index 91a6597..3fa279b 100644 --- a/src/views/account-management/enterprise-customer/index.vue +++ b/src/views/account-management/enterprise-customer/index.vue @@ -204,6 +204,9 @@ import { h, onActivated } from 'vue' import { useRouter } from 'vue-router' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' import { EnterpriseService, ShopService } from '@/api/modules' import { ElMessage, ElSwitch, ElCascader } from 'element-plus' import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus' @@ -502,6 +505,21 @@ const getActions = (row: EnterpriseItem) => { const actions: any[] = [] + if (hasAuth(AUDIT_PERMISSIONS.enterpriseEntry)) { + const auditTarget = resolveAuditResourceTarget({ + userType: userStore.getUserInfo.user_type, + resourceType: 'enterprise', + internalId: row.id, + businessIdentifier: row.enterprise_code + }) + if (auditTarget) + actions.push({ + label: '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + } + if (hasAuth('enterprise_customer:look_customer')) { actions.push({ label: '账号列表', diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 1cc029f..3776df0 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -9,6 +9,9 @@
+ + {{ isSubjectActivityView ? '活动记录' : '审计记录' }} + + @@ -589,10 +610,14 @@ getExpiryEstimateTooltip, getExpiryEstimateClass } from '@/utils/business/expiryEstimate' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' // Props interface BindingCard { id: number + card_id?: number iccid: string msisdn?: string carrier_name?: string @@ -609,7 +634,9 @@ } interface AssetInfo { + asset_id: number asset_type: 'card' | 'device' + identifier: string iccid?: string imei?: string msisdn?: string @@ -717,6 +744,64 @@ const { hasAuth } = useAuth() const userStore = useUserStore() + const isSubjectActivityView = computed(() => [3, 4].includes(Number(userStore.info.user_type))) + const auditTarget = computed(() => { + const userType = Number(userStore.info.user_type) + const permission = + userType === 3 + ? AUDIT_PERMISSIONS.agentActivity + : userType === 4 + ? AUDIT_PERMISSIONS.enterpriseActivity + : props.cardInfo.asset_type === 'card' + ? AUDIT_PERMISSIONS.cardEntry + : AUDIT_PERMISSIONS.deviceEntry + if (!hasAuth(permission)) return null + return resolveAuditResourceTarget({ + userType, + resourceType: props.cardInfo.asset_type === 'card' ? 'iot_card' : 'device', + internalId: props.cardInfo.asset_id, + businessIdentifier: + props.cardInfo.asset_type === 'card' ? props.cardInfo.iccid : props.cardInfo.virtual_no + }) + }) + const openAssetAudit = () => { + if (auditTarget.value) openAuditInvestigation(auditTarget.value) + } + const getBindingCardAuditTarget = (card: BindingCard) => { + const userType = Number(userStore.info.user_type) + const permission = + userType === 3 + ? AUDIT_PERMISSIONS.agentActivity + : userType === 4 + ? AUDIT_PERMISSIONS.enterpriseActivity + : AUDIT_PERMISSIONS.cardEntry + if (!hasAuth(permission)) return null + return resolveAuditResourceTarget({ + userType, + resourceType: 'iot_card', + internalId: card.card_id, + businessIdentifier: card.iccid + }) + } + const getBindingAuditTarget = (card: BindingCard) => { + if ( + ![1, 2].includes(Number(userStore.info.user_type)) || + !hasAuth(AUDIT_PERMISSIONS.resourceTimeline) + ) + return null + return resolveAuditResourceTarget({ + resourceType: 'device_sim_binding', + internalId: card.id + }) + } + const openBindingCardAudit = (card: BindingCard) => { + const target = getBindingCardAuditTarget(card) + if (target) openAuditInvestigation(target) + } + const openBindingAudit = (card: BindingCard) => { + const target = getBindingAuditTarget(card) + if (target) openAuditInvestigation(target) + } const { width } = useWindowSize() const CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage' diff --git a/src/views/asset-management/asset-information/components/WalletTransactionCard.vue b/src/views/asset-management/asset-information/components/WalletTransactionCard.vue index c513476..d8e6135 100644 --- a/src/views/asset-management/asset-information/components/WalletTransactionCard.vue +++ b/src/views/asset-management/asset-information/components/WalletTransactionCard.vue @@ -44,6 +44,20 @@ /> 查询 重置 + 资金链路 + 资产审计
@@ -167,6 +181,13 @@ AssetWalletTransactionParams, TransactionType } from '@/types/api' + import { useAuth } from '@/composables/useAuth' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' interface Props { assetIdentifier: string @@ -182,6 +203,19 @@ boundDeviceId: undefined, boundDeviceNo: '' }) + const { hasAuth } = useAuth() + + const openWalletAudit = () => { + const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id) + if (target) openAuditInvestigation(target) + } + const openAssetAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: props.walletInfo?.resource_type || '', + internalId: props.walletInfo?.resource_id + }) + if (target) openAuditInvestigation(target) + } const emit = defineEmits<{ (e: 'navigateToDevice', deviceNo: string): void diff --git a/src/views/asset-management/device-list/index.vue b/src/views/asset-management/device-list/index.vue index de7672e..4491d03 100644 --- a/src/views/asset-management/device-list/index.vue +++ b/src/views/asset-management/device-list/index.vue @@ -969,6 +969,10 @@ import { computed, h } from 'vue' import { useRouter } from 'vue-router' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' + import { useUserStore } from '@/store/modules/user' import { DeviceService, ShopService, @@ -1027,6 +1031,7 @@ const { hasAuth } = useAuth() const router = useRouter() + const userStore = useUserStore() const loading = ref(false) const allocateLoading = ref(false) @@ -3173,6 +3178,27 @@ }) } + const userType = Number(userStore.getUserInfo.user_type) + const auditPermission = + userType === 3 + ? AUDIT_PERMISSIONS.agentActivity + : userType === 4 + ? AUDIT_PERMISSIONS.enterpriseActivity + : AUDIT_PERMISSIONS.deviceEntry + if (hasAuth(auditPermission)) { + const auditTarget = resolveAuditResourceTarget({ + userType, + resourceType: 'device', + internalId: row.id, + businessIdentifier: row.virtual_no + }) + if (auditTarget) + moreActions.push({ + label: userType >= 3 ? '活动记录' : '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + } // 如果有更多操作,添加到 actions 中 if (moreActions.length > 0) { actions.push(...moreActions) diff --git a/src/views/asset-management/exchange-management/detail.vue b/src/views/asset-management/exchange-management/detail.vue index 65b6882..a9cc144 100644 --- a/src/views/asset-management/exchange-management/detail.vue +++ b/src/views/asset-management/exchange-management/detail.vue @@ -10,6 +10,30 @@ 返回

换货单详情

+ + {{ userType === 3 ? '活动记录' : '换货审计' }} + + + 旧资产审计 + + + 新资产审计 + @@ -35,15 +59,51 @@ import { formatDateTime } from '@/utils/business/format' import DetailPage from '@/components/common/DetailPage.vue' import type { DetailSection } from '@/components/common/DetailPage.vue' + import { useAuth } from '@/composables/useAuth' + import { useUserStore } from '@/store/modules/user' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'ExchangeDetail' }) const route = useRoute() const router = useRouter() + const { hasAuth } = useAuth() + const userStore = useUserStore() const loading = ref(false) const exchangeDetail = ref(null) const exchangeId = ref(0) + const userType = computed(() => Number(userStore.info.user_type)) + const exchangeAuditPermission = computed(() => + userType.value === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline + ) + const exchangeAuditTarget = computed(() => { + if (!exchangeDetail.value || userType.value === 4) return null + return resolveAuditResourceTarget({ + userType: userType.value, + resourceType: 'exchange_order', + internalId: exchangeDetail.value.id, + businessIdentifier: exchangeDetail.value.exchange_no + }) + }) + const oldAssetAuditTarget = computed(() => { + if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null + return resolveAuditResourceTarget({ + userType: userType.value, + resourceType: exchangeDetail.value.old_asset_type, + internalId: exchangeDetail.value.old_asset_id + }) + }) + const newAssetAuditTarget = computed(() => { + if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null + return resolveAuditResourceTarget({ + userType: userType.value, + resourceType: exchangeDetail.value.new_asset_type || '', + internalId: exchangeDetail.value.new_asset_id + }) + }) const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => { if (shopName) return shopName diff --git a/src/views/asset-management/exchange-management/index.vue b/src/views/asset-management/exchange-management/index.vue index c29b48d..50f5b65 100644 --- a/src/views/asset-management/exchange-management/index.vue +++ b/src/views/asset-management/exchange-management/index.vue @@ -35,7 +35,7 @@ :total="pagination.total" :marginTop="10" :actions="getActions" - :actionsWidth="120" + :actionsWidth="160" @size-change="handleSizeChange" @current-change="handleCurrentChange" > @@ -438,10 +438,15 @@ getProcessingStatusText } from '@/utils/business/approvalSummary' import { RoutesAlias } from '@/router/routesAlias' + import { useUserStore } from '@/store/modules/user' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'ExchangeManagement' }) const { hasAuth } = useAuth() + const userStore = useUserStore() const router = useRouter() const currentExchangeRow = ref(null) @@ -1548,6 +1553,45 @@ const actions: any[] = [] const status = row.status const flowType = row.flow_type || 'shipping' // 历史数据兼容 + const userType = Number(userStore.info.user_type) + const entryPermission = + userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline + if (userType !== 4 && hasAuth(entryPermission)) { + const exchangeTarget = resolveAuditResourceTarget({ + userType, + resourceType: 'exchange_order', + internalId: row.id, + businessIdentifier: row.exchange_no + }) + if (exchangeTarget) + actions.push({ + label: userType === 3 ? '活动记录' : '换货审计', + handler: () => openAuditInvestigation(exchangeTarget), + type: 'primary' + }) + if ([1, 2].includes(userType)) { + const oldAssetTarget = resolveAuditResourceTarget({ + resourceType: row.old_asset_type, + internalId: row.old_asset_id + }) + if (oldAssetTarget) + actions.push({ + label: '旧资产审计', + handler: () => openAuditInvestigation(oldAssetTarget), + type: 'primary' + }) + const newAssetTarget = resolveAuditResourceTarget({ + resourceType: row.new_asset_type || '', + internalId: row.new_asset_id + }) + if (newAssetTarget) + actions.push({ + label: '新资产审计', + handler: () => openAuditInvestigation(newAssetTarget), + type: 'primary' + }) + } + } // 直接换货不支持任何操作(发货、确认完成、取消) if (flowType === 'direct') { diff --git a/src/views/asset-management/iot-card-management/index.vue b/src/views/asset-management/iot-card-management/index.vue index b99c4b5..ef810e3 100644 --- a/src/views/asset-management/iot-card-management/index.vue +++ b/src/views/asset-management/iot-card-management/index.vue @@ -1024,6 +1024,9 @@ import { computed, h } from 'vue' import { useRouter } from 'vue-router' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' import { CardService, ShopService, @@ -2331,6 +2334,27 @@ }) } + const userType = Number(userStore.getUserInfo.user_type) + const auditPermission = + userType === 3 + ? AUDIT_PERMISSIONS.agentActivity + : userType === 4 + ? AUDIT_PERMISSIONS.enterpriseActivity + : AUDIT_PERMISSIONS.cardEntry + if (hasAuth(auditPermission)) { + const auditTarget = resolveAuditResourceTarget({ + userType, + resourceType: 'iot_card', + internalId: row.id, + businessIdentifier: row.iccid + }) + if (auditTarget) + actions.push({ + label: userType >= 3 ? '活动记录' : '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + } return actions } @@ -2448,8 +2472,8 @@ return } - const successCount = res.data?.success_count ?? assetIds.length - ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`) + const successCount = res.data?.success_count ?? assetIds.length + ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`) batchRealnamePolicyDialogVisible.value = false selectedCards.value = [] await getTableData() diff --git a/src/views/asset-management/record-management/asset-assign/detail.vue b/src/views/asset-management/record-management/asset-assign/detail.vue index 4eee848..d521a72 100644 --- a/src/views/asset-management/record-management/asset-assign/detail.vue +++ b/src/views/asset-management/record-management/asset-assign/detail.vue @@ -10,6 +10,22 @@ 返回

资产分配详情

+ + {{ userType === 3 ? '活动记录' : '分配审计' }} + + + 资产审计 + @@ -25,7 +41,7 @@ + + diff --git a/src/views/audit/events/index.vue b/src/views/audit/events/index.vue new file mode 100644 index 0000000..c810269 --- /dev/null +++ b/src/views/audit/events/index.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/src/views/audit/integrations/detail.vue b/src/views/audit/integrations/detail.vue new file mode 100644 index 0000000..25fdbb0 --- /dev/null +++ b/src/views/audit/integrations/detail.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/src/views/audit/integrations/index.vue b/src/views/audit/integrations/index.vue new file mode 100644 index 0000000..09cf253 --- /dev/null +++ b/src/views/audit/integrations/index.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/src/views/audit/risks/index.vue b/src/views/audit/risks/index.vue new file mode 100644 index 0000000..089fbb1 --- /dev/null +++ b/src/views/audit/risks/index.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/src/views/commission-management/agent-fund-overview/index.vue b/src/views/commission-management/agent-fund-overview/index.vue index 3e1dd3c..6842f83 100644 --- a/src/views/commission-management/agent-fund-overview/index.vue +++ b/src/views/commission-management/agent-fund-overview/index.vue @@ -30,6 +30,8 @@ :currentPage="pagination.page" :pageSize="pagination.pageSize" :total="pagination.total" + :actions="getAuditActions" + :actionsWidth="120" :marginTop="10" @size-change="handleSizeChange" @current-change="handleCurrentChange" @@ -458,6 +460,9 @@ } from '@/config/constants/commission' import { RoutesAlias } from '@/router/routesAlias' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'AgentCommission' }) @@ -471,6 +476,20 @@ const tableRef = ref() const summaryList = ref([]) + const getAuditActions = (row: ShopFundSummaryItem) => { + if (!hasAuth(AUDIT_PERMISSIONS.financeTimeline)) return [] + const target = resolveFinanceAuditTarget('shop_id', row.shop_id) + return target + ? [ + { + label: '资金链路', + handler: () => openAuditInvestigation(target), + type: 'primary' as const + } + ] + : [] + } + // 搜索表单 const searchForm = reactive({ shop_name: '', diff --git a/src/views/finance/agent-recharge/detail.vue b/src/views/finance/agent-recharge/detail.vue index e0c1007..e66e72e 100644 --- a/src/views/finance/agent-recharge/detail.vue +++ b/src/views/finance/agent-recharge/detail.vue @@ -10,6 +10,34 @@ 返回

{{ pageTitle }}

+ + 审计记录 + + + 资金链路 + + + 审批审计 + @@ -42,15 +70,44 @@ import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import { formatRejectionReason } from './agentRechargeDisplay' + import { useAuth } from '@/composables/useAuth' + import { useUserStore } from '@/store/modules/user' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'AgentRechargeDetail' }) const route = useRoute() const router = useRouter() + const { hasAuth } = useAuth() + const userStore = useUserStore() const loading = ref(false) const detailData = ref(null) const paymentVoucherFileKeys = ref([]) + const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type))) + const openRechargeAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: 'agent_recharge', + internalId: detailData.value?.id + }) + if (target) openAuditInvestigation(target) + } + const openRechargeFinance = () => { + const target = resolveFinanceAuditTarget('recharge_id', detailData.value?.id) + if (target) openAuditInvestigation(target) + } + const openApprovalAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: 'approval_instance', + internalId: detailData.value?.approval_instance_id + }) + if (target) openAuditInvestigation(target) + } const pageTitle = computed(() => `充值订单详情`) diff --git a/src/views/finance/agent-recharge/index.vue b/src/views/finance/agent-recharge/index.vue index 1a6f83e..124ed6d 100644 --- a/src/views/finance/agent-recharge/index.vue +++ b/src/views/finance/agent-recharge/index.vue @@ -41,8 +41,8 @@ :total="pagination.total" :marginTop="10" :actions="getActions" - :inlineActionsCount="2" - :actionsWidth="200" + :inlineActionsCount="1" + :actionsWidth="160" @size-change="handleSizeChange" @current-change="handleCurrentChange" > @@ -317,6 +317,12 @@ import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { useAuth } from '@/composables/useAuth' import { useUserStore } from '@/store/modules/user' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' @@ -1284,12 +1290,46 @@ // 获取操作按钮 const getActions = (row: AgentRecharge) => { - return buildAgentRechargeActions(row, { + const actions = buildAgentRechargeActions(row, { hasAuth, onViewPaymentVoucher: handleViewPaymentVoucher, onConfirmPayment: handleShowConfirmPay, onReject: handleShowReject }) + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) { + const resourceTarget = resolveAuditResourceTarget({ + resourceType: 'agent_recharge', + internalId: row.id + }) + if (resourceTarget) + actions.unshift({ + label: '审计记录', + handler: () => openAuditInvestigation(resourceTarget), + type: 'primary' + }) + } + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.financeTimeline)) { + const financeTarget = resolveFinanceAuditTarget('recharge_id', row.id) + if (financeTarget) + actions.unshift({ + label: '资金链路', + handler: () => openAuditInvestigation(financeTarget), + type: 'primary' + }) + } + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) { + const approvalTarget = resolveAuditResourceTarget({ + resourceType: 'approval_instance', + internalId: row.approval_instance_id + }) + if (approvalTarget) + actions.unshift({ + label: '审批审计', + handler: () => openAuditInvestigation(approvalTarget), + type: 'primary' + }) + } + return actions } // 查看支付凭证 diff --git a/src/views/finance/refund/detail.vue b/src/views/finance/refund/detail.vue index 43a2295..7522cee 100644 --- a/src/views/finance/refund/detail.vue +++ b/src/views/finance/refund/detail.vue @@ -10,6 +10,34 @@ 返回

{{ pageTitle }}

+ + 审计记录 + + + 资金链路 + + + 审批审计 + (null) const refundVoucherFileKeys = ref([]) + const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type))) + const openRefundAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: 'refund', + internalId: refund.value?.id + }) + if (target) openAuditInvestigation(target) + } + const openRefundFinance = () => { + const target = resolveFinanceAuditTarget('refund_id', refund.value?.id) + if (target) openAuditInvestigation(target) + } + const openApprovalAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: 'approval_instance', + internalId: refund.value?.approval_instance_id + }) + if (target) openAuditInvestigation(target) + } const pageTitle = computed(() => `退款详情`) diff --git a/src/views/finance/refund/index.vue b/src/views/finance/refund/index.vue index 9c218fe..76af064 100644 --- a/src/views/finance/refund/index.vue +++ b/src/views/finance/refund/index.vue @@ -38,8 +38,8 @@ :total="pagination.total" :marginTop="10" :actions="getActions" - :actionsWidth="220" - :inlineActionsCount="2" + :actionsWidth="160" + :inlineActionsCount="1" @size-change="handleSizeChange" @current-change="handleCurrentChange" > @@ -164,6 +164,13 @@ } from '@/utils/business/approvalSummary' import { toVoucherKeyList } from '@/utils/business' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' + import { useUserStore } from '@/store/modules/user' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' @@ -174,6 +181,7 @@ defineOptions({ name: 'RefundList' }) const router = useRouter() + const userStore = useUserStore() const { hasAuth } = useAuth() const loading = ref(false) @@ -730,6 +738,43 @@ // 获取操作按钮 const getActions = (row: Refund) => { const actions: any[] = [] + + if ( + [1, 2].includes(userStore.getUserInfo.user_type || 0) && + hasAuth(AUDIT_PERMISSIONS.refundEntry) + ) { + const auditTarget = resolveAuditResourceTarget({ + userType: userStore.getUserInfo.user_type, + resourceType: 'refund', + internalId: row.id + }) + if (auditTarget) + actions.push({ + label: '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) { + const financeTarget = resolveFinanceAuditTarget('refund_id', row.id) + if (financeTarget) + actions.push({ + label: '资金链路', + handler: () => openAuditInvestigation(financeTarget), + type: 'primary' + }) + } + const approvalTarget = resolveAuditResourceTarget({ + userType: userStore.getUserInfo.user_type, + resourceType: 'approval_instance', + internalId: row.approval_instance_id + }) + if (approvalTarget) + actions.push({ + label: '审批审计', + handler: () => openAuditInvestigation(approvalTarget), + type: 'primary' + }) + } const voucherKeys = getRefundAttachmentKeys(row) if (canResubmit(row) && hasAuth('refund:resubmit')) { diff --git a/src/views/order-management/order-list/detail.vue b/src/views/order-management/order-list/detail.vue index 39dfbf7..49ee711 100644 --- a/src/views/order-management/order-list/detail.vue +++ b/src/views/order-management/order-list/detail.vue @@ -10,6 +10,22 @@ 返回

订单详情

+ + 审计记录 + + + 资金链路 + @@ -82,6 +98,12 @@ import { formatDateTime } from '@/utils/business/format' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'OrderDetail' }) @@ -93,6 +115,18 @@ const loading = ref(false) const detailData = ref(null) const paymentVoucherFileKeys = ref([]) + const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type))) + const openOrderAudit = () => { + const target = resolveAuditResourceTarget({ + resourceType: 'order', + internalId: detailData.value?.id + }) + if (target) openAuditInvestigation(target) + } + const openOrderFinance = () => { + const target = resolveFinanceAuditTarget('order_id', detailData.value?.id) + if (target) openAuditInvestigation(target) + } // 格式化货币 - 将分转换为元 const formatCurrency = (amount: number): string => { diff --git a/src/views/order-management/order-list/index.vue b/src/views/order-management/order-list/index.vue index d7f0b7f..c795b24 100644 --- a/src/views/order-management/order-list/index.vue +++ b/src/views/order-management/order-list/index.vue @@ -282,6 +282,12 @@ import { formatDateTime } from '@/utils/business/format' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue' @@ -1351,6 +1357,31 @@ const getActions = (row: Order) => { const actions: any[] = [] + if ( + [1, 2].includes(userStore.getUserInfo.user_type || 0) && + hasAuth(AUDIT_PERMISSIONS.orderEntry) + ) { + const auditTarget = resolveAuditResourceTarget({ + userType: userStore.getUserInfo.user_type, + resourceType: 'order', + internalId: row.id + }) + if (auditTarget) + actions.push({ + label: '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) { + const financeTarget = resolveFinanceAuditTarget('order_id', row.id) + if (financeTarget) + actions.push({ + label: '资金链路', + handler: () => openAuditInvestigation(financeTarget), + type: 'primary' + }) + } + } // 线下支付且有凭证可以查看 if ( row.payment_method === 'offline' && diff --git a/src/views/shop-management/list/index.vue b/src/views/shop-management/list/index.vue index 896456e..8b5aff1 100644 --- a/src/views/shop-management/list/index.vue +++ b/src/views/shop-management/list/index.vue @@ -351,6 +351,12 @@ import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants' import { regionData } from '@/utils/constants/regionData' import { RoutesAlias } from '@/router/routesAlias' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' defineOptions({ name: 'Shop' }) @@ -809,6 +815,32 @@ const getActions = (row: ShopResponse) => { const actions: any[] = [] + if (hasAuth(AUDIT_PERMISSIONS.shopEntry)) { + const auditTarget = resolveAuditResourceTarget({ + userType: userStore.getUserInfo.user_type, + resourceType: 'shop', + internalId: row.id, + businessIdentifier: row.shop_code + }) + if (auditTarget) + actions.push({ + label: '审计记录', + handler: () => openAuditInvestigation(auditTarget), + type: 'primary' + }) + } + if ( + [1, 2].includes(userStore.getUserInfo.user_type || 0) && + hasAuth(AUDIT_PERMISSIONS.financeTimeline) + ) { + const financeTarget = resolveFinanceAuditTarget('shop_id', row.id) + if (financeTarget) + actions.push({ + label: '资金链路', + handler: () => openAuditInvestigation(financeTarget), + type: 'primary' + }) + } if (hasAuth('shop:look_customer')) { actions.push({ label: '账号列表',