feat(platform): complete finance content audit APIs
This commit is contained in:
@@ -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`).
|
||||||
90
backend/api/internal/logic/platform/audit.go
Normal file
90
backend/api/internal/logic/platform/audit.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
61
backend/api/internal/logic/platform/audit_test.go
Normal file
61
backend/api/internal/logic/platform/audit_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
31
backend/api/internal/logic/platform/health_test.go
Normal file
31
backend/api/internal/logic/platform/health_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "git.apinb.com/bsm-sdk/core/database"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.apinb.com/bsm-sdk/core/database"
|
||||||
|
)
|
||||||
|
|
||||||
// AudApproval 对应 aud_approval,保存审批流与复核意见。
|
// AudApproval 对应 aud_approval,保存审批流与复核意见。
|
||||||
type AudApproval struct {
|
type AudApproval struct {
|
||||||
@@ -9,6 +13,8 @@ type AudApproval struct {
|
|||||||
BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"`
|
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"`
|
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"`
|
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{}) }
|
func init() { database.AppendMigrate(&AudApproval{}) }
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) {
|
|||||||
registerDeviceRoute(protected)
|
registerDeviceRoute(protected)
|
||||||
registerSafetyRoute(protected)
|
registerSafetyRoute(protected)
|
||||||
registerCommerceRoute(protected)
|
registerCommerceRoute(protected)
|
||||||
|
registerFinanceRoute(protected)
|
||||||
|
registerContentRoute(protected)
|
||||||
|
registerAuditRoute(protected)
|
||||||
registerPlatformRoute(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{})
|
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) {
|
func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) {
|
||||||
resource := group.Group(path)
|
resource := group.Group(path)
|
||||||
resource.GET("", list)
|
resource.GET("", list)
|
||||||
@@ -116,6 +146,13 @@ func registerRestrictedWritableResource(group *gin.RouterGroup, path string, mod
|
|||||||
registerWritableResource(group, path, list, create, get, update, model)
|
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 {
|
func requiredRelation(input, column string, model any) platform.ResourceRelation {
|
||||||
return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true}
|
return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, method := range methods {
|
for _, method := range methods {
|
||||||
|
|||||||
Reference in New Issue
Block a user