diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md new file mode 100644 index 0000000..1d23035 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md @@ -0,0 +1,26 @@ +# Task 5 implementation report + +## RED / GREEN + +- RED: `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues)' -v` failed before implementation because Task 5 routes were absent and the approval update whitelist did not exist. +- GREEN: the same route and approval-contract tests pass. A transactional approval test confirms the approval update only persists status, opinion, handler identity, and handling time, then inserts an `aud_operation_log` in the same transaction. + +## Changes + +- Registered writable, restricted-field finance APIs (`fin_payment`, `fin_settlement`, `fin_reconciliation`) and content/customer-service APIs (`cnt_content`, `ntf_template`, `cs_ticket`). They retain the standard status patch and logical archive behavior. +- Registered wallet, report, and audit record resources as GET-only list/detail APIs. No generic write route is registered for those resources. +- Added `POST /audit/aud_approval/:identity/approve`. It derives the handler identity from the authenticated JWT, records the handling timestamp, limits the update to the four approval fields, and appends before/after audit data atomically. +- Extended `AudApproval` with persisted `handler_identity` and `handled_at` fields. +- Added route, approval/audit-transaction, and empty-dashboard-zero-value coverage. + +## Verification + +- `gofmt -w internal/models/aud_approval.go internal/routers/platform.go internal/routers/platform_test.go internal/logic/platform/audit.go internal/logic/platform/audit_test.go internal/logic/platform/health_test.go` — PASS +- `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues|ApproveAuditOnlyUpdates|DashboardOverviewReturnsZero)' -v` — PASS +- `go test ./...` — PASS +- `go build ./cmd/main` — PASS +- `git diff --check` — PASS + +## Concerns + +- Approval status values are accepted as non-empty strings to preserve the existing status model; a future workflow may want an explicit state-transition policy (for example, only `pending -> approved|rejected`). diff --git a/backend/api/internal/logic/platform/audit.go b/backend/api/internal/logic/platform/audit.go new file mode 100644 index 0000000..e2f4398 --- /dev/null +++ b/backend/api/internal/logic/platform/audit.go @@ -0,0 +1,90 @@ +package platform + +import ( + "encoding/json" + "errors" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func approvalValues(status, opinion, operatorIdentity string) map[string]any { + return map[string]any{ + "status": status, + "opinion": opinion, + "handler_identity": operatorIdentity, + "handled_at": time.Now().UTC(), + } +} + +// ApproveAudit records the reviewer and decision without allowing an approval +// to mutate any business fields. The operation audit is created atomically with +// the approval update. +func ApproveAudit(ctx *gin.Context) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var request struct { + Status string `json:"status" binding:"required,max=32"` + Opinion string `json:"opinion" binding:"max=2000"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + + values := approvalValues(request.Status, request.Opinion, claims.Identity) + var approval models.AudApproval + err = impl.DBService.Transaction(func(transaction *gorm.DB) error { + if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&approval).Error; err != nil { + return err + } + before, err := json.Marshal(gin.H{ + "status": approval.Status, "opinion": approval.Opinion, + "handler_identity": approval.HandlerIdentity, "handled_at": approval.HandledAt, + }) + if err != nil { + return err + } + if result := transaction.Model(&models.AudApproval{}).Where("identity = ?", approval.Identity).Updates(values); result.Error != nil { + return result.Error + } else if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + after, err := json.Marshal(values) + if err != nil { + return err + } + return transaction.Create(&models.AudOperationLog{ + Entity: newEntity("enabled"), + OperatorIdentity: claims.Identity, + Action: "approve", + ObjectType: "aud_approval", + ObjectIdentity: approval.Identity, + BeforeData: string(before), + AfterData: string(after), + }).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Error(ctx, err) + return + } + approval.Status = request.Status + approval.Opinion = request.Opinion + approval.HandlerIdentity = claims.Identity + handledAt := values["handled_at"].(time.Time) + approval.HandledAt = &handledAt + infra.Response.Success(ctx, resourceResponse(approval)) +} diff --git a/backend/api/internal/logic/platform/audit_test.go b/backend/api/internal/logic/platform/audit_test.go new file mode 100644 index 0000000..e18513c --- /dev/null +++ b/backend/api/internal/logic/platform/audit_test.go @@ -0,0 +1,61 @@ +package platform + +import ( + "database/sql/driver" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "git.apinb.com/bsm-sdk/core/types" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestApprovalValuesOnlyChangesApprovalFieldsAndRecordsOperator(t *testing.T) { + values := approvalValues("approved", "accepted", "operator-a") + + if values["status"] != "approved" || values["opinion"] != "accepted" || values["handler_identity"] != "operator-a" { + t.Fatalf("approval values do not retain the approved state, opinion, and operator: %#v", values) + } + if _, ok := values["handled_at"]; !ok { + t.Fatalf("approval values do not record handling time: %#v", values) + } + if len(values) != 4 { + t.Fatalf("approval update includes fields outside its whitelist: %#v", values) + } +} + +func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testing.T) { + _, 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`)). + 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`)). + WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a"). + 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"`)). + 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.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + ApproveAudit(ctx) + + assertMockExpectations(t, mock) + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"handler_identity":"operator-a"`) { + t.Fatalf("approval response omitted its handler: %s", recorder.Body.String()) + } +} + +type jsonContaining string + +func (expected jsonContaining) Match(value driver.Value) bool { + actual, ok := value.(string) + return ok && strings.Contains(actual, string(expected)) +} diff --git a/backend/api/internal/logic/platform/health_test.go b/backend/api/internal/logic/platform/health_test.go new file mode 100644 index 0000000..921198d --- /dev/null +++ b/backend/api/internal/logic/platform/health_test.go @@ -0,0 +1,31 @@ +package platform + +import ( + "regexp" + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestDashboardOverviewReturnsZeroValuesForAnEmptyDatabase(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + for _, query := range []string{ + `SELECT count\(\*\) FROM "gas_basic" WHERE status = \$1`, + `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`, + } { + mock.ExpectQuery(regexp.MustCompile(query).String()).WithArgs(sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + } + + overview, err := models.GetDashboardOverview() + if err != nil { + t.Fatal(err) + } + if overview != (models.DashboardOverview{}) { + t.Fatalf("empty dashboard = %#v, want zero values", overview) + } + assertMockExpectations(t, mock) +} diff --git a/backend/api/internal/models/aud_approval.go b/backend/api/internal/models/aud_approval.go index a028fc3..3129044 100644 --- a/backend/api/internal/models/aud_approval.go +++ b/backend/api/internal/models/aud_approval.go @@ -1,14 +1,20 @@ package models -import "git.apinb.com/bsm-sdk/core/database" +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) // AudApproval 对应 aud_approval,保存审批流与复核意见。 type AudApproval struct { Entity - BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` - BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` - ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` - Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` + BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` + BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` + ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` + Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` + HandlerIdentity string `gorm:"column:handler_identity;type:varchar(36);not null;default:'';index" json:"handler_identity"` + HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"` } func init() { database.AppendMigrate(&AudApproval{}) } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 89a5baf..f606e75 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -29,6 +29,9 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeviceRoute(protected) registerSafetyRoute(protected) registerCommerceRoute(protected) + registerFinanceRoute(protected) + registerContentRoute(protected) + registerAuditRoute(protected) registerPlatformRoute(protected) } @@ -101,6 +104,33 @@ func registerPlatformRoute(group *gin.RouterGroup) { registerWritableResource(group, "/platform/platform_menu", platform.ListPlatformMenu, platform.CreatePlatformMenu, platform.GetPlatformMenu, platform.UpdatePlatformMenu, &models.PlatformMenu{}) } +func registerFinanceRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/finance/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) + registerRestrictedWritableResource(group, "/finance/fin_settlement", &models.FinSettlement{}, []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}) + registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) + + registerReadOnlyResource(group, "/wallet/wallet", &models.Wallet{}) + registerReadOnlyResource(group, "/wallet/wallet_ledger", &models.WalletLedger{}) + registerReadOnlyResource(group, "/wallet/wallet_recharge", &models.WalletRecharge{}) + registerReadOnlyResource(group, "/wallet/wallet_withdrawal", &models.WalletWithdrawal{}) + registerReadOnlyResource(group, "/report/report", &models.Report{}) + registerReadOnlyResource(group, "/report/report_item", &models.ReportItem{}) + registerReadOnlyResource(group, "/report/report_metric_snapshot", &models.ReportMetricSnapshot{}) +} + +func registerContentRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/content/cnt_content", &models.CntContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"}) + registerRestrictedWritableResource(group, "/notification/ntf_template", &models.NtfTemplate{}, []string{"template_code", "channel", "content"}) + registerRestrictedWritableResource(group, "/customer_service/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) +} + +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) +} + func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) { resource := group.Group(path) resource.GET("", list) @@ -116,6 +146,13 @@ func registerRestrictedWritableResource(group *gin.RouterGroup, path string, mod registerWritableResource(group, path, list, create, get, update, model) } +func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) { + list, _, get, _ := platform.ResourceHandlers(model, nil, nil) + resource := group.Group(path) + resource.GET("", list) + resource.GET("/:identity", get) +} + func requiredRelation(input, column string, model any) platform.ResourceRelation { return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true} } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 134a5ff..22d096e 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -95,6 +95,45 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te } } +func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + + for _, resource := range []string{ + "/finance/fin_payment", "/finance/fin_settlement", "/finance/fin_reconciliation", + "/content/cnt_content", "/notification/ntf_template", "/customer_service/cs_ticket", + } { + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) + } + + 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", + } { + path := "/heqi/platform/v1" + resource + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + if routes[path][method] || routes[path+"/:identity"][method] { + t.Errorf("read-only resource %s unexpectedly permits %s", resource, method) + } + } + } + + assertRouteMethods(t, routes, "/heqi/platform/v1/audit/aud_approval/:identity/approve", http.MethodPost) +} + func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) { t.Helper() for _, method := range methods {