feat(platform): complete finance content audit APIs

This commit is contained in:
2026-07-27 03:18:41 +08:00
parent 08bc6b3ec4
commit dd23452e14
7 changed files with 295 additions and 5 deletions

View 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))
}

View 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))
}

View 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)
}