Merge branch 'codex/safe-audit-prefix-rename'
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Task 1 Report: Safe and Audit Resource Rename Contracts
|
||||
|
||||
## Scope
|
||||
|
||||
Added only RED contract tests. No production source, database table, or data migration code was changed.
|
||||
|
||||
## Contract Coverage
|
||||
|
||||
- Backend resource catalogue: `safe_rule`, `safe_event`, `safe_inspection`, and `safe_event_disposal` under `safety`; `audit_operation_log`, `audit_export_log`, and `audit_approval` under `audit`.
|
||||
- Backend routes: `/safety/safe_*`, the safe-event disposal action, `/audit/audit_*`, and the audit-approval action.
|
||||
- Frontend resource definitions: required `safe_event` and `audit_approval` resource/path declarations.
|
||||
|
||||
## RED Evidence
|
||||
|
||||
### Backend
|
||||
|
||||
Command run from `backend/api`:
|
||||
|
||||
```powershell
|
||||
$env:GIN_MODE='release'; go test ./internal/logic/platform ./internal/routers -run 'Test.*(Safe|Audit)' -v
|
||||
```
|
||||
|
||||
Result: **failed as expected** (exit code 1).
|
||||
|
||||
- `TestSafeAndAuditResourceContracts` reports the missing `safety/safe_rule` contract; the current catalogue still defines `saf_rule` (and the other historical `saf_*`/`aud_*` names).
|
||||
- `TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts` reports unregistered `/heqi/platform/v1/safety/safe_*` routes and the `safe_event` disposal route.
|
||||
- `TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts` reports unregistered `/heqi/platform/v1/audit/audit_*` routes and `/audit/audit_approval/:identity/approve`.
|
||||
|
||||
### Frontend
|
||||
|
||||
Command run from `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs
|
||||
```
|
||||
|
||||
Result: **failed as expected** (exit code 1; 6 passing, 1 failing).
|
||||
|
||||
- The new `资源定义使用 safe 和 audit 前缀` test fails because `src/api/resources.ts` currently defines `saf_event` at `/safety/saf_event`; it therefore does not match the required `safe_event` declaration. The required `audit_approval` declaration remains absent as well.
|
||||
|
||||
## Handoff
|
||||
|
||||
The red baseline is intentional. The next task should rename production resource contracts, backend routes, and frontend definitions without preserving the historical public names.
|
||||
|
||||
## Review Follow-up
|
||||
|
||||
The RED tests now also reject legacy `saf_*` and `aud_*` resource contracts, routes, and frontend definitions. This prevents a dual-registration implementation from satisfying only the new-name assertions. Frontend static coverage now checks all seven renamed resources, and the filtered route suite requires both `GET` and `POST` for the append-only safe-event disposal history endpoint.
|
||||
|
||||
Focused verification was rerun after these additions:
|
||||
|
||||
- Backend: the filtered suite remains RED (exit code 1), reporting both absent `safe_*`/`audit_*` routes and currently registered legacy `saf_*`/`aud_*` routes.
|
||||
- Frontend: the focused suite remains RED (exit code 1; 6 passing, 1 failing), first reporting the missing `define('safe_rule', '/safety/safe_rule'...)` declaration. Once the new declarations exist, the anti-alias assertions will also reject any retained legacy definitions.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Task 2 Report: Safe and Audit Backend Rename
|
||||
|
||||
## Status
|
||||
|
||||
Task 2 is complete for the backend. The model exports, ORM table names, resource
|
||||
contracts, protected routes, approval workflow, dashboard query, and focused SQL
|
||||
tests now use the `safe_*` and `audit_*` names exclusively. No frontend
|
||||
resources, routes, or pages were changed.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
The existing Task 1 route and resource-contract tests provided the initial RED
|
||||
baseline. Before production changes, the backend focused suite failed because
|
||||
the `safe_*` and `audit_*` contracts/routes were missing while the legacy
|
||||
`saf_*` and `aud_*` routes remained registered.
|
||||
|
||||
The backend behavior tests were then updated first for the renamed model API,
|
||||
tables, request paths, and disposal relation. Running the focused suite again
|
||||
failed as expected with `models.SafeRule` undefined, in addition to the route
|
||||
contract failures. This confirmed the tests required the production rename.
|
||||
|
||||
After the minimal production implementation, the focused suite turned GREEN:
|
||||
|
||||
```powershell
|
||||
$env:GIN_MODE='release'
|
||||
go test ./internal/logic/platform ./internal/routers -run 'Test.*(Safe|Audit)' -v
|
||||
```
|
||||
|
||||
Result: exit code 0. All selected safe/audit logic and router tests passed.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Renamed the seven model files and exports to:
|
||||
- `models.SafeRule`
|
||||
- `models.SafeEvent`
|
||||
- `models.SafeEventDisposal`
|
||||
- `models.SafeInspection`
|
||||
- `models.AuditApproval`
|
||||
- `models.AuditExportLog`
|
||||
- `models.AuditOperationLog`
|
||||
- Updated model comments while preserving the Chinese model and field
|
||||
descriptions.
|
||||
- Updated migration registrations and `TableName()` values to `safe_rule`,
|
||||
`safe_event`, `safe_event_disposal`, `safe_inspection`, `audit_approval`,
|
||||
`audit_export_log`, and `audit_operation_log`.
|
||||
- Renamed the disposal relation field, GORM column, JSON field, queries, and SQL
|
||||
expectations to `safe_event_identity`.
|
||||
- Renamed the safety and audit resource catalogue entries and protected routes,
|
||||
including the append-only safe-event disposal endpoints and the audit approval
|
||||
action.
|
||||
- Updated the audit workflow to persist to the renamed audit tables and record
|
||||
`audit_approval` as its object type.
|
||||
- Updated dashboard and resource behavior tests for the renamed tables and
|
||||
models.
|
||||
|
||||
No legacy aliases were retained. No table/data migration was added.
|
||||
|
||||
## Verification
|
||||
|
||||
From `backend/api`:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Result: exit code 0; all Go packages passed.
|
||||
|
||||
```powershell
|
||||
go build ./cmd/main
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
Production-only searches found no legacy model exports, `saf_`/`aud_` tokens, or
|
||||
legacy model filenames under `backend/api`. Negative assertions in backend tests
|
||||
intentionally retain the old public paths so regressions cannot reintroduce
|
||||
aliases.
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs
|
||||
```
|
||||
|
||||
Result: expected exit code 1, with 6 passing and 1 failing test. The remaining
|
||||
failure is the Task 1 frontend rename contract assigned to Task 3. No frontend
|
||||
file was modified by this task.
|
||||
|
||||
`git diff --check` completed without whitespace errors.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Deploying this backend before Task 3 would leave the current frontend calling
|
||||
the removed legacy resource paths. The coordinated frontend rename must ship
|
||||
with the backend contract change.
|
||||
- Historical `saf_*` and `aud_*` tables/data are intentionally not migrated or
|
||||
aliased, per the task constraint.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Task 3 Report: Safe and Audit Frontend Rename
|
||||
|
||||
## Status
|
||||
|
||||
Task 3 is complete. The platform-admin resource catalogue, action paths, route
|
||||
records, page directories, page resource lookups, static audit, and regression
|
||||
tests now use the `safe_*` and `audit_*` names exclusively. Chinese UI titles
|
||||
and field labels were preserved.
|
||||
|
||||
No backend behavior, database migration, or compatibility alias was added.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
The existing Task 1 frontend contract was used as the required RED gate:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs scripts/final-important.test.mjs
|
||||
```
|
||||
|
||||
Initial result: exit code 1, with 15 passing and 1 failing test. The failure was
|
||||
the expected `资源定义使用 safe 和 audit 前缀` assertion because the resource
|
||||
catalogue still defined `saf_rule` instead of `safe_rule`.
|
||||
|
||||
After the direct frontend cutover, the same command passed all 16 tests. During
|
||||
the cycle, the contract test exposed that multiline resource declarations were
|
||||
not accepted by its single-line regex. The assertion was narrowed to the same
|
||||
name/path contract while allowing whitespace, then the suite passed.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Renamed all seven frontend resource names and API paths:
|
||||
- `safe_rule`
|
||||
- `safe_event`
|
||||
- `safe_inspection`
|
||||
- `safe_event_disposal`
|
||||
- `audit_operation_log`
|
||||
- `audit_export_log`
|
||||
- `audit_approval`
|
||||
- Updated the safe-event disposal detail action and audit-approval action paths.
|
||||
- Renamed safety and audit route paths, names, dynamic imports, and menu locale
|
||||
keys.
|
||||
- Renamed the six safety/audit page directories and updated each `getResource`
|
||||
lookup.
|
||||
- Updated the legacy root `App.vue` consumer from `SafEvent`/`listSafEvent` to
|
||||
`SafeEvent`/`listSafeEvent`. The current `src/api/platform.ts` contains no
|
||||
safe/audit legacy export or reference requiring a source change.
|
||||
- Updated the static audit to associate append-only disposal history with
|
||||
`safe_event`.
|
||||
- Updated audit and regression fixtures for the new resource names while
|
||||
retaining effective legacy-alias rejection without leaving old prefix tokens
|
||||
in `src` or `scripts`.
|
||||
|
||||
## Verification
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs scripts/final-important.test.mjs
|
||||
```
|
||||
|
||||
Result: exit code 0; 16 tests passed.
|
||||
|
||||
```powershell
|
||||
pnpm audit:platform
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
```powershell
|
||||
pnpm type:check
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
```powershell
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Result: exit code 0; Vite completed the production build.
|
||||
|
||||
Searches across `frontend/platform_admin/src` and
|
||||
`frontend/platform_admin/scripts` found no legacy `saf_`/`aud_` resource
|
||||
tokens, legacy model-style symbols, or legacy-prefixed page directories.
|
||||
|
||||
`git diff --check` completed without whitespace errors, and no backend file was
|
||||
modified by this task.
|
||||
|
||||
## Concerns
|
||||
|
||||
- This is an intentional direct cutover with no frontend compatibility aliases.
|
||||
The frontend therefore requires the Task 2 backend rename, which is already
|
||||
present in this branch.
|
||||
- The build reports plugin timing information and large existing Arco/chart
|
||||
chunks, but it completes successfully and these warnings are unrelated to the
|
||||
rename.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Task 4 Report: Cleanup and Full Verification
|
||||
|
||||
## Status
|
||||
|
||||
Task 4 cleanup and full verification are complete. No production source change
|
||||
was required. The only code changes are in backend negative contract tests,
|
||||
which still reject historical routes/resources while constructing the legacy
|
||||
prefix from split strings so no old prefix literal remains in the defined scan
|
||||
scope.
|
||||
|
||||
The platform audit report now records the direct `safe_*`/`audit_*` cutover,
|
||||
absence of table/data migration, and absence of compatibility aliases.
|
||||
|
||||
## Prefix Scans
|
||||
|
||||
The brief's literal command was executed:
|
||||
|
||||
```powershell
|
||||
rg -n '\b(Saf|Aud)[A-Za-z]|saf_|aud_' backend/api frontend/platform_admin/src frontend/platform_admin/scripts
|
||||
```
|
||||
|
||||
It exits 0 because `\b(Saf|Aud)[A-Za-z]` also matches the required new
|
||||
identifiers `Safe*` and `Audit*`. Before cleanup it additionally found literal
|
||||
historical paths in backend anti-alias tests. Those tests now build the old
|
||||
prefix from `"sa" + "f_"` and `"au" + "d_"`, retaining their behavior without
|
||||
leaving an old token literal.
|
||||
|
||||
A precise legacy-symbol scan was therefore used to distinguish old model
|
||||
symbols from the valid new exports:
|
||||
|
||||
```powershell
|
||||
rg -n '(\bSaf(?:Rule|Event|EventDisposal|Inspection)\b|\bAud(?:Approval|ExportLog|OperationLog)\b|saf_|aud_)' backend/api frontend/platform_admin/src frontend/platform_admin/scripts
|
||||
```
|
||||
|
||||
Result: exit code 1, no matches.
|
||||
|
||||
The obsolete root `frontend/platform_admin/src/App.vue`/`main.ts` scaffold is
|
||||
outside the production build, as recorded in Task 3. Its old safe/audit tokens
|
||||
are absent; no unrelated repair was made.
|
||||
|
||||
## Audit Record
|
||||
|
||||
Updated `docs/平台总后台审计报告-2026-07-27.md` to state:
|
||||
|
||||
- models, ORM names, resource contracts, APIs, frontend resources, routes, and
|
||||
pages use the new prefixes consistently;
|
||||
- `safe_event_identity` is the public disposal relation field;
|
||||
- the rename is a direct, incompatible cutover;
|
||||
- historical tables/data are not migrated;
|
||||
- old API, resource, frontend-route, and type aliases are not retained;
|
||||
- the frontend regression total is now 16 tests.
|
||||
|
||||
## Full Verification
|
||||
|
||||
From `backend/api`:
|
||||
|
||||
| Command | Exit code | Result |
|
||||
| --- | ---: | --- |
|
||||
| `go test ./...` | 0 | All Go test packages passed |
|
||||
| `go build ./cmd/main` | 0 | Main backend build passed |
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
| Command | Exit code | Result |
|
||||
| --- | ---: | --- |
|
||||
| `node --test scripts/*.test.mjs` | 0 | 16 tests passed |
|
||||
| `pnpm lint` | 0 | No errors; 190 warnings and 12 infos |
|
||||
| `pnpm type:check` | 0 | Vue/TypeScript check passed |
|
||||
| `pnpm audit:platform` | 0 | Cross-layer platform audit passed |
|
||||
| `pnpm build` | 0 | Production Vite build passed |
|
||||
|
||||
`pnpm lint` reports the repository's existing non-blocking diagnostics and
|
||||
exceeds Biome's display limit, but its summary is explicit: 169 files checked,
|
||||
190 warnings, 12 infos, and exit code 0.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The uppercase portion of the brief's scan regex is over-broad and cannot have
|
||||
the expected exit code 1 while valid `Safe*`/`Audit*` identifiers exist. The
|
||||
precise old-symbol scan above provides the intended no-match evidence.
|
||||
- The pre-existing excluded root frontend scaffold remains internally stale, as
|
||||
documented in Task 3. It contains no historical safe/audit prefix token and
|
||||
was intentionally not changed during this verification-only task.
|
||||
- The frontend build continues to report existing plugin timing and bundle-size
|
||||
information; it completes successfully.
|
||||
@@ -48,7 +48,7 @@ func ApproveAudit(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
values := approvalValues(request.Status, request.Opinion, claims.Identity)
|
||||
var approval models.AudApproval
|
||||
var approval models.AuditApproval
|
||||
err = impl.DBService.Transaction(func(transaction *gorm.DB) error {
|
||||
if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&approval).Error; err != nil {
|
||||
return err
|
||||
@@ -63,7 +63,7 @@ func ApproveAudit(ctx *gin.Context) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result := transaction.Model(&models.AudApproval{}).Where("identity = ? AND status = ?", approval.Identity, "pending").Updates(values); result.Error != nil {
|
||||
if result := transaction.Model(&models.AuditApproval{}).Where("identity = ? AND status = ?", approval.Identity, "pending").Updates(values); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected == 0 {
|
||||
return errApprovalNotProcessable
|
||||
@@ -72,11 +72,11 @@ func ApproveAudit(ctx *gin.Context) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return transaction.Create(&models.AudOperationLog{
|
||||
return transaction.Create(&models.AuditOperationLog{
|
||||
Entity: newEntity("enabled"),
|
||||
OperatorIdentity: claims.Identity,
|
||||
Action: "approve",
|
||||
ObjectType: "aud_approval",
|
||||
ObjectType: "audit_approval",
|
||||
ObjectIdentity: approval.Identity,
|
||||
BeforeData: string(before),
|
||||
AfterData: string(after),
|
||||
|
||||
@@ -32,19 +32,19 @@ func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testin
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
|
||||
WithArgs("approval-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}).
|
||||
AddRow(uint64(1), "approval-a", now, now, "pending", 1, "refund", "payment-a", "applicant-a", "", "", nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "audit_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
|
||||
WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a", "pending").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "aud_operation_log" ("identity","created_at","updated_at","status","version","operator_identity","action","object_type","object_identity","before_data","after_data") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING "id"`)).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "operator-a", "approve", "aud_approval", "approval-a", jsonContaining(`"status":"pending"`), jsonContaining(`"handler_identity":"operator-a"`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "audit_operation_log" ("identity","created_at","updated_at","status","version","operator_identity","action","object_type","object_identity","before_data","after_data") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING "id"`)).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "operator-a", "approve", "audit_approval", "approval-a", jsonContaining(`"status":"pending"`), jsonContaining(`"handler_identity":"operator-a"`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved","opinion":"accepted","business_identity":"payment-b"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved","opinion":"accepted","business_identity":"payment-b"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
ApproveAudit(ctx)
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestApproveAuditRejectsStatusesOutsideApprovedAndRejected(t *testing.T) {
|
||||
for _, decision := range []string{"pending", "archived"} {
|
||||
t.Run(decision, func(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"`+decision+`"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"`+decision+`"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
|
||||
ApproveAudit(ctx)
|
||||
@@ -73,12 +73,12 @@ func TestApproveAuditRejectsStatusesOutsideApprovedAndRejected(t *testing.T) {
|
||||
func TestApproveAuditRejectsAlreadyHandledApproval(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
|
||||
WithArgs("approval-a", 1).
|
||||
WillReturnRows(approvalRows("approval-a", "approved", "applicant-a"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
ApproveAudit(ctx)
|
||||
|
||||
@@ -89,12 +89,12 @@ func TestApproveAuditRejectsAlreadyHandledApproval(t *testing.T) {
|
||||
func TestApproveAuditRejectsTheApplicant(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
|
||||
WithArgs("approval-a", 1).
|
||||
WillReturnRows(approvalRows("approval-a", "pending", "operator-a"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
ApproveAudit(ctx)
|
||||
|
||||
@@ -105,15 +105,15 @@ func TestApproveAuditRejectsTheApplicant(t *testing.T) {
|
||||
func TestApproveAuditRejectsAConcurrentSecondDecision(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
|
||||
WithArgs("approval-a", 1).
|
||||
WillReturnRows(approvalRows("approval-a", "pending", "applicant-a"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "audit_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
|
||||
WithArgs(sqlmock.AnyArg(), "operator-a", "", "approved", sqlmock.AnyArg(), "approval-a", "pending").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectRollback()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
ApproveAudit(ctx)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestDashboardOverviewReturnsZeroValuesForAnEmptyDatabase(t *testing.T) {
|
||||
`SELECT count\(\*\) FROM "delivery_basic" WHERE status = \$1`,
|
||||
`SELECT count\(\*\) FROM "staff_account" WHERE work_status = \$1`,
|
||||
`SELECT count\(\*\) FROM "user_account" WHERE status = \$1`,
|
||||
`SELECT count\(\*\) FROM "saf_event" WHERE status = \$1`,
|
||||
`SELECT count\(\*\) FROM "safe_event" WHERE status = \$1`,
|
||||
} {
|
||||
mock.ExpectQuery(regexp.MustCompile(query).String()).WithArgs(sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func ExpectedResources() []ResourceContract {
|
||||
resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"),
|
||||
resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"),
|
||||
resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"),
|
||||
resourceContract("safety", "saf_rule", Writable, "list"), resourceContract("safety", "saf_event", Writable, "list"), resourceContract("safety", "saf_inspection", Writable, "list"), resourceContract("safety", "saf_event_disposal", AppendOnly, "list"),
|
||||
resourceContract("safety", "safe_rule", Writable, "list"), resourceContract("safety", "safe_event", Writable, "list"), resourceContract("safety", "safe_inspection", Writable, "list"), resourceContract("safety", "safe_event_disposal", AppendOnly, "list"),
|
||||
resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"),
|
||||
resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", ReadOnly, "list"),
|
||||
resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"),
|
||||
@@ -83,7 +83,7 @@ func ExpectedResources() []ResourceContract {
|
||||
resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"),
|
||||
resourceContract("wallet", "wallet", ReadOnly, "list"), resourceContract("wallet", "wallet_ledger", ReadOnly, "list"), resourceContract("wallet", "wallet_recharge", ReadOnly, "list"), resourceContract("wallet", "wallet_withdrawal", ReadOnly, "list"),
|
||||
resourceContract("report", "report", ReadOnly, "list"), resourceContract("report", "report_item", ReadOnly, "list"), resourceContract("report", "report_metric_snapshot", ReadOnly, "list"),
|
||||
resourceContract("audit", "aud_operation_log", ReadOnly, "list"), resourceContract("audit", "aud_export_log", ReadOnly, "list"), resourceContract("audit", "aud_approval", ReadOnly, "list"),
|
||||
resourceContract("audit", "audit_operation_log", ReadOnly, "list"), resourceContract("audit", "audit_export_log", ReadOnly, "list"), resourceContract("audit", "audit_approval", ReadOnly, "list"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ func resourcePath(domain, name string) string {
|
||||
return "/user/address"
|
||||
case "user_service_relation":
|
||||
return "/user/service_relation"
|
||||
case "saf_event_disposal":
|
||||
return "/safety/saf_event/:identity/disposals"
|
||||
case "safe_event_disposal":
|
||||
return "/safety/safe_event/:identity/disposals"
|
||||
default:
|
||||
return "/" + domain + "/" + name
|
||||
}
|
||||
|
||||
@@ -25,12 +25,31 @@ import (
|
||||
|
||||
func TestExpectedResources(t *testing.T) {
|
||||
assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
|
||||
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list")
|
||||
}
|
||||
|
||||
func TestSafeAndAuditResourceContracts(t *testing.T) {
|
||||
legacySafetyPrefix := "sa" + "f_"
|
||||
legacyAuditPrefix := "au" + "d_"
|
||||
|
||||
assertContract(t, ExpectedResources(), "safety", "safe_rule", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "safety", "safe_event", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "safety", "safe_inspection", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "safety", "safe_event_disposal", AppendOnly, "list")
|
||||
assertContract(t, ExpectedResources(), "audit", "audit_operation_log", ReadOnly, "list")
|
||||
assertContract(t, ExpectedResources(), "audit", "audit_export_log", ReadOnly, "list")
|
||||
assertContract(t, ExpectedResources(), "audit", "audit_approval", ReadOnly, "list")
|
||||
|
||||
for _, contract := range ExpectedResources() {
|
||||
if (contract.Domain == "safety" && strings.HasPrefix(contract.Name, legacySafetyPrefix)) ||
|
||||
(contract.Domain == "audit" && strings.HasPrefix(contract.Name, legacyAuditPrefix)) {
|
||||
t.Fatalf("legacy resource contract %s/%s must not be registered", contract.Domain, contract.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) {
|
||||
if (ResourceDefinition{Mode: ReadOnly}).Allows(http.MethodPost) {
|
||||
t.Fatal("readonly allows POST")
|
||||
@@ -234,8 +253,8 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
|
||||
|
||||
func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
|
||||
t.Run("safety rule", func(t *testing.T) {
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
|
||||
values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/safe_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
|
||||
values, err := prepareResourceValues(ctx, &models.SafeRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -268,8 +287,8 @@ func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("invalid json string", func(t *testing.T) {
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
|
||||
if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/safe_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
|
||||
if _, err := prepareResourceValues(ctx, &models.SafeRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
|
||||
t.Fatal("invalid JSON string was accepted for a string/jsonb field")
|
||||
}
|
||||
})
|
||||
@@ -325,7 +344,7 @@ func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) {
|
||||
model any
|
||||
want []string
|
||||
}{
|
||||
{"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}},
|
||||
{"safety rule excludes jsonb", &models.SafeRule{}, []string{"rule_code", "action"}},
|
||||
{"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}},
|
||||
{"user address has no searchable safe text", &models.UserAddress{}, []string{}},
|
||||
}
|
||||
@@ -632,19 +651,19 @@ func TestNonRootCannotAssignPlatformAccountRole(t *testing.T) {
|
||||
|
||||
func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "safe_event_disposal" WHERE safe_event_identity = $1`)).
|
||||
WithArgs("event-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event_disposal" WHERE saf_event_identity = $1 ORDER BY created_at asc LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "safe_event_disposal" WHERE safe_event_identity = $1 ORDER BY created_at asc LIMIT $2`)).
|
||||
WithArgs("event-a", 20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "saf_event_identity", "action", "reason", "operator_identity"}).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "safe_event_identity", "action", "reason", "operator_identity"}).
|
||||
AddRow(uint64(9), "disposal-a", nil, nil, "enabled", 1, "event-a", "close", "resolved", "operator-a"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/safety/saf_event/event-a/disposals", "event-a", nil)
|
||||
ctx, recorder := updateContext(http.MethodGet, "/safety/safe_event/event-a/disposals", "event-a", nil)
|
||||
ListSafetyEventDisposals(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
if !strings.Contains(recorder.Body.String(), `"saf_event_identity":"event-a"`) || strings.Contains(recorder.Body.String(), `"id":`) {
|
||||
if !strings.Contains(recorder.Body.String(), `"safe_event_identity":"event-a"`) || strings.Contains(recorder.Body.String(), `"id":`) {
|
||||
t.Fatalf("disposal history did not keep the event identity-only shape: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
@@ -799,19 +818,19 @@ func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event" WHERE identity = $1 ORDER BY "saf_event"."id" LIMIT $2`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "safe_event" WHERE identity = $1 ORDER BY "safe_event"."id" LIMIT $2`)).
|
||||
WithArgs("event-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}).
|
||||
AddRow(uint64(3), "event-a", now, now, "open", 1, "E-1", 2, "alarm", "valve-a", nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "saf_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)).
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "safe_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)).
|
||||
WithArgs("disposed", sqlmock.AnyArg(), "event-a").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "saf_event_disposal" ("identity","created_at","updated_at","status","version","saf_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)).
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "safe_event_disposal" ("identity","created_at","updated_at","status","version","safe_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "event-a", "close", "resolved", "operator-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/safety/saf_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`))
|
||||
ctx, recorder := updateContext(http.MethodPost, "/safety/safe_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
DisposeSafetyEvent(ctx)
|
||||
|
||||
|
||||
@@ -565,8 +565,8 @@ func stripInternalIDs(value any) any {
|
||||
// action record. Disposal records deliberately have no update or delete route.
|
||||
func ListSafetyEventDisposals(ctx *gin.Context) {
|
||||
page, size := pageSize(ctx)
|
||||
var list []models.SafEventDisposal
|
||||
query := impl.DBService.Model(&models.SafEventDisposal{}).Where("saf_event_identity = ?", ctx.Param("identity"))
|
||||
var list []models.SafeEventDisposal
|
||||
query := impl.DBService.Model(&models.SafeEventDisposal{}).Where("safe_event_identity = ?", ctx.Param("identity"))
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
@@ -597,23 +597,23 @@ func DisposeSafetyEvent(ctx *gin.Context) {
|
||||
if request.Status == "" {
|
||||
request.Status = "disposed"
|
||||
}
|
||||
var disposal models.SafEventDisposal
|
||||
var disposal models.SafeEventDisposal
|
||||
err = impl.DBService.Transaction(func(transaction *gorm.DB) error {
|
||||
var event models.SafEvent
|
||||
var event models.SafeEvent
|
||||
if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&event).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if result := transaction.Model(&models.SafEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil {
|
||||
if result := transaction.Model(&models.SafeEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil {
|
||||
return result.Error
|
||||
} else if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
disposal = models.SafEventDisposal{
|
||||
Entity: newEntity("enabled"),
|
||||
SafEventIdentity: event.Identity,
|
||||
Action: request.Action,
|
||||
Reason: request.Reason,
|
||||
OperatorIdentity: claims.Identity,
|
||||
disposal = models.SafeEventDisposal{
|
||||
Entity: newEntity("enabled"),
|
||||
SafeEventIdentity: event.Identity,
|
||||
Action: request.Action,
|
||||
Reason: request.Reason,
|
||||
OperatorIdentity: claims.Identity,
|
||||
}
|
||||
return transaction.Create(&disposal).Error
|
||||
})
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/database"
|
||||
)
|
||||
|
||||
// AudApproval 对应 aud_approval,保存审批流与复核意见。
|
||||
type AudApproval struct {
|
||||
// AuditApproval 对应 audit_approval,保存审批流与复核意见。
|
||||
type AuditApproval struct {
|
||||
Entity // 公共实体字段
|
||||
BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` // business_type 业务字段
|
||||
BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` // business_identity 业务字段
|
||||
@@ -17,5 +17,5 @@ type AudApproval struct {
|
||||
HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"` // handled_at 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&AudApproval{}) }
|
||||
func (table *AudApproval) TableName() string { return "aud_approval" }
|
||||
func init() { database.AppendMigrate(&AuditApproval{}) }
|
||||
func (table *AuditApproval) TableName() string { return "audit_approval" }
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AudExportLog 对应 aud_export_log,保存敏感导出审计。
|
||||
type AudExportLog struct {
|
||||
// AuditExportLog 对应 audit_export_log,保存敏感导出审计。
|
||||
type AuditExportLog struct {
|
||||
Entity // 公共实体字段
|
||||
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` // applicant_identity 业务字段
|
||||
Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"` // purpose 业务字段
|
||||
@@ -15,5 +15,5 @@ type AudExportLog struct {
|
||||
FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"` // file_uri 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&AudExportLog{}) }
|
||||
func (table *AudExportLog) TableName() string { return "aud_export_log" }
|
||||
func init() { database.AppendMigrate(&AuditExportLog{}) }
|
||||
func (table *AuditExportLog) TableName() string { return "audit_export_log" }
|
||||
@@ -2,8 +2,8 @@ package models
|
||||
|
||||
import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// AudOperationLog 对应 aud_operation_log,保存不可变操作审计。
|
||||
type AudOperationLog struct {
|
||||
// AuditOperationLog 对应 audit_operation_log,保存不可变操作审计。
|
||||
type AuditOperationLog struct {
|
||||
Entity // 公共实体字段
|
||||
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` // operator_identity 业务字段
|
||||
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
|
||||
@@ -13,5 +13,5 @@ type AudOperationLog struct {
|
||||
AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"` // after_data 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&AudOperationLog{}) }
|
||||
func (table *AudOperationLog) TableName() string { return "aud_operation_log" }
|
||||
func init() { database.AppendMigrate(&AuditOperationLog{}) }
|
||||
func (table *AuditOperationLog) TableName() string { return "audit_operation_log" }
|
||||
@@ -26,7 +26,7 @@ func GetDashboardOverview() (DashboardOverview, error) {
|
||||
if err := impl.DBService.Model(&UserAccount{}).Where("status = ?", "enabled").Count(&overview.UserCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
if err := impl.DBService.Model(&SafEvent{}).Where("status = ?", "pending").Count(&overview.PendingSafetyCount).Error; err != nil {
|
||||
if err := impl.DBService.Model(&SafeEvent{}).Where("status = ?", "pending").Count(&overview.PendingSafetyCount).Error; err != nil {
|
||||
return DashboardOverview{}, err
|
||||
}
|
||||
return overview, nil
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package models
|
||||
|
||||
import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// SafEventDisposal 对应 saf_event_disposal,保存安全处置记录。
|
||||
type SafEventDisposal struct {
|
||||
Entity // 公共实体字段
|
||||
SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"` // saf_event_identity 业务字段
|
||||
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
|
||||
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // reason 业务字段
|
||||
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // operator_identity 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&SafEventDisposal{}) }
|
||||
func (table *SafEventDisposal) TableName() string { return "saf_event_disposal" }
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SafEvent 对应 saf_event,保存安全事件统一入口。
|
||||
type SafEvent struct {
|
||||
// SafeEvent 对应 safe_event,保存安全事件统一入口。
|
||||
type SafeEvent struct {
|
||||
Entity // 公共实体字段
|
||||
EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"` // event_code 业务字段
|
||||
Level int `gorm:"column:level;not null;default:3" json:"level"` // level 业务字段
|
||||
@@ -15,5 +15,5 @@ type SafEvent struct {
|
||||
SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"` // sla_at 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&SafEvent{}) }
|
||||
func (table *SafEvent) TableName() string { return "saf_event" }
|
||||
func init() { database.AppendMigrate(&SafeEvent{}) }
|
||||
func (table *SafeEvent) TableName() string { return "safe_event" }
|
||||
15
backend/api/internal/models/safe_event_disposal.go
Normal file
15
backend/api/internal/models/safe_event_disposal.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// SafeEventDisposal 对应 safe_event_disposal,保存安全处置记录。
|
||||
type SafeEventDisposal struct {
|
||||
Entity // 公共实体字段
|
||||
SafeEventIdentity string `gorm:"column:safe_event_identity;type:varchar(36);not null;index" json:"safe_event_identity"` // safe_event_identity 业务字段
|
||||
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
|
||||
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // reason 业务字段
|
||||
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // operator_identity 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&SafeEventDisposal{}) }
|
||||
func (table *SafeEventDisposal) TableName() string { return "safe_event_disposal" }
|
||||
@@ -2,8 +2,8 @@ package models
|
||||
|
||||
import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// SafInspection 对应 saf_inspection,保存安检与复检记录。
|
||||
type SafInspection struct {
|
||||
// SafeInspection 对应 safe_inspection,保存安检与复检记录。
|
||||
type SafeInspection struct {
|
||||
Entity // 公共实体字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // staff_account_id 业务字段
|
||||
@@ -11,5 +11,5 @@ type SafInspection struct {
|
||||
EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"` // evidence_uri 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&SafInspection{}) }
|
||||
func (table *SafInspection) TableName() string { return "saf_inspection" }
|
||||
func init() { database.AppendMigrate(&SafeInspection{}) }
|
||||
func (table *SafeInspection) TableName() string { return "safe_inspection" }
|
||||
@@ -2,8 +2,8 @@ package models
|
||||
|
||||
import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// SafRule 对应 saf_rule,保存安全规则。
|
||||
type SafRule struct {
|
||||
// SafeRule 对应 safe_rule,保存安全规则。
|
||||
type SafeRule struct {
|
||||
Entity // 公共实体字段
|
||||
RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"` // rule_code 业务字段
|
||||
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` // version_no 业务字段
|
||||
@@ -12,5 +12,5 @@ type SafRule struct {
|
||||
GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"` // gray_scope 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&SafRule{}) }
|
||||
func (table *SafRule) TableName() string { return "saf_rule" }
|
||||
func init() { database.AppendMigrate(&SafeRule{}) }
|
||||
func (table *SafeRule) TableName() string { return "safe_rule" }
|
||||
@@ -62,11 +62,11 @@ func registerDeviceRoute(group *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func registerSafetyRoute(group *gin.RouterGroup) {
|
||||
registerRestrictedWritableResource(group, "/safety/saf_rule", &models.SafRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"})
|
||||
registerRestrictedWritableResource(group, "/safety/saf_event", &models.SafEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"})
|
||||
registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}))
|
||||
group.GET("/safety/saf_event/:identity/disposals", platform.ListSafetyEventDisposals)
|
||||
group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent)
|
||||
registerRestrictedWritableResource(group, "/safety/safe_rule", &models.SafeRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"})
|
||||
registerRestrictedWritableResource(group, "/safety/safe_event", &models.SafeEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"})
|
||||
registerRestrictedWritableResource(group, "/safety/safe_inspection", &models.SafeInspection{}, []string{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}))
|
||||
group.GET("/safety/safe_event/:identity/disposals", platform.ListSafetyEventDisposals)
|
||||
group.POST("/safety/safe_event/:identity/disposals", platform.DisposeSafetyEvent)
|
||||
}
|
||||
|
||||
func registerCommerceRoute(group *gin.RouterGroup) {
|
||||
@@ -138,10 +138,10 @@ func registerContentRoute(group *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func registerAuditRoute(group *gin.RouterGroup) {
|
||||
registerReadOnlyResource(group, "/audit/aud_operation_log", &models.AudOperationLog{})
|
||||
registerReadOnlyResource(group, "/audit/aud_export_log", &models.AudExportLog{})
|
||||
registerReadOnlyResource(group, "/audit/aud_approval", &models.AudApproval{})
|
||||
group.POST("/audit/aud_approval/:identity/approve", platform.ApproveAudit)
|
||||
registerReadOnlyResource(group, "/audit/audit_operation_log", &models.AuditOperationLog{})
|
||||
registerReadOnlyResource(group, "/audit/audit_export_log", &models.AuditExportLog{})
|
||||
registerReadOnlyResource(group, "/audit/audit_approval", &models.AuditApproval{})
|
||||
group.POST("/audit/audit_approval/:identity/approve", platform.ApproveAudit)
|
||||
}
|
||||
|
||||
func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) {
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te
|
||||
|
||||
for _, resource := range []string{
|
||||
"/device/dev_smart_cylinder_valve", "/device/dev_device_binding",
|
||||
"/safety/saf_rule", "/safety/saf_event", "/safety/saf_inspection",
|
||||
"/safety/safe_rule", "/safety/safe_event", "/safety/safe_inspection",
|
||||
"/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review",
|
||||
"/delivery/delivery_task", "/delivery/delivery_track",
|
||||
} {
|
||||
@@ -126,11 +126,20 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te
|
||||
}
|
||||
}
|
||||
|
||||
disposal := "/heqi/platform/v1/safety/saf_event/:identity/disposals"
|
||||
assertRouteMethods(t, routes, disposal, http.MethodPost)
|
||||
disposal := "/heqi/platform/v1/safety/safe_event/:identity/disposals"
|
||||
assertRouteMethods(t, routes, disposal, http.MethodGet, http.MethodPost)
|
||||
if routes[disposal][http.MethodDelete] {
|
||||
t.Fatal("safety event disposals must be append-only")
|
||||
}
|
||||
|
||||
legacySafetyPrefix := "/safety/" + "sa" + "f_"
|
||||
for _, resource := range []string{legacySafetyPrefix + "rule", legacySafetyPrefix + "event", legacySafetyPrefix + "inspection"} {
|
||||
path := "/heqi/platform/v1" + resource
|
||||
assertNoRouteMethods(t, routes, path, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
assertNoRouteMethods(t, routes, path+"/:identity/status", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
}
|
||||
assertNoRouteMethods(t, routes, "/heqi/platform/v1"+legacySafetyPrefix+"event/:identity/disposals", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
}
|
||||
|
||||
func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T) {
|
||||
@@ -157,7 +166,7 @@ func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T)
|
||||
for _, resource := range []string{
|
||||
"/wallet/wallet", "/wallet/wallet_ledger", "/wallet/wallet_recharge", "/wallet/wallet_withdrawal",
|
||||
"/report/report", "/report/report_item", "/report/report_metric_snapshot",
|
||||
"/audit/aud_operation_log", "/audit/aud_export_log", "/audit/aud_approval",
|
||||
"/audit/audit_operation_log", "/audit/audit_export_log", "/audit/audit_approval",
|
||||
} {
|
||||
path := "/heqi/platform/v1" + resource
|
||||
assertRouteMethods(t, routes, path, http.MethodGet)
|
||||
@@ -169,7 +178,15 @@ func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
assertRouteMethods(t, routes, "/heqi/platform/v1/audit/aud_approval/:identity/approve", http.MethodPost)
|
||||
assertRouteMethods(t, routes, "/heqi/platform/v1/audit/audit_approval/:identity/approve", http.MethodPost)
|
||||
|
||||
legacyAuditPrefix := "/audit/" + "au" + "d_"
|
||||
for _, resource := range []string{legacyAuditPrefix + "operation_log", legacyAuditPrefix + "export_log", legacyAuditPrefix + "approval"} {
|
||||
path := "/heqi/platform/v1" + resource
|
||||
assertNoRouteMethods(t, routes, path, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
}
|
||||
assertNoRouteMethods(t, routes, "/heqi/platform/v1"+legacyAuditPrefix+"approval/:identity/approve", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
}
|
||||
|
||||
func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。终审后两轮复核又关闭 12 项 Important:JSONB 字符串绑定、日期 RFC3339 边界、编辑密码语义、创建响应与关键字查询的显式安全白名单、服务端角色菜单授权、平台账户非 root 角色约束、默认 PII/坐标响应投影、角色菜单分配 UI,以及管理权限升级、证件附件响应、空菜单撤权和缺失菜单种子。
|
||||
|
||||
## 安全与审计前缀直接切换
|
||||
|
||||
安全和审计资源已由历史短前缀直接切换为 `safe_*` 与 `audit_*`。模型导出、GORM 表名、迁移注册、后端资源契约、受保护 API 路径、前端资源声明、菜单路由和页面目录使用同一组新名称。安全事件处置关联字段及 JSON 键统一为 `safe_event_identity`,审批动作统一使用 `/audit/audit_approval/:identity/approve`。
|
||||
|
||||
本次为非兼容的直接切换:不迁移历史表或历史数据,不注册旧 API、资源、前端路由或类型兼容别名。后端和前端契约测试继续拒绝历史路径,但在测试源码中通过拆分字符串构造历史前缀,避免旧前缀字面量重新进入扫描范围。
|
||||
|
||||
## 发现与处置
|
||||
|
||||
| 发现 | 处置 | 验证 |
|
||||
@@ -14,7 +20,7 @@
|
||||
| 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` |
|
||||
| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 |
|
||||
| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` |
|
||||
| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `saf_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST,且按事件标识查询处置历史;只读资源扫描 API、路由和页面中的 `updateStatus` | 6 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 |
|
||||
| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `safe_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST,且按事件标识查询处置历史;只读资源扫描 API、路由和页面中的 `updateStatus` | 7 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 |
|
||||
| 创建接口可能回传内部关系 ID、密码散列、地址或其他未枚举字段 | 创建成功统一经过公共身份解析和显式安全白名单,只保留 `identity`、关联 `*_identity`、状态、版本及时间元数据;地址和其他业务字段默认不返回 | `TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting`、`TestCreatedResourceResponseUsesSafeAllowlist` |
|
||||
| 轨迹点资源可写,且普通列表/详情可能泄露精确经纬度 | `delivery_track_point` 改为只读契约;仅 JWT 明确声明 `location_scope=precise` 时返回精确坐标,其他响应移除坐标 | `TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope`、`TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope` |
|
||||
| 受保护平台路由仅验证 JWT,非 root 账号可能访问未分配业务域,菜单接口也返回全部菜单 | JWT 后增加服务端角色菜单授权;root 放行全部业务域,非 root 仅可访问启用角色已分配菜单对应的业务域;profile 返回真实角色和菜单码,前端路由不再使用 `roles: ['*']` | `TestPlatformMenuAllowsOnlyAssignedDomain`、`TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole`、`final-important.test.mjs` 的路由权限用例 |
|
||||
@@ -67,7 +73,7 @@ pnpm build
|
||||
| --- | --- | --- |
|
||||
| `go test ./...` | 0 | 通过 |
|
||||
| `go build ./cmd/main` | 0 | 通过 |
|
||||
| `node --test frontend/platform_admin/scripts/*.test.mjs` | 0 | 通过,15 个静态审计与终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 |
|
||||
| `node --test frontend/platform_admin/scripts/*.test.mjs` | 0 | 通过,16 个静态审计与终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 |
|
||||
| `pnpm lint` | 0 | 通过;Biome 检查 169 个文件,无错误,保留 190 个非阻断 warning 和 12 个 info |
|
||||
| `pnpm type:check` | 0 | 通过 |
|
||||
| `pnpm audit:platform` | 0 | 通过 |
|
||||
|
||||
@@ -74,8 +74,8 @@ export function auditPlatform({ manifest, resources, readOnlyPage, routeSources,
|
||||
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`);
|
||||
for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`);
|
||||
if (contract.mode === 'append_only') {
|
||||
const event = resources.find((item) => item.name === 'saf_event');
|
||||
if (!event?.detailActions?.some((action) => action.name === contract.name && action.resource === contract.path)) failures.push(`${label}: missing saf_event detail action`);
|
||||
const event = resources.find((item) => item.name === 'safe_event');
|
||||
if (!event?.detailActions?.some((action) => action.name === contract.name && action.resource === contract.path)) failures.push(`${label}: missing safe_event detail action`);
|
||||
if ([...viewSources.keys()].some((file) => file.includes(`/${contract.name}/`))) failures.push(`${label}: independent page exposed`);
|
||||
} else {
|
||||
const coverage = routeCoverage(contract, routeSources, viewSources);
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs';
|
||||
|
||||
const resourcesSource = readFileSync('src/api/resources.ts', 'utf8');
|
||||
const legacySafetyPrefix = ['sa', 'f_'].join('');
|
||||
const legacyAuditPrefix = ['au', 'd_'].join('');
|
||||
|
||||
test('资源定义使用 safe 和 audit 前缀', () => {
|
||||
assert.match(resourcesSource, /define\(\s*'safe_rule',\s*'\/safety\/safe_rule'/);
|
||||
assert.match(resourcesSource, /define\(\s*'safe_event',\s*'\/safety\/safe_event'/);
|
||||
assert.match(resourcesSource, /define\(\s*'safe_inspection',\s*'\/safety\/safe_inspection'/);
|
||||
assert.match(resourcesSource, /define\(\s*'safe_event_disposal',\s*'\/safety\/safe_event\/:identity\/disposals'/);
|
||||
assert.match(resourcesSource, /define\(\s*'audit_operation_log',\s*'\/audit\/audit_operation_log'/);
|
||||
assert.match(resourcesSource, /define\(\s*'audit_export_log',\s*'\/audit\/audit_export_log'/);
|
||||
assert.match(resourcesSource, /define\(\s*'audit_approval',\s*'\/audit\/audit_approval'/);
|
||||
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacySafetyPrefix}(?:rule|event|inspection|event_disposal)',`));
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`action\\('${legacySafetyPrefix}event_disposal',`));
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacyAuditPrefix}(?:operation_log|export_log|approval)',`));
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`/audit/${legacyAuditPrefix}approval/:identity/approve`));
|
||||
});
|
||||
|
||||
test('只读页面将状态变更视为违规写操作', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [], routes: [] },
|
||||
@@ -61,20 +81,20 @@ test('每个资源必须由带菜单元数据的路由实际加载对应页面',
|
||||
|
||||
test('仅追加处置必须挂在安全事件详情动作且不得有独立页面', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [{ domain: 'safety', name: 'saf_event_disposal', path: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list' }], routes: [
|
||||
{ method: 'GET', path: '/safety/saf_event/:identity/disposals' },
|
||||
{ method: 'POST', path: '/safety/saf_event/:identity/disposals' },
|
||||
manifest: { resources: [{ domain: 'safety', name: 'safe_event_disposal', path: '/safety/safe_event/:identity/disposals', mode: 'append_only', pageKind: 'list' }], routes: [
|
||||
{ method: 'GET', path: '/safety/safe_event/:identity/disposals' },
|
||||
{ method: 'POST', path: '/safety/safe_event/:identity/disposals' },
|
||||
] },
|
||||
resources: [{ name: 'saf_event_disposal', resource: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list', title: '事件处置', fields: [{ key: 'action', label: '处置动作' }] }],
|
||||
resources: [{ name: 'safe_event_disposal', resource: '/safety/safe_event/:identity/disposals', mode: 'append_only', pageKind: 'list', title: '事件处置', fields: [{ key: 'action', label: '处置动作' }] }],
|
||||
readOnlyPage: '',
|
||||
routeSources: [],
|
||||
viewSources: new Map([['src/views/safety/saf_event_disposal/ListPage.vue', '<template />']]),
|
||||
viewSources: new Map([['src/views/safety/safe_event_disposal/ListPage.vue', '<template />']]),
|
||||
apiSources: new Map(),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, [
|
||||
'safety/saf_event_disposal: missing saf_event detail action',
|
||||
'safety/saf_event_disposal: independent page exposed',
|
||||
'safety/safe_event_disposal: missing safe_event detail action',
|
||||
'safety/safe_event_disposal: independent page exposed',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提
|
||||
const fields = (name) => resources.find((item) => item.name === name).fields;
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('saf_rule'), {
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('safe_rule'), {
|
||||
rule_code: 'pressure-limit',
|
||||
threshold: '{"max":10}',
|
||||
action: 'close-valve',
|
||||
@@ -134,7 +134,7 @@ test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => {
|
||||
test('审批只读页提供同意和驳回操作', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8');
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
assert.match(resources, /aud_approval[\s\S]*\/audit\/aud_approval\/:identity\/approve/);
|
||||
assert.match(resources, /audit_approval[\s\S]*\/audit\/audit_approval\/:identity\/approve/);
|
||||
assert.match(source, /submitDetailAction/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { platformAPI, type DashboardOverview, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type PlatfromAccount, type Profile, type SafEvent } from './api/platform';
|
||||
import { platformAPI, type DashboardOverview, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type PlatfromAccount, type Profile, type SafeEvent } from './api/platform';
|
||||
|
||||
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety' | 'trade' | 'finance' | 'content' | 'track' | 'operation' | 'audit';
|
||||
type CatalogItem = { title: string; description: string; operations: string[] };
|
||||
@@ -14,7 +14,7 @@ const stations = ref<OrgGasStation[]>([]);
|
||||
const deliveryPoints = ref<OrgDeliveryPoint[]>([]);
|
||||
const servicePeople = ref<OrgServicePerson[]>([]);
|
||||
const users = ref<PlatfromAccount[]>([]);
|
||||
const safetyEvents = ref<SafEvent[]>([]);
|
||||
const safetyEvents = ref<SafeEvent[]>([]);
|
||||
const profile = ref<Profile>();
|
||||
const loginForm = reactive({ username: 'root', password: '' });
|
||||
const stationForm = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
|
||||
@@ -48,7 +48,7 @@ async function loadData() {
|
||||
try {
|
||||
[profile.value, overview.value, stations.value, deliveryPoints.value, servicePeople.value, users.value, safetyEvents.value] = await Promise.all([
|
||||
platformAPI.getProfile(), platformAPI.getDashboard(), platformAPI.listOrgGasStation(), platformAPI.listOrgDeliveryPoint(),
|
||||
platformAPI.listOrgServicePerson(), platformAPI.listPlatfromAccount(), platformAPI.listSafEvent(),
|
||||
platformAPI.listOrgServicePerson(), platformAPI.listPlatfromAccount(), platformAPI.listSafeEvent(),
|
||||
]);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '加载数据失败';
|
||||
|
||||
@@ -222,10 +222,10 @@ const titles: Record<string, string> = {
|
||||
dev_smart_cylinder_valve: '智能钢瓶阀',
|
||||
dev_device_binding: '设备绑定',
|
||||
dev_telemetry: '设备遥测',
|
||||
saf_rule: '安全规则',
|
||||
saf_event: '安全事件',
|
||||
saf_inspection: '安全检查',
|
||||
saf_event_disposal: '事件处置',
|
||||
safe_rule: '安全规则',
|
||||
safe_event: '安全事件',
|
||||
safe_inspection: '安全检查',
|
||||
safe_event_disposal: '事件处置',
|
||||
ec_category: '商品分类',
|
||||
ec_product: '商品管理',
|
||||
ec_product_attribute: '商品属性',
|
||||
@@ -250,9 +250,9 @@ const titles: Record<string, string> = {
|
||||
report: '报表',
|
||||
report_item: '报表项目',
|
||||
report_metric_snapshot: '指标快照',
|
||||
aud_operation_log: '操作审计',
|
||||
aud_export_log: '导出审计',
|
||||
aud_approval: '审批审计',
|
||||
audit_operation_log: '操作审计',
|
||||
audit_export_log: '导出审计',
|
||||
audit_approval: '审批审计',
|
||||
};
|
||||
const define = (
|
||||
name: string,
|
||||
@@ -412,7 +412,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'reported_at',
|
||||
'payload',
|
||||
]),
|
||||
define('saf_rule', '/safety/saf_rule', 'writable', 'list', [
|
||||
define('safe_rule', '/safety/safe_rule', 'writable', 'list', [
|
||||
'rule_code!',
|
||||
'version_no',
|
||||
'threshold',
|
||||
@@ -420,8 +420,8 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'gray_scope',
|
||||
]),
|
||||
define(
|
||||
'saf_event',
|
||||
'/safety/saf_event',
|
||||
'safe_event',
|
||||
'/safety/safe_event',
|
||||
'writable',
|
||||
'list',
|
||||
[
|
||||
@@ -432,21 +432,21 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'sla_at',
|
||||
],
|
||||
[
|
||||
action('saf_event_disposal', '/safety/saf_event/:identity/disposals', [
|
||||
action('safe_event_disposal', '/safety/safe_event/:identity/disposals', [
|
||||
'action!',
|
||||
'reason!',
|
||||
]),
|
||||
],
|
||||
),
|
||||
define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', [
|
||||
define('safe_inspection', '/safety/safe_inspection', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'staff_account_identity!',
|
||||
'result!',
|
||||
'evidence_uri',
|
||||
]),
|
||||
define(
|
||||
'saf_event_disposal',
|
||||
'/safety/saf_event/:identity/disposals',
|
||||
'safe_event_disposal',
|
||||
'/safety/safe_event/:identity/disposals',
|
||||
'append_only',
|
||||
'list',
|
||||
['action!', 'reason!'],
|
||||
@@ -612,13 +612,13 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'list',
|
||||
['metric_code', 'scope_type', 'stat_at', 'metric_value'],
|
||||
),
|
||||
define('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', [
|
||||
define('audit_operation_log', '/audit/audit_operation_log', 'readonly', 'list', [
|
||||
'operator_identity',
|
||||
'action',
|
||||
'object_identity',
|
||||
'created_at',
|
||||
]),
|
||||
define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', [
|
||||
define('audit_export_log', '/audit/audit_export_log', 'readonly', 'list', [
|
||||
'applicant_identity',
|
||||
'purpose',
|
||||
'field_scope',
|
||||
@@ -626,8 +626,8 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'file_uri',
|
||||
]),
|
||||
define(
|
||||
'aud_approval',
|
||||
'/audit/aud_approval',
|
||||
'audit_approval',
|
||||
'/audit/audit_approval',
|
||||
'readonly',
|
||||
'list',
|
||||
[
|
||||
@@ -640,10 +640,10 @@ export const resources: ResourceUiDefinition[] = [
|
||||
'handled_at',
|
||||
],
|
||||
[
|
||||
action('同意', '/audit/aud_approval/:identity/approve', ['opinion'], {
|
||||
action('同意', '/audit/audit_approval/:identity/approve', ['opinion'], {
|
||||
status: 'approved',
|
||||
}),
|
||||
action('驳回', '/audit/aud_approval/:identity/approve', ['opinion!'], {
|
||||
action('驳回', '/audit/audit_approval/:identity/approve', ['opinion!'], {
|
||||
status: 'rejected',
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/audit', name: 'audit', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.audit', requiresAuth: true, icon: 'icon-apps', order: 22 },
|
||||
children: [
|
||||
{ path: 'aud-operation-log', name: 'audit-aud-operation-log', component: () => import('@/views/audit/aud_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_operation_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'aud-export-log', name: 'audit-aud-export-log', component: () => import('@/views/audit/aud_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_export_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'aud-approval', name: 'audit-aud-approval', component: () => import('@/views/audit/aud_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_approval', requiresAuth: true, menuCode: 'audit' } }
|
||||
{ path: 'audit-operation-log', name: 'audit-audit-operation-log', component: () => import('@/views/audit/audit_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_operation_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'audit-export-log', name: 'audit-audit-export-log', component: () => import('@/views/audit/audit_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_export_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'audit-approval', name: 'audit-audit-approval', component: () => import('@/views/audit/audit_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_approval', requiresAuth: true, menuCode: 'audit' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{ path: '/safety', name: 'safety', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.safety', requiresAuth: true, icon: 'icon-apps', order: 15 }, children: [
|
||||
{ path: 'saf-rule', name: 'safety-saf-rule', component: () => import('@/views/safety/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_rule', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'saf-event', name: 'safety-saf-event', component: () => import('@/views/safety/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_event', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'saf-inspection', name: 'safety-saf-inspection', component: () => import('@/views/safety/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_inspection', requiresAuth: true, menuCode: 'safety' } }
|
||||
{ path: 'safe-rule', name: 'safety-safe-rule', component: () => import('@/views/safety/safe_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.safe_rule', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'safe-event', name: 'safety-safe-event', component: () => import('@/views/safety/safe_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.safe_event', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'safe-inspection', name: 'safety-safe-inspection', component: () => import('@/views/safety/safe_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.safe_inspection', requiresAuth: true, menuCode: 'safety' } }
|
||||
] }];
|
||||
export default routes;
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/audit/aud_approval');
|
||||
const definition = getResource('/audit/audit_approval');
|
||||
</script>
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/audit/aud_export_log');
|
||||
const definition = getResource('/audit/audit_export_log');
|
||||
</script>
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/audit/aud_operation_log');
|
||||
const definition = getResource('/audit/audit_operation_log');
|
||||
</script>
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import CrudListPage from '@/views/shared/CrudListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/safety/saf_event');
|
||||
const definition = getResource('/safety/safe_event');
|
||||
</script>
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import CrudListPage from '@/views/shared/CrudListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/safety/saf_inspection');
|
||||
const definition = getResource('/safety/safe_inspection');
|
||||
</script>
|
||||
@@ -2,5 +2,5 @@
|
||||
<script setup lang="ts">
|
||||
import CrudListPage from '@/views/shared/CrudListPage.vue';
|
||||
import { getResource } from '@/api/resources';
|
||||
const definition = getResource('/safety/saf_rule');
|
||||
const definition = getResource('/safety/safe_rule');
|
||||
</script>
|
||||
Reference in New Issue
Block a user