增加备注
This commit is contained in:
parent
4bd22c61d6
commit
ff5df240dd
File diff suppressed because it is too large
Load Diff
@ -228,6 +228,7 @@ message AdminCreditAssetRequest {
|
||||
string reason = 6;
|
||||
string evidence_ref = 7;
|
||||
string app_code = 8;
|
||||
string remark = 9;
|
||||
}
|
||||
|
||||
message AdminCreditAssetResponse {
|
||||
|
||||
@ -72,6 +72,7 @@ type coinAdjustmentDTO struct {
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
AvailableAfter int64 `json:"availableAfter"`
|
||||
Reason string `json:"reason"`
|
||||
Remark string `json:"remark"`
|
||||
OperatorUserID string `json:"operatorUserId"`
|
||||
Operator coinAdjustmentOperatorDTO `json:"operator"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
@ -84,4 +85,5 @@ type coinAdjustmentCreateDTO struct {
|
||||
Amount int64 `json:"amount"`
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
Reason string `json:"reason"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
@ -125,7 +125,7 @@ func (h *Handler) CreateCoinAdjustment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "coin-adjustment-create", "wallet_entries", result.TransactionID, "success",
|
||||
fmt.Sprintf("target_user_id=%v amount=%d transaction_id=%s reason=%q", req.TargetUserID, req.Amount, result.TransactionID, strings.TrimSpace(req.Reason)))
|
||||
fmt.Sprintf("target_user_id=%v amount=%d transaction_id=%s reason=%q remark=%q", req.TargetUserID, req.Amount, result.TransactionID, strings.TrimSpace(req.Reason), strings.TrimSpace(req.Remark)))
|
||||
response.Created(c, result)
|
||||
}
|
||||
|
||||
|
||||
@ -28,4 +28,5 @@ type coinAdjustmentRequest struct {
|
||||
TargetUserID any `json:"targetUserId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
@ -419,6 +420,7 @@ func (s *Service) ListCoinAdjustments(ctx context.Context, appCode string, query
|
||||
item.Direction = directionForDelta(item.AvailableDelta)
|
||||
item.Amount = item.AvailableDelta
|
||||
item.Reason = metadataString(metadata, "reason")
|
||||
item.Remark = metadataString(metadata, "remark")
|
||||
item.OperatorUserID = formatOptionalID(operatorID)
|
||||
items = append(items, item)
|
||||
itemUserIDs = append(itemUserIDs, userID)
|
||||
@ -509,9 +511,16 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
|
||||
if reason == "" {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is required")
|
||||
}
|
||||
if len(reason) > 512 {
|
||||
if utf8.RuneCountInString(reason) > 512 {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is too long")
|
||||
}
|
||||
remark := strings.TrimSpace(req.Remark)
|
||||
if remark == "" {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is required")
|
||||
}
|
||||
if utf8.RuneCountInString(remark) > 512 {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is too long")
|
||||
}
|
||||
user, err := s.LookupCoinAdjustmentTarget(ctx, appCode, strconv.FormatInt(targetUserID, 10))
|
||||
if err != nil {
|
||||
return coinAdjustmentCreateDTO{}, err
|
||||
@ -533,6 +542,7 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
|
||||
Reason: reason,
|
||||
EvidenceRef: evidenceRef,
|
||||
AppCode: appCode,
|
||||
Remark: remark,
|
||||
})
|
||||
if err != nil {
|
||||
return coinAdjustmentCreateDTO{}, err
|
||||
@ -548,6 +558,7 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto
|
||||
Amount: req.Amount,
|
||||
AvailableDelta: req.Amount,
|
||||
Reason: reason,
|
||||
Remark: remark,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,118 @@
|
||||
package coinledger
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
type fakeCoinAdjustmentWallet struct {
|
||||
walletclient.Client
|
||||
req *walletv1.AdminCreditAssetRequest
|
||||
}
|
||||
|
||||
func (f *fakeCoinAdjustmentWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
f.req = req
|
||||
return &walletv1.AdminCreditAssetResponse{
|
||||
TransactionId: "tx-admin-coin-adjustment",
|
||||
Balance: &walletv1.AssetBalance{AvailableAmount: 880},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCreateCoinAdjustmentRequiresRemark(t *testing.T) {
|
||||
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
|
||||
_, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
|
||||
TargetUserID: "20001",
|
||||
Amount: 100,
|
||||
Reason: "manual adjustment",
|
||||
Remark: " ",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "remark is required") {
|
||||
t.Fatalf("expected required remark error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCoinAdjustmentRejectsLongRemark(t *testing.T) {
|
||||
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
|
||||
_, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
|
||||
TargetUserID: "20001",
|
||||
Amount: 100,
|
||||
Reason: "manual adjustment",
|
||||
Remark: strings.Repeat("r", 513),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "remark is too long") {
|
||||
t.Fatalf("expected long remark error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCoinAdjustmentRemarkLengthUsesCharacters(t *testing.T) {
|
||||
service := NewService(nil, nil, nil, &fakeCoinAdjustmentWallet{})
|
||||
_, validErr := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
|
||||
TargetUserID: "20001",
|
||||
Amount: 100,
|
||||
Reason: "manual adjustment",
|
||||
Remark: strings.Repeat("补", 512),
|
||||
})
|
||||
if validErr == nil || strings.Contains(validErr.Error(), "remark is too long") {
|
||||
t.Fatalf("512 multibyte characters should pass length validation, got %v", validErr)
|
||||
}
|
||||
|
||||
_, longErr := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
|
||||
TargetUserID: "20001",
|
||||
Amount: 100,
|
||||
Reason: "manual adjustment",
|
||||
Remark: strings.Repeat("补", 513),
|
||||
})
|
||||
if longErr == nil || !strings.Contains(longErr.Error(), "remark is too long") {
|
||||
t.Fatalf("expected long multibyte remark error, got %v", longErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCoinAdjustmentTrimsAndSendsRemark(t *testing.T) {
|
||||
userDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new sqlmock failed: %v", err)
|
||||
}
|
||||
defer userDB.Close()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')")).
|
||||
WithArgs("lalu", "20001", "20001", "20001", sqlmock.AnyArg(), "20001", "%20001%", "20001", "20001", "20001", sqlmock.AnyArg(), "20001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "current_display_user_id", "username", "avatar"}).AddRow(int64(20001), "6688", "target", "avatar.png"))
|
||||
|
||||
wallet := &fakeCoinAdjustmentWallet{}
|
||||
service := NewService(userDB, nil, nil, wallet)
|
||||
result, err := service.CreateCoinAdjustment(context.Background(), "lalu", 90001, "req-coin-remark", coinAdjustmentRequest{
|
||||
CommandID: " cmd-admin-coin ",
|
||||
TargetUserID: "20001",
|
||||
Amount: 120,
|
||||
Reason: " manual adjustment ",
|
||||
Remark: " ticket approved by finance ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCoinAdjustment failed: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations mismatch: %v", err)
|
||||
}
|
||||
if wallet.req == nil {
|
||||
t.Fatalf("wallet request was not sent")
|
||||
}
|
||||
if wallet.req.GetRemark() != "ticket approved by finance" || wallet.req.GetReason() != "manual adjustment" {
|
||||
t.Fatalf("wallet audit fields mismatch: reason=%q remark=%q", wallet.req.GetReason(), wallet.req.GetRemark())
|
||||
}
|
||||
if wallet.req.GetCommandId() != "cmd-admin-coin" || wallet.req.GetTargetUserId() != 20001 || wallet.req.GetAmount() != 120 || wallet.req.GetOperatorUserId() != 90001 {
|
||||
t.Fatalf("wallet request mismatch: %+v", wallet.req)
|
||||
}
|
||||
if result.Remark != "ticket approved by finance" || result.Reason != "manual adjustment" || result.BalanceAfter != 880 {
|
||||
t.Fatalf("create dto mismatch: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeListQueryCapsPageSize(t *testing.T) {
|
||||
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, UserKeyword: " 10001 "})
|
||||
|
||||
@ -245,6 +245,7 @@ type AdminCreditAssetCommand struct {
|
||||
Amount int64
|
||||
OperatorUserID int64
|
||||
Reason string
|
||||
Remark string
|
||||
EvidenceRef string
|
||||
}
|
||||
|
||||
|
||||
@ -1534,6 +1534,7 @@ func TestAdminCreditAssetIsIdempotent(t *testing.T) {
|
||||
Amount: 500,
|
||||
OperatorUserID: 90001,
|
||||
Reason: "manual recharge",
|
||||
Remark: "finance ticket approved",
|
||||
EvidenceRef: "ticket-1",
|
||||
}
|
||||
|
||||
@ -1556,6 +1557,54 @@ func TestAdminCreditAssetIsIdempotent(t *testing.T) {
|
||||
if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
|
||||
t.Fatalf("balances mismatch: %+v", balances)
|
||||
}
|
||||
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, "finance ticket approved"); got != 1 {
|
||||
t.Fatalf("admin credit metadata should carry remark, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.metadata.remark')) = ?", txID, "WalletBalanceChanged", "finance ticket approved"); got != 1 {
|
||||
t.Fatalf("admin credit outbox payload should carry remark, got %d", got)
|
||||
}
|
||||
|
||||
conflict := command
|
||||
conflict.Remark = "different finance ticket"
|
||||
if _, _, err := svc.AdminCreditAsset(context.Background(), conflict); !xerr.IsCode(err, xerr.LedgerConflict) {
|
||||
t.Fatalf("same command with different remark should conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark 验证未传 remark 的旧调用仍沿用原幂等哈希。
|
||||
func TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
command := ledger.AdminCreditAssetCommand{
|
||||
CommandID: "cmd-admin-credit-legacy",
|
||||
TargetUserID: 20003,
|
||||
AssetType: ledger.AssetCoin,
|
||||
Amount: 300,
|
||||
OperatorUserID: 90001,
|
||||
Reason: "legacy manual recharge",
|
||||
EvidenceRef: "ticket-legacy",
|
||||
}
|
||||
|
||||
first, txID, err := svc.AdminCreditAsset(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy AdminCreditAsset failed: %v", err)
|
||||
}
|
||||
second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy AdminCreditAsset retry failed: %v", err)
|
||||
}
|
||||
if txID == "" || txID != secondTxID || first.AvailableAmount != 300 || second.AvailableAmount != 300 {
|
||||
t.Fatalf("legacy idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID)
|
||||
}
|
||||
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, ""); got != 1 {
|
||||
t.Fatalf("legacy admin credit metadata should keep empty remark, got %d", got)
|
||||
}
|
||||
|
||||
withRemark := command
|
||||
withRemark.Remark = "added after legacy request"
|
||||
if _, _, err := svc.AdminCreditAsset(context.Background(), withRemark); !xerr.IsCode(err, xerr.LedgerConflict) {
|
||||
t.Fatalf("legacy command retried with remark should conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币,且扣账不能穿透余额约束。
|
||||
|
||||
@ -51,6 +51,7 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
||||
Amount: command.Amount,
|
||||
OperatorUserID: command.OperatorUserID,
|
||||
Reason: command.Reason,
|
||||
Remark: command.Remark,
|
||||
EvidenceRef: command.EvidenceRef,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeManualCredit, requestHash, command.EvidenceRef, metadata, nowMs); err != nil {
|
||||
@ -93,13 +94,25 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
||||
}
|
||||
|
||||
func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string {
|
||||
return stableHash(fmt.Sprintf("admin_credit|%s|%d|%s|%d|%d|%s|%s",
|
||||
if command.Remark == "" {
|
||||
return stableHash(fmt.Sprintf("admin_credit|%s|%d|%s|%d|%d|%s|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.TargetUserID,
|
||||
command.AssetType,
|
||||
command.Amount,
|
||||
command.OperatorUserID,
|
||||
command.Reason,
|
||||
command.EvidenceRef,
|
||||
))
|
||||
}
|
||||
return stableHash(fmt.Sprintf("admin_credit_v2|%s|%d|%s|%d|%d|%s|%s|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.TargetUserID,
|
||||
command.AssetType,
|
||||
command.Amount,
|
||||
command.OperatorUserID,
|
||||
command.Reason,
|
||||
command.Remark,
|
||||
command.EvidenceRef,
|
||||
))
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ type adminCreditMetadata struct {
|
||||
Amount int64 `json:"amount"`
|
||||
OperatorUserID int64 `json:"operator_user_id"`
|
||||
Reason string `json:"reason"`
|
||||
Remark string `json:"remark"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCredit
|
||||
Amount: req.GetAmount(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
Reason: req.GetReason(),
|
||||
Remark: req.GetRemark(),
|
||||
EvidenceRef: req.GetEvidenceRef(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user