Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ var auditContentTemplates = map[string]string{
"user.topup_complete": "Completed top-up order ${tradeNo}",
"option.update": "Updated system setting ${key}",
"option.reset_ratio": "Reset model ratio settings",
"billing.reconciliation_blocking_policy_update": "Updated billing reconciliation default user-blocking policy to ${block_user_by_default}",
"billing.reconciliation_review": "Reviewed billing settlement ${settlement_id} (block user: ${block_user})",

"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
Expand Down
122 changes: 122 additions & 0 deletions controller/smart_ops_alert.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package controller

import (
"errors"
"fmt"
"net/http"
"strconv"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/MAX-API-Next/MAX-API/service"
"github.com/gin-gonic/gin"
)
Expand All @@ -15,3 +20,120 @@ func GetSmartOpsAlerts(c *gin.Context) {
"data": service.GetSmartOpsAlerts(),
})
}

type billingSettlementBlockingPolicyRequest struct {
BlockUserByDefault *bool `json:"block_user_by_default"`
}

func UpdateBillingSettlementBlockingPolicy(c *gin.Context) {
var request billingSettlementBlockingPolicyRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.BlockUserByDefault == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "block_user_by_default is required",
})
return
}
if err := service.UpdateBillingSettlementBlockingPolicy(*request.BlockUserByDefault); err != nil {
common.SysError(fmt.Sprintf("failed to update billing reconciliation blocking policy: %v", err))
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to update billing reconciliation blocking policy",
})
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
recordManageAudit(c, "billing.reconciliation_blocking_policy_update", map[string]interface{}{
"block_user_by_default": *request.BlockUserByDefault,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"block_user_by_default": *request.BlockUserByDefault,
},
})
}

type billingSettlementReviewRequest struct {
BlockUser *bool `json:"block_user"`
Note string `json:"note"`
}

func ReviewBillingSettlement(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid billing settlement id",
})
return
}
var request billingSettlementReviewRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid billing settlement review request",
})
return
}
record, err := service.ReviewBillingSettlement(id, c.GetInt("id"), request.BlockUser, request.Note)
if err != nil {
status := http.StatusInternalServerError
message := "failed to review billing settlement"
switch {
case errors.Is(err, service.ErrInvalidBillingSettlementReconciliationReview):
status = http.StatusBadRequest
message = err.Error()
case errors.Is(err, model.ErrBillingSettlementReviewConflict):
status = http.StatusConflict
message = "billing settlement is no longer pending manual reconciliation"
}
if status == http.StatusInternalServerError {
common.SysError(fmt.Sprintf("failed to review billing settlement %d: %v", id, err))
}
c.JSON(status, gin.H{"success": false, "message": message})
return
}
recordManageAudit(c, "billing.reconciliation_review", map[string]interface{}{
"settlement_id": record.ID,
"target_user_id": record.UserID,
"block_user": *request.BlockUser,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": record.ID,
"reconciliation_reviewed_at": record.ReconciliationReviewedAt,
"block_user": *request.BlockUser,
},
})
}

// GetBillingSettlementReconciliation exposes bounded, read-only evidence for
// unresolved positive final settlements. It does not retry or mutate records.
func GetBillingSettlementReconciliation(c *gin.Context) {
limit, err := parseIntQuery(c, "limit")
if err != nil {
respondInvalidPerformanceQuery(
c,
err,
service.ErrInvalidBillingSettlementReconciliationQuery,
"failed to query billing settlement reconciliation",
)
return
}
result, err := service.GetBillingSettlementReconciliation(limit)
if err != nil {
respondPerformanceError(
c,
err,
service.ErrInvalidBillingSettlementReconciliationQuery,
"failed to query billing settlement reconciliation",
)
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
116 changes: 116 additions & 0 deletions controller/smart_ops_alert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package controller

import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func TestBillingSettlementMutationRequestsPreserveExplicitFalse(t *testing.T) {
var policy billingSettlementBlockingPolicyRequest
require.NoError(t, common.Unmarshal([]byte(`{"block_user_by_default":false}`), &policy))
require.NotNil(t, policy.BlockUserByDefault)
assert.False(t, *policy.BlockUserByDefault)

var review billingSettlementReviewRequest
require.NoError(t, common.Unmarshal([]byte(`{"block_user":false,"note":"verified"}`), &review))
require.NotNil(t, review.BlockUser)
assert.False(t, *review.BlockUser)
}

func TestReviewBillingSettlementAuditRecordsActingAdministrator(t *testing.T) {
gin.SetMode(gin.TestMode)
oldDB := model.DB
oldLogDB := model.LOG_DB
oldRedisEnabled := common.RedisEnabled
oldMemoryCacheEnabled := common.MemoryCacheEnabled
common.RedisEnabled = false
common.MemoryCacheEnabled = false

dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.BillingSettlement{}, &model.Log{}))
model.DB = db
model.LOG_DB = db
t.Cleanup(func() {
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
model.DB = oldDB
model.LOG_DB = oldLogDB
common.RedisEnabled = oldRedisEnabled
common.MemoryCacheEnabled = oldMemoryCacheEnabled
})

administrator := model.User{
Id: 7101,
Username: "billing-review-admin",
AffCode: "billing-review-admin-aff",
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
Group: "default",
}
owner := model.User{
Id: 7102,
Username: "billing-review-owner",
AffCode: "billing-review-owner-aff",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
}
require.NoError(t, db.Create(&administrator).Error)
require.NoError(t, db.Create(&owner).Error)
settlement := model.BillingSettlement{
OperationKey: "request:controller-review-audit:finalize",
Source: model.BillingSettlementSourceWallet,
UserID: owner.Id,
FundingDelta: 10,
TokenDelta: 10,
Status: model.BillingSettlementStatusPending,
CreatedAt: 1,
UpdatedAt: 1,
Revision: 1,
}
require.NoError(t, db.Create(&settlement).Error)

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
fmt.Sprintf("/api/smart-ops/billing-settlements/%d/review", settlement.ID),
bytes.NewBufferString(`{"block_user":false,"note":"Verified provider evidence"}`),
)
ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", settlement.ID)}}
ctx.Set("id", administrator.Id)
ctx.Set("username", administrator.Username)
ctx.Set("role", administrator.Role)

ReviewBillingSettlement(ctx)

require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var audit model.Log
require.NoError(t, db.Where("type = ?", model.LogTypeManage).Order("id DESC").First(&audit).Error)
assert.Equal(t, administrator.Id, audit.UserId)
assert.Equal(t, administrator.Username, audit.Username)
var other struct {
Op struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
} `json:"op"`
}
require.NoError(t, common.UnmarshalJsonStr(audit.Other, &other))
assert.Equal(t, "billing.reconciliation_review", other.Op.Action)
assert.EqualValues(t, owner.Id, other.Op.Params["target_user_id"])
}
Loading
Loading