增加备注

This commit is contained in:
zhx 2026-07-05 13:45:39 +08:00
parent 4bd22c61d6
commit ff5df240dd
12 changed files with 4128 additions and 3926 deletions

File diff suppressed because it is too large Load Diff

View File

@ -228,6 +228,7 @@ message AdminCreditAssetRequest {
string reason = 6;
string evidence_ref = 7;
string app_code = 8;
string remark = 9;
}
message AdminCreditAssetResponse {

View File

@ -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"`
}

View File

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

View File

@ -28,4 +28,5 @@ type coinAdjustmentRequest struct {
TargetUserID any `json:"targetUserId"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
Remark string `json:"remark"`
}

View File

@ -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
}

View File

@ -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 "})

View File

@ -245,6 +245,7 @@ type AdminCreditAssetCommand struct {
Amount int64
OperatorUserID int64
Reason string
Remark string
EvidenceRef string
}

View File

@ -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 可表达加金币和扣金币,且扣账不能穿透余额约束。

View File

@ -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,
))
}

View File

@ -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"`
}

View File

@ -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 {