From c96777e405560a9706f13c58949c1d912a327838 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 23 Jun 2026 11:53:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=E6=A2=B3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 3 +- docker-compose.yml | 2 +- pkg/logx/logx.go | 14 + pkg/logx/logx_test.go | 3 + pkg/servicekit/app/background.go | 61 + pkg/servicekit/app/background_test.go | 22 + pkg/servicekit/app/http.go | 37 + pkg/servicekit/config/config.go | 78 + pkg/servicekit/config/config_test.go | 53 + pkg/servicekit/grpcserver/grpcserver.go | 28 + pkg/servicekit/health/health.go | 56 + pkg/servicekit/health/health_test.go | 48 + pkg/servicekit/mq/rocketmq.go | 66 + scripts/apply-local-rocketmq-topics.sh | 5 +- scripts/prepare-local-rocketmq-config.sh | 48 + services/activity-service/internal/app/app.go | 53 +- .../activity-service/internal/app/health.go | 19 +- services/activity-service/internal/app/mq.go | 5 +- services/cron-service/internal/app/app.go | 44 +- services/game-service/internal/app/app.go | 31 +- services/gateway-service/internal/app/app.go | 17 +- services/notice-service/internal/app/app.go | 68 +- services/robot-service/internal/app/app.go | 21 +- services/room-service/internal/app/app.go | 97 +- .../statistics-service/internal/app/app.go | 25 +- services/user-service/internal/app/app.go | 70 +- .../mysql/initdb/001_wallet_service.sql | 9 + services/wallet-service/internal/app/app.go | 375 +- .../internal/app/background_workers.go | 28 + .../wallet-service/internal/app/clients.go | 20 + .../internal/app/external_recharge_worker.go | 32 + .../wallet-service/internal/app/health.go | 28 + services/wallet-service/internal/app/mq.go | 89 + .../internal/app/outbox_worker.go | 148 + .../internal/app/red_packet_worker.go | 42 + .../internal/domain/ledger/coin_seller.go | 138 + .../internal/domain/ledger/constants.go | 132 + .../internal/domain/ledger/game.go | 42 + .../internal/domain/ledger/gift.go | 119 + .../internal/domain/ledger/host_salary.go | 64 + .../internal/domain/ledger/ledger.go | 1407 ----- .../internal/domain/ledger/recharge.go | 325 ++ .../internal/domain/ledger/red_packet.go | 146 + .../internal/domain/ledger/reward.go | 170 + .../internal/domain/ledger/vip.go | 111 + .../internal/domain/ledger/wallet_query.go | 195 + .../internal/domain/resource/badge.go | 30 + .../internal/domain/resource/constants.go | 73 + .../internal/domain/resource/entitlement.go | 55 + .../internal/domain/resource/gift_config.go | 168 + .../internal/domain/resource/grant.go | 60 + .../internal/domain/resource/group.go | 92 + .../internal/domain/resource/resource.go | 604 +- .../internal/domain/resource/shop.go | 134 + .../internal/domain/resource/validation.go | 10 + .../internal/service/wallet/admin_ledger.go | 30 + .../internal/service/wallet/balance.go | 98 + .../internal/service/wallet/coin_seller.go | 151 + .../internal/service/wallet/compat.go | 36 + .../service/wallet/external_recharge.go | 952 --- .../wallet/external_recharge_config.go | 24 + .../wallet/external_recharge_notify.go | 71 + .../service/wallet/external_recharge_order.go | 235 + .../external_recharge_payment_methods.go | 129 + .../wallet/external_recharge_reconcile.go | 115 + .../wallet/external_recharge_validate.go | 268 + .../internal/service/wallet/game.go | 39 + .../internal/service/wallet/gift.go | 31 + .../internal/service/wallet/host_salary.go | 90 + .../internal/service/wallet/ports/clients.go | 164 + .../service/wallet/ports/repository.go | 200 + .../internal/service/wallet/projection.go | 146 + .../internal/service/wallet/recharge.go | 243 + .../internal/service/wallet/reward.go | 194 + .../internal/service/wallet/service.go | 1239 +--- .../service/wallet/usecase/gift/service.go | 91 + .../internal/service/wallet/vip.go | 109 + .../internal/storage/mysql/account.go | 180 + .../internal/storage/mysql/admin_ledger.go | 105 + .../storage/mysql/app_wallet_constants.go | 8 + .../storage/mysql/app_wallet_repository.go | 1146 ---- .../internal/storage/mysql/balance_query.go | 83 + .../storage/mysql/coin_seller_ledger.go | 677 +++ .../internal/storage/mysql/constants.go | 38 + .../internal/storage/mysql/entry.go | 59 + .../external_recharge_order_repository.go | 479 ++ .../internal/storage/mysql/game_ledger.go | 112 + .../mysql/gift_batch_debit_repository.go | 395 ++ .../mysql/gift_config_query_repository.go | 274 + .../storage/mysql/gift_config_repository.go | 201 + .../storage/mysql/gift_config_validation.go | 137 + .../storage/mysql/gift_debit_repository.go | 340 ++ .../mysql/gift_entitlement_repository.go | 95 + .../storage/mysql/gift_ledger_helpers.go | 95 + .../storage/mysql/gift_price_repository.go | 96 + .../internal/storage/mysql/gift_pricing.go | 279 + .../mysql/gift_type_config_repository.go | 194 + .../storage/mysql/gift_wall_repository.go | 86 + .../internal/storage/mysql/host_diamond.go | 150 + .../internal/storage/mysql/metadata.go | 270 + .../internal/storage/mysql/outbox.go | 721 +++ .../mysql/payment_method_repository.go | 234 + .../internal/storage/mysql/payment_seed.go | 242 + .../internal/storage/mysql/receipts.go | 440 ++ .../mysql/red_packet_claim_repository.go | 283 + .../mysql/red_packet_config_repository.go | 273 + .../storage/mysql/red_packet_constants.go | 83 + .../mysql/red_packet_create_repository.go | 315 + .../mysql/red_packet_query_repository.go | 312 + .../mysql/red_packet_refund_repository.go | 212 + .../storage/mysql/red_packet_repository.go | 1426 ----- .../internal/storage/mysql/repository.go | 5098 ----------------- .../mysql/resource_catalog_repository.go | 582 ++ .../storage/mysql/resource_constants.go | 28 + .../mysql/resource_entitlement_repository.go | 600 ++ .../mysql/resource_grant_repository.go | 584 ++ .../mysql/resource_group_repository.go | 572 ++ .../internal/storage/mysql/resource_outbox.go | 34 + .../storage/mysql/resource_query_helpers.go | 105 + .../storage/mysql/resource_repository.go | 3987 ------------- .../storage/mysql/resource_shop_repository.go | 694 +++ .../internal/storage/mysql/reward_ledger.go | 755 +++ .../internal/storage/mysql/schema.go | 271 + .../mysql/third_party_payment_repository.go | 941 --- .../internal/storage/mysql/transaction.go | 69 + .../internal/storage/mysql/utils.go | 84 + .../storage/mysql/vip_admin_repository.go | 147 + .../storage/mysql/vip_grant_repository.go | 141 + .../storage/mysql/vip_purchase_repository.go | 425 ++ .../storage/mysql/vip_query_repository.go | 251 + .../internal/storage/mysql/vip_types.go | 36 + .../storage/mysql/wallet_query_repository.go | 190 + .../internal/transport/grpc/admin_ledger.go | 39 + .../internal/transport/grpc/balance.go | 208 + .../internal/transport/grpc/coin_seller.go | 151 + .../transport/grpc/external_recharge.go | 242 + .../internal/transport/grpc/game.go | 36 + .../internal/transport/grpc/gift.go | 127 + .../internal/transport/grpc/host_salary.go | 78 + .../transport/grpc/recharge_product.go | 157 + .../internal/transport/grpc/resource.go | 387 -- .../transport/grpc/resource_command_mapper.go | 173 + .../transport/grpc/resource_mapper.go | 225 + .../internal/transport/grpc/reward.go | 254 + .../internal/transport/grpc/server.go | 1371 ----- .../internal/transport/grpc/vip.go | 174 + 146 files changed, 20557 insertions(+), 19174 deletions(-) create mode 100644 pkg/servicekit/app/background.go create mode 100644 pkg/servicekit/app/background_test.go create mode 100644 pkg/servicekit/app/http.go create mode 100644 pkg/servicekit/config/config.go create mode 100644 pkg/servicekit/config/config_test.go create mode 100644 pkg/servicekit/grpcserver/grpcserver.go create mode 100644 pkg/servicekit/health/health.go create mode 100644 pkg/servicekit/health/health_test.go create mode 100644 pkg/servicekit/mq/rocketmq.go create mode 100755 scripts/prepare-local-rocketmq-config.sh create mode 100644 services/wallet-service/internal/app/background_workers.go create mode 100644 services/wallet-service/internal/app/clients.go create mode 100644 services/wallet-service/internal/app/external_recharge_worker.go create mode 100644 services/wallet-service/internal/app/health.go create mode 100644 services/wallet-service/internal/app/mq.go create mode 100644 services/wallet-service/internal/app/outbox_worker.go create mode 100644 services/wallet-service/internal/app/red_packet_worker.go create mode 100644 services/wallet-service/internal/domain/ledger/coin_seller.go create mode 100644 services/wallet-service/internal/domain/ledger/constants.go create mode 100644 services/wallet-service/internal/domain/ledger/game.go create mode 100644 services/wallet-service/internal/domain/ledger/gift.go create mode 100644 services/wallet-service/internal/domain/ledger/host_salary.go delete mode 100644 services/wallet-service/internal/domain/ledger/ledger.go create mode 100644 services/wallet-service/internal/domain/ledger/recharge.go create mode 100644 services/wallet-service/internal/domain/ledger/red_packet.go create mode 100644 services/wallet-service/internal/domain/ledger/reward.go create mode 100644 services/wallet-service/internal/domain/ledger/vip.go create mode 100644 services/wallet-service/internal/domain/ledger/wallet_query.go create mode 100644 services/wallet-service/internal/domain/resource/badge.go create mode 100644 services/wallet-service/internal/domain/resource/constants.go create mode 100644 services/wallet-service/internal/domain/resource/entitlement.go create mode 100644 services/wallet-service/internal/domain/resource/gift_config.go create mode 100644 services/wallet-service/internal/domain/resource/grant.go create mode 100644 services/wallet-service/internal/domain/resource/group.go create mode 100644 services/wallet-service/internal/domain/resource/shop.go create mode 100644 services/wallet-service/internal/domain/resource/validation.go create mode 100644 services/wallet-service/internal/service/wallet/admin_ledger.go create mode 100644 services/wallet-service/internal/service/wallet/balance.go create mode 100644 services/wallet-service/internal/service/wallet/coin_seller.go create mode 100644 services/wallet-service/internal/service/wallet/compat.go delete mode 100644 services/wallet-service/internal/service/wallet/external_recharge.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_config.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_notify.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_order.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_payment_methods.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_reconcile.go create mode 100644 services/wallet-service/internal/service/wallet/external_recharge_validate.go create mode 100644 services/wallet-service/internal/service/wallet/game.go create mode 100644 services/wallet-service/internal/service/wallet/gift.go create mode 100644 services/wallet-service/internal/service/wallet/host_salary.go create mode 100644 services/wallet-service/internal/service/wallet/ports/clients.go create mode 100644 services/wallet-service/internal/service/wallet/ports/repository.go create mode 100644 services/wallet-service/internal/service/wallet/projection.go create mode 100644 services/wallet-service/internal/service/wallet/recharge.go create mode 100644 services/wallet-service/internal/service/wallet/reward.go create mode 100644 services/wallet-service/internal/service/wallet/usecase/gift/service.go create mode 100644 services/wallet-service/internal/service/wallet/vip.go create mode 100644 services/wallet-service/internal/storage/mysql/account.go create mode 100644 services/wallet-service/internal/storage/mysql/admin_ledger.go create mode 100644 services/wallet-service/internal/storage/mysql/app_wallet_constants.go delete mode 100644 services/wallet-service/internal/storage/mysql/app_wallet_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/balance_query.go create mode 100644 services/wallet-service/internal/storage/mysql/coin_seller_ledger.go create mode 100644 services/wallet-service/internal/storage/mysql/constants.go create mode 100644 services/wallet-service/internal/storage/mysql/entry.go create mode 100644 services/wallet-service/internal/storage/mysql/external_recharge_order_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/game_ledger.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_config_query_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_config_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_config_validation.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_debit_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_price_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_pricing.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_type_config_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/gift_wall_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/host_diamond.go create mode 100644 services/wallet-service/internal/storage/mysql/metadata.go create mode 100644 services/wallet-service/internal/storage/mysql/outbox.go create mode 100644 services/wallet-service/internal/storage/mysql/payment_method_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/payment_seed.go create mode 100644 services/wallet-service/internal/storage/mysql/receipts.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_claim_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_config_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_constants.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_create_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_query_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/red_packet_refund_repository.go delete mode 100644 services/wallet-service/internal/storage/mysql/red_packet_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_catalog_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_constants.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_grant_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_group_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_outbox.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_query_helpers.go delete mode 100644 services/wallet-service/internal/storage/mysql/resource_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/resource_shop_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/reward_ledger.go create mode 100644 services/wallet-service/internal/storage/mysql/schema.go delete mode 100644 services/wallet-service/internal/storage/mysql/third_party_payment_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/transaction.go create mode 100644 services/wallet-service/internal/storage/mysql/utils.go create mode 100644 services/wallet-service/internal/storage/mysql/vip_admin_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/vip_grant_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/vip_purchase_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/vip_query_repository.go create mode 100644 services/wallet-service/internal/storage/mysql/vip_types.go create mode 100644 services/wallet-service/internal/storage/mysql/wallet_query_repository.go create mode 100644 services/wallet-service/internal/transport/grpc/admin_ledger.go create mode 100644 services/wallet-service/internal/transport/grpc/balance.go create mode 100644 services/wallet-service/internal/transport/grpc/coin_seller.go create mode 100644 services/wallet-service/internal/transport/grpc/external_recharge.go create mode 100644 services/wallet-service/internal/transport/grpc/game.go create mode 100644 services/wallet-service/internal/transport/grpc/gift.go create mode 100644 services/wallet-service/internal/transport/grpc/host_salary.go create mode 100644 services/wallet-service/internal/transport/grpc/recharge_product.go create mode 100644 services/wallet-service/internal/transport/grpc/resource_command_mapper.go create mode 100644 services/wallet-service/internal/transport/grpc/resource_mapper.go create mode 100644 services/wallet-service/internal/transport/grpc/reward.go create mode 100644 services/wallet-service/internal/transport/grpc/vip.go diff --git a/Makefile b/Makefile index ab7df8dc..8318d010 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,8 @@ build: # 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。 run: ./scripts/resolve-compose-container-conflicts.sh - docker compose up -d mysql redis + ./scripts/prepare-local-rocketmq-config.sh + ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker ./scripts/apply-local-mysql-initdb.sh ./scripts/apply-local-rocketmq-topics.sh docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true diff --git a/docker-compose.yml b/docker-compose.yml index 1078adde..bba19aeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: - "10911:10911" - "19011:10911" volumes: - - ./deploy/rocketmq/broker.local.conf:/home/rocketmq/broker.conf:ro + - ${ROCKETMQ_BROKER_CONFIG:-./deploy/rocketmq/broker.local.conf}:/home/rocketmq/broker.conf:ro - rocketmq-store:/home/rocketmq/store depends_on: - rocketmq-namesrv diff --git a/pkg/logx/logx.go b/pkg/logx/logx.go index 3ee1f4f0..5722c972 100644 --- a/pkg/logx/logx.go +++ b/pkg/logx/logx.go @@ -257,6 +257,10 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor { slog.String("reason", string(xerr.ReasonFromGRPC(err))), slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()), } + if err != nil { + // gRPC access 日志是跨服务排障入口;reason 只给稳定错误码,message 保留服务端校验分支,便于定位 400/409 的真实拒绝原因。 + attrs = append(attrs, slog.String("error_message", grpcErrorMessage(err))) + } attrs = appendServiceAttr(attrs, service) attrs = append(attrs, commonAttrsFromPayload(request)...) if cfg.IncludeRequestBody { @@ -278,6 +282,16 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor { } } +func grpcErrorMessage(err error) string { + if err == nil { + return "" + } + if st, ok := status.FromError(err); ok { + return st.Message() + } + return err.Error() +} + type stdLogWriter struct{} func (stdLogWriter) Write(data []byte) (int, error) { diff --git a/pkg/logx/logx_test.go b/pkg/logx/logx_test.go index d1830fbe..44b5bc83 100644 --- a/pkg/logx/logx_test.go +++ b/pkg/logx/logx_test.go @@ -122,6 +122,9 @@ func TestUnaryServerInterceptorLevelsAndRequestMeta(t *testing.T) { if entry["grpc_code"] != "InvalidArgument" { t.Fatalf("grpc_code=%v, want InvalidArgument", entry["grpc_code"]) } + if entry["reason"] != "INVALID_ARGUMENT" || entry["error_message"] != "bad user id" { + t.Fatalf("grpc error fields=%+v, want stable reason and service message", entry) + } } func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) { diff --git a/pkg/servicekit/app/background.go b/pkg/servicekit/app/background.go new file mode 100644 index 00000000..4a86bcc2 --- /dev/null +++ b/pkg/servicekit/app/background.go @@ -0,0 +1,61 @@ +// Package app provides small process lifecycle primitives for service app packages. +package app + +import ( + "context" + "sync" +) + +// BackgroundGroup owns a cancellable worker context and waits for all registered workers to finish. +type BackgroundGroup struct { + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + once sync.Once +} + +// NewBackground creates a worker group rooted at parent; nil parent means context.Background. +func NewBackground(parent context.Context) *BackgroundGroup { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + return &BackgroundGroup{ctx: ctx, cancel: cancel} +} + +// Context returns the shared worker context. Workers should stop when it is canceled. +func (g *BackgroundGroup) Context() context.Context { + if g == nil || g.ctx == nil { + return context.Background() + } + return g.ctx +} + +// Go starts one worker and gives it the shared cancellable context. +func (g *BackgroundGroup) Go(run func(context.Context)) { + if g == nil || run == nil { + return + } + g.wg.Add(1) + go func() { + defer g.wg.Done() + run(g.Context()) + }() +} + +// Stop cancels the shared worker context. It is safe to call more than once. +func (g *BackgroundGroup) Stop() { + if g == nil { + return + } + g.once.Do(g.cancel) +} + +// StopAndWait cancels the shared context and waits for all workers to return. +func (g *BackgroundGroup) StopAndWait() { + if g == nil { + return + } + g.Stop() + g.wg.Wait() +} diff --git a/pkg/servicekit/app/background_test.go b/pkg/servicekit/app/background_test.go new file mode 100644 index 00000000..85fc3e1b --- /dev/null +++ b/pkg/servicekit/app/background_test.go @@ -0,0 +1,22 @@ +package app + +import ( + "context" + "testing" + "time" +) + +func TestBackgroundGroupStopAndWait(t *testing.T) { + group := NewBackground(context.Background()) + done := make(chan struct{}) + group.Go(func(ctx context.Context) { + <-ctx.Done() + close(done) + }) + group.StopAndWait() + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("worker did not observe cancellation") + } +} diff --git a/pkg/servicekit/app/http.go b/pkg/servicekit/app/http.go new file mode 100644 index 00000000..7ea2e233 --- /dev/null +++ b/pkg/servicekit/app/http.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + "errors" + "net" + "net/http" + "time" +) + +// ServeHTTP runs an HTTP server and treats graceful shutdown as a normal lifecycle exit. +func ServeHTTP(server *http.Server, listener net.Listener) error { + if server == nil { + return errors.New("http server is required") + } + if listener == nil { + return errors.New("http listener is required") + } + err := server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// ShutdownHTTP first drains accepted requests and then force-closes the server if the drain budget is exceeded. +func ShutdownHTTP(server *http.Server, timeout time.Duration) error { + if server == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + return errors.Join(err, server.Close()) + } + return nil +} diff --git a/pkg/servicekit/config/config.go b/pkg/servicekit/config/config.go new file mode 100644 index 00000000..5e206cdd --- /dev/null +++ b/pkg/servicekit/config/config.go @@ -0,0 +1,78 @@ +// Package config provides shared config loading and environment override helpers. +package config + +import ( + "errors" + "os" + "strconv" + "strings" + "time" + + "hyapp/pkg/configx" +) + +// Validator lets a service config enforce cross-field invariants after file and env overrides are applied. +type Validator interface { + Validate() error +} + +// LoadYAML loads YAML into out and calls Validate when the target config implements Validator. +func LoadYAML(path string, out any) error { + if strings.TrimSpace(path) == "" { + return errors.New("config path is required") + } + if out == nil { + return errors.New("config target is required") + } + if err := configx.LoadYAML(path, out); err != nil { + return err + } + if validator, ok := out.(Validator); ok { + return validator.Validate() + } + return nil +} + +// OverrideStringFromEnv replaces target when envName exists and is not blank. +func OverrideStringFromEnv(target *string, envName string) { + if target == nil || strings.TrimSpace(envName) == "" { + return + } + if value, ok := os.LookupEnv(envName); ok && strings.TrimSpace(value) != "" { + *target = strings.TrimSpace(value) + } +} + +// OverrideBoolFromEnv parses a bool environment value into target when envName exists. +func OverrideBoolFromEnv(target *bool, envName string) error { + if target == nil || strings.TrimSpace(envName) == "" { + return nil + } + value, ok := os.LookupEnv(envName) + if !ok || strings.TrimSpace(value) == "" { + return nil + } + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return err + } + *target = parsed + return nil +} + +// OverrideDurationFromEnv parses a Go duration environment value into target when envName exists. +func OverrideDurationFromEnv(target *time.Duration, envName string) error { + if target == nil || strings.TrimSpace(envName) == "" { + return nil + } + value, ok := os.LookupEnv(envName) + if !ok || strings.TrimSpace(value) == "" { + return nil + } + parsed, err := time.ParseDuration(strings.TrimSpace(value)) + if err != nil { + return err + } + *target = parsed + return nil +} diff --git a/pkg/servicekit/config/config_test.go b/pkg/servicekit/config/config_test.go new file mode 100644 index 00000000..265b7690 --- /dev/null +++ b/pkg/servicekit/config/config_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +type testConfig struct { + Name string `yaml:"name"` +} + +func (c *testConfig) Validate() error { + if c.Name == "" { + return os.ErrInvalid + } + return nil +} + +func TestLoadYAMLValidatesConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("name: activity-service\n"), 0o644); err != nil { + t.Fatalf("write config failed: %v", err) + } + var cfg testConfig + if err := LoadYAML(path, &cfg); err != nil { + t.Fatalf("load yaml failed: %v", err) + } + if cfg.Name != "activity-service" { + t.Fatalf("unexpected name: %q", cfg.Name) + } +} + +func TestEnvOverrides(t *testing.T) { + t.Setenv("SERVICEKIT_STRING", " value ") + t.Setenv("SERVICEKIT_BOOL", "true") + t.Setenv("SERVICEKIT_DURATION", "3s") + + text := "" + enabled := false + delay := time.Duration(0) + OverrideStringFromEnv(&text, "SERVICEKIT_STRING") + if err := OverrideBoolFromEnv(&enabled, "SERVICEKIT_BOOL"); err != nil { + t.Fatalf("bool override failed: %v", err) + } + if err := OverrideDurationFromEnv(&delay, "SERVICEKIT_DURATION"); err != nil { + t.Fatalf("duration override failed: %v", err) + } + if text != "value" || !enabled || delay != 3*time.Second { + t.Fatalf("env overrides mismatch: text=%q enabled=%t delay=%s", text, enabled, delay) + } +} diff --git a/pkg/servicekit/grpcserver/grpcserver.go b/pkg/servicekit/grpcserver/grpcserver.go new file mode 100644 index 00000000..b7eaff43 --- /dev/null +++ b/pkg/servicekit/grpcserver/grpcserver.go @@ -0,0 +1,28 @@ +// Package grpcserver owns the shared gRPC process wiring used by backend services. +package grpcserver + +import ( + "time" + + "google.golang.org/grpc" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/logx" +) + +const defaultGracefulStopTimeout = 15 * time.Second + +// New creates the standard unary gRPC server used by owner services. +// Business services still register protobuf handlers themselves; this helper only centralizes runtime middleware. +func New(serviceName string, options ...grpc.ServerOption) *grpc.Server { + base := []grpc.ServerOption{grpc.UnaryInterceptor(logx.UnaryServerInterceptor(serviceName))} + base = append(base, options...) + return grpc.NewServer(base...) +} + +// GracefulStop drains in-flight RPCs with the same timeout behavior across services. +func GracefulStop(server *grpc.Server, timeout time.Duration) { + if timeout <= 0 { + timeout = defaultGracefulStopTimeout + } + grpcshutdown.GracefulStop(server, timeout) +} diff --git a/pkg/servicekit/health/health.go b/pkg/servicekit/health/health.go new file mode 100644 index 00000000..d81e3797 --- /dev/null +++ b/pkg/servicekit/health/health.go @@ -0,0 +1,56 @@ +// Package health wires standard gRPC health and the optional HTTP readiness endpoint. +package health + +import ( + "errors" + + "google.golang.org/grpc" + healthgrpc "google.golang.org/grpc/health/grpc_health_v1" + "hyapp/pkg/grpchealth" + "hyapp/pkg/healthhttp" +) + +// Config describes one service's shared gRPC health and HTTP readiness endpoint. +type Config struct { + ServiceName string + HTTPAddr string + NodeID string + Dependencies []grpchealth.Dependency +} + +// New registers standard gRPC health and creates the companion HTTP health server. +// Both endpoints share one checker so readiness/draining cannot diverge between CLB and gRPC probes. +func New(server *grpc.Server, cfg Config) (*grpchealth.ServingChecker, *healthhttp.Server, error) { + if server == nil { + return nil, nil, errors.New("grpc server is required") + } + health := grpchealth.NewServingChecker(cfg.ServiceName, cfg.Dependencies...) + healthHTTP, err := Register(server, health, cfg.HTTPAddr, cfg.NodeID) + if err != nil { + return nil, nil, err + } + return health, healthHTTP, nil +} + +// Register exposes an existing checker through standard gRPC health and HTTP readiness. +// Services with richer runtime checks keep their domain-specific checker but share the same transport wiring. +func Register(server *grpc.Server, checker grpchealth.Checker, httpAddr string, nodeID string) (*healthhttp.Server, error) { + if server == nil { + return nil, errors.New("grpc server is required") + } + if checker == nil { + return nil, errors.New("health checker is required") + } + healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(checker)) + return healthhttp.New(httpAddr, nodeID, checker) +} + +// NewHTTP creates the shared checker and HTTP readiness endpoint for services that do not expose gRPC. +func NewHTTP(cfg Config) (*grpchealth.ServingChecker, *healthhttp.Server, error) { + health := grpchealth.NewServingChecker(cfg.ServiceName, cfg.Dependencies...) + healthHTTP, err := healthhttp.New(cfg.HTTPAddr, cfg.NodeID, health) + if err != nil { + return nil, nil, err + } + return health, healthHTTP, nil +} diff --git a/pkg/servicekit/health/health_test.go b/pkg/servicekit/health/health_test.go new file mode 100644 index 00000000..8135dd29 --- /dev/null +++ b/pkg/servicekit/health/health_test.go @@ -0,0 +1,48 @@ +package health + +import ( + "context" + "testing" + + "hyapp/pkg/grpchealth" + "hyapp/pkg/servicekit/grpcserver" +) + +func TestNewRegistersSharedChecker(t *testing.T) { + server := grpcserver.New("test-service") + health, healthHTTP, err := New(server, Config{ + ServiceName: "test-service", + NodeID: "node-1", + Dependencies: []grpchealth.Dependency{{ + Name: "mysql", + Check: func(context.Context) error { return nil }, + }}, + }) + if err != nil { + t.Fatalf("new health failed: %v", err) + } + if health == nil { + t.Fatalf("health checker is required") + } + if healthHTTP != nil { + t.Fatalf("empty HTTP addr should disable health HTTP server") + } + health.MarkServing() + if !grpchealth.Healthy(health.Ready(context.Background())) { + t.Fatalf("health should be ready after serving mark and passing dependency") + } + grpcserver.GracefulStop(server, 0) +} + +func TestNewHTTPCreatesCheckerWithoutGRPC(t *testing.T) { + health, healthHTTP, err := NewHTTP(Config{ServiceName: "statistics-service", NodeID: "node-1"}) + if err != nil { + t.Fatalf("new HTTP health failed: %v", err) + } + if health == nil { + t.Fatalf("health checker is required") + } + if healthHTTP != nil { + t.Fatalf("empty HTTP addr should disable health HTTP server") + } +} diff --git a/pkg/servicekit/mq/rocketmq.go b/pkg/servicekit/mq/rocketmq.go new file mode 100644 index 00000000..fe3f5e30 --- /dev/null +++ b/pkg/servicekit/mq/rocketmq.go @@ -0,0 +1,66 @@ +// Package mq owns shared RocketMQ lifecycle helpers. +package mq + +import ( + "context" + "log/slog" + + "hyapp/pkg/logx" + "hyapp/pkg/rocketmqx" +) + +// StartConsumers starts consumers in order; if one fails, already-started consumers are shut down. +func StartConsumers(consumers []*rocketmqx.Consumer) error { + started := make([]*rocketmqx.Consumer, 0, len(consumers)) + for _, consumer := range consumers { + if consumer == nil { + continue + } + if err := consumer.Start(); err != nil { + ShutdownConsumers(started) + return err + } + started = append(started, consumer) + } + return nil +} + +// ShutdownConsumers releases consumer groups and logs shutdown failures without masking the caller's main error. +func ShutdownConsumers(consumers []*rocketmqx.Consumer) { + for _, consumer := range consumers { + if consumer == nil { + continue + } + if err := consumer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) + } + } +} + +// StartProducers starts producers in order; if one fails, already-started producers are shut down. +func StartProducers(producers []*rocketmqx.Producer) error { + started := make([]*rocketmqx.Producer, 0, len(producers)) + for _, producer := range producers { + if producer == nil { + continue + } + if err := producer.Start(); err != nil { + ShutdownProducers(started) + return err + } + started = append(started, producer) + } + return nil +} + +// ShutdownProducers releases producer groups and logs shutdown failures without masking the caller's main error. +func ShutdownProducers(producers []*rocketmqx.Producer) { + for _, producer := range producers { + if producer == nil { + continue + } + if err := producer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) + } + } +} diff --git a/scripts/apply-local-rocketmq-topics.sh b/scripts/apply-local-rocketmq-topics.sh index f1d58e9a..124193dc 100755 --- a/scripts/apply-local-rocketmq-topics.sh +++ b/scripts/apply-local-rocketmq-topics.sh @@ -9,8 +9,11 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${PROJECT_ROOT}" +"${SCRIPT_DIR}/prepare-local-rocketmq-config.sh" >/dev/null +export ROCKETMQ_BROKER_CONFIG="${PROJECT_ROOT}/tmp/rocketmq/broker.local.conf" "${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" rocketmq-namesrv rocketmq-broker -docker compose up -d rocketmq-namesrv rocketmq-broker >/dev/null +docker compose up -d rocketmq-namesrv >/dev/null +docker compose up -d --force-recreate rocketmq-broker >/dev/null MQADMIN="/home/rocketmq/rocketmq-5.3.1/bin/mqadmin" NAMESRV="rocketmq-namesrv:9876" diff --git a/scripts/prepare-local-rocketmq-config.sh b/scripts/prepare-local-rocketmq-config.sh new file mode 100755 index 00000000..ef1e6530 --- /dev/null +++ b/scripts/prepare-local-rocketmq-config.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# RocketMQ NameServer returns brokerIP1 to every producer and consumer. In +# `make run` the clients are host-side `go run` processes, while Docker mode may +# still have container clients, so advertise the host LAN IP that both sides can +# dial through the compose port mapping. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +OUTPUT_PATH="${PROJECT_ROOT}/tmp/rocketmq/broker.local.conf" + +detect_host_ip() { + if [[ -n "${ROCKETMQ_BROKER_IP:-}" ]]; then + printf '%s\n' "${ROCKETMQ_BROKER_IP}" + return + fi + + local iface="" + if command -v route >/dev/null 2>&1; then + iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" + fi + if [[ -n "${iface}" ]] && command -v ipconfig >/dev/null 2>&1; then + ipconfig getifaddr "${iface}" 2>/dev/null && return + fi + + if command -v ifconfig >/dev/null 2>&1; then + ifconfig | awk '/inet / && $2 != "127.0.0.1" {print $2; exit}' && return + fi + + printf '127.0.0.1\n' +} + +broker_ip="$(detect_host_ip)" +mkdir -p "$(dirname "${OUTPUT_PATH}")" +cat > "${OUTPUT_PATH}" < 0 { // presence worker 只清理本节点已装载 Cell,命令仍走 Room Cell 持久化链路。 - a.workerWG.Go(func() { - a.service.RunPresenceStaleWorker(a.workerCtx, a.cfg.PresenceStaleScanInterval) + a.workers.Go(func(ctx context.Context) { + a.service.RunPresenceStaleWorker(ctx, a.cfg.PresenceStaleScanInterval) }) } if a.cfg.MicPublishScanInterval > 0 { // MicUp 只代表业务占麦;后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。 - a.workerWG.Go(func() { - a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval) + a.workers.Go(func(ctx context.Context) { + a.service.RunMicPublishTimeoutWorker(ctx, a.cfg.MicPublishScanInterval) }) } if a.cfg.RoomRocketLaunchScanInterval > 0 { // 火箭发射仍通过 Room Cell 命令链路提交,worker 只负责触发到点的系统命令。 - a.workerWG.Go(func() { - a.service.RunRoomRocketLaunchWorker(a.workerCtx, a.cfg.RoomRocketLaunchScanInterval) + a.workers.Go(func(ctx context.Context) { + a.service.RunRoomRocketLaunchWorker(ctx, a.cfg.RoomRocketLaunchScanInterval) }) } if a.cfg.OutboxWorker.Enabled { - a.workerWG.Go(func() { - a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{ + a.workers.Go(func(ctx context.Context) { + a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ PollInterval: a.cfg.OutboxWorker.PollInterval, BatchSize: a.cfg.OutboxWorker.BatchSize, PublishTimeout: a.cfg.OutboxWorker.PublishTimeout, @@ -472,8 +463,8 @@ func (a *App) Run() error { }) } if a.cfg.RobotOutboxWorker.Enabled { - a.workerWG.Go(func() { - a.service.RunRobotOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{ + a.workers.Go(func(ctx context.Context) { + a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ PollInterval: a.cfg.RobotOutboxWorker.PollInterval, BatchSize: a.cfg.RobotOutboxWorker.BatchSize, PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout, @@ -484,11 +475,11 @@ func (a *App) Run() error { }) }) } - a.workerWG.Go(func() { - a.service.RunRobotRoomRuntimeManager(a.workerCtx, 10*time.Second) + a.workers.Go(func(ctx context.Context) { + a.service.RunRobotRoomRuntimeManager(ctx, 10*time.Second) }) - a.workerWG.Go(func() { - a.service.RunHumanRoomRobotRuntimeManager(a.workerCtx, 15*time.Second) + a.workers.Go(func(ctx context.Context) { + a.service.RunHumanRoomRobotRuntimeManager(ctx, 15*time.Second) }) err := a.grpcServer.Serve(a.listener) @@ -509,14 +500,11 @@ func (a *App) Close() { if a.service != nil { a.service.MarkDraining() } - if a.workerCancel != nil { - // 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。 - a.workerCancel() - } - a.workerWG.Wait() + // 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。 + a.workers.StopAndWait() a.shutdownMQ() // GracefulStop 让已进入的 gRPC 请求自然结束。 - grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second) + servicegrpc.GracefulStop(a.grpcServer, 15*time.Second) if a.service != nil { // gRPC drain 完成后释放本节点持有的 room route,降低滚动发布时等待 Redis TTL 的窗口。 releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -620,32 +608,19 @@ func (a *App) closeHealthHTTP() { } func (a *App) startMQ() error { - for _, producer := range a.mqProducers { - if err := producer.Start(); err != nil { - a.shutdownMQ() - return err - } + if err := servicemq.StartProducers(a.mqProducers); err != nil { + return err } - for _, consumer := range a.mqConsumers { - if err := consumer.Start(); err != nil { - a.shutdownMQ() - return err - } + if err := servicemq.StartConsumers(a.mqConsumers); err != nil { + servicemq.ShutdownProducers(a.mqProducers) + return err } return nil } func (a *App) shutdownMQ() { - for _, consumer := range a.mqConsumers { - if err := consumer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) - } - } - for _, producer := range a.mqProducers { - if err := producer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) - } - } + servicemq.ShutdownConsumers(a.mqConsumers) + servicemq.ShutdownProducers(a.mqProducers) } func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig { diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index 92a000c8..cc144814 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -2,7 +2,6 @@ package app import ( "context" - "log/slog" "strings" "sync" "time" @@ -16,6 +15,8 @@ import ( "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" "hyapp/pkg/roommq" + servicehealth "hyapp/pkg/servicekit/health" + servicemq "hyapp/pkg/servicekit/mq" "hyapp/pkg/usermq" "hyapp/pkg/walletmq" "hyapp/services/statistics-service/internal/config" @@ -45,8 +46,12 @@ func New(cfg config.Config) (*App, error) { _ = repo.Close() return nil, err } - health := grpchealth.NewServingChecker("statistics-service", grpchealth.Dependency{Name: "mysql", Check: repo.Ping}) - healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + health, healthHTTP, err := servicehealth.NewHTTP(servicehealth.Config{ + ServiceName: "statistics-service", + HTTPAddr: cfg.HealthHTTPAddr, + NodeID: cfg.NodeID, + Dependencies: []grpchealth.Dependency{{Name: "mysql", Check: repo.Ping}}, + }) if err != nil { shutdownConsumers(consumers) _ = repo.Close() @@ -562,21 +567,11 @@ func int64Slice(raw any) []int64 { } func startConsumers(consumers []*rocketmqx.Consumer) error { - for _, consumer := range consumers { - if err := consumer.Start(); err != nil { - shutdownConsumers(consumers) - return err - } - } - return nil + return servicemq.StartConsumers(consumers) } func shutdownConsumers(consumers []*rocketmqx.Consumer) { - for _, consumer := range consumers { - if err := consumer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) - } - } + servicemq.ShutdownConsumers(consumers) } func consumerConfig(cfg config.RocketMQConfig, mq config.ConsumerMQConfig) rocketmqx.ConsumerConfig { diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index e9dfd9e7..265ccc1b 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -17,12 +17,15 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/grpcclient" "hyapp/pkg/grpchealth" - "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" "hyapp/pkg/roommq" + serviceapp "hyapp/pkg/servicekit/app" + servicegrpc "hyapp/pkg/servicekit/grpcserver" + servicehealth "hyapp/pkg/servicekit/health" + servicemq "hyapp/pkg/servicekit/mq" "hyapp/pkg/tencentim" "hyapp/pkg/usermq" "hyapp/pkg/walletmq" @@ -43,7 +46,6 @@ import ( grpcserver "hyapp/services/user-service/internal/transport/grpc" "google.golang.org/grpc" - healthgrpc "google.golang.org/grpc/health/grpc_health_v1" ) // App 装配 user-service gRPC 入口和底座依赖。 @@ -84,12 +86,8 @@ type App struct { cpLeaderboardRedisClose func() error // cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。 cfg config.Config - // workerCtx 统一控制后台 worker 生命周期。 - workerCtx context.Context - // workerCancel 停止补偿 worker。 - workerCancel context.CancelFunc - // workerWG 等待后台 worker 当前批次安全退出。 - workerWG sync.WaitGroup + // workers 统一控制后台 worker 生命周期。 + workers *serviceapp.BackgroundGroup // closeOnce 防止信号退出和 Serve 异常同时触发重复关闭。 closeOnce sync.Once } @@ -114,7 +112,7 @@ func New(cfg config.Config) (*App, error) { return nil, err } - server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("user-service"))) + server := servicegrpc.New("user-service") thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier( authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{ ProjectID: cfg.ThirdParty.Firebase.ProjectID, @@ -384,9 +382,8 @@ func New(cfg config.Config) (*App, error) { Check: mysqlRepo.Ping, }} health := grpchealth.NewServingChecker("user-service", healthDependencies...) - // user-service 目前使用简单 serving checker,存储探测由后续专用 health 可扩展。 - healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) - healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + // user-service 目前使用简单 serving checker,存储探测由 servicekit 统一注册。 + healthHTTP, err := servicehealth.Register(server, health, cfg.HealthHTTPAddr, cfg.NodeID) if err != nil { shutdownMQConsumers(mqConsumers) if loginRiskRedisClose != nil { @@ -408,8 +405,6 @@ func New(cfg config.Config) (*App, error) { _ = mysqlRepo.Close() return nil, err } - workerCtx, workerCancel := context.WithCancel(context.Background()) - return &App{ server: server, listener: listener, @@ -429,8 +424,7 @@ func New(cfg config.Config) (*App, error) { loginRiskRedisClose: loginRiskRedisClose, cpLeaderboardRedisClose: cpLeaderboardRedisClose, cfg: cfg, - workerCtx: workerCtx, - workerCancel: workerCancel, + workers: serviceapp.NewBackground(context.Background()), }, nil } @@ -449,10 +443,7 @@ func (a *App) Run() error { // 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。 a.health.MarkServing() defer func() { - if a.workerCancel != nil { - a.workerCancel() - } - a.workerWG.Wait() + a.workers.StopAndWait() shutdownMQConsumers(a.mqConsumers) a.shutdownUserOutboxProducer() }() @@ -471,14 +462,11 @@ func (a *App) Close() { a.closeOnce.Do(func() { // draining 会让 health 立即失败,避免下线进程继续接新 RPC。 a.health.MarkDraining() - if a.workerCancel != nil { - a.workerCancel() - } - a.workerWG.Wait() + a.workers.StopAndWait() shutdownMQConsumers(a.mqConsumers) a.shutdownUserOutboxProducer() // GracefulStop 等待已进入的 RPC 结束,避免 token/session 写入被硬切。 - grpcshutdown.GracefulStop(a.server, 15*time.Second) + servicegrpc.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.mysqlRepo != nil { // MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。 @@ -507,30 +495,28 @@ func (a *App) runUserOutboxWorker() { return } workerID := "user-outbox-" + a.cfg.NodeID - a.workerWG.Add(1) - go func() { - defer a.workerWG.Done() + a.workers.Go(func(ctx context.Context) { ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval) defer ticker.Stop() for { processed, err := a.processUserOutboxBatch(workerID) if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(a.workerCtx, "user_outbox_publish_failed", err, slog.String("worker_id", workerID)) + logx.Error(ctx, "user_outbox_publish_failed", err, slog.String("worker_id", workerID)) } if processed >= a.cfg.OutboxWorker.BatchSize { continue } select { - case <-a.workerCtx.Done(): + case <-ctx.Done(): return case <-ticker.C: } } - }() + }) } func (a *App) processUserOutboxBatch(workerID string) (int, error) { - records, err := a.mysqlRepo.ClaimPendingUserOutbox(a.workerCtx, workerID, a.cfg.OutboxWorker.BatchSize) + records, err := a.mysqlRepo.ClaimPendingUserOutbox(a.workers.Context(), workerID, a.cfg.OutboxWorker.BatchSize) if err != nil { return 0, err } @@ -582,16 +568,14 @@ func (a *App) startUserOutboxProducer() error { if a.userOutboxProducer == nil { return nil } - return a.userOutboxProducer.Start() + return servicemq.StartProducers([]*rocketmqx.Producer{a.userOutboxProducer}) } func (a *App) shutdownUserOutboxProducer() { if a.userOutboxProducer == nil { return } - if err := a.userOutboxProducer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) - } + servicemq.ShutdownProducers([]*rocketmqx.Producer{a.userOutboxProducer}) } func (a *App) runHealthHTTP() { @@ -812,21 +796,11 @@ func normalizeInviteRechargeType(value string) string { } func startMQConsumers(consumers []*rocketmqx.Consumer) error { - for _, consumer := range consumers { - if err := consumer.Start(); err != nil { - shutdownMQConsumers(consumers) - return err - } - } - return nil + return servicemq.StartConsumers(consumers) } func shutdownMQConsumers(consumers []*rocketmqx.Consumer) { - for _, consumer := range consumers { - if err := consumer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) - } - } + servicemq.ShutdownConsumers(consumers) } func walletOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 46c5aca5..b81deb98 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -1222,6 +1222,15 @@ CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( KEY idx_gift_diamond_ratio_region (app_code, region_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'; +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_diamond_ratio_configs' AND COLUMN_NAME = 'coin_return_ratio_percent') = 0, + 'ALTER TABLE gift_diamond_ratio_configs ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT ''收礼人金币返还比例,百分比'' AFTER ratio_percent', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS red_packet_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用红包', diff --git a/services/wallet-service/internal/app/app.go b/services/wallet-service/internal/app/app.go index e333c33e..92d26136 100644 --- a/services/wallet-service/internal/app/app.go +++ b/services/wallet-service/internal/app/app.go @@ -3,24 +3,19 @@ package app import ( "context" "errors" - "fmt" - "log/slog" "net" - "strings" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - healthgrpc "google.golang.org/grpc/health/grpc_health_v1" walletv1 "hyapp.local/api/proto/wallet/v1" - "hyapp/pkg/appcode" "hyapp/pkg/grpchealth" - "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" - "hyapp/pkg/walletmq" + servicegrpc "hyapp/pkg/servicekit/grpcserver" + servicehealth "hyapp/pkg/servicekit/health" "hyapp/services/wallet-service/internal/client" "hyapp/services/wallet-service/internal/client/googleplay" "hyapp/services/wallet-service/internal/client/mifapay" @@ -62,7 +57,6 @@ func New(cfg config.Config) (*App, error) { startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // wallet-service 的账户、交易、分录和 outbox 必须共享同一个 MySQL 事务边界。 repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN) if err != nil { return nil, err @@ -101,7 +95,7 @@ func New(cfg config.Config) (*App, error) { } } - server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service"))) + server := servicegrpc.New("wallet-service") svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn)) svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled, @@ -134,7 +128,7 @@ func New(cfg config.Config) (*App, error) { } svc.SetMifaPayClient(mifaPayClient) } else { - // 新功能配置默认打开,但商户私钥和平台公钥只能来自环境;缺失时启动不失败,只是不注入真实支付网关。 + logx.Warn(context.Background(), "mifapay_enabled_without_credentials") } } @@ -142,7 +136,7 @@ func New(cfg config.Config) (*App, error) { if v5payConfigReady(cfg.V5Pay) { svc.SetV5PayClient(v5pay.New(cfg.V5Pay)) } else { - // V5Pay 使用 merchantNo/appKey/secretKey 做 MD5 签名;缺任一项时不能发真实下单请求,但本地服务仍可启动。 + logx.Warn(context.Background(), "v5pay_enabled_without_credentials") } } @@ -151,12 +145,15 @@ func New(cfg config.Config) (*App, error) { } walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc)) walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc)) - health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{ - Name: "mysql", - Check: repository.Ping, + health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{ + ServiceName: "wallet-service", + HTTPAddr: cfg.HealthHTTPAddr, + NodeID: cfg.NodeID, + Dependencies: []grpchealth.Dependency{{ + Name: "mysql", + Check: repository.Ping, + }}, }) - healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) - healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) if err != nil { shutdownProducers(outboxProducer, realtimeOutboxProducer) _ = activityConn.Close() @@ -220,357 +217,15 @@ func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() a.closeBackgroundWorkers() - grpcshutdown.GracefulStop(a.server, 15*time.Second) + servicegrpc.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.activityConn != nil { _ = a.activityConn.Close() } a.shutdownMQ() if a.mysqlRepo != nil { - // MySQL 连接池最后关闭,避免正在 drain 的扣费请求丢失提交能力。 + _ = a.mysqlRepo.Close() } }) } - -func (a *App) runBackgroundWorkers() { - if a.walletSvc == nil { - return - } - ctx, cancel := context.WithCancel(context.Background()) - a.stopWorker = cancel - if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil { - excludedRealtimeTypes := []string(nil) - if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil { - // 普通账务 worker 在实时通道可用时主动跳过红包 UI 事件,避免两个 worker 抢同一行, - // 也避免红包事实继续排在 WalletBalanceChanged 和礼物流水之后。 - excludedRealtimeTypes = a.realtimeOutboxWorkerCfg.EventTypes - } - a.startWalletOutboxWorkers(ctx, "wallet-outbox", a.outboxWorkerCfg, a.outboxProducer, a.walletOutboxTopic, nil, excludedRealtimeTypes) - } - if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil { - a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil) - } - if a.externalRechargeReconcileWorkerCfg.Enabled { - workerID := "external-recharge-reconcile-" + a.nodeID - a.workers.Add(1) - go func() { - defer a.workers.Done() - ticker := time.NewTicker(a.externalRechargeReconcileWorkerCfg.PollInterval) - defer ticker.Stop() - for { - // 三方回调是主链路,但用户杀 App、本地/测试回调不可达、或支付平台短暂回调失败时, - // 服务端必须自己查单补偿;这里只扫 redirected 订单,入账仍由 repository 的幂等事务兜住。 - _, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize) - if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID)) - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } - }() - } - if a.redPacketExpiryWorkerCfg.Enabled { - redPacketWorkerID := "red-packet-expiry-" + a.nodeID - a.workers.Add(1) - go func() { - defer a.workers.Done() - ticker := time.NewTicker(a.redPacketExpiryWorkerCfg.PollInterval) - defer ticker.Stop() - for { - result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize) - if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", redPacketWorkerID)) - } - if result.ExpiredCount > 0 { - logx.Info(ctx, "red_packet_expired", - slog.String("worker_id", redPacketWorkerID), - slog.Int64("expired_count", int64(result.ExpiredCount)), - slog.Int64("refunded_amount", result.RefundedAmount), - ) - } - if result.ExpiredCount >= a.redPacketExpiryWorkerCfg.BatchSize { - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } - }() - } -} - -func (a *App) startWalletOutboxWorkers(ctx context.Context, workerName string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) { - if options.Concurrency <= 0 { - options.Concurrency = 1 - } - for workerIndex := 1; workerIndex <= options.Concurrency; workerIndex++ { - workerID := fmt.Sprintf("%s-%s-%02d", workerName, a.nodeID, workerIndex) - a.workers.Add(1) - go func() { - defer a.workers.Done() - a.runWalletOutboxWorker(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes) - }() - } -} - -func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) { - ticker := time.NewTicker(options.PollInterval) - defer ticker.Stop() - for { - processed, err := a.processWalletOutboxBatch(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes) - if err != nil && !errors.Is(err, context.Canceled) { - logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID)) - } - if processed >= options.BatchSize { - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) (int, error) { - if a.mysqlRepo == nil || producer == nil { - return 0, nil - } - records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{ - IncludeEventTypes: includeEventTypes, - ExcludeEventTypes: excludeEventTypes, - }) - if err != nil { - return 0, err - } - for _, record := range records { - if record.RetryCount >= options.MaxRetryCount { - markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) - err := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record)) - cancel() - if err != nil { - return 0, err - } - continue - } - publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) - err := a.publishWalletOutboxRecord(publishCtx, producer, topic, record) - cancel() - if err != nil { - nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli() - markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) - markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS) - markCancel() - if markErr != nil { - return 0, markErr - } - continue - } - markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) - markErr := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID) - markCancel() - if markErr != nil { - return 0, markErr - } - } - return len(records), nil -} - -func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error { - body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ - AppCode: record.AppCode, - EventID: record.EventID, - EventType: record.EventType, - TransactionID: record.TransactionID, - CommandID: record.CommandID, - UserID: record.UserID, - AssetType: record.AssetType, - AvailableDelta: record.AvailableDelta, - FrozenDelta: record.FrozenDelta, - PayloadJSON: record.PayloadJSON, - OccurredAtMS: record.CreatedAtMS, - }) - if err != nil { - return err - } - return producer.SendSync(ctx, rocketmqx.Message{ - Topic: topic, - Tag: walletmq.TagWalletOutboxEvent, - Keys: []string{record.EventID, record.TransactionID, record.CommandID}, - Body: body, - }) -} - -func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string { - last := strings.TrimSpace(record.LastError) - if last == "" { - return fmt.Sprintf("wallet outbox exceeded retry limit: retry_count=%d", record.RetryCount) - } - return last -} - -func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time.Duration { - if retryCount <= 0 { - return options.InitialBackoff - } - backoff := options.InitialBackoff - for i := 1; i < retryCount; i++ { - backoff *= 2 - if backoff >= options.MaxBackoff { - return options.MaxBackoff - } - } - return backoff -} - -func (a *App) closeBackgroundWorkers() { - if a.stopWorker != nil { - a.stopWorker() - } - a.workers.Wait() -} - -func (a *App) startMQ() error { - if a.outboxProducer != nil { - if err := a.outboxProducer.Start(); err != nil { - return err - } - } - if a.realtimeOutboxProducer != nil { - if err := a.realtimeOutboxProducer.Start(); err != nil { - if a.outboxProducer != nil { - _ = a.outboxProducer.Shutdown() - } - return err - } - } - if a.projectionConsumer != nil { - if err := a.projectionConsumer.Start(); err != nil { - shutdownProducers(a.outboxProducer, a.realtimeOutboxProducer) - return err - } - } - return nil -} - -func (a *App) shutdownMQ() { - if a.projectionConsumer != nil { - if err := a.projectionConsumer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_projection_consumer_shutdown_failed", slog.String("error", err.Error())) - } - } - if a.outboxProducer != nil { - if err := a.outboxProducer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) - } - } - if a.realtimeOutboxProducer != nil { - if err := a.realtimeOutboxProducer.Shutdown(); err != nil { - logx.Warn(context.Background(), "rocketmq_realtime_producer_shutdown_failed", slog.String("error", err.Error())) - } - } -} - -func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) { - if svc == nil || !cfg.ProjectionWorker.Enabled { - return nil, nil - } - consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker)) - if err != nil { - return nil, err - } - workerID := "wallet-projection-" + cfg.NodeID - if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { - walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) - if err != nil { - return err - } - _, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL) - return err - }); err != nil { - _ = consumer.Shutdown() - return nil, err - } - return consumer, nil -} - -func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig { - return rocketmqx.ConsumerConfig{ - EndpointConfig: rocketmqx.EndpointConfig{ - NameServers: cfg.NameServers, - NameServerDomain: cfg.NameServerDomain, - AccessKey: cfg.AccessKey, - SecretKey: cfg.SecretKey, - SecurityToken: cfg.SecurityToken, - Namespace: cfg.Namespace, - VIPChannel: cfg.VIPChannel, - }, - GroupName: projection.ConsumerGroup, - MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes, - ConsumePullBatch: int32(projection.BatchSize), - } -} - -func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig { - return rocketmqx.ProducerConfig{ - EndpointConfig: rocketmqx.EndpointConfig{ - NameServers: cfg.NameServers, - NameServerDomain: cfg.NameServerDomain, - AccessKey: cfg.AccessKey, - SecretKey: cfg.SecretKey, - SecurityToken: cfg.SecurityToken, - Namespace: cfg.Namespace, - VIPChannel: cfg.VIPChannel, - }, - GroupName: groupName, - SendTimeout: cfg.SendTimeout, - Retry: cfg.Retry, - } -} - -func mifapayConfigReady(cfg config.MifaPayConfig) bool { - return strings.TrimSpace(cfg.MerAccount) != "" && - strings.TrimSpace(cfg.MerNo) != "" && - strings.TrimSpace(cfg.PrivateKey) != "" && - strings.TrimSpace(cfg.PlatformPublicKey) != "" -} - -func v5payConfigReady(cfg config.V5PayConfig) bool { - return strings.TrimSpace(cfg.MerchantNo) != "" && - strings.TrimSpace(cfg.AppKey) != "" && - strings.TrimSpace(cfg.SecretKey) != "" -} - -func shutdownProducers(producers ...*rocketmqx.Producer) { - for _, producer := range producers { - if producer != nil { - _ = producer.Shutdown() - } - } -} - -func (a *App) runHealthHTTP() { - if a.healthHTTP == nil { - return - } - go func() { - if err := a.healthHTTP.Run(); err != nil { - logx.Error(context.Background(), "health_http_run_failed", err) - } - }() -} - -func (a *App) closeHealthHTTP() { - if a.healthHTTP == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = a.healthHTTP.Close(ctx) -} diff --git a/services/wallet-service/internal/app/background_workers.go b/services/wallet-service/internal/app/background_workers.go new file mode 100644 index 00000000..e8ec4ccb --- /dev/null +++ b/services/wallet-service/internal/app/background_workers.go @@ -0,0 +1,28 @@ +package app + +import "context" + +func (a *App) runBackgroundWorkers() { + if a.walletSvc == nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + a.stopWorker = cancel + if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil { + excludedRealtimeTypes := []string(nil) + if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil { + // 普通账务 worker 在实时通道可用时跳过红包 UI 事件,避免两个 worker 抢同一行。 + excludedRealtimeTypes = a.realtimeOutboxWorkerCfg.EventTypes + } + a.startWalletOutboxWorkers(ctx, "wallet-outbox", a.outboxWorkerCfg, a.outboxProducer, a.walletOutboxTopic, nil, excludedRealtimeTypes) + } + if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil { + a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil) + } + if a.externalRechargeReconcileWorkerCfg.Enabled { + a.startExternalRechargeReconcileWorker(ctx) + } + if a.redPacketExpiryWorkerCfg.Enabled { + a.startRedPacketExpiryWorker(ctx) + } +} diff --git a/services/wallet-service/internal/app/clients.go b/services/wallet-service/internal/app/clients.go new file mode 100644 index 00000000..3ee1dbd9 --- /dev/null +++ b/services/wallet-service/internal/app/clients.go @@ -0,0 +1,20 @@ +package app + +import ( + "strings" + + "hyapp/services/wallet-service/internal/config" +) + +func mifapayConfigReady(cfg config.MifaPayConfig) bool { + return strings.TrimSpace(cfg.MerAccount) != "" && + strings.TrimSpace(cfg.MerNo) != "" && + strings.TrimSpace(cfg.PrivateKey) != "" && + strings.TrimSpace(cfg.PlatformPublicKey) != "" +} + +func v5payConfigReady(cfg config.V5PayConfig) bool { + return strings.TrimSpace(cfg.MerchantNo) != "" && + strings.TrimSpace(cfg.AppKey) != "" && + strings.TrimSpace(cfg.SecretKey) != "" +} diff --git a/services/wallet-service/internal/app/external_recharge_worker.go b/services/wallet-service/internal/app/external_recharge_worker.go new file mode 100644 index 00000000..5408857d --- /dev/null +++ b/services/wallet-service/internal/app/external_recharge_worker.go @@ -0,0 +1,32 @@ +package app + +import ( + "context" + "errors" + "log/slog" + "time" + + "hyapp/pkg/logx" +) + +func (a *App) startExternalRechargeReconcileWorker(ctx context.Context) { + workerID := "external-recharge-reconcile-" + a.nodeID + a.workers.Add(1) + go func() { + defer a.workers.Done() + ticker := time.NewTicker(a.externalRechargeReconcileWorkerCfg.PollInterval) + defer ticker.Stop() + for { + // 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。 + _, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize) + if err != nil && !errors.Is(err, context.Canceled) { + logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID)) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} diff --git a/services/wallet-service/internal/app/health.go b/services/wallet-service/internal/app/health.go new file mode 100644 index 00000000..387ada85 --- /dev/null +++ b/services/wallet-service/internal/app/health.go @@ -0,0 +1,28 @@ +package app + +import ( + "context" + "time" + + "hyapp/pkg/logx" +) + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/wallet-service/internal/app/mq.go b/services/wallet-service/internal/app/mq.go new file mode 100644 index 00000000..140d3c68 --- /dev/null +++ b/services/wallet-service/internal/app/mq.go @@ -0,0 +1,89 @@ +package app + +import ( + "context" + + "hyapp/pkg/appcode" + "hyapp/pkg/rocketmqx" + servicemq "hyapp/pkg/servicekit/mq" + "hyapp/pkg/walletmq" + "hyapp/services/wallet-service/internal/config" + walletservice "hyapp/services/wallet-service/internal/service/wallet" +) + +func (a *App) startMQ() error { + if err := servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer}); err != nil { + return err + } + if err := servicemq.StartConsumers([]*rocketmqx.Consumer{a.projectionConsumer}); err != nil { + servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer}) + return err + } + return nil +} + +func (a *App) shutdownMQ() { + servicemq.ShutdownConsumers([]*rocketmqx.Consumer{a.projectionConsumer}) + servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer}) +} + +func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) { + if svc == nil || !cfg.ProjectionWorker.Enabled { + return nil, nil + } + consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker)) + if err != nil { + return nil, err + } + workerID := "wallet-projection-" + cfg.NodeID + if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) + if err != nil { + return err + } + _, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL) + return err + }); err != nil { + _ = consumer.Shutdown() + return nil, err + } + return consumer, nil +} + +func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig { + return rocketmqx.ConsumerConfig{ + EndpointConfig: rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + }, + GroupName: projection.ConsumerGroup, + MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes, + ConsumePullBatch: int32(projection.BatchSize), + } +} + +func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig { + return rocketmqx.ProducerConfig{ + EndpointConfig: rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + }, + GroupName: groupName, + SendTimeout: cfg.SendTimeout, + Retry: cfg.Retry, + } +} + +func shutdownProducers(producers ...*rocketmqx.Producer) { + servicemq.ShutdownProducers(producers) +} diff --git a/services/wallet-service/internal/app/outbox_worker.go b/services/wallet-service/internal/app/outbox_worker.go new file mode 100644 index 00000000..40aa06da --- /dev/null +++ b/services/wallet-service/internal/app/outbox_worker.go @@ -0,0 +1,148 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/logx" + "hyapp/pkg/rocketmqx" + "hyapp/pkg/walletmq" + "hyapp/services/wallet-service/internal/config" + mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" +) + +func (a *App) startWalletOutboxWorkers(ctx context.Context, workerName string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) { + if options.Concurrency <= 0 { + options.Concurrency = 1 + } + for workerIndex := 1; workerIndex <= options.Concurrency; workerIndex++ { + workerID := fmt.Sprintf("%s-%s-%02d", workerName, a.nodeID, workerIndex) + a.workers.Add(1) + go func() { + defer a.workers.Done() + a.runWalletOutboxWorker(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes) + }() + } +} + +func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) { + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + for { + processed, err := a.processWalletOutboxBatch(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes) + if err != nil && !errors.Is(err, context.Canceled) { + logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID)) + } + if processed >= options.BatchSize { + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) (int, error) { + if a.mysqlRepo == nil || producer == nil { + return 0, nil + } + records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{ + IncludeEventTypes: includeEventTypes, + ExcludeEventTypes: excludeEventTypes, + }) + if err != nil { + return 0, err + } + for _, record := range records { + if record.RetryCount >= options.MaxRetryCount { + markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) + err := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record)) + cancel() + if err != nil { + return 0, err + } + continue + } + publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) + err := a.publishWalletOutboxRecord(publishCtx, producer, topic, record) + cancel() + if err != nil { + nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli() + markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) + markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS) + markCancel() + if markErr != nil { + return 0, markErr + } + continue + } + markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) + markErr := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID) + markCancel() + if markErr != nil { + return 0, markErr + } + } + return len(records), nil +} + +func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error { + body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ + AppCode: record.AppCode, + EventID: record.EventID, + EventType: record.EventType, + TransactionID: record.TransactionID, + CommandID: record.CommandID, + UserID: record.UserID, + AssetType: record.AssetType, + AvailableDelta: record.AvailableDelta, + FrozenDelta: record.FrozenDelta, + PayloadJSON: record.PayloadJSON, + OccurredAtMS: record.CreatedAtMS, + }) + if err != nil { + return err + } + return producer.SendSync(ctx, rocketmqx.Message{ + Topic: topic, + Tag: walletmq.TagWalletOutboxEvent, + Keys: []string{record.EventID, record.TransactionID, record.CommandID}, + Body: body, + }) +} + +func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string { + last := strings.TrimSpace(record.LastError) + if last == "" { + return fmt.Sprintf("wallet outbox exceeded retry limit: retry_count=%d", record.RetryCount) + } + return last +} + +func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time.Duration { + if retryCount <= 0 { + return options.InitialBackoff + } + backoff := options.InitialBackoff + for i := 1; i < retryCount; i++ { + backoff *= 2 + if backoff >= options.MaxBackoff { + return options.MaxBackoff + } + } + return backoff +} + +func (a *App) closeBackgroundWorkers() { + if a.stopWorker != nil { + a.stopWorker() + } + a.workers.Wait() +} diff --git a/services/wallet-service/internal/app/red_packet_worker.go b/services/wallet-service/internal/app/red_packet_worker.go new file mode 100644 index 00000000..843329b8 --- /dev/null +++ b/services/wallet-service/internal/app/red_packet_worker.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "errors" + "log/slog" + "time" + + "hyapp/pkg/logx" +) + +func (a *App) startRedPacketExpiryWorker(ctx context.Context) { + workerID := "red-packet-expiry-" + a.nodeID + a.workers.Add(1) + go func() { + defer a.workers.Done() + ticker := time.NewTicker(a.redPacketExpiryWorkerCfg.PollInterval) + defer ticker.Stop() + for { + // 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。 + result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize) + if err != nil && !errors.Is(err, context.Canceled) { + logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID)) + } + if result.ExpiredCount > 0 { + logx.Info(ctx, "red_packet_expired", + slog.String("worker_id", workerID), + slog.Int64("expired_count", int64(result.ExpiredCount)), + slog.Int64("refunded_amount", result.RefundedAmount), + ) + } + if result.ExpiredCount >= a.redPacketExpiryWorkerCfg.BatchSize { + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} diff --git a/services/wallet-service/internal/domain/ledger/coin_seller.go b/services/wallet-service/internal/domain/ledger/coin_seller.go new file mode 100644 index 00000000..c7bf8887 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/coin_seller.go @@ -0,0 +1,138 @@ +package ledger + +import ( + "strings" +) + +// CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。 +type CoinSellerTransferCommand struct { + AppCode string + CommandID string + SellerUserID int64 + TargetUserID int64 + TargetCountryID int64 + SellerRegionID int64 + TargetRegionID int64 + Amount int64 + Reason string +} + +// CoinSellerTransferReceipt 是币商转账完成后的稳定回执。 +type CoinSellerTransferReceipt struct { + TransactionID string + SellerBalanceAfter int64 + TargetBalanceAfter int64 + Amount int64 + RechargeSequence int64 + RechargeUSDMinor int64 + RechargeCurrencyCode string + RechargePolicyID int64 + RechargePolicyVersion string + RechargePolicyCoinAmount int64 + RechargePolicyUSDMinorUnit int64 +} + +// CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。 +type CoinSellerSalaryExchangeRateTier struct { + RegionID int64 + MinUSDMinor int64 + MaxUSDMinor int64 + CoinPerUSD int64 + Status string + SortOrder int + UpdatedAtMS int64 +} + +// SalaryExchangeCommand 是用户把某个身份工资美元钱包兑换为自己普通金币的账务命令。 +type SalaryExchangeCommand struct { + AppCode string + CommandID string + UserID int64 + SalaryAssetType string + SalaryUSDMinor int64 + Reason string +} + +// SalaryExchangeReceipt 返回工资扣减和普通金币入账后的双边余额。 +type SalaryExchangeReceipt struct { + TransactionID string + UserID int64 + SalaryAssetType string + SalaryBalanceAfter int64 + CoinBalanceAfter int64 + SalaryUSDMinor int64 + CoinAmount int64 + CoinPerUSD int64 + CreatedAtMS int64 +} + +// SalaryTransferToCoinSellerCommand 是用户把某个身份工资美元钱包转给同区域币商专用金币库存的账务命令。 +type SalaryTransferToCoinSellerCommand struct { + AppCode string + CommandID string + SourceUserID int64 + SellerUserID int64 + SalaryAssetType string + SalaryUSDMinor int64 + RegionID int64 + Reason string +} + +// SalaryTransferToCoinSellerReceipt 返回工资扣减和币商库存入账后的双边余额与命中的区间。 +type SalaryTransferToCoinSellerReceipt struct { + TransactionID string + SourceUserID int64 + SellerUserID int64 + SalaryAssetType string + SourceSalaryBalanceAfter int64 + SellerBalanceAfter int64 + SalaryUSDMinor int64 + CoinAmount int64 + CoinPerUSD int64 + RateMinUSDMinor int64 + RateMaxUSDMinor int64 + CreatedAtMS int64 +} + +// CoinSellerStockCreditCommand 是后台给币商专用金币库存入账的最小账务命令。 +type CoinSellerStockCreditCommand struct { + AppCode string + CommandID string + SellerUserID int64 + SellerCountryID int64 + SellerRegionID int64 + StockType string + CoinAmount int64 + PaidCurrencyCode string + PaidAmountMicro int64 + PaymentRef string + EvidenceRef string + OperatorUserID int64 + Reason string +} + +// CoinSellerStockCreditReceipt 是币商库存入账成功后的稳定回执。 +type CoinSellerStockCreditReceipt struct { + TransactionID string + SellerUserID int64 + SellerCountryID int64 + SellerRegionID int64 + StockType string + CoinAmount int64 + PaidCurrencyCode string + PaidAmountMicro int64 + CountsAsSellerRecharge bool + BalanceAfter int64 + CreatedAtMS int64 +} + +func NormalizeCoinSellerStockType(stockType string) string { + switch strings.ToLower(strings.TrimSpace(stockType)) { + case StockTypeUSDTPurchase: + return StockTypeUSDTPurchase + case StockTypeCoinCompensation: + return StockTypeCoinCompensation + default: + return "" + } +} diff --git a/services/wallet-service/internal/domain/ledger/constants.go b/services/wallet-service/internal/domain/ledger/constants.go new file mode 100644 index 00000000..41597c65 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/constants.go @@ -0,0 +1,132 @@ +package ledger + +const ( + // AssetCoin 是用户送礼消费资产。 + AssetCoin = "COIN" + // AssetCoinSellerCoin 是币商专用金币资产,只能通过币商转账兑换成玩家普通 COIN。 + AssetCoinSellerCoin = "COIN_SELLER_COIN" + // AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。 + AssetRobotCoin = "ROBOT_COIN" + // AssetBagGift 是送礼回执里的支付来源标识,不是钱包资产账户;Bag 礼物只扣用户资源权益库存。 + AssetBagGift = "BAG" + // AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。 + AssetGiftPoint = "GIFT_POINT" + // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 + AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" + // AssetHostSalaryUSD 是主播工资美元钱包,只接收主播工资和月底剩余钻石折美元。 + AssetHostSalaryUSD = "HOST_SALARY_USD" + // AssetAgencySalaryUSD 是代理工资美元钱包,只接收主播结算同时产生的代理工资。 + AssetAgencySalaryUSD = "AGENCY_SALARY_USD" + // AssetBDSalaryUSD 是 BD 工资美元钱包,只接收 BD 政策结算工资。 + AssetBDSalaryUSD = "BD_SALARY_USD" + // AssetAdminSalaryUSD 是 Admin 工资美元钱包,只接收 Admin 政策结算工资。 + AssetAdminSalaryUSD = "ADMIN_SALARY_USD" + // SalaryExchangeCoinPerUSD 是工资直接兑换普通金币的固定比例,金额以 1 USD 为单位。 + SalaryExchangeCoinPerUSD int64 = 80000 + + // StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。 + StockTypeUSDTPurchase = "usdt_purchase" + // StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。 + StockTypeCoinCompensation = "coin_compensation" + // PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。 + PaidCurrencyUSDT = "USDT" + // RechargeProductStatusActive 表示内购商品已上架,可进入 App 充值页。 + RechargeProductStatusActive = "active" + // RechargeProductStatusDisabled 表示内购商品未上架,仅后台可见。 + RechargeProductStatusDisabled = "disabled" + // RechargeProductPlatformAndroid 表示 Google Play 侧内购商品。 + RechargeProductPlatformAndroid = "android" + // RechargeProductPlatformIOS 表示 Apple IAP 侧内购商品。 + RechargeProductPlatformIOS = "ios" + // RechargeProductPlatformWeb 表示 H5 站外充值商品,支付方式由订单选择,不绑定应用商店。 + RechargeProductPlatformWeb = "web" + // RechargeAudienceNormal 表示普通用户可购买的充值档位。 + RechargeAudienceNormal = "normal" + // RechargeAudienceCoinSeller 表示币商可购买的充值档位。 + RechargeAudienceCoinSeller = "coin_seller" + // RechargeChannelGoogle 是 android 平台内购渠道标识。 + RechargeChannelGoogle = "google" + // RechargeChannelApple 是 iOS 平台内购渠道标识。 + RechargeChannelApple = "apple" + // RechargeChannelExternal 是 H5 站外充值渠道,具体 provider 在外部订单上保存。 + RechargeChannelExternal = "external" + // PaymentProviderGooglePlay 是 Google Play 一次性内购支付渠道。 + PaymentProviderGooglePlay = "google_play" + // PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。 + PaymentProviderMifaPay = "mifapay" + // PaymentProviderV5Pay 是 V5Pay 三方收银台支付渠道。 + PaymentProviderV5Pay = "v5pay" + // PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。 + PaymentProviderUSDTTRC20 = "usdt_trc20" + // ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。 + ThirdPartyPaymentStatusActive = "active" + // ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。 + ThirdPartyPaymentStatusDisabled = "disabled" + // ExternalRechargeStatusPending 表示外部充值订单已创建但尚未确认到账。 + ExternalRechargeStatusPending = "pending" + // ExternalRechargeStatusRedirected 表示 MiFaPay 下单成功,用户已拿到跳转链接。 + ExternalRechargeStatusRedirected = "redirected" + // ExternalRechargeStatusCredited 表示外部充值已完成钱包入账。 + ExternalRechargeStatusCredited = "credited" + // ExternalRechargeStatusFailed 表示外部充值被验签、金额或链上校验拒绝。 + ExternalRechargeStatusFailed = "failed" + // PaymentStatusCredited 表示支付已完成校验并入账。 + PaymentStatusCredited = "credited" + // PaymentConsumeStatePending 表示已入账但 Google consume 尚未确认完成。 + PaymentConsumeStatePending = "consume_pending" + // PaymentConsumeStateConsumed 表示 Google consume 已确认完成。 + PaymentConsumeStateConsumed = "consumed" + // GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。 + GooglePurchaseStatePurchased = "PURCHASED" + + // HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。 + HostSalarySettlementModeDaily = "daily" + // HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。 + HostSalarySettlementModeHalfMonth = "half_month" + // HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。 + HostSalarySettlementTypeMonthEnd = "month_end" + // HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。 + HostSalarySettlementTriggerAutomatic = "automatic" + // HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。 + HostSalarySettlementTriggerManual = "manual" + + // VipStatusActive 表示 VIP 配置或用户会员状态当前有效。 + VipStatusActive = "active" + // VipStatusDisabled 表示 VIP 配置被后台停用。 + VipStatusDisabled = "disabled" + // VipGrantSourcePurchase 表示用户主动购买或续费 VIP。 + VipGrantSourcePurchase = "vip_purchase" + // VipGrantSourceActivity 表示活动系统赠送 VIP。 + VipGrantSourceActivity = "activity_grant" + // VipGrantSourceAdmin 表示后台人工赠送 VIP。 + VipGrantSourceAdmin = "admin_grant" + // VipGrantSourceManagerCenter 表示经理中心按权限赠送 VIP,账务仍复用后台赠送激活链路。 + VipGrantSourceManagerCenter = "manager_center" + + // GameOpDebit 表示游戏平台下注、局内道具等扣金币行为。 + GameOpDebit = "debit" + // GameOpCredit 表示游戏平台中奖、派奖等加金币行为。 + GameOpCredit = "credit" + // GameOpRefund 表示游戏平台扣款失败或取消后的退款入账。 + GameOpRefund = "refund" + // GameOpReverse 表示平台冲正;首版按扣回用户 COIN 处理。 + GameOpReverse = "reverse" + + // RedPacketTypeNormal 表示发出后立即可抢的普通红包。 + RedPacketTypeNormal = "normal" + // RedPacketTypeDelayed 表示发出后按后台秒数延迟打开的红包。 + RedPacketTypeDelayed = "delayed" + // RedPacketStatusWaitingOpen 表示延迟红包已扣款但未到打开时间。 + RedPacketStatusWaitingOpen = "waiting_open" + // RedPacketStatusActive 表示红包可领取。 + RedPacketStatusActive = "active" + // RedPacketStatusFinished 表示红包所有份额已领取。 + RedPacketStatusFinished = "finished" + // RedPacketStatusRefunded 表示红包过期未领取金额已退回。 + RedPacketStatusRefunded = "refunded" + // RedPacketClaimStatusClaimed 表示红包份额领取成功。 + RedPacketClaimStatusClaimed = "claimed" + + // RedPacketExpireSeconds 固定房内红包 24 小时过期退款。 + RedPacketExpireSeconds int32 = 24 * 60 * 60 +) diff --git a/services/wallet-service/internal/domain/ledger/game.go b/services/wallet-service/internal/domain/ledger/game.go new file mode 100644 index 00000000..88d220c1 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/game.go @@ -0,0 +1,42 @@ +package ledger + +import ( + "strings" +) + +// GameCoinChangeCommand 是 game-service 对钱包发起的游戏专用金币改账命令。 +type GameCoinChangeCommand struct { + AppCode string + CommandID string + UserID int64 + PlatformCode string + GameID string + ProviderOrderID string + ProviderRoundID string + OpType string + CoinAmount int64 + RoomID string + RequestHash string +} + +// GameCoinChangeReceipt 是游戏改账成功或幂等重放后的稳定回执。 +type GameCoinChangeReceipt struct { + TransactionID string + BalanceAfter int64 + IdempotentReplay bool +} + +func NormalizeGameOpType(opType string) string { + switch strings.ToLower(strings.TrimSpace(opType)) { + case GameOpDebit: + return GameOpDebit + case GameOpCredit: + return GameOpCredit + case GameOpRefund: + return GameOpRefund + case GameOpReverse: + return GameOpReverse + default: + return "" + } +} diff --git a/services/wallet-service/internal/domain/ledger/gift.go b/services/wallet-service/internal/domain/ledger/gift.go new file mode 100644 index 00000000..3f570d96 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/gift.go @@ -0,0 +1,119 @@ +package ledger + +import ( + "strings" +) + +// DebitGiftCommand 是 room-service 送礼扣费的账务命令。 +type DebitGiftCommand struct { + AppCode string + CommandID string + RoomID string + SenderUserID int64 + TargetUserID int64 + GiftID string + GiftCount int32 + PriceVersion string + RegionID int64 + // SenderRegionID 是送礼用户所属区域;用于按区域匹配礼物入主播周期钻石比例。 + SenderRegionID int64 + // TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。 + TargetIsHost bool + // TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。 + TargetHostRegionID int64 + // TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。 + TargetAgencyOwnerUserID int64 + // RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN,不给主播钻石,不进入真实礼物墙投影。 + RobotGift bool + // EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。 + EntitlementID string + // ChargeSource 区分 coin/bag;为空按 coin 兼容旧链路。 + ChargeSource string +} + +// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。 +type DebitGiftTargetCommand struct { + // CommandID 是单个目标交易的幂等键;同一批量送礼必须为每个目标派生独立值。 + CommandID string + // TargetUserID 是本次收礼用户;GIFT_POINT 已下线,新送礼不会再给目标用户积分入账。 + TargetUserID int64 + // TargetIsHost 只能由 gateway 注入,批量目标之间不能共享该身份快照。 + TargetIsHost bool + // TargetHostRegionID 是该目标主播身份所属区域。 + TargetHostRegionID int64 + // TargetAgencyOwnerUserID 是该目标主播当前代理 owner 收款快照。 + TargetAgencyOwnerUserID int64 +} + +// BatchDebitGiftCommand 在一个钱包事务中结算同一 sender 对多个 target 的同款礼物。 +type BatchDebitGiftCommand struct { + AppCode string + CommandID string + RoomID string + SenderUserID int64 + GiftID string + GiftCount int32 + PriceVersion string + RegionID int64 + SenderRegionID int64 + Targets []DebitGiftTargetCommand + EntitlementID string + ChargeSource string +} + +// Receipt 是账务命令落账后的稳定回执。 +type Receipt struct { + BillingReceiptID string + TransactionID string + CoinSpent int64 + ChargeAssetType string + ChargeAmount int64 + // GiftPointAdded 是历史回执字段,新送礼固定为 0;房间贡献和主播周期钻石只按真实扣费金额计算。 + GiftPointAdded int64 + HeatValue int64 + GiftTypeCode string + // CPRelationType 只在 CP 礼物类型上有值,room-service 会把它写入 RoomGiftSent 供 user-service 建关系申请。 + CPRelationType string + // 礼物展示字段是扣费时的资源快照,避免异步消费者再查礼物配置导致历史 IM 展示被后续配置修改影响。 + GiftName string + GiftIconURL string + GiftAnimationURL string + GiftEffectTypes []string + PriceVersion string + BalanceAfter int64 + // HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。 + HostPeriodDiamondAdded int64 + // HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。 + HostPeriodCycleKey string + EntitlementID string + ChargeSource string +} + +// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。 +type BatchGiftTargetReceipt struct { + TargetUserID int64 + CommandID string + Receipt Receipt +} + +// BatchGiftReceipt 同时返回聚合展示值和逐目标事实回执。 +type BatchGiftReceipt struct { + Aggregate Receipt + Targets []BatchGiftTargetReceipt +} + +func NormalizeGiftChargeAssetType(assetType string) string { + if strings.ToUpper(strings.TrimSpace(assetType)) == AssetBagGift { + return AssetBagGift + } + return AssetCoin +} + +func ValidGiftChargeAssetType(assetType string) bool { + switch strings.ToUpper(strings.TrimSpace(assetType)) { + case AssetCoin: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/domain/ledger/host_salary.go b/services/wallet-service/internal/domain/ledger/host_salary.go new file mode 100644 index 00000000..d93c9550 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/host_salary.go @@ -0,0 +1,64 @@ +package ledger + +// HostSalarySettlementBatchCommand 是 cron 或测试触发结算批处理的最小输入。 +type HostSalarySettlementBatchCommand struct { + AppCode string + RunID string + WorkerID string + BatchSize int + SettlementType string + // TriggerMode 只决定本次入账动作读取 automatic 还是 manual 政策;待结算账单展示可以同时读取两类政策。 + TriggerMode string + // SettlementRole 为空或 all 时完整结算主播和代理权益;后台人工入口传 host/agency 时只推进对应身份钱包。 + SettlementRole string + CycleKey string + // UserIDs 为空时扫描全量候选;后台人工批量结算会传入勾选主播,防止跨区域或跨周期误结算。 + UserIDs []int64 + NowMs int64 +} + +// HostSalarySettlementBatchResult 汇总一个结算批次的扫描和入账结果。 +type HostSalarySettlementBatchResult struct { + ClaimedCount int + ProcessedCount int + SuccessCount int + FailureCount int + HasMore bool +} + +// HostSalaryPolicy 是 wallet-service 结算读取的后台政策运行快照。 +type HostSalaryPolicy struct { + PolicyID uint64 + Name string + RegionID int64 + Status string + SettlementMode string + SettlementTriggerMode string + GiftCoinToDiamondRatio string + ResidualDiamondToUSDRate string + EffectiveFromMs int64 + EffectiveToMs int64 + Levels []HostSalaryPolicyLevel +} + +// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。 +type HostSalaryPolicyLevel struct { + LevelNo int + RequiredDiamonds int64 + HostSalaryUSDMinor int64 + HostCoinReward int64 + AgencySalaryUSDMinor int64 + Status string + SortOrder int +} + +// HostSalaryProgress 是主播当前工资周期的钻石累计投影,用于 H5 按工资政策计算距离下一等级的差值。 +type HostSalaryProgress struct { + HostUserID int64 + CycleKey string + RegionID int64 + AgencyOwnerUserID int64 + TotalDiamonds int64 + GiftDiamondTotal int64 + UpdatedAtMS int64 +} diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go deleted file mode 100644 index eeaa52ec..00000000 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ /dev/null @@ -1,1407 +0,0 @@ -package ledger - -import "strings" - -const ( - // AssetCoin 是用户送礼消费资产。 - AssetCoin = "COIN" - // AssetCoinSellerCoin 是币商专用金币资产,只能通过币商转账兑换成玩家普通 COIN。 - AssetCoinSellerCoin = "COIN_SELLER_COIN" - // AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。 - AssetRobotCoin = "ROBOT_COIN" - // AssetBagGift 是送礼回执里的支付来源标识,不是钱包资产账户;Bag 礼物只扣用户资源权益库存。 - AssetBagGift = "BAG" - // AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。 - AssetGiftPoint = "GIFT_POINT" - // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 - AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" - // AssetHostSalaryUSD 是主播工资美元钱包,只接收主播工资和月底剩余钻石折美元。 - AssetHostSalaryUSD = "HOST_SALARY_USD" - // AssetAgencySalaryUSD 是代理工资美元钱包,只接收主播结算同时产生的代理工资。 - AssetAgencySalaryUSD = "AGENCY_SALARY_USD" - // AssetBDSalaryUSD 是 BD 工资美元钱包,只接收 BD 政策结算工资。 - AssetBDSalaryUSD = "BD_SALARY_USD" - // AssetAdminSalaryUSD 是 Admin 工资美元钱包,只接收 Admin 政策结算工资。 - AssetAdminSalaryUSD = "ADMIN_SALARY_USD" - // SalaryExchangeCoinPerUSD 是工资直接兑换普通金币的固定比例,金额以 1 USD 为单位。 - SalaryExchangeCoinPerUSD int64 = 80000 - - // StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。 - StockTypeUSDTPurchase = "usdt_purchase" - // StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。 - StockTypeCoinCompensation = "coin_compensation" - // PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。 - PaidCurrencyUSDT = "USDT" - // RechargeProductStatusActive 表示内购商品已上架,可进入 App 充值页。 - RechargeProductStatusActive = "active" - // RechargeProductStatusDisabled 表示内购商品未上架,仅后台可见。 - RechargeProductStatusDisabled = "disabled" - // RechargeProductPlatformAndroid 表示 Google Play 侧内购商品。 - RechargeProductPlatformAndroid = "android" - // RechargeProductPlatformIOS 表示 Apple IAP 侧内购商品。 - RechargeProductPlatformIOS = "ios" - // RechargeProductPlatformWeb 表示 H5 站外充值商品,支付方式由订单选择,不绑定应用商店。 - RechargeProductPlatformWeb = "web" - // RechargeAudienceNormal 表示普通用户可购买的充值档位。 - RechargeAudienceNormal = "normal" - // RechargeAudienceCoinSeller 表示币商可购买的充值档位。 - RechargeAudienceCoinSeller = "coin_seller" - // RechargeChannelGoogle 是 android 平台内购渠道标识。 - RechargeChannelGoogle = "google" - // RechargeChannelApple 是 iOS 平台内购渠道标识。 - RechargeChannelApple = "apple" - // RechargeChannelExternal 是 H5 站外充值渠道,具体 provider 在外部订单上保存。 - RechargeChannelExternal = "external" - // PaymentProviderGooglePlay 是 Google Play 一次性内购支付渠道。 - PaymentProviderGooglePlay = "google_play" - // PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。 - PaymentProviderMifaPay = "mifapay" - // PaymentProviderV5Pay 是 V5Pay 三方收银台支付渠道。 - PaymentProviderV5Pay = "v5pay" - // PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。 - PaymentProviderUSDTTRC20 = "usdt_trc20" - // ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。 - ThirdPartyPaymentStatusActive = "active" - // ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。 - ThirdPartyPaymentStatusDisabled = "disabled" - // ExternalRechargeStatusPending 表示外部充值订单已创建但尚未确认到账。 - ExternalRechargeStatusPending = "pending" - // ExternalRechargeStatusRedirected 表示 MiFaPay 下单成功,用户已拿到跳转链接。 - ExternalRechargeStatusRedirected = "redirected" - // ExternalRechargeStatusCredited 表示外部充值已完成钱包入账。 - ExternalRechargeStatusCredited = "credited" - // ExternalRechargeStatusFailed 表示外部充值被验签、金额或链上校验拒绝。 - ExternalRechargeStatusFailed = "failed" - // PaymentStatusCredited 表示支付已完成校验并入账。 - PaymentStatusCredited = "credited" - // PaymentConsumeStatePending 表示已入账但 Google consume 尚未确认完成。 - PaymentConsumeStatePending = "consume_pending" - // PaymentConsumeStateConsumed 表示 Google consume 已确认完成。 - PaymentConsumeStateConsumed = "consumed" - // GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。 - GooglePurchaseStatePurchased = "PURCHASED" - - // HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。 - HostSalarySettlementModeDaily = "daily" - // HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。 - HostSalarySettlementModeHalfMonth = "half_month" - // HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。 - HostSalarySettlementTypeMonthEnd = "month_end" - // HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。 - HostSalarySettlementTriggerAutomatic = "automatic" - // HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。 - HostSalarySettlementTriggerManual = "manual" - - // VipStatusActive 表示 VIP 配置或用户会员状态当前有效。 - VipStatusActive = "active" - // VipStatusDisabled 表示 VIP 配置被后台停用。 - VipStatusDisabled = "disabled" - // VipGrantSourcePurchase 表示用户主动购买或续费 VIP。 - VipGrantSourcePurchase = "vip_purchase" - // VipGrantSourceActivity 表示活动系统赠送 VIP。 - VipGrantSourceActivity = "activity_grant" - // VipGrantSourceAdmin 表示后台人工赠送 VIP。 - VipGrantSourceAdmin = "admin_grant" - // VipGrantSourceManagerCenter 表示经理中心按权限赠送 VIP,账务仍复用后台赠送激活链路。 - VipGrantSourceManagerCenter = "manager_center" - - // GameOpDebit 表示游戏平台下注、局内道具等扣金币行为。 - GameOpDebit = "debit" - // GameOpCredit 表示游戏平台中奖、派奖等加金币行为。 - GameOpCredit = "credit" - // GameOpRefund 表示游戏平台扣款失败或取消后的退款入账。 - GameOpRefund = "refund" - // GameOpReverse 表示平台冲正;首版按扣回用户 COIN 处理。 - GameOpReverse = "reverse" - - // RedPacketTypeNormal 表示发出后立即可抢的普通红包。 - RedPacketTypeNormal = "normal" - // RedPacketTypeDelayed 表示发出后按后台秒数延迟打开的红包。 - RedPacketTypeDelayed = "delayed" - // RedPacketStatusWaitingOpen 表示延迟红包已扣款但未到打开时间。 - RedPacketStatusWaitingOpen = "waiting_open" - // RedPacketStatusActive 表示红包可领取。 - RedPacketStatusActive = "active" - // RedPacketStatusFinished 表示红包所有份额已领取。 - RedPacketStatusFinished = "finished" - // RedPacketStatusRefunded 表示红包过期未领取金额已退回。 - RedPacketStatusRefunded = "refunded" - // RedPacketClaimStatusClaimed 表示红包份额领取成功。 - RedPacketClaimStatusClaimed = "claimed" - - // RedPacketExpireSeconds 固定房内红包 24 小时过期退款。 - RedPacketExpireSeconds int32 = 24 * 60 * 60 -) - -// DebitGiftCommand 是 room-service 送礼扣费的账务命令。 -type DebitGiftCommand struct { - AppCode string - CommandID string - RoomID string - SenderUserID int64 - TargetUserID int64 - GiftID string - GiftCount int32 - PriceVersion string - RegionID int64 - // SenderRegionID 是送礼用户所属区域;用于按区域匹配礼物入主播周期钻石比例。 - SenderRegionID int64 - // TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。 - TargetIsHost bool - // TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。 - TargetHostRegionID int64 - // TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。 - TargetAgencyOwnerUserID int64 - // RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN,不给主播钻石,不进入真实礼物墙投影。 - RobotGift bool - // EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。 - EntitlementID string - // ChargeSource 区分 coin/bag;为空按 coin 兼容旧链路。 - ChargeSource string -} - -// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。 -type DebitGiftTargetCommand struct { - // CommandID 是单个目标交易的幂等键;同一批量送礼必须为每个目标派生独立值。 - CommandID string - // TargetUserID 是本次收礼用户;GIFT_POINT 已下线,新送礼不会再给目标用户积分入账。 - TargetUserID int64 - // TargetIsHost 只能由 gateway 注入,批量目标之间不能共享该身份快照。 - TargetIsHost bool - // TargetHostRegionID 是该目标主播身份所属区域。 - TargetHostRegionID int64 - // TargetAgencyOwnerUserID 是该目标主播当前代理 owner 收款快照。 - TargetAgencyOwnerUserID int64 -} - -// BatchDebitGiftCommand 在一个钱包事务中结算同一 sender 对多个 target 的同款礼物。 -type BatchDebitGiftCommand struct { - AppCode string - CommandID string - RoomID string - SenderUserID int64 - GiftID string - GiftCount int32 - PriceVersion string - RegionID int64 - SenderRegionID int64 - Targets []DebitGiftTargetCommand - EntitlementID string - ChargeSource string -} - -// Receipt 是账务命令落账后的稳定回执。 -type Receipt struct { - BillingReceiptID string - TransactionID string - CoinSpent int64 - ChargeAssetType string - ChargeAmount int64 - // GiftPointAdded 是历史回执字段,新送礼固定为 0;房间贡献和主播周期钻石只按真实扣费金额计算。 - GiftPointAdded int64 - HeatValue int64 - GiftTypeCode string - // CPRelationType 只在 CP 礼物类型上有值,room-service 会把它写入 RoomGiftSent 供 user-service 建关系申请。 - CPRelationType string - // 礼物展示字段是扣费时的资源快照,避免异步消费者再查礼物配置导致历史 IM 展示被后续配置修改影响。 - GiftName string - GiftIconURL string - GiftAnimationURL string - GiftEffectTypes []string - PriceVersion string - BalanceAfter int64 - // HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。 - HostPeriodDiamondAdded int64 - // HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。 - HostPeriodCycleKey string - EntitlementID string - ChargeSource string -} - -// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。 -type BatchGiftTargetReceipt struct { - TargetUserID int64 - CommandID string - Receipt Receipt -} - -// BatchGiftReceipt 同时返回聚合展示值和逐目标事实回执。 -type BatchGiftReceipt struct { - Aggregate Receipt - Targets []BatchGiftTargetReceipt -} - -// HostSalarySettlementBatchCommand 是 cron 或测试触发结算批处理的最小输入。 -type HostSalarySettlementBatchCommand struct { - AppCode string - RunID string - WorkerID string - BatchSize int - SettlementType string - // TriggerMode 只决定本次入账动作读取 automatic 还是 manual 政策;待结算账单展示可以同时读取两类政策。 - TriggerMode string - // SettlementRole 为空或 all 时完整结算主播和代理权益;后台人工入口传 host/agency 时只推进对应身份钱包。 - SettlementRole string - CycleKey string - // UserIDs 为空时扫描全量候选;后台人工批量结算会传入勾选主播,防止跨区域或跨周期误结算。 - UserIDs []int64 - NowMs int64 -} - -// HostSalarySettlementBatchResult 汇总一个结算批次的扫描和入账结果。 -type HostSalarySettlementBatchResult struct { - ClaimedCount int - ProcessedCount int - SuccessCount int - FailureCount int - HasMore bool -} - -// HostSalaryPolicy 是 wallet-service 结算读取的后台政策运行快照。 -type HostSalaryPolicy struct { - PolicyID uint64 - Name string - RegionID int64 - Status string - SettlementMode string - SettlementTriggerMode string - GiftCoinToDiamondRatio string - ResidualDiamondToUSDRate string - EffectiveFromMs int64 - EffectiveToMs int64 - Levels []HostSalaryPolicyLevel -} - -// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。 -type HostSalaryPolicyLevel struct { - LevelNo int - RequiredDiamonds int64 - HostSalaryUSDMinor int64 - HostCoinReward int64 - AgencySalaryUSDMinor int64 - Status string - SortOrder int -} - -// HostSalaryProgress 是主播当前工资周期的钻石累计投影,用于 H5 按工资政策计算距离下一等级的差值。 -type HostSalaryProgress struct { - HostUserID int64 - CycleKey string - RegionID int64 - AgencyOwnerUserID int64 - TotalDiamonds int64 - GiftDiamondTotal int64 - UpdatedAtMS int64 -} - -// CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。 -type CoinSellerTransferCommand struct { - AppCode string - CommandID string - SellerUserID int64 - TargetUserID int64 - TargetCountryID int64 - SellerRegionID int64 - TargetRegionID int64 - Amount int64 - Reason string -} - -// CoinSellerTransferReceipt 是币商转账完成后的稳定回执。 -type CoinSellerTransferReceipt struct { - TransactionID string - SellerBalanceAfter int64 - TargetBalanceAfter int64 - Amount int64 - RechargeSequence int64 - RechargeUSDMinor int64 - RechargeCurrencyCode string - RechargePolicyID int64 - RechargePolicyVersion string - RechargePolicyCoinAmount int64 - RechargePolicyUSDMinorUnit int64 -} - -// CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。 -type CoinSellerSalaryExchangeRateTier struct { - RegionID int64 - MinUSDMinor int64 - MaxUSDMinor int64 - CoinPerUSD int64 - Status string - SortOrder int - UpdatedAtMS int64 -} - -// SalaryExchangeCommand 是用户把某个身份工资美元钱包兑换为自己普通金币的账务命令。 -type SalaryExchangeCommand struct { - AppCode string - CommandID string - UserID int64 - SalaryAssetType string - SalaryUSDMinor int64 - Reason string -} - -// SalaryExchangeReceipt 返回工资扣减和普通金币入账后的双边余额。 -type SalaryExchangeReceipt struct { - TransactionID string - UserID int64 - SalaryAssetType string - SalaryBalanceAfter int64 - CoinBalanceAfter int64 - SalaryUSDMinor int64 - CoinAmount int64 - CoinPerUSD int64 - CreatedAtMS int64 -} - -// SalaryTransferToCoinSellerCommand 是用户把某个身份工资美元钱包转给同区域币商专用金币库存的账务命令。 -type SalaryTransferToCoinSellerCommand struct { - AppCode string - CommandID string - SourceUserID int64 - SellerUserID int64 - SalaryAssetType string - SalaryUSDMinor int64 - RegionID int64 - Reason string -} - -// SalaryTransferToCoinSellerReceipt 返回工资扣减和币商库存入账后的双边余额与命中的区间。 -type SalaryTransferToCoinSellerReceipt struct { - TransactionID string - SourceUserID int64 - SellerUserID int64 - SalaryAssetType string - SourceSalaryBalanceAfter int64 - SellerBalanceAfter int64 - SalaryUSDMinor int64 - CoinAmount int64 - CoinPerUSD int64 - RateMinUSDMinor int64 - RateMaxUSDMinor int64 - CreatedAtMS int64 -} - -// CoinSellerStockCreditCommand 是后台给币商专用金币库存入账的最小账务命令。 -type CoinSellerStockCreditCommand struct { - AppCode string - CommandID string - SellerUserID int64 - SellerCountryID int64 - SellerRegionID int64 - StockType string - CoinAmount int64 - PaidCurrencyCode string - PaidAmountMicro int64 - PaymentRef string - EvidenceRef string - OperatorUserID int64 - Reason string -} - -// CoinSellerStockCreditReceipt 是币商库存入账成功后的稳定回执。 -type CoinSellerStockCreditReceipt struct { - TransactionID string - SellerUserID int64 - SellerCountryID int64 - SellerRegionID int64 - StockType string - CoinAmount int64 - PaidCurrencyCode string - PaidAmountMicro int64 - CountsAsSellerRecharge bool - BalanceAfter int64 - CreatedAtMS int64 -} - -// RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。 -type RechargeBill struct { - AppCode string - TransactionID string - CommandID string - RechargeType string - Status string - ExternalRef string - UserID int64 - SellerUserID int64 - SellerRegionID int64 - TargetRegionID int64 - PolicyID int64 - PolicyVersion string - CurrencyCode string - CoinAmount int64 - USDMinorAmount int64 - ExchangeCoinAmount int64 - ExchangeUSDMinorAmount int64 - CreatedAtMS int64 -} - -// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。 -type ListRechargeBillsQuery struct { - AppCode string - UserID int64 - SellerUserID int64 - RegionID int64 - RechargeType string - Status string - Keyword string - StartAtMS int64 - EndAtMS int64 - Page int32 - PageSize int32 -} - -// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。 -type WalletFeatureFlags struct { - RechargeEnabled bool - DiamondExchangeEnabled bool -} - -// WalletOverview 是我的页和钱包首页共同使用的轻量摘要。 -type WalletOverview struct { - Balances []AssetBalance - FeatureFlags WalletFeatureFlags -} - -// WalletValueSummary 是我的页钱包卡片的最小余额投影。 -type WalletValueSummary struct { - CoinAmount int64 - UpdatedAtMS int64 -} - -// GiftWallResource 是礼物墙展示所需的资源快照字段,避免读取接口再依赖资源配置实时状态。 -type GiftWallResource struct { - AppCode string - ResourceID int64 - ResourceCode string - ResourceType string - Name string - Status string - Grantable bool - GrantStrategy string - WalletAssetType string - WalletAssetAmount int64 - UsageScopes []string - AssetURL string - PreviewURL string - AnimationURL string - MetadataJSON string - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -// GiftWallItem 是当前用户收到礼物的按 gift_id 聚合投影。 -type GiftWallItem struct { - GiftID string - GiftName string - ResourceID int64 - Resource GiftWallResource - ResourceSnapshotJSON string - GiftTypeCode string - PresentationJSON string - GiftCount int64 - TotalValue int64 - TotalCoinValue int64 - TotalDiamondValue int64 - // TotalGiftPoint 是历史礼物墙字段,新送礼不再累加,保留用于旧数据展示兼容。 - TotalGiftPoint int64 - TotalHeatValue int64 - ChargeAssetType string - LastPriceVersion string - FirstReceivedAtMS int64 - LastReceivedAtMS int64 - SortOrder int32 -} - -// UserGiftWall 汇总礼物墙首屏需要的全量统计和按礼物类型聚合列表。 -type UserGiftWall struct { - Items []GiftWallItem - GiftKindCount int64 - GiftTotalCount int64 - TotalValue int64 - TotalCoinValue int64 - TotalDiamondValue int64 - // TotalGiftPoint 是历史礼物墙汇总字段,新送礼不再累加,保留用于旧数据展示兼容。 - TotalGiftPoint int64 - TotalHeatValue int64 -} - -// RechargeProduct 是用户充值页读取的充值档位。 -type RechargeProduct struct { - AppCode string - AudienceType string - ProductID int64 - ProductCode string - ProductName string - Description string - Platform string - Channel string - CurrencyCode string - AmountMicro int64 - AmountMinor int64 - CoinAmount int64 - PolicyVersion string - Enabled bool - Status string - SortOrder int32 - RegionIDs []int64 - ResourceAssetType string - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -// ListRechargeProductsQuery 是后台内购商品配置列表的筛选条件。 -type ListRechargeProductsQuery struct { - AppCode string - Status string - Platform string - AudienceType string - RegionID int64 - Keyword string - Page int32 - PageSize int32 -} - -// RechargeProductCommand 是后台创建或更新内购商品配置的完整事实输入。 -type RechargeProductCommand struct { - AppCode string - AudienceType string - ProductID int64 - AmountMicro int64 - CoinAmount int64 - ProductName string - Description string - Platform string - RegionIDs []int64 - Enabled bool - OperatorUserID int64 -} - -// GooglePaymentCommand 是 App 完成 Google Play 支付后提交给后端确认和入账的命令。 -type GooglePaymentCommand struct { - AppCode string - CommandID string - UserID int64 - RegionID int64 - ProductID int64 - ProductCode string - PackageName string - PurchaseToken string - OrderID string - PurchaseTimeMS int64 -} - -// GooglePlayPurchase 是 Google Play Developer API 返回的一次性商品购买状态快照。 -type GooglePlayPurchase struct { - PackageName string - ProductID string - OrderID string - PurchaseState string - ConsumptionState string - AcknowledgementState string - PurchaseCompletionTime string - RawJSON string -} - -// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。 -type GooglePaymentReceipt struct { - PaymentOrderID string - TransactionID string - Status string - ProductID int64 - ProductCode string - CoinAmount int64 - Balance AssetBalance - IdempotentReplay bool - ConsumeState string -} - -// ThirdPartyPaymentMethod 是 MiFaPay 等三方支付方式的后台配置事实。 -type ThirdPartyPaymentMethod struct { - MethodID int64 - AppCode string - ProviderCode string - ProviderName string - CountryCode string - CountryName string - CurrencyCode string - PayWay string - PayType string - MethodName string - LogoURL string - Status string - USDToCurrencyRate string - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -// ThirdPartyPaymentChannel 聚合一个三方渠道及其国家/方式列表,供后台展开展示。 -type ThirdPartyPaymentChannel struct { - AppCode string - ProviderCode string - ProviderName string - Status string - SortOrder int32 - Methods []ThirdPartyPaymentMethod -} - -type ListThirdPartyPaymentChannelsQuery struct { - AppCode string - ProviderCode string - Status string - IncludeDisabledMethods bool -} - -type ThirdPartyPaymentMethodStatusCommand struct { - AppCode string - MethodID int64 - Enabled bool - OperatorUserID int64 -} - -type ThirdPartyPaymentRateCommand struct { - AppCode string - MethodID int64 - USDToCurrencyRate string - OperatorUserID int64 -} - -// H5RechargeOptionsQuery 是站外充值页在账号确认后读取商品和支付方式的最小条件。 -type H5RechargeOptionsQuery struct { - AppCode string - TargetUserID int64 - TargetRegionID int64 - TargetCountryCode string - AudienceType string -} - -type H5RechargeOptions struct { - Products []RechargeProduct - PaymentMethods []ThirdPartyPaymentMethod - USDTTRC20Enabled bool - USDTTRC20Address string -} - -// ExternalRechargeOrder 保存 H5 外部充值订单的订单、支付和入账状态。 -type ExternalRechargeOrder struct { - OrderID string - AppCode string - CommandID string - TargetUserID int64 - TargetRegionID int64 - TargetCountryCode string - AudienceType string - ProductID int64 - ProductCode string - ProductName string - CoinAmount int64 - USDMinorAmount int64 - ProviderCode string - PaymentMethodID int64 - CountryCode string - CurrencyCode string - ProviderAmountMinor int64 - PayWay string - PayType string - PayURL string - ProviderOrderID string - TxHash string - ReceiveAddress string - Status string - FailureReason string - TransactionID string - ProviderPayloadJSON string - CreatedAtMS int64 - UpdatedAtMS int64 - IdempotentReplay bool -} - -type CreateExternalRechargeOrderCommand struct { - AppCode string - CommandID string - TargetUserID int64 - TargetRegionID int64 - TargetCountryCode string - AudienceType string - ProductID int64 - ProviderCode string - PaymentMethodID int64 - ReturnURL string - NotifyURL string - ClientIP string - Language string - PayerName string - PayerAccount string - PayerEmail string -} - -type SubmitExternalRechargeTxCommand struct { - AppCode string - OrderID string - TargetUserID int64 - TxHash string -} - -type MifaPayNotification struct { - MerAccount string - Data string - Sign string -} - -// V5PayNotification 保存 V5Pay 回调原始字段;验签必须使用所有返回字段,避免未来新增字段绕过签名校验。 -type V5PayNotification struct { - Fields map[string]string - RawJSON string -} - -// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。 -type DiamondExchangeRule struct { - ExchangeType string - FromAssetType string - ToAssetType string - FromAmount int64 - ToAmount int64 - Enabled bool -} - -// WalletTransaction 是钱包流水页按用户分录查询的投影。 -type WalletTransaction struct { - EntryID int64 - TransactionID string - BizType string - AssetType string - AvailableDelta int64 - FrozenDelta int64 - AvailableAfter int64 - FrozenAfter int64 - CounterpartyUserID int64 - RoomID string - CreatedAtMS int64 -} - -// ListWalletTransactionsQuery 描述 App 钱包流水分页条件。 -type ListWalletTransactionsQuery struct { - AppCode string - UserID int64 - AssetType string - Page int32 - PageSize int32 -} - -// VipRewardItem 是 VIP 资源组权益的轻量展示投影。 -type VipRewardItem struct { - ResourceID int64 - ResourceCode string - ResourceType string - Name string - Quantity int64 - ExpiresAtMS int64 - AssetURL string - PreviewURL string - AnimationURL string -} - -// VipLevel 是可购买 VIP 等级配置。 -type VipLevel struct { - Level int32 - Name string - Status string - PriceCoin int64 - DurationMS int64 - RewardResourceGroupID int64 - RequiredRechargeCoinAmount int64 - UserRechargeCoinAmount int64 - RechargeGateRequired bool - PurchaseLockedReason string - RewardItems []VipRewardItem - CanPurchase bool - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -// UserVip 是用户当前会员状态。是否有效以 ExpiresAtMS 和当前时间判断。 -type UserVip struct { - UserID int64 - Level int32 - Name string - Active bool - StartedAtMS int64 - ExpiresAtMS int64 - UpdatedAtMS int64 -} - -// PurchaseVipCommand 是 App 购买或升级 VIP 的账务命令。 -type PurchaseVipCommand struct { - AppCode string - CommandID string - UserID int64 - Level int32 -} - -// PurchaseVipReceipt 是购买 VIP 成功后的稳定回执。 -type PurchaseVipReceipt struct { - OrderID string - TransactionID string - Vip UserVip - CoinSpent int64 - CoinBalanceAfter int64 - RewardItems []VipRewardItem -} - -// CPBreakupFeeCommand 是解除 CP/兄弟/姐妹关系时的专用金币扣费命令。 -type CPBreakupFeeCommand struct { - AppCode string - CommandID string - UserID int64 - RelationshipID string - RelationType string - Amount int64 -} - -// CPBreakupFeeReceipt 返回解除关系扣费流水和 COIN 账后余额。 -type CPBreakupFeeReceipt struct { - TransactionID string - CoinSpent int64 - CoinBalanceAfter int64 - Balance AssetBalance -} - -// WheelDrawDebitCommand 是转盘抽奖扣费命令;中奖和 RTP 不在钱包内决策,钱包只保留独立 reason 的 COIN 支出事实。 -type WheelDrawDebitCommand struct { - AppCode string - CommandID string - UserID int64 - WheelID string - DrawCount int32 - Amount int64 -} - -// WheelDrawDebitReceipt 返回转盘抽奖扣费流水,activity-service 会用该流水作为抽奖已付费事实。 -type WheelDrawDebitReceipt struct { - TransactionID string - CoinSpent int64 - CoinBalanceAfter int64 - Balance AssetBalance -} - -// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。 -type GrantVipCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Level int32 - GrantSource string - OperatorUserID int64 - Reason string -} - -// GrantVipReceipt 是赠送 VIP 成功后的稳定回执。 -type GrantVipReceipt struct { - TransactionID string - Vip UserVip - RewardItems []VipRewardItem -} - -// AdminVipLevelCommand 是后台保存 VIP 等级配置的完整事实输入。 -type AdminVipLevelCommand struct { - Level int32 - Name string - Status string - PriceCoin int64 - DurationMS int64 - RewardResourceGroupID int64 - RequiredRechargeCoinAmount int64 - SortOrder int32 -} - -// AssetBalance 是账户当前可用/冻结余额投影。 -type AssetBalance struct { - AppCode string - UserID int64 - AssetType string - AvailableAmount int64 - FrozenAmount int64 - Version int64 - UpdatedAtMs int64 -} - -// AdminCreditAssetCommand 是后台手动调账的最小审计命令,Amount 为正数入账、负数扣账。 -type AdminCreditAssetCommand struct { - AppCode string - CommandID string - TargetUserID int64 - AssetType string - Amount int64 - OperatorUserID int64 - Reason string - EvidenceRef string -} - -// TaskRewardCommand 是 activity-service 任务领奖的 COIN 入账命令。 -type TaskRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - TaskType string - TaskID string - CycleKey string - Reason string -} - -// TaskRewardReceipt 是任务奖励入账后的稳定回执。 -type TaskRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// LuckyGiftRewardCommand 是 activity-service 用抽奖 draw_id 发起的 COIN 入账命令。 -type LuckyGiftRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - DrawID string - RoomID string - VisibleRegionID int64 - CountryID int64 - GiftID string - PoolID string - Reason string -} - -// LuckyGiftRewardReceipt 是幸运礼物返奖入账后的稳定回执。 -type LuckyGiftRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// WheelRewardCommand 是 activity-service 用转盘 draw_id 发起的 COIN 入账命令。 -type WheelRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - DrawID string - WheelID string - SelectedTierID string - VisibleRegionID int64 - Reason string -} - -// WheelRewardReceipt 是转盘金币奖励入账后的稳定回执。 -type WheelRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// RoomTurnoverRewardCommand 是 activity-service 每周房间流水奖励的 COIN 入账命令。 -type RoomTurnoverRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - SettlementID string - RoomID string - PeriodStartMS int64 - PeriodEndMS int64 - CoinSpent int64 - TierID int64 - TierCode string - Reason string -} - -// RoomTurnoverRewardReceipt 是房间流水奖励入账后的稳定回执。 -type RoomTurnoverRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// InviteActivityRewardCommand 是 activity-service 邀请活动领奖的 COIN 入账命令。 -type InviteActivityRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - ClaimID string - RewardType string - TierID int64 - TierCode string - CycleKey string - ReachedValue int64 - Reason string -} - -// InviteActivityRewardReceipt 是邀请活动奖励入账后的稳定回执。 -type InviteActivityRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// AgencyOpeningRewardCommand 是 activity-service 代理开业流水档位奖励的 COIN 入账命令。 -type AgencyOpeningRewardCommand struct { - AppCode string - CommandID string - TargetUserID int64 - Amount int64 - ApplicationID string - CycleID string - AgencyID int64 - RankNo int32 - ScoreCoins int64 - Reason string -} - -// AgencyOpeningRewardReceipt 是代理开业流水档位奖励入账后的稳定回执。 -type AgencyOpeningRewardReceipt struct { - TransactionID string - Balance AssetBalance - Amount int64 - GrantedAtMS int64 -} - -// GameCoinChangeCommand 是 game-service 对钱包发起的游戏专用金币改账命令。 -type GameCoinChangeCommand struct { - AppCode string - CommandID string - UserID int64 - PlatformCode string - GameID string - ProviderOrderID string - ProviderRoundID string - OpType string - CoinAmount int64 - RoomID string - RequestHash string -} - -// GameCoinChangeReceipt 是游戏改账成功或幂等重放后的稳定回执。 -type GameCoinChangeReceipt struct { - TransactionID string - BalanceAfter int64 - IdempotentReplay bool -} - -// RedPacketConfig 是后台红包配置的服务端事实,发送路径必须由 wallet-service 强校验。 -type RedPacketConfig struct { - AppCode string - Enabled bool - CountTiers []int32 - AmountTiers []int64 - DelayedOpenSeconds int32 - ExpireSeconds int32 - DailySendLimit int32 - UpdatedByAdminID int64 - RuleURL string - CreatedAtMS int64 - UpdatedAtMS int64 -} - -// RedPacket 是红包资金池和状态的只读投影。 -type RedPacket struct { - AppCode string - PacketID string - CommandID string - SenderUserID int64 - RoomID string - RegionID int64 - PacketType string - TotalAmount int64 - PacketCount int32 - RemainingAmount int64 - RemainingCount int32 - Status string - OpenAtMS int64 - ExpiresAtMS int64 - CreatedAtMS int64 - UpdatedAtMS int64 - ClaimedByViewer bool - ViewerClaimAmount int64 - Claims []RedPacketClaim - RefundAmount int64 - RefundStatus string - RefundedAtMS int64 -} - -// RedPacketClaim 是单个用户抢红包的到账事实。 -type RedPacketClaim struct { - AppCode string - ClaimID string - CommandID string - PacketID string - UserID int64 - Amount int64 - WalletTransactionID string - Status string - FailureReason string - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type RedPacketCreateCommand struct { - AppCode string - CommandID string - SenderUserID int64 - RoomID string - RegionID int64 - PacketType string - TotalAmount int64 - PacketCount int32 -} - -type RedPacketCreateReceipt struct { - Packet RedPacket - BalanceAfter int64 -} - -type RedPacketClaimCommand struct { - AppCode string - CommandID string - PacketID string - UserID int64 -} - -type RedPacketClaimReceipt struct { - Claim RedPacketClaim - Packet RedPacket - BalanceAfter int64 -} - -type RedPacketRefundReceipt struct { - Packet RedPacket - RefundedAmount int64 -} - -type RedPacketQuery struct { - AppCode string - RoomID string - SenderUserID int64 - RegionID int64 - PacketType string - Status string - ViewerUserID int64 - StartAtMS int64 - EndAtMS int64 - Page int32 - PageSize int32 -} - -type RedPacketExpireResult struct { - ExpiredCount int32 - RefundedAmount int64 -} - -// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。 -func ValidAssetType(assetType string) bool { - switch assetType { - case AssetCoin, AssetCoinSellerCoin, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: - return true - default: - return false - } -} - -// ValidSalaryAssetType 只允许身份工资美元钱包进入工资兑换和转币商链路,避免普通金币或币商库存被误扣。 -func ValidSalaryAssetType(assetType string) bool { - switch strings.ToUpper(strings.TrimSpace(assetType)) { - case AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: - return true - default: - return false - } -} - -func NormalizeGiftChargeAssetType(assetType string) string { - if strings.ToUpper(strings.TrimSpace(assetType)) == AssetBagGift { - return AssetBagGift - } - return AssetCoin -} - -func NormalizeVipGrantSource(source string) string { - switch strings.ToLower(strings.TrimSpace(source)) { - case VipGrantSourcePurchase: - return VipGrantSourcePurchase - case VipGrantSourceActivity: - return VipGrantSourceActivity - case VipGrantSourceAdmin: - return VipGrantSourceAdmin - case VipGrantSourceManagerCenter: - return VipGrantSourceManagerCenter - default: - return "" - } -} - -func ValidGiftChargeAssetType(assetType string) bool { - switch strings.ToUpper(strings.TrimSpace(assetType)) { - case AssetCoin: - return true - default: - return false - } -} - -func NormalizeCoinSellerStockType(stockType string) string { - switch strings.ToLower(strings.TrimSpace(stockType)) { - case StockTypeUSDTPurchase: - return StockTypeUSDTPurchase - case StockTypeCoinCompensation: - return StockTypeCoinCompensation - default: - return "" - } -} - -func NormalizeRechargeProductStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case RechargeProductStatusActive: - return RechargeProductStatusActive - case RechargeProductStatusDisabled: - return RechargeProductStatusDisabled - default: - return "" - } -} - -func NormalizeRechargeProductPlatform(platform string) string { - switch strings.ToLower(strings.TrimSpace(platform)) { - case RechargeProductPlatformAndroid: - return RechargeProductPlatformAndroid - case RechargeProductPlatformIOS: - return RechargeProductPlatformIOS - case RechargeProductPlatformWeb: - return RechargeProductPlatformWeb - default: - return "" - } -} - -func RechargeChannelForPlatform(platform string) string { - switch NormalizeRechargeProductPlatform(platform) { - case RechargeProductPlatformAndroid: - return RechargeChannelGoogle - case RechargeProductPlatformIOS: - return RechargeChannelApple - case RechargeProductPlatformWeb: - return RechargeChannelExternal - default: - return "" - } -} - -func NormalizeRechargeAudienceType(audienceType string) string { - switch strings.ToLower(strings.TrimSpace(audienceType)) { - case "", RechargeAudienceNormal: - return RechargeAudienceNormal - case RechargeAudienceCoinSeller: - return RechargeAudienceCoinSeller - default: - return "" - } -} - -func NormalizePaymentProvider(provider string) string { - switch strings.ToLower(strings.TrimSpace(provider)) { - case PaymentProviderMifaPay: - return PaymentProviderMifaPay - case PaymentProviderV5Pay: - return PaymentProviderV5Pay - case PaymentProviderUSDTTRC20: - return PaymentProviderUSDTTRC20 - case PaymentProviderGooglePlay: - return PaymentProviderGooglePlay - default: - return "" - } -} - -func NormalizeThirdPartyPaymentStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case ThirdPartyPaymentStatusActive: - return ThirdPartyPaymentStatusActive - case ThirdPartyPaymentStatusDisabled: - return ThirdPartyPaymentStatusDisabled - default: - return "" - } -} - -func NormalizeExternalRechargeStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case ExternalRechargeStatusPending: - return ExternalRechargeStatusPending - case ExternalRechargeStatusRedirected: - return ExternalRechargeStatusRedirected - case ExternalRechargeStatusCredited: - return ExternalRechargeStatusCredited - case ExternalRechargeStatusFailed: - return ExternalRechargeStatusFailed - default: - return "" - } -} - -func NormalizeGameOpType(opType string) string { - switch strings.ToLower(strings.TrimSpace(opType)) { - case GameOpDebit: - return GameOpDebit - case GameOpCredit: - return GameOpCredit - case GameOpRefund: - return GameOpRefund - case GameOpReverse: - return GameOpReverse - default: - return "" - } -} - -func NormalizeRedPacketType(packetType string) string { - switch strings.ToUpper(strings.TrimSpace(packetType)) { - case "IMMEDIATE": - return RedPacketTypeNormal - case "DELAYED": - return RedPacketTypeDelayed - } - switch strings.ToLower(strings.TrimSpace(packetType)) { - case RedPacketTypeNormal: - return RedPacketTypeNormal - case RedPacketTypeDelayed: - return RedPacketTypeDelayed - default: - return "" - } -} - -func NormalizeRedPacketStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case RedPacketStatusWaitingOpen: - return RedPacketStatusWaitingOpen - case RedPacketStatusActive: - return RedPacketStatusActive - case RedPacketStatusFinished: - return RedPacketStatusFinished - case RedPacketStatusRefunded: - return RedPacketStatusRefunded - default: - return "" - } -} diff --git a/services/wallet-service/internal/domain/ledger/recharge.go b/services/wallet-service/internal/domain/ledger/recharge.go new file mode 100644 index 00000000..e640a50c --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/recharge.go @@ -0,0 +1,325 @@ +package ledger + +import ( + "strings" +) + +// RechargeProduct 是用户充值页读取的充值档位。 +type RechargeProduct struct { + AppCode string + AudienceType string + ProductID int64 + ProductCode string + ProductName string + Description string + Platform string + Channel string + CurrencyCode string + AmountMicro int64 + AmountMinor int64 + CoinAmount int64 + PolicyVersion string + Enabled bool + Status string + SortOrder int32 + RegionIDs []int64 + ResourceAssetType string + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// ListRechargeProductsQuery 是后台内购商品配置列表的筛选条件。 +type ListRechargeProductsQuery struct { + AppCode string + Status string + Platform string + AudienceType string + RegionID int64 + Keyword string + Page int32 + PageSize int32 +} + +// RechargeProductCommand 是后台创建或更新内购商品配置的完整事实输入。 +type RechargeProductCommand struct { + AppCode string + AudienceType string + ProductID int64 + AmountMicro int64 + CoinAmount int64 + ProductName string + Description string + Platform string + RegionIDs []int64 + Enabled bool + OperatorUserID int64 +} + +// GooglePaymentCommand 是 App 完成 Google Play 支付后提交给后端确认和入账的命令。 +type GooglePaymentCommand struct { + AppCode string + CommandID string + UserID int64 + RegionID int64 + ProductID int64 + ProductCode string + PackageName string + PurchaseToken string + OrderID string + PurchaseTimeMS int64 +} + +// GooglePlayPurchase 是 Google Play Developer API 返回的一次性商品购买状态快照。 +type GooglePlayPurchase struct { + PackageName string + ProductID string + OrderID string + PurchaseState string + ConsumptionState string + AcknowledgementState string + PurchaseCompletionTime string + RawJSON string +} + +// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。 +type GooglePaymentReceipt struct { + PaymentOrderID string + TransactionID string + Status string + ProductID int64 + ProductCode string + CoinAmount int64 + Balance AssetBalance + IdempotentReplay bool + ConsumeState string +} + +// ThirdPartyPaymentMethod 是 MiFaPay 等三方支付方式的后台配置事实。 +type ThirdPartyPaymentMethod struct { + MethodID int64 + AppCode string + ProviderCode string + ProviderName string + CountryCode string + CountryName string + CurrencyCode string + PayWay string + PayType string + MethodName string + LogoURL string + Status string + USDToCurrencyRate string + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// ThirdPartyPaymentChannel 聚合一个三方渠道及其国家/方式列表,供后台展开展示。 +type ThirdPartyPaymentChannel struct { + AppCode string + ProviderCode string + ProviderName string + Status string + SortOrder int32 + Methods []ThirdPartyPaymentMethod +} + +type ListThirdPartyPaymentChannelsQuery struct { + AppCode string + ProviderCode string + Status string + IncludeDisabledMethods bool +} + +type ThirdPartyPaymentMethodStatusCommand struct { + AppCode string + MethodID int64 + Enabled bool + OperatorUserID int64 +} + +type ThirdPartyPaymentRateCommand struct { + AppCode string + MethodID int64 + USDToCurrencyRate string + OperatorUserID int64 +} + +// H5RechargeOptionsQuery 是站外充值页在账号确认后读取商品和支付方式的最小条件。 +type H5RechargeOptionsQuery struct { + AppCode string + TargetUserID int64 + TargetRegionID int64 + TargetCountryCode string + AudienceType string +} + +type H5RechargeOptions struct { + Products []RechargeProduct + PaymentMethods []ThirdPartyPaymentMethod + USDTTRC20Enabled bool + USDTTRC20Address string +} + +// ExternalRechargeOrder 保存 H5 外部充值订单的订单、支付和入账状态。 +type ExternalRechargeOrder struct { + OrderID string + AppCode string + CommandID string + TargetUserID int64 + TargetRegionID int64 + TargetCountryCode string + AudienceType string + ProductID int64 + ProductCode string + ProductName string + CoinAmount int64 + USDMinorAmount int64 + ProviderCode string + PaymentMethodID int64 + CountryCode string + CurrencyCode string + ProviderAmountMinor int64 + PayWay string + PayType string + PayURL string + ProviderOrderID string + TxHash string + ReceiveAddress string + Status string + FailureReason string + TransactionID string + ProviderPayloadJSON string + CreatedAtMS int64 + UpdatedAtMS int64 + IdempotentReplay bool +} + +type CreateExternalRechargeOrderCommand struct { + AppCode string + CommandID string + TargetUserID int64 + TargetRegionID int64 + TargetCountryCode string + AudienceType string + ProductID int64 + ProviderCode string + PaymentMethodID int64 + ReturnURL string + NotifyURL string + ClientIP string + Language string + PayerName string + PayerAccount string + PayerEmail string +} + +type SubmitExternalRechargeTxCommand struct { + AppCode string + OrderID string + TargetUserID int64 + TxHash string +} + +type MifaPayNotification struct { + MerAccount string + Data string + Sign string +} + +// V5PayNotification 保存 V5Pay 回调原始字段;验签必须使用所有返回字段,避免未来新增字段绕过签名校验。 +type V5PayNotification struct { + Fields map[string]string + RawJSON string +} + +func NormalizeRechargeProductStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case RechargeProductStatusActive: + return RechargeProductStatusActive + case RechargeProductStatusDisabled: + return RechargeProductStatusDisabled + default: + return "" + } +} + +func NormalizeRechargeProductPlatform(platform string) string { + switch strings.ToLower(strings.TrimSpace(platform)) { + case RechargeProductPlatformAndroid: + return RechargeProductPlatformAndroid + case RechargeProductPlatformIOS: + return RechargeProductPlatformIOS + case RechargeProductPlatformWeb: + return RechargeProductPlatformWeb + default: + return "" + } +} + +func RechargeChannelForPlatform(platform string) string { + switch NormalizeRechargeProductPlatform(platform) { + case RechargeProductPlatformAndroid: + return RechargeChannelGoogle + case RechargeProductPlatformIOS: + return RechargeChannelApple + case RechargeProductPlatformWeb: + return RechargeChannelExternal + default: + return "" + } +} + +func NormalizeRechargeAudienceType(audienceType string) string { + switch strings.ToLower(strings.TrimSpace(audienceType)) { + case "", RechargeAudienceNormal: + return RechargeAudienceNormal + case RechargeAudienceCoinSeller: + return RechargeAudienceCoinSeller + default: + return "" + } +} + +func NormalizePaymentProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case PaymentProviderMifaPay: + return PaymentProviderMifaPay + case PaymentProviderV5Pay: + return PaymentProviderV5Pay + case PaymentProviderUSDTTRC20: + return PaymentProviderUSDTTRC20 + case PaymentProviderGooglePlay: + return PaymentProviderGooglePlay + default: + return "" + } +} + +func NormalizeThirdPartyPaymentStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case ThirdPartyPaymentStatusActive: + return ThirdPartyPaymentStatusActive + case ThirdPartyPaymentStatusDisabled: + return ThirdPartyPaymentStatusDisabled + default: + return "" + } +} + +func NormalizeExternalRechargeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case ExternalRechargeStatusPending: + return ExternalRechargeStatusPending + case ExternalRechargeStatusRedirected: + return ExternalRechargeStatusRedirected + case ExternalRechargeStatusCredited: + return ExternalRechargeStatusCredited + case ExternalRechargeStatusFailed: + return ExternalRechargeStatusFailed + default: + return "" + } +} diff --git a/services/wallet-service/internal/domain/ledger/red_packet.go b/services/wallet-service/internal/domain/ledger/red_packet.go new file mode 100644 index 00000000..858d868f --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/red_packet.go @@ -0,0 +1,146 @@ +package ledger + +import ( + "strings" +) + +// RedPacketConfig 是后台红包配置的服务端事实,发送路径必须由 wallet-service 强校验。 +type RedPacketConfig struct { + AppCode string + Enabled bool + CountTiers []int32 + AmountTiers []int64 + DelayedOpenSeconds int32 + ExpireSeconds int32 + DailySendLimit int32 + UpdatedByAdminID int64 + RuleURL string + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// RedPacket 是红包资金池和状态的只读投影。 +type RedPacket struct { + AppCode string + PacketID string + CommandID string + SenderUserID int64 + RoomID string + RegionID int64 + PacketType string + TotalAmount int64 + PacketCount int32 + RemainingAmount int64 + RemainingCount int32 + Status string + OpenAtMS int64 + ExpiresAtMS int64 + CreatedAtMS int64 + UpdatedAtMS int64 + ClaimedByViewer bool + ViewerClaimAmount int64 + Claims []RedPacketClaim + RefundAmount int64 + RefundStatus string + RefundedAtMS int64 +} + +// RedPacketClaim 是单个用户抢红包的到账事实。 +type RedPacketClaim struct { + AppCode string + ClaimID string + CommandID string + PacketID string + UserID int64 + Amount int64 + WalletTransactionID string + Status string + FailureReason string + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type RedPacketCreateCommand struct { + AppCode string + CommandID string + SenderUserID int64 + RoomID string + RegionID int64 + PacketType string + TotalAmount int64 + PacketCount int32 +} + +type RedPacketCreateReceipt struct { + Packet RedPacket + BalanceAfter int64 +} + +type RedPacketClaimCommand struct { + AppCode string + CommandID string + PacketID string + UserID int64 +} + +type RedPacketClaimReceipt struct { + Claim RedPacketClaim + Packet RedPacket + BalanceAfter int64 +} + +type RedPacketRefundReceipt struct { + Packet RedPacket + RefundedAmount int64 +} + +type RedPacketQuery struct { + AppCode string + RoomID string + SenderUserID int64 + RegionID int64 + PacketType string + Status string + ViewerUserID int64 + StartAtMS int64 + EndAtMS int64 + Page int32 + PageSize int32 +} + +type RedPacketExpireResult struct { + ExpiredCount int32 + RefundedAmount int64 +} + +func NormalizeRedPacketType(packetType string) string { + switch strings.ToUpper(strings.TrimSpace(packetType)) { + case "IMMEDIATE": + return RedPacketTypeNormal + case "DELAYED": + return RedPacketTypeDelayed + } + switch strings.ToLower(strings.TrimSpace(packetType)) { + case RedPacketTypeNormal: + return RedPacketTypeNormal + case RedPacketTypeDelayed: + return RedPacketTypeDelayed + default: + return "" + } +} + +func NormalizeRedPacketStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case RedPacketStatusWaitingOpen: + return RedPacketStatusWaitingOpen + case RedPacketStatusActive: + return RedPacketStatusActive + case RedPacketStatusFinished: + return RedPacketStatusFinished + case RedPacketStatusRefunded: + return RedPacketStatusRefunded + default: + return "" + } +} diff --git a/services/wallet-service/internal/domain/ledger/reward.go b/services/wallet-service/internal/domain/ledger/reward.go new file mode 100644 index 00000000..5059c5da --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/reward.go @@ -0,0 +1,170 @@ +package ledger + +// CPBreakupFeeCommand 是解除 CP/兄弟/姐妹关系时的专用金币扣费命令。 +type CPBreakupFeeCommand struct { + AppCode string + CommandID string + UserID int64 + RelationshipID string + RelationType string + Amount int64 +} + +// CPBreakupFeeReceipt 返回解除关系扣费流水和 COIN 账后余额。 +type CPBreakupFeeReceipt struct { + TransactionID string + CoinSpent int64 + CoinBalanceAfter int64 + Balance AssetBalance +} + +// WheelDrawDebitCommand 是转盘抽奖扣费命令;中奖和 RTP 不在钱包内决策,钱包只保留独立 reason 的 COIN 支出事实。 +type WheelDrawDebitCommand struct { + AppCode string + CommandID string + UserID int64 + WheelID string + DrawCount int32 + Amount int64 +} + +// WheelDrawDebitReceipt 返回转盘抽奖扣费流水,activity-service 会用该流水作为抽奖已付费事实。 +type WheelDrawDebitReceipt struct { + TransactionID string + CoinSpent int64 + CoinBalanceAfter int64 + Balance AssetBalance +} + +// TaskRewardCommand 是 activity-service 任务领奖的 COIN 入账命令。 +type TaskRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + TaskType string + TaskID string + CycleKey string + Reason string +} + +// TaskRewardReceipt 是任务奖励入账后的稳定回执。 +type TaskRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} + +// LuckyGiftRewardCommand 是 activity-service 用抽奖 draw_id 发起的 COIN 入账命令。 +type LuckyGiftRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + DrawID string + RoomID string + VisibleRegionID int64 + CountryID int64 + GiftID string + PoolID string + Reason string +} + +// LuckyGiftRewardReceipt 是幸运礼物返奖入账后的稳定回执。 +type LuckyGiftRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} + +// WheelRewardCommand 是 activity-service 用转盘 draw_id 发起的 COIN 入账命令。 +type WheelRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + DrawID string + WheelID string + SelectedTierID string + VisibleRegionID int64 + Reason string +} + +// WheelRewardReceipt 是转盘金币奖励入账后的稳定回执。 +type WheelRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} + +// RoomTurnoverRewardCommand 是 activity-service 每周房间流水奖励的 COIN 入账命令。 +type RoomTurnoverRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + SettlementID string + RoomID string + PeriodStartMS int64 + PeriodEndMS int64 + CoinSpent int64 + TierID int64 + TierCode string + Reason string +} + +// RoomTurnoverRewardReceipt 是房间流水奖励入账后的稳定回执。 +type RoomTurnoverRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} + +// InviteActivityRewardCommand 是 activity-service 邀请活动领奖的 COIN 入账命令。 +type InviteActivityRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + ClaimID string + RewardType string + TierID int64 + TierCode string + CycleKey string + ReachedValue int64 + Reason string +} + +// InviteActivityRewardReceipt 是邀请活动奖励入账后的稳定回执。 +type InviteActivityRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} + +// AgencyOpeningRewardCommand 是 activity-service 代理开业流水档位奖励的 COIN 入账命令。 +type AgencyOpeningRewardCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Amount int64 + ApplicationID string + CycleID string + AgencyID int64 + RankNo int32 + ScoreCoins int64 + Reason string +} + +// AgencyOpeningRewardReceipt 是代理开业流水档位奖励入账后的稳定回执。 +type AgencyOpeningRewardReceipt struct { + TransactionID string + Balance AssetBalance + Amount int64 + GrantedAtMS int64 +} diff --git a/services/wallet-service/internal/domain/ledger/vip.go b/services/wallet-service/internal/domain/ledger/vip.go new file mode 100644 index 00000000..18df3fd8 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/vip.go @@ -0,0 +1,111 @@ +package ledger + +import ( + "strings" +) + +// VipRewardItem 是 VIP 资源组权益的轻量展示投影。 +type VipRewardItem struct { + ResourceID int64 + ResourceCode string + ResourceType string + Name string + Quantity int64 + ExpiresAtMS int64 + AssetURL string + PreviewURL string + AnimationURL string +} + +// VipLevel 是可购买 VIP 等级配置。 +type VipLevel struct { + Level int32 + Name string + Status string + PriceCoin int64 + DurationMS int64 + RewardResourceGroupID int64 + RequiredRechargeCoinAmount int64 + UserRechargeCoinAmount int64 + RechargeGateRequired bool + PurchaseLockedReason string + RewardItems []VipRewardItem + CanPurchase bool + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// UserVip 是用户当前会员状态。是否有效以 ExpiresAtMS 和当前时间判断。 +type UserVip struct { + UserID int64 + Level int32 + Name string + Active bool + StartedAtMS int64 + ExpiresAtMS int64 + UpdatedAtMS int64 +} + +// PurchaseVipCommand 是 App 购买或升级 VIP 的账务命令。 +type PurchaseVipCommand struct { + AppCode string + CommandID string + UserID int64 + Level int32 +} + +// PurchaseVipReceipt 是购买 VIP 成功后的稳定回执。 +type PurchaseVipReceipt struct { + OrderID string + TransactionID string + Vip UserVip + CoinSpent int64 + CoinBalanceAfter int64 + RewardItems []VipRewardItem +} + +// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。 +type GrantVipCommand struct { + AppCode string + CommandID string + TargetUserID int64 + Level int32 + GrantSource string + OperatorUserID int64 + Reason string +} + +// GrantVipReceipt 是赠送 VIP 成功后的稳定回执。 +type GrantVipReceipt struct { + TransactionID string + Vip UserVip + RewardItems []VipRewardItem +} + +// AdminVipLevelCommand 是后台保存 VIP 等级配置的完整事实输入。 +type AdminVipLevelCommand struct { + Level int32 + Name string + Status string + PriceCoin int64 + DurationMS int64 + RewardResourceGroupID int64 + RequiredRechargeCoinAmount int64 + SortOrder int32 +} + +func NormalizeVipGrantSource(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case VipGrantSourcePurchase: + return VipGrantSourcePurchase + case VipGrantSourceActivity: + return VipGrantSourceActivity + case VipGrantSourceAdmin: + return VipGrantSourceAdmin + case VipGrantSourceManagerCenter: + return VipGrantSourceManagerCenter + default: + return "" + } +} diff --git a/services/wallet-service/internal/domain/ledger/wallet_query.go b/services/wallet-service/internal/domain/ledger/wallet_query.go new file mode 100644 index 00000000..6bc02496 --- /dev/null +++ b/services/wallet-service/internal/domain/ledger/wallet_query.go @@ -0,0 +1,195 @@ +package ledger + +import ( + "strings" +) + +// RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。 +type RechargeBill struct { + AppCode string + TransactionID string + CommandID string + RechargeType string + Status string + ExternalRef string + UserID int64 + SellerUserID int64 + SellerRegionID int64 + TargetRegionID int64 + PolicyID int64 + PolicyVersion string + CurrencyCode string + CoinAmount int64 + USDMinorAmount int64 + ExchangeCoinAmount int64 + ExchangeUSDMinorAmount int64 + CreatedAtMS int64 +} + +// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。 +type ListRechargeBillsQuery struct { + AppCode string + UserID int64 + SellerUserID int64 + RegionID int64 + RechargeType string + Status string + Keyword string + StartAtMS int64 + EndAtMS int64 + Page int32 + PageSize int32 +} + +// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。 +type WalletFeatureFlags struct { + RechargeEnabled bool + DiamondExchangeEnabled bool +} + +// WalletOverview 是我的页和钱包首页共同使用的轻量摘要。 +type WalletOverview struct { + Balances []AssetBalance + FeatureFlags WalletFeatureFlags +} + +// WalletValueSummary 是我的页钱包卡片的最小余额投影。 +type WalletValueSummary struct { + CoinAmount int64 + UpdatedAtMS int64 +} + +// GiftWallResource 是礼物墙展示所需的资源快照字段,避免读取接口再依赖资源配置实时状态。 +type GiftWallResource struct { + AppCode string + ResourceID int64 + ResourceCode string + ResourceType string + Name string + Status string + Grantable bool + GrantStrategy string + WalletAssetType string + WalletAssetAmount int64 + UsageScopes []string + AssetURL string + PreviewURL string + AnimationURL string + MetadataJSON string + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// GiftWallItem 是当前用户收到礼物的按 gift_id 聚合投影。 +type GiftWallItem struct { + GiftID string + GiftName string + ResourceID int64 + Resource GiftWallResource + ResourceSnapshotJSON string + GiftTypeCode string + PresentationJSON string + GiftCount int64 + TotalValue int64 + TotalCoinValue int64 + TotalDiamondValue int64 + // TotalGiftPoint 是历史礼物墙字段,新送礼不再累加,保留用于旧数据展示兼容。 + TotalGiftPoint int64 + TotalHeatValue int64 + ChargeAssetType string + LastPriceVersion string + FirstReceivedAtMS int64 + LastReceivedAtMS int64 + SortOrder int32 +} + +// UserGiftWall 汇总礼物墙首屏需要的全量统计和按礼物类型聚合列表。 +type UserGiftWall struct { + Items []GiftWallItem + GiftKindCount int64 + GiftTotalCount int64 + TotalValue int64 + TotalCoinValue int64 + TotalDiamondValue int64 + // TotalGiftPoint 是历史礼物墙汇总字段,新送礼不再累加,保留用于旧数据展示兼容。 + TotalGiftPoint int64 + TotalHeatValue int64 +} + +// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。 +type DiamondExchangeRule struct { + ExchangeType string + FromAssetType string + ToAssetType string + FromAmount int64 + ToAmount int64 + Enabled bool +} + +// WalletTransaction 是钱包流水页按用户分录查询的投影。 +type WalletTransaction struct { + EntryID int64 + TransactionID string + BizType string + AssetType string + AvailableDelta int64 + FrozenDelta int64 + AvailableAfter int64 + FrozenAfter int64 + CounterpartyUserID int64 + RoomID string + CreatedAtMS int64 +} + +// ListWalletTransactionsQuery 描述 App 钱包流水分页条件。 +type ListWalletTransactionsQuery struct { + AppCode string + UserID int64 + AssetType string + Page int32 + PageSize int32 +} + +// AssetBalance 是账户当前可用/冻结余额投影。 +type AssetBalance struct { + AppCode string + UserID int64 + AssetType string + AvailableAmount int64 + FrozenAmount int64 + Version int64 + UpdatedAtMs int64 +} + +// AdminCreditAssetCommand 是后台手动调账的最小审计命令,Amount 为正数入账、负数扣账。 +type AdminCreditAssetCommand struct { + AppCode string + CommandID string + TargetUserID int64 + AssetType string + Amount int64 + OperatorUserID int64 + Reason string + EvidenceRef string +} + +// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。 +func ValidAssetType(assetType string) bool { + switch assetType { + case AssetCoin, AssetCoinSellerCoin, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: + return true + default: + return false + } +} + +// ValidSalaryAssetType 只允许身份工资美元钱包进入工资兑换和转币商链路,避免普通金币或币商库存被误扣。 +func ValidSalaryAssetType(assetType string) bool { + switch strings.ToUpper(strings.TrimSpace(assetType)) { + case AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/domain/resource/badge.go b/services/wallet-service/internal/domain/resource/badge.go new file mode 100644 index 00000000..1a9d100c --- /dev/null +++ b/services/wallet-service/internal/domain/resource/badge.go @@ -0,0 +1,30 @@ +package resource + +// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection. +type BadgeGrantOutbox struct { + AppCode string + EventID string + EventType string + CommandID string + UserID int64 + GrantID string + GrantSource string + PayloadJSON string + Status string + AttemptCount int32 + FailureReason string + CreatedAtMS int64 + UpdatedAtMS int64 + Items []BadgeGrantItem +} + +// BadgeGrantItem is the badge subset of a resource grant item. +type BadgeGrantItem struct { + ResourceID int64 `json:"resource_id"` + ResourceCode string `json:"resource_code"` + ResourceType string `json:"resource_type"` + Name string `json:"name"` + EntitlementID string `json:"entitlement_id"` + ResourceSnapshotJSON string `json:"resource_snapshot_json"` + MetadataJSON string `json:"metadata_json"` +} diff --git a/services/wallet-service/internal/domain/resource/constants.go b/services/wallet-service/internal/domain/resource/constants.go new file mode 100644 index 00000000..da57d5ae --- /dev/null +++ b/services/wallet-service/internal/domain/resource/constants.go @@ -0,0 +1,73 @@ +package resource + +const ( + TypeAvatarFrame = "avatar_frame" + TypeProfileCard = "profile_card" + TypeCoin = "coin" + TypeVehicle = "vehicle" + TypeChatBubble = "chat_bubble" + TypeBadge = "badge" + TypeFloatingScreen = "floating_screen" + TypeGift = "gift" + TypeMicSeatIcon = "mic_seat_icon" + TypeMicSeatAnimation = "mic_seat_animation" + TypeEmojiPack = "emoji_pack" + + StatusActive = "active" + StatusDisabled = "disabled" + + GrantStrategyWalletCredit = "wallet_credit" + GrantStrategyNewEntitlement = "new_entitlement" + GrantStrategyExtendExpiry = "extend_expiry" + GrantStrategyIncreaseQuantity = "increase_quantity" + GrantStrategySetActiveFlag = "set_active_flag" + + GrantSubjectResource = "resource" + GrantSubjectGroup = "resource_group" + + GrantSourceAdmin = "admin" + GrantSourceManagerCenter = "manager_center" + GrantSourceGrowthLevel = "growth_level" + GrantSourceAchievement = "achievement" + GrantSourceResourceShop = "resource_shop" + GrantSourceGameRobotInit = "game_robot_init" + GrantStatusDone = "succeeded" + ResultWalletCredit = "wallet_credit" + ResultEntitlement = "entitlement" + + GroupItemTypeResource = "resource" + GroupItemTypeWalletAsset = "wallet_asset" + + GiftTypeNormal = "normal" + GiftTypeCP = "cp" + GiftTypeLucky = "lucky" + GiftTypeSuperLucky = "super_lucky" + GiftTypeExclusive = "exclusive" + GiftTypeNoble = "noble" + GiftTypeFlag = "flag" + GiftTypeActivity = "activity" + GiftTypeMagic = "magic" + GiftTypeCustom = "custom" + + GiftTypeTabKeyNormal = "Gift" + GiftTypeTabKeyCP = "CP" + GiftTypeTabKeyLucky = "Lucky" + GiftTypeTabKeySuperLucky = "Super Lucky" + GiftTypeTabKeyExclusive = "Exclusive" + GiftTypeTabKeyNoble = "Noble" + GiftTypeTabKeyFlag = "Flag" + GiftTypeTabKeyActivity = "Activity" + GiftTypeTabKeyMagic = "Magic" + GiftTypeTabKeyCustom = "Custom" + + GiftEffectAnimation = "animation" + GiftEffectMusic = "music" + GiftEffectGlobalBroadcast = "global_broadcast" + + PriceTypeCoin = "coin" + PriceTypeFree = "free" + + ShopDurationOneDay int32 = 1 + ShopDurationThreeDays int32 = 3 + ShopDurationSevenDays int32 = 7 +) diff --git a/services/wallet-service/internal/domain/resource/entitlement.go b/services/wallet-service/internal/domain/resource/entitlement.go new file mode 100644 index 00000000..8dd46f4c --- /dev/null +++ b/services/wallet-service/internal/domain/resource/entitlement.go @@ -0,0 +1,55 @@ +package resource + +type UserResourceEntitlement struct { + AppCode string + EntitlementID string + UserID int64 + ResourceID int64 + Resource Resource + Status string + Quantity int64 + RemainingQuantity int64 + EffectiveAtMS int64 + ExpiresAtMS int64 + SourceGrantID string + CreatedAtMS int64 + UpdatedAtMS int64 + Equipped bool +} + +type ListUserResourcesQuery struct { + AppCode string + UserID int64 + ResourceType string + ActiveOnly bool +} + +type EquipUserResourceCommand struct { + AppCode string + UserID int64 + ResourceID int64 + EntitlementID string +} + +type UnequipUserResourceCommand struct { + AppCode string + UserID int64 + ResourceType string +} + +type UnequipUserResourceResult struct { + ResourceType string + Unequipped bool + UpdatedAtMS int64 +} + +type BatchGetUserEquippedResourcesQuery struct { + AppCode string + UserIDs []int64 + ResourceTypes []string +} + +type UserEquippedResources struct { + UserID int64 + Resources []UserResourceEntitlement +} diff --git a/services/wallet-service/internal/domain/resource/gift_config.go b/services/wallet-service/internal/domain/resource/gift_config.go new file mode 100644 index 00000000..aa02f94e --- /dev/null +++ b/services/wallet-service/internal/domain/resource/gift_config.go @@ -0,0 +1,168 @@ +package resource + +import ( + "strings" + "unicode/utf8" +) + +type GiftConfig struct { + AppCode string + GiftID string + ResourceID int64 + Resource Resource + Status string + Name string + SortOrder int32 + PresentationJSON string + PriceVersion string + CoinPrice int64 + // GiftPointAmount 是历史字段;送礼结算不再读取,新配置固定为 0。 + GiftPointAmount int64 + HeatValue int64 + GiftTypeCode string + // CPRelationType 只在 gift_type_code=cp 的礼物上使用,值为 cp、brother 或 sister。 + CPRelationType string + ChargeAssetType string + EffectiveFromMS int64 + EffectiveToMS int64 + EffectTypes []string + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 + RegionIDs []int64 +} + +type GiftTypeConfig struct { + AppCode string + TypeCode string + Name string + TabKey string + Status string + SortOrder int32 + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type ListGiftConfigsQuery struct { + AppCode string + Status string + Keyword string + GiftTypeCode string + Page int32 + PageSize int32 + ActiveOnly bool + RegionID int64 + FilterRegion bool +} + +type ListGiftTypeConfigsQuery struct { + AppCode string + Status string + ActiveOnly bool +} + +type GiftConfigCommand struct { + AppCode string + GiftID string + ResourceID int64 + Status string + Name string + SortOrder int32 + PresentationJSON string + PriceVersion string + CoinPrice int64 + // GiftPointAmount 是历史字段;保存礼物价格时固定为 0,业务计算只读 CoinPrice。 + GiftPointAmount int64 + HeatValue int64 + EffectiveAtMS int64 + GiftTypeCode string + // CPRelationType 会写入 presentation_json,避免新增 gift_configs 表字段。 + CPRelationType string + ChargeAssetType string + EffectiveFromMS int64 + EffectiveToMS int64 + EffectTypes []string + OperatorUserID int64 + RegionIDs []int64 +} + +type GiftTypeConfigCommand struct { + AppCode string + TypeCode string + Name string + TabKey string + Status string + SortOrder int32 + OperatorUserID int64 +} + +func NormalizeGiftTypeCode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, "-", "_") + if value == "" { + return GiftTypeNormal + } + return value +} + +func ValidGiftTypeCode(value string) bool { + value = NormalizeGiftTypeCode(value) + if value == "" || len(value) > 32 { + return false + } + for index, char := range value { + if index == 0 && (char < 'a' || char > 'z') { + return false + } + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' { + continue + } + return false + } + return true +} + +func NormalizeGiftTypeTabKey(value string) string { + return strings.TrimSpace(value) +} + +func ValidGiftTypeTabKey(value string) bool { + value = NormalizeGiftTypeTabKey(value) + return value != "" && utf8.RuneCountInString(value) <= 32 +} + +func DefaultGiftTypeConfigs(appCode string) []GiftTypeConfig { + appCode = strings.TrimSpace(appCode) + defaults := []GiftTypeConfig{ + {TypeCode: GiftTypeNormal, Name: "普通礼物", TabKey: GiftTypeTabKeyNormal, Status: StatusActive, SortOrder: 10}, + {TypeCode: GiftTypeCP, Name: "CP礼物", TabKey: GiftTypeTabKeyCP, Status: StatusActive, SortOrder: 20}, + {TypeCode: GiftTypeLucky, Name: "幸运礼物", TabKey: GiftTypeTabKeyLucky, Status: StatusActive, SortOrder: 30}, + {TypeCode: GiftTypeSuperLucky, Name: "超级幸运礼物", TabKey: GiftTypeTabKeySuperLucky, Status: StatusActive, SortOrder: 40}, + {TypeCode: GiftTypeExclusive, Name: "专属礼物", TabKey: GiftTypeTabKeyExclusive, Status: StatusActive, SortOrder: 50}, + {TypeCode: GiftTypeNoble, Name: "贵族礼物", TabKey: GiftTypeTabKeyNoble, Status: StatusActive, SortOrder: 60}, + {TypeCode: GiftTypeFlag, Name: "国旗礼物", TabKey: GiftTypeTabKeyFlag, Status: StatusActive, SortOrder: 70}, + {TypeCode: GiftTypeActivity, Name: "活动礼物", TabKey: GiftTypeTabKeyActivity, Status: StatusActive, SortOrder: 80}, + {TypeCode: GiftTypeMagic, Name: "魔法礼物", TabKey: GiftTypeTabKeyMagic, Status: StatusActive, SortOrder: 90}, + {TypeCode: GiftTypeCustom, Name: "定制礼物", TabKey: GiftTypeTabKeyCustom, Status: StatusActive, SortOrder: 100}, + } + for index := range defaults { + defaults[index].AppCode = appCode + } + return defaults +} + +func NormalizeGiftEffectType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func ValidGiftEffectType(value string) bool { + switch NormalizeGiftEffectType(value) { + case GiftEffectAnimation, GiftEffectMusic, GiftEffectGlobalBroadcast: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/domain/resource/grant.go b/services/wallet-service/internal/domain/resource/grant.go new file mode 100644 index 00000000..6f35c604 --- /dev/null +++ b/services/wallet-service/internal/domain/resource/grant.go @@ -0,0 +1,60 @@ +package resource + +type ResourceGrantItem struct { + GrantItemID int64 + GrantID string + ResourceID int64 + ResourceSnapshotJSON string + Quantity int64 + DurationMS int64 + ResultType string + WalletTransactionID string + EntitlementID string + CreatedAtMS int64 +} + +type ResourceGrant struct { + AppCode string + GrantID string + CommandID string + TargetUserID int64 + GrantSource string + GrantSubjectType string + GrantSubjectID string + Status string + Reason string + OperatorUserID int64 + Items []ResourceGrantItem + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type GrantResourceCommand struct { + AppCode string + CommandID string + TargetUserID int64 + ResourceID int64 + Quantity int64 + DurationMS int64 + Reason string + OperatorUserID int64 + GrantSource string +} + +type GrantResourceGroupCommand struct { + AppCode string + CommandID string + TargetUserID int64 + GroupID int64 + Reason string + OperatorUserID int64 + GrantSource string +} + +type ListResourceGrantsQuery struct { + AppCode string + TargetUserID int64 + Status string + Page int32 + PageSize int32 +} diff --git a/services/wallet-service/internal/domain/resource/group.go b/services/wallet-service/internal/domain/resource/group.go new file mode 100644 index 00000000..65963d04 --- /dev/null +++ b/services/wallet-service/internal/domain/resource/group.go @@ -0,0 +1,92 @@ +package resource + +import ( + "strings" +) + +type ResourceGroupItem struct { + GroupItemID int64 + GroupID int64 + ItemType string + ResourceID int64 + Resource Resource + WalletAssetType string + WalletAssetAmount int64 + Quantity int64 + DurationMS int64 + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type ResourceGroup struct { + AppCode string + GroupID int64 + GroupCode string + Name string + Status string + Description string + SortOrder int32 + Items []ResourceGroupItem + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type ResourceGroupItemInput struct { + ItemType string + ResourceID int64 + WalletAssetType string + WalletAssetAmount int64 + Quantity int64 + DurationMS int64 + SortOrder int32 +} + +type ListResourceGroupsQuery struct { + AppCode string + Status string + Keyword string + Page int32 + PageSize int32 + ActiveOnly bool +} + +type ResourceGroupCommand struct { + AppCode string + GroupID int64 + GroupCode string + Name string + Status string + Description string + SortOrder int32 + Items []ResourceGroupItemInput + OperatorUserID int64 +} + +func ValidGrantStrategy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case GrantStrategyWalletCredit, GrantStrategyNewEntitlement, GrantStrategyExtendExpiry, GrantStrategyIncreaseQuantity, GrantStrategySetActiveFlag: + return true + default: + return false + } +} + +func NormalizeGroupItemType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return GroupItemTypeResource + } + return value +} + +func ValidGroupItemType(value string) bool { + switch NormalizeGroupItemType(value) { + case GroupItemTypeResource, GroupItemTypeWalletAsset: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/domain/resource/resource.go b/services/wallet-service/internal/domain/resource/resource.go index 9ce329ca..657d9466 100644 --- a/services/wallet-service/internal/domain/resource/resource.go +++ b/services/wallet-service/internal/domain/resource/resource.go @@ -1,82 +1,8 @@ package resource import ( - "strings" - "unicode/utf8" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -const ( - TypeAvatarFrame = "avatar_frame" - TypeProfileCard = "profile_card" - TypeCoin = "coin" - TypeVehicle = "vehicle" - TypeChatBubble = "chat_bubble" - TypeBadge = "badge" - TypeFloatingScreen = "floating_screen" - TypeGift = "gift" - TypeMicSeatIcon = "mic_seat_icon" - TypeMicSeatAnimation = "mic_seat_animation" - TypeEmojiPack = "emoji_pack" - - StatusActive = "active" - StatusDisabled = "disabled" - - GrantStrategyWalletCredit = "wallet_credit" - GrantStrategyNewEntitlement = "new_entitlement" - GrantStrategyExtendExpiry = "extend_expiry" - GrantStrategyIncreaseQuantity = "increase_quantity" - GrantStrategySetActiveFlag = "set_active_flag" - - GrantSubjectResource = "resource" - GrantSubjectGroup = "resource_group" - - GrantSourceAdmin = "admin" - GrantSourceManagerCenter = "manager_center" - GrantSourceGrowthLevel = "growth_level" - GrantSourceAchievement = "achievement" - GrantSourceResourceShop = "resource_shop" - GrantSourceGameRobotInit = "game_robot_init" - GrantStatusDone = "succeeded" - ResultWalletCredit = "wallet_credit" - ResultEntitlement = "entitlement" - - GroupItemTypeResource = "resource" - GroupItemTypeWalletAsset = "wallet_asset" - - GiftTypeNormal = "normal" - GiftTypeCP = "cp" - GiftTypeLucky = "lucky" - GiftTypeSuperLucky = "super_lucky" - GiftTypeExclusive = "exclusive" - GiftTypeNoble = "noble" - GiftTypeFlag = "flag" - GiftTypeActivity = "activity" - GiftTypeMagic = "magic" - GiftTypeCustom = "custom" - - GiftTypeTabKeyNormal = "Gift" - GiftTypeTabKeyCP = "CP" - GiftTypeTabKeyLucky = "Lucky" - GiftTypeTabKeySuperLucky = "Super Lucky" - GiftTypeTabKeyExclusive = "Exclusive" - GiftTypeTabKeyNoble = "Noble" - GiftTypeTabKeyFlag = "Flag" - GiftTypeTabKeyActivity = "Activity" - GiftTypeTabKeyMagic = "Magic" - GiftTypeTabKeyCustom = "Custom" - - GiftEffectAnimation = "animation" - GiftEffectMusic = "music" - GiftEffectGlobalBroadcast = "global_broadcast" - - PriceTypeCoin = "coin" - PriceTypeFree = "free" - - ShopDurationOneDay int32 = 1 - ShopDurationThreeDays int32 = 3 - ShopDurationSevenDays int32 = 7 + "strings" ) type Resource struct { @@ -107,160 +33,6 @@ type Resource struct { ManagerGrantEnabled bool } -type ResourceGroupItem struct { - GroupItemID int64 - GroupID int64 - ItemType string - ResourceID int64 - Resource Resource - WalletAssetType string - WalletAssetAmount int64 - Quantity int64 - DurationMS int64 - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type ResourceGroup struct { - AppCode string - GroupID int64 - GroupCode string - Name string - Status string - Description string - SortOrder int32 - Items []ResourceGroupItem - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type GiftConfig struct { - AppCode string - GiftID string - ResourceID int64 - Resource Resource - Status string - Name string - SortOrder int32 - PresentationJSON string - PriceVersion string - CoinPrice int64 - // GiftPointAmount 是历史字段;送礼结算不再读取,新配置固定为 0。 - GiftPointAmount int64 - HeatValue int64 - GiftTypeCode string - // CPRelationType 只在 gift_type_code=cp 的礼物上使用,值为 cp、brother 或 sister。 - CPRelationType string - ChargeAssetType string - EffectiveFromMS int64 - EffectiveToMS int64 - EffectTypes []string - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 - RegionIDs []int64 -} - -type GiftTypeConfig struct { - AppCode string - TypeCode string - Name string - TabKey string - Status string - SortOrder int32 - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type ResourceShopItem struct { - AppCode string - ShopItemID int64 - ResourceID int64 - Resource Resource - Status string - DurationDays int32 - PriceType string - CoinPrice int64 - EffectiveFromMS int64 - EffectiveToMS int64 - SortOrder int32 - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type ResourceShopPurchaseOrder struct { - AppCode string - OrderID string - CommandID string - UserID int64 - ShopItemID int64 - ResourceID int64 - Resource Resource - ResourceSnapshotJSON string - DurationDays int32 - PriceCoin int64 - Status string - WalletTransactionID string - ResourceGrantID string - EntitlementID string - CreatedAtMS int64 - UpdatedAtMS int64 -} - -type UserResourceEntitlement struct { - AppCode string - EntitlementID string - UserID int64 - ResourceID int64 - Resource Resource - Status string - Quantity int64 - RemainingQuantity int64 - EffectiveAtMS int64 - ExpiresAtMS int64 - SourceGrantID string - CreatedAtMS int64 - UpdatedAtMS int64 - Equipped bool -} - -type ResourceGrantItem struct { - GrantItemID int64 - GrantID string - ResourceID int64 - ResourceSnapshotJSON string - Quantity int64 - DurationMS int64 - ResultType string - WalletTransactionID string - EntitlementID string - CreatedAtMS int64 -} - -type ResourceGrant struct { - AppCode string - GrantID string - CommandID string - TargetUserID int64 - GrantSource string - GrantSubjectType string - GrantSubjectID string - Status string - Reason string - OperatorUserID int64 - Items []ResourceGrantItem - CreatedAtMS int64 - UpdatedAtMS int64 -} - type ListResourcesQuery struct { AppCode string ResourceType string @@ -305,246 +77,6 @@ type StatusCommand struct { OperatorUserID int64 } -type ResourceGroupItemInput struct { - ItemType string - ResourceID int64 - WalletAssetType string - WalletAssetAmount int64 - Quantity int64 - DurationMS int64 - SortOrder int32 -} - -type ListResourceGroupsQuery struct { - AppCode string - Status string - Keyword string - Page int32 - PageSize int32 - ActiveOnly bool -} - -type ResourceGroupCommand struct { - AppCode string - GroupID int64 - GroupCode string - Name string - Status string - Description string - SortOrder int32 - Items []ResourceGroupItemInput - OperatorUserID int64 -} - -type ListGiftConfigsQuery struct { - AppCode string - Status string - Keyword string - GiftTypeCode string - Page int32 - PageSize int32 - ActiveOnly bool - RegionID int64 - FilterRegion bool -} - -type ListGiftTypeConfigsQuery struct { - AppCode string - Status string - ActiveOnly bool -} - -type GiftConfigCommand struct { - AppCode string - GiftID string - ResourceID int64 - Status string - Name string - SortOrder int32 - PresentationJSON string - PriceVersion string - CoinPrice int64 - // GiftPointAmount 是历史字段;保存礼物价格时固定为 0,业务计算只读 CoinPrice。 - GiftPointAmount int64 - HeatValue int64 - EffectiveAtMS int64 - GiftTypeCode string - // CPRelationType 会写入 presentation_json,避免新增 gift_configs 表字段。 - CPRelationType string - ChargeAssetType string - EffectiveFromMS int64 - EffectiveToMS int64 - EffectTypes []string - OperatorUserID int64 - RegionIDs []int64 -} - -type GiftTypeConfigCommand struct { - AppCode string - TypeCode string - Name string - TabKey string - Status string - SortOrder int32 - OperatorUserID int64 -} - -type GrantResourceCommand struct { - AppCode string - CommandID string - TargetUserID int64 - ResourceID int64 - Quantity int64 - DurationMS int64 - Reason string - OperatorUserID int64 - GrantSource string -} - -type GrantResourceGroupCommand struct { - AppCode string - CommandID string - TargetUserID int64 - GroupID int64 - Reason string - OperatorUserID int64 - GrantSource string -} - -type ListUserResourcesQuery struct { - AppCode string - UserID int64 - ResourceType string - ActiveOnly bool -} - -type EquipUserResourceCommand struct { - AppCode string - UserID int64 - ResourceID int64 - EntitlementID string -} - -type UnequipUserResourceCommand struct { - AppCode string - UserID int64 - ResourceType string -} - -type UnequipUserResourceResult struct { - ResourceType string - Unequipped bool - UpdatedAtMS int64 -} - -type BatchGetUserEquippedResourcesQuery struct { - AppCode string - UserIDs []int64 - ResourceTypes []string -} - -type UserEquippedResources struct { - UserID int64 - Resources []UserResourceEntitlement -} - -type ListResourceGrantsQuery struct { - AppCode string - TargetUserID int64 - Status string - Page int32 - PageSize int32 -} - -type ResourceShopItemInput struct { - ShopItemID int64 - ResourceID int64 - Status string - DurationDays int32 - EffectiveFromMS int64 - EffectiveToMS int64 - SortOrder int32 -} - -type ListResourceShopItemsQuery struct { - AppCode string - ResourceType string - Status string - Keyword string - Page int32 - PageSize int32 - ActiveOnly bool -} - -type ListResourceShopPurchaseOrdersQuery struct { - AppCode string - UserID int64 - ResourceType string - Status string - Keyword string - Page int32 - PageSize int32 -} - -type ResourceShopItemsCommand struct { - AppCode string - Items []ResourceShopItemInput - OperatorUserID int64 -} - -type ResourceShopItemStatusCommand struct { - AppCode string - ShopItemID int64 - Status string - OperatorUserID int64 -} - -type ResourceShopPurchaseCommand struct { - AppCode string - CommandID string - UserID int64 - ShopItemID int64 -} - -type ResourceShopPurchaseReceipt struct { - OrderID string - TransactionID string - ShopItem ResourceShopItem - Resource UserResourceEntitlement - Balance ledger.AssetBalance - CoinSpent int64 - GrantID string -} - -// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection. -type BadgeGrantOutbox struct { - AppCode string - EventID string - EventType string - CommandID string - UserID int64 - GrantID string - GrantSource string - PayloadJSON string - Status string - AttemptCount int32 - FailureReason string - CreatedAtMS int64 - UpdatedAtMS int64 - Items []BadgeGrantItem -} - -// BadgeGrantItem is the badge subset of a resource grant item. -type BadgeGrantItem struct { - ResourceID int64 `json:"resource_id"` - ResourceCode string `json:"resource_code"` - ResourceType string `json:"resource_type"` - Name string `json:"name"` - EntitlementID string `json:"entitlement_id"` - ResourceSnapshotJSON string `json:"resource_snapshot_json"` - MetadataJSON string `json:"metadata_json"` -} - func NormalizeStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { @@ -570,59 +102,6 @@ func ValidResourceType(value string) bool { } } -func ResourceTypeSellableInShop(value string) bool { - switch NormalizeResourceType(value) { - case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation: - return true - default: - return false - } -} - -func ValidShopDurationDays(value int32) bool { - switch value { - case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays: - return true - default: - return false - } -} - -func ValidStatus(value string) bool { - switch NormalizeStatus(value) { - case StatusActive, StatusDisabled: - return true - default: - return false - } -} - -func ValidGrantStrategy(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case GrantStrategyWalletCredit, GrantStrategyNewEntitlement, GrantStrategyExtendExpiry, GrantStrategyIncreaseQuantity, GrantStrategySetActiveFlag: - return true - default: - return false - } -} - -func NormalizeGroupItemType(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return GroupItemTypeResource - } - return value -} - -func ValidGroupItemType(value string) bool { - switch NormalizeGroupItemType(value) { - case GroupItemTypeResource, GroupItemTypeWalletAsset: - return true - default: - return false - } -} - func NormalizeResourceType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } @@ -635,87 +114,6 @@ func NormalizeWalletAssetType(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } -func NormalizePriceType(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func ValidPriceType(value string) bool { - switch NormalizePriceType(value) { - case PriceTypeCoin, PriceTypeFree: - return true - default: - return false - } -} - -func NormalizeGiftTypeCode(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.ReplaceAll(value, "-", "_") - if value == "" { - return GiftTypeNormal - } - return value -} - -func ValidGiftTypeCode(value string) bool { - value = NormalizeGiftTypeCode(value) - if value == "" || len(value) > 32 { - return false - } - for index, char := range value { - if index == 0 && (char < 'a' || char > 'z') { - return false - } - if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' { - continue - } - return false - } - return true -} - -func NormalizeGiftTypeTabKey(value string) string { - return strings.TrimSpace(value) -} - -func ValidGiftTypeTabKey(value string) bool { - value = NormalizeGiftTypeTabKey(value) - return value != "" && utf8.RuneCountInString(value) <= 32 -} - -func DefaultGiftTypeConfigs(appCode string) []GiftTypeConfig { - appCode = strings.TrimSpace(appCode) - defaults := []GiftTypeConfig{ - {TypeCode: GiftTypeNormal, Name: "普通礼物", TabKey: GiftTypeTabKeyNormal, Status: StatusActive, SortOrder: 10}, - {TypeCode: GiftTypeCP, Name: "CP礼物", TabKey: GiftTypeTabKeyCP, Status: StatusActive, SortOrder: 20}, - {TypeCode: GiftTypeLucky, Name: "幸运礼物", TabKey: GiftTypeTabKeyLucky, Status: StatusActive, SortOrder: 30}, - {TypeCode: GiftTypeSuperLucky, Name: "超级幸运礼物", TabKey: GiftTypeTabKeySuperLucky, Status: StatusActive, SortOrder: 40}, - {TypeCode: GiftTypeExclusive, Name: "专属礼物", TabKey: GiftTypeTabKeyExclusive, Status: StatusActive, SortOrder: 50}, - {TypeCode: GiftTypeNoble, Name: "贵族礼物", TabKey: GiftTypeTabKeyNoble, Status: StatusActive, SortOrder: 60}, - {TypeCode: GiftTypeFlag, Name: "国旗礼物", TabKey: GiftTypeTabKeyFlag, Status: StatusActive, SortOrder: 70}, - {TypeCode: GiftTypeActivity, Name: "活动礼物", TabKey: GiftTypeTabKeyActivity, Status: StatusActive, SortOrder: 80}, - {TypeCode: GiftTypeMagic, Name: "魔法礼物", TabKey: GiftTypeTabKeyMagic, Status: StatusActive, SortOrder: 90}, - {TypeCode: GiftTypeCustom, Name: "定制礼物", TabKey: GiftTypeTabKeyCustom, Status: StatusActive, SortOrder: 100}, - } - for index := range defaults { - defaults[index].AppCode = appCode - } - return defaults -} - -func NormalizeGiftEffectType(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func ValidGiftEffectType(value string) bool { - switch NormalizeGiftEffectType(value) { - case GiftEffectAnimation, GiftEffectMusic, GiftEffectGlobalBroadcast: - return true - default: - return false - } -} - func IsWalletCredit(resource Resource) bool { return NormalizeGrantStrategy(resource.GrantStrategy) == GrantStrategyWalletCredit } diff --git a/services/wallet-service/internal/domain/resource/shop.go b/services/wallet-service/internal/domain/resource/shop.go new file mode 100644 index 00000000..3fea677f --- /dev/null +++ b/services/wallet-service/internal/domain/resource/shop.go @@ -0,0 +1,134 @@ +package resource + +import ( + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +type ResourceShopItem struct { + AppCode string + ShopItemID int64 + ResourceID int64 + Resource Resource + Status string + DurationDays int32 + PriceType string + CoinPrice int64 + EffectiveFromMS int64 + EffectiveToMS int64 + SortOrder int32 + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type ResourceShopPurchaseOrder struct { + AppCode string + OrderID string + CommandID string + UserID int64 + ShopItemID int64 + ResourceID int64 + Resource Resource + ResourceSnapshotJSON string + DurationDays int32 + PriceCoin int64 + Status string + WalletTransactionID string + ResourceGrantID string + EntitlementID string + CreatedAtMS int64 + UpdatedAtMS int64 +} + +type ResourceShopItemInput struct { + ShopItemID int64 + ResourceID int64 + Status string + DurationDays int32 + EffectiveFromMS int64 + EffectiveToMS int64 + SortOrder int32 +} + +type ListResourceShopItemsQuery struct { + AppCode string + ResourceType string + Status string + Keyword string + Page int32 + PageSize int32 + ActiveOnly bool +} + +type ListResourceShopPurchaseOrdersQuery struct { + AppCode string + UserID int64 + ResourceType string + Status string + Keyword string + Page int32 + PageSize int32 +} + +type ResourceShopItemsCommand struct { + AppCode string + Items []ResourceShopItemInput + OperatorUserID int64 +} + +type ResourceShopItemStatusCommand struct { + AppCode string + ShopItemID int64 + Status string + OperatorUserID int64 +} + +type ResourceShopPurchaseCommand struct { + AppCode string + CommandID string + UserID int64 + ShopItemID int64 +} + +type ResourceShopPurchaseReceipt struct { + OrderID string + TransactionID string + ShopItem ResourceShopItem + Resource UserResourceEntitlement + Balance ledger.AssetBalance + CoinSpent int64 + GrantID string +} + +func ResourceTypeSellableInShop(value string) bool { + switch NormalizeResourceType(value) { + case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation: + return true + default: + return false + } +} + +func ValidShopDurationDays(value int32) bool { + switch value { + case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays: + return true + default: + return false + } +} + +func NormalizePriceType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func ValidPriceType(value string) bool { + switch NormalizePriceType(value) { + case PriceTypeCoin, PriceTypeFree: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/domain/resource/validation.go b/services/wallet-service/internal/domain/resource/validation.go new file mode 100644 index 00000000..d1adc68a --- /dev/null +++ b/services/wallet-service/internal/domain/resource/validation.go @@ -0,0 +1,10 @@ +package resource + +func ValidStatus(value string) bool { + switch NormalizeStatus(value) { + case StatusActive, StatusDisabled: + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/service/wallet/admin_ledger.go b/services/wallet-service/internal/service/wallet/admin_ledger.go new file mode 100644 index 00000000..af435e00 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/admin_ledger.go @@ -0,0 +1,30 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// AdminCreditAsset 执行后台手动调账;amount 为正数入账、负数扣账,审计字段必须随交易一起落库。 +func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 { + return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin adjustment command is incomplete") + } + command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType)) + if !ledger.ValidAssetType(command.AssetType) { + return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "asset_type is invalid") + } + if command.Reason == "" || command.EvidenceRef == "" { + return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "reason and evidence_ref are required") + } + if s.repository == nil { + return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.AdminCreditAsset(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/balance.go b/services/wallet-service/internal/service/wallet/balance.go new file mode 100644 index 00000000..dcd7468f --- /dev/null +++ b/services/wallet-service/internal/service/wallet/balance.go @@ -0,0 +1,98 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。 +func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { + if userID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + normalized := make([]string, 0, len(assetTypes)) + for _, assetType := range assetTypes { + assetType = strings.ToUpper(strings.TrimSpace(assetType)) + if assetType == "" { + continue + } + if !ledger.ValidAssetType(assetType) { + return nil, xerr.New(xerr.InvalidArgument, "asset_type is invalid") + } + normalized = append(normalized, assetType) + } + + return s.repository.GetBalances(ctx, userID, normalized) +} + +// GetWalletOverview 返回钱包首页摘要,余额和能力开关都由 wallet-service 统一给出。 +func (s *Service) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) { + if userID <= 0 { + return ledger.WalletOverview{}, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return ledger.WalletOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.GetWalletOverview(ctx, userID) +} + +// GetWalletValueSummary 返回我的页钱包卡片摘要,只读取 COIN 账户。 +func (s *Service) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) { + if userID <= 0 { + return ledger.WalletValueSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return ledger.WalletValueSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.GetWalletValueSummary(ctx, userID) +} + +// GetUserGiftWall 返回用户收礼聚合投影;收礼事实只来自成功送礼账务,不读取房间运行态。 +func (s *Service) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) { + if userID <= 0 { + return ledger.UserGiftWall{}, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.GetUserGiftWall(ctx, userID) +} + +// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。 +func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) { + if userID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.GetDiamondExchangeConfig(ctx, userID) +} + +// ListWalletTransactions 返回当前用户钱包流水,避免我的页首屏扫描流水表。 +func (s *Service) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) { + if query.UserID <= 0 { + return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") + } + query.AssetType = strings.ToUpper(strings.TrimSpace(query.AssetType)) + if query.AssetType != "" && !ledger.ValidAssetType(query.AssetType) { + return nil, 0, xerr.New(xerr.InvalidArgument, "asset_type is invalid") + } + if s.repository == nil { + return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + query.AppCode = appcode.Normalize(query.AppCode) + ctx = appcode.WithContext(ctx, query.AppCode) + return s.repository.ListWalletTransactions(ctx, query) +} diff --git a/services/wallet-service/internal/service/wallet/coin_seller.go b/services/wallet-service/internal/service/wallet/coin_seller.go new file mode 100644 index 00000000..27a0bdff --- /dev/null +++ b/services/wallet-service/internal/service/wallet/coin_seller.go @@ -0,0 +1,151 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账;USDT 进货和金币补偿使用不同账务类型。 +func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { + if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete") + } + if command.SellerCountryID <= 0 || command.SellerRegionID <= 0 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "seller country and region are required") + } + if len(command.CommandID) > 128 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") + } + if command.CoinAmount <= 0 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin_amount must be positive") + } + command.StockType = ledger.NormalizeCoinSellerStockType(command.StockType) + if command.StockType == "" { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") + } + command.PaidCurrencyCode = strings.ToUpper(strings.TrimSpace(command.PaidCurrencyCode)) + command.PaymentRef = strings.TrimSpace(command.PaymentRef) + command.EvidenceRef = strings.TrimSpace(command.EvidenceRef) + command.Reason = strings.TrimSpace(command.Reason) + if len(command.PaidCurrencyCode) > 8 || len(command.PaymentRef) > 128 || len(command.EvidenceRef) > 256 || len(command.Reason) > 512 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long") + } + switch command.StockType { + case ledger.StockTypeUSDTPurchase: + if command.PaidCurrencyCode == "" { + command.PaidCurrencyCode = ledger.PaidCurrencyUSDT + } + if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount") + } + case ledger.StockTypeCoinCompensation: + if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin compensation must not include paid amount") + } + default: + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") + } + if s.repository == nil { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.AdminCreditCoinSellerStock(ctx, command) +} + +// TransferCoinFromSeller 执行币商专用金币转普通金币;身份和区域都由 gateway/user-service 校验并传入。 +func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { + if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller transfer command is incomplete") + } + if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are required") + } + if command.TargetCountryID <= 0 { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "target country is required") + } + if command.SellerRegionID != command.TargetRegionID { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Conflict, "seller and target region must match") + } + if strings.TrimSpace(command.Reason) == "" { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.Reason = strings.TrimSpace(command.Reason) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.TransferCoinFromSeller(ctx, command) +} + +// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间;gateway 用它给 H5 展示预计到账金币。 +func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { + if regionID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "region_id is required") + } + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + appCode = appcode.Normalize(appCode) + ctx = appcode.WithContext(ctx, appCode) + return s.repository.ListCoinSellerSalaryExchangeRateTiers(ctx, appCode, regionID, includeDisabled) +} + +// ExchangeSalaryToCoin 校验工资兑换命令;身份归属由 gateway 校验,wallet 只接受明确的工资资产和正向美分金额。 +func (s *Service) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) { + if command.CommandID == "" || command.UserID <= 0 || command.SalaryUSDMinor <= 0 { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange command is incomplete") + } + command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) + if !ledger.ValidSalaryAssetType(command.SalaryAssetType) { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + command.Reason = "salary exchange" + } + if len(command.CommandID) > 128 || len(command.Reason) > 512 { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange text fields are too long") + } + if s.repository == nil { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.ExchangeSalaryToCoin(ctx, command) +} + +// TransferSalaryToCoinSeller 校验工资转币商命令;比例区间命中和双边入账由 repository 在同一事务内完成。 +func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) { + if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete") + } + if command.RegionID <= 0 { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required") + } + command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) + if !ledger.ValidSalaryAssetType(command.SalaryAssetType) { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + command.Reason = "salary transfer" + } + if len(command.CommandID) > 128 || len(command.Reason) > 512 { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer text fields are too long") + } + if s.repository == nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.TransferSalaryToCoinSeller(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/compat.go b/services/wallet-service/internal/service/wallet/compat.go new file mode 100644 index 00000000..15633af9 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/compat.go @@ -0,0 +1,36 @@ +package wallet + +import "hyapp/services/wallet-service/internal/service/wallet/ports" + +// Repository 是旧门面包暴露的仓储端口别名;新用例代码应直接依赖 ports.Repository。 +type Repository = ports.Repository + +// ActivityBadgeClient 是旧门面包暴露的 activity client 端口别名。 +type ActivityBadgeClient = ports.ActivityBadgeClient + +// GooglePlayClient 是旧门面包暴露的 Google Play client 端口别名。 +type GooglePlayClient = ports.GooglePlayClient + +// MifaPayClient 是旧门面包暴露的 MiFaPay client 端口别名。 +type MifaPayClient = ports.MifaPayClient + +// V5PayClient 是旧门面包暴露的 V5Pay client 端口别名。 +type V5PayClient = ports.V5PayClient + +// TronUSDTClient 是旧门面包暴露的 TRON USDT client 端口别名。 +type TronUSDTClient = ports.TronUSDTClient + +type MifaPayCreateOrderRequest = ports.MifaPayCreateOrderRequest +type MifaPayCreateOrderResponse = ports.MifaPayCreateOrderResponse +type MifaPayQueryOrderRequest = ports.MifaPayQueryOrderRequest +type MifaPayQueryOrderResponse = ports.MifaPayQueryOrderResponse +type MifaPayNotifyData = ports.MifaPayNotifyData + +type V5PayCreateOrderRequest = ports.V5PayCreateOrderRequest +type V5PayCreateOrderResponse = ports.V5PayCreateOrderResponse +type V5PayQueryOrderRequest = ports.V5PayQueryOrderRequest +type V5PayQueryOrderResponse = ports.V5PayQueryOrderResponse +type V5PayNotifyData = ports.V5PayNotifyData +type V5PayProductTypesRequest = ports.V5PayProductTypesRequest +type V5PayProductType = ports.V5PayProductType +type V5PayProductTypesResponse = ports.V5PayProductTypesResponse diff --git a/services/wallet-service/internal/service/wallet/external_recharge.go b/services/wallet-service/internal/service/wallet/external_recharge.go deleted file mode 100644 index ebf8c3b4..00000000 --- a/services/wallet-service/internal/service/wallet/external_recharge.go +++ /dev/null @@ -1,952 +0,0 @@ -package wallet - -import ( - "context" - "encoding/json" - "errors" - "math" - "net/url" - "strconv" - "strings" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。 -type ExternalRechargeConfig struct { - USDTTRC20Enabled bool - USDTTRC20Address string - MifaPayNotifyURL string - MifaPayReturnURL string - V5PayNotifyURL string - V5PayReturnURL string -} - -// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。 -type MifaPayCreateOrderRequest struct { - OrderID string - TargetUserID int64 - ProductName string - CoinAmount int64 - AmountMinor int64 - CurrencyCode string - CountryCode string - PayWay string - PayType string - NotifyURL string - ReturnURL string - ClientIP string - Language string - ProviderAmountMinor int64 - PayerName string - PayerAccount string - PayerEmail string -} - -type MifaPayCreateOrderResponse struct { - OrderID string - ProviderOrderID string - PayURL string - RawJSON string -} - -type MifaPayQueryOrderRequest struct { - OrderID string - ProviderOrderID string - OrderCreatedAtMS int64 -} - -type MifaPayQueryOrderResponse struct { - OrderID string - ProviderOrderID string - Status string - Amount string - Currency string - PayType string - RawJSON string -} - -type MifaPayNotifyData struct { - OrderID string - ProviderOrderID string - OrderStatus string - Amount string - Currency string - PayType string - RawJSON string -} - -// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。 -type V5PayCreateOrderRequest struct { - OrderID string - TargetUserID int64 - ProductName string - CoinAmount int64 - AmountMinor int64 - CurrencyCode string - CountryCode string - ProductType string - NotifyURL string - ReturnURL string - Language string - ProviderAmountMinor int64 - PayerName string - PayerAccount string - PayerEmail string -} - -type V5PayCreateOrderResponse struct { - OrderID string - ProviderOrderID string - PayURL string - RawJSON string -} - -type V5PayQueryOrderRequest struct { - OrderID string - ProviderOrderID string - CountryCode string - CurrencyCode string -} - -type V5PayQueryOrderResponse struct { - OrderID string - ProviderOrderID string - Status string - Amount string - Currency string - ProductType string - RawJSON string -} - -type V5PayNotifyData struct { - OrderID string - ProviderOrderID string - OrderStatus string - Amount string - Currency string - ProductType string - RawJSON string -} - -type V5PayProductTypesRequest struct { - CountryCode string - CurrencyCode string -} - -type V5PayProductType struct { - ProductType string - ProductName string - Currency string - SupportCashierMode int -} - -type V5PayProductTypesResponse struct { - PayinList []V5PayProductType - RawJSON string -} - -// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节,service 只处理订单状态。 -type MifaPayClient interface { - CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error) - QueryOrder(ctx context.Context, req MifaPayQueryOrderRequest) (MifaPayQueryOrderResponse, error) - ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error) -} - -// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节,service 只处理订单状态。 -type V5PayClient interface { - CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error) - QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error) - ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error) - ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error) -} - -// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。 -type TronUSDTClient interface { - VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) -} - -func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig { - config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address) - config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL) - config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL) - config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL) - config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL) - return config -} - -// ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。 -func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - query.AppCode = appcode.Normalize(query.AppCode) - if strings.TrimSpace(query.Status) != "" { - query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status) - if query.Status == "" { - return nil, xerr.New(xerr.InvalidArgument, "status is invalid") - } - } - ctx = appcode.WithContext(ctx, query.AppCode) - return s.repository.ListThirdPartyPaymentChannels(ctx, query) -} - -func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) { - if command.MethodID <= 0 || command.OperatorUserID <= 0 { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required") - } - if s.repository == nil { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command) -} - -func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) { - command.AppCode = appcode.Normalize(command.AppCode) - command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate) - if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid") - } - if s.repository == nil { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.UpdateThirdPartyPaymentRate(ctx, command) -} - -// ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。 -func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) { - query.AppCode = appcode.Normalize(query.AppCode) - query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) - query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode)) - if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" { - return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete") - } - if s.repository == nil { - return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, query.AppCode) - options, err := s.repository.ListH5RechargeOptions(ctx, query) - if err != nil { - return ledger.H5RechargeOptions{}, err - } - if s.mifaPay == nil && s.v5Pay == nil { - options.PaymentMethods = nil - } else { - filtered := options.PaymentMethods[:0] - for _, method := range options.PaymentMethods { - // H5 只展示当前运行时已装配 client 的支付公司;后台仍可维护全部 provider 的开关和汇率。 - if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil { - filtered = append(filtered, method) - } - if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil { - filtered = append(filtered, method) - } - } - options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered) - } - options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != "" - options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address - return options, nil -} - -func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod { - if s.v5Pay == nil || len(methods) == 0 { - return methods - } - type productKey struct { - country string - currency string - } - allowedByKey := map[productKey]map[string]bool{} - filtered := methods[:0] - for _, method := range methods { - if method.ProviderCode != ledger.PaymentProviderV5Pay { - filtered = append(filtered, method) - continue - } - key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))} - allowed, ok := allowedByKey[key] - if !ok { - allowed = map[string]bool{} - products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency}) - if err == nil { - for _, product := range products.PayinList { - // V5Pay 的 appKey 可按国家/币种/产品单独开通;H5 只展示当前应用实际可走收银台的产品,避免用户点到 1013 app invalid。 - if product.SupportCashierMode == 0 { - continue - } - if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) { - continue - } - if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" { - allowed[productType] = true - } - } - } - allowedByKey[key] = allowed - } - if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] { - filtered = append(filtered, method) - } - } - return filtered -} - -// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。 -func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) { - command = normalizeCreateExternalRechargeOrderCommand(command) - if err := validateCreateExternalRechargeOrderCommand(command); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if s.repository == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, command.AppCode) - product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := validateExternalRechargeProduct(product, command); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - switch command.ProviderCode { - case ledger.PaymentProviderUSDTTRC20: - return s.createUSDTRechargeOrder(ctx, command, product) - case ledger.PaymentProviderMifaPay: - return s.createMifaPayRechargeOrder(ctx, command, product) - case ledger.PaymentProviderV5Pay: - return s.createV5PayRechargeOrder(ctx, command, product) - default: - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid") - } -} - -func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { - if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured") - } - return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address) -} - -func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { - if s.mifaPay == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay client is not configured") - } - method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := validateMifaPayMethodForOrder(method, command); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "") - if err != nil || order.IdempotentReplay { - return order, err - } - notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.MifaPayNotifyURL) - returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.MifaPayReturnURL) - if notifyURL == "" { - _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "mifapay notify_url is not configured", "") - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay notify_url is not configured") - } - resp, err := s.mifaPay.CreateOrder(ctx, MifaPayCreateOrderRequest{ - OrderID: order.OrderID, - TargetUserID: order.TargetUserID, - ProductName: order.ProductName, - CoinAmount: order.CoinAmount, - AmountMinor: order.USDMinorAmount, - CurrencyCode: order.CurrencyCode, - CountryCode: order.CountryCode, - PayWay: order.PayWay, - PayType: order.PayType, - NotifyURL: notifyURL, - ReturnURL: mifaPayProviderReturnURL(returnURL), - ClientIP: command.ClientIP, - Language: command.Language, - ProviderAmountMinor: providerAmountMinor, - PayerName: command.PayerName, - PayerAccount: command.PayerAccount, - PayerEmail: command.PayerEmail, - }) - if err != nil { - _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err)) - return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err) - } - return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) -} - -func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { - if s.v5Pay == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") - } - method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := validateV5PayMethodForOrder(method, command); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "") - if err != nil || order.IdempotentReplay { - return order, err - } - notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL) - returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL) - if notifyURL == "" { - _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "") - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured") - } - resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{ - OrderID: order.OrderID, - TargetUserID: order.TargetUserID, - ProductName: order.ProductName, - CoinAmount: order.CoinAmount, - AmountMinor: order.USDMinorAmount, - CurrencyCode: order.CurrencyCode, - CountryCode: order.CountryCode, - ProductType: order.PayType, - NotifyURL: notifyURL, - ReturnURL: v5PayProviderReturnURL(returnURL), - Language: command.Language, - ProviderAmountMinor: providerAmountMinor, - PayerName: command.PayerName, - PayerAccount: command.PayerAccount, - PayerEmail: command.PayerEmail, - }) - if err != nil { - _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), providerPayloadJSON(err)) - return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay") - } - return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) -} - -func normalizeMifaPayOrderError(err error) error { - return normalizeExternalProviderOrderError(err, "mifapay") -} - -type mifaPayProviderPayloadError interface { - ProviderPayloadJSON() string -} - -func mifaPayProviderPayloadJSON(err error) string { - return providerPayloadJSON(err) -} - -func normalizeExternalProviderOrderError(err error, provider string) error { - if err == nil { - return nil - } - if _, ok := xerr.As(err); ok { - return err - } - // HTTP、JSON、签名和响应结构错误都是外部支付依赖不可用;不能把普通 Go error 透到 gRPC 边界, - // 否则 gateway 只能降级成 INTERNAL_ERROR,前端拿不到“上游支付失败”的可重试语义。 - return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid") -} - -func providerPayloadJSON(err error) string { - var payloadErr mifaPayProviderPayloadError - if errors.As(err, &payloadErr) { - // 支付网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。 - // 普通网络错误、JSON 解析错误或本地签名错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。 - return strings.TrimSpace(payloadErr.ProviderPayloadJSON()) - } - return "" -} - -// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。 -func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) { - command.AppCode = appcode.Normalize(command.AppCode) - command.OrderID = strings.TrimSpace(command.OrderID) - command.TxHash = strings.TrimSpace(command.TxHash) - if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete") - } - if s.repository == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, command.AppCode) - order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if order.TargetUserID != command.TargetUserID { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") - } - if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") - } - if s.tronUSDT == nil { - return s.repository.AttachExternalRechargeTx(ctx, command, "") - } - rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount) - if err != nil { - _, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON) - return ledger.ExternalRechargeOrder{}, err - } - if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON) -} - -func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) { - if s.repository == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), strings.TrimSpace(orderID)) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if targetUserID > 0 && order.TargetUserID != targetUserID { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") - } - if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil { - order = refreshed - } - if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil { - order = refreshed - } - return order, nil -} - -func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) { - if s.repository == nil { - return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - if limit <= 0 { - return 0, nil - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit) - if err != nil { - return 0, err - } - processed := 0 - for _, order := range orders { - // 补偿任务只负责把“已经跳转到三方收银台但回调/回跳未完成”的订单拉回服务端状态机。 - // 单笔三方查询失败不能中断整批;失败订单会保留 redirected,下一轮继续查,成功/终态失败则复用幂等落账。 - if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil { - processed++ - continue - } - if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil { - processed++ - continue - } - processed++ - } - return processed, nil -} - -func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) { - if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed { - return order, nil - } - query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{ - OrderID: order.OrderID, - ProviderOrderID: order.ProviderOrderID, - OrderCreatedAtMS: order.CreatedAtMS, - }) - if err != nil { - // H5 轮询不能因为 MiFaPay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。 - return order, err - } - providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID) - data := MifaPayNotifyData{ - OrderID: firstNonEmpty(query.OrderID, order.OrderID), - ProviderOrderID: providerOrderID, - Amount: query.Amount, - Currency: query.Currency, - PayType: query.PayType, - RawJSON: query.RawJSON, - } - switch strings.TrimSpace(query.Status) { - case "1": - if !mifaPayQueryMatchesOrder(data, order) { - // 主动查询和回调使用同一笔本地订单快照校验金额、币种和支付类型;不匹配时拒绝入账,避免三方串单或通道异常。 - return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay query order data mismatch", query.RawJSON) - } - return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON) - case "4": - return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status 4", query.RawJSON) - case "0", "5", "": - return order, nil - default: - return order, nil - } -} - -func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) { - if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed { - return order, nil - } - query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{ - OrderID: order.OrderID, - ProviderOrderID: order.ProviderOrderID, - CountryCode: order.CountryCode, - CurrencyCode: order.CurrencyCode, - }) - if err != nil { - // H5 轮询不能因为 V5Pay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。 - return order, err - } - providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID) - data := V5PayNotifyData{ - OrderID: firstNonEmpty(query.OrderID, order.OrderID), - ProviderOrderID: providerOrderID, - OrderStatus: query.Status, - Amount: query.Amount, - Currency: query.Currency, - ProductType: query.ProductType, - RawJSON: query.RawJSON, - } - switch strings.TrimSpace(query.Status) { - case "2": - if !v5PayOrderDataMatchesOrder(data, order) { - // 主动查询和回调使用同一笔本地订单快照校验金额、币种和产品编码;不匹配时拒绝入账,避免三方串单或通道异常。 - return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON) - } - return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON) - case "3", "5", "6": - return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON) - case "0", "1", "": - return order, nil - default: - return order, nil - } -} - -// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。 -func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) { - if s.mifaPay == nil { - return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured") - } - if s.repository == nil { - return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - data, err := s.mifaPay.ParseNotification(notification) - if err != nil { - return ledger.ExternalRechargeOrder{}, false, err - } - order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID) - if err != nil { - return ledger.ExternalRechargeOrder{}, false, err - } - if !strings.EqualFold(data.OrderStatus, "SUCCESS") { - order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON) - return order, true, err - } - if !mifaPayNotifyMatchesOrder(data, order) { - order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON) - return order, false, err - } - credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON) - return credited, err == nil, err -} - -// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。 -func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) { - if s.v5Pay == nil { - return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured") - } - if s.repository == nil { - return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - data, err := s.v5Pay.ParseNotification(notification) - if err != nil { - return ledger.ExternalRechargeOrder{}, false, err - } - order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID) - if err != nil { - return ledger.ExternalRechargeOrder{}, false, err - } - switch strings.TrimSpace(data.OrderStatus) { - case "2": - if !v5PayOrderDataMatchesOrder(data, order) { - order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON) - return order, false, err - } - credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON) - return credited, err == nil, err - case "3", "5", "6": - order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON) - return order, true, err - default: - return order, true, nil - } -} - -func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand { - command.AppCode = appcode.Normalize(command.AppCode) - command.CommandID = strings.TrimSpace(command.CommandID) - command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)) - command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType) - command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode) - command.ReturnURL = strings.TrimSpace(command.ReturnURL) - command.NotifyURL = strings.TrimSpace(command.NotifyURL) - command.ClientIP = strings.TrimSpace(command.ClientIP) - command.Language = normalizeMifaPayLanguage(command.Language) - command.PayerName = strings.TrimSpace(command.PayerName) - command.PayerAccount = strings.TrimSpace(command.PayerAccount) - command.PayerEmail = strings.TrimSpace(command.PayerEmail) - return command -} - -func normalizeMifaPayLanguage(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "en" - } - if comma := strings.IndexByte(value, ','); comma >= 0 { - value = value[:comma] - } - if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { - value = value[:semicolon] - } - value = strings.TrimSpace(value) - if !validMifaPayLanguageTag(value) { - return "en" - } - // wallet-service 是 MiFaPay 的最后一道协议边界;即使未来有非 gateway 调用方传入浏览器语言列表, - // 这里也只向三方支付发送单个 BCP47 风格标签,避免订单落库后被 MiFaPay 参数格式校验拒掉。 - return value -} - -func validMifaPayLanguageTag(value string) bool { - if value == "" { - return false - } - parts := strings.Split(value, "-") - if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) { - return false - } - for _, part := range parts[1:] { - if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) { - return false - } - } - return true -} - -func allASCIILetters(value string) bool { - for _, char := range value { - if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') { - return false - } - } - return value != "" -} - -func allASCIILettersOrDigits(value string) bool { - for _, char := range value { - if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') { - return false - } - } - return value != "" -} - -func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error { - if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 { - return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete") - } - if command.AudienceType == "" || command.ProviderCode == "" { - return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid") - } - if len(command.CommandID) > 128 { - return xerr.New(xerr.InvalidArgument, "command_id is too long") - } - return nil -} - -func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error { - if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { - return xerr.New(xerr.Conflict, "recharge product is not active") - } - if product.Platform != ledger.RechargeProductPlatformWeb { - return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product") - } - if product.AudienceType != command.AudienceType { - return xerr.New(xerr.Conflict, "recharge product audience does not match") - } - if !rechargeProductSupportsRegion(product, command.TargetRegionID) { - return xerr.New(xerr.Conflict, "recharge product is not available in user region") - } - return nil -} - -func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error { - if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive { - return xerr.New(xerr.Conflict, "mifapay method is not active") - } - if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { - return xerr.New(xerr.Conflict, "mifapay method country does not match") - } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { - return err - } - return nil -} - -func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error { - if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive { - return xerr.New(xerr.Conflict, "v5pay method is not active") - } - if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { - return xerr.New(xerr.Conflict, "v5pay method country does not match") - } - if strings.TrimSpace(method.PayType) == "" { - return xerr.New(xerr.InvalidArgument, "v5pay product type is required") - } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { - return err - } - return nil -} - -func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { - if usdMinor <= 0 { - return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") - } - value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) - if err != nil || value <= 0 { - return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") - } - return int64(math.Round(float64(usdMinor) * value)), nil -} - -func amountMicroToUSDMinor(amountMicro int64) int64 { - if amountMicro <= 0 { - return 0 - } - return amountMicro / 10_000 -} - -func validNonNegativeDecimal(value string) bool { - parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - return err == nil && parsed >= 0 -} - -func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { - if !strings.EqualFold(data.Currency, order.CurrencyCode) { - return false - } - if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { - return false - } - amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) - return err == nil && amount == order.ProviderAmountMinor -} - -func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { - if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) { - return false - } - if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) { - return false - } - if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { - return false - } - amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) - return err == nil && amount == order.ProviderAmountMinor -} - -func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool { - if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) { - return false - } - if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) { - return false - } - if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) { - return false - } - amount, err := parseProviderAmountMinor(data.Amount) - return err == nil && amount == order.ProviderAmountMinor -} - -func parseProviderAmountMinor(value string) (int64, error) { - value = strings.TrimSpace(value) - if value == "" { - return 0, strconv.ErrSyntax - } - if !strings.Contains(value, ".") { - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return 0, err - } - return parsed * 100, nil - } - parts := strings.SplitN(value, ".", 2) - whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64) - if err != nil { - return 0, err - } - fraction := parts[1] - if len(fraction) > 2 { - fraction = fraction[:2] - } - for len(fraction) < 2 { - fraction += "0" - } - minor, err := strconv.ParseInt(fraction, 10, 64) - if err != nil { - return 0, err - } - return whole*100 + minor, nil -} - -func mifaPayProviderReturnURL(returnURL string) string { - returnURL = strings.TrimSpace(returnURL) - if returnURL == "" { - return returnURL - } - parsed, err := url.Parse(returnURL) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return returnURL - } - // MiFaPay 生产网关会对 payment.resultRedirect 做严格格式校验,动态 query 会在下单阶段被拒。 - // 订单 ID 已经通过创建订单响应返回给 H5 并落在钱包库,回跳页只能作为稳定入口,不能依赖三方回传本地查询参数。 - parsed.RawQuery = "" - parsed.Fragment = "" - return parsed.String() -} - -func v5PayProviderReturnURL(returnURL string) string { - return mifaPayProviderReturnURL(returnURL) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value = strings.TrimSpace(value); value != "" { - return value - } - } - return "" -} - -func mustJSONText(value any) string { - body, err := json.Marshal(value) - if err != nil { - return "{}" - } - return string(body) -} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_config.go b/services/wallet-service/internal/service/wallet/external_recharge_config.go new file mode 100644 index 00000000..5f7cf266 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_config.go @@ -0,0 +1,24 @@ +package wallet + +import ( + "strings" +) + +// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。 +type ExternalRechargeConfig struct { + USDTTRC20Enabled bool + USDTTRC20Address string + MifaPayNotifyURL string + MifaPayReturnURL string + V5PayNotifyURL string + V5PayReturnURL string +} + +func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig { + config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address) + config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL) + config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL) + config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL) + config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL) + return config +} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_notify.go b/services/wallet-service/internal/service/wallet/external_recharge_notify.go new file mode 100644 index 00000000..c87e1245 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_notify.go @@ -0,0 +1,71 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。 +func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) { + if s.mifaPay == nil { + return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured") + } + if s.repository == nil { + return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) + data, err := s.mifaPay.ParseNotification(notification) + if err != nil { + return ledger.ExternalRechargeOrder{}, false, err + } + order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID) + if err != nil { + return ledger.ExternalRechargeOrder{}, false, err + } + if !strings.EqualFold(data.OrderStatus, "SUCCESS") { + order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON) + return order, true, err + } + if !mifaPayNotifyMatchesOrder(data, order) { + order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON) + return order, false, err + } + credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON) + return credited, err == nil, err +} + +// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。 +func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) { + if s.v5Pay == nil { + return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured") + } + if s.repository == nil { + return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) + data, err := s.v5Pay.ParseNotification(notification) + if err != nil { + return ledger.ExternalRechargeOrder{}, false, err + } + order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID) + if err != nil { + return ledger.ExternalRechargeOrder{}, false, err + } + switch strings.TrimSpace(data.OrderStatus) { + case "2": + if !v5PayOrderDataMatchesOrder(data, order) { + order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON) + return order, false, err + } + credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON) + return credited, err == nil, err + case "3", "5", "6": + order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON) + return order, true, err + default: + return order, true, nil + } +} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_order.go b/services/wallet-service/internal/service/wallet/external_recharge_order.go new file mode 100644 index 00000000..74cbdd11 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_order.go @@ -0,0 +1,235 @@ +package wallet + +import ( + "context" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +type mifaPayProviderPayloadError interface { + ProviderPayloadJSON() string +} + +// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。 +func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) { + command = normalizeCreateExternalRechargeOrderCommand(command) + if err := validateCreateExternalRechargeOrderCommand(command); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if s.repository == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, command.AppCode) + product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := validateExternalRechargeProduct(product, command); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + switch command.ProviderCode { + case ledger.PaymentProviderUSDTTRC20: + return s.createUSDTRechargeOrder(ctx, command, product) + case ledger.PaymentProviderMifaPay: + return s.createMifaPayRechargeOrder(ctx, command, product) + case ledger.PaymentProviderV5Pay: + return s.createV5PayRechargeOrder(ctx, command, product) + default: + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid") + } +} + +func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { + if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured") + } + return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address) +} + +func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { + if s.mifaPay == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay client is not configured") + } + method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := validateMifaPayMethodForOrder(method, command); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "") + if err != nil || order.IdempotentReplay { + return order, err + } + notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.MifaPayNotifyURL) + returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.MifaPayReturnURL) + if notifyURL == "" { + _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "mifapay notify_url is not configured", "") + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay notify_url is not configured") + } + resp, err := s.mifaPay.CreateOrder(ctx, MifaPayCreateOrderRequest{ + OrderID: order.OrderID, + TargetUserID: order.TargetUserID, + ProductName: order.ProductName, + CoinAmount: order.CoinAmount, + AmountMinor: order.USDMinorAmount, + CurrencyCode: order.CurrencyCode, + CountryCode: order.CountryCode, + PayWay: order.PayWay, + PayType: order.PayType, + NotifyURL: notifyURL, + ReturnURL: mifaPayProviderReturnURL(returnURL), + ClientIP: command.ClientIP, + Language: command.Language, + ProviderAmountMinor: providerAmountMinor, + PayerName: command.PayerName, + PayerAccount: command.PayerAccount, + PayerEmail: command.PayerEmail, + }) + if err != nil { + _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err)) + return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err) + } + return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) +} + +func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) { + if s.v5Pay == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") + } + method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := validateV5PayMethodForOrder(method, command); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "") + if err != nil || order.IdempotentReplay { + return order, err + } + notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL) + returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL) + if notifyURL == "" { + _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "") + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured") + } + resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{ + OrderID: order.OrderID, + TargetUserID: order.TargetUserID, + ProductName: order.ProductName, + CoinAmount: order.CoinAmount, + AmountMinor: order.USDMinorAmount, + CurrencyCode: order.CurrencyCode, + CountryCode: order.CountryCode, + ProductType: order.PayType, + NotifyURL: notifyURL, + ReturnURL: v5PayProviderReturnURL(returnURL), + Language: command.Language, + ProviderAmountMinor: providerAmountMinor, + PayerName: command.PayerName, + PayerAccount: command.PayerAccount, + PayerEmail: command.PayerEmail, + }) + if err != nil { + _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), providerPayloadJSON(err)) + return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay") + } + return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) +} + +func normalizeMifaPayOrderError(err error) error { + return normalizeExternalProviderOrderError(err, "mifapay") +} + +func mifaPayProviderPayloadJSON(err error) string { + return providerPayloadJSON(err) +} + +func normalizeExternalProviderOrderError(err error, provider string) error { + if err == nil { + return nil + } + if _, ok := xerr.As(err); ok { + return err + } + + return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid") +} + +func providerPayloadJSON(err error) string { + var payloadErr mifaPayProviderPayloadError + if errors.As(err, &payloadErr) { + + return strings.TrimSpace(payloadErr.ProviderPayloadJSON()) + } + return "" +} + +// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。 +func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) { + command.AppCode = appcode.Normalize(command.AppCode) + command.OrderID = strings.TrimSpace(command.OrderID) + command.TxHash = strings.TrimSpace(command.TxHash) + if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete") + } + if s.repository == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, command.AppCode) + order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if order.TargetUserID != command.TargetUserID { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") + } + if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") + } + if s.tronUSDT == nil { + return s.repository.AttachExternalRechargeTx(ctx, command, "") + } + rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount) + if err != nil { + _, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON) + return ledger.ExternalRechargeOrder{}, err + } + if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON) +} + +func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) { + if s.repository == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) + order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), strings.TrimSpace(orderID)) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if targetUserID > 0 && order.TargetUserID != targetUserID { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user") + } + if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil { + order = refreshed + } + if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil { + order = refreshed + } + return order, nil +} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_payment_methods.go b/services/wallet-service/internal/service/wallet/external_recharge_payment_methods.go new file mode 100644 index 00000000..3a31347a --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_payment_methods.go @@ -0,0 +1,129 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。 +func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + query.AppCode = appcode.Normalize(query.AppCode) + if strings.TrimSpace(query.Status) != "" { + query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status) + if query.Status == "" { + return nil, xerr.New(xerr.InvalidArgument, "status is invalid") + } + } + ctx = appcode.WithContext(ctx, query.AppCode) + return s.repository.ListThirdPartyPaymentChannels(ctx, query) +} + +func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) { + if command.MethodID <= 0 || command.OperatorUserID <= 0 { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required") + } + if s.repository == nil { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command) +} + +func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) { + command.AppCode = appcode.Normalize(command.AppCode) + command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate) + if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid") + } + if s.repository == nil { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.UpdateThirdPartyPaymentRate(ctx, command) +} + +// ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。 +func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) { + query.AppCode = appcode.Normalize(query.AppCode) + query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) + query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode)) + if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" { + return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete") + } + if s.repository == nil { + return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, query.AppCode) + options, err := s.repository.ListH5RechargeOptions(ctx, query) + if err != nil { + return ledger.H5RechargeOptions{}, err + } + if s.mifaPay == nil && s.v5Pay == nil { + options.PaymentMethods = nil + } else { + filtered := options.PaymentMethods[:0] + for _, method := range options.PaymentMethods { + + if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil { + filtered = append(filtered, method) + } + if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil { + filtered = append(filtered, method) + } + } + options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered) + } + options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != "" + options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address + return options, nil +} + +func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod { + if s.v5Pay == nil || len(methods) == 0 { + return methods + } + type productKey struct { + country string + currency string + } + allowedByKey := map[productKey]map[string]bool{} + filtered := methods[:0] + for _, method := range methods { + if method.ProviderCode != ledger.PaymentProviderV5Pay { + filtered = append(filtered, method) + continue + } + key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))} + allowed, ok := allowedByKey[key] + if !ok { + allowed = map[string]bool{} + products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency}) + if err == nil { + for _, product := range products.PayinList { + + if product.SupportCashierMode == 0 { + continue + } + if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) { + continue + } + if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" { + allowed[productType] = true + } + } + } + allowedByKey[key] = allowed + } + if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] { + filtered = append(filtered, method) + } + } + return filtered +} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_reconcile.go b/services/wallet-service/internal/service/wallet/external_recharge_reconcile.go new file mode 100644 index 00000000..66f1acc9 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_reconcile.go @@ -0,0 +1,115 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) { + if s.repository == nil { + return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + if limit <= 0 { + return 0, nil + } + ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) + orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit) + if err != nil { + return 0, err + } + processed := 0 + for _, order := range orders { + + if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil { + processed++ + continue + } + if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil { + processed++ + continue + } + processed++ + } + return processed, nil +} + +func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) { + if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed { + return order, nil + } + query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{ + OrderID: order.OrderID, + ProviderOrderID: order.ProviderOrderID, + OrderCreatedAtMS: order.CreatedAtMS, + }) + if err != nil { + + return order, err + } + providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID) + data := MifaPayNotifyData{ + OrderID: firstNonEmpty(query.OrderID, order.OrderID), + ProviderOrderID: providerOrderID, + Amount: query.Amount, + Currency: query.Currency, + PayType: query.PayType, + RawJSON: query.RawJSON, + } + switch strings.TrimSpace(query.Status) { + case "1": + if !mifaPayQueryMatchesOrder(data, order) { + + return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay query order data mismatch", query.RawJSON) + } + return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON) + case "4": + return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status 4", query.RawJSON) + case "0", "5", "": + return order, nil + default: + return order, nil + } +} + +func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) { + if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed { + return order, nil + } + query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{ + OrderID: order.OrderID, + ProviderOrderID: order.ProviderOrderID, + CountryCode: order.CountryCode, + CurrencyCode: order.CurrencyCode, + }) + if err != nil { + + return order, err + } + providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID) + data := V5PayNotifyData{ + OrderID: firstNonEmpty(query.OrderID, order.OrderID), + ProviderOrderID: providerOrderID, + OrderStatus: query.Status, + Amount: query.Amount, + Currency: query.Currency, + ProductType: query.ProductType, + RawJSON: query.RawJSON, + } + switch strings.TrimSpace(query.Status) { + case "2": + if !v5PayOrderDataMatchesOrder(data, order) { + + return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON) + } + return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON) + case "3", "5", "6": + return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON) + case "0", "1", "": + return order, nil + default: + return order, nil + } +} diff --git a/services/wallet-service/internal/service/wallet/external_recharge_validate.go b/services/wallet-service/internal/service/wallet/external_recharge_validate.go new file mode 100644 index 00000000..195cc697 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/external_recharge_validate.go @@ -0,0 +1,268 @@ +package wallet + +import ( + "encoding/json" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "math" + "net/url" + "strconv" + "strings" +) + +func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand { + command.AppCode = appcode.Normalize(command.AppCode) + command.CommandID = strings.TrimSpace(command.CommandID) + command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)) + command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType) + command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode) + command.ReturnURL = strings.TrimSpace(command.ReturnURL) + command.NotifyURL = strings.TrimSpace(command.NotifyURL) + command.ClientIP = strings.TrimSpace(command.ClientIP) + command.Language = normalizeMifaPayLanguage(command.Language) + command.PayerName = strings.TrimSpace(command.PayerName) + command.PayerAccount = strings.TrimSpace(command.PayerAccount) + command.PayerEmail = strings.TrimSpace(command.PayerEmail) + return command +} + +func normalizeMifaPayLanguage(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "en" + } + if comma := strings.IndexByte(value, ','); comma >= 0 { + value = value[:comma] + } + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = value[:semicolon] + } + value = strings.TrimSpace(value) + if !validMifaPayLanguageTag(value) { + return "en" + } + + return value +} + +func validMifaPayLanguageTag(value string) bool { + if value == "" { + return false + } + parts := strings.Split(value, "-") + if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) { + return false + } + for _, part := range parts[1:] { + if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) { + return false + } + } + return true +} + +func allASCIILetters(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') { + return false + } + } + return value != "" +} + +func allASCIILettersOrDigits(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') { + return false + } + } + return value != "" +} + +func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error { + if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 { + return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete") + } + if command.AudienceType == "" || command.ProviderCode == "" { + return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid") + } + if len(command.CommandID) > 128 { + return xerr.New(xerr.InvalidArgument, "command_id is too long") + } + return nil +} + +func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error { + if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { + return xerr.New(xerr.Conflict, "recharge product is not active") + } + if product.Platform != ledger.RechargeProductPlatformWeb { + return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product") + } + if product.AudienceType != command.AudienceType { + return xerr.New(xerr.Conflict, "recharge product audience does not match") + } + if !rechargeProductSupportsRegion(product, command.TargetRegionID) { + return xerr.New(xerr.Conflict, "recharge product is not available in user region") + } + return nil +} + +func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error { + if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive { + return xerr.New(xerr.Conflict, "mifapay method is not active") + } + if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { + return xerr.New(xerr.Conflict, "mifapay method country does not match") + } + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + return err + } + return nil +} + +func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error { + if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive { + return xerr.New(xerr.Conflict, "v5pay method is not active") + } + if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { + return xerr.New(xerr.Conflict, "v5pay method country does not match") + } + if strings.TrimSpace(method.PayType) == "" { + return xerr.New(xerr.InvalidArgument, "v5pay product type is required") + } + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + return err + } + return nil +} + +func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { + if usdMinor <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") + } + value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) + if err != nil || value <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") + } + return int64(math.Round(float64(usdMinor) * value)), nil +} + +func amountMicroToUSDMinor(amountMicro int64) int64 { + if amountMicro <= 0 { + return 0 + } + return amountMicro / 10_000 +} + +func validNonNegativeDecimal(value string) bool { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + return err == nil && parsed >= 0 +} + +func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { + if !strings.EqualFold(data.Currency, order.CurrencyCode) { + return false + } + if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { + return false + } + amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) + return err == nil && amount == order.ProviderAmountMinor +} + +func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { + if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) { + return false + } + if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) { + return false + } + if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { + return false + } + amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) + return err == nil && amount == order.ProviderAmountMinor +} + +func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool { + if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) { + return false + } + if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) { + return false + } + if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) { + return false + } + amount, err := parseProviderAmountMinor(data.Amount) + return err == nil && amount == order.ProviderAmountMinor +} + +func parseProviderAmountMinor(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, strconv.ErrSyntax + } + if !strings.Contains(value, ".") { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, err + } + return parsed * 100, nil + } + parts := strings.SplitN(value, ".", 2) + whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64) + if err != nil { + return 0, err + } + fraction := parts[1] + if len(fraction) > 2 { + fraction = fraction[:2] + } + for len(fraction) < 2 { + fraction += "0" + } + minor, err := strconv.ParseInt(fraction, 10, 64) + if err != nil { + return 0, err + } + return whole*100 + minor, nil +} + +func mifaPayProviderReturnURL(returnURL string) string { + returnURL = strings.TrimSpace(returnURL) + if returnURL == "" { + return returnURL + } + parsed, err := url.Parse(returnURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return returnURL + } + + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +func v5PayProviderReturnURL(returnURL string) string { + return mifaPayProviderReturnURL(returnURL) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func mustJSONText(value any) string { + body, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(body) +} diff --git a/services/wallet-service/internal/service/wallet/game.go b/services/wallet-service/internal/service/wallet/game.go new file mode 100644 index 00000000..6f4b1ce9 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/game.go @@ -0,0 +1,39 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。 +func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) { + if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game coin command is incomplete") + } + command.OpType = ledger.NormalizeGameOpType(command.OpType) + if command.OpType == "" { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game op_type is invalid") + } + if command.CoinAmount <= 0 { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "coin_amount must be positive") + } + command.RequestHash = strings.TrimSpace(command.RequestHash) + if command.RequestHash == "" { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "request_hash is required") + } + if s.repository == nil { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.PlatformCode = strings.TrimSpace(command.PlatformCode) + command.GameID = strings.TrimSpace(command.GameID) + command.ProviderOrderID = strings.TrimSpace(command.ProviderOrderID) + command.ProviderRoundID = strings.TrimSpace(command.ProviderRoundID) + command.RoomID = strings.TrimSpace(command.RoomID) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.ApplyGameCoinChange(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/gift.go b/services/wallet-service/internal/service/wallet/gift.go new file mode 100644 index 00000000..0acca865 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/gift.go @@ -0,0 +1,31 @@ +package wallet + +import ( + "context" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。 +func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + if s == nil || s.giftLedger == nil { + return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + return s.giftLedger.DebitGift(ctx, command) +} + +// DebitRobotGift 校验机器人房间专用送礼扣费命令,并强制使用 ROBOT_COIN 账务语义。 +func (s *Service) DebitRobotGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + if s == nil || s.giftLedger == nil { + return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + return s.giftLedger.DebitRobotGift(ctx, command) +} + +// BatchDebitGift 校验多目标送礼扣费命令,并要求 repository 在同一事务中完成全部目标结算。 +func (s *Service) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) { + if s == nil || s.giftLedger == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + return s.giftLedger.BatchDebitGift(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/host_salary.go b/services/wallet-service/internal/service/wallet/host_salary.go new file mode 100644 index 00000000..951db257 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/host_salary.go @@ -0,0 +1,90 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +// ProcessHostSalarySettlementBatch 执行主播/代理工资结算批处理;日结和半月结不清空周期钻石,月底只做逻辑清算标记。 +func (s *Service) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { + if s.repository == nil { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + switch strings.TrimSpace(command.SettlementType) { + case ledger.HostSalarySettlementModeDaily, ledger.HostSalarySettlementModeHalfMonth, ledger.HostSalarySettlementTypeMonthEnd: + default: + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") + } + if command.BatchSize <= 0 { + command.BatchSize = 100 + } + if command.BatchSize > 500 { + command.BatchSize = 500 + } + if strings.TrimSpace(command.RunID) == "" || strings.TrimSpace(command.WorkerID) == "" { + return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id and worker_id are required") + } + command.AppCode = appcode.Normalize(command.AppCode) + if command.NowMs <= 0 { + command.NowMs = s.now().UTC().UnixMilli() + } + if strings.TrimSpace(command.CycleKey) == "" { + command.CycleKey = hostSalaryCycleKey(command.NowMs) + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.ProcessHostSalarySettlementBatch(ctx, command) +} + +func hostSalaryCycleKey(nowMs int64) string { + // 结算周期按 UTC 月锚定,和送礼入账的 host_period_diamond_accounts.cycle_key 保持同一口径。 + return time.UnixMilli(nowMs).UTC().Format("2006-01") +} + +// GetActiveHostSalaryPolicy 读取主播所在区域当前生效的工资政策,返回的 policy 与工资结算使用同一张 runtime 表。 +func (s *Service) GetActiveHostSalaryPolicy(ctx context.Context, appCode string, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { + if regionID <= 0 { + return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "region_id is required") + } + if s.repository == nil { + return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + settlementMode = strings.TrimSpace(settlementMode) + if settlementMode != "" && settlementMode != ledger.HostSalarySettlementModeDaily && settlementMode != ledger.HostSalarySettlementModeHalfMonth { + return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_mode is invalid") + } + triggerMode = strings.TrimSpace(triggerMode) + if triggerMode != "" && triggerMode != ledger.HostSalarySettlementTriggerAutomatic && triggerMode != ledger.HostSalarySettlementTriggerManual { + return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_trigger_mode is invalid") + } + if nowMs <= 0 { + nowMs = s.now().UTC().UnixMilli() + } + appCode = appcode.Normalize(appCode) + ctx = appcode.WithContext(ctx, appCode) + // H5 展示可以不传 trigger mode,此时 automatic/manual 政策都可展示;结算任务仍会传具体 trigger 精确匹配。 + return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs) +} + +// GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0,避免 H5 用其他等级系统兜底导致进度口径错误。 +func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) { + if userID <= 0 { + return ledger.HostSalaryProgress{}, xerr.New(xerr.InvalidArgument, "host_user_id is required") + } + if s.repository == nil { + return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + if nowMs <= 0 { + nowMs = s.now().UTC().UnixMilli() + } + cycleKey = strings.TrimSpace(cycleKey) + if cycleKey == "" { + cycleKey = hostSalaryCycleKey(nowMs) + } + appCode = appcode.Normalize(appCode) + ctx = appcode.WithContext(ctx, appCode) + return s.repository.GetHostSalaryProgress(ctx, userID, cycleKey) +} diff --git a/services/wallet-service/internal/service/wallet/ports/clients.go b/services/wallet-service/internal/service/wallet/ports/clients.go new file mode 100644 index 00000000..abb7c6e3 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/ports/clients.go @@ -0,0 +1,164 @@ +package ports + +import ( + "context" + + "google.golang.org/grpc" + activityv1 "hyapp.local/api/proto/activity/v1" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ActivityBadgeClient 是 wallet -> activity 的最小投影接口,避免 wallet 依赖 activity 内部包。 +type ActivityBadgeClient interface { + ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) +} + +// GooglePlayClient 隔离 Google Play Developer API,service 只依赖购买校验和消耗能力。 +type GooglePlayClient interface { + GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) + ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error +} + +// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。 +type MifaPayCreateOrderRequest struct { + OrderID string + TargetUserID int64 + ProductName string + CoinAmount int64 + AmountMinor int64 + CurrencyCode string + CountryCode string + PayWay string + PayType string + NotifyURL string + ReturnURL string + ClientIP string + Language string + ProviderAmountMinor int64 + PayerName string + PayerAccount string + PayerEmail string +} + +type MifaPayCreateOrderResponse struct { + OrderID string + ProviderOrderID string + PayURL string + RawJSON string +} + +type MifaPayQueryOrderRequest struct { + OrderID string + ProviderOrderID string + OrderCreatedAtMS int64 +} + +type MifaPayQueryOrderResponse struct { + OrderID string + ProviderOrderID string + Status string + Amount string + Currency string + PayType string + RawJSON string +} + +type MifaPayNotifyData struct { + OrderID string + ProviderOrderID string + OrderStatus string + Amount string + Currency string + PayType string + RawJSON string +} + +// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。 +type V5PayCreateOrderRequest struct { + OrderID string + TargetUserID int64 + ProductName string + CoinAmount int64 + AmountMinor int64 + CurrencyCode string + CountryCode string + ProductType string + NotifyURL string + ReturnURL string + Language string + ProviderAmountMinor int64 + PayerName string + PayerAccount string + PayerEmail string +} + +type V5PayCreateOrderResponse struct { + OrderID string + ProviderOrderID string + PayURL string + RawJSON string +} + +type V5PayQueryOrderRequest struct { + OrderID string + ProviderOrderID string + CountryCode string + CurrencyCode string +} + +type V5PayQueryOrderResponse struct { + OrderID string + ProviderOrderID string + Status string + Amount string + Currency string + ProductType string + RawJSON string +} + +type V5PayNotifyData struct { + OrderID string + ProviderOrderID string + OrderStatus string + Amount string + Currency string + ProductType string + RawJSON string +} + +type V5PayProductTypesRequest struct { + CountryCode string + CurrencyCode string +} + +type V5PayProductType struct { + ProductType string + ProductName string + Currency string + SupportCashierMode int +} + +type V5PayProductTypesResponse struct { + PayinList []V5PayProductType + RawJSON string +} + +// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节,service 只处理订单状态。 +type MifaPayClient interface { + CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error) + QueryOrder(ctx context.Context, req MifaPayQueryOrderRequest) (MifaPayQueryOrderResponse, error) + ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error) +} + +// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节,service 只处理订单状态。 +type V5PayClient interface { + CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error) + QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error) + ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error) + ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error) +} + +// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。 +type TronUSDTClient interface { + VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) +} diff --git a/services/wallet-service/internal/service/wallet/ports/repository.go b/services/wallet-service/internal/service/wallet/ports/repository.go new file mode 100644 index 00000000..61d883e1 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/ports/repository.go @@ -0,0 +1,200 @@ +// Package ports defines wallet-service usecase dependencies. +package ports + +import ( + "context" + "time" + + "hyapp/pkg/walletmq" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" +) + +// Repository 聚合 wallet-service 当前运行需要的全部持久化能力。 +// +// 门面层继续接收这个总接口,usecase 拆分时按下方能力接口依赖最小端口,避免单测 +// 为一个用例实现完整钱包仓储面。 +type Repository interface { + GiftLedgerStore + BalanceStore + HostSalaryStore + CoinSellerStore + AdminLedgerStore + RewardLedgerStore + GameLedgerStore + RechargeStore + ExternalRechargeStore + VIPStore + RedPacketStore + ResourceCatalogStore + ResourceGrantStore + ResourceEquipmentStore + ResourceShopStore + WalletProjectionStore + BadgeProjectionStore +} + +// GiftLedgerStore 管理礼物扣费;repository 必须保证余额、幂等交易和 outbox 同事务提交。 +type GiftLedgerStore interface { + DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) + BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) +} + +// BalanceStore 提供钱包余额、首页、流水和礼物墙读模型。 +type BalanceStore interface { + GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) + GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) + GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) + GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) + GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) + ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) +} + +// HostSalaryStore 管理主播/公会工资政策、进度和批量结算。 +type HostSalaryStore interface { + GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) + GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error) + ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) +} + +// CoinSellerStore 管理币商库存、转币和工资换币。 +type CoinSellerStore interface { + AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) + TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) + ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) + ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) + TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) +} + +// AdminLedgerStore 管理后台人工调账。 +type AdminLedgerStore interface { + AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) +} + +// RewardLedgerStore 管理活动和运营奖励入账。 +type RewardLedgerStore interface { + CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) + CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) + CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) + CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) + CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) + CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) + DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) + DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) +} + +// GameLedgerStore 管理游戏扣款、返奖、退款和冲正账变。 +type GameLedgerStore interface { + ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) +} + +// RechargeStore 管理内购充值档位、Google 支付和充值账单。 +type RechargeStore interface { + ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) + ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) + ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) + GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error) + GetGoogleRechargeProductByCode(ctx context.Context, appCode string, googleProductID string) (ledger.RechargeProduct, error) + ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error) + MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error + CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) + UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) + DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error +} + +// ExternalRechargeStore 管理 H5 三方充值方式、订单、回调和查单补偿。 +type ExternalRechargeStore interface { + ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) + SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) + UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) + GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error) + ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) + CreateExternalRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string) (ledger.ExternalRechargeOrder, error) + MarkExternalRechargeOrderRedirected(ctx context.Context, appCode string, orderID string, providerOrderID string, payURL string, payloadJSON string) (ledger.ExternalRechargeOrder, error) + AttachExternalRechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) + CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) + MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error) + GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) + ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error) +} + +// VIPStore 管理 VIP 购买、发放和后台等级配置。 +type VIPStore interface { + ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) + GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) + PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) + GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) + ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) + UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) +} + +// RedPacketStore 管理红包配置、创建、领取、查询和退款补偿。 +type RedPacketStore interface { + GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error) + UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error) + CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error) + ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error) + ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) + GetRedPacket(ctx context.Context, appCode string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) + ExpireRedPackets(ctx context.Context, appCode string, batchSize int32) (ledger.RedPacketExpireResult, error) + RetryRedPacketRefund(ctx context.Context, appCode string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) +} + +// ResourceCatalogStore 管理资源、资源组、礼物配置和礼物类型配置。 +type ResourceCatalogStore interface { + ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) + GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) + CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) + UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) + SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) + ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) + GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) + CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) + UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) + SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) + ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) + ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) + CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) + UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) + SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) + DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) + UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) +} + +// ResourceGrantStore 管理资源发放和发放记录。 +type ResourceGrantStore interface { + GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) + GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) + ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) +} + +// ResourceEquipmentStore 管理用户资源权益和穿戴状态。 +type ResourceEquipmentStore interface { + ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) + EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) + UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) + BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) +} + +// ResourceShopStore 管理资源商城档位、购买记录和购买事务。 +type ResourceShopStore interface { + ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) + UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) + SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) + ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) + PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) +} + +// WalletProjectionStore 管理钱包 outbox 到本地读模型的投影。 +type WalletProjectionStore interface { + ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) + ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) +} + +// BadgeProjectionStore 管理 badge grant 事件投影到 activity-service 的投递位点。 +type BadgeProjectionStore interface { + ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) + ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) + MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error + MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error +} diff --git a/services/wallet-service/internal/service/wallet/projection.go b/services/wallet-service/internal/service/wallet/projection.go new file mode 100644 index 00000000..574900b1 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/projection.go @@ -0,0 +1,146 @@ +package wallet + +import ( + "context" + activityv1 "hyapp.local/api/proto/activity/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// ProjectPendingGiftWallEvents 消费钱包 outbox 中的送礼成功事件,异步维护用户礼物墙投影。 +func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return 0, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if s.repository == nil { + return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL) +} + +// ProcessWalletProjectionMessage consumes one wallet_outbox MQ fact for wallet-owned projections. +func (s *Service) ProcessWalletProjectionMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if s.repository == nil { + return false, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, message.AppCode) + handled := false + projected, err := s.repository.ProjectGiftWallMessage(ctx, workerID+"-gift-wall", message, lockTTL) + if err != nil { + return handled, err + } + handled = handled || projected + projected, err = s.projectBadgeGrantMessage(ctx, workerID+"-badge-grant", message, lockTTL) + if err != nil { + return handled, err + } + handled = handled || projected + return handled, nil +} + +// ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection. +func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return 0, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if s.repository == nil { + return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + if s.activity == nil { + return 0, xerr.New(xerr.Unavailable, "activity badge client is not configured") + } + if limit <= 0 { + limit = 50 + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + nowMS := s.now().UnixMilli() + events, err := s.repository.ClaimPendingBadgeGrantEvents(ctx, workerID, nowMS, lockTTL, limit) + if err != nil { + return 0, err + } + processed := 0 + for _, event := range events { + processed++ + if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { + return processed, err + } + } + return processed, nil +} + +func (s *Service) projectBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { + if !isBadgeGrantWalletProjectionMessage(message) { + return false, nil + } + if s.activity == nil { + return false, xerr.New(xerr.Unavailable, "activity badge client is not configured") + } + nowMS := s.now().UnixMilli() + event, claimed, err := s.repository.ClaimBadgeGrantMessage(ctx, workerID, message, nowMS, lockTTL) + if err != nil || !claimed { + return claimed, err + } + if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { + return true, err + } + return true, nil +} + +func (s *Service) deliverBadgeGrantProjection(ctx context.Context, event resourcedomain.BadgeGrantOutbox) error { + reqCtx := appcode.WithContext(ctx, event.AppCode) + resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{ + Meta: &activityv1.RequestMeta{ + Caller: "wallet-service", + AppCode: event.AppCode, + SentAtMs: s.now().UnixMilli(), + }, + EventId: event.EventID, + EventType: event.EventType, + SourceService: "wallet-service", + UserId: event.UserID, + MetricType: walletBadgeGrantMetric, + Value: 1, + OccurredAtMs: event.CreatedAtMS, + DimensionsJson: event.PayloadJSON, + }) + if consumeErr != nil { + return s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()) + } + if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) { + return s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()) + } + return s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()) +} + +func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) bool { + if strings.TrimSpace(message.AssetType) != "RESOURCE" { + return false + } + switch message.EventType { + case "ResourceGranted", "ResourceGroupGranted": + return true + default: + return false + } +} + +func isBadgeGrantProjectionStatusTerminal(status string) bool { + switch status { + case "consumed", "skipped", "duplicate": + return true + default: + return false + } +} diff --git a/services/wallet-service/internal/service/wallet/recharge.go b/services/wallet-service/internal/service/wallet/recharge.go new file mode 100644 index 00000000..9c39f094 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/recharge.go @@ -0,0 +1,243 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。 +func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) { + if s.repository == nil { + return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + query.AppCode = appcode.Normalize(query.AppCode) + ctx = appcode.WithContext(ctx, query.AppCode) + + return s.repository.ListRechargeBills(ctx, query) +} + +// ListRechargeProducts 返回区域化充值档位;region_id 必须由 gateway 从 user-service 资料解析。 +func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) { + if userID <= 0 || regionID <= 0 { + return nil, nil, xerr.New(xerr.InvalidArgument, "user_id and region_id are required") + } + if strings.TrimSpace(platform) != "" { + platform = ledger.NormalizeRechargeProductPlatform(platform) + if platform == "" { + return nil, nil, xerr.New(xerr.InvalidArgument, "platform is invalid") + } + } + audienceType = ledger.NormalizeRechargeAudienceType(audienceType) + if audienceType == "" { + return nil, nil, xerr.New(xerr.InvalidArgument, "audience_type is invalid") + } + if s.repository == nil { + return nil, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.ListRechargeProducts(ctx, userID, regionID, platform, audienceType) +} + +// ListAdminRechargeProducts 返回后台配置列表;后台入口负责 RBAC 和审计。 +func (s *Service) ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) { + if s.repository == nil { + return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + query.AppCode = appcode.Normalize(query.AppCode) + if strings.TrimSpace(query.Status) != "" { + query.Status = ledger.NormalizeRechargeProductStatus(query.Status) + if query.Status == "" { + return nil, 0, xerr.New(xerr.InvalidArgument, "status is invalid") + } + } + if strings.TrimSpace(query.Platform) != "" { + query.Platform = ledger.NormalizeRechargeProductPlatform(query.Platform) + if query.Platform == "" { + return nil, 0, xerr.New(xerr.InvalidArgument, "platform is invalid") + } + } + if strings.TrimSpace(query.AudienceType) != "" { + query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) + if query.AudienceType == "" { + return nil, 0, xerr.New(xerr.InvalidArgument, "audience_type is invalid") + } + } + ctx = appcode.WithContext(ctx, query.AppCode) + return s.repository.ListAdminRechargeProducts(ctx, query) +} + +// CreateRechargeProduct 创建内购商品配置;首版充值资源只允许发 COIN。 +func (s *Service) CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { + if s.repository == nil { + return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + if err := validateRechargeProductCommand(command, false); err != nil { + return ledger.RechargeProduct{}, err + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.CreateRechargeProduct(ctx, command) +} + +// UpdateRechargeProduct 覆盖内购商品配置和支持区域,避免状态散落在后台库。 +func (s *Service) UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { + if s.repository == nil { + return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + if err := validateRechargeProductCommand(command, true); err != nil { + return ledger.RechargeProduct{}, err + } + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.UpdateRechargeProduct(ctx, command) +} + +// DeleteRechargeProduct 删除尚未接入 provider order 的配置事实;订单实现后这里需要先做引用检查。 +func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error { + if productID <= 0 { + return xerr.New(xerr.InvalidArgument, "product_id is required") + } + if s.repository == nil { + return xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) + return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID) +} + +// ConfirmGooglePayment 校验 Google Play purchase token,原子入账并记录支付审计。 +func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) { + command.AppCode = appcode.Normalize(command.AppCode) + command.CommandID = strings.TrimSpace(command.CommandID) + command.ProductCode = strings.TrimSpace(command.ProductCode) + command.PackageName = strings.TrimSpace(command.PackageName) + command.PurchaseToken = strings.TrimSpace(command.PurchaseToken) + command.OrderID = strings.TrimSpace(command.OrderID) + if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.PackageName == "" || command.PurchaseToken == "" || (command.ProductID <= 0 && command.ProductCode == "") { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete") + } + if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long") + } + if s.repository == nil { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + if s.googlePlay == nil { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured") + } + ctx = appcode.WithContext(ctx, command.AppCode) + + product, err := s.googleRechargeProductForPayment(ctx, command) + if err != nil { + return ledger.GooglePaymentReceipt{}, err + } + if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active") + } + if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product") + } + if command.ProductCode != "" && command.ProductCode != product.ProductName && command.ProductCode != product.ProductCode { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match") + } + if !rechargeProductSupportsRegion(product, command.RegionID) { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region") + } + + purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken) + if err != nil { + return ledger.GooglePaymentReceipt{}, err + } + if purchase.PackageName == "" { + purchase.PackageName = command.PackageName + } + if purchase.PackageName != command.PackageName { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match") + } + if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased") + } + // 旧 App 首充曾把钱包内部 product_code 当成确认参数;上面的兼容只负责放行历史入参, + // 真正的 Google 商品事实仍以 purchase.ProductID 对齐 product_name,避免内部编码绕过支付商品校验。 + if purchase.ProductID != "" && purchase.ProductID != product.ProductName { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product") + } + if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID { + return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match") + } + + receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase) + if err != nil { + return ledger.GooglePaymentReceipt{}, err + } + if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed { + return receipt, nil + } + consumeProductID := product.ProductCode + if purchase.ProductID != "" { + consumeProductID = purchase.ProductID + } + if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil { + return receipt, nil + } + if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil { + return receipt, nil + } + receipt.ConsumeState = ledger.PaymentConsumeStateConsumed + return receipt, nil +} + +func (s *Service) googleRechargeProductForPayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.RechargeProduct, error) { + if command.ProductID > 0 { + return s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) + } + // 首充弹窗只持有 Google 商品 ID,不再依赖 App 充值商品列表返回 product_id; + // 这里仍然回到钱包内部商品配置取币数、金额和区域,避免客户端自报充值规格。 + return s.repository.GetGoogleRechargeProductByCode(ctx, command.AppCode, command.ProductCode) +} + +func validateRechargeProductCommand(command ledger.RechargeProductCommand, requireProductID bool) error { + if requireProductID && command.ProductID <= 0 { + return xerr.New(xerr.InvalidArgument, "product_id is required") + } + if command.AmountMicro <= 0 || command.CoinAmount <= 0 || command.OperatorUserID <= 0 { + return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required") + } + if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 { + return xerr.New(xerr.InvalidArgument, "product_name is invalid") + } + if len([]rune(strings.TrimSpace(command.Description))) > 512 { + return xerr.New(xerr.InvalidArgument, "description is too long") + } + if ledger.NormalizeRechargeProductPlatform(command.Platform) == "" { + return xerr.New(xerr.InvalidArgument, "platform is invalid") + } + if ledger.NormalizeRechargeAudienceType(command.AudienceType) == "" { + return xerr.New(xerr.InvalidArgument, "audience_type is invalid") + } + if len(command.RegionIDs) == 0 { + return xerr.New(xerr.InvalidArgument, "region_ids are required") + } + seen := make(map[int64]struct{}, len(command.RegionIDs)) + for _, regionID := range command.RegionIDs { + if regionID <= 0 { + return xerr.New(xerr.InvalidArgument, "region_id is invalid") + } + if _, ok := seen[regionID]; ok { + return xerr.New(xerr.InvalidArgument, "region_id is duplicated") + } + seen[regionID] = struct{}{} + } + return nil +} + +func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool { + for _, productRegionID := range product.RegionIDs { + if productRegionID == regionID { + return true + } + } + return false +} diff --git a/services/wallet-service/internal/service/wallet/reward.go b/services/wallet-service/internal/service/wallet/reward.go new file mode 100644 index 00000000..ff43e10c --- /dev/null +++ b/services/wallet-service/internal/service/wallet/reward.go @@ -0,0 +1,194 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// CreditTaskReward 发放任务奖励金币;任务完成判断属于 activity-service,这里只负责账本幂等入账。 +func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.TaskID == "" || command.CycleKey == "" { + return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task reward command is incomplete") + } + command.TaskType = strings.ToLower(strings.TrimSpace(command.TaskType)) + if command.TaskType != "daily" && command.TaskType != "exclusive" { + return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task_type is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditTaskReward(ctx, command) +} + +// CreditLuckyGiftReward 发放幸运礼物中奖金币;抽奖资格和金额已由 activity-service 决策,钱包只做幂等入账。 +func (s *Service) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.RoomID == "" || command.GiftID == "" || command.PoolID == "" { + return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "lucky gift reward command is incomplete") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.DrawID = strings.TrimSpace(command.DrawID) + command.RoomID = strings.TrimSpace(command.RoomID) + command.GiftID = strings.TrimSpace(command.GiftID) + command.PoolID = strings.TrimSpace(command.PoolID) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditLuckyGiftReward(ctx, command) +} + +// CreditWheelReward 发放转盘金币奖品;抽奖、RTP 和奖品命中归 activity-service,钱包只负责独立 reason 的幂等入账。 +func (s *Service) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.WheelID == "" || command.SelectedTierID == "" { + return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "wheel reward command is incomplete") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.DrawID = strings.TrimSpace(command.DrawID) + command.WheelID = strings.TrimSpace(command.WheelID) + command.SelectedTierID = strings.TrimSpace(command.SelectedTierID) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditWheelReward(ctx, command) +} + +// CreditRoomTurnoverReward 发放每周房间流水奖励金币;结算归 activity-service,钱包只负责幂等入账。 +func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) { + // 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。 + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.SettlementID == "" || command.RoomID == "" { + return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward command is incomplete") + } + // 周期、流水和档位是账务元数据的一部分,必须随交易落库,后续对账才能从钱包交易反查是哪一周、哪个房间、哪个档位。 + if command.PeriodStartMS <= 0 || command.PeriodEndMS <= command.PeriodStartMS || command.CoinSpent <= 0 || command.TierID <= 0 { + return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward period or tier is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.SettlementID = strings.TrimSpace(command.SettlementID) + command.RoomID = strings.TrimSpace(command.RoomID) + command.TierCode = strings.TrimSpace(command.TierCode) + // AppCode 归一化后写入 context,让 repository 的交易、账户和 outbox 都落到同一个 App 分区。 + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditRoomTurnoverReward(ctx, command) +} + +// CreditInviteActivityReward 发放邀请活动金币奖励;达标和重复领取判断属于 activity-service,钱包只负责幂等入账。 +func (s *Service) CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) { + // 钱包入口再次校验最小账务语义,避免上游 bug 生成空 claim 或 0 金额交易。 + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.ClaimID == "" || command.TierID <= 0 || command.CycleKey == "" { + return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward command is incomplete") + } + command.RewardType = strings.ToLower(strings.TrimSpace(command.RewardType)) + if command.RewardType != "recharge" && command.RewardType != "valid_invite" && command.RewardType != "invite_inviter" && command.RewardType != "invite_invitee" { + return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward_type is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.ClaimID = strings.TrimSpace(command.ClaimID) + command.TierCode = strings.TrimSpace(command.TierCode) + command.CycleKey = strings.TrimSpace(command.CycleKey) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditInviteActivityReward(ctx, command) +} + +// CreditAgencyOpeningReward 发放代理开业流水档位金币奖励;命中的档位序号和金额由 activity-service 固化,钱包只做幂等入账。 +func (s *Service) CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.ApplicationID == "" || command.CycleID == "" || command.AgencyID <= 0 || command.RankNo <= 0 { + return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "agency opening reward command is incomplete") + } + if command.ScoreCoins <= 0 { + return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "agency opening score is required") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") + } + if s.repository == nil { + return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.ApplicationID = strings.TrimSpace(command.ApplicationID) + command.CycleID = strings.TrimSpace(command.CycleID) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.CreditAgencyOpeningReward(ctx, command) +} + +// DebitCPBreakupFee 扣除解除关系费用;关系是否可解除仍由 user-service 的 pending/confirm 流程决定。 +func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) { + if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.RelationshipID) == "" || strings.TrimSpace(command.RelationType) == "" { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee command is incomplete") + } + if len(command.CommandID) > 128 { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") + } + if command.Amount <= 0 { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee amount must be positive") + } + if s.repository == nil { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.RelationshipID = strings.TrimSpace(command.RelationshipID) + command.RelationType = strings.ToLower(strings.TrimSpace(command.RelationType)) + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.DebitCPBreakupFee(ctx, command) +} + +// DebitWheelDraw 扣除转盘抽奖消耗金币;抽奖命中必须在 activity-service 完成,wallet 只提供可幂等复用的扣费事实。 +func (s *Service) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) { + if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.WheelID) == "" { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit command is incomplete") + } + if len(command.CommandID) > 128 { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") + } + if command.DrawCount <= 0 { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "draw_count must be positive") + } + if command.Amount <= 0 { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit amount must be positive") + } + if s.repository == nil { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + command.WheelID = strings.TrimSpace(command.WheelID) + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.DebitWheelDraw(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index e4d9fb5a..09532dba 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -1,171 +1,58 @@ package wallet import ( - "context" - "strings" "time" - "google.golang.org/grpc" - activityv1 "hyapp.local/api/proto/activity/v1" - "hyapp/pkg/appcode" - "hyapp/pkg/walletmq" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "hyapp/services/wallet-service/internal/service/wallet/ports" + giftusecase "hyapp/services/wallet-service/internal/service/wallet/usecase/gift" ) const walletBadgeGrantMetric = "wallet_badge_grant" -// Repository 隔离账务存储,扣费必须由 repository 保证余额和幂等。 -type Repository interface { - DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) - BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) - GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) - GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) - GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error) - AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) - AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) - TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) - ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) - ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) - TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) - CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) - CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) - CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) - CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) - CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) - CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) - ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) - DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) - GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error) - UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error) - CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error) - ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error) - ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) - GetRedPacket(ctx context.Context, appCode string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) - ExpireRedPackets(ctx context.Context, appCode string, batchSize int32) (ledger.RedPacketExpireResult, error) - RetryRedPacketRefund(ctx context.Context, appCode string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) - ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) - GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) - GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) - GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) - ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) - ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) - GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error) - GetGoogleRechargeProductByCode(ctx context.Context, appCode string, googleProductID string) (ledger.RechargeProduct, error) - ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error) - MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error - CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) - UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) - DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error - ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) - SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) - UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) - GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error) - ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) - CreateExternalRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string) (ledger.ExternalRechargeOrder, error) - MarkExternalRechargeOrderRedirected(ctx context.Context, appCode string, orderID string, providerOrderID string, payURL string, payloadJSON string) (ledger.ExternalRechargeOrder, error) - AttachExternalRechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) - CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) - MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error) - GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) - ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error) - GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) - ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) - ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) - GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) - PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) - DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) - GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) - ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) - UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) - ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) - ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) - GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) - CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) - UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) - SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) - ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) - GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) - CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) - UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) - SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) - ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) - ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) - CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) - UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) - SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) - DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) - UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) - GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) - GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) - ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) - EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) - UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) - BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) - ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) - ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) - UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) - SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) - ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) - PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) - ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) - ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) - ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) - ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) - MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error - MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error -} - -// ActivityBadgeClient 是 wallet -> activity 的最小投影接口,避免 wallet 依赖 activity 内部包。 -type ActivityBadgeClient interface { - ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) -} - -// GooglePlayClient 隔离 Google Play Developer API,service 只依赖购买校验和消耗能力。 -type GooglePlayClient interface { - GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) - ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error -} - // Service 承载钱包账务用例。 type Service struct { - repository Repository - activity ActivityBadgeClient - googlePlay GooglePlayClient - mifaPay MifaPayClient - v5Pay V5PayClient - tronUSDT TronUSDTClient + repository ports.Repository + giftLedger *giftusecase.Service + activity ports.ActivityBadgeClient + googlePlay ports.GooglePlayClient + mifaPay ports.MifaPayClient + v5Pay ports.V5PayClient + tronUSDT ports.TronUSDTClient externalRecharge ExternalRechargeConfig now func() time.Time } // New 创建钱包服务。 -func New(repository Repository, activity ...ActivityBadgeClient) *Service { - var activityClient ActivityBadgeClient +func New(repository ports.Repository, activity ...ports.ActivityBadgeClient) *Service { + var activityClient ports.ActivityBadgeClient if len(activity) > 0 { activityClient = activity[0] } - return &Service{repository: repository, activity: activityClient, now: time.Now} + return &Service{ + repository: repository, + giftLedger: giftusecase.New(repository), + activity: activityClient, + now: time.Now, + } } // SetGooglePlayClient 注入 Google Play Developer API 客户端;未配置时 confirm 返回依赖不可用。 -func (s *Service) SetGooglePlayClient(client GooglePlayClient) { +func (s *Service) SetGooglePlayClient(client ports.GooglePlayClient) { s.googlePlay = client } // SetMifaPayClient 注入 MiFaPay 下单和验签客户端;未配置时 H5 MiFaPay 下单返回依赖不可用。 -func (s *Service) SetMifaPayClient(client MifaPayClient) { +func (s *Service) SetMifaPayClient(client ports.MifaPayClient) { s.mifaPay = client } // SetV5PayClient 注入 V5Pay 下单、查询和验签客户端;未配置时 H5 V5Pay 下单返回依赖不可用。 -func (s *Service) SetV5PayClient(client V5PayClient) { +func (s *Service) SetV5PayClient(client ports.V5PayClient) { s.v5Pay = client } // SetTronUSDTClient 注入 TRON TRC20 交易校验客户端;未配置时 USDT tx 提交只会保留待确认订单。 -func (s *Service) SetTronUSDTClient(client TronUSDTClient) { +func (s *Service) SetTronUSDTClient(client ports.TronUSDTClient) { s.tronUSDT = client } @@ -173,1087 +60,3 @@ func (s *Service) SetTronUSDTClient(client TronUSDTClient) { func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) { s.externalRecharge = normalizeExternalRechargeConfig(config) } - -// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。 -func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { - command.RobotGift = false - return s.debitGift(ctx, command) -} - -// DebitRobotGift 校验机器人房间专用送礼扣费命令,并强制使用 ROBOT_COIN 账务语义。 -func (s *Service) DebitRobotGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { - command.RobotGift = true - command.TargetIsHost = false - command.TargetHostRegionID = 0 - command.TargetAgencyOwnerUserID = 0 - return s.debitGift(ctx, command) -} - -func (s *Service) debitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { - if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete") - } - if command.GiftCount <= 0 { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") - } - if command.TargetIsHost && command.TargetHostRegionID <= 0 { - // 主播周期钻石后续要按区域工资政策结算;只有 active host profile 且带区域时才允许入账。 - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") - } - if s.repository == nil { - return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.DebitGift(ctx, command) -} - -// BatchDebitGift 校验多目标送礼扣费命令,并要求 repository 在同一事务中完成全部目标结算。 -func (s *Service) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) { - if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.GiftID == "" { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete") - } - if command.GiftCount <= 0 { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") - } - if len(command.Targets) == 0 { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift targets are required") - } - seen := make(map[int64]struct{}, len(command.Targets)) - for _, target := range command.Targets { - if target.CommandID == "" || target.TargetUserID <= 0 { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is incomplete") - } - if _, exists := seen[target.TargetUserID]; exists { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is duplicated") - } - seen[target.TargetUserID] = struct{}{} - if target.TargetIsHost && target.TargetHostRegionID <= 0 { - // 主播工资入账必须带该目标自己的主播区域,不能复用房间区域或其他目标快照。 - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") - } - } - if s.repository == nil { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.BatchDebitGift(ctx, command) -} - -// ProcessHostSalarySettlementBatch 执行主播/代理工资结算批处理;日结和半月结不清空周期钻石,月底只做逻辑清算标记。 -func (s *Service) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { - if s.repository == nil { - return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - switch strings.TrimSpace(command.SettlementType) { - case ledger.HostSalarySettlementModeDaily, ledger.HostSalarySettlementModeHalfMonth, ledger.HostSalarySettlementTypeMonthEnd: - default: - return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") - } - if command.BatchSize <= 0 { - command.BatchSize = 100 - } - if command.BatchSize > 500 { - command.BatchSize = 500 - } - if strings.TrimSpace(command.RunID) == "" || strings.TrimSpace(command.WorkerID) == "" { - return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "run_id and worker_id are required") - } - command.AppCode = appcode.Normalize(command.AppCode) - if command.NowMs <= 0 { - command.NowMs = s.now().UTC().UnixMilli() - } - if strings.TrimSpace(command.CycleKey) == "" { - command.CycleKey = hostSalaryCycleKey(command.NowMs) - } - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.ProcessHostSalarySettlementBatch(ctx, command) -} - -func hostSalaryCycleKey(nowMs int64) string { - // 结算周期按 UTC 月锚定,和送礼入账的 host_period_diamond_accounts.cycle_key 保持同一口径。 - return time.UnixMilli(nowMs).UTC().Format("2006-01") -} - -// GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。 -func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { - if userID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - normalized := make([]string, 0, len(assetTypes)) - for _, assetType := range assetTypes { - assetType = strings.ToUpper(strings.TrimSpace(assetType)) - if assetType == "" { - continue - } - if !ledger.ValidAssetType(assetType) { - return nil, xerr.New(xerr.InvalidArgument, "asset_type is invalid") - } - normalized = append(normalized, assetType) - } - - return s.repository.GetBalances(ctx, userID, normalized) -} - -// GetActiveHostSalaryPolicy 读取主播所在区域当前生效的工资政策,返回的 policy 与工资结算使用同一张 runtime 表。 -func (s *Service) GetActiveHostSalaryPolicy(ctx context.Context, appCode string, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { - if regionID <= 0 { - return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "region_id is required") - } - if s.repository == nil { - return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - settlementMode = strings.TrimSpace(settlementMode) - if settlementMode != "" && settlementMode != ledger.HostSalarySettlementModeDaily && settlementMode != ledger.HostSalarySettlementModeHalfMonth { - return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_mode is invalid") - } - triggerMode = strings.TrimSpace(triggerMode) - if triggerMode != "" && triggerMode != ledger.HostSalarySettlementTriggerAutomatic && triggerMode != ledger.HostSalarySettlementTriggerManual { - return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_trigger_mode is invalid") - } - if nowMs <= 0 { - nowMs = s.now().UTC().UnixMilli() - } - appCode = appcode.Normalize(appCode) - ctx = appcode.WithContext(ctx, appCode) - // H5 展示可以不传 trigger mode,此时 automatic/manual 政策都可展示;结算任务仍会传具体 trigger 精确匹配。 - return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs) -} - -// GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0,避免 H5 用其他等级系统兜底导致进度口径错误。 -func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) { - if userID <= 0 { - return ledger.HostSalaryProgress{}, xerr.New(xerr.InvalidArgument, "host_user_id is required") - } - if s.repository == nil { - return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - if nowMs <= 0 { - nowMs = s.now().UTC().UnixMilli() - } - cycleKey = strings.TrimSpace(cycleKey) - if cycleKey == "" { - cycleKey = hostSalaryCycleKey(nowMs) - } - appCode = appcode.Normalize(appCode) - ctx = appcode.WithContext(ctx, appCode) - return s.repository.GetHostSalaryProgress(ctx, userID, cycleKey) -} - -// AdminCreditAsset 执行后台手动调账;amount 为正数入账、负数扣账,审计字段必须随交易一起落库。 -func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 { - return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin adjustment command is incomplete") - } - command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType)) - if !ledger.ValidAssetType(command.AssetType) { - return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "asset_type is invalid") - } - if command.Reason == "" || command.EvidenceRef == "" { - return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "reason and evidence_ref are required") - } - if s.repository == nil { - return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.AdminCreditAsset(ctx, command) -} - -// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账;USDT 进货和金币补偿使用不同账务类型。 -func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { - if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete") - } - if command.SellerCountryID <= 0 || command.SellerRegionID <= 0 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "seller country and region are required") - } - if len(command.CommandID) > 128 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") - } - if command.CoinAmount <= 0 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin_amount must be positive") - } - command.StockType = ledger.NormalizeCoinSellerStockType(command.StockType) - if command.StockType == "" { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") - } - command.PaidCurrencyCode = strings.ToUpper(strings.TrimSpace(command.PaidCurrencyCode)) - command.PaymentRef = strings.TrimSpace(command.PaymentRef) - command.EvidenceRef = strings.TrimSpace(command.EvidenceRef) - command.Reason = strings.TrimSpace(command.Reason) - if len(command.PaidCurrencyCode) > 8 || len(command.PaymentRef) > 128 || len(command.EvidenceRef) > 256 || len(command.Reason) > 512 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long") - } - switch command.StockType { - case ledger.StockTypeUSDTPurchase: - if command.PaidCurrencyCode == "" { - command.PaidCurrencyCode = ledger.PaidCurrencyUSDT - } - if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount") - } - case ledger.StockTypeCoinCompensation: - if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin compensation must not include paid amount") - } - default: - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") - } - if s.repository == nil { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.AdminCreditCoinSellerStock(ctx, command) -} - -// TransferCoinFromSeller 执行币商专用金币转普通金币;身份和区域都由 gateway/user-service 校验并传入。 -func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { - if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller transfer command is incomplete") - } - if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are required") - } - if command.TargetCountryID <= 0 { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "target country is required") - } - if command.SellerRegionID != command.TargetRegionID { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Conflict, "seller and target region must match") - } - if strings.TrimSpace(command.Reason) == "" { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.Reason = strings.TrimSpace(command.Reason) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.TransferCoinFromSeller(ctx, command) -} - -// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间;gateway 用它给 H5 展示预计到账金币。 -func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { - if regionID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "region_id is required") - } - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - appCode = appcode.Normalize(appCode) - ctx = appcode.WithContext(ctx, appCode) - return s.repository.ListCoinSellerSalaryExchangeRateTiers(ctx, appCode, regionID, includeDisabled) -} - -// ExchangeSalaryToCoin 校验工资兑换命令;身份归属由 gateway 校验,wallet 只接受明确的工资资产和正向美分金额。 -func (s *Service) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) { - if command.CommandID == "" || command.UserID <= 0 || command.SalaryUSDMinor <= 0 { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange command is incomplete") - } - command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) - if !ledger.ValidSalaryAssetType(command.SalaryAssetType) { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - command.Reason = "salary exchange" - } - if len(command.CommandID) > 128 || len(command.Reason) > 512 { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange text fields are too long") - } - if s.repository == nil { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.ExchangeSalaryToCoin(ctx, command) -} - -// TransferSalaryToCoinSeller 校验工资转币商命令;比例区间命中和双边入账由 repository 在同一事务内完成。 -func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) { - if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete") - } - if command.RegionID <= 0 { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required") - } - command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) - if !ledger.ValidSalaryAssetType(command.SalaryAssetType) { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - command.Reason = "salary transfer" - } - if len(command.CommandID) > 128 || len(command.Reason) > 512 { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer text fields are too long") - } - if s.repository == nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.TransferSalaryToCoinSeller(ctx, command) -} - -// CreditTaskReward 发放任务奖励金币;任务完成判断属于 activity-service,这里只负责账本幂等入账。 -func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.TaskID == "" || command.CycleKey == "" { - return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task reward command is incomplete") - } - command.TaskType = strings.ToLower(strings.TrimSpace(command.TaskType)) - if command.TaskType != "daily" && command.TaskType != "exclusive" { - return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "task_type is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditTaskReward(ctx, command) -} - -// CreditLuckyGiftReward 发放幸运礼物中奖金币;抽奖资格和金额已由 activity-service 决策,钱包只做幂等入账。 -func (s *Service) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.RoomID == "" || command.GiftID == "" || command.PoolID == "" { - return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "lucky gift reward command is incomplete") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.DrawID = strings.TrimSpace(command.DrawID) - command.RoomID = strings.TrimSpace(command.RoomID) - command.GiftID = strings.TrimSpace(command.GiftID) - command.PoolID = strings.TrimSpace(command.PoolID) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditLuckyGiftReward(ctx, command) -} - -// CreditWheelReward 发放转盘金币奖品;抽奖、RTP 和奖品命中归 activity-service,钱包只负责独立 reason 的幂等入账。 -func (s *Service) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.WheelID == "" || command.SelectedTierID == "" { - return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "wheel reward command is incomplete") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.DrawID = strings.TrimSpace(command.DrawID) - command.WheelID = strings.TrimSpace(command.WheelID) - command.SelectedTierID = strings.TrimSpace(command.SelectedTierID) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditWheelReward(ctx, command) -} - -// CreditRoomTurnoverReward 发放每周房间流水奖励金币;结算归 activity-service,钱包只负责幂等入账。 -func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) { - // 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。 - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.SettlementID == "" || command.RoomID == "" { - return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward command is incomplete") - } - // 周期、流水和档位是账务元数据的一部分,必须随交易落库,后续对账才能从钱包交易反查是哪一周、哪个房间、哪个档位。 - if command.PeriodStartMS <= 0 || command.PeriodEndMS <= command.PeriodStartMS || command.CoinSpent <= 0 || command.TierID <= 0 { - return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "room turnover reward period or tier is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.SettlementID = strings.TrimSpace(command.SettlementID) - command.RoomID = strings.TrimSpace(command.RoomID) - command.TierCode = strings.TrimSpace(command.TierCode) - // AppCode 归一化后写入 context,让 repository 的交易、账户和 outbox 都落到同一个 App 分区。 - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditRoomTurnoverReward(ctx, command) -} - -// CreditInviteActivityReward 发放邀请活动金币奖励;达标和重复领取判断属于 activity-service,钱包只负责幂等入账。 -func (s *Service) CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) { - // 钱包入口再次校验最小账务语义,避免上游 bug 生成空 claim 或 0 金额交易。 - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.ClaimID == "" || command.TierID <= 0 || command.CycleKey == "" { - return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward command is incomplete") - } - command.RewardType = strings.ToLower(strings.TrimSpace(command.RewardType)) - if command.RewardType != "recharge" && command.RewardType != "valid_invite" && command.RewardType != "invite_inviter" && command.RewardType != "invite_invitee" { - return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "invite activity reward_type is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.ClaimID = strings.TrimSpace(command.ClaimID) - command.TierCode = strings.TrimSpace(command.TierCode) - command.CycleKey = strings.TrimSpace(command.CycleKey) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditInviteActivityReward(ctx, command) -} - -// CreditAgencyOpeningReward 发放代理开业流水档位金币奖励;命中的档位序号和金额由 activity-service 固化,钱包只做幂等入账。 -func (s *Service) CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.ApplicationID == "" || command.CycleID == "" || command.AgencyID <= 0 || command.RankNo <= 0 { - return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "agency opening reward command is incomplete") - } - if command.ScoreCoins <= 0 { - return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "agency opening score is required") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required") - } - if s.repository == nil { - return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.ApplicationID = strings.TrimSpace(command.ApplicationID) - command.CycleID = strings.TrimSpace(command.CycleID) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.CreditAgencyOpeningReward(ctx, command) -} - -// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。 -func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) { - if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game coin command is incomplete") - } - command.OpType = ledger.NormalizeGameOpType(command.OpType) - if command.OpType == "" { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game op_type is invalid") - } - if command.CoinAmount <= 0 { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "coin_amount must be positive") - } - command.RequestHash = strings.TrimSpace(command.RequestHash) - if command.RequestHash == "" { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "request_hash is required") - } - if s.repository == nil { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.PlatformCode = strings.TrimSpace(command.PlatformCode) - command.GameID = strings.TrimSpace(command.GameID) - command.ProviderOrderID = strings.TrimSpace(command.ProviderOrderID) - command.ProviderRoundID = strings.TrimSpace(command.ProviderRoundID) - command.RoomID = strings.TrimSpace(command.RoomID) - ctx = appcode.WithContext(ctx, command.AppCode) - - return s.repository.ApplyGameCoinChange(ctx, command) -} - -// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。 -func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) { - if s.repository == nil { - return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - query.AppCode = appcode.Normalize(query.AppCode) - ctx = appcode.WithContext(ctx, query.AppCode) - - return s.repository.ListRechargeBills(ctx, query) -} - -// GetWalletOverview 返回钱包首页摘要,余额和能力开关都由 wallet-service 统一给出。 -func (s *Service) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) { - if userID <= 0 { - return ledger.WalletOverview{}, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return ledger.WalletOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.GetWalletOverview(ctx, userID) -} - -// GetWalletValueSummary 返回我的页钱包卡片摘要,只读取 COIN 账户。 -func (s *Service) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) { - if userID <= 0 { - return ledger.WalletValueSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return ledger.WalletValueSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.GetWalletValueSummary(ctx, userID) -} - -// GetUserGiftWall 返回用户收礼聚合投影;收礼事实只来自成功送礼账务,不读取房间运行态。 -func (s *Service) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) { - if userID <= 0 { - return ledger.UserGiftWall{}, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.GetUserGiftWall(ctx, userID) -} - -// ProjectPendingGiftWallEvents 消费钱包 outbox 中的送礼成功事件,异步维护用户礼物墙投影。 -func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { - workerID = strings.TrimSpace(workerID) - if workerID == "" { - return 0, xerr.New(xerr.InvalidArgument, "worker_id is required") - } - if s.repository == nil { - return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL) -} - -// ProcessWalletProjectionMessage consumes one wallet_outbox MQ fact for wallet-owned projections. -func (s *Service) ProcessWalletProjectionMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { - workerID = strings.TrimSpace(workerID) - if workerID == "" { - return false, xerr.New(xerr.InvalidArgument, "worker_id is required") - } - if s.repository == nil { - return false, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, message.AppCode) - handled := false - projected, err := s.repository.ProjectGiftWallMessage(ctx, workerID+"-gift-wall", message, lockTTL) - if err != nil { - return handled, err - } - handled = handled || projected - projected, err = s.projectBadgeGrantMessage(ctx, workerID+"-badge-grant", message, lockTTL) - if err != nil { - return handled, err - } - handled = handled || projected - return handled, nil -} - -// ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection. -func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { - workerID = strings.TrimSpace(workerID) - if workerID == "" { - return 0, xerr.New(xerr.InvalidArgument, "worker_id is required") - } - if s.repository == nil { - return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - if s.activity == nil { - return 0, xerr.New(xerr.Unavailable, "activity badge client is not configured") - } - if limit <= 0 { - limit = 50 - } - if lockTTL <= 0 { - lockTTL = 30 * time.Second - } - nowMS := s.now().UnixMilli() - events, err := s.repository.ClaimPendingBadgeGrantEvents(ctx, workerID, nowMS, lockTTL, limit) - if err != nil { - return 0, err - } - processed := 0 - for _, event := range events { - processed++ - if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { - return processed, err - } - } - return processed, nil -} - -func (s *Service) projectBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) { - if !isBadgeGrantWalletProjectionMessage(message) { - return false, nil - } - if s.activity == nil { - return false, xerr.New(xerr.Unavailable, "activity badge client is not configured") - } - nowMS := s.now().UnixMilli() - event, claimed, err := s.repository.ClaimBadgeGrantMessage(ctx, workerID, message, nowMS, lockTTL) - if err != nil || !claimed { - return claimed, err - } - if err := s.deliverBadgeGrantProjection(ctx, event); err != nil { - return true, err - } - return true, nil -} - -func (s *Service) deliverBadgeGrantProjection(ctx context.Context, event resourcedomain.BadgeGrantOutbox) error { - reqCtx := appcode.WithContext(ctx, event.AppCode) - resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{ - Meta: &activityv1.RequestMeta{ - Caller: "wallet-service", - AppCode: event.AppCode, - SentAtMs: s.now().UnixMilli(), - }, - EventId: event.EventID, - EventType: event.EventType, - SourceService: "wallet-service", - UserId: event.UserID, - MetricType: walletBadgeGrantMetric, - Value: 1, - OccurredAtMs: event.CreatedAtMS, - DimensionsJson: event.PayloadJSON, - }) - if consumeErr != nil { - return s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()) - } - if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) { - return s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()) - } - return s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()) -} - -func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) bool { - if strings.TrimSpace(message.AssetType) != "RESOURCE" { - return false - } - switch message.EventType { - case "ResourceGranted", "ResourceGroupGranted": - return true - default: - return false - } -} - -func isBadgeGrantProjectionStatusTerminal(status string) bool { - switch status { - case "consumed", "skipped", "duplicate": - return true - default: - return false - } -} - -// ListRechargeProducts 返回区域化充值档位;region_id 必须由 gateway 从 user-service 资料解析。 -func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) { - if userID <= 0 || regionID <= 0 { - return nil, nil, xerr.New(xerr.InvalidArgument, "user_id and region_id are required") - } - if strings.TrimSpace(platform) != "" { - platform = ledger.NormalizeRechargeProductPlatform(platform) - if platform == "" { - return nil, nil, xerr.New(xerr.InvalidArgument, "platform is invalid") - } - } - audienceType = ledger.NormalizeRechargeAudienceType(audienceType) - if audienceType == "" { - return nil, nil, xerr.New(xerr.InvalidArgument, "audience_type is invalid") - } - if s.repository == nil { - return nil, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.ListRechargeProducts(ctx, userID, regionID, platform, audienceType) -} - -// ListAdminRechargeProducts 返回后台配置列表;后台入口负责 RBAC 和审计。 -func (s *Service) ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error) { - if s.repository == nil { - return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - query.AppCode = appcode.Normalize(query.AppCode) - if strings.TrimSpace(query.Status) != "" { - query.Status = ledger.NormalizeRechargeProductStatus(query.Status) - if query.Status == "" { - return nil, 0, xerr.New(xerr.InvalidArgument, "status is invalid") - } - } - if strings.TrimSpace(query.Platform) != "" { - query.Platform = ledger.NormalizeRechargeProductPlatform(query.Platform) - if query.Platform == "" { - return nil, 0, xerr.New(xerr.InvalidArgument, "platform is invalid") - } - } - if strings.TrimSpace(query.AudienceType) != "" { - query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType) - if query.AudienceType == "" { - return nil, 0, xerr.New(xerr.InvalidArgument, "audience_type is invalid") - } - } - ctx = appcode.WithContext(ctx, query.AppCode) - return s.repository.ListAdminRechargeProducts(ctx, query) -} - -// CreateRechargeProduct 创建内购商品配置;首版充值资源只允许发 COIN。 -func (s *Service) CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { - if s.repository == nil { - return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - if err := validateRechargeProductCommand(command, false); err != nil { - return ledger.RechargeProduct{}, err - } - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.CreateRechargeProduct(ctx, command) -} - -// UpdateRechargeProduct 覆盖内购商品配置和支持区域,避免状态散落在后台库。 -func (s *Service) UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error) { - if s.repository == nil { - return ledger.RechargeProduct{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - if err := validateRechargeProductCommand(command, true); err != nil { - return ledger.RechargeProduct{}, err - } - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.UpdateRechargeProduct(ctx, command) -} - -// DeleteRechargeProduct 删除尚未接入 provider order 的配置事实;订单实现后这里需要先做引用检查。 -func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error { - if productID <= 0 { - return xerr.New(xerr.InvalidArgument, "product_id is required") - } - if s.repository == nil { - return xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID) -} - -// ConfirmGooglePayment 校验 Google Play purchase token,原子入账并记录支付审计。 -func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) { - command.AppCode = appcode.Normalize(command.AppCode) - command.CommandID = strings.TrimSpace(command.CommandID) - command.ProductCode = strings.TrimSpace(command.ProductCode) - command.PackageName = strings.TrimSpace(command.PackageName) - command.PurchaseToken = strings.TrimSpace(command.PurchaseToken) - command.OrderID = strings.TrimSpace(command.OrderID) - if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.PackageName == "" || command.PurchaseToken == "" || (command.ProductID <= 0 && command.ProductCode == "") { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete") - } - if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long") - } - if s.repository == nil { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - if s.googlePlay == nil { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured") - } - ctx = appcode.WithContext(ctx, command.AppCode) - - product, err := s.googleRechargeProductForPayment(ctx, command) - if err != nil { - return ledger.GooglePaymentReceipt{}, err - } - if product.Status != ledger.RechargeProductStatusActive || !product.Enabled { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active") - } - if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product") - } - if command.ProductCode != "" && command.ProductCode != product.ProductName && command.ProductCode != product.ProductCode { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match") - } - if !rechargeProductSupportsRegion(product, command.RegionID) { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region") - } - - purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken) - if err != nil { - return ledger.GooglePaymentReceipt{}, err - } - if purchase.PackageName == "" { - purchase.PackageName = command.PackageName - } - if purchase.PackageName != command.PackageName { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match") - } - if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased") - } - // 旧 App 首充曾把钱包内部 product_code 当成确认参数;上面的兼容只负责放行历史入参, - // 真正的 Google 商品事实仍以 purchase.ProductID 对齐 product_name,避免内部编码绕过支付商品校验。 - if purchase.ProductID != "" && purchase.ProductID != product.ProductName { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product") - } - if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID { - return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match") - } - - receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase) - if err != nil { - return ledger.GooglePaymentReceipt{}, err - } - if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed { - return receipt, nil - } - consumeProductID := product.ProductCode - if purchase.ProductID != "" { - consumeProductID = purchase.ProductID - } - if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil { - return receipt, nil - } - if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil { - return receipt, nil - } - receipt.ConsumeState = ledger.PaymentConsumeStateConsumed - return receipt, nil -} - -func (s *Service) googleRechargeProductForPayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.RechargeProduct, error) { - if command.ProductID > 0 { - return s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID) - } - // 首充弹窗只持有 Google 商品 ID,不再依赖 App 充值商品列表返回 product_id; - // 这里仍然回到钱包内部商品配置取币数、金额和区域,避免客户端自报充值规格。 - return s.repository.GetGoogleRechargeProductByCode(ctx, command.AppCode, command.ProductCode) -} - -// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。 -func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) { - if userID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.GetDiamondExchangeConfig(ctx, userID) -} - -// ListWalletTransactions 返回当前用户钱包流水,避免我的页首屏扫描流水表。 -func (s *Service) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) { - if query.UserID <= 0 { - return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") - } - query.AssetType = strings.ToUpper(strings.TrimSpace(query.AssetType)) - if query.AssetType != "" && !ledger.ValidAssetType(query.AssetType) { - return nil, 0, xerr.New(xerr.InvalidArgument, "asset_type is invalid") - } - if s.repository == nil { - return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - query.AppCode = appcode.Normalize(query.AppCode) - ctx = appcode.WithContext(ctx, query.AppCode) - return s.repository.ListWalletTransactions(ctx, query) -} - -// ListVipPackages 返回可购买 VIP 包和当前会员状态。 -func (s *Service) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) { - if userID <= 0 { - return ledger.UserVip{}, nil, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.ListVipPackages(ctx, userID) -} - -// GetMyVip 返回当前用户会员状态;过期会员会按 active=false 投影。 -func (s *Service) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) { - if userID <= 0 { - return ledger.UserVip{}, xerr.New(xerr.InvalidArgument, "user_id is required") - } - if s.repository == nil { - return ledger.UserVip{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.GetMyVip(ctx, userID) -} - -// PurchaseVip 执行 VIP 购买、升级或续期;降级由 repository 在锁内按当前会员状态拒绝。 -func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { - if command.CommandID == "" || command.UserID <= 0 || command.Level <= 0 { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip purchase command is incomplete") - } - if s.repository == nil { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.PurchaseVip(ctx, command) -} - -// DebitCPBreakupFee 扣除解除关系费用;关系是否可解除仍由 user-service 的 pending/confirm 流程决定。 -func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) { - if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.RelationshipID) == "" || strings.TrimSpace(command.RelationType) == "" { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee command is incomplete") - } - if len(command.CommandID) > 128 { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") - } - if command.Amount <= 0 { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee amount must be positive") - } - if s.repository == nil { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.RelationshipID = strings.TrimSpace(command.RelationshipID) - command.RelationType = strings.ToLower(strings.TrimSpace(command.RelationType)) - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.DebitCPBreakupFee(ctx, command) -} - -// DebitWheelDraw 扣除转盘抽奖消耗金币;抽奖命中必须在 activity-service 完成,wallet 只提供可幂等复用的扣费事实。 -func (s *Service) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) { - if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.WheelID) == "" { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit command is incomplete") - } - if len(command.CommandID) > 128 { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") - } - if command.DrawCount <= 0 { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "draw_count must be positive") - } - if command.Amount <= 0 { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit amount must be positive") - } - if s.repository == nil { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - command.WheelID = strings.TrimSpace(command.WheelID) - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.DebitWheelDraw(ctx, command) -} - -// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。 -func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) { - if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant command is incomplete") - } - if len(command.CommandID) > 128 { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") - } - command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource) - switch command.GrantSource { - case ledger.VipGrantSourceAdmin, ledger.VipGrantSourceManagerCenter: - if command.OperatorUserID <= 0 { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is required") - } - case ledger.VipGrantSourceActivity: - if command.OperatorUserID < 0 { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is invalid") - } - default: - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - command.Reason = command.GrantSource - } - if len(command.Reason) > 512 { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "reason is too long") - } - if s.repository == nil { - return ledger.GrantVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - command.AppCode = appcode.Normalize(command.AppCode) - ctx = appcode.WithContext(ctx, command.AppCode) - return s.repository.GrantVip(ctx, command) -} - -// ListAdminVipLevels 返回后台 VIP 配置页的 10 级配置快照。 -func (s *Service) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) { - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.ListAdminVipLevels(ctx) -} - -// UpdateAdminVipLevels 保存后台 VIP 等级配置;完整校验下沉到 repository,保证和购买事务一致。 -func (s *Service) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) { - if operatorUserID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "operator_user_id is required") - } - if s.repository == nil { - return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - normalized := make([]ledger.AdminVipLevelCommand, 0, len(levels)) - for _, level := range levels { - level.Name = strings.TrimSpace(level.Name) - level.Status = strings.ToLower(strings.TrimSpace(level.Status)) - normalized = append(normalized, level) - } - ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) - return s.repository.UpdateAdminVipLevels(ctx, normalized, operatorUserID) -} - -func validateRechargeProductCommand(command ledger.RechargeProductCommand, requireProductID bool) error { - if requireProductID && command.ProductID <= 0 { - return xerr.New(xerr.InvalidArgument, "product_id is required") - } - if command.AmountMicro <= 0 || command.CoinAmount <= 0 || command.OperatorUserID <= 0 { - return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required") - } - if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 { - return xerr.New(xerr.InvalidArgument, "product_name is invalid") - } - if len([]rune(strings.TrimSpace(command.Description))) > 512 { - return xerr.New(xerr.InvalidArgument, "description is too long") - } - if ledger.NormalizeRechargeProductPlatform(command.Platform) == "" { - return xerr.New(xerr.InvalidArgument, "platform is invalid") - } - if ledger.NormalizeRechargeAudienceType(command.AudienceType) == "" { - return xerr.New(xerr.InvalidArgument, "audience_type is invalid") - } - if len(command.RegionIDs) == 0 { - return xerr.New(xerr.InvalidArgument, "region_ids are required") - } - seen := make(map[int64]struct{}, len(command.RegionIDs)) - for _, regionID := range command.RegionIDs { - if regionID <= 0 { - return xerr.New(xerr.InvalidArgument, "region_id is invalid") - } - if _, ok := seen[regionID]; ok { - return xerr.New(xerr.InvalidArgument, "region_id is duplicated") - } - seen[regionID] = struct{}{} - } - return nil -} - -func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool { - for _, productRegionID := range product.RegionIDs { - if productRegionID == regionID { - return true - } - } - return false -} diff --git a/services/wallet-service/internal/service/wallet/usecase/gift/service.go b/services/wallet-service/internal/service/wallet/usecase/gift/service.go new file mode 100644 index 00000000..aede63f9 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/usecase/gift/service.go @@ -0,0 +1,91 @@ +// Package gift contains gift debit usecases. +package gift + +import ( + "context" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "hyapp/services/wallet-service/internal/service/wallet/ports" +) + +// Service 校验礼物扣费命令,并把原子账务提交交给 repository。 +type Service struct { + // repository 只需要礼物账本端口,后续单测不用实现完整 wallet Repository。 + repository ports.GiftLedgerStore +} + +// New 创建礼物扣费用例。 +func New(repository ports.GiftLedgerStore) *Service { + return &Service{repository: repository} +} + +// DebitGift 校验普通送礼扣费命令,并交给 repository 做原子落账和幂等。 +func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + command.RobotGift = false + return s.debitGift(ctx, command) +} + +// DebitRobotGift 校验机器人房间专用送礼扣费命令,并强制使用 ROBOT_COIN 账务语义。 +func (s *Service) DebitRobotGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + command.RobotGift = true + command.TargetIsHost = false + command.TargetHostRegionID = 0 + command.TargetAgencyOwnerUserID = 0 + return s.debitGift(ctx, command) +} + +func (s *Service) debitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete") + } + if command.GiftCount <= 0 { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") + } + if command.TargetIsHost && command.TargetHostRegionID <= 0 { + // 主播周期钻石后续要按区域工资政策结算;只有 active host profile 且带区域时才允许入账。 + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") + } + if s == nil || s.repository == nil { + return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.DebitGift(ctx, command) +} + +// BatchDebitGift 校验多目标送礼扣费命令,并要求 repository 在同一事务中完成全部目标结算。 +func (s *Service) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) { + if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.GiftID == "" { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete") + } + if command.GiftCount <= 0 { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") + } + if len(command.Targets) == 0 { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift targets are required") + } + seen := make(map[int64]struct{}, len(command.Targets)) + for _, target := range command.Targets { + if target.CommandID == "" || target.TargetUserID <= 0 { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is incomplete") + } + if _, exists := seen[target.TargetUserID]; exists { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "gift target is duplicated") + } + seen[target.TargetUserID] = struct{}{} + if target.TargetIsHost && target.TargetHostRegionID <= 0 { + // 主播工资入账必须带该目标自己的主播区域,不能复用房间区域或其他目标快照。 + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "target_host_region_id is required") + } + } + if s == nil || s.repository == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + + return s.repository.BatchDebitGift(ctx, command) +} diff --git a/services/wallet-service/internal/service/wallet/vip.go b/services/wallet-service/internal/service/wallet/vip.go new file mode 100644 index 00000000..c2995c45 --- /dev/null +++ b/services/wallet-service/internal/service/wallet/vip.go @@ -0,0 +1,109 @@ +package wallet + +import ( + "context" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// ListVipPackages 返回可购买 VIP 包和当前会员状态。 +func (s *Service) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) { + if userID <= 0 { + return ledger.UserVip{}, nil, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.ListVipPackages(ctx, userID) +} + +// GetMyVip 返回当前用户会员状态;过期会员会按 active=false 投影。 +func (s *Service) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) { + if userID <= 0 { + return ledger.UserVip{}, xerr.New(xerr.InvalidArgument, "user_id is required") + } + if s.repository == nil { + return ledger.UserVip{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.GetMyVip(ctx, userID) +} + +// PurchaseVip 执行 VIP 购买、升级或续期;降级由 repository 在锁内按当前会员状态拒绝。 +func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { + if command.CommandID == "" || command.UserID <= 0 || command.Level <= 0 { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip purchase command is incomplete") + } + if s.repository == nil { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.PurchaseVip(ctx, command) +} + +// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。 +func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) { + if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant command is incomplete") + } + if len(command.CommandID) > 128 { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long") + } + command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource) + switch command.GrantSource { + case ledger.VipGrantSourceAdmin, ledger.VipGrantSourceManagerCenter: + if command.OperatorUserID <= 0 { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is required") + } + case ledger.VipGrantSourceActivity: + if command.OperatorUserID < 0 { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is invalid") + } + default: + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + command.Reason = command.GrantSource + } + if len(command.Reason) > 512 { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "reason is too long") + } + if s.repository == nil { + return ledger.GrantVipReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + command.AppCode = appcode.Normalize(command.AppCode) + ctx = appcode.WithContext(ctx, command.AppCode) + return s.repository.GrantVip(ctx, command) +} + +// ListAdminVipLevels 返回后台 VIP 配置页的 10 级配置快照。 +func (s *Service) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) { + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.ListAdminVipLevels(ctx) +} + +// UpdateAdminVipLevels 保存后台 VIP 等级配置;完整校验下沉到 repository,保证和购买事务一致。 +func (s *Service) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) { + if operatorUserID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "operator_user_id is required") + } + if s.repository == nil { + return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + normalized := make([]ledger.AdminVipLevelCommand, 0, len(levels)) + for _, level := range levels { + level.Name = strings.TrimSpace(level.Name) + level.Status = strings.ToLower(strings.TrimSpace(level.Status)) + normalized = append(normalized, level) + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.UpdateAdminVipLevels(ctx, normalized, operatorUserID) +} diff --git a/services/wallet-service/internal/storage/mysql/account.go b/services/wallet-service/internal/storage/mysql/account.go new file mode 100644 index 00000000..04eef348 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/account.go @@ -0,0 +1,180 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "sort" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +type walletAccount struct { + AppCode string + UserID int64 + AssetType string + AvailableAmount int64 + FrozenAmount int64 + Version int64 + UpdatedAtMS int64 +} + +type walletAccountLockRequest struct { + UserID int64 + AssetType string + CreateIfMissing bool +} + +type walletAccountState struct { + account walletAccount +} + +func walletAccountLockKey(userID int64, assetType string) string { + return fmt.Sprintf("%s:%d", strings.ToUpper(strings.TrimSpace(assetType)), userID) +} + +func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, requests []walletAccountLockRequest, nowMs int64) (map[string]*walletAccountState, error) { + merged := make(map[string]walletAccountLockRequest, len(requests)) + for _, request := range requests { + request.AssetType = ledger.NormalizeGiftChargeAssetType(request.AssetType) + if request.UserID <= 0 || strings.TrimSpace(request.AssetType) == "" { + return nil, xerr.New(xerr.InvalidArgument, "wallet account lock request is invalid") + } + key := walletAccountLockKey(request.UserID, request.AssetType) + if existing, ok := merged[key]; ok { + + existing.CreateIfMissing = existing.CreateIfMissing || request.CreateIfMissing + merged[key] = existing + continue + } + merged[key] = request + } + + ordered := make([]walletAccountLockRequest, 0, len(merged)) + for _, request := range merged { + ordered = append(ordered, request) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].AssetType == ordered[j].AssetType { + return ordered[i].UserID < ordered[j].UserID + } + return ordered[i].AssetType < ordered[j].AssetType + }) + + states := make(map[string]*walletAccountState, len(ordered)) + for _, request := range ordered { + account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs) + if err != nil { + return nil, err + } + states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account} + } + return states, nil +} + +func (r *Repository) applyTrackedAccountDelta(ctx context.Context, tx *sql.Tx, state *walletAccountState, availableDelta int64, frozenDelta int64, nowMs int64) (walletAccount, error) { + if state == nil { + return walletAccount{}, xerr.New(xerr.Internal, "wallet account state is missing") + } + before := state.account + if err := r.applyAccountDelta(ctx, tx, before, availableDelta, frozenDelta, nowMs); err != nil { + return walletAccount{}, err + } + after := before + after.AvailableAmount += availableDelta + after.FrozenAmount += frozenDelta + after.Version++ + after.UpdatedAtMS = nowMs + state.account = after + return after, nil +} + +func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64, assetType string, createIfMissing bool, nowMs int64) (walletAccount, error) { + account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) + if err != nil || exists || !createIfMissing { + if err != nil { + return walletAccount{}, err + } + if !exists { + return walletAccount{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + return account, nil + } + + if _, err := tx.ExecContext(ctx, + `INSERT INTO wallet_accounts (app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, + appcode.FromContext(ctx), userID, assetType, nowMs, nowMs, + ); err != nil { + return walletAccount{}, err + } + return r.queryRequiredAccountForUpdate(ctx, tx, userID, assetType) +} + +func (r *Repository) queryAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, bool, error) { + row := tx.QueryRowContext(ctx, + `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms + FROM wallet_accounts + WHERE app_code = ? AND user_id = ? AND asset_type = ? + FOR UPDATE`, + appcode.FromContext(ctx), + userID, assetType, + ) + var account walletAccount + if err := row.Scan(&account.AppCode, &account.UserID, &account.AssetType, &account.AvailableAmount, &account.FrozenAmount, &account.Version, &account.UpdatedAtMS); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return walletAccount{}, false, nil + } + return walletAccount{}, false, err + } + return account, true, nil +} + +func (r *Repository) queryRequiredAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, error) { + account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) + if err != nil { + return walletAccount{}, err + } + if !exists { + return walletAccount{}, xerr.New(xerr.Internal, "wallet account creation failed") + } + return account, nil +} + +func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account walletAccount, availableDelta int64, frozenDelta int64, nowMs int64) error { + availableAfter := account.AvailableAmount + availableDelta + frozenAfter := account.FrozenAmount + frozenDelta + if availableAfter < 0 || frozenAfter < 0 || availableAfter > math.MaxInt64-frozenAfter { + + return xerr.New(xerr.LedgerConflict, "wallet balance delta is invalid") + } + + result, err := tx.ExecContext(ctx, + `UPDATE wallet_accounts + SET available_amount = ?, frozen_amount = ?, version = version + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`, + availableAfter, + frozenAfter, + nowMs, + account.AppCode, + account.UserID, + account.AssetType, + account.Version, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return xerr.New(xerr.LedgerConflict, "wallet account version conflict") + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/admin_ledger.go b/services/wallet-service/internal/storage/mysql/admin_ledger.go new file mode 100644 index 00000000..4aea468a --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/admin_ledger.go @@ -0,0 +1,105 @@ +package mysql + +import ( + "context" + "fmt" + "math" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// AdminCreditAsset 在一个事务内写入后台调账交易、分录和 outbox。 +func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) { + if r == nil || r.db == nil { + return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.AssetBalance{}, "", err + } + defer func() { _ = tx.Rollback() }() + + requestHash := adminCreditRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeManualCredit); err != nil || exists { + if err != nil || !exists { + return ledger.AssetBalance{}, "", err + } + balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.TargetUserID, command.AssetType) + return balance, txRow.TransactionID, balanceErr + } + + nowMs := time.Now().UnixMilli() + + account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, command.Amount > 0, nowMs) + if err != nil { + return ledger.AssetBalance{}, "", err + } + if command.Amount < 0 && (command.Amount == math.MinInt64 || account.AvailableAmount < -command.Amount) { + return ledger.AssetBalance{}, "", xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + transactionID := transactionID(command.AppCode, command.CommandID) + metadata := adminCreditMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: command.AssetType, + Amount: command.Amount, + OperatorUserID: command.OperatorUserID, + Reason: command.Reason, + EvidenceRef: command.EvidenceRef, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeManualCredit, requestHash, command.EvidenceRef, metadata, nowMs); err != nil { + return ledger.AssetBalance{}, "", err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.AssetBalance{}, "", err + } + balance := ledger.AssetBalance{ + AppCode: command.AppCode, + UserID: command.TargetUserID, + AssetType: command.AssetType, + AvailableAmount: account.AvailableAmount + command.Amount, + FrozenAmount: account.FrozenAmount, + Version: account.Version + 1, + UpdatedAtMs: nowMs, + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: command.AssetType, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balance.AvailableAmount, + FrozenAfter: balance.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.AssetBalance{}, "", err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), + }); err != nil { + return ledger.AssetBalance{}, "", err + } + if err := tx.Commit(); err != nil { + return ledger.AssetBalance{}, "", err + } + + return balance, transactionID, nil +} + +func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string { + 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, + )) +} diff --git a/services/wallet-service/internal/storage/mysql/app_wallet_constants.go b/services/wallet-service/internal/storage/mysql/app_wallet_constants.go new file mode 100644 index 00000000..9a635e58 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/app_wallet_constants.go @@ -0,0 +1,8 @@ +package mysql + +const ( + bizTypeVIPPurchase = "vip_purchase" + bizTypeVIPGrant = "vip_grant" + vipOutboxAsset = "VIP" + managerCenterVIPGrantDurationMS = 30 * 24 * 60 * 60 * 1000 +) diff --git a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go b/services/wallet-service/internal/storage/mysql/app_wallet_repository.go deleted file mode 100644 index beaa2686..00000000 --- a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go +++ /dev/null @@ -1,1146 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - resourcedomain "hyapp/services/wallet-service/internal/domain/resource" -) - -const ( - bizTypeVIPPurchase = "vip_purchase" - bizTypeVIPGrant = "vip_grant" - vipOutboxAsset = "VIP" - managerCenterVIPGrantDurationMS = 30 * 24 * 60 * 60 * 1000 -) - -// GetWalletOverview 返回钱包首页摘要;开关放在 wallet-service 内,避免 gateway 硬编码账务能力。 -func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) { - // 钱包首页返回所有可展示的资金口袋;工资钱包按身份拆分,0 余额也返回,便于客户端固定布局。 - balances, err := r.GetBalances(ctx, userID, []string{ - ledger.AssetCoin, - ledger.AssetHostSalaryUSD, - ledger.AssetAgencySalaryUSD, - ledger.AssetBDSalaryUSD, - ledger.AssetAdminSalaryUSD, - }) - if err != nil { - return ledger.WalletOverview{}, err - } - return ledger.WalletOverview{ - Balances: balances, - FeatureFlags: ledger.WalletFeatureFlags{ - RechargeEnabled: true, - DiamondExchangeEnabled: false, - }, - }, nil -} - -// GetWalletValueSummary 返回我的页钱包卡片需要的最小金币余额。 -func (r *Repository) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) { - balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin}) - if err != nil { - return ledger.WalletValueSummary{}, err - } - if len(balances) == 0 { - return ledger.WalletValueSummary{}, nil - } - return ledger.WalletValueSummary{ - CoinAmount: balances[0].AvailableAmount, - UpdatedAtMS: balances[0].UpdatedAtMs, - }, nil -} - -// GetUserGiftWall 读取送礼事务维护的收礼聚合表;它只表达已扣费成功的礼物事实。 -func (r *Repository) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) { - if r == nil || r.db == nil { - return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - appCode := appcode.FromContext(ctx) - rows, err := r.db.QueryContext(ctx, ` - SELECT gift_id, gift_name, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'), - gift_type_code, COALESCE(CAST(presentation_json AS CHAR), '{}'), gift_count, - total_value, total_coin_value, total_diamond_value, total_gift_point, total_heat_value, - charge_asset_type, last_price_version, first_received_at_ms, last_received_at_ms, sort_order - FROM user_gift_wall - WHERE app_code = ? AND user_id = ? - ORDER BY sort_order ASC, total_value DESC, last_received_at_ms DESC, gift_id ASC`, - appCode, userID, - ) - if err != nil { - return ledger.UserGiftWall{}, err - } - defer rows.Close() - - wall := ledger.UserGiftWall{Items: make([]ledger.GiftWallItem, 0)} - for rows.Next() { - var item ledger.GiftWallItem - if err := rows.Scan( - &item.GiftID, - &item.GiftName, - &item.ResourceID, - &item.ResourceSnapshotJSON, - &item.GiftTypeCode, - &item.PresentationJSON, - &item.GiftCount, - &item.TotalValue, - &item.TotalCoinValue, - &item.TotalDiamondValue, - &item.TotalGiftPoint, - &item.TotalHeatValue, - &item.ChargeAssetType, - &item.LastPriceVersion, - &item.FirstReceivedAtMS, - &item.LastReceivedAtMS, - &item.SortOrder, - ); err != nil { - return ledger.UserGiftWall{}, err - } - item.Resource = giftWallResourceFromJSON(item.ResourceSnapshotJSON) - wall.Items = append(wall.Items, item) - wall.GiftKindCount++ - wall.GiftTotalCount += item.GiftCount - wall.TotalValue += item.TotalValue - wall.TotalCoinValue += item.TotalCoinValue - wall.TotalDiamondValue += item.TotalDiamondValue - wall.TotalGiftPoint += item.TotalGiftPoint - wall.TotalHeatValue += item.TotalHeatValue - } - if err := rows.Err(); err != nil { - return ledger.UserGiftWall{}, err - } - return wall, nil -} - -func giftWallResourceFromJSON(raw string) ledger.GiftWallResource { - var resource ledger.GiftWallResource - if strings.TrimSpace(raw) == "" { - return resource - } - // 礼物墙统计不能因为展示资源快照异常而丢失;原始 JSON 仍会返回给客户端用于排查。 - _ = json.Unmarshal([]byte(raw), &resource) - return resource -} - -// GetDiamondExchangeConfig 返回后台配置的钻石兑换规则,未配置时返回空列表而不是 gateway 默认值。 -func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]ledger.DiamondExchangeRule, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - return []ledger.DiamondExchangeRule{}, nil -} - -// ListWalletTransactions 分页读取当前用户钱包分录,并带出交易业务类型。 -func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - where := `WHERE e.app_code = ? AND e.user_id = ?` - args := []any{query.AppCode, query.UserID} - if strings.TrimSpace(query.AssetType) != "" { - where += ` AND e.asset_type = ?` - args = append(args, query.AssetType) - } - - var total int64 - if err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM wallet_entries e - `+where, - args..., - ).Scan(&total); err != nil { - return nil, 0, err - } - - rows, err := r.db.QueryContext(ctx, ` - SELECT e.entry_id, e.transaction_id, wt.biz_type, e.asset_type, - e.available_delta, e.frozen_delta, e.available_after, e.frozen_after, - e.counterparty_user_id, e.room_id, e.created_at_ms - FROM wallet_entries e - JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id - `+where+` - ORDER BY e.created_at_ms DESC, e.entry_id DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items := make([]ledger.WalletTransaction, 0, query.PageSize) - for rows.Next() { - var item ledger.WalletTransaction - if err := rows.Scan( - &item.EntryID, - &item.TransactionID, - &item.BizType, - &item.AssetType, - &item.AvailableDelta, - &item.FrozenDelta, - &item.AvailableAfter, - &item.FrozenAfter, - &item.CounterpartyUserID, - &item.RoomID, - &item.CreatedAtMS, - ); err != nil { - return nil, 0, err - } - items = append(items, item) - } - return items, total, rows.Err() -} - -// GetMyVip 返回用户当前 VIP 状态;过期只影响 active 投影,不直接修改历史事实。 -func (r *Repository) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) { - if r == nil || r.db == nil { - return ledger.UserVip{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - vip, err := r.queryUserVip(ctx, r.db, userID, time.Now().UnixMilli(), false) - if errors.Is(err, sql.ErrNoRows) { - return ledger.UserVip{UserID: userID}, nil - } - return vip, err -} - -// ListVipPackages 返回当前会员和全部 active VIP 包。 -func (r *Repository) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) { - if r == nil || r.db == nil { - return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - nowMs := time.Now().UnixMilli() - current, err := r.GetMyVip(ctx, userID) - if err != nil { - return ledger.UserVip{}, nil, err - } - totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, r.db, userID) - if err != nil { - return ledger.UserVip{}, nil, err - } - levels, err := r.listVipLevels(ctx, nowMs, current.Level, totalRechargeCoin) - return current, levels, err -} - -// ListAdminVipLevels 返回后台配置页所需的全部 VIP 等级。 -func (r *Repository) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - rows, err := r.db.QueryContext(ctx, ` - SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, - required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms - FROM vip_levels - WHERE app_code = ? - ORDER BY level ASC`, - appcode.FromContext(ctx), - ) - if err != nil { - return nil, err - } - defer rows.Close() - - levels := make([]ledger.VipLevel, 0, 10) - for rows.Next() { - level, err := r.scanVipLevel(rows) - if err != nil { - return nil, err - } - if level.RewardResourceGroupID > 0 { - level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, time.Now().UnixMilli()+level.DurationMS) - if err != nil { - return nil, err - } - } - levels = append(levels, level) - } - return levels, rows.Err() -} - -// UpdateAdminVipLevels 保存完整 10 级 VIP 配置。 -func (r *Repository) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if operatorUserID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "operator_user_id is required") - } - normalized, err := normalizeAdminVipLevelCommands(levels) - if err != nil { - return nil, err - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - for _, level := range normalized { - if level.Status == ledger.VipStatusActive { - if err := r.requireActiveResourceGroup(ctx, tx, level.RewardResourceGroupID); err != nil { - return nil, err - } - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO vip_levels ( - app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, - required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - name = VALUES(name), - status = VALUES(status), - price_coin = VALUES(price_coin), - duration_ms = VALUES(duration_ms), - reward_resource_group_id = VALUES(reward_resource_group_id), - required_recharge_coin_amount = VALUES(required_recharge_coin_amount), - sort_order = VALUES(sort_order), - updated_at_ms = VALUES(updated_at_ms)`, - appcode.FromContext(ctx), - level.Level, - level.Name, - level.Status, - level.PriceCoin, - level.DurationMS, - level.RewardResourceGroupID, - level.RequiredRechargeCoinAmount, - level.SortOrder, - nowMs, - nowMs, - ); err != nil { - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return r.ListAdminVipLevels(ctx) -} - -// PurchaseVip 在一个事务内完成金币扣费、VIP 状态更新、订单历史和资源组发放。 -func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { - if r == nil || r.db == nil { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := stableHash(fmt.Sprintf("vip_purchase|%s|%d|%d", command.AppCode, command.UserID, command.Level)) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeVIPPurchase); err != nil || exists { - if err != nil || !exists { - return ledger.PurchaseVipReceipt{}, err - } - return r.vipPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) - } - - nowMs := time.Now().UnixMilli() - level, err := r.getVipLevelForUpdate(ctx, tx, command.Level) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if level.Status != ledger.VipStatusActive { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled") - } - current, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMs) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - previousLevel := int32(0) - baseExpiresAt := nowMs - if current.Active { - previousLevel = current.Level - baseExpiresAt = current.ExpiresAtMS - if current.Level > command.Level { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed") - } - } - if level.RechargeGateRequired { - totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, tx, command.UserID) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if totalRechargeCoin < level.RequiredRechargeCoinAmount { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPRechargeRequired, "vip recharge requirement is not met") - } - } - newExpiresAt := baseExpiresAt + level.DurationMS - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if account.AvailableAmount < level.PriceCoin { - return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - - transactionID := transactionID(command.AppCode, command.CommandID) - orderID := "vip_order_" + stableHash(command.AppCode+"|"+command.CommandID) - coinBalanceAfter := account.AvailableAmount - level.PriceCoin - metadata := map[string]any{ - "app_code": command.AppCode, - "order_id": orderID, - "user_id": command.UserID, - "target_level": command.Level, - "previous_level": previousLevel, - "price_coin": level.PriceCoin, - "expires_at_ms": newExpiresAt, - "balance_after": coinBalanceAfter, - "reward_group_id": level.RewardResourceGroupID, - "duration_ms": level.DurationMS, - "required_recharge_coin_amount": level.RequiredRechargeCoinAmount, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPPurchase, requestHash, orderID, metadata, nowMs); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, -level.PriceCoin, 0, nowMs); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -level.PriceCoin, - FrozenDelta: 0, - AvailableAfter: coinBalanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - activation, err := r.activateUserVip(ctx, tx, vipActivationCommand{ - AppCode: command.AppCode, - CommandID: command.CommandID, - UserID: command.UserID, - Source: ledger.VipGrantSourcePurchase, - Reason: "vip purchase reward", - OperatorUserID: command.UserID, - TransactionID: transactionID, - RecordID: orderID, - Level: level, - Current: current, - NowMS: nowMs, - }) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO vip_purchase_orders ( - app_code, order_id, command_id, user_id, target_level, previous_level, price_coin, - status, wallet_transaction_id, resource_grant_id, request_hash, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`, - command.AppCode, orderID, command.CommandID, command.UserID, command.Level, previousLevel, - level.PriceCoin, transactionID, activation.ResourceGrantID, requestHash, nowMs, nowMs, - ); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - vipEventType := "VipPurchased" - if activation.Action == "renew" { - vipEventType = "VipRenewed" - } else if activation.Action == "upgrade" { - vipEventType = "VipUpgraded" - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - { - EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"), - EventType: vipEventType, - TransactionID: transactionID, - CommandID: command.CommandID, - UserID: command.UserID, - AssetType: "VIP", - AvailableDelta: 0, - FrozenDelta: 0, - Payload: metadata, - CreatedAtMS: nowMs, - }, - vipActivatedEvent(transactionID, command.CommandID, command.UserID, ledger.VipGrantSourcePurchase, activation.Action, activation.PreviousLevel, activation.VIP, level.RewardResourceGroupID, activation.ResourceGrantID, activation.RewardItems, nowMs), - }); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.PurchaseVipReceipt{}, err - } - - return ledger.PurchaseVipReceipt{ - OrderID: orderID, - TransactionID: transactionID, - Vip: activation.VIP, - CoinSpent: level.PriceCoin, - CoinBalanceAfter: coinBalanceAfter, - RewardItems: activation.RewardItems, - }, nil -} - -// GrantVip 是活动和后台赠送 VIP 的统一账务入口;它不扣金币,也不检查充值门槛。 -func (r *Repository) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) { - if r == nil || r.db == nil { - return ledger.GrantVipReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource) - if command.GrantSource == "" || command.GrantSource == ledger.VipGrantSourcePurchase { - return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid") - } - command.Reason = strings.TrimSpace(command.Reason) - if command.Reason == "" { - command.Reason = command.GrantSource - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := vipGrantRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeVIPGrant); err != nil || exists { - if err != nil || !exists { - return ledger.GrantVipReceipt{}, err - } - return r.vipGrantReceiptForTransaction(ctx, tx, txRow, command.TargetUserID) - } - - nowMs := time.Now().UnixMilli() - level, err := r.getVipLevelForUpdate(ctx, tx, command.Level) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - if level.Status != ledger.VipStatusActive { - return ledger.GrantVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled") - } - if command.GrantSource == ledger.VipGrantSourceManagerCenter { - // 经理中心只能赠送 30 天 VIP;这里覆盖配置里的购买周期,保证 H5 即使不能传 duration,也不会受后台 VIP 套餐周期影响。 - level.DurationMS = managerCenterVIPGrantDurationMS - } - current, err := r.ensureUserVipForUpdate(ctx, tx, command.TargetUserID, nowMs) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - recordID := "vip_grant_" + stableHash(command.AppCode+"|"+command.CommandID) - expectedResourceGrantID := resourceGrantID(command.AppCode, "vip_reward:"+command.CommandID) - metadata := map[string]any{ - "app_code": command.AppCode, - "grant_id": recordID, - "resource_grant_id": expectedResourceGrantID, - "user_id": command.TargetUserID, - "target_level": command.Level, - "grant_source": command.GrantSource, - "operator_user_id": command.OperatorUserID, - "reason": command.Reason, - "reward_group_id": level.RewardResourceGroupID, - "duration_ms": level.DurationMS, - "transaction_id": transactionID, - "required_recharge_bypassed": true, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPGrant, requestHash, recordID, metadata, nowMs); err != nil { - return ledger.GrantVipReceipt{}, err - } - activation, err := r.activateUserVip(ctx, tx, vipActivationCommand{ - AppCode: command.AppCode, - CommandID: command.CommandID, - UserID: command.TargetUserID, - Source: command.GrantSource, - Reason: command.Reason, - OperatorUserID: command.OperatorUserID, - TransactionID: transactionID, - RecordID: recordID, - Level: level, - Current: current, - NowMS: nowMs, - }) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - vipActivatedEvent(transactionID, command.CommandID, command.TargetUserID, command.GrantSource, activation.Action, activation.PreviousLevel, activation.VIP, level.RewardResourceGroupID, activation.ResourceGrantID, activation.RewardItems, nowMs), - }); err != nil { - return ledger.GrantVipReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.GrantVipReceipt{}, err - } - return ledger.GrantVipReceipt{ - TransactionID: transactionID, - Vip: activation.VIP, - RewardItems: activation.RewardItems, - }, nil -} - -type vipActivationCommand struct { - AppCode string - CommandID string - UserID int64 - Source string - Reason string - OperatorUserID int64 - TransactionID string - RecordID string - Level ledger.VipLevel - Current ledger.UserVip - NowMS int64 -} - -type vipActivationResult struct { - VIP ledger.UserVip - PreviousLevel int32 - Action string - ResourceGrantID string - RewardItems []ledger.VipRewardItem -} - -func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vipActivationCommand) (vipActivationResult, error) { - previousLevel := int32(0) - baseExpiresAt := command.NowMS - startedAt := command.NowMS - if command.Current.Active { - previousLevel = command.Current.Level - baseExpiresAt = command.Current.ExpiresAtMS - startedAt = command.Current.StartedAtMS - if command.Current.Level > command.Level.Level { - return vipActivationResult{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed") - } - } - expiresAt := baseExpiresAt + command.Level.DurationMS - rewardItems, resourceGrantID, err := r.applyVipRewardGroup(ctx, tx, vipRewardGrantCommand{ - AppCode: command.AppCode, - CommandID: command.CommandID, - UserID: command.UserID, - Source: command.Source, - Reason: command.Reason, - OperatorUserID: command.OperatorUserID, - }, command.Level, expiresAt, command.NowMS) - if err != nil { - return vipActivationResult{}, err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE user_vip_memberships - SET level = ?, name = ?, status = ?, started_at_ms = ?, expires_at_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND user_id = ?`, - command.Level.Level, command.Level.Name, ledger.VipStatusActive, startedAt, expiresAt, command.NowMS, command.AppCode, command.UserID, - ); err != nil { - return vipActivationResult{}, err - } - action := vipActivationAction(command.Source, previousLevel, command.Level.Level) - if _, err := tx.ExecContext(ctx, ` - INSERT INTO user_vip_history ( - app_code, user_id, action, previous_level, new_level, order_id, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - command.AppCode, command.UserID, action, previousLevel, command.Level.Level, command.RecordID, command.NowMS, - ); err != nil { - return vipActivationResult{}, err - } - return vipActivationResult{ - VIP: ledger.UserVip{ - UserID: command.UserID, - Level: command.Level.Level, - Name: command.Level.Name, - Active: true, - StartedAtMS: startedAt, - ExpiresAtMS: expiresAt, - UpdatedAtMS: command.NowMS, - }, - PreviousLevel: previousLevel, - Action: action, - ResourceGrantID: resourceGrantID, - RewardItems: rewardItems, - }, nil -} - -func vipActivationAction(source string, previousLevel int32, newLevel int32) string { - if source != ledger.VipGrantSourcePurchase { - return source - } - if previousLevel == newLevel { - return "renew" - } - if previousLevel > 0 && previousLevel < newLevel { - return "upgrade" - } - return "purchase" -} - -func (r *Repository) getVipLevelForUpdate(ctx context.Context, tx *sql.Tx, level int32) (ledger.VipLevel, error) { - item, err := r.scanVipLevel(tx.QueryRowContext(ctx, ` - SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, - required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms - FROM vip_levels - WHERE app_code = ? AND level = ? - FOR UPDATE`, - appcode.FromContext(ctx), level, - )) - if errors.Is(err, sql.ErrNoRows) { - return ledger.VipLevel{}, xerr.New(xerr.VIPLevelNotFound, "vip level not found") - } - return item, err -} - -func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLevel int32, totalRechargeCoin int64) ([]ledger.VipLevel, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, - required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms - FROM vip_levels - WHERE app_code = ? AND status = ? - ORDER BY sort_order ASC, level ASC`, - appcode.FromContext(ctx), ledger.VipStatusActive, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - levels := make([]ledger.VipLevel, 0) - for rows.Next() { - level, err := r.scanVipLevel(rows) - if err != nil { - return nil, err - } - level.UserRechargeCoinAmount = totalRechargeCoin - level.RechargeGateRequired = level.Level >= 6 - level.CanPurchase = currentLevel == 0 || level.Level >= currentLevel - if !level.CanPurchase { - level.PurchaseLockedReason = "current_vip_higher" - } - if level.CanPurchase && level.RechargeGateRequired && totalRechargeCoin < level.RequiredRechargeCoinAmount { - level.CanPurchase = false - level.PurchaseLockedReason = "recharge_required" - } - level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, nowMs+level.DurationMS) - if err != nil { - return nil, err - } - levels = append(levels, level) - } - return levels, rows.Err() -} - -func (r *Repository) scanVipLevel(scanner scanTarget) (ledger.VipLevel, error) { - var level ledger.VipLevel - err := scanner.Scan( - &level.Level, - &level.Name, - &level.Status, - &level.PriceCoin, - &level.DurationMS, - &level.RewardResourceGroupID, - &level.RequiredRechargeCoinAmount, - &level.SortOrder, - &level.CreatedAtMS, - &level.UpdatedAtMS, - ) - level.RechargeGateRequired = level.Level >= 6 - return level, err -} - -func (r *Repository) userRechargeCoinAmount(ctx context.Context, querier interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -}, userID int64) (int64, error) { - var total int64 - err := querier.QueryRowContext(ctx, ` - SELECT total_coin_amount - FROM wallet_user_recharge_stats - WHERE app_code = ? AND user_id = ?`, - appcode.FromContext(ctx), userID, - ).Scan(&total) - if errors.Is(err, sql.ErrNoRows) { - return 0, nil - } - return total, err -} - -func normalizeAdminVipLevelCommands(levels []ledger.AdminVipLevelCommand) ([]ledger.AdminVipLevelCommand, error) { - if len(levels) != 10 { - return nil, xerr.New(xerr.InvalidArgument, "vip config requires exactly 10 levels") - } - byLevel := make(map[int32]ledger.AdminVipLevelCommand, 10) - for _, level := range levels { - level.Name = strings.TrimSpace(level.Name) - level.Status = strings.ToLower(strings.TrimSpace(level.Status)) - if level.Status == "" { - level.Status = ledger.VipStatusDisabled - } - if level.Level < 1 || level.Level > 10 { - return nil, xerr.New(xerr.InvalidArgument, "vip level must be between 1 and 10") - } - if _, exists := byLevel[level.Level]; exists { - return nil, xerr.New(xerr.InvalidArgument, "vip level is duplicated") - } - if level.Level <= 5 { - level.RequiredRechargeCoinAmount = 0 - } - if err := validateAdminVipLevelCommand(level); err != nil { - return nil, err - } - byLevel[level.Level] = level - } - normalized := make([]ledger.AdminVipLevelCommand, 0, 10) - for level := int32(1); level <= 10; level++ { - item, ok := byLevel[level] - if !ok { - return nil, xerr.New(xerr.InvalidArgument, "vip config requires levels 1 to 10") - } - normalized = append(normalized, item) - } - return normalized, nil -} - -func validateAdminVipLevelCommand(level ledger.AdminVipLevelCommand) error { - if level.Name == "" { - return xerr.New(xerr.InvalidArgument, "vip level name is required") - } - switch level.Status { - case ledger.VipStatusActive, ledger.VipStatusDisabled: - default: - return xerr.New(xerr.InvalidArgument, "vip level status is invalid") - } - if level.PriceCoin < 0 || level.DurationMS <= 0 || level.RewardResourceGroupID < 0 || level.RequiredRechargeCoinAmount < 0 { - return xerr.New(xerr.InvalidArgument, "vip level config is invalid") - } - if level.Status != ledger.VipStatusActive { - return nil - } - if level.PriceCoin <= 0 { - return xerr.New(xerr.InvalidArgument, "active vip level price_coin must be greater than zero") - } - if level.RewardResourceGroupID <= 0 { - return xerr.New(xerr.InvalidArgument, "active vip level reward_resource_group_id is required") - } - if level.Level >= 6 && level.RequiredRechargeCoinAmount <= 0 { - return xerr.New(xerr.InvalidArgument, "vip level 6-10 requires recharge threshold") - } - return nil -} - -func (r *Repository) requireActiveResourceGroup(ctx context.Context, tx *sql.Tx, groupID int64) error { - group, err := r.getResourceGroup(ctx, tx, groupID, true) - if err != nil { - return err - } - if group.Status != resourcedomain.StatusActive { - return xerr.New(xerr.Conflict, "vip reward group is disabled") - } - return nil -} - -func (r *Repository) ensureUserVipForUpdate(ctx context.Context, tx *sql.Tx, userID int64, nowMs int64) (ledger.UserVip, error) { - if _, err := tx.ExecContext(ctx, ` - INSERT IGNORE INTO user_vip_memberships ( - app_code, user_id, level, name, status, started_at_ms, expires_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, 0, '', 'inactive', 0, 0, ?, ?)`, - appcode.FromContext(ctx), userID, nowMs, nowMs, - ); err != nil { - return ledger.UserVip{}, err - } - return r.queryUserVip(ctx, tx, userID, nowMs, true) -} - -func (r *Repository) queryUserVip(ctx context.Context, querier interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -}, userID int64, nowMs int64, lock bool) (ledger.UserVip, error) { - query := ` - SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms - FROM user_vip_memberships m - LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level - WHERE m.app_code = ? AND m.user_id = ?` - if lock { - query += ` FOR UPDATE` - } - var vip ledger.UserVip - if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&vip.UserID, &vip.Level, &vip.Name, &vip.StartedAtMS, &vip.ExpiresAtMS, &vip.UpdatedAtMS); err != nil { - return ledger.UserVip{}, err - } - vip.Active = vip.Level > 0 && vip.ExpiresAtMS > nowMs - if !vip.Active { - vip.Level = 0 - vip.Name = "" - } - return vip, nil -} - -type vipRewardGrantCommand struct { - AppCode string - CommandID string - UserID int64 - Source string - Reason string - OperatorUserID int64 -} - -func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, command vipRewardGrantCommand, level ledger.VipLevel, expiresAtMS int64, nowMs int64) ([]ledger.VipRewardItem, string, error) { - if level.RewardResourceGroupID <= 0 { - return nil, "", nil - } - group, err := r.getResourceGroup(ctx, tx, level.RewardResourceGroupID, true) - if err != nil { - return nil, "", err - } - if group.Status != resourcedomain.StatusActive { - return nil, "", xerr.New(xerr.Conflict, "vip reward group is disabled") - } - group.Items, err = r.listResourceGroupItemsTx(ctx, tx, level.RewardResourceGroupID, true) - if err != nil { - return nil, "", err - } - groupSnapshot, err := json.Marshal(group) - if err != nil { - return nil, "", err - } - rewardCommandID := "vip_reward:" + command.CommandID - grantID := resourceGrantID(command.AppCode, rewardCommandID) - source := ledger.NormalizeVipGrantSource(command.Source) - if source == "" { - source = ledger.VipGrantSourcePurchase - } - reason := strings.TrimSpace(command.Reason) - if reason == "" { - reason = source - } - operatorUserID := command.OperatorUserID - if operatorUserID <= 0 { - operatorUserID = command.UserID - } - if err := r.insertResourceGrant(ctx, tx, grantID, rewardCommandID, command.UserID, source, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", level.RewardResourceGroupID), stableHash(string(groupSnapshot)), string(groupSnapshot), reason, operatorUserID, nowMs); err != nil { - return nil, "", err - } - durationMS := expiresAtMS - nowMs - rewards := make([]ledger.VipRewardItem, 0, len(group.Items)) - for _, item := range group.Items { - if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset { - inserted, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item, nowMs) - if err != nil { - return nil, "", err - } - rewards = append(rewards, ledger.VipRewardItem{Quantity: inserted.Quantity, ExpiresAtMS: 0}) - continue - } - if err := validateActiveResourceGroupResource(item.Resource); err != nil { - return nil, "", err - } - inserted, err := r.applyGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item.Resource, item.Quantity, durationMS, nowMs) - if err != nil { - return nil, "", err - } - if inserted.EntitlementID != "" { - if err := r.alignEntitlementExpiresAt(ctx, tx, inserted.EntitlementID, expiresAtMS, nowMs); err != nil { - return nil, "", err - } - } - rewards = append(rewards, ledger.VipRewardItem{ - ResourceID: item.Resource.ResourceID, - ResourceCode: item.Resource.ResourceCode, - ResourceType: item.Resource.ResourceType, - Name: item.Resource.Name, - Quantity: inserted.Quantity, - ExpiresAtMS: expiresAtMS, - AssetURL: item.Resource.AssetURL, - PreviewURL: item.Resource.PreviewURL, - AnimationURL: item.Resource.AnimationURL, - }) - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGroupGranted", rewardCommandID, command.UserID, level.RewardResourceGroupID, map[string]any{"grant_id": grantID, "source": source}, nowMs), - }); err != nil { - return nil, "", err - } - return rewards, grantID, nil -} - -func (r *Repository) vipRewardItems(ctx context.Context, groupID int64, expiresAtMS int64) ([]ledger.VipRewardItem, error) { - if groupID <= 0 { - return nil, nil - } - items, err := r.listResourceGroupItemsWithQuery(ctx, r.db, groupID) - if err != nil { - return nil, err - } - rewards := make([]ledger.VipRewardItem, 0, len(items)) - for _, item := range items { - if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource { - continue - } - rewards = append(rewards, ledger.VipRewardItem{ - ResourceID: item.Resource.ResourceID, - ResourceCode: item.Resource.ResourceCode, - ResourceType: item.Resource.ResourceType, - Name: item.Resource.Name, - Quantity: item.Quantity, - ExpiresAtMS: expiresAtMS, - AssetURL: item.Resource.AssetURL, - PreviewURL: item.Resource.PreviewURL, - AnimationURL: item.Resource.AnimationURL, - }) - } - return rewards, nil -} - -func vipActivatedEvent(transactionID string, commandID string, userID int64, source string, action string, previousLevel int32, vip ledger.UserVip, rewardGroupID int64, resourceGrantID string, rewardItems []ledger.VipRewardItem, nowMs int64) walletOutboxEvent { - payload := map[string]any{ - "event_type": "VipActivated", - "transaction_id": transactionID, - "command_id": commandID, - "user_id": userID, - "source": source, - "action": action, - "previous_level": previousLevel, - "level": vip.Level, - "vip_name": vip.Name, - "started_at_ms": vip.StartedAtMS, - "expires_at_ms": vip.ExpiresAtMS, - "reward_resource_group_id": rewardGroupID, - "resource_grant_id": resourceGrantID, - "reward_items": vipRewardItemsNoticePayload(rewardItems), - "created_at_ms": nowMs, - } - return walletOutboxEvent{ - EventID: eventID(transactionID, "VipActivated", userID, vipOutboxAsset), - EventType: "VipActivated", - TransactionID: transactionID, - CommandID: commandID, - UserID: userID, - AssetType: vipOutboxAsset, - AvailableDelta: 0, - FrozenDelta: 0, - Payload: payload, - CreatedAtMS: nowMs, - } -} - -func vipRewardItemsNoticePayload(items []ledger.VipRewardItem) []map[string]any { - payload := make([]map[string]any, 0, len(items)) - for _, item := range items { - payload = append(payload, map[string]any{ - "resource_id": item.ResourceID, - "resource_code": item.ResourceCode, - "resource_type": item.ResourceType, - "name": item.Name, - "quantity": item.Quantity, - "expires_at_ms": item.ExpiresAtMS, - "asset_url": item.AssetURL, - "preview_url": item.PreviewURL, - "animation_url": item.AnimationURL, - }) - } - return payload -} - -func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (ledger.PurchaseVipReceipt, error) { - var orderID string - var level int32 - var priceCoin int64 - var grantID string - err := tx.QueryRowContext(ctx, ` - SELECT order_id, target_level, price_coin, resource_grant_id - FROM vip_purchase_orders - WHERE app_code = ? AND wallet_transaction_id = ?`, - appcode.FromContext(ctx), transactionID, - ).Scan(&orderID, &level, &priceCoin, &grantID) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - vip, err := r.queryUserVip(ctx, tx, userID, time.Now().UnixMilli(), false) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, vip.ExpiresAtMS) - if err != nil { - return ledger.PurchaseVipReceipt{}, err - } - return ledger.PurchaseVipReceipt{ - OrderID: orderID, - TransactionID: transactionID, - Vip: vip, - CoinSpent: priceCoin, - CoinBalanceAfter: balance.AvailableAmount, - RewardItems: rewards, - }, nil -} - -func (r *Repository) vipGrantReceiptForTransaction(ctx context.Context, tx *sql.Tx, txRow transactionRow, userID int64) (ledger.GrantVipReceipt, error) { - var metadata struct { - ResourceGrantID string `json:"resource_grant_id"` - } - _ = json.Unmarshal([]byte(txRow.MetadataJSON), &metadata) - vip, err := r.queryUserVip(ctx, tx, userID, time.Now().UnixMilli(), false) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - rewards, err := r.rewardItemsByGrant(ctx, tx, metadata.ResourceGrantID, vip.ExpiresAtMS) - if err != nil { - return ledger.GrantVipReceipt{}, err - } - return ledger.GrantVipReceipt{ - TransactionID: txRow.TransactionID, - Vip: vip, - RewardItems: rewards, - }, nil -} - -func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID string, expiresAtMS int64) ([]ledger.VipRewardItem, error) { - if grantID == "" { - return nil, nil - } - rows, err := tx.QueryContext(ctx, ` - SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity, - COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, '') - FROM resource_grant_items i - JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id - WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ? - ORDER BY i.grant_item_id ASC`, - appcode.FromContext(ctx), grantID, resourcedomain.ResultEntitlement, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]ledger.VipRewardItem, 0) - for rows.Next() { - var item ledger.VipRewardItem - if err := rows.Scan( - &item.ResourceID, - &item.ResourceCode, - &item.ResourceType, - &item.Name, - &item.Quantity, - &item.AssetURL, - &item.PreviewURL, - &item.AnimationURL, - ); err != nil { - return nil, err - } - item.ExpiresAtMS = expiresAtMS - items = append(items, item) - } - return items, rows.Err() -} - -func vipGrantRequestHash(command ledger.GrantVipCommand) string { - return stableHash(fmt.Sprintf("vip_grant|%s|%d|%d|%s|%d|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Level, - ledger.NormalizeVipGrantSource(command.GrantSource), - command.OperatorUserID, - strings.TrimSpace(command.Reason), - )) -} diff --git a/services/wallet-service/internal/storage/mysql/balance_query.go b/services/wallet-service/internal/storage/mysql/balance_query.go new file mode 100644 index 00000000..e894b775 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/balance_query.go @@ -0,0 +1,83 @@ +package mysql + +import ( + "context" + "sort" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// GetBalances 读取用户多资产余额;指定资产不存在时返回 0 投影,便于客户端稳定渲染。 +func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + assetTypes = normalizeAssetTypes(assetTypes) + appCode := appcode.FromContext(ctx) + + query := `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms + FROM wallet_accounts WHERE app_code = ? AND user_id = ?` + args := []any{appCode, userID} + if len(assetTypes) > 0 { + placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",") + query += " AND asset_type IN (" + placeholders + ")" + for _, assetType := range assetTypes { + args = append(args, assetType) + } + } + query += " ORDER BY asset_type" + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + byAsset := make(map[string]ledger.AssetBalance) + for rows.Next() { + var balance ledger.AssetBalance + if err := rows.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount, &balance.Version, &balance.UpdatedAtMs); err != nil { + return nil, err + } + byAsset[balance.AssetType] = balance + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(assetTypes) == 0 { + balances := make([]ledger.AssetBalance, 0, len(byAsset)) + for _, balance := range byAsset { + balances = append(balances, balance) + } + sort.Slice(balances, func(i, j int) bool { return balances[i].AssetType < balances[j].AssetType }) + return balances, nil + } + + balances := make([]ledger.AssetBalance, 0, len(assetTypes)) + for _, assetType := range assetTypes { + if balance, ok := byAsset[assetType]; ok { + balances = append(balances, balance) + continue + } + balances = append(balances, ledger.AssetBalance{AppCode: appCode, UserID: userID, AssetType: assetType}) + } + return balances, nil +} + +func normalizeAssetTypes(assetTypes []string) []string { + seen := make(map[string]bool, len(assetTypes)) + result := make([]string, 0, len(assetTypes)) + for _, assetType := range assetTypes { + assetType = strings.ToUpper(strings.TrimSpace(assetType)) + if assetType == "" || seen[assetType] { + continue + } + seen[assetType] = true + result = append(result, assetType) + } + return result +} diff --git a/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go new file mode 100644 index 00000000..f8c6bb74 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go @@ -0,0 +1,677 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。 +func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { + if r == nil || r.db == nil { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + bizType := coinSellerStockBizType(command.StockType) + if bizType == "" { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") + } + requestHash := coinSellerStockRequestHash(command) + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { + if err != nil || !exists { + return ledger.CoinSellerStockCreditReceipt{}, err + } + return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID) + } + if command.PaymentRef != "" { + if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated { + if err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") + } + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) + if err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount) + if err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + + transactionID := transactionID(command.AppCode, command.CommandID) + countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase + metadata := coinSellerStockMetadata{ + AppCode: command.AppCode, + SellerUserID: command.SellerUserID, + SellerCountryID: command.SellerCountryID, + SellerRegionID: command.SellerRegionID, + StockType: command.StockType, + CoinAmount: command.CoinAmount, + PaidCurrencyCode: command.PaidCurrencyCode, + PaidAmountMicro: command.PaidAmountMicro, + PaymentRef: command.PaymentRef, + EvidenceRef: command.EvidenceRef, + OperatorUserID: command.OperatorUserID, + Reason: command.Reason, + AssetType: ledger.AssetCoinSellerCoin, + CountsAsSellerRecharge: countsAsSellerRecharge, + BalanceAfter: balanceAfter, + CreatedAtMS: nowMs, + } + externalRef := command.EvidenceRef + if command.PaymentRef != "" { + externalRef = command.PaymentRef + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SellerUserID, + AssetType: ledger.AssetCoinSellerCoin, + AvailableDelta: command.CoinAmount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + + return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil +} + +// TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。 +func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { + if r == nil || r.db == nil { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := coinSellerTransferRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCoinSellerTransfer); err != nil || exists { + if err != nil || !exists { + return ledger.CoinSellerTransferReceipt{}, err + } + return r.receiptForCoinSellerTransferTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, false, nowMs) + if err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if seller.AvailableAmount < command.Amount { + return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + + transactionID := transactionID(command.AppCode, command.CommandID) + + rechargeUSDMinor := int64(0) + rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs) + if err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + sellerAfter := seller.AvailableAmount - command.Amount + targetAfter := target.AvailableAmount + command.Amount + metadata := coinSellerTransferMetadata{ + AppCode: command.AppCode, + SellerUserID: command.SellerUserID, + TargetUserID: command.TargetUserID, + TargetCountryID: command.TargetCountryID, + SellerRegionID: command.SellerRegionID, + TargetRegionID: command.TargetRegionID, + Amount: command.Amount, + Reason: command.Reason, + SellerAssetType: ledger.AssetCoinSellerCoin, + TargetAssetType: ledger.AssetCoin, + SellerBalanceAfter: sellerAfter, + TargetBalanceAfter: targetAfter, + RechargeSequence: rechargeSequence, + RechargeUSDMinor: rechargeUSDMinor, + RechargeCurrencyCode: "", + RechargePolicyID: 0, + RechargePolicyVersion: "", + RechargePolicyCoinAmount: 0, + RechargePolicyUSDMinorAmount: 0, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCoinSellerTransfer, requestHash, fmt.Sprintf("coin_seller:%d:%d", command.SellerUserID, command.TargetUserID), metadata, nowMs); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, seller, -command.Amount, 0, nowMs); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SellerUserID, + AssetType: ledger.AssetCoinSellerCoin, + AvailableDelta: -command.Amount, + FrozenDelta: 0, + AvailableAfter: sellerAfter, + FrozenAfter: seller.FrozenAmount, + CounterpartyUserID: command.TargetUserID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, target, command.Amount, 0, nowMs); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: targetAfter, + FrozenAfter: target.FrozenAmount, + CounterpartyUserID: command.SellerUserID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMs); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), + coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs), + rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs), + }); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + + return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil +} + +// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。 +func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, appCode) + statusClause := "AND status = 'active'" + if includeDisabled { + statusClause = "" + } + query := fmt.Sprintf(` + SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms + FROM coin_seller_salary_exchange_rate_tiers + WHERE app_code = ? AND region_id = ? %s + ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause) + rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID) + if err != nil { + return nil, err + } + defer rows.Close() + + tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0) + for rows.Next() { + var tier ledger.CoinSellerSalaryExchangeRateTier + if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + if err := rows.Err(); err != nil { + return nil, err + } + return tiers, nil +} + +// ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账,保证两边余额和幂等一起落库。 +func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) { + if r == nil || r.db == nil { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := salaryExchangeRequestHash(command) + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists { + if err != nil || !exists { + return ledger.SalaryExchangeReceipt{}, err + } + return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD) + if err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs) + if err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if salaryAccount.AvailableAmount < command.SalaryUSDMinor { + return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + + transactionID := transactionID(command.AppCode, command.CommandID) + salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor + coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount) + if err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + metadata := salaryExchangeMetadata{ + AppCode: command.AppCode, + UserID: command.UserID, + SalaryAssetType: command.SalaryAssetType, + CoinAssetType: ledger.AssetCoin, + SalaryUSDMinor: command.SalaryUSDMinor, + CoinPerUSD: ledger.SalaryExchangeCoinPerUSD, + CoinAmount: coinAmount, + SalaryBalanceAfter: salaryAfter, + CoinBalanceAfter: coinAfter, + Reason: command.Reason, + CreatedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: command.SalaryAssetType, + AvailableDelta: -command.SalaryUSDMinor, + FrozenDelta: 0, + AvailableAfter: salaryAfter, + FrozenAfter: salaryAccount.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: coinAmount, + FrozenDelta: 0, + AvailableAfter: coinAfter, + FrozenAfter: coinAccount.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs), + }); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + + return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil +} + +// TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。 +func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) { + if r == nil || r.db == nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := salaryTransferToCoinSellerRequestHash(command) + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists { + if err != nil || !exists { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if source.AvailableAmount < command.SalaryUSDMinor { + return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + + transactionID := transactionID(command.AppCode, command.CommandID) + sourceAfter := source.AvailableAmount - command.SalaryUSDMinor + sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount) + if err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + metadata := salaryTransferToCoinSellerMetadata{ + AppCode: command.AppCode, + SourceUserID: command.SourceUserID, + SellerUserID: command.SellerUserID, + RegionID: command.RegionID, + SalaryAssetType: command.SalaryAssetType, + SellerAssetType: ledger.AssetCoinSellerCoin, + SalaryUSDMinor: command.SalaryUSDMinor, + CoinPerUSD: rate.CoinPerUSD, + CoinAmount: coinAmount, + RateMinUSDMinor: rate.MinUSDMinor, + RateMaxUSDMinor: rate.MaxUSDMinor, + SourceSalaryBalanceAfter: sourceAfter, + SellerBalanceAfter: sellerAfter, + Reason: command.Reason, + CreatedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SourceUserID, + AssetType: command.SalaryAssetType, + AvailableDelta: -command.SalaryUSDMinor, + FrozenDelta: 0, + AvailableAfter: sourceAfter, + FrozenAfter: source.FrozenAmount, + CounterpartyUserID: command.SellerUserID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SellerUserID, + AssetType: ledger.AssetCoinSellerCoin, + AvailableDelta: coinAmount, + FrozenDelta: 0, + AvailableAfter: sellerAfter, + FrozenAfter: seller.FrozenAmount, + CounterpartyUserID: command.SourceUserID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), + }); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + + return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil +} + +func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO wallet_recharge_records ( + app_code, transaction_id, user_id, recharge_sequence, seller_user_id, seller_region_id, target_region_id, + policy_id, policy_version, currency_code, coin_amount, usd_minor_amount, + exchange_coin_amount, exchange_usd_minor_amount, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalizedEntryAppCode(ctx, metadata.AppCode), + transactionID, + metadata.TargetUserID, + metadata.RechargeSequence, + metadata.SellerUserID, + metadata.SellerRegionID, + metadata.TargetRegionID, + metadata.RechargePolicyID, + metadata.RechargePolicyVersion, + metadata.RechargeCurrencyCode, + metadata.Amount, + metadata.RechargeUSDMinor, + metadata.Amount, + metadata.RechargePolicyUSDMinorAmount, + nowMs, + ) + return err +} + +func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) { + normalizedApp := normalizedEntryAppCode(ctx, appCode) + var count int64 + err := tx.QueryRowContext(ctx, ` + SELECT recharge_count + FROM wallet_user_recharge_stats + WHERE app_code = ? AND user_id = ? + FOR UPDATE`, + normalizedApp, userID, + ).Scan(&count) + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.ExecContext(ctx, ` + INSERT INTO wallet_user_recharge_stats ( + app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms, + total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms + ) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`, + normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs, + ) + if err != nil { + return 0, err + } + return 1, nil + } + if err != nil { + return 0, err + } + next := count + 1 + _, err = tx.ExecContext(ctx, ` + UPDATE wallet_user_recharge_stats + SET recharge_count = ?, + total_coin_amount = total_coin_amount + ?, + total_usd_minor_amount = total_usd_minor_amount + ?, + updated_at_ms = ? + WHERE app_code = ? AND user_id = ?`, + next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID, + ) + if err != nil { + return 0, err + } + return next, nil +} + +func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error { + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return err + } + var paymentRef any + if metadata.PaymentRef != "" { + paymentRef = metadata.PaymentRef + } + _, err = tx.ExecContext(ctx, + `INSERT INTO coin_seller_stock_records ( + app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type, + counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro, + payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalizedEntryAppCode(ctx, metadata.AppCode), + coinSellerStockID(metadata.AppCode, commandID), + transactionID, + commandID, + metadata.SellerUserID, + metadata.StockType, + metadata.CountsAsSellerRecharge, + metadata.CoinAmount, + metadata.PaidCurrencyCode, + metadata.PaidAmountMicro, + paymentRef, + metadata.EvidenceRef, + metadata.OperatorUserID, + metadata.Reason, + metadata.BalanceAfter, + string(metadataJSON), + nowMs, + ) + if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" { + return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") + } + return err +} + +func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) { + var stockID string + err := tx.QueryRowContext(ctx, + `SELECT stock_id + FROM coin_seller_stock_records + WHERE app_code = ? AND stock_type = ? AND payment_ref = ? + LIMIT 1`, + appcode.FromContext(ctx), + stockType, + paymentRef, + ).Scan(&stockID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + return true, nil +} + +func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string { + return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%d|%d|%s|%d|%s|%d|%s|%s|%d|%s", + appcode.Normalize(command.AppCode), + command.SellerUserID, + command.SellerCountryID, + command.SellerRegionID, + command.StockType, + command.CoinAmount, + command.PaidCurrencyCode, + command.PaidAmountMicro, + command.PaymentRef, + command.EvidenceRef, + command.OperatorUserID, + command.Reason, + )) +} + +func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string { + return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s", + appcode.Normalize(command.AppCode), + command.SellerUserID, + command.TargetUserID, + command.SellerRegionID, + command.TargetRegionID, + command.Amount, + strings.TrimSpace(command.Reason), + )) +} + +func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string { + return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s", + appcode.Normalize(command.AppCode), + command.UserID, + strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), + command.SalaryUSDMinor, + strings.TrimSpace(command.Reason), + )) +} + +func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string { + return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s", + appcode.Normalize(command.AppCode), + command.SourceUserID, + command.SellerUserID, + strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), + command.SalaryUSDMinor, + command.RegionID, + strings.TrimSpace(command.Reason), + )) +} + +func coinSellerStockBizType(stockType string) string { + switch stockType { + case ledger.StockTypeUSDTPurchase: + return bizTypeCoinSellerStockPurchase + case ledger.StockTypeCoinCompensation: + return bizTypeCoinSellerCoinCompensation + default: + return "" + } +} diff --git a/services/wallet-service/internal/storage/mysql/constants.go b/services/wallet-service/internal/storage/mysql/constants.go new file mode 100644 index 00000000..36cc9153 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/constants.go @@ -0,0 +1,38 @@ +package mysql + +const ( + transactionStatusSucceeded = "succeeded" + bizTypeGiftDebit = "gift_debit" + bizTypeRobotGiftDebit = "robot_gift_debit" + bizTypeManualCredit = "manual_credit" + bizTypeTaskReward = "task_reward" + bizTypeLuckyGiftReward = "lucky_gift_reward" + bizTypeWheelReward = "wheel_reward" + bizTypeRoomTurnoverReward = "room_turnover_reward" + bizTypeInviteActivityReward = "invite_activity_reward" + bizTypeAgencyOpeningReward = "agency_opening_reward" + bizTypeCoinSellerTransfer = "coin_seller_transfer" + bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase" + bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation" + bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin" + bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller" + bizTypeGooglePlayRecharge = "google_play_recharge" + bizTypeMifaPayRecharge = "mifapay" + bizTypeV5PayRecharge = "v5pay" + bizTypeUSDTTRC20Recharge = "usdt_trc20" + bizTypeGameDebit = "game_debit" + bizTypeGameCredit = "game_credit" + bizTypeGameRefund = "game_refund" + bizTypeGameReverse = "game_reverse" + bizTypeHostSalarySettlement = "host_salary_settlement" + bizTypeCPBreakupFee = "cp_breakup_fee" + bizTypeWheelDrawDebit = "wheel_draw_debit" + outboxStatusPending = "pending" + outboxStatusDelivering = "delivering" + outboxStatusDelivered = "delivered" + outboxStatusRetryable = "retryable" + outboxStatusFailed = "failed" + giftWallChargeAssetMixed = "MIXED" + giftChargeSourceCoin = "coin" + giftChargeSourceBag = "bag" +) diff --git a/services/wallet-service/internal/storage/mysql/entry.go b/services/wallet-service/internal/storage/mysql/entry.go new file mode 100644 index 00000000..756e110c --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/entry.go @@ -0,0 +1,59 @@ +package mysql + +import ( + "context" + "database/sql" + + "hyapp/pkg/appcode" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +type walletEntry struct { + AppCode string + TransactionID string + UserID int64 + AssetType string + AvailableDelta int64 + FrozenDelta int64 + AvailableAfter int64 + FrozenAfter int64 + CounterpartyUserID int64 + RoomID string + CreatedAtMS int64 +} + +func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEntry) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO wallet_entries ( + app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, + available_after, frozen_after, counterparty_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalizedEntryAppCode(ctx, entry.AppCode), + entry.TransactionID, + entry.UserID, + entry.AssetType, + entry.AvailableDelta, + entry.FrozenDelta, + entry.AvailableAfter, + entry.FrozenAfter, + entry.CounterpartyUserID, + entry.RoomID, + entry.CreatedAtMS, + ) + return err +} + +func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string) (ledger.AssetBalance, error) { + row := tx.QueryRowContext(ctx, + `SELECT app_code, user_id, asset_type, available_after, frozen_after + FROM wallet_entries + WHERE app_code = ? AND transaction_id = ? AND user_id = ? AND asset_type = ? + ORDER BY entry_id DESC LIMIT 1`, + appcode.FromContext(ctx), transactionID, userID, assetType, + ) + var balance ledger.AssetBalance + if err := row.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount); err != nil { + return ledger.AssetBalance{}, err + } + return balance, nil +} diff --git a/services/wallet-service/internal/storage/mysql/external_recharge_order_repository.go b/services/wallet-service/internal/storage/mysql/external_recharge_order_repository.go new file mode 100644 index 00000000..9bf0dbda --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/external_recharge_order_repository.go @@ -0,0 +1,479 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +func (r *Repository) CreateExternalRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string) (ledger.ExternalRechargeOrder, error) { + if r == nil || r.db == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + if existing, found, err := r.lookupExternalRechargeOrderByCommand(ctx, command.AppCode, command.CommandID); err != nil || found { + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + existing.IdempotentReplay = true + return existing, nil + } + order := externalRechargeOrderFromCommand(command, product, method, providerAmountMinor, receiveAddress, time.Now().UnixMilli()) + _, err := r.db.ExecContext(ctx, ` + INSERT INTO external_recharge_orders ( + app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type, + product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id, + country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode, + order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount, + order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor, + order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS, + ) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return order, nil +} + +func (r *Repository) MarkExternalRechargeOrderRedirected(ctx context.Context, appCode string, orderID string, providerOrderID string, payURL string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + ctx = contextWithCommandApp(ctx, appCode) + nowMS := time.Now().UnixMilli() + _, err := r.db.ExecContext(ctx, ` + UPDATE external_recharge_orders + SET provider_order_id = ?, pay_url = ?, provider_payload = ?, status = ?, updated_at_ms = ? + WHERE app_code = ? AND order_id = ? AND status = ?`, + strings.TrimSpace(providerOrderID), strings.TrimSpace(payURL), nullableJSON(payloadJSON), ledger.ExternalRechargeStatusRedirected, + nowMS, appcode.FromContext(ctx), strings.TrimSpace(orderID), ledger.ExternalRechargeStatusPending, + ) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), orderID) +} + +func (r *Repository) AttachExternalRechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + ctx = contextWithCommandApp(ctx, command.AppCode) + txHash := strings.TrimSpace(command.TxHash) + result, err := r.db.ExecContext(ctx, ` + UPDATE external_recharge_orders + SET tx_hash = ?, provider_payload = ?, updated_at_ms = ? + WHERE app_code = ? AND order_id = ? AND target_user_id = ? AND provider_code = ? AND status = ?`, + txHash, nullableJSON(payloadJSON), time.Now().UnixMilli(), appcode.FromContext(ctx), strings.TrimSpace(command.OrderID), + command.TargetUserID, ledger.PaymentProviderUSDTTRC20, ledger.ExternalRechargeStatusPending, + ) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return ledger.ExternalRechargeOrder{}, err + } else if affected == 0 { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") + } + return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), command.OrderID) +} + +func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + if r == nil || r.db == nil { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, appCode) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + defer func() { _ = tx.Rollback() }() + + order, err := r.lockExternalRechargeOrder(ctx, tx, appcode.FromContext(ctx), strings.TrimSpace(orderID)) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if order.Status == ledger.ExternalRechargeStatusCredited { + order.IdempotentReplay = true + return order, nil + } + if order.Status == ledger.ExternalRechargeStatusFailed { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is failed") + } + if strings.TrimSpace(txHash) != "" { + if err := r.ensureExternalTxHashUnused(ctx, tx, order.AppCode, order.ProviderCode, strings.TrimSpace(txHash), order.OrderID); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + order.TxHash = strings.TrimSpace(txHash) + } + if strings.TrimSpace(providerOrderID) != "" { + order.ProviderOrderID = strings.TrimSpace(providerOrderID) + } + + nowMS := time.Now().UnixMilli() + assetType := ledger.AssetCoin + rechargeType := order.ProviderCode + bizType := providerRechargeBizType(order.ProviderCode) + isCoinSellerRecharge := order.AudienceType == ledger.RechargeAudienceCoinSeller + if isCoinSellerRecharge { + assetType = ledger.AssetCoinSellerCoin + rechargeType = "coin_seller_recharge" + bizType = "coin_seller_recharge" + } + account, err := r.lockAccount(ctx, tx, order.TargetUserID, assetType, true, nowMS) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + transactionID := transactionID(order.AppCode, order.CommandID) + if _, exists, err := r.lookupTransaction(ctx, tx, order.CommandID, externalRechargeRequestHash(order, assetType), bizType); err != nil || exists { + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return r.lockExternalRechargeOrder(ctx, tx, order.AppCode, order.OrderID) + } + balanceAfter, err := checkedAdd(account.AvailableAmount, order.CoinAmount) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + metadata := coinSellerTransferMetadata{ + AppCode: order.AppCode, + PaymentOrderID: order.OrderID, + ProviderCode: order.ProviderCode, + PaymentMethodID: order.PaymentMethodID, + TargetUserID: order.TargetUserID, + TargetRegionID: order.TargetRegionID, + Amount: order.CoinAmount, + TargetAssetType: assetType, + TargetBalanceAfter: balanceAfter, + RechargeUSDMinor: order.USDMinorAmount, + RechargeCurrencyCode: order.CurrencyCode, + RechargePolicyID: order.ProductID, + RechargePolicyVersion: order.ProductCode, + RechargePolicyCoinAmount: order.CoinAmount, + RechargePolicyUSDMinorAmount: order.USDMinorAmount, + RechargeType: rechargeType, + } + var transactionMetadata any = metadata + var stockMetadata coinSellerStockMetadata + if isCoinSellerRecharge { + + paidAmountMicro, err := checkedMul(order.USDMinorAmount, 10_000) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + stockMetadata = coinSellerStockMetadata{ + AppCode: order.AppCode, + PaymentOrderID: order.OrderID, + ProviderCode: order.ProviderCode, + PaymentMethodID: order.PaymentMethodID, + SellerUserID: order.TargetUserID, + SellerRegionID: order.TargetRegionID, + StockType: ledger.StockTypeUSDTPurchase, + CoinAmount: order.CoinAmount, + PaidCurrencyCode: ledger.PaidCurrencyUSDT, + PaidAmountMicro: paidAmountMicro, + PaymentRef: externalRechargePaymentRef(order), + EvidenceRef: order.OrderID, + Reason: "h5 external recharge", + AssetType: ledger.AssetCoinSellerCoin, + CountsAsSellerRecharge: true, + BalanceAfter: balanceAfter, + CreatedAtMS: nowMS, + } + transactionMetadata = stockMetadata + } else { + rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, order.AppCode, order.TargetUserID, transactionID, order.CoinAmount, order.USDMinorAmount, nowMS) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + metadata.RechargeSequence = rechargeSequence + } + if err := r.insertTransaction(ctx, tx, transactionID, order.CommandID, bizType, externalRechargeRequestHash(order, assetType), order.OrderID, transactionMetadata, nowMS); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, order.CoinAmount, 0, nowMS); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: order.TargetUserID, + AssetType: assetType, + AvailableDelta: order.CoinAmount, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMS, + }); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + + outboxEvents := []walletOutboxEvent{ + balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, transactionMetadata, nowMS), + } + if isCoinSellerRecharge { + if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, order.CommandID, stockMetadata, nowMS); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + outboxEvents = append(outboxEvents, coinSellerStockCreditedEvent(transactionID, order.CommandID, stockMetadata, nowMS)) + } else { + if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMS); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + outboxEvents = append(outboxEvents, rechargeRecordedEvent(transactionID, order.CommandID, order.TargetUserID, order.CoinAmount, metadata, nowMS)) + } + if _, err := tx.ExecContext(ctx, ` + UPDATE external_recharge_orders + SET status = ?, provider_order_id = ?, tx_hash = ?, wallet_transaction_id = ?, provider_payload = ?, updated_at_ms = ? + WHERE app_code = ? AND order_id = ?`, + ledger.ExternalRechargeStatusCredited, order.ProviderOrderID, order.TxHash, transactionID, nullableJSON(payloadJSON), + nowMS, order.AppCode, order.OrderID, + ); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + if err := tx.Commit(); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return r.GetExternalRechargeOrder(ctx, order.AppCode, order.OrderID) +} + +func (r *Repository) MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + ctx = contextWithCommandApp(ctx, appCode) + _, err := r.db.ExecContext(ctx, ` + UPDATE external_recharge_orders + SET status = ?, failure_reason = ?, provider_payload = ?, updated_at_ms = ? + WHERE app_code = ? AND order_id = ? AND status <> ?`, + ledger.ExternalRechargeStatusFailed, trimMax(reason, 512), nullableJSON(payloadJSON), time.Now().UnixMilli(), + appcode.FromContext(ctx), strings.TrimSpace(orderID), ledger.ExternalRechargeStatusCredited, + ) + if err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), orderID) +} + +func (r *Repository) GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) { + ctx = contextWithCommandApp(ctx, appCode) + order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` + WHERE app_code = ? AND order_id = ?`, + appcode.FromContext(ctx), strings.TrimSpace(orderID), + )) + if errors.Is(err, sql.ErrNoRows) { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") + } + return order, err +} + +func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, appCode) + if limit <= 0 { + return nil, nil + } + if limit > 500 { + limit = 500 + } + rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+` + WHERE app_code = ? + AND provider_code IN (?, ?) + AND status = ? + ORDER BY updated_at_ms ASC, created_at_ms ASC + LIMIT ?`, + appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, + ledger.ExternalRechargeStatusRedirected, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + orders := make([]ledger.ExternalRechargeOrder, 0, limit) + for rows.Next() { + order, err := scanExternalRechargeOrder(rows) + if err != nil { + return nil, err + } + orders = append(orders, order) + } + return orders, rows.Err() +} + +func (r *Repository) lookupExternalRechargeOrderByCommand(ctx context.Context, appCode string, commandID string) (ledger.ExternalRechargeOrder, bool, error) { + order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` + WHERE app_code = ? AND command_id = ?`, + appCode, strings.TrimSpace(commandID), + )) + if errors.Is(err, sql.ErrNoRows) { + return ledger.ExternalRechargeOrder{}, false, nil + } + return order, err == nil, err +} + +func (r *Repository) lockExternalRechargeOrder(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) { + order, err := scanExternalRechargeOrder(tx.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` + WHERE app_code = ? AND order_id = ? + FOR UPDATE`, + appCode, strings.TrimSpace(orderID), + )) + if errors.Is(err, sql.ErrNoRows) { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") + } + return order, err +} + +func externalRechargeOrderSelectSQL() string { + return ` + SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type, + product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id, + country_code, currency_code, provider_amount_minor, pay_way, pay_type, COALESCE(pay_url, ''), + provider_order_id, tx_hash, receive_address, status, failure_reason, wallet_transaction_id, + COALESCE(CAST(provider_payload AS CHAR), ''), created_at_ms, updated_at_ms + FROM external_recharge_orders + ` +} + +func scanExternalRechargeOrder(scanner scanTarget) (ledger.ExternalRechargeOrder, error) { + var order ledger.ExternalRechargeOrder + if err := scanner.Scan( + &order.AppCode, + &order.OrderID, + &order.CommandID, + &order.TargetUserID, + &order.TargetRegionID, + &order.TargetCountryCode, + &order.AudienceType, + &order.ProductID, + &order.ProductCode, + &order.ProductName, + &order.CoinAmount, + &order.USDMinorAmount, + &order.ProviderCode, + &order.PaymentMethodID, + &order.CountryCode, + &order.CurrencyCode, + &order.ProviderAmountMinor, + &order.PayWay, + &order.PayType, + &order.PayURL, + &order.ProviderOrderID, + &order.TxHash, + &order.ReceiveAddress, + &order.Status, + &order.FailureReason, + &order.TransactionID, + &order.ProviderPayloadJSON, + &order.CreatedAtMS, + &order.UpdatedAtMS, + ); err != nil { + return ledger.ExternalRechargeOrder{}, err + } + return order, nil +} + +func externalRechargePaymentRef(order ledger.ExternalRechargeOrder) string { + for _, value := range []string{order.ProviderOrderID, order.TxHash, order.OrderID} { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string, nowMS int64) ledger.ExternalRechargeOrder { + order := ledger.ExternalRechargeOrder{ + OrderID: externalRechargeOrderID(command.AppCode, command.CommandID), + AppCode: command.AppCode, + CommandID: strings.TrimSpace(command.CommandID), + TargetUserID: command.TargetUserID, + TargetRegionID: command.TargetRegionID, + TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)), + AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType), + ProductID: product.ProductID, + ProductCode: product.ProductCode, + ProductName: product.ProductName, + CoinAmount: product.CoinAmount, + USDMinorAmount: googleAmountMicroToUSDMinor(product.AmountMicro), + ProviderCode: ledger.NormalizePaymentProvider(command.ProviderCode), + ProviderAmountMinor: providerAmountMinor, + ReceiveAddress: strings.TrimSpace(receiveAddress), + Status: ledger.ExternalRechargeStatusPending, + CreatedAtMS: nowMS, + UpdatedAtMS: nowMS, + } + if method != nil { + order.PaymentMethodID = method.MethodID + order.CountryCode = method.CountryCode + order.CurrencyCode = method.CurrencyCode + order.PayWay = method.PayWay + order.PayType = method.PayType + } + if order.ProviderCode == ledger.PaymentProviderUSDTTRC20 { + order.CountryCode = "GLOBAL" + order.CurrencyCode = ledger.PaidCurrencyUSDT + } + return order +} + +func externalRechargeOrderID(appCode string, commandID string) string { + + return stableHash(appcode.Normalize(appCode) + "|" + strings.TrimSpace(commandID))[:32] +} + +func (r *Repository) ensureExternalTxHashUnused(ctx context.Context, tx *sql.Tx, appCode string, providerCode string, txHash string, orderID string) error { + var existing string + err := tx.QueryRowContext(ctx, ` + SELECT order_id + FROM external_recharge_orders + WHERE app_code = ? AND provider_code = ? AND tx_hash = ? AND order_id <> ? AND status = ? + LIMIT 1`, + appCode, providerCode, txHash, orderID, ledger.ExternalRechargeStatusCredited, + ).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + return xerr.New(xerr.Conflict, "tx_hash already credited") +} + +func providerRechargeBizType(providerCode string) string { + switch ledger.NormalizePaymentProvider(providerCode) { + case ledger.PaymentProviderMifaPay: + return bizTypeMifaPayRecharge + case ledger.PaymentProviderV5Pay: + return bizTypeV5PayRecharge + case ledger.PaymentProviderUSDTTRC20: + return bizTypeUSDTTRC20Recharge + default: + return strings.ToLower(strings.TrimSpace(providerCode)) + } +} + +func externalRechargeRequestHash(order ledger.ExternalRechargeOrder, assetType string) string { + return stableHash(fmt.Sprintf("external_recharge|%s|%s|%d|%d|%d|%s|%s|%s", + order.AppCode, order.OrderID, order.TargetUserID, order.ProductID, order.CoinAmount, + order.ProviderCode, order.ProviderOrderID, assetType, + )) +} + +func nullableJSON(payload string) any { + payload = strings.TrimSpace(payload) + if payload == "" { + return nil + } + return payload +} + +func trimMax(value string, max int) string { + value = strings.TrimSpace(value) + if len([]rune(value)) <= max { + return value + } + return string([]rune(value)[:max]) +} diff --git a/services/wallet-service/internal/storage/mysql/game_ledger.go b/services/wallet-service/internal/storage/mysql/game_ledger.go new file mode 100644 index 00000000..d2f56ac5 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/game_ledger.go @@ -0,0 +1,112 @@ +package mysql + +import ( + "context" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。 +func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) { + if r == nil || r.db == nil { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount) + if err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { + if err != nil || !exists { + return ledger.GameCoinChangeReceipt{}, err + } + receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID) + receipt.IdempotentReplay = true + return receipt, receiptErr + } + + nowMs := time.Now().UnixMilli() + createIfMissing := delta > 0 + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs) + if err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + if delta < 0 && account.AvailableAmount < -delta { + return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + delta + metadata := gameCoinMetadata{ + AppCode: command.AppCode, + UserID: command.UserID, + PlatformCode: command.PlatformCode, + GameID: command.GameID, + ProviderOrderID: command.ProviderOrderID, + ProviderRoundID: command.ProviderRoundID, + OpType: command.OpType, + AssetType: ledger.AssetCoin, + CoinAmount: command.CoinAmount, + AvailableDelta: delta, + RoomID: command.RoomID, + BalanceAfter: balanceAfter, + AppliedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: delta, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + }); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + + return receiptFromGameMetadata(transactionID, metadata, false), nil +} + +func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) { + switch ledger.NormalizeGameOpType(opType) { + case ledger.GameOpDebit: + return bizTypeGameDebit, -coinAmount, nil + case ledger.GameOpCredit: + return bizTypeGameCredit, coinAmount, nil + case ledger.GameOpRefund: + return bizTypeGameRefund, coinAmount, nil + case ledger.GameOpReverse: + + return bizTypeGameReverse, -coinAmount, nil + default: + return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid") + } +} diff --git a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go new file mode 100644 index 00000000..261c8002 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go @@ -0,0 +1,395 @@ +package mysql + +import ( + "context" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +// BatchDebitGift 在一个 MySQL 事务内完成多目标送礼;任一目标失败时整批回滚,避免房间命令和账务出现部分成功。 +func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) { + if r == nil || r.db == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + defer func() { + + _ = tx.Rollback() + }() + + nowMs := time.Now().UnixMilli() + if command.RegionID < 0 { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") + } + if command.SenderRegionID < 0 { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid") + } + giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) + chargeAssetType := price.ChargeAssetType + if chargeSource == giftChargeSourceBag { + chargeAssetType = ledger.AssetBagGift + } + chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount)) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + + roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + + heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + giftIncomeCoinAmount, err := giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets))) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + + existing := make(map[string]ledger.BatchGiftTargetReceipt, len(command.Targets)) + for _, target := range command.Targets { + targetCommand := debitGiftCommandFromBatchTarget(command, target) + requestHash := debitRequestHash(targetCommand) + txRow, exists, err := r.lookupTransaction(ctx, tx, target.CommandID, requestHash, bizTypeGiftDebit) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + if !exists { + continue + } + receipt, err := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + existing[target.CommandID] = ledger.BatchGiftTargetReceipt{ + TargetUserID: target.TargetUserID, + CommandID: target.CommandID, + Receipt: receipt, + } + } + if len(existing) > 0 { + if len(existing) != len(command.Targets) { + + return ledger.BatchGiftReceipt{}, xerr.New(xerr.IdempotencyConflict, "wallet batch command idempotency conflict") + } + targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) + for _, target := range command.Targets { + targetReceipts = append(targetReceipts, existing[target.CommandID]) + } + aggregate, err := aggregateGiftReceipts(targetReceipts) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil + } + + accountAssetType := chargeAssetType + if chargeSource == giftChargeSourceBag { + accountAssetType = ledger.AssetCoin + } + lockRequests := []walletAccountLockRequest{{ + UserID: command.SenderUserID, + AssetType: accountAssetType, + CreateIfMissing: totalChargeAmount == 0 || chargeSource == giftChargeSourceBag, + }} + if giftIncomeCoinAmount > 0 { + for _, target := range command.Targets { + lockRequests = append(lockRequests, walletAccountLockRequest{ + UserID: target.TargetUserID, + AssetType: ledger.AssetCoin, + CreateIfMissing: true, + }) + } + } + accountStates, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + senderState := accountStates[walletAccountLockKey(command.SenderUserID, accountAssetType)] + sender := senderState.account + if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + events := make([]walletOutboxEvent, 0, len(command.Targets)*5+1) + if chargeSource == giftChargeSourceBag { + resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), giftConfig.ResourceID, int64(command.GiftCount)*int64(len(command.Targets)), giftMetadata{ + AppCode: command.AppCode, + GiftID: command.GiftID, + ResourceID: giftConfig.ResourceID, + GiftCount: command.GiftCount, + ChargeAssetType: chargeAssetType, + ChargeSource: chargeSource, + EntitlementID: strings.TrimSpace(command.EntitlementID), + CoinSpent: totalChargeAmount, + SenderUserID: command.SenderUserID, + RoomID: command.RoomID, + }, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + events = append(events, resourceEvent) + } + + anyHostTarget := false + for _, target := range command.Targets { + if target.TargetIsHost { + anyHostTarget = true + break + } + } + hostRatio := giftDiamondRatioSnapshot{Percent: "0.00", PPM: 0} + hostRatioRegionID := int64(0) + if anyHostTarget { + hostRatio, hostRatioRegionID, err = r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + + targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) + for _, target := range command.Targets { + hostPeriodDiamondAdded := int64(0) + hostPeriodCycleKey := "" + giftDiamondRatioPercent := "0.00" + giftDiamondRatioRegionID := int64(0) + if target.TargetIsHost { + + hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, hostRatio.PPM) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + giftDiamondRatioPercent = hostRatio.Percent + giftDiamondRatioRegionID = hostRatioRegionID + hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + } + + transactionID := transactionID(command.AppCode, target.CommandID) + senderAfter := senderState.account.AvailableAmount - chargeAmount + if chargeSource == giftChargeSourceBag { + + senderAfter = senderState.account.AvailableAmount + } + giftIncomeBalanceAfter := int64(0) + targetCoinState := accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetCoin)] + if giftIncomeCoinAmount > 0 { + if targetCoinState == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "gift income account is missing") + } + giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount + if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { + + giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount + senderAfter = giftIncomeBalanceAfter + } + } + metadata := giftMetadata{ + AppCode: command.AppCode, + GiftID: command.GiftID, + GiftName: giftConfig.Name, + GiftIconURL: giftDisplayIconURL(giftConfig.Resource), + GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), + + GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + ChargeSource: chargeSource, + EntitlementID: strings.TrimSpace(command.EntitlementID), + GiftPointAdded: 0, + HeatValue: heatValue, + BalanceAfter: senderAfter, + GiftIncomeCoinAmount: giftIncomeCoinAmount, + GiftIncomeRatioPercent: giftIncomeRatio.Percent, + GiftIncomeRatioRegionID: giftIncomeRatioRegionID, + GiftIncomeBalanceAfter: giftIncomeBalanceAfter, + BillingReceipt: billingReceiptID(command.AppCode, target.CommandID), + SenderUserID: command.SenderUserID, + SenderRegionID: command.SenderRegionID, + TargetUserID: target.TargetUserID, + TargetIsHost: target.TargetIsHost, + TargetHostRegionID: target.TargetHostRegionID, + TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioRegionID: giftDiamondRatioRegionID, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + RoomRegionID: command.RegionID, + RoomContributionRatioPercent: roomContributionRatio.Percent, + RoomContributionRatioRegionID: roomContributionRatioRegionID, + CoinPrice: price.CoinPrice, + GiftPointAmount: 0, + HeatUnitValue: price.CoinPrice, + } + if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { + return ledger.BatchGiftReceipt{}, err + } + if chargeSource != giftChargeSourceBag { + debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SenderUserID, + AssetType: price.ChargeAssetType, + AvailableDelta: -chargeAmount, + FrozenDelta: 0, + AvailableAfter: debitAccount.AvailableAmount, + FrozenAfter: debitAccount.FrozenAmount, + CounterpartyUserID: target.TargetUserID, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + if giftIncomeCoinAmount > 0 { + incomeState := targetCoinState + if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { + + incomeState = senderState + } + incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount + if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { + metadata.BalanceAfter = incomeAccount.AvailableAmount + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: target.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: giftIncomeCoinAmount, + FrozenDelta: 0, + AvailableAfter: incomeAccount.AvailableAmount, + FrozenAfter: incomeAccount.FrozenAmount, + CounterpartyUserID: command.SenderUserID, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + if hostPeriodDiamondAdded > 0 { + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + } + + if chargeSource != giftChargeSourceBag { + if target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) + } else { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) + } + } + if giftIncomeCoinAmount > 0 && !(target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs)) + } + events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs)) + if giftIncomeCoinAmount > 0 { + events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) + } + if hostPeriodDiamondAdded > 0 { + events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) + } + targetReceipts = append(targetReceipts, ledger.BatchGiftTargetReceipt{ + TargetUserID: target.TargetUserID, + CommandID: target.CommandID, + Receipt: receiptFromGiftMetadata(transactionID, metadata), + }) + } + + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { + return ledger.BatchGiftReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.BatchGiftReceipt{}, err + } + + aggregate, err := aggregateGiftReceipts(targetReceipts) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil +} + +func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, target ledger.DebitGiftTargetCommand) ledger.DebitGiftCommand { + + return ledger.DebitGiftCommand{ + AppCode: command.AppCode, + CommandID: target.CommandID, + RoomID: command.RoomID, + SenderUserID: command.SenderUserID, + TargetUserID: target.TargetUserID, + GiftID: command.GiftID, + GiftCount: command.GiftCount, + PriceVersion: command.PriceVersion, + RegionID: command.RegionID, + SenderRegionID: command.SenderRegionID, + TargetIsHost: target.TargetIsHost, + TargetHostRegionID: target.TargetHostRegionID, + TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, + EntitlementID: command.EntitlementID, + ChargeSource: command.ChargeSource, + } +} + +func debitGiftCommandFromBatchCommand(command ledger.BatchDebitGiftCommand) ledger.DebitGiftCommand { + + return ledger.DebitGiftCommand{ + AppCode: command.AppCode, + CommandID: command.CommandID, + RoomID: command.RoomID, + SenderUserID: command.SenderUserID, + GiftID: command.GiftID, + GiftCount: command.GiftCount, + PriceVersion: command.PriceVersion, + RegionID: command.RegionID, + SenderRegionID: command.SenderRegionID, + EntitlementID: command.EntitlementID, + ChargeSource: command.ChargeSource, + } +} diff --git a/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go b/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go new file mode 100644 index 00000000..dd9031ff --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go @@ -0,0 +1,274 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeGiftConfigListQuery(query) + where, args := giftConfigWhereSQL(query) + var total int64 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM gift_configs gc + JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id + `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+` + ORDER BY gc.sort_order ASC, gc.created_at_ms DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + nowMs := time.Now().UnixMilli() + items := make([]resourcedomain.GiftConfig, 0, query.PageSize) + for rows.Next() { + item, err := scanGiftConfig(rows) + if err != nil { + return nil, 0, err + } + item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID) + if err != nil { + return nil, 0, err + } + price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs) + if priceErr == nil { + item.PriceVersion = price.PriceVersion + item.ChargeAssetType = price.ChargeAssetType + item.CoinPrice = price.CoinPrice + item.GiftPointAmount = 0 + item.HeatValue = price.CoinPrice + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) { + row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID) + gift, err := scanGiftConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") + } + if err != nil { + return resourcedomain.GiftConfig{}, err + } + price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli()) + if priceErr == nil { + gift.PriceVersion = price.PriceVersion + gift.ChargeAssetType = price.ChargeAssetType + gift.CoinPrice = price.CoinPrice + + gift.GiftPointAmount = 0 + + gift.HeatValue = price.CoinPrice + } + gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + return gift, nil +} + +func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, giftID string) (resourcedomain.GiftConfig, error) { + row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ? FOR UPDATE`, appcode.FromContext(ctx), giftID) + gift, err := scanGiftConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") + } + if err != nil { + return resourcedomain.GiftConfig{}, err + } + + price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli()) + if priceErr == nil { + gift.PriceVersion = price.PriceVersion + gift.ChargeAssetType = price.ChargeAssetType + gift.CoinPrice = price.CoinPrice + gift.GiftPointAmount = 0 + gift.HeatValue = price.CoinPrice + } + gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + return gift, nil +} + +func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { + return err + } + for _, regionID := range regionIDs { + if regionID < 0 { + return xerr.New(xerr.InvalidArgument, "gift region is invalid") + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO gift_config_regions ( + app_code, gift_id, region_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs, + ); err != nil { + return err + } + } + return nil +} + +func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) { + rows, err := querier.QueryContext(ctx, ` + SELECT region_id + FROM gift_config_regions + WHERE app_code = ? AND gift_id = ? + ORDER BY region_id ASC`, + appcode.FromContext(ctx), giftID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + regionIDs := make([]int64, 0) + for rows.Next() { + var regionID int64 + if err := rows.Scan(®ionID); err != nil { + return nil, err + } + regionIDs = append(regionIDs, regionID) + } + return regionIDs, rows.Err() +} + +func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) { + var gift resourcedomain.GiftConfig + var resource resourcedomain.Resource + var scopesJSON string + var effectTypesJSON string + if err := scanner.Scan( + &gift.AppCode, + &gift.GiftID, + &gift.ResourceID, + &gift.Status, + &gift.Name, + &gift.SortOrder, + &gift.PresentationJSON, + &gift.GiftTypeCode, + &gift.EffectiveFromMS, + &gift.EffectiveToMS, + &effectTypesJSON, + &gift.CreatedByUserID, + &gift.UpdatedByUserID, + &gift.CreatedAtMS, + &gift.UpdatedAtMS, + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + ); err != nil { + return resourcedomain.GiftConfig{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + gift.Resource = resource + gift.EffectTypes = parseStringArray(effectTypesJSON) + + gift.CPRelationType = cpRelationTypeFromPresentationJSON(gift.PresentationJSON) + return gift, nil +} + +func giftConfigSelectSQL() string { + return ` + SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order, + COALESCE(CAST(gc.presentation_json AS CHAR), '{}'), + COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0), + COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'), + gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms, + ` + resourceColumnsWithAlias("r") + ` + FROM gift_configs gc + JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id + ` +} + +func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) { + where := `WHERE gc.app_code = ?` + args := []any{query.AppCode} + if query.ActiveOnly { + where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' + AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) + AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)` + nowMs := time.Now().UnixMilli() + args = append(args, nowMs, nowMs) + } else if query.Status != "" { + where += ` AND gc.status = ?` + args = append(args, query.Status) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)` + args = append(args, like, like, like) + } + if query.GiftTypeCode != "" { + where += ` AND gc.gift_type_code = ?` + args = append(args, query.GiftTypeCode) + } + if query.FilterRegion { + where += ` AND EXISTS ( + SELECT 1 + FROM gift_config_regions gcr + WHERE gcr.app_code = gc.app_code + AND gcr.gift_id = gc.gift_id + AND (gcr.region_id = 0 OR gcr.region_id = ?) + )` + args = append(args, query.RegionID) + } + return where, args +} + +func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery { + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Keyword = strings.TrimSpace(query.Keyword) + + if strings.TrimSpace(query.GiftTypeCode) != "" { + query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode) + } + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} diff --git a/services/wallet-service/internal/storage/mysql/gift_config_repository.go b/services/wallet-service/internal/storage/mysql/gift_config_repository.go new file mode 100644 index 00000000..5a255fd4 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_config_repository.go @@ -0,0 +1,201 @@ +package mysql + +import ( + "context" + "encoding/json" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +func (r *Repository) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { + return r.upsertGiftConfig(ctx, command, false) +} + +func (r *Repository) UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { + return r.upsertGiftConfig(ctx, command, true) +} + +func (r *Repository) SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) { + if r == nil || r.db == nil { + return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if strings.TrimSpace(command.StringID) == "" { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + status := resourcedomain.NormalizeStatus(command.Status) + if !resourcedomain.ValidStatus(status) { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "status is invalid") + } + nowMs := time.Now().UnixMilli() + result, err := r.db.ExecContext(ctx, + `UPDATE gift_configs SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`, + status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), strings.TrimSpace(command.StringID), + ) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.GiftConfig{}, err + } else if affected == 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") + } + gift, err := r.getGiftConfig(ctx, strings.TrimSpace(command.StringID)) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + _ = r.insertResourceOutboxNoTx(ctx, "GiftConfigChanged", fmt.Sprintf("gift:%s:status:%s:%d", gift.GiftID, status, nowMs), 0, gift.ResourceID, gift) + return gift, nil +} + +func (r *Repository) DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) { + if r == nil || r.db == nil { + return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if strings.TrimSpace(command.StringID) == "" { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + giftID := strings.TrimSpace(command.StringID) + nowMs := time.Now().UnixMilli() + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + defer func() { _ = tx.Rollback() }() + + gift, err := r.getGiftConfigForUpdateTx(ctx, tx, giftID) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { + return resourcedomain.GiftConfig{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { + return resourcedomain.GiftConfig{}, err + } + result, err := tx.ExecContext(ctx, `DELETE FROM gift_configs WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.GiftConfig{}, err + } else if affected == 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") + } + + deletedGift := gift + deletedGift.Status = "deleted" + + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:delete:%d", giftID, nowMs), 0, gift.ResourceID, deletedGift, nowMs), + }); err != nil { + return resourcedomain.GiftConfig{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.GiftConfig{}, err + } + return gift, nil +} + +func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand, update bool) (resourcedomain.GiftConfig, error) { + if r == nil || r.db == nil { + return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeGiftConfigCommand(command) + if command.ResourceID <= 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config command is incomplete") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + defer func() { _ = tx.Rollback() }() + + resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if resource.ResourceType != resourcedomain.TypeGift { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource") + } + command = applyResourcePricingToGiftCommand(command, resource) + if resourcedomain.NormalizePriceType(resource.PriceType) == "" && command.CoinPrice <= 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift price is invalid") + } + if err := validateGiftConfigCommand(command); err != nil { + return resourcedomain.GiftConfig{}, err + } + if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive { + return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled") + } + if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { + return resourcedomain.GiftConfig{}, err + } + if ok, err := r.giftTypeConfigExistsTx(ctx, tx, command.GiftTypeCode); err != nil { + return resourcedomain.GiftConfig{}, err + } else if !ok { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift type is not configured") + } + nowMs := time.Now().UnixMilli() + + presentation := giftPresentationWithCPRelationType(command.PresentationJSON, command.CPRelationType) + effectTypesJSON, err := json.Marshal(command.EffectTypes) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if !update { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO gift_configs ( + app_code, gift_id, resource_id, status, name, sort_order, presentation_json, + gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, + command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), + command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, + ); err != nil { + return resourcedomain.GiftConfig{}, err + } + } else { + result, err := tx.ExecContext(ctx, ` + UPDATE gift_configs + SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?, + gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?, + updated_by_user_id = ?, updated_at_ms = ? + WHERE app_code = ? AND gift_id = ?`, + command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, + command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), + command.OperatorUserID, nowMs, command.AppCode, command.GiftID, + ) + if err != nil { + return resourcedomain.GiftConfig{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.GiftConfig{}, err + } else if affected == 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") + } + } + if err := r.upsertGiftPriceTx(ctx, tx, command, nowMs); err != nil { + return resourcedomain.GiftConfig{}, err + } + if err := r.replaceGiftConfigRegionsTx(ctx, tx, command.GiftID, command.RegionIDs, nowMs); err != nil { + return resourcedomain.GiftConfig{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:config:%d", command.GiftID, nowMs), 0, command.ResourceID, command, nowMs), + }); err != nil { + return resourcedomain.GiftConfig{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.GiftConfig{}, err + } + return r.getGiftConfig(ctx, command.GiftID) +} diff --git a/services/wallet-service/internal/storage/mysql/gift_config_validation.go b/services/wallet-service/internal/storage/mysql/gift_config_validation.go new file mode 100644 index 00000000..dffdbe2d --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_config_validation.go @@ -0,0 +1,137 @@ +package mysql + +import ( + "encoding/json" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "sort" + "strings" +) + +func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand { + command.GiftID = strings.TrimSpace(command.GiftID) + command.Status = resourcedomain.NormalizeStatus(command.Status) + command.Name = strings.TrimSpace(command.Name) + command.PresentationJSON = normalizeJSONObject(command.PresentationJSON) + command.CPRelationType = normalizeCPRelationType(command.CPRelationType) + command.PriceVersion = strings.TrimSpace(command.PriceVersion) + command.RegionIDs = normalizeRegionIDs(command.RegionIDs) + command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode) + command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType)) + if command.ChargeAssetType == "" { + command.ChargeAssetType = ledger.AssetCoin + } + command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes) + + command.GiftPointAmount = 0 + if command.EffectiveAtMS < 0 { + command.EffectiveAtMS = 0 + } + if command.EffectiveFromMS < 0 { + command.EffectiveFromMS = 0 + } + if command.EffectiveToMS < 0 { + command.EffectiveToMS = 0 + } + return command +} + +func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error { + if command.GiftID == "" || command.ResourceID <= 0 || command.Name == "" || command.PriceVersion == "" { + return xerr.New(xerr.InvalidArgument, "gift config command is incomplete") + } + if !resourcedomain.ValidStatus(command.Status) { + return xerr.New(xerr.InvalidArgument, "status is invalid") + } + if command.CoinPrice < 0 || command.HeatValue < 0 { + return xerr.New(xerr.InvalidArgument, "gift price is invalid") + } + if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) { + return xerr.New(xerr.InvalidArgument, "gift type is invalid") + } + if command.CPRelationType != "" && command.GiftTypeCode != resourcedomain.GiftTypeCP { + return xerr.New(xerr.InvalidArgument, "cp relation type requires cp gift type") + } + if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) { + return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid") + } + if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS { + return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid") + } + for _, effectType := range command.EffectTypes { + if !resourcedomain.ValidGiftEffectType(effectType) { + return xerr.New(xerr.InvalidArgument, "gift effect type is invalid") + } + } + if len(command.RegionIDs) == 0 { + return xerr.New(xerr.InvalidArgument, "gift regions are required") + } + for _, regionID := range command.RegionIDs { + if regionID < 0 { + return xerr.New(xerr.InvalidArgument, "gift region is invalid") + } + } + return nil +} + +func normalizeGiftEffectTypes(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = resourcedomain.NormalizeGiftEffectType(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} + +func cpRelationTypeFromPresentationJSON(value string) string { + var payload map[string]any + if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil { + return "" + } + if raw, ok := payload["cp_relation_type"].(string); ok { + return normalizeCPRelationType(raw) + } + return "" +} + +func giftPresentationWithCPRelationType(value string, relationType string) string { + relationType = normalizeCPRelationType(relationType) + var payload map[string]any + if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil || payload == nil { + payload = map[string]any{} + } + + if relationType == "" { + delete(payload, "cp_relation_type") + } else { + payload["cp_relation_type"] = relationType + } + encoded, err := json.Marshal(payload) + if err != nil { + return "{}" + } + return string(encoded) +} + +func normalizeCPRelationType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "cp": + return "cp" + case "brother": + return "brother" + case "sister": + return "sister" + default: + return "" + } +} diff --git a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go new file mode 100644 index 00000000..1c812120 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go @@ -0,0 +1,340 @@ +package mysql + +import ( + "context" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +// DebitGift 在一个 MySQL 事务内完成 sender 扣费、主播周期钻石入账、分录和 outbox;历史 GIFT_POINT 回执字段保留但不再入账。 +func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { + if r == nil || r.db == nil { + return ledger.Receipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.Receipt{}, err + } + defer func() { + + _ = tx.Rollback() + }() + + requestHash := debitRequestHash(command) + bizType := giftDebitBizType(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizType); err != nil || exists { + if err != nil || !exists { + return ledger.Receipt{}, err + } + receipt, receiptErr := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) + return receipt, receiptErr + } + + nowMs := time.Now().UnixMilli() + if command.RegionID < 0 { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") + } + if command.SenderRegionID < 0 { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid") + } + giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) + if err != nil { + return ledger.Receipt{}, err + } + price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) + if command.RobotGift && chargeSource == giftChargeSourceBag { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "robot gift cannot use bag entitlement") + } + chargeAssetType := price.ChargeAssetType + if command.RobotGift { + + chargeAssetType = ledger.AssetRobotCoin + } else if chargeSource == giftChargeSourceBag { + chargeAssetType = ledger.AssetBagGift + } + chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount)) + if err != nil { + return ledger.Receipt{}, err + } + + roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) + if err != nil { + return ledger.Receipt{}, err + } + + heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM) + if err != nil { + return ledger.Receipt{}, err + } + giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) + if err != nil { + return ledger.Receipt{}, err + } + giftIncomeCoinAmount := int64(0) + if !command.RobotGift { + + giftIncomeCoinAmount, err = giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM) + if err != nil { + return ledger.Receipt{}, err + } + } + hostPeriodDiamondAdded := int64(0) + hostPeriodCycleKey := "" + giftDiamondRatioPercent := "0.00" + giftDiamondRatioRegionID := int64(0) + if command.TargetIsHost && !command.RobotGift { + ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) + if err != nil { + return ledger.Receipt{}, err + } + + hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM) + if err != nil { + return ledger.Receipt{}, err + } + giftDiamondRatioPercent = ratio.Percent + giftDiamondRatioRegionID = ratioRegionID + hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + } + + accountAssetType := chargeAssetType + if chargeSource == giftChargeSourceBag { + accountAssetType = ledger.AssetCoin + } + var senderState *walletAccountState + var targetCoinState *walletAccountState + if command.RobotGift { + + sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + senderState = &walletAccountState{account: sender} + } else { + lockRequests := []walletAccountLockRequest{{ + UserID: command.SenderUserID, + AssetType: accountAssetType, + CreateIfMissing: chargeAmount == 0 || chargeSource == giftChargeSourceBag, + }} + if giftIncomeCoinAmount > 0 { + lockRequests = append(lockRequests, walletAccountLockRequest{ + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + CreateIfMissing: true, + }) + } + states, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + senderState = states[walletAccountLockKey(command.SenderUserID, accountAssetType)] + if giftIncomeCoinAmount > 0 { + targetCoinState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetCoin)] + } + } + sender := senderState.account + robotCoinTopUp := int64(0) + if command.RobotGift && sender.AvailableAmount < chargeAmount { + + robotCoinTopUp = chargeAmount - sender.AvailableAmount + } + if !command.RobotGift && chargeSource != giftChargeSourceBag && sender.AvailableAmount < chargeAmount { + return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount + if chargeSource == giftChargeSourceBag { + + senderAfter = sender.AvailableAmount + } + giftIncomeBalanceAfter := int64(0) + if giftIncomeCoinAmount > 0 { + if targetCoinState == nil { + return ledger.Receipt{}, xerr.New(xerr.Internal, "gift income account is missing") + } + giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount + if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag { + + giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount + senderAfter = giftIncomeBalanceAfter + } + } + transactionID := transactionID(command.AppCode, command.CommandID) + metadata := giftMetadata{ + AppCode: command.AppCode, + GiftID: command.GiftID, + GiftName: giftConfig.Name, + GiftIconURL: giftDisplayIconURL(giftConfig.Resource), + GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), + + GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + ChargeSource: chargeSource, + EntitlementID: strings.TrimSpace(command.EntitlementID), + GiftPointAdded: 0, + HeatValue: heatValue, + BalanceAfter: senderAfter, + GiftIncomeCoinAmount: giftIncomeCoinAmount, + GiftIncomeRatioPercent: giftIncomeRatio.Percent, + GiftIncomeRatioRegionID: giftIncomeRatioRegionID, + GiftIncomeBalanceAfter: giftIncomeBalanceAfter, + BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), + SenderUserID: command.SenderUserID, + SenderRegionID: command.SenderRegionID, + TargetUserID: command.TargetUserID, + TargetIsHost: command.TargetIsHost, + TargetHostRegionID: command.TargetHostRegionID, + TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioRegionID: giftDiamondRatioRegionID, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + RoomRegionID: command.RegionID, + RoomContributionRatioPercent: roomContributionRatio.Percent, + RoomContributionRatioRegionID: roomContributionRatioRegionID, + CoinPrice: price.CoinPrice, + GiftPointAmount: 0, + HeatUnitValue: price.CoinPrice, + RobotGift: command.RobotGift, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { + return ledger.Receipt{}, err + } + events := make([]walletOutboxEvent, 0, 5) + if robotCoinTopUp > 0 { + topUpAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, robotCoinTopUp, 0, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SenderUserID, + AssetType: chargeAssetType, + AvailableDelta: robotCoinTopUp, + FrozenDelta: 0, + AvailableAfter: topUpAccount.AvailableAmount, + FrozenAfter: topUpAccount.FrozenAmount, + CounterpartyUserID: 0, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.Receipt{}, err + } + + sender = senderState.account + } + if chargeSource == giftChargeSourceBag { + resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, giftConfig.ResourceID, int64(command.GiftCount), metadata, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + events = append(events, resourceEvent) + } else { + debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SenderUserID, + AssetType: chargeAssetType, + AvailableDelta: -chargeAmount, + FrozenDelta: 0, + AvailableAfter: debitAccount.AvailableAmount, + FrozenAfter: debitAccount.FrozenAmount, + CounterpartyUserID: command.TargetUserID, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.Receipt{}, err + } + } + if giftIncomeCoinAmount > 0 { + incomeState := targetCoinState + if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { + + incomeState = senderState + } + incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount + if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { + metadata.BalanceAfter = incomeAccount.AvailableAmount + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: giftIncomeCoinAmount, + FrozenDelta: 0, + AvailableAfter: incomeAccount.AvailableAmount, + FrozenAfter: incomeAccount.FrozenAmount, + CounterpartyUserID: command.SenderUserID, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.Receipt{}, err + } + } + if hostPeriodDiamondAdded > 0 { + + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + } + + if !command.RobotGift { + + if chargeSource != giftChargeSourceBag { + if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) + } else { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) + } + } + if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs)) + } + events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs)) + if giftIncomeCoinAmount > 0 { + events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + } + } + if hostPeriodDiamondAdded > 0 { + events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + } + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { + return ledger.Receipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.Receipt{}, err + } + + return receiptFromGiftMetadata(transactionID, metadata), nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go new file mode 100644 index 00000000..212a379b --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go @@ -0,0 +1,95 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" +) + +func normalizeGiftChargeSource(source string, entitlementID string) string { + normalized := strings.ToLower(strings.TrimSpace(source)) + if normalized == giftChargeSourceBag || strings.TrimSpace(entitlementID) != "" { + return giftChargeSourceBag + } + return giftChargeSourceCoin +} + +func (r *Repository) consumeGiftEntitlementForSend(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, giftResourceID int64, quantity int64, metadata giftMetadata, nowMs int64) (walletOutboxEvent, error) { + entitlementID := strings.TrimSpace(command.EntitlementID) + if entitlementID == "" { + return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "entitlement_id is required") + } + if command.SenderUserID <= 0 || giftResourceID <= 0 || quantity <= 0 { + return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement command is invalid") + } + row := tx.QueryRowContext(ctx, userResourceSelectSQL()+` + WHERE e.app_code = ? AND e.entitlement_id = ? + FOR UPDATE`, + appcode.FromContext(ctx), entitlementID, + ) + entitlement, err := scanUserResourceEntitlement(row) + if errors.Is(err, sql.ErrNoRows) { + return walletOutboxEvent{}, xerr.New(xerr.NotFound, "bag gift entitlement not found") + } + if err != nil { + return walletOutboxEvent{}, err + } + if entitlement.UserID != command.SenderUserID { + return walletOutboxEvent{}, xerr.New(xerr.PermissionDenied, "bag gift entitlement does not belong to sender") + } + if entitlement.ResourceID != giftResourceID { + return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement resource mismatch") + } + if entitlement.Status != resourcedomain.StatusActive || entitlement.Resource.Status != resourcedomain.StatusActive { + return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is not active") + } + if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeGift { + return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag entitlement resource_type must be gift") + } + if entitlement.EffectiveAtMS > nowMs || (entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMs) { + return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is expired") + } + if entitlement.RemainingQuantity < quantity { + return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient") + } + result, err := tx.ExecContext(ctx, ` + UPDATE user_resource_entitlements + SET remaining_quantity = remaining_quantity - ?, updated_at_ms = ? + WHERE app_code = ? AND entitlement_id = ? AND remaining_quantity >= ?`, + quantity, nowMs, appcode.FromContext(ctx), entitlementID, quantity, + ) + if err != nil { + return walletOutboxEvent{}, err + } + affected, err := result.RowsAffected() + if err != nil { + return walletOutboxEvent{}, err + } + if affected != 1 { + + return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient") + } + entitlement.RemainingQuantity -= quantity + entitlement.UpdatedAtMS = nowMs + return resourceOutboxEvent("UserResourceChanged", command.CommandID, command.SenderUserID, giftResourceID, map[string]any{ + "action": "consume_gift_send", + "app_code": appcode.FromContext(ctx), + "user_id": command.SenderUserID, + "resource_id": giftResourceID, + "resource_type": entitlement.Resource.ResourceType, + "entitlement_id": entitlementID, + "consumed_quantity": quantity, + "remaining_quantity": entitlement.RemainingQuantity, + "room_id": command.RoomID, + "gift_id": command.GiftID, + "charge_source": giftChargeSourceBag, + "gift_value_coins": metadata.CoinSpent, + "updated_at_ms": nowMs, + "entitlement": entitlement, + }, nowMs), nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go b/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go new file mode 100644 index 00000000..87805517 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go @@ -0,0 +1,95 @@ +package mysql + +import ( + "fmt" + "hyapp/pkg/appcode" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +func giftDebitBizType(command ledger.DebitGiftCommand) string { + if command.RobotGift { + return bizTypeRobotGiftDebit + } + return bizTypeGiftDebit +} + +func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Receipt, error) { + aggregate := ledger.Receipt{} + billingReceiptIDs := make([]string, 0, len(targets)) + transactionIDs := make([]string, 0, len(targets)) + for _, target := range targets { + receipt := target.Receipt + if receipt.BillingReceiptID != "" { + billingReceiptIDs = append(billingReceiptIDs, receipt.BillingReceiptID) + } + if receipt.TransactionID != "" { + transactionIDs = append(transactionIDs, receipt.TransactionID) + } + var err error + if aggregate.CoinSpent, err = checkedAdd(aggregate.CoinSpent, receipt.CoinSpent); err != nil { + return ledger.Receipt{}, err + } + if aggregate.ChargeAmount, err = checkedAdd(aggregate.ChargeAmount, receipt.ChargeAmount); err != nil { + return ledger.Receipt{}, err + } + if aggregate.HeatValue, err = checkedAdd(aggregate.HeatValue, receipt.HeatValue); err != nil { + return ledger.Receipt{}, err + } + if aggregate.HostPeriodDiamondAdded, err = checkedAdd(aggregate.HostPeriodDiamondAdded, receipt.HostPeriodDiamondAdded); err != nil { + return ledger.Receipt{}, err + } + if aggregate.ChargeAssetType == "" { + aggregate.ChargeAssetType = receipt.ChargeAssetType + } else if receipt.ChargeAssetType != "" && aggregate.ChargeAssetType != receipt.ChargeAssetType { + aggregate.ChargeAssetType = giftWallChargeAssetMixed + } + if aggregate.GiftTypeCode == "" { + aggregate.GiftTypeCode = receipt.GiftTypeCode + } + if aggregate.CPRelationType == "" { + aggregate.CPRelationType = receipt.CPRelationType + } + if aggregate.GiftName == "" { + aggregate.GiftName = receipt.GiftName + } + if aggregate.GiftIconURL == "" { + aggregate.GiftIconURL = receipt.GiftIconURL + } + if aggregate.GiftAnimationURL == "" { + aggregate.GiftAnimationURL = receipt.GiftAnimationURL + } + if aggregate.PriceVersion == "" { + aggregate.PriceVersion = receipt.PriceVersion + } + if aggregate.HostPeriodCycleKey == "" { + aggregate.HostPeriodCycleKey = receipt.HostPeriodCycleKey + } + + aggregate.BalanceAfter = receipt.BalanceAfter + } + aggregate.BillingReceiptID = strings.Join(billingReceiptIDs, ",") + aggregate.TransactionID = strings.Join(transactionIDs, ",") + return aggregate, nil +} + +func debitRequestHash(command ledger.DebitGiftCommand) string { + + return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s", + appcode.Normalize(command.AppCode), + command.RoomID, + command.SenderUserID, + command.TargetUserID, + command.GiftID, + command.GiftCount, + strings.TrimSpace(command.PriceVersion), + command.RegionID, + command.SenderRegionID, + command.TargetIsHost, + command.TargetHostRegionID, + command.TargetAgencyOwnerUserID, + command.RobotGift, + strings.TrimSpace(command.EntitlementID), + normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID), + )) +} diff --git a/services/wallet-service/internal/storage/mysql/gift_price_repository.go b/services/wallet-service/internal/storage/mysql/gift_price_repository.go new file mode 100644 index 00000000..7755a75a --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_price_repository.go @@ -0,0 +1,96 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "time" +) + +func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error { + legacyHeatValue := command.CoinPrice + _, err := tx.ExecContext(ctx, ` + INSERT INTO wallet_gift_prices ( + app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, + heat_value, effective_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + charge_asset_type = VALUES(charge_asset_type), + coin_price = VALUES(coin_price), + gift_point_amount = VALUES(gift_point_amount), + heat_value = VALUES(heat_value), + effective_at_ms = VALUES(effective_at_ms), + updated_at_ms = VALUES(updated_at_ms)`, + appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice, + command.GiftPointAmount, legacyHeatValue, command.EffectiveAtMS, nowMs, nowMs, + ) + return err +} + +func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) { + row := querier.QueryRowContext(ctx, + `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value + FROM wallet_gift_prices + WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? + ORDER BY effective_at_ms DESC, price_version DESC + LIMIT 1`, + appcode.FromContext(ctx), giftID, nowMs, + ) + var price giftPrice + if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") + } + return giftPrice{}, err + } + price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) + return price, nil +} + +func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) { + nowMs := time.Now().UnixMilli() + row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+` + WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' + AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) + AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?) + AND EXISTS ( + SELECT 1 + FROM gift_config_regions gcr + WHERE gcr.app_code = gc.app_code + AND gcr.gift_id = gc.gift_id + AND (gcr.region_id = 0 OR gcr.region_id = ?) + ) + FOR UPDATE`, + appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID, + ) + gift, err := scanGiftConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config is not active") + } + if err != nil { + return resourcedomain.GiftConfig{}, err + } + gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) + return gift, err +} + +func applyResourcePricingToGiftCommand(command resourcedomain.GiftConfigCommand, resource resourcedomain.Resource) resourcedomain.GiftConfigCommand { + switch resourcedomain.NormalizePriceType(resource.PriceType) { + case resourcedomain.PriceTypeCoin: + command.ChargeAssetType = ledger.AssetCoin + command.CoinPrice = resource.CoinPrice + command.GiftPointAmount = 0 + case resourcedomain.PriceTypeFree: + command.ChargeAssetType = ledger.AssetCoin + command.CoinPrice = 0 + command.GiftPointAmount = 0 + } + + command.HeatValue = command.CoinPrice + return command +} diff --git a/services/wallet-service/internal/storage/mysql/gift_pricing.go b/services/wallet-service/internal/storage/mysql/gift_pricing.go new file mode 100644 index 00000000..5afff69e --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_pricing.go @@ -0,0 +1,279 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" +) + +type giftPrice struct { + GiftID string + PriceVersion string + ChargeAssetType string + CoinPrice int64 + // GiftPointAmount 是 wallet_gift_prices 的历史列;送礼结算只按 CoinPrice 和比例计算,不再读取它做收益。 + GiftPointAmount int64 + HeatValue int64 +} + +type rechargePolicy struct { + PolicyID int64 + RegionID int64 + PolicyVersion string + CurrencyCode string + CoinAmount int64 + USDMinorAmount int64 + EffectiveFromMs int64 +} + +type giftDiamondRatioSnapshot struct { + Percent string + PPM int64 +} + +func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID string, priceVersion string, nowMs int64) (giftPrice, error) { + var row *sql.Row + if strings.TrimSpace(priceVersion) != "" { + row = tx.QueryRowContext(ctx, + `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value + FROM wallet_gift_prices + WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`, + appcode.FromContext(ctx), giftID, priceVersion, nowMs, + ) + } else { + row = tx.QueryRowContext(ctx, + `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value + FROM wallet_gift_prices + WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? + ORDER BY effective_at_ms DESC, price_version DESC + LIMIT 1`, + appcode.FromContext(ctx), giftID, nowMs, + ) + } + + var price giftPrice + if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") + } + return giftPrice{}, err + } + if price.CoinPrice < 0 || price.HeatValue < 0 { + return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid") + } + price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) + return price, nil +} + +func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { + giftTypeCode = strings.TrimSpace(giftTypeCode) + if giftTypeCode == "" { + giftTypeCode = "normal" + } + regions := []int64{regionID} + if regionID != 0 { + regions = append(regions, 0) + } + for _, regionID := range regions { + ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) + if err != nil { + return giftDiamondRatioSnapshot{}, 0, err + } + if exists { + return ratio, regionID, nil + } + } + return defaultGiftDiamondRatio(giftTypeCode), 0, nil +} + +func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { + + return r.resolveGiftDiamondRatio(ctx, tx, appCode, 0, giftTypeCode) +} + +func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { + var percent string + var ppm int64 + err := tx.QueryRowContext(ctx, ` + SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED) + FROM gift_diamond_ratio_configs + WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' + LIMIT 1`, + appcode.Normalize(appCode), regionID, giftTypeCode, + ).Scan(&percent, &ppm) + if errors.Is(err, sql.ErrNoRows) { + return giftDiamondRatioSnapshot{}, false, nil + } + if err != nil { + return giftDiamondRatioSnapshot{}, false, err + } + if ppm < 0 || ppm > 1_000_000 { + return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") + } + percent = strings.TrimSpace(percent) + if percent == "" { + percent = "100.00" + } + return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil +} + +func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { + giftTypeCode = strings.TrimSpace(giftTypeCode) + if giftTypeCode == "" { + giftTypeCode = "normal" + } + regions := []int64{regionID} + if regionID != 0 { + regions = append(regions, 0) + } + for _, regionID := range regions { + ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, giftTypeCode) + if err != nil { + return giftDiamondRatioSnapshot{}, 0, err + } + if exists { + return ratio, regionID, nil + } + } + return defaultGiftReturnCoinRatio(giftTypeCode), 0, nil +} + +func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { + var percent string + var ppm int64 + err := tx.QueryRowContext(ctx, ` + SELECT CAST(coin_return_ratio_percent AS CHAR), CAST(ROUND(coin_return_ratio_percent * 10000) AS SIGNED) + FROM gift_diamond_ratio_configs + WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' + LIMIT 1`, + appcode.Normalize(appCode), regionID, giftTypeCode, + ).Scan(&percent, &ppm) + if errors.Is(err, sql.ErrNoRows) { + return giftDiamondRatioSnapshot{}, false, nil + } + if err != nil { + return giftDiamondRatioSnapshot{}, false, err + } + if ppm < 0 || ppm > 1_000_000 { + return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift return coin ratio is invalid") + } + percent = strings.TrimSpace(percent) + if percent == "" { + percent = defaultGiftReturnCoinRatio(giftTypeCode).Percent + } + return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil +} + +func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { + if chargeAmount < 0 || ratioPPM < 0 { + return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid") + } + product, err := checkedMul(chargeAmount, ratioPPM) + if err != nil { + return 0, err + } + return product / 1_000_000, nil +} + +func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot { + switch strings.TrimSpace(giftTypeCode) { + case "lucky": + return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} + case "super_lucky": + return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} + default: + return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} + } +} + +func defaultGiftReturnCoinRatio(giftTypeCode string) giftDiamondRatioSnapshot { + switch strings.TrimSpace(giftTypeCode) { + case resourcedomain.GiftTypeLucky: + return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} + case resourcedomain.GiftTypeSuperLucky: + return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} + default: + return giftDiamondRatioSnapshot{Percent: "30.00", PPM: 300_000} + } +} + +func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { + row := tx.QueryRowContext(ctx, + `SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms + FROM wallet_recharge_policies + WHERE app_code = ? AND region_id = ? AND status = 'active' + AND effective_from_ms <= ? + AND (effective_to_ms IS NULL OR effective_to_ms > ?) + ORDER BY effective_from_ms DESC, policy_id DESC + LIMIT 1`, + appcode.FromContext(ctx), + regionID, + nowMs, + nowMs, + ) + var policy rechargePolicy + if err := row.Scan(&policy.PolicyID, &policy.RegionID, &policy.PolicyVersion, &policy.CurrencyCode, &policy.CoinAmount, &policy.USDMinorAmount, &policy.EffectiveFromMs); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return rechargePolicy{}, xerr.New(xerr.NotFound, "recharge policy not found") + } + return rechargePolicy{}, err + } + if policy.CoinAmount <= 0 || policy.USDMinorAmount <= 0 || strings.TrimSpace(policy.CurrencyCode) == "" { + return rechargePolicy{}, xerr.New(xerr.Internal, "recharge policy is invalid") + } + return policy, nil +} + +func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64, error) { + numerator, err := checkedMul(coinAmount, policy.USDMinorAmount) + if err != nil { + return 0, err + } + + return numerator / policy.CoinAmount, nil +} + +func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) { + row := tx.QueryRowContext(ctx, + `SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms + FROM coin_seller_salary_exchange_rate_tiers + WHERE app_code = ? AND region_id = ? AND status = 'active' + AND min_usd_minor <= ? AND max_usd_minor >= ? + ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC + LIMIT 1 + FOR UPDATE`, + appcode.FromContext(ctx), + regionID, + salaryUSDMinor, + salaryUSDMinor, + ) + var tier ledger.CoinSellerSalaryExchangeRateTier + if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured") + } + return ledger.CoinSellerSalaryExchangeRateTier{}, err + } + if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 { + + return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid") + } + return tier, nil +} + +func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) { + numerator, err := checkedMul(salaryUSDMinor, coinPerUSD) + if err != nil { + return 0, err + } + if numerator%100 != 0 { + return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate") + } + return numerator / 100, nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_type_config_repository.go b/services/wallet-service/internal/storage/mysql/gift_type_config_repository.go new file mode 100644 index 00000000..4354488f --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_type_config_repository.go @@ -0,0 +1,194 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// ListGiftTypeConfigs 返回礼物面板 tab 类型配置;默认类型会按 app_code 自动补齐,避免新租户缺配置。 +func (r *Repository) ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + if err := r.ensureDefaultGiftTypeConfigs(ctx, r.db); err != nil { + return nil, err + } + query.AppCode = appcode.FromContext(ctx) + query = normalizeGiftTypeConfigListQuery(query) + where, args := giftTypeConfigWhereSQL(query) + rows, err := r.db.QueryContext(ctx, giftTypeConfigSelectSQL()+where+` + ORDER BY sort_order ASC, type_code ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]resourcedomain.GiftTypeConfig, 0, len(resourcedomain.DefaultGiftTypeConfigs(query.AppCode))) + for rows.Next() { + item, err := scanGiftTypeConfig(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +// UpsertGiftTypeConfig 更新礼物类型 tab 展示配置;type_code 是礼物表引用的稳定业务键。 +func (r *Repository) UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) { + if r == nil || r.db == nil { + return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeGiftTypeConfigCommand(command) + if err := validateGiftTypeConfigCommand(command); err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + defer func() { _ = tx.Rollback() }() + if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + nowMs := time.Now().UnixMilli() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO gift_type_configs ( + app_code, type_code, name, tab_key, status, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + tab_key = VALUES(tab_key), + status = VALUES(status), + sort_order = VALUES(sort_order), + updated_by_user_id = VALUES(updated_by_user_id), + updated_at_ms = VALUES(updated_at_ms)`, + command.AppCode, command.TypeCode, command.Name, command.TabKey, command.Status, command.SortOrder, + command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, + ); err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + item, err := r.getGiftTypeConfigTx(ctx, tx, command.TypeCode) + if err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.GiftTypeConfig{}, err + } + return item, nil +} + +func (r *Repository) getGiftTypeConfigTx(ctx context.Context, tx *sql.Tx, typeCode string) (resourcedomain.GiftTypeConfig, error) { + row := tx.QueryRowContext(ctx, giftTypeConfigSelectSQL()+`WHERE app_code = ? AND type_code = ?`, appcode.FromContext(ctx), typeCode) + item, err := scanGiftTypeConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.NotFound, "gift type config not found") + } + return item, err +} + +func (r *Repository) giftTypeConfigExistsTx(ctx context.Context, tx *sql.Tx, typeCode string) (bool, error) { + var exists int + err := tx.QueryRowContext(ctx, ` + SELECT 1 + FROM gift_type_configs + WHERE app_code = ? AND type_code = ? + LIMIT 1`, appcode.FromContext(ctx), typeCode).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func (r *Repository) ensureDefaultGiftTypeConfigs(ctx context.Context, execer sqlExecer) error { + nowMs := time.Now().UnixMilli() + for _, item := range resourcedomain.DefaultGiftTypeConfigs(appcode.FromContext(ctx)) { + if _, err := execer.ExecContext(ctx, ` + INSERT IGNORE INTO gift_type_configs ( + app_code, type_code, name, tab_key, status, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`, + item.AppCode, item.TypeCode, item.Name, item.TabKey, item.Status, item.SortOrder, nowMs, nowMs, + ); err != nil { + return err + } + } + return nil +} + +func scanGiftTypeConfig(scanner scanTarget) (resourcedomain.GiftTypeConfig, error) { + var item resourcedomain.GiftTypeConfig + err := scanner.Scan( + &item.AppCode, + &item.TypeCode, + &item.Name, + &item.TabKey, + &item.Status, + &item.SortOrder, + &item.CreatedByUserID, + &item.UpdatedByUserID, + &item.CreatedAtMS, + &item.UpdatedAtMS, + ) + return item, err +} + +func giftTypeConfigSelectSQL() string { + return ` + SELECT app_code, type_code, name, tab_key, status, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + FROM gift_type_configs + ` +} + +func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (string, []any) { + where := `WHERE app_code = ?` + args := []any{query.AppCode} + if query.ActiveOnly { + where += ` AND status = 'active'` + } else if query.Status != "" { + where += ` AND status = ?` + args = append(args, query.Status) + } + return where, args +} + +func normalizeGiftTypeConfigListQuery(query resourcedomain.ListGiftTypeConfigsQuery) resourcedomain.ListGiftTypeConfigsQuery { + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + if query.Status != "" && !resourcedomain.ValidStatus(query.Status) { + query.Status = "__invalid__" + } + return query +} + +func normalizeGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) resourcedomain.GiftTypeConfigCommand { + command.TypeCode = resourcedomain.NormalizeGiftTypeCode(command.TypeCode) + command.Name = strings.TrimSpace(command.Name) + command.TabKey = resourcedomain.NormalizeGiftTypeTabKey(command.TabKey) + command.Status = resourcedomain.NormalizeStatus(command.Status) + return command +} + +func validateGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) error { + if !resourcedomain.ValidGiftTypeCode(command.TypeCode) || command.Name == "" || !resourcedomain.ValidGiftTypeTabKey(command.TabKey) { + return xerr.New(xerr.InvalidArgument, "gift type config is incomplete") + } + if !resourcedomain.ValidStatus(command.Status) { + return xerr.New(xerr.InvalidArgument, "status is invalid") + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_wall_repository.go b/services/wallet-service/internal/storage/mysql/gift_wall_repository.go new file mode 100644 index 00000000..fc117809 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_wall_repository.go @@ -0,0 +1,86 @@ +package mysql + +import ( + "context" + "database/sql" + "hyapp/pkg/appcode" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error { + if metadata.RobotGift { + + return nil + } + chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType) + chargeAmount := metadata.ChargeAmount + if chargeAmount == 0 { + + chargeAmount = metadata.CoinSpent + } + totalCoinValue, totalDiamondValue := chargeAmount, int64(0) + resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot) + if resourceSnapshotJSON == "" { + resourceSnapshotJSON = "{}" + } + presentationJSON := strings.TrimSpace(metadata.PresentationJSON) + if presentationJSON == "" { + presentationJSON = "{}" + } + giftTypeCode := strings.TrimSpace(metadata.GiftTypeCode) + if giftTypeCode == "" { + giftTypeCode = "normal" + } + + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_gift_wall ( + app_code, user_id, gift_id, gift_name, resource_id, resource_snapshot_json, + gift_type_code, presentation_json, gift_count, total_value, total_coin_value, + total_diamond_value, total_gift_point, total_heat_value, charge_asset_type, + last_price_version, first_received_at_ms, last_received_at_ms, sort_order, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + gift_name = VALUES(gift_name), + resource_id = VALUES(resource_id), + resource_snapshot_json = VALUES(resource_snapshot_json), + gift_type_code = VALUES(gift_type_code), + presentation_json = VALUES(presentation_json), + gift_count = gift_count + VALUES(gift_count), + total_value = total_value + VALUES(total_value), + total_coin_value = total_coin_value + VALUES(total_coin_value), + total_diamond_value = total_diamond_value + VALUES(total_diamond_value), + total_gift_point = total_gift_point + VALUES(total_gift_point), + total_heat_value = total_heat_value + VALUES(total_heat_value), + charge_asset_type = IF(charge_asset_type = VALUES(charge_asset_type), charge_asset_type, ?), + last_price_version = VALUES(last_price_version), + last_received_at_ms = VALUES(last_received_at_ms), + sort_order = VALUES(sort_order), + updated_at_ms = VALUES(updated_at_ms) + `, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.GiftID, + metadata.GiftName, + metadata.ResourceID, + resourceSnapshotJSON, + giftTypeCode, + presentationJSON, + int64(metadata.GiftCount), + chargeAmount, + totalCoinValue, + totalDiamondValue, + 0, + metadata.HeatValue, + chargeAssetType, + metadata.PriceVersion, + nowMs, + nowMs, + metadata.SortOrder, + nowMs, + nowMs, + giftWallChargeAssetMixed, + ) + return err +} diff --git a/services/wallet-service/internal/storage/mysql/host_diamond.go b/services/wallet-service/internal/storage/mysql/host_diamond.go new file mode 100644 index 00000000..46f2231d --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/host_diamond.go @@ -0,0 +1,150 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) { + + if metadata.HostPeriodDiamondAdded <= 0 { + return 0, 0, nil + } + after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + if err != nil { + return 0, 0, err + } + if !exists { + + after = metadata.HostPeriodDiamondAdded + version = 1 + _, err = tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_accounts ( + app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total, + version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + nowMs, + nowMs, + ) + if err != nil && isMySQLDuplicateError(err) { + + after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) + } + if err != nil { + return 0, 0, err + } + if !exists { + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil + } + } + + after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded) + if err != nil { + return 0, 0, err + } + version++ + + result, err := tx.ExecContext(ctx, + `UPDATE host_period_diamond_accounts + SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?, + version = ?, last_gift_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND cycle_key = ?`, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + after, + metadata.HostPeriodDiamondAdded, + version, + nowMs, + nowMs, + appcode.FromContext(ctx), + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + ) + if err != nil { + return 0, 0, err + } + rows, err := result.RowsAffected() + if err != nil { + return 0, 0, err + } + if rows != 1 { + return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict") + } + if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { + return 0, 0, err + } + return after, version, nil +} + +func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) { + + row := tx.QueryRowContext(ctx, + `SELECT total_diamonds, version + FROM host_period_diamond_accounts + WHERE app_code = ? AND user_id = ? AND cycle_key = ? + FOR UPDATE`, + appcode.FromContext(ctx), + userID, + cycleKey, + ) + var totalDiamonds int64 + var version int64 + if err := row.Scan(&totalDiamonds, &version); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, false, nil + } + return 0, 0, false, err + } + return totalDiamonds, version, true, nil +} + +func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error { + + _, err := tx.ExecContext(ctx, + `INSERT INTO host_period_diamond_entries ( + app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id, + diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount, + gift_id, gift_count, sender_user_id, room_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + transactionID, + commandID, + metadata.TargetUserID, + metadata.HostPeriodCycleKey, + metadata.TargetHostRegionID, + metadata.TargetAgencyOwnerUserID, + metadata.HostPeriodDiamondAdded, + diamondAfter, + metadata.ChargeAssetType, + metadata.ChargeAmount, + metadata.GiftID, + metadata.GiftCount, + metadata.SenderUserID, + metadata.RoomID, + nowMs, + ) + return err +} + +func hostPeriodCycleKeyFromMS(ms int64) string { + + return time.UnixMilli(ms).UTC().Format("2006-01") +} diff --git a/services/wallet-service/internal/storage/mysql/metadata.go b/services/wallet-service/internal/storage/mysql/metadata.go new file mode 100644 index 00000000..28b9f007 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/metadata.go @@ -0,0 +1,270 @@ +package mysql + +type giftMetadata struct { + AppCode string `json:"app_code"` + GiftID string `json:"gift_id"` + GiftName string `json:"gift_name"` + GiftIconURL string `json:"gift_icon_url"` + GiftAnimationURL string `json:"gift_animation_url"` + GiftEffectTypes []string `json:"gift_effect_types"` + ResourceID int64 `json:"resource_id"` + ResourceSnapshot string `json:"resource_snapshot_json"` + GiftTypeCode string `json:"gift_type_code"` + CPRelationType string `json:"cp_relation_type"` + PresentationJSON string `json:"presentation_json"` + SortOrder int32 `json:"sort_order"` + GiftCount int32 `json:"gift_count"` + PriceVersion string `json:"price_version"` + CoinPrice int64 `json:"coin_price"` + // GiftPointAmount 是历史礼物积分单价字段,新送礼固定写 0,业务统计只读真实扣费和热度。 + GiftPointAmount int64 `json:"gift_point_amount"` + HeatUnitValue int64 `json:"heat_unit_value"` + ChargeAssetType string `json:"charge_asset_type"` + ChargeAmount int64 `json:"charge_amount"` + CoinSpent int64 `json:"coin_spent"` + // ChargeSource 记录本次送礼从哪里扣费;bag 会扣用户资源库存,但 CoinSpent 仍保留礼物价值用于房间贡献。 + ChargeSource string `json:"charge_source,omitempty"` + // EntitlementID 只在背包送礼时存在,用于排查某次送礼具体消耗了哪条用户权益。 + EntitlementID string `json:"entitlement_id,omitempty"` + // GiftPointAdded 是历史收礼积分字段,新送礼固定写 0,保留 JSON 字段只为旧事件解析兼容。 + GiftPointAdded int64 `json:"gift_point_added"` + HeatValue int64 `json:"heat_value"` + BalanceAfter int64 `json:"balance_after"` + GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"` + GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"` + GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"` + GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"` + BillingReceipt string `json:"billing_receipt_id"` + SenderUserID int64 `json:"sender_user_id"` + SenderRegionID int64 `json:"sender_region_id"` + TargetUserID int64 `json:"target_user_id"` + TargetIsHost bool `json:"target_is_host"` + TargetHostRegionID int64 `json:"target_host_region_id"` + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` + GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` + HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` + HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` + HostPeriodCycleKey string `json:"host_period_cycle_key"` + RoomID string `json:"room_id"` + RoomRegionID int64 `json:"room_region_id"` + RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"` + RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"` + RobotGift bool `json:"robot_gift,omitempty"` +} + +type adminCreditMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + OperatorUserID int64 `json:"operator_user_id"` + Reason string `json:"reason"` + EvidenceRef string `json:"evidence_ref"` +} + +type cpBreakupFeeMetadata struct { + AppCode string `json:"app_code"` + UserID int64 `json:"user_id"` + RelationshipID string `json:"relationship_id"` + RelationType string `json:"relation_type"` + Amount int64 `json:"amount"` + CoinBalanceAfter int64 `json:"coin_balance_after"` +} + +type wheelDrawDebitMetadata struct { + AppCode string `json:"app_code"` + UserID int64 `json:"user_id"` + WheelID string `json:"wheel_id"` + DrawCount int32 `json:"draw_count"` + Amount int64 `json:"amount"` + CoinBalanceAfter int64 `json:"coin_balance_after"` +} + +type taskRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + TaskType string `json:"task_type"` + TaskID string `json:"task_id"` + CycleKey string `json:"cycle_key"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type luckyGiftRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + DrawID string `json:"draw_id"` + RoomID string `json:"room_id"` + VisibleRegionID int64 `json:"visible_region_id"` + CountryID int64 `json:"country_id"` + GiftID string `json:"gift_id"` + PoolID string `json:"pool_id"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type wheelRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + DrawID string `json:"draw_id"` + WheelID string `json:"wheel_id"` + SelectedTierID string `json:"selected_tier_id"` + VisibleRegionID int64 `json:"visible_region_id"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type roomTurnoverRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + SettlementID string `json:"settlement_id"` + RoomID string `json:"room_id"` + PeriodStartMS int64 `json:"period_start_ms"` + PeriodEndMS int64 `json:"period_end_ms"` + CoinSpent int64 `json:"coin_spent"` + TierID int64 `json:"tier_id"` + TierCode string `json:"tier_code"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type inviteActivityRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + ClaimID string `json:"claim_id"` + RewardType string `json:"reward_type"` + TierID int64 `json:"tier_id"` + TierCode string `json:"tier_code"` + CycleKey string `json:"cycle_key"` + ReachedValue int64 `json:"reached_value"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type agencyOpeningRewardMetadata struct { + AppCode string `json:"app_code"` + TargetUserID int64 `json:"target_user_id"` + AssetType string `json:"asset_type"` + Amount int64 `json:"amount"` + ApplicationID string `json:"application_id"` + CycleID string `json:"cycle_id"` + AgencyID int64 `json:"agency_id"` + RankNo int32 `json:"rank_no"` + ScoreCoins int64 `json:"score_coins"` + Reason string `json:"reason"` + BalanceAfter int64 `json:"balance_after"` + GrantedAtMS int64 `json:"granted_at_ms"` +} + +type gameCoinMetadata struct { + AppCode string `json:"app_code"` + UserID int64 `json:"user_id"` + PlatformCode string `json:"platform_code"` + GameID string `json:"game_id"` + ProviderOrderID string `json:"provider_order_id"` + ProviderRoundID string `json:"provider_round_id"` + OpType string `json:"op_type"` + AssetType string `json:"asset_type"` + CoinAmount int64 `json:"coin_amount"` + AvailableDelta int64 `json:"available_delta"` + RoomID string `json:"room_id"` + BalanceAfter int64 `json:"balance_after"` + AppliedAtMS int64 `json:"applied_at_ms"` +} + +type coinSellerTransferMetadata struct { + AppCode string `json:"app_code"` + PaymentOrderID string `json:"payment_order_id,omitempty"` + ProviderCode string `json:"provider,omitempty"` + PaymentMethodID int64 `json:"payment_method_id,omitempty"` + SellerUserID int64 `json:"seller_user_id"` + TargetUserID int64 `json:"target_user_id"` + TargetCountryID int64 `json:"target_country_id"` + SellerRegionID int64 `json:"seller_region_id"` + TargetRegionID int64 `json:"target_region_id"` + Amount int64 `json:"amount"` + Reason string `json:"reason"` + SellerAssetType string `json:"seller_asset_type"` + TargetAssetType string `json:"target_asset_type"` + SellerBalanceAfter int64 `json:"seller_balance_after"` + TargetBalanceAfter int64 `json:"target_balance_after"` + RechargeSequence int64 `json:"recharge_sequence"` + RechargeUSDMinor int64 `json:"recharge_usd_minor"` + RechargeCurrencyCode string `json:"recharge_currency_code"` + RechargePolicyID int64 `json:"recharge_policy_id"` + RechargePolicyVersion string `json:"recharge_policy_version"` + RechargePolicyCoinAmount int64 `json:"recharge_policy_coin_amount"` + RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"` + RechargeType string `json:"recharge_type,omitempty"` +} + +type salaryExchangeMetadata struct { + AppCode string `json:"app_code"` + UserID int64 `json:"user_id"` + SalaryAssetType string `json:"salary_asset_type"` + CoinAssetType string `json:"coin_asset_type"` + SalaryUSDMinor int64 `json:"salary_usd_minor"` + CoinPerUSD int64 `json:"coin_per_usd"` + CoinAmount int64 `json:"coin_amount"` + SalaryBalanceAfter int64 `json:"salary_balance_after"` + CoinBalanceAfter int64 `json:"coin_balance_after"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type salaryTransferToCoinSellerMetadata struct { + AppCode string `json:"app_code"` + SourceUserID int64 `json:"source_user_id"` + SellerUserID int64 `json:"seller_user_id"` + RegionID int64 `json:"region_id"` + SalaryAssetType string `json:"salary_asset_type"` + SellerAssetType string `json:"seller_asset_type"` + SalaryUSDMinor int64 `json:"salary_usd_minor"` + CoinPerUSD int64 `json:"coin_per_usd"` + CoinAmount int64 `json:"coin_amount"` + RateMinUSDMinor int64 `json:"rate_min_usd_minor"` + RateMaxUSDMinor int64 `json:"rate_max_usd_minor"` + SourceSalaryBalanceAfter int64 `json:"source_salary_balance_after"` + SellerBalanceAfter int64 `json:"seller_balance_after"` + Reason string `json:"reason"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type coinSellerStockMetadata struct { + AppCode string `json:"app_code"` + PaymentOrderID string `json:"payment_order_id,omitempty"` + ProviderCode string `json:"provider,omitempty"` + PaymentMethodID int64 `json:"payment_method_id,omitempty"` + SellerUserID int64 `json:"seller_user_id"` + SellerCountryID int64 `json:"seller_country_id"` + SellerRegionID int64 `json:"seller_region_id"` + StockType string `json:"stock_type"` + CoinAmount int64 `json:"coin_amount"` + PaidCurrencyCode string `json:"paid_currency_code"` + PaidAmountMicro int64 `json:"paid_amount_micro"` + PaymentRef string `json:"payment_ref"` + EvidenceRef string `json:"evidence_ref"` + OperatorUserID int64 `json:"operator_user_id"` + Reason string `json:"reason"` + AssetType string `json:"asset_type"` + CountsAsSellerRecharge bool `json:"counts_as_seller_recharge"` + BalanceAfter int64 `json:"balance_after"` + CreatedAtMS int64 `json:"created_at_ms"` +} diff --git a/services/wallet-service/internal/storage/mysql/outbox.go b/services/wallet-service/internal/storage/mysql/outbox.go new file mode 100644 index 00000000..f042180b --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/outbox.go @@ -0,0 +1,721 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// WalletOutboxRecord is a committed wallet fact ready for external fanout. +type WalletOutboxRecord struct { + AppCode string + EventID string + EventType string + TransactionID string + CommandID string + UserID int64 + AssetType string + AvailableDelta int64 + FrozenDelta int64 + PayloadJSON string + CreatedAtMS int64 + RetryCount int + LastError string + WorkerID string + LockUntilMS int64 +} + +// WalletOutboxClaimFilter narrows one MQ fanout lane by event type. +type WalletOutboxClaimFilter struct { + // IncludeEventTypes lets a realtime worker own only the configured UI event facts. + IncludeEventTypes []string + // ExcludeEventTypes lets the generic wallet worker skip facts owned by the realtime lane. + ExcludeEventTypes []string +} + +type walletOutboxEvent struct { + AppCode string + EventID string + EventType string + TransactionID string + CommandID string + UserID int64 + AssetType string + AvailableDelta int64 + FrozenDelta int64 + Payload any + CreatedAtMS int64 +} + +func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events []walletOutboxEvent) error { + for _, event := range events { + payloadJSON, err := json.Marshal(event.Payload) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO wallet_outbox ( + app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + normalizedEntryAppCode(ctx, event.AppCode), + event.EventID, + event.EventType, + event.TransactionID, + event.CommandID, + event.UserID, + event.AssetType, + event.AvailableDelta, + event.FrozenDelta, + string(payloadJSON), + outboxStatusPending, + event.CreatedAtMS, + event.CreatedAtMS, + ); err != nil { + return err + } + } + return nil +} + +// ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout. +func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) { + return r.ClaimPendingWalletOutboxFiltered(ctx, workerID, limit, lockUntilMS, WalletOutboxClaimFilter{}) +} + +// ClaimPendingWalletOutboxFiltered atomically claims committed wallet facts for one MQ fanout lane. +func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, workerID string, limit int, lockUntilMS int64, filter WalletOutboxClaimFilter) ([]WalletOutboxRecord, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + nowMS := time.Now().UTC().UnixMilli() + if lockUntilMS <= nowMS { + lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli() + } + + appCode := appcode.FromContext(ctx) + eventFilterSQL, eventFilterArgs := walletOutboxEventFilterSQL(filter) + pendingIndex, claimIndex := walletOutboxClaimIndexes(filter) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + records := make([]WalletOutboxRecord, 0, limit) + claimBranches := []struct { + query string + args []any + }{ + { + query: walletOutboxClaimQuery(pendingIndex, ` + AND next_retry_at_ms IS NULL + AND lock_until_ms IS NULL`, eventFilterSQL), + args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...), + }, + { + query: walletOutboxClaimQuery(pendingIndex, ` + AND next_retry_at_ms <= ? + AND lock_until_ms IS NULL`, eventFilterSQL), + args: append([]any{appCode, outboxStatusRetryable, nowMS}, eventFilterArgs...), + }, + { + query: walletOutboxClaimQuery(claimIndex, ` + AND lock_until_ms <= ?`, eventFilterSQL), + args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...), + }, + } + for _, branch := range claimBranches { + remaining := limit - len(records) + if remaining <= 0 { + break + } + args := append(append([]any{}, branch.args...), remaining) + branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...) + if err != nil { + return nil, err + } + records = append(records, branchRecords...) + } + + for _, record := range records { + if _, err := tx.ExecContext(ctx, ` + UPDATE wallet_outbox + SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ?`, + outboxStatusDelivering, + workerID, + lockUntilMS, + nowMS, + record.AppCode, + record.EventID, + ); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + for index := range records { + records[index].WorkerID = workerID + records[index].LockUntilMS = lockUntilMS + } + return records, nil +} + +func walletOutboxClaimQuery(indexName string, statusCondition string, eventFilterSQL string) string { + return ` + SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, + retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0) + FROM wallet_outbox FORCE INDEX (` + indexName + `) + WHERE app_code = ? AND status = ?` + statusCondition + eventFilterSQL + ` + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? + FOR UPDATE SKIP LOCKED` +} + +func walletOutboxEventFilterSQL(filter WalletOutboxClaimFilter) (string, []any) { + include := normalizeWalletOutboxEventTypes(filter.IncludeEventTypes) + if len(include) > 0 { + return " AND event_type IN (" + walletOutboxPlaceholders(len(include)) + ")", walletOutboxEventArgs(include) + } + exclude := normalizeWalletOutboxEventTypes(filter.ExcludeEventTypes) + if len(exclude) > 0 { + return " AND event_type NOT IN (" + walletOutboxPlaceholders(len(exclude)) + ")", walletOutboxEventArgs(exclude) + } + return "", nil +} + +func walletOutboxClaimIndexes(filter WalletOutboxClaimFilter) (string, string) { + if len(normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)) > 0 { + return "idx_wallet_outbox_event_pending", "idx_wallet_outbox_event_claim" + } + return "idx_wallet_outbox_pending", "idx_wallet_outbox_claim" +} + +func normalizeWalletOutboxEventTypes(values []string) []string { + normalized := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + return normalized +} + +func walletOutboxPlaceholders(count int) string { + if count <= 0 { + return "" + } + placeholders := make([]string, count) + for index := range placeholders { + placeholders[index] = "?" + } + return strings.Join(placeholders, ",") +} + +func walletOutboxEventArgs(values []string) []any { + args := make([]any, 0, len(values)) + for _, value := range values { + args = append(args, value) + } + return args +} + +func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + records := make([]WalletOutboxRecord, 0) + for rows.Next() { + var record WalletOutboxRecord + if err := rows.Scan( + &record.AppCode, + &record.EventID, + &record.EventType, + &record.TransactionID, + &record.CommandID, + &record.UserID, + &record.AssetType, + &record.AvailableDelta, + &record.FrozenDelta, + &record.PayloadJSON, + &record.CreatedAtMS, + &record.RetryCount, + &record.LastError, + &record.WorkerID, + &record.LockUntilMS, + ); err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, err + } + return records, nil +} + +// MarkWalletOutboxDelivered marks one wallet fact as accepted by the MQ broker. +func (r *Repository) MarkWalletOutboxDelivered(ctx context.Context, eventID string) error { + if r == nil || r.db == nil { + return xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + _, err := r.db.ExecContext(ctx, ` + UPDATE wallet_outbox + SET status = ?, worker_id = '', lock_until_ms = NULL, next_retry_at_ms = NULL, last_error = NULL, updated_at_ms = ? + WHERE app_code = ? AND event_id = ?`, + outboxStatusDelivered, + time.Now().UTC().UnixMilli(), + appcode.FromContext(ctx), + eventID, + ) + return err +} + +// MarkWalletOutboxRetryable releases one failed MQ publish with a bounded retry schedule. +func (r *Repository) MarkWalletOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { + if r == nil || r.db == nil { + return xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + _, err := r.db.ExecContext(ctx, ` + UPDATE wallet_outbox + SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ?`, + outboxStatusRetryable, + nextRetryAtMS, + trimWalletOutboxError(lastErr), + time.Now().UTC().UnixMilli(), + appcode.FromContext(ctx), + eventID, + ) + return err +} + +// MarkWalletOutboxDead stops retrying a poison wallet fact after the configured retry ceiling. +func (r *Repository) MarkWalletOutboxDead(ctx context.Context, eventID string, lastErr string) error { + if r == nil || r.db == nil { + return xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + _, err := r.db.ExecContext(ctx, ` + UPDATE wallet_outbox + SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ?`, + outboxStatusFailed, + trimWalletOutboxError(lastErr), + time.Now().UTC().UnixMilli(), + appcode.FromContext(ctx), + eventID, + ) + return err +} + +func trimWalletOutboxError(value string) string { + value = strings.TrimSpace(value) + if len(value) > 1024 { + return value[:1024] + } + return value +} + +func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType), + EventType: "WalletBalanceChanged", + TransactionID: transactionID, + CommandID: commandID, + UserID: userID, + AssetType: assetType, + AvailableDelta: availableDelta, + FrozenDelta: frozenDelta, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": userID, + "asset_type": assetType, + "available_delta": availableDelta, + "frozen_delta": frozenDelta, + "available_after": availableAfter, + "frozen_after": frozenAfter, + "balance_version": balanceVersion, + "metadata": payload, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin), + EventType: "WalletCoinSellerTransferred", + TransactionID: transactionID, + CommandID: commandID, + UserID: sellerUserID, + AssetType: ledger.AssetCoinSellerCoin, + AvailableDelta: availableDelta, + FrozenDelta: 0, + Payload: payload, + CreatedAtMS: nowMs, + } +} + +func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { + assetType := ledger.AssetCoin + if metadata, ok := payload.(coinSellerTransferMetadata); ok { + if strings.TrimSpace(metadata.TargetAssetType) != "" { + assetType = metadata.TargetAssetType + } + rechargeType := strings.TrimSpace(metadata.RechargeType) + if rechargeType == "" { + rechargeType = "coin_seller_transfer" + } + + payload = map[string]any{ + "app_code": metadata.AppCode, + "payment_order_id": metadata.PaymentOrderID, + "provider": metadata.ProviderCode, + "payment_method_id": metadata.PaymentMethodID, + "seller_user_id": metadata.SellerUserID, + "target_user_id": metadata.TargetUserID, + "target_country_id": metadata.TargetCountryID, + "country_id": metadata.TargetCountryID, + "seller_region_id": metadata.SellerRegionID, + "target_region_id": metadata.TargetRegionID, + "region_id": metadata.TargetRegionID, + "amount": metadata.Amount, + "reason": metadata.Reason, + "seller_asset_type": metadata.SellerAssetType, + "target_asset_type": metadata.TargetAssetType, + "seller_balance_after": metadata.SellerBalanceAfter, + "target_balance_after": metadata.TargetBalanceAfter, + "recharge_sequence": metadata.RechargeSequence, + "recharge_usd_minor": metadata.RechargeUSDMinor, + "recharge_currency_code": metadata.RechargeCurrencyCode, + "recharge_policy_id": metadata.RechargePolicyID, + "recharge_policy_version": metadata.RechargePolicyVersion, + "recharge_policy_coin_amount": metadata.RechargePolicyCoinAmount, + "recharge_policy_usd_minor_amount": metadata.RechargePolicyUSDMinorAmount, + "recharge_type": rechargeType, + "created_at_ms": nowMs, + } + } + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletRechargeRecorded", targetUserID, assetType), + EventType: "WalletRechargeRecorded", + TransactionID: transactionID, + CommandID: commandID, + UserID: targetUserID, + AssetType: assetType, + AvailableDelta: availableDelta, + FrozenDelta: 0, + Payload: payload, + CreatedAtMS: nowMs, + } +} + +func taskRewardCreditedEvent(transactionID string, commandID string, metadata taskRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletTaskRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletTaskRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "task_type": metadata.TaskType, + "task_id": metadata.TaskID, + "cycle_key": metadata.CycleKey, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metadata luckyGiftRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletLuckyGiftRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletLuckyGiftRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "draw_id": metadata.DrawID, + "room_id": metadata.RoomID, + "visible_region_id": metadata.VisibleRegionID, + "region_id": metadata.VisibleRegionID, + "country_id": metadata.CountryID, + "gift_id": metadata.GiftID, + "pool_id": metadata.PoolID, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func wheelRewardCreditedEvent(transactionID string, commandID string, metadata wheelRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletWheelRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletWheelRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "draw_id": metadata.DrawID, + "wheel_id": metadata.WheelID, + "selected_tier_id": metadata.SelectedTierID, + "visible_region_id": metadata.VisibleRegionID, + "region_id": metadata.VisibleRegionID, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletRoomTurnoverRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "settlement_id": metadata.SettlementID, + "room_id": metadata.RoomID, + "period_start_ms": metadata.PeriodStartMS, + "period_end_ms": metadata.PeriodEndMS, + "coin_spent": metadata.CoinSpent, + "tier_id": metadata.TierID, + "tier_code": metadata.TierCode, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func inviteActivityRewardCreditedEvent(transactionID string, commandID string, metadata inviteActivityRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletInviteActivityRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletInviteActivityRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "claim_id": metadata.ClaimID, + "reward_type": metadata.RewardType, + "tier_id": metadata.TierID, + "tier_code": metadata.TierCode, + "cycle_key": metadata.CycleKey, + "reached_value": metadata.ReachedValue, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func agencyOpeningRewardCreditedEvent(transactionID string, commandID string, metadata agencyOpeningRewardMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletAgencyOpeningRewardCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletAgencyOpeningRewardCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "application_id": metadata.ApplicationID, + "cycle_id": metadata.CycleID, + "agency_id": metadata.AgencyID, + "rank_no": metadata.RankNo, + "score_coins": metadata.ScoreCoins, + "amount": metadata.Amount, + "reason": metadata.Reason, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent { + eventType := "WalletCoinSellerCoinCompensated" + if metadata.StockType == ledger.StockTypeUSDTPurchase { + eventType = "WalletCoinSellerStockPurchased" + } + return walletOutboxEvent{ + EventID: eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin), + EventType: eventType, + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.SellerUserID, + AssetType: ledger.AssetCoinSellerCoin, + AvailableDelta: metadata.CoinAmount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "seller_user_id": metadata.SellerUserID, + "seller_country_id": metadata.SellerCountryID, + "seller_region_id": metadata.SellerRegionID, + "country_id": metadata.SellerCountryID, + "region_id": metadata.SellerRegionID, + "stock_type": metadata.StockType, + "coin_amount": metadata.CoinAmount, + "paid_currency_code": metadata.PaidCurrencyCode, + "paid_amount_micro": metadata.PaidAmountMicro, + "counts_as_seller_recharge": metadata.CountsAsSellerRecharge, + "operator_user_id": metadata.OperatorUserID, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType), + EventType: "WalletGiftDebited", + TransactionID: transactionID, + CommandID: commandID, + UserID: userID, + AssetType: assetType, + AvailableDelta: availableDelta, + FrozenDelta: frozenDelta, + Payload: payload, + CreatedAtMS: nowMs, + } +} + +func giftIncomeCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "WalletGiftIncomeCredited", metadata.TargetUserID, ledger.AssetCoin), + EventType: "WalletGiftIncomeCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.GiftIncomeCoinAmount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": metadata.TargetUserID, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "gift_type_code": metadata.GiftTypeCode, + "gift_charge_amount": metadata.ChargeAmount, + "charge_source": metadata.ChargeSource, + "amount": metadata.GiftIncomeCoinAmount, + "ratio_percent": metadata.GiftIncomeRatioPercent, + "ratio_region_id": metadata.GiftIncomeRatioRegionID, + "gift_income_balance_after": metadata.GiftIncomeBalanceAfter, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} + +func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond), + EventType: "HostPeriodDiamondCredited", + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.TargetUserID, + AssetType: ledger.AssetHostPeriodDiamond, + AvailableDelta: metadata.HostPeriodDiamondAdded, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "cycle_key": metadata.HostPeriodCycleKey, + "region_id": metadata.TargetHostRegionID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "diamond_delta": metadata.HostPeriodDiamondAdded, + "diamond_after": metadata.HostPeriodDiamondAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "sender_user_id": metadata.SenderUserID, + "sender_region_id": metadata.SenderRegionID, + "room_id": metadata.RoomID, + "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, + "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, + "created_at_ms": nowMs, + }, + CreatedAtMS: nowMs, + } +} diff --git a/services/wallet-service/internal/storage/mysql/payment_method_repository.go b/services/wallet-service/internal/storage/mysql/payment_method_repository.go new file mode 100644 index 00000000..e82c7e0d --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/payment_method_repository.go @@ -0,0 +1,234 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strconv" + "strings" + "time" +) + +func (r *Repository) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + app := appcode.FromContext(ctx) + providerCode := strings.ToLower(strings.TrimSpace(query.ProviderCode)) + status := ledger.NormalizeThirdPartyPaymentStatus(query.Status) + where := `WHERE app_code = ?` + args := []any{app} + if providerCode != "" { + where += ` AND provider_code = ?` + args = append(args, providerCode) + } + if status != "" { + where += ` AND status = ?` + args = append(args, status) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code, provider_code, provider_name, status, sort_order + FROM third_party_payment_channels + `+where+` + ORDER BY sort_order ASC, provider_code ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + channels := make([]ledger.ThirdPartyPaymentChannel, 0) + for rows.Next() { + var channel ledger.ThirdPartyPaymentChannel + if err := rows.Scan(&channel.AppCode, &channel.ProviderCode, &channel.ProviderName, &channel.Status, &channel.SortOrder); err != nil { + return nil, err + } + channels = append(channels, channel) + } + if err := rows.Err(); err != nil { + return nil, err + } + for index := range channels { + methods, err := r.listThirdPartyPaymentMethods(ctx, app, channels[index].ProviderCode, "", query.IncludeDisabledMethods, false) + if err != nil { + return nil, err + } + channels[index].Methods = methods + } + return channels, nil +} + +func (r *Repository) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) { + if r == nil || r.db == nil { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + status := ledger.ThirdPartyPaymentStatusDisabled + if command.Enabled { + status = ledger.ThirdPartyPaymentStatusActive + } + result, err := r.db.ExecContext(ctx, ` + UPDATE third_party_payment_methods + SET status = ?, updated_at_ms = ? + WHERE app_code = ? AND method_id = ?`, + status, time.Now().UnixMilli(), appcode.FromContext(ctx), command.MethodID, + ) + if err != nil { + return ledger.ThirdPartyPaymentMethod{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return ledger.ThirdPartyPaymentMethod{}, err + } else if affected == 0 { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") + } + return r.GetThirdPartyPaymentMethod(ctx, appcode.FromContext(ctx), command.MethodID) +} + +func (r *Repository) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) { + if r == nil || r.db == nil { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + result, err := r.db.ExecContext(ctx, ` + UPDATE third_party_payment_methods + SET usd_to_currency_rate = ?, updated_at_ms = ? + WHERE app_code = ? AND method_id = ?`, + command.USDToCurrencyRate, time.Now().UnixMilli(), appcode.FromContext(ctx), command.MethodID, + ) + if err != nil { + return ledger.ThirdPartyPaymentMethod{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return ledger.ThirdPartyPaymentMethod{}, err + } else if affected == 0 { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") + } + return r.GetThirdPartyPaymentMethod(ctx, appcode.FromContext(ctx), command.MethodID) +} + +func (r *Repository) GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error) { + ctx = contextWithCommandApp(ctx, appCode) + row := r.db.QueryRowContext(ctx, thirdPartyPaymentMethodSelectSQL()+` + WHERE m.app_code = ? AND m.method_id = ?`, + appcode.FromContext(ctx), methodID, + ) + method, err := scanThirdPartyPaymentMethod(row) + if errors.Is(err, sql.ErrNoRows) { + return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") + } + return method, err +} + +func (r *Repository) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) { + if r == nil || r.db == nil { + return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + products, _, err := r.ListRechargeProducts(ctx, query.TargetUserID, query.TargetRegionID, ledger.RechargeProductPlatformWeb, query.AudienceType) + if err != nil { + return ledger.H5RechargeOptions{}, err + } + methods, err := r.listH5ThirdPartyPaymentMethods(ctx, appcode.FromContext(ctx), strings.ToUpper(strings.TrimSpace(query.TargetCountryCode))) + if err != nil { + return ledger.H5RechargeOptions{}, err + } + return ledger.H5RechargeOptions{Products: products, PaymentMethods: methods}, nil +} + +func (r *Repository) listH5ThirdPartyPaymentMethods(ctx context.Context, appCode string, countryCode string) ([]ledger.ThirdPartyPaymentMethod, error) { + where := `WHERE m.app_code = ? AND m.status = 'active' AND m.usd_to_currency_rate > 0` + args := []any{appCode} + if countryCode != "" { + where += ` AND m.country_code IN (?, 'GLOBAL')` + args = append(args, countryCode) + } + rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+` + `+where+` + ORDER BY c.sort_order ASC, CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + methods := make([]ledger.ThirdPartyPaymentMethod, 0) + for rows.Next() { + method, err := scanThirdPartyPaymentMethod(rows) + if err != nil { + return nil, err + } + methods = append(methods, method) + } + return methods, rows.Err() +} + +func (r *Repository) listThirdPartyPaymentMethods(ctx context.Context, appCode string, providerCode string, countryCode string, includeDisabled bool, requireRate bool) ([]ledger.ThirdPartyPaymentMethod, error) { + where := `WHERE m.app_code = ? AND m.provider_code = ?` + args := []any{appCode, providerCode} + if countryCode != "" { + where += ` AND m.country_code IN (?, 'GLOBAL')` + args = append(args, countryCode) + } + if !includeDisabled { + where += ` AND m.status = 'active'` + } + if requireRate { + where += ` AND m.usd_to_currency_rate > 0` + } + rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+` + `+where+` + ORDER BY CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + methods := make([]ledger.ThirdPartyPaymentMethod, 0) + for rows.Next() { + method, err := scanThirdPartyPaymentMethod(rows) + if err != nil { + return nil, err + } + methods = append(methods, method) + } + return methods, rows.Err() +} + +func thirdPartyPaymentMethodSelectSQL() string { + return ` + SELECT m.method_id, m.app_code, m.provider_code, c.provider_name, m.country_code, m.country_name, + m.currency_code, m.pay_way, m.pay_type, m.method_name, m.logo_url, m.status, + CAST(m.usd_to_currency_rate AS CHAR), m.sort_order, m.created_at_ms, m.updated_at_ms + FROM third_party_payment_methods m + JOIN third_party_payment_channels c ON c.app_code = m.app_code AND c.provider_code = m.provider_code + ` +} + +func scanThirdPartyPaymentMethod(scanner scanTarget) (ledger.ThirdPartyPaymentMethod, error) { + var method ledger.ThirdPartyPaymentMethod + if err := scanner.Scan( + &method.MethodID, + &method.AppCode, + &method.ProviderCode, + &method.ProviderName, + &method.CountryCode, + &method.CountryName, + &method.CurrencyCode, + &method.PayWay, + &method.PayType, + &method.MethodName, + &method.LogoURL, + &method.Status, + &method.USDToCurrencyRate, + &method.SortOrder, + &method.CreatedAtMS, + &method.UpdatedAtMS, + ); err != nil { + return ledger.ThirdPartyPaymentMethod{}, err + } + return method, nil +} + +func decimalRateIsPositive(rate string) bool { + value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) + return err == nil && value > 0 +} diff --git a/services/wallet-service/internal/storage/mysql/payment_seed.go b/services/wallet-service/internal/storage/mysql/payment_seed.go new file mode 100644 index 00000000..abea5522 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/payment_seed.go @@ -0,0 +1,242 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + mysqlDriver "github.com/go-sql-driver/mysql" + "strings" + "time" +) + +type seedPaymentMethod struct { + CountryCode string + CountryName string + CurrencyCode string + PayWay string + PayType string + MethodName string + Rate string + SortOrder int +} + +func isDuplicateColumnError(err error) bool { + var mysqlErr *mysqlDriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 +} + +// defaultMifaPayLogoURL 按 MiFaPay 的国家、payWay、payType 精确回填 COS logo;key 全部归一化,兼容存量库里的大小写差异。 +func defaultMifaPayLogoURL(countryCode string, payWay string, payType string) string { + logos := map[string]string{ + "AE|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png", + "BD|ewallet|nagad": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png", + "BD|ewallet|bkash": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png", + "BH|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png", + "EG|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png", + "EG|ewallet|fawrypay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png", + "EG|ewallet|meeza": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png", + "EG|ewallet|opay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png", + "EG|ewallet|orange": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png", + "EG|ewallet|vodafone": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png", + "GLOBAL|banktransfer|unionpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png", + "GLOBAL|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png", + "GLOBAL|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png", + "GLOBAL|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png", + "ID|banktransfer|bca": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png", + "ID|banktransfer|bni": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png", + "ID|banktransfer|bri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png", + "ID|banktransfer|mandiri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png", + "ID|banktransfer|permata": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png", + "ID|banktransfer|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png", + "ID|ewallet|dana": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png", + "ID|ewallet|linkaja": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png", + "ID|ewallet|ovo": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png", + "ID|ewallet|shopeepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png", + "ID|qr|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png", + "IN|ewallet|bhim": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png", + "IN|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png", + "IN|ewallet|mobikwik": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png", + "IN|ewallet|paytm": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png", + "IN|ewallet|phonepe": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png", + "IN|ewallet|upi": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png", + "KW|card|kent": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png", + "KW|card|knet": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png", + "MY|banktransfer|fpx": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png", + "MY|ewallet|tng": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png", + "OM|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png", + "PH|banktransfer|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png", + "PH|ewallet|gcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png", + "PH|ewallet|paymaya": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png", + "PH|qr|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png", + "PK|ewallet|easypaisa": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png", + "PK|ewallet|jazzcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png", + "QA|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png", + "SA|card|mada": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png", + "SA|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png", + "SA|ewallet|stcpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png", + "TH|banktransfer|promptpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png", + "TH|ewallet|truemoney": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png", + "VN|banktransfer|vn_banktransfer": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png", + "VN|qr|vietqr": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png", + } + key := strings.ToUpper(strings.TrimSpace(countryCode)) + "|" + strings.ToLower(strings.TrimSpace(payWay)) + "|" + strings.ToLower(strings.TrimSpace(payType)) + return logos[key] +} + +func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error { + nowMS := time.Now().UnixMilli() + if _, err := db.ExecContext(ctx, ` + INSERT INTO third_party_payment_channels ( + app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms + ) VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, ?, ?) + ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil { + return err + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO third_party_payment_channels ( + app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms + ) VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, ?, ?) + ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil { + return err + } + + methods := []seedPaymentMethod{ + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BCA", MethodName: "BCA Bank", Rate: "1.00000000", SortOrder: 110}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BNI", MethodName: "BNI Bank", Rate: "1.00000000", SortOrder: 120}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BRI", MethodName: "BRI Bank", Rate: "1.00000000", SortOrder: 130}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "Mandiri", MethodName: "Mandiri Bank", Rate: "1.00000000", SortOrder: 140}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "Permata", MethodName: "Permata Bank", Rate: "1.00000000", SortOrder: 150}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "DANA", MethodName: "DANA", Rate: "1.00000000", SortOrder: 160}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "LinkAja", MethodName: "LinkAja", Rate: "1.00000000", SortOrder: 170}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "OVO", MethodName: "OVO", Rate: "1.00000000", SortOrder: 180}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "ShopeePay", MethodName: "ShopeePay", Rate: "1.00000000", SortOrder: 190}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "QR", PayType: "QRIS", MethodName: "QRIS", Rate: "1.00000000", SortOrder: 200}, + {CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", PayWay: "BankTransfer", PayType: "VN_BankTransfer", MethodName: "Vietnam Bank Transfer", Rate: "1.00000000", SortOrder: 210}, + {CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", PayWay: "QR", PayType: "VietQR", MethodName: "VietQR", Rate: "1.00000000", SortOrder: 220}, + {CountryCode: "BH", CountryName: "Bahrain", CurrencyCode: "BHD", PayWay: "Card", PayType: "CreditCard", MethodName: "Bahrain Credit Card", Rate: "1.00000000", SortOrder: 310}, + {CountryCode: "QA", CountryName: "Qatar", CurrencyCode: "QAR", PayWay: "Card", PayType: "CreditCard", MethodName: "Qatar Credit Card", Rate: "1.00000000", SortOrder: 410}, + {CountryCode: "OM", CountryName: "Oman", CurrencyCode: "OMR", PayWay: "Card", PayType: "CreditCard", MethodName: "Oman Credit Card", Rate: "1.00000000", SortOrder: 510}, + {CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", PayWay: "Card", PayType: "CreditCard", MethodName: "UAE Credit Card", Rate: "1.00000000", SortOrder: 610}, + {CountryCode: "KW", CountryName: "Kuwait", CurrencyCode: "KWD", PayWay: "Card", PayType: "Knet", MethodName: "KNET", Rate: "1.00000000", SortOrder: 710}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Rate: "1.00000000", SortOrder: 810}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "ApplePay", MethodName: "ApplePay", Rate: "1.00000000", SortOrder: 820}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPay", MethodName: "STCPay", Rate: "1.00000000", SortOrder: 830}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "BHIM", MethodName: "BHIM", Rate: "1.00000000", SortOrder: 910}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "GooglePay", MethodName: "GooglePay", Rate: "1.00000000", SortOrder: 920}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "Mobikwik", MethodName: "Mobikwik", Rate: "1.00000000", SortOrder: 930}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "Paytm", MethodName: "Paytm", Rate: "1.00000000", SortOrder: 940}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "PhonePe", MethodName: "PhonePe", Rate: "1.00000000", SortOrder: 950}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "UPI", MethodName: "UPI", Rate: "1.00000000", SortOrder: 960}, + {CountryCode: "BD", CountryName: "Bangladesh", CurrencyCode: "BDT", PayWay: "Ewallet", PayType: "bKash", MethodName: "bKash", Rate: "1.00000000", SortOrder: 1010}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "Easypaisa", MethodName: "Easypaisa", Rate: "1.00000000", SortOrder: 1110}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "JazzCash", MethodName: "JazzCash", Rate: "1.00000000", SortOrder: 1120}, + {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "FawryPay", MethodName: "FawryPay", Rate: "1.00000000", SortOrder: 1210}, + {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Meeza", MethodName: "Meeza", Rate: "1.00000000", SortOrder: 1220}, + {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Orange", MethodName: "Orange", Rate: "1.00000000", SortOrder: 1230}, + {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Vodafone", MethodName: "Vodafone", Rate: "1.00000000", SortOrder: 1240}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "Gcash", MethodName: "GCash", Rate: "1.00000000", SortOrder: 1310}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "PayMaya", MethodName: "PayMaya", Rate: "1.00000000", SortOrder: 1320}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "QRPH", MethodName: "QRPH", Rate: "1.00000000", SortOrder: 1330}, + } + for _, method := range methods { + rate := strings.TrimSpace(method.Rate) + if rate == "" { + rate = "1.00000000" + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO third_party_payment_methods ( + app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type, + method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms + ) VALUES ('lalu', 'mifapay', ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + country_name = VALUES(country_name), + currency_code = VALUES(currency_code), + method_name = VALUES(method_name), + logo_url = CASE + WHEN logo_url = '' THEN VALUES(logo_url) + WHEN logo_url LIKE 'https://placehold.co/%' THEN VALUES(logo_url) + WHEN logo_url LIKE '%/image-preview' THEN VALUES(logo_url) + ELSE logo_url + END, + sort_order = VALUES(sort_order), + usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate), + updated_at_ms = VALUES(updated_at_ms)`, + strings.ToUpper(method.CountryCode), method.CountryName, strings.ToUpper(method.CurrencyCode), + method.PayWay, method.PayType, method.MethodName, defaultMifaPayLogoURL(method.CountryCode, method.PayWay, method.PayType), rate, method.SortOrder, nowMS, nowMS, + ); err != nil { + return err + } + } + + v5Methods := []seedPaymentMethod{ + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UB_ONLINE", MethodName: "UnionBank Online", Rate: "1.00000000", SortOrder: 2010}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "INSTAPAY", MethodName: "InstaPay", Rate: "1.00000000", SortOrder: 2020}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PESONET", MethodName: "PESONet", Rate: "1.00000000", SortOrder: 2030}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GCASH_ONLINE", MethodName: "GCash Online", Rate: "1.00000000", SortOrder: 2040}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "COINS_ONLINE", MethodName: "Coins.ph Online", Rate: "1.00000000", SortOrder: 2050}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BPI_ONLINE", MethodName: "BPI Online", Rate: "1.00000000", SortOrder: 2060}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "RCBC_ONLINE", MethodName: "RCBC Online", Rate: "1.00000000", SortOrder: 2070}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UCPB_ONLINE", MethodName: "UCPB Online", Rate: "1.00000000", SortOrder: 2080}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "ROBINSONSBANK_ONLINE", MethodName: "Robinsons Bank Online", Rate: "1.00000000", SortOrder: 2090}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PSBANK_ONLINE", MethodName: "PSBank Online", Rate: "1.00000000", SortOrder: 2100}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "METROBANK_ONLINE", MethodName: "Metrobank Online", Rate: "1.00000000", SortOrder: 2110}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "LANDBANK_ONLINE", MethodName: "Landbank Online", Rate: "1.00000000", SortOrder: 2120}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "CHINABANK_ONLINE", MethodName: "China Bank Online", Rate: "1.00000000", SortOrder: 2130}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BDO_ONLINE", MethodName: "BDO Online", Rate: "1.00000000", SortOrder: 2140}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BOC_ONLINE", MethodName: "Bank of Commerce Online", Rate: "1.00000000", SortOrder: 2150}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GRABPAY_ONLINE", MethodName: "GrabPay Online", Rate: "1.00000000", SortOrder: 2160}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "INSTAPAY_QR", MethodName: "InstaPay QR", Rate: "1.00000000", SortOrder: 2170}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "ALIPAY_QR", MethodName: "Alipay QR", Rate: "1.00000000", SortOrder: 2180}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "ALIPAY_PLUS", MethodName: "Alipay+", Rate: "1.00000000", SortOrder: 2190}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Billing", PayType: "BILL", MethodName: "Bills Payment", Rate: "1.00000000", SortOrder: 2200}, + {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "QRPH_GCASH", MethodName: "QRPh GCash", Rate: "1.00000000", SortOrder: 2210}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPAY", MethodName: "STC Pay", Rate: "1.00000000", SortOrder: 2310}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Rate: "1.00000000", SortOrder: 2320}, + {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "APPLEPAY", MethodName: "Apple Pay", Rate: "1.00000000", SortOrder: 2330}, + {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Card", PayType: "CARD", MethodName: "Egypt Card", Rate: "1.00000000", SortOrder: 2410}, + {CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", PayWay: "Card", PayType: "CARD", MethodName: "UAE Card", Rate: "1.00000000", SortOrder: 2510}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BNI_VA", MethodName: "BNI Virtual Account", Rate: "1.00000000", SortOrder: 2610}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BRI_VA", MethodName: "BRI Virtual Account", Rate: "1.00000000", SortOrder: 2620}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "CIMB_VA", MethodName: "CIMB Virtual Account", Rate: "1.00000000", SortOrder: 2630}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MANDIRI_VA", MethodName: "Mandiri Virtual Account", Rate: "1.00000000", SortOrder: 2640}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MAYBANK_VA", MethodName: "Maybank Virtual Account", Rate: "1.00000000", SortOrder: 2650}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "QR", PayType: "NOBU_BANK_QRIS", MethodName: "Nobu Bank QRIS", Rate: "1.00000000", SortOrder: 2660}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "OVO", MethodName: "OVO", Rate: "1.00000000", SortOrder: 2670}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "PERMATA_VA", MethodName: "Permata Virtual Account", Rate: "1.00000000", SortOrder: 2680}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "SHOPEEPAY_JUMPAPP", MethodName: "ShopeePay Jump App", Rate: "1.00000000", SortOrder: 2690}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "GOPAY", MethodName: "GoPay", Rate: "1.00000000", SortOrder: 2700}, + {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "ALIPAY_CN_ALIPAYPLUS", MethodName: "Alipay+ Indonesia", Rate: "1.00000000", SortOrder: 2710}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "BANK_ONLINE", MethodName: "Bank Online", Rate: "1.00000000", SortOrder: 2810}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "UPI", PayType: "UPI", MethodName: "UPI", Rate: "1.00000000", SortOrder: 2820}, + {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "ONLINE_BANKING", MethodName: "Online Banking", Rate: "1.00000000", SortOrder: 2830}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "EASYPAISA", MethodName: "Easypaisa", Rate: "1.00000000", SortOrder: 2910}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "JAZZCASH", MethodName: "JazzCash", Rate: "1.00000000", SortOrder: 2920}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "BankTransfer", PayType: "ALFA", MethodName: "Bank Alfalah", Rate: "1.00000000", SortOrder: 2930}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "HBL_KONNECT", MethodName: "HBL Konnect", Rate: "1.00000000", SortOrder: 2940}, + {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Billing", PayType: "BILL_PK", MethodName: "Pakistan Bill Payment", Rate: "1.00000000", SortOrder: 2950}, + } + for _, method := range v5Methods { + rate := strings.TrimSpace(method.Rate) + if rate == "" { + rate = "1.00000000" + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO third_party_payment_methods ( + app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type, + method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms + ) VALUES ('lalu', 'v5pay', ?, ?, ?, ?, ?, ?, '', 'active', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + country_name = VALUES(country_name), + currency_code = VALUES(currency_code), + method_name = VALUES(method_name), + sort_order = VALUES(sort_order), + usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate), + updated_at_ms = VALUES(updated_at_ms)`, + strings.ToUpper(method.CountryCode), method.CountryName, strings.ToUpper(method.CurrencyCode), + method.PayWay, method.PayType, method.MethodName, rate, method.SortOrder, nowMS, nowMS, + ); err != nil { + return err + } + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/receipts.go b/services/wallet-service/internal/storage/mysql/receipts.go new file mode 100644 index 00000000..e86b8538 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/receipts.go @@ -0,0 +1,440 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + + "hyapp/pkg/appcode" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" +) + +func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.Receipt{}, err + } + var metadata giftMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.Receipt{}, err + } + return receiptFromGiftMetadata(transactionID, metadata), nil +} + +func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt { + chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType) + if metadata.RobotGift { + chargeAssetType = ledger.AssetRobotCoin + } + chargeAmount := metadata.ChargeAmount + if chargeAmount == 0 { + chargeAmount = metadata.CoinSpent + } + return ledger.Receipt{ + BillingReceiptID: metadata.BillingReceipt, + TransactionID: transactionID, + CoinSpent: metadata.CoinSpent, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + GiftPointAdded: 0, + HeatValue: metadata.HeatValue, + GiftTypeCode: metadata.GiftTypeCode, + CPRelationType: metadata.CPRelationType, + GiftName: metadata.GiftName, + GiftIconURL: metadata.GiftIconURL, + GiftAnimationURL: metadata.GiftAnimationURL, + GiftEffectTypes: normalizeGiftEffectTypes(metadata.GiftEffectTypes), + PriceVersion: metadata.PriceVersion, + BalanceAfter: metadata.BalanceAfter, + HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded, + HostPeriodCycleKey: metadata.HostPeriodCycleKey, + EntitlementID: strings.TrimSpace(metadata.EntitlementID), + ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID), + } +} + +func giftDisplayIconURL(resource resourcedomain.Resource) string { + + if value := strings.TrimSpace(resource.PreviewURL); value != "" { + return value + } + return strings.TrimSpace(resource.AssetURL) +} + +func (r *Repository) receiptForTaskRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.TaskRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.TaskRewardReceipt{}, err + } + var metadata taskRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.TaskRewardReceipt{}, err + } + return receiptFromTaskRewardMetadata(transactionID, metadata), nil +} + +func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMetadata) ledger.TaskRewardReceipt { + return ledger.TaskRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForLuckyGiftRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.LuckyGiftRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + var metadata luckyGiftRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil +} + +func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGiftRewardMetadata) ledger.LuckyGiftRewardReceipt { + return ledger.LuckyGiftRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForWheelRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.WheelRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.WheelRewardReceipt{}, err + } + var metadata wheelRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.WheelRewardReceipt{}, err + } + return receiptFromWheelRewardMetadata(transactionID, metadata), nil +} + +func receiptFromWheelRewardMetadata(transactionID string, metadata wheelRewardMetadata) ledger.WheelRewardReceipt { + return ledger.WheelRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + var metadata roomTurnoverRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil +} + +func receiptFromRoomTurnoverRewardMetadata(transactionID string, metadata roomTurnoverRewardMetadata) ledger.RoomTurnoverRewardReceipt { + return ledger.RoomTurnoverRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForInviteActivityRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.InviteActivityRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + var metadata inviteActivityRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + return receiptFromInviteActivityRewardMetadata(transactionID, metadata), nil +} + +func receiptFromInviteActivityRewardMetadata(transactionID string, metadata inviteActivityRewardMetadata) ledger.InviteActivityRewardReceipt { + return ledger.InviteActivityRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForAgencyOpeningRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.AgencyOpeningRewardReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + var metadata agencyOpeningRewardMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + return receiptFromAgencyOpeningRewardMetadata(transactionID, metadata), nil +} + +func receiptFromAgencyOpeningRewardMetadata(transactionID string, metadata agencyOpeningRewardMetadata) ledger.AgencyOpeningRewardReceipt { + return ledger.AgencyOpeningRewardReceipt{ + TransactionID: transactionID, + Balance: ledger.AssetBalance{ + AppCode: metadata.AppCode, + UserID: metadata.TargetUserID, + AssetType: metadata.AssetType, + AvailableAmount: metadata.BalanceAfter, + }, + Amount: metadata.Amount, + GrantedAtMS: metadata.GrantedAtMS, + } +} + +func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + var metadata gameCoinMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.GameCoinChangeReceipt{}, err + } + return receiptFromGameMetadata(transactionID, metadata, false), nil +} + +func receiptFromGameMetadata(transactionID string, metadata gameCoinMetadata, idempotentReplay bool) ledger.GameCoinChangeReceipt { + return ledger.GameCoinChangeReceipt{ + TransactionID: transactionID, + BalanceAfter: metadata.BalanceAfter, + IdempotentReplay: idempotentReplay, + } +} + +func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) { + row := tx.QueryRowContext(ctx, + `SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro, + counts_as_seller_recharge, balance_after, metadata_json, created_at_ms + FROM coin_seller_stock_records + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ) + var receipt ledger.CoinSellerStockCreditReceipt + var metadataJSON string + if err := row.Scan( + &receipt.SellerUserID, + &receipt.StockType, + &receipt.CoinAmount, + &receipt.PaidCurrencyCode, + &receipt.PaidAmountMicro, + &receipt.CountsAsSellerRecharge, + &receipt.BalanceAfter, + &metadataJSON, + &receipt.CreatedAtMS, + ); err != nil { + return ledger.CoinSellerStockCreditReceipt{}, err + } + if metadataJSON != "" { + var metadata coinSellerStockMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err == nil { + receipt.SellerCountryID = metadata.SellerCountryID + receipt.SellerRegionID = metadata.SellerRegionID + } + } + receipt.TransactionID = transactionID + return receipt, nil +} + +func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt { + return ledger.CoinSellerStockCreditReceipt{ + TransactionID: transactionID, + SellerUserID: metadata.SellerUserID, + SellerCountryID: metadata.SellerCountryID, + SellerRegionID: metadata.SellerRegionID, + StockType: metadata.StockType, + CoinAmount: metadata.CoinAmount, + PaidCurrencyCode: metadata.PaidCurrencyCode, + PaidAmountMicro: metadata.PaidAmountMicro, + CountsAsSellerRecharge: metadata.CountsAsSellerRecharge, + BalanceAfter: metadata.BalanceAfter, + CreatedAtMS: metadata.CreatedAtMS, + } +} + +func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + var metadata coinSellerTransferMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.CoinSellerTransferReceipt{}, err + } + return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil +} + +func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSellerTransferMetadata) ledger.CoinSellerTransferReceipt { + return ledger.CoinSellerTransferReceipt{ + TransactionID: transactionID, + SellerBalanceAfter: metadata.SellerBalanceAfter, + TargetBalanceAfter: metadata.TargetBalanceAfter, + Amount: metadata.Amount, + RechargeSequence: metadata.RechargeSequence, + RechargeUSDMinor: metadata.RechargeUSDMinor, + RechargeCurrencyCode: metadata.RechargeCurrencyCode, + RechargePolicyID: metadata.RechargePolicyID, + RechargePolicyVersion: metadata.RechargePolicyVersion, + RechargePolicyCoinAmount: metadata.RechargePolicyCoinAmount, + RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount, + } +} + +func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + var metadata salaryExchangeMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.SalaryExchangeReceipt{}, err + } + return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil +} + +func receiptFromSalaryExchangeMetadata(transactionID string, metadata salaryExchangeMetadata) ledger.SalaryExchangeReceipt { + return ledger.SalaryExchangeReceipt{ + TransactionID: transactionID, + UserID: metadata.UserID, + SalaryAssetType: metadata.SalaryAssetType, + SalaryBalanceAfter: metadata.SalaryBalanceAfter, + CoinBalanceAfter: metadata.CoinBalanceAfter, + SalaryUSDMinor: metadata.SalaryUSDMinor, + CoinAmount: metadata.CoinAmount, + CoinPerUSD: metadata.CoinPerUSD, + CreatedAtMS: metadata.CreatedAtMS, + } +} + +func (r *Repository) receiptForSalaryTransferToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryTransferToCoinSellerReceipt, error) { + var metadataJSON string + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), + transactionID, + ).Scan(&metadataJSON); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + var metadata salaryTransferToCoinSellerMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil +} + +func receiptFromSalaryTransferToCoinSellerMetadata(transactionID string, metadata salaryTransferToCoinSellerMetadata) ledger.SalaryTransferToCoinSellerReceipt { + return ledger.SalaryTransferToCoinSellerReceipt{ + TransactionID: transactionID, + SourceUserID: metadata.SourceUserID, + SellerUserID: metadata.SellerUserID, + SalaryAssetType: metadata.SalaryAssetType, + SourceSalaryBalanceAfter: metadata.SourceSalaryBalanceAfter, + SellerBalanceAfter: metadata.SellerBalanceAfter, + SalaryUSDMinor: metadata.SalaryUSDMinor, + CoinAmount: metadata.CoinAmount, + CoinPerUSD: metadata.CoinPerUSD, + RateMinUSDMinor: metadata.RateMinUSDMinor, + RateMaxUSDMinor: metadata.RateMaxUSDMinor, + CreatedAtMS: metadata.CreatedAtMS, + } +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_claim_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_claim_repository.go new file mode 100644 index 00000000..68e2e562 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_claim_repository.go @@ -0,0 +1,283 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +// ClaimRedPacket 领取一个未分配份额,并把金额原子入账到领取者 COIN 余额。 +func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error) { + if r == nil || r.db == nil { + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := redPacketClaimRequestHash(command) + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketClaim, xerr.RequestConflict); err != nil || exists { + if err != nil || !exists { + return ledger.RedPacketClaimReceipt{}, err + } + return r.redPacketClaimReceiptForTransaction(ctx, tx, txRow.TransactionID) + } + nowMS := time.Now().UTC().UnixMilli() + packet, err := r.getRedPacketForUpdate(ctx, tx, command.PacketID) + if err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if packet.Status == ledger.RedPacketStatusFinished || packet.RemainingCount <= 0 || packet.RemainingAmount <= 0 { + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out") + } + if packet.Status == ledger.RedPacketStatusRefunded || nowMS >= packet.ExpiresAtMS { + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is expired") + } + if nowMS < packet.OpenAtMS { + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketNotOpen, "red packet is not open") + } + if existingClaim, claimed, err := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID); err != nil || claimed { + if err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + return ledger.RedPacketClaimReceipt{Claim: existingClaim, Packet: packet}, nil + } + allocationNo, amount, err := r.lockOneRedPacketAllocation(ctx, tx, command.PacketID) + if err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if amount <= 0 || amount > packet.RemainingAmount { + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet allocation is invalid") + } + + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMS) + if err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + claimID := redPacketClaimID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + amount + metadata := redPacketClaimMetadata{ + AppCode: command.AppCode, + ClaimID: claimID, + CommandID: command.CommandID, + PacketID: command.PacketID, + UserID: command.UserID, + Amount: amount, + RoomID: packet.RoomID, + RegionID: packet.RegionID, + PacketType: packet.PacketType, + RemainingAmount: packet.RemainingAmount - amount, + RemainingCount: packet.RemainingCount - 1, + BalanceAfter: balanceAfter, + WalletTransaction: transactionID, + ClaimedAtMS: nowMS, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketClaim, requestHash, command.PacketID, metadata, nowMS); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMS); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CounterpartyUserID: packet.SenderUserID, + RoomID: packet.RoomID, + CreatedAtMS: nowMS, + }); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if err := r.insertRedPacketClaim(ctx, tx, metadata); err != nil { + if isMySQLDuplicateError(err) { + existingClaim, claimed, lookupErr := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID) + if lookupErr != nil { + return ledger.RedPacketClaimReceipt{}, lookupErr + } + if claimed { + return r.redPacketClaimReceiptForTransaction(ctx, tx, existingClaim.WalletTransactionID) + } + return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet claim already exists") + } + return ledger.RedPacketClaimReceipt{}, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE red_packet_allocations + SET status = ?, claimed_by_user_id = ?, claim_id = ?, claimed_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND packet_id = ? AND allocation_no = ? AND status = ?`, + redPacketAllocationClaimed, command.UserID, claimID, nowMS, nowMS, + appcode.FromContext(ctx), command.PacketID, allocationNo, redPacketAllocationUnclaimed, + ); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + newRemainingAmount := packet.RemainingAmount - amount + newRemainingCount := packet.RemainingCount - 1 + newStatus := ledger.RedPacketStatusActive + if newRemainingCount == 0 { + newStatus = ledger.RedPacketStatusFinished + } + metadata.PacketStatus = newStatus + if _, err := tx.ExecContext(ctx, ` + UPDATE red_packets + SET remaining_amount = ?, remaining_count = ?, status = ?, updated_at_ms = ? + WHERE app_code = ? AND packet_id = ?`, + newRemainingAmount, newRemainingCount, newStatus, nowMS, appcode.FromContext(ctx), command.PacketID, + ); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS), + redPacketClaimedEvent(transactionID, command.CommandID, metadata, nowMS), + }); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + packet.RemainingAmount = newRemainingAmount + packet.RemainingCount = newRemainingCount + packet.Status = newStatus + packet.UpdatedAtMS = nowMS + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: balanceAfter}, nil +} + +func (r *Repository) insertRedPacketClaim(ctx context.Context, tx *sql.Tx, metadata redPacketClaimMetadata) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_claims ( + app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, + status, failure_reason, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`, + appcode.FromContext(ctx), metadata.ClaimID, metadata.CommandID, metadata.PacketID, metadata.UserID, + metadata.Amount, metadata.WalletTransaction, ledger.RedPacketClaimStatusClaimed, metadata.ClaimedAtMS, metadata.ClaimedAtMS, + ) + return err +} + +func (r *Repository) getUserRedPacketClaim(ctx context.Context, tx *sql.Tx, packetID string, userID int64) (ledger.RedPacketClaim, bool, error) { + var claim ledger.RedPacketClaim + err := tx.QueryRowContext(ctx, ` + SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, + status, failure_reason, created_at_ms, updated_at_ms + FROM red_packet_claims + WHERE app_code = ? AND packet_id = ? AND user_id = ? + LIMIT 1`, + appcode.FromContext(ctx), packetID, userID, + ).Scan( + &claim.AppCode, + &claim.ClaimID, + &claim.CommandID, + &claim.PacketID, + &claim.UserID, + &claim.Amount, + &claim.WalletTransactionID, + &claim.Status, + &claim.FailureReason, + &claim.CreatedAtMS, + &claim.UpdatedAtMS, + ) + if errors.Is(err, sql.ErrNoRows) { + return ledger.RedPacketClaim{}, false, nil + } + return claim, err == nil, err +} + +func (r *Repository) lockOneRedPacketAllocation(ctx context.Context, tx *sql.Tx, packetID string) (int32, int64, error) { + row := tx.QueryRowContext(ctx, ` + SELECT allocation_no, amount + FROM red_packet_allocations + WHERE app_code = ? AND packet_id = ? AND status = ? + ORDER BY allocation_no ASC + LIMIT 1 + FOR UPDATE`, + appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed, + ) + var allocationNo int32 + var amount int64 + if err := row.Scan(&allocationNo, &amount); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out") + } + return 0, 0, err + } + return allocationNo, amount, nil +} + +func (r *Repository) redPacketClaimReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketClaimReceipt, error) { + var raw string + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), transactionID, + ).Scan(&raw); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + var metadata redPacketClaimMetadata + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return ledger.RedPacketClaimReceipt{}, err + } + packet, err := r.getRedPacket(ctx, tx, metadata.PacketID) + if err != nil { + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: metadata.BalanceAfter}, nil + } + return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil +} + +func redPacketClaimFromMetadata(metadata redPacketClaimMetadata) ledger.RedPacketClaim { + return ledger.RedPacketClaim{ + AppCode: metadata.AppCode, + ClaimID: metadata.ClaimID, + CommandID: metadata.CommandID, + PacketID: metadata.PacketID, + UserID: metadata.UserID, + Amount: metadata.Amount, + WalletTransactionID: metadata.WalletTransaction, + Status: ledger.RedPacketClaimStatusClaimed, + CreatedAtMS: metadata.ClaimedAtMS, + UpdatedAtMS: metadata.ClaimedAtMS, + } +} + +func redPacketClaimedEvent(transactionID string, commandID string, metadata redPacketClaimMetadata, nowMS int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, eventTypeRedPacketClaimed, metadata.UserID, ledger.AssetCoin), + EventType: eventTypeRedPacketClaimed, + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.Amount, + FrozenDelta: 0, + Payload: metadata, + CreatedAtMS: nowMS, + } +} + +func redPacketClaimRequestHash(command ledger.RedPacketClaimCommand) string { + return stableHash(fmt.Sprintf("red_packet_claim|%s|%s|%d", + appcode.Normalize(command.AppCode), + strings.TrimSpace(command.PacketID), + command.UserID, + )) +} + +func redPacketClaimID(app string, commandID string) string { + return "rpc_" + stableHash(appcode.Normalize(app)+"|"+commandID) +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_config_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_config_repository.go new file mode 100644 index 00000000..36cf4990 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_config_repository.go @@ -0,0 +1,273 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "sort" + "strings" + "time" +) + +// GetRedPacketConfig 返回红包配置;未配置时返回关闭状态和空档位,调用方不需要把 NotFound 当错误处理。 +func (r *Repository) GetRedPacketConfig(ctx context.Context, app string) (ledger.RedPacketConfig, error) { + if r == nil || r.db == nil { + return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, app) + config, err := r.getRedPacketConfig(ctx, r.db) + if err != nil { + return ledger.RedPacketConfig{}, err + } + return config, nil +} + +// UpdateRedPacketConfig 原子替换红包配置和档位;发送路径只读取 active 档位。 +func (r *Repository) UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error) { + if r == nil || r.db == nil { + return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, config.AppCode) + config.AppCode = appcode.FromContext(ctx) + nowMS := time.Now().UTC().UnixMilli() + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.RedPacketConfig{}, err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_configs ( + app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, rule_url, + updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + enabled = VALUES(enabled), + delayed_open_seconds = VALUES(delayed_open_seconds), + expire_seconds = VALUES(expire_seconds), + daily_send_limit = VALUES(daily_send_limit), + rule_url = VALUES(rule_url), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms)`, + config.AppCode, + config.Enabled, + config.DelayedOpenSeconds, + ledger.RedPacketExpireSeconds, + config.DailySendLimit, + strings.TrimSpace(config.RuleURL), + config.UpdatedByAdminID, + nowMS, + nowMS, + ); err != nil { + return ledger.RedPacketConfig{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_count_tiers WHERE app_code = ?`, config.AppCode); err != nil { + return ledger.RedPacketConfig{}, err + } + for index, count := range config.CountTiers { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_count_tiers ( + app_code, packet_count, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, ?, ?)`, + config.AppCode, count, index, nowMS, nowMS, + ); err != nil { + return ledger.RedPacketConfig{}, err + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_amount_tiers WHERE app_code = ?`, config.AppCode); err != nil { + return ledger.RedPacketConfig{}, err + } + for index, amount := range config.AmountTiers { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_amount_tiers ( + app_code, total_amount, status, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, ?, ?)`, + config.AppCode, amount, index, nowMS, nowMS, + ); err != nil { + return ledger.RedPacketConfig{}, err + } + } + if err := tx.Commit(); err != nil { + return ledger.RedPacketConfig{}, err + } + return r.GetRedPacketConfig(ctx, config.AppCode) +} + +func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQuerier) (ledger.RedPacketConfig, error) { + row := querier.QueryRowContext(ctx, ` + SELECT app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, + updated_by_admin_id, COALESCE(rule_url, ''), created_at_ms, updated_at_ms + FROM red_packet_configs + WHERE app_code = ?`, + appcode.FromContext(ctx), + ) + config := ledger.RedPacketConfig{AppCode: appcode.FromContext(ctx), ExpireSeconds: ledger.RedPacketExpireSeconds} + if err := row.Scan( + &config.AppCode, + &config.Enabled, + &config.DelayedOpenSeconds, + &config.ExpireSeconds, + &config.DailySendLimit, + &config.UpdatedByAdminID, + &config.RuleURL, + &config.CreatedAtMS, + &config.UpdatedAtMS, + ); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return ledger.RedPacketConfig{}, err + } + return config, nil + } + config.ExpireSeconds = ledger.RedPacketExpireSeconds + counts, err := r.listRedPacketCountTiers(ctx, querier) + if err != nil { + return ledger.RedPacketConfig{}, err + } + amounts, err := r.listRedPacketAmountTiers(ctx, querier) + if err != nil { + return ledger.RedPacketConfig{}, err + } + config.CountTiers = counts + config.AmountTiers = amounts + return config, nil +} + +func (r *Repository) listRedPacketCountTiers(ctx context.Context, querier redPacketQuerier) ([]int32, error) { + rows, err := querier.QueryContext(ctx, ` + SELECT packet_count + FROM red_packet_count_tiers + WHERE app_code = ? AND status = 'active' + ORDER BY sort_order ASC, packet_count ASC`, + appcode.FromContext(ctx), + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]int32, 0) + for rows.Next() { + var value int32 + if err := rows.Scan(&value); err != nil { + return nil, err + } + items = append(items, value) + } + return items, rows.Err() +} + +func (r *Repository) listRedPacketAmountTiers(ctx context.Context, querier redPacketQuerier) ([]int64, error) { + rows, err := querier.QueryContext(ctx, ` + SELECT total_amount + FROM red_packet_amount_tiers + WHERE app_code = ? AND status = 'active' + ORDER BY sort_order ASC, total_amount ASC`, + appcode.FromContext(ctx), + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]int64, 0) + for rows.Next() { + var value int64 + if err := rows.Scan(&value); err != nil { + return nil, err + } + items = append(items, value) + } + return items, rows.Err() +} + +func (r *Repository) incrementRedPacketDailyCounter(ctx context.Context, tx *sql.Tx, userID int64, limit int32, nowMS int64) error { + if limit <= 0 { + return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached") + } + day := time.UnixMilli(nowMS).UTC().Format("2006-01-02") + var current int32 + err := tx.QueryRowContext(ctx, ` + SELECT sent_count + FROM red_packet_user_daily_counters + WHERE app_code = ? AND user_id = ? AND send_day = ? + FOR UPDATE`, + appcode.FromContext(ctx), userID, day, + ).Scan(¤t) + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.ExecContext(ctx, ` + INSERT INTO red_packet_user_daily_counters ( + app_code, user_id, send_day, sent_count, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 1, ?, ?)`, + appcode.FromContext(ctx), userID, day, nowMS, nowMS, + ) + return err + } + if err != nil { + return err + } + if current >= limit { + return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached") + } + _, err = tx.ExecContext(ctx, ` + UPDATE red_packet_user_daily_counters + SET sent_count = sent_count + 1, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND send_day = ?`, + nowMS, appcode.FromContext(ctx), userID, day, + ) + return err +} + +func int64InSet(value int64, values []int64) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} + +func int32InSet(value int32, values []int32) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} + +func normalizeRedPacketConfig(config ledger.RedPacketConfig) ledger.RedPacketConfig { + config.AppCode = appcode.Normalize(config.AppCode) + config.CountTiers = normalizeRedPacketCountTiers(config.CountTiers) + config.AmountTiers = normalizeRedPacketAmountTiers(config.AmountTiers) + return config +} + +func normalizeRedPacketCountTiers(values []int32) []int32 { + seen := make(map[int32]bool, len(values)) + out := make([]int32, 0, len(values)) + for _, value := range values { + if value <= 0 || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func normalizeRedPacketAmountTiers(values []int64) []int64 { + seen := make(map[int64]bool, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_constants.go b/services/wallet-service/internal/storage/mysql/red_packet_constants.go new file mode 100644 index 00000000..dc1f708a --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_constants.go @@ -0,0 +1,83 @@ +package mysql + +import ( + "context" + "database/sql" +) + +const ( + bizTypeRedPacketCreate = "red_packet_create" + bizTypeRedPacketClaim = "red_packet_claim" + bizTypeRedPacketRefund = "red_packet_refund" + + eventTypeRedPacketCreated = "WalletRedPacketCreated" + eventTypeRedPacketClaimed = "WalletRedPacketClaimed" + eventTypeRedPacketRefunded = "WalletRedPacketRefunded" + + redPacketAllocationUnclaimed = "unclaimed" + redPacketAllocationClaimed = "claimed" + redPacketAllocationExpired = "expired" +) + +type redPacketMetadata struct { + AppCode string `json:"app_code"` + PacketID string `json:"packet_id"` + CommandID string `json:"command_id"` + SenderUserID int64 `json:"sender_user_id"` + RoomID string `json:"room_id"` + RegionID int64 `json:"region_id"` + PacketType string `json:"packet_type"` + TotalAmount int64 `json:"total_amount"` + PacketCount int32 `json:"packet_count"` + RemainingAmount int64 `json:"remaining_amount"` + RemainingCount int32 `json:"remaining_count"` + Status string `json:"status"` + OpenAtMS int64 `json:"open_at_ms"` + ExpiresAtMS int64 `json:"expires_at_ms"` + BalanceAfter int64 `json:"balance_after"` + WalletTransaction string `json:"wallet_transaction_id"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type redPacketClaimMetadata struct { + AppCode string `json:"app_code"` + ClaimID string `json:"claim_id"` + CommandID string `json:"command_id"` + PacketID string `json:"packet_id"` + UserID int64 `json:"user_id"` + Amount int64 `json:"amount"` + RoomID string `json:"room_id"` + RegionID int64 `json:"region_id"` + PacketType string `json:"packet_type"` + RemainingAmount int64 `json:"remaining_amount"` + RemainingCount int32 `json:"remaining_count"` + PacketStatus string `json:"packet_status"` + BalanceAfter int64 `json:"balance_after"` + WalletTransaction string `json:"wallet_transaction_id"` + ClaimedAtMS int64 `json:"claimed_at_ms"` +} + +type redPacketRefundMetadata struct { + AppCode string `json:"app_code"` + RefundID string `json:"refund_id"` + CommandID string `json:"command_id"` + PacketID string `json:"packet_id"` + SenderUserID int64 `json:"sender_user_id"` + RoomID string `json:"room_id"` + RegionID int64 `json:"region_id"` + PacketType string `json:"packet_type"` + RefundAmount int64 `json:"refund_amount"` + PacketStatus string `json:"packet_status"` + BalanceAfter int64 `json:"balance_after"` + WalletTransaction string `json:"wallet_transaction_id"` + RefundedAtMS int64 `json:"refunded_at_ms"` +} + +type redPacketQuerier interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +type redPacketScanner interface { + Scan(dest ...any) error +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go new file mode 100644 index 00000000..f42c8197 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go @@ -0,0 +1,315 @@ +package mysql + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "math/big" + "strings" + "time" +) + +// CreateRedPacket 在同一事务内校验配置、扣 sender 金币、写随机份额和钱包 outbox。 +func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error) { + if r == nil || r.db == nil { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := redPacketCreateRequestHash(command) + if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketCreate, xerr.RequestConflict); err != nil || exists { + if err != nil || !exists { + return ledger.RedPacketCreateReceipt{}, err + } + return r.redPacketCreateReceiptForTransaction(ctx, tx, txRow.TransactionID) + } + + nowMS := time.Now().UTC().UnixMilli() + config, err := r.getRedPacketConfig(ctx, tx) + if err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if !config.Enabled { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketDisabled, "red packet is disabled") + } + if !int64InSet(command.TotalAmount, config.AmountTiers) { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidAmountTier, "red packet amount tier is invalid") + } + if !int32InSet(command.PacketCount, config.CountTiers) { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidCountTier, "red packet count tier is invalid") + } + if command.TotalAmount < int64(command.PacketCount) { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small") + } + if ledger.RedPacketExpireSeconds <= 0 || (command.PacketType == ledger.RedPacketTypeDelayed && ledger.RedPacketExpireSeconds <= config.DelayedOpenSeconds) { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InvalidArgument, "red packet config time range is invalid") + } + if err := r.incrementRedPacketDailyCounter(ctx, tx, command.SenderUserID, config.DailySendLimit, nowMS); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + + sender, err := r.lockAccount(ctx, tx, command.SenderUserID, ledger.AssetCoin, false, nowMS) + if err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if sender.AvailableAmount < command.TotalAmount { + return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + allocations, err := splitRedPacketAmounts(command.TotalAmount, command.PacketCount) + if err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + + packetID := redPacketID(command.AppCode, command.CommandID) + transactionID := transactionID(command.AppCode, command.CommandID) + openAtMS := nowMS + status := ledger.RedPacketStatusActive + if command.PacketType == ledger.RedPacketTypeDelayed { + openAtMS = nowMS + int64(config.DelayedOpenSeconds)*1000 + status = ledger.RedPacketStatusWaitingOpen + } + expiresAtMS := nowMS + int64(ledger.RedPacketExpireSeconds)*1000 + balanceAfter := sender.AvailableAmount - command.TotalAmount + metadata := redPacketMetadata{ + AppCode: command.AppCode, + PacketID: packetID, + CommandID: command.CommandID, + SenderUserID: command.SenderUserID, + RoomID: command.RoomID, + RegionID: command.RegionID, + PacketType: command.PacketType, + TotalAmount: command.TotalAmount, + PacketCount: command.PacketCount, + RemainingAmount: command.TotalAmount, + RemainingCount: command.PacketCount, + Status: status, + OpenAtMS: openAtMS, + ExpiresAtMS: expiresAtMS, + BalanceAfter: balanceAfter, + WalletTransaction: transactionID, + CreatedAtMS: nowMS, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketCreate, requestHash, packetID, metadata, nowMS); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, sender, -command.TotalAmount, 0, nowMS); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.SenderUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -command.TotalAmount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: sender.FrozenAmount, + RoomID: command.RoomID, + CreatedAtMS: nowMS, + }); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := r.insertRedPacket(ctx, tx, metadata); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := r.insertRedPacketAllocations(ctx, tx, packetID, allocations, nowMS); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -command.TotalAmount, 0, balanceAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMS), + redPacketCreatedEvent(transactionID, command.CommandID, metadata, nowMS), + }); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + return ledger.RedPacketCreateReceipt{Packet: redPacketFromMetadata(metadata), BalanceAfter: balanceAfter}, nil +} + +func (r *Repository) insertRedPacket(ctx context.Context, tx *sql.Tx, metadata redPacketMetadata) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packets ( + app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, + total_amount, packet_count, remaining_amount, remaining_count, status, + open_at_ms, expires_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), metadata.PacketID, metadata.CommandID, metadata.SenderUserID, metadata.RoomID, metadata.RegionID, + metadata.PacketType, metadata.TotalAmount, metadata.PacketCount, metadata.RemainingAmount, metadata.RemainingCount, + metadata.Status, metadata.OpenAtMS, metadata.ExpiresAtMS, metadata.CreatedAtMS, metadata.CreatedAtMS, + ) + return err +} + +func (r *Repository) insertRedPacketAllocations(ctx context.Context, tx *sql.Tx, packetID string, allocations []int64, nowMS int64) error { + for index, amount := range allocations { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_allocations ( + app_code, packet_id, allocation_no, amount, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), packetID, index+1, amount, redPacketAllocationUnclaimed, nowMS, nowMS, + ); err != nil { + return err + } + } + return nil +} + +func (r *Repository) redPacketCreateReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketCreateReceipt, error) { + var raw string + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ?`, + appcode.FromContext(ctx), transactionID, + ).Scan(&raw); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + var metadata redPacketMetadata + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return ledger.RedPacketCreateReceipt{}, err + } + packet, err := r.getRedPacket(ctx, tx, metadata.PacketID) + if err != nil { + packet = redPacketFromMetadata(metadata) + } + return ledger.RedPacketCreateReceipt{Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil +} + +func redPacketFromMetadata(metadata redPacketMetadata) ledger.RedPacket { + return ledger.RedPacket{ + AppCode: metadata.AppCode, + PacketID: metadata.PacketID, + CommandID: metadata.CommandID, + SenderUserID: metadata.SenderUserID, + RoomID: metadata.RoomID, + RegionID: metadata.RegionID, + PacketType: metadata.PacketType, + TotalAmount: metadata.TotalAmount, + PacketCount: metadata.PacketCount, + RemainingAmount: metadata.RemainingAmount, + RemainingCount: metadata.RemainingCount, + Status: metadata.Status, + OpenAtMS: metadata.OpenAtMS, + ExpiresAtMS: metadata.ExpiresAtMS, + CreatedAtMS: metadata.CreatedAtMS, + UpdatedAtMS: metadata.CreatedAtMS, + } +} + +func redPacketCreatedEvent(transactionID string, commandID string, metadata redPacketMetadata, nowMS int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, eventTypeRedPacketCreated, metadata.SenderUserID, ledger.AssetCoin), + EventType: eventTypeRedPacketCreated, + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.SenderUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -metadata.TotalAmount, + FrozenDelta: 0, + Payload: map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "packet_id": metadata.PacketID, + "packet_type": metadata.PacketType, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "region_id": metadata.RegionID, + "total_amount": metadata.TotalAmount, + "packet_count": metadata.PacketCount, + "remaining_amount": metadata.RemainingAmount, + "remaining_count": metadata.RemainingCount, + "status": metadata.Status, + "open_at_ms": metadata.OpenAtMS, + "expires_at_ms": metadata.ExpiresAtMS, + "created_at_ms": metadata.CreatedAtMS, + }, + CreatedAtMS: nowMS, + } +} + +func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string { + return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d", + appcode.Normalize(command.AppCode), + command.CommandID, + command.SenderUserID, + strings.TrimSpace(command.RoomID), + command.RegionID, + command.PacketType, + command.TotalAmount, + command.PacketCount, + )) +} + +func redPacketID(app string, commandID string) string { + return "rp_" + stableHash(appcode.Normalize(app)+"|"+commandID) +} + +func splitRedPacketAmounts(total int64, count int32) ([]int64, error) { + if total < int64(count) || count <= 0 { + return nil, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small") + } + remainingAmount := total + remainingCount := int64(count) + amounts := make([]int64, 0, count) + for remainingCount > 0 { + if remainingCount == 1 { + amounts = append(amounts, remainingAmount) + break + } + maxAmount := remainingAmount - (remainingCount - 1) + avg := remainingAmount / remainingCount + drawMax := avg * 2 + if drawMax < 1 { + drawMax = 1 + } + if drawMax > maxAmount { + drawMax = maxAmount + } + drawn, err := secureRandomInt64(1, drawMax) + if err != nil { + return nil, err + } + amounts = append(amounts, drawn) + remainingAmount -= drawn + remainingCount-- + } + if err := shuffleInt64(amounts); err != nil { + return nil, err + } + return amounts, nil +} + +func secureRandomInt64(min int64, max int64) (int64, error) { + if min > max { + return 0, xerr.New(xerr.InvalidArgument, "random range is invalid") + } + span := big.NewInt(max - min + 1) + value, err := rand.Int(rand.Reader, span) + if err != nil { + return 0, err + } + return min + value.Int64(), nil +} + +func shuffleInt64(values []int64) error { + for i := len(values) - 1; i > 0; i-- { + j, err := secureRandomInt64(0, int64(i)) + if err != nil { + return err + } + values[i], values[int(j)] = values[int(j)], values[i] + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_query_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_query_repository.go new file mode 100644 index 00000000..62e6c293 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_query_repository.go @@ -0,0 +1,312 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +func (r *Repository) ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + if query.Page <= 0 { + query.Page = 1 + } + if query.PageSize <= 0 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + where, args := redPacketWhereSQL(query) + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM red_packets `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + args = append(args, int(query.PageSize), int((query.Page-1)*query.PageSize)) + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, + total_amount, packet_count, remaining_amount, remaining_count, status, + open_at_ms, expires_at_ms, created_at_ms, updated_at_ms + FROM red_packets `+where+` + ORDER BY created_at_ms DESC, packet_id DESC + LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + packets := make([]ledger.RedPacket, 0, query.PageSize) + for rows.Next() { + packet, err := scanRedPacket(rows) + if err != nil { + return nil, 0, err + } + packets = append(packets, packet) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + if err := r.attachRefunds(ctx, packets); err != nil { + return nil, 0, err + } + if query.ViewerUserID > 0 { + if err := r.attachViewerClaims(ctx, packets, query.ViewerUserID); err != nil { + return nil, 0, err + } + } + return packets, total, nil +} + +func (r *Repository) GetRedPacket(ctx context.Context, app string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) { + if r == nil || r.db == nil { + return ledger.RedPacket{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, app) + packet, err := r.getRedPacket(ctx, r.db, packetID) + if err != nil { + return ledger.RedPacket{}, err + } + if viewerUserID > 0 { + packets := []ledger.RedPacket{packet} + if err := r.attachViewerClaims(ctx, packets, viewerUserID); err != nil { + return ledger.RedPacket{}, err + } + if err := r.attachRefunds(ctx, packets); err != nil { + return ledger.RedPacket{}, err + } + packet = packets[0] + } else { + packets := []ledger.RedPacket{packet} + if err := r.attachRefunds(ctx, packets); err != nil { + return ledger.RedPacket{}, err + } + packet = packets[0] + } + if includeClaims { + claims, err := r.listRedPacketClaims(ctx, packetID) + if err != nil { + return ledger.RedPacket{}, err + } + packet.Claims = claims + } + return packet, nil +} + +func (r *Repository) getRedPacketForUpdate(ctx context.Context, tx *sql.Tx, packetID string) (ledger.RedPacket, error) { + row := tx.QueryRowContext(ctx, redPacketSelectSQL()+` + WHERE app_code = ? AND packet_id = ? + FOR UPDATE`, + appcode.FromContext(ctx), packetID, + ) + packet, err := scanRedPacket(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found") + } + return ledger.RedPacket{}, err + } + return packet, nil +} + +func (r *Repository) getRedPacket(ctx context.Context, querier sqlRowQuerier, packetID string) (ledger.RedPacket, error) { + row := querier.QueryRowContext(ctx, redPacketSelectSQL()+`WHERE app_code = ? AND packet_id = ?`, appcode.FromContext(ctx), packetID) + packet, err := scanRedPacket(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found") + } + return ledger.RedPacket{}, err + } + return packet, nil +} + +func redPacketSelectSQL() string { + return ` + SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, + total_amount, packet_count, remaining_amount, remaining_count, status, + open_at_ms, expires_at_ms, created_at_ms, updated_at_ms + FROM red_packets ` +} + +func scanRedPacket(row redPacketScanner) (ledger.RedPacket, error) { + var packet ledger.RedPacket + err := row.Scan( + &packet.AppCode, + &packet.PacketID, + &packet.CommandID, + &packet.SenderUserID, + &packet.RoomID, + &packet.RegionID, + &packet.PacketType, + &packet.TotalAmount, + &packet.PacketCount, + &packet.RemainingAmount, + &packet.RemainingCount, + &packet.Status, + &packet.OpenAtMS, + &packet.ExpiresAtMS, + &packet.CreatedAtMS, + &packet.UpdatedAtMS, + ) + return packet, err +} + +func (r *Repository) attachViewerClaims(ctx context.Context, packets []ledger.RedPacket, viewerUserID int64) error { + if len(packets) == 0 || viewerUserID <= 0 { + return nil + } + packetIDs := make([]string, 0, len(packets)) + indexByID := make(map[string]int, len(packets)) + for index := range packets { + packetIDs = append(packetIDs, packets[index].PacketID) + indexByID[packets[index].PacketID] = index + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",") + args := []any{appcode.FromContext(ctx), viewerUserID} + for _, packetID := range packetIDs { + args = append(args, packetID) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT packet_id, amount + FROM red_packet_claims + WHERE app_code = ? AND user_id = ? AND packet_id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var packetID string + var amount int64 + if err := rows.Scan(&packetID, &amount); err != nil { + return err + } + if index, ok := indexByID[packetID]; ok { + packets[index].ClaimedByViewer = true + packets[index].ViewerClaimAmount = amount + } + } + return rows.Err() +} + +func (r *Repository) attachRefunds(ctx context.Context, packets []ledger.RedPacket) error { + if len(packets) == 0 { + return nil + } + packetIDs := make([]string, 0, len(packets)) + indexByID := make(map[string]int, len(packets)) + for index := range packets { + packetIDs = append(packetIDs, packets[index].PacketID) + indexByID[packets[index].PacketID] = index + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",") + args := []any{appcode.FromContext(ctx)} + for _, packetID := range packetIDs { + args = append(args, packetID) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT packet_id, refund_amount, status, updated_at_ms + FROM red_packet_refunds + WHERE app_code = ? AND packet_id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var packetID string + var amount int64 + var status string + var updatedAtMS int64 + if err := rows.Scan(&packetID, &amount, &status, &updatedAtMS); err != nil { + return err + } + if index, ok := indexByID[packetID]; ok { + packets[index].RefundAmount = amount + packets[index].RefundStatus = status + if status == ledger.RedPacketStatusRefunded { + packets[index].RefundedAtMS = updatedAtMS + } + } + } + return rows.Err() +} + +func (r *Repository) listRedPacketClaims(ctx context.Context, packetID string) ([]ledger.RedPacketClaim, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, + status, failure_reason, created_at_ms, updated_at_ms + FROM red_packet_claims + WHERE app_code = ? AND packet_id = ? + ORDER BY created_at_ms ASC, claim_id ASC`, + appcode.FromContext(ctx), packetID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + claims := make([]ledger.RedPacketClaim, 0) + for rows.Next() { + var claim ledger.RedPacketClaim + if err := rows.Scan( + &claim.AppCode, + &claim.ClaimID, + &claim.CommandID, + &claim.PacketID, + &claim.UserID, + &claim.Amount, + &claim.WalletTransactionID, + &claim.Status, + &claim.FailureReason, + &claim.CreatedAtMS, + &claim.UpdatedAtMS, + ); err != nil { + return nil, err + } + claims = append(claims, claim) + } + return claims, rows.Err() +} + +func redPacketWhereSQL(query ledger.RedPacketQuery) (string, []any) { + where := "WHERE app_code = ?" + args := []any{appcode.Normalize(query.AppCode)} + if strings.TrimSpace(query.RoomID) != "" { + where += " AND room_id = ?" + args = append(args, strings.TrimSpace(query.RoomID)) + } + if query.SenderUserID > 0 { + where += " AND sender_user_id = ?" + args = append(args, query.SenderUserID) + } + if query.RegionID > 0 { + where += " AND region_id = ?" + args = append(args, query.RegionID) + } + if query.PacketType != "" { + where += " AND packet_type = ?" + args = append(args, query.PacketType) + } + if query.Status != "" { + where += " AND status = ?" + args = append(args, query.Status) + } + if query.StartAtMS > 0 { + where += " AND created_at_ms >= ?" + args = append(args, query.StartAtMS) + } + if query.EndAtMS > 0 { + where += " AND created_at_ms < ?" + args = append(args, query.EndAtMS) + } + return where, args +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_refund_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_refund_repository.go new file mode 100644 index 00000000..10234504 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/red_packet_refund_repository.go @@ -0,0 +1,212 @@ +package mysql + +import ( + "context" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize int32) (ledger.RedPacketExpireResult, error) { + if r == nil || r.db == nil { + return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, app) + if batchSize <= 0 { + batchSize = 50 + } + if batchSize > 200 { + batchSize = 200 + } + nowMS := time.Now().UTC().UnixMilli() + rows, err := r.db.QueryContext(ctx, ` + SELECT packet_id + FROM red_packets + WHERE app_code = ? AND status IN (?, ?) AND expires_at_ms <= ? + ORDER BY expires_at_ms ASC, packet_id ASC + LIMIT ?`, + appcode.FromContext(ctx), ledger.RedPacketStatusActive, ledger.RedPacketStatusWaitingOpen, nowMS, batchSize, + ) + if err != nil { + return ledger.RedPacketExpireResult{}, err + } + packetIDs := make([]string, 0, batchSize) + for rows.Next() { + var packetID string + if err := rows.Scan(&packetID); err != nil { + _ = rows.Close() + return ledger.RedPacketExpireResult{}, err + } + packetIDs = append(packetIDs, packetID) + } + if err := rows.Close(); err != nil { + return ledger.RedPacketExpireResult{}, err + } + var result ledger.RedPacketExpireResult + for _, packetID := range packetIDs { + refunded, amount, err := r.expireOneRedPacket(ctx, packetID, nowMS) + if err != nil { + return result, err + } + if refunded { + result.ExpiredCount++ + result.RefundedAmount += amount + } + } + return result, nil +} + +func (r *Repository) RetryRedPacketRefund(ctx context.Context, app string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) { + if r == nil || r.db == nil { + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + _ = operatorUserID + ctx = contextWithCommandApp(ctx, app) + nowMS := time.Now().UTC().UnixMilli() + refunded, amount, err := r.expireOneRedPacket(ctx, strings.TrimSpace(packetID), nowMS) + if err != nil { + return ledger.RedPacketRefundReceipt{}, err + } + packet, err := r.GetRedPacket(ctx, appcode.FromContext(ctx), packetID, 0, false) + if err != nil { + return ledger.RedPacketRefundReceipt{}, err + } + if !refunded { + if packet.Status == ledger.RedPacketStatusRefunded { + return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: packet.RefundAmount}, nil + } + return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is not expired") + } + return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: amount}, nil +} + +func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, nowMS int64) (bool, int64, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, 0, err + } + defer func() { _ = tx.Rollback() }() + + packet, err := r.getRedPacketForUpdate(ctx, tx, packetID) + if err != nil { + return false, 0, err + } + if packet.Status != ledger.RedPacketStatusActive && packet.Status != ledger.RedPacketStatusWaitingOpen { + return false, 0, nil + } + if packet.ExpiresAtMS > nowMS { + return false, 0, nil + } + if packet.RemainingAmount <= 0 || packet.RemainingCount <= 0 { + if _, err := tx.ExecContext(ctx, ` + UPDATE red_packets + SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ? + WHERE app_code = ? AND packet_id = ?`, + ledger.RedPacketStatusFinished, nowMS, appcode.FromContext(ctx), packetID, + ); err != nil { + return false, 0, err + } + return true, 0, tx.Commit() + } + refundAmount := packet.RemainingAmount + commandID := "red_packet_refund:" + packetID + requestHash := stableHash(fmt.Sprintf("red_packet_refund|%s|%s|%d|%d", appcode.FromContext(ctx), packetID, packet.SenderUserID, refundAmount)) + if _, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizTypeRedPacketRefund, xerr.RequestConflict); err != nil || exists { + return false, 0, err + } + account, err := r.lockAccount(ctx, tx, packet.SenderUserID, ledger.AssetCoin, true, nowMS) + if err != nil { + return false, 0, err + } + transactionID := transactionID(appcode.FromContext(ctx), commandID) + refundID := redPacketRefundID(appcode.FromContext(ctx), packetID) + balanceAfter := account.AvailableAmount + refundAmount + metadata := redPacketRefundMetadata{ + AppCode: appcode.FromContext(ctx), + RefundID: refundID, + CommandID: commandID, + PacketID: packetID, + SenderUserID: packet.SenderUserID, + RoomID: packet.RoomID, + RegionID: packet.RegionID, + PacketType: packet.PacketType, + RefundAmount: refundAmount, + PacketStatus: ledger.RedPacketStatusRefunded, + BalanceAfter: balanceAfter, + WalletTransaction: transactionID, + RefundedAtMS: nowMS, + } + if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeRedPacketRefund, requestHash, packetID, metadata, nowMS); err != nil { + return false, 0, err + } + if err := r.applyAccountDelta(ctx, tx, account, refundAmount, 0, nowMS); err != nil { + return false, 0, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: packet.SenderUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: refundAmount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + RoomID: packet.RoomID, + CreatedAtMS: nowMS, + }); err != nil { + return false, 0, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO red_packet_refunds ( + app_code, refund_id, command_id, packet_id, sender_user_id, refund_amount, + wallet_transaction_id, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'refunded', ?, ?)`, + appcode.FromContext(ctx), refundID, commandID, packetID, packet.SenderUserID, refundAmount, transactionID, nowMS, nowMS, + ); err != nil { + return false, 0, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE red_packet_allocations + SET status = ?, updated_at_ms = ? + WHERE app_code = ? AND packet_id = ? AND status = ?`, + redPacketAllocationExpired, nowMS, appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed, + ); err != nil { + return false, 0, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE red_packets + SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ? + WHERE app_code = ? AND packet_id = ?`, + ledger.RedPacketStatusRefunded, nowMS, appcode.FromContext(ctx), packetID, + ); err != nil { + return false, 0, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, commandID, packet.SenderUserID, ledger.AssetCoin, refundAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS), + redPacketRefundedEvent(transactionID, commandID, metadata, nowMS), + }); err != nil { + return false, 0, err + } + return true, refundAmount, tx.Commit() +} + +func redPacketRefundedEvent(transactionID string, commandID string, metadata redPacketRefundMetadata, nowMS int64) walletOutboxEvent { + return walletOutboxEvent{ + EventID: eventID(transactionID, eventTypeRedPacketRefunded, metadata.SenderUserID, ledger.AssetCoin), + EventType: eventTypeRedPacketRefunded, + TransactionID: transactionID, + CommandID: commandID, + UserID: metadata.SenderUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: metadata.RefundAmount, + FrozenDelta: 0, + Payload: metadata, + CreatedAtMS: nowMS, + } +} + +func redPacketRefundID(app string, packetID string) string { + return "rpr_" + stableHash(appcode.Normalize(app)+"|"+packetID) +} diff --git a/services/wallet-service/internal/storage/mysql/red_packet_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_repository.go deleted file mode 100644 index 1ef2eec7..00000000 --- a/services/wallet-service/internal/storage/mysql/red_packet_repository.go +++ /dev/null @@ -1,1426 +0,0 @@ -package mysql - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math/big" - "sort" - "strings" - "time" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -const ( - bizTypeRedPacketCreate = "red_packet_create" - bizTypeRedPacketClaim = "red_packet_claim" - bizTypeRedPacketRefund = "red_packet_refund" - - eventTypeRedPacketCreated = "WalletRedPacketCreated" - eventTypeRedPacketClaimed = "WalletRedPacketClaimed" - eventTypeRedPacketRefunded = "WalletRedPacketRefunded" - - redPacketAllocationUnclaimed = "unclaimed" - redPacketAllocationClaimed = "claimed" - redPacketAllocationExpired = "expired" -) - -type redPacketMetadata struct { - AppCode string `json:"app_code"` - PacketID string `json:"packet_id"` - CommandID string `json:"command_id"` - SenderUserID int64 `json:"sender_user_id"` - RoomID string `json:"room_id"` - RegionID int64 `json:"region_id"` - PacketType string `json:"packet_type"` - TotalAmount int64 `json:"total_amount"` - PacketCount int32 `json:"packet_count"` - RemainingAmount int64 `json:"remaining_amount"` - RemainingCount int32 `json:"remaining_count"` - Status string `json:"status"` - OpenAtMS int64 `json:"open_at_ms"` - ExpiresAtMS int64 `json:"expires_at_ms"` - BalanceAfter int64 `json:"balance_after"` - WalletTransaction string `json:"wallet_transaction_id"` - CreatedAtMS int64 `json:"created_at_ms"` -} - -type redPacketClaimMetadata struct { - AppCode string `json:"app_code"` - ClaimID string `json:"claim_id"` - CommandID string `json:"command_id"` - PacketID string `json:"packet_id"` - UserID int64 `json:"user_id"` - Amount int64 `json:"amount"` - RoomID string `json:"room_id"` - RegionID int64 `json:"region_id"` - PacketType string `json:"packet_type"` - RemainingAmount int64 `json:"remaining_amount"` - RemainingCount int32 `json:"remaining_count"` - PacketStatus string `json:"packet_status"` - BalanceAfter int64 `json:"balance_after"` - WalletTransaction string `json:"wallet_transaction_id"` - ClaimedAtMS int64 `json:"claimed_at_ms"` -} - -type redPacketRefundMetadata struct { - AppCode string `json:"app_code"` - RefundID string `json:"refund_id"` - CommandID string `json:"command_id"` - PacketID string `json:"packet_id"` - SenderUserID int64 `json:"sender_user_id"` - RoomID string `json:"room_id"` - RegionID int64 `json:"region_id"` - PacketType string `json:"packet_type"` - RefundAmount int64 `json:"refund_amount"` - PacketStatus string `json:"packet_status"` - BalanceAfter int64 `json:"balance_after"` - WalletTransaction string `json:"wallet_transaction_id"` - RefundedAtMS int64 `json:"refunded_at_ms"` -} - -type redPacketQuerier interface { - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - -// GetRedPacketConfig 返回红包配置;未配置时返回关闭状态和空档位,调用方不需要把 NotFound 当错误处理。 -func (r *Repository) GetRedPacketConfig(ctx context.Context, app string) (ledger.RedPacketConfig, error) { - if r == nil || r.db == nil { - return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, app) - config, err := r.getRedPacketConfig(ctx, r.db) - if err != nil { - return ledger.RedPacketConfig{}, err - } - return config, nil -} - -// UpdateRedPacketConfig 原子替换红包配置和档位;发送路径只读取 active 档位。 -func (r *Repository) UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error) { - if r == nil || r.db == nil { - return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, config.AppCode) - config.AppCode = appcode.FromContext(ctx) - nowMS := time.Now().UTC().UnixMilli() - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.RedPacketConfig{}, err - } - defer func() { _ = tx.Rollback() }() - - if _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_configs ( - app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, rule_url, - updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - enabled = VALUES(enabled), - delayed_open_seconds = VALUES(delayed_open_seconds), - expire_seconds = VALUES(expire_seconds), - daily_send_limit = VALUES(daily_send_limit), - rule_url = VALUES(rule_url), - updated_by_admin_id = VALUES(updated_by_admin_id), - updated_at_ms = VALUES(updated_at_ms)`, - config.AppCode, - config.Enabled, - config.DelayedOpenSeconds, - ledger.RedPacketExpireSeconds, - config.DailySendLimit, - strings.TrimSpace(config.RuleURL), - config.UpdatedByAdminID, - nowMS, - nowMS, - ); err != nil { - return ledger.RedPacketConfig{}, err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_count_tiers WHERE app_code = ?`, config.AppCode); err != nil { - return ledger.RedPacketConfig{}, err - } - for index, count := range config.CountTiers { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_count_tiers ( - app_code, packet_count, status, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, 'active', ?, ?, ?)`, - config.AppCode, count, index, nowMS, nowMS, - ); err != nil { - return ledger.RedPacketConfig{}, err - } - } - if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_amount_tiers WHERE app_code = ?`, config.AppCode); err != nil { - return ledger.RedPacketConfig{}, err - } - for index, amount := range config.AmountTiers { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_amount_tiers ( - app_code, total_amount, status, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, 'active', ?, ?, ?)`, - config.AppCode, amount, index, nowMS, nowMS, - ); err != nil { - return ledger.RedPacketConfig{}, err - } - } - if err := tx.Commit(); err != nil { - return ledger.RedPacketConfig{}, err - } - return r.GetRedPacketConfig(ctx, config.AppCode) -} - -// CreateRedPacket 在同一事务内校验配置、扣 sender 金币、写随机份额和钱包 outbox。 -func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error) { - if r == nil || r.db == nil { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := redPacketCreateRequestHash(command) - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketCreate, xerr.RequestConflict); err != nil || exists { - if err != nil || !exists { - return ledger.RedPacketCreateReceipt{}, err - } - return r.redPacketCreateReceiptForTransaction(ctx, tx, txRow.TransactionID) - } - - nowMS := time.Now().UTC().UnixMilli() - config, err := r.getRedPacketConfig(ctx, tx) - if err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if !config.Enabled { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketDisabled, "red packet is disabled") - } - if !int64InSet(command.TotalAmount, config.AmountTiers) { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidAmountTier, "red packet amount tier is invalid") - } - if !int32InSet(command.PacketCount, config.CountTiers) { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidCountTier, "red packet count tier is invalid") - } - if command.TotalAmount < int64(command.PacketCount) { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small") - } - if ledger.RedPacketExpireSeconds <= 0 || (command.PacketType == ledger.RedPacketTypeDelayed && ledger.RedPacketExpireSeconds <= config.DelayedOpenSeconds) { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InvalidArgument, "red packet config time range is invalid") - } - if err := r.incrementRedPacketDailyCounter(ctx, tx, command.SenderUserID, config.DailySendLimit, nowMS); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - - sender, err := r.lockAccount(ctx, tx, command.SenderUserID, ledger.AssetCoin, false, nowMS) - if err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if sender.AvailableAmount < command.TotalAmount { - return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - allocations, err := splitRedPacketAmounts(command.TotalAmount, command.PacketCount) - if err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - - packetID := redPacketID(command.AppCode, command.CommandID) - transactionID := transactionID(command.AppCode, command.CommandID) - openAtMS := nowMS - status := ledger.RedPacketStatusActive - if command.PacketType == ledger.RedPacketTypeDelayed { - openAtMS = nowMS + int64(config.DelayedOpenSeconds)*1000 - status = ledger.RedPacketStatusWaitingOpen - } - expiresAtMS := nowMS + int64(ledger.RedPacketExpireSeconds)*1000 - balanceAfter := sender.AvailableAmount - command.TotalAmount - metadata := redPacketMetadata{ - AppCode: command.AppCode, - PacketID: packetID, - CommandID: command.CommandID, - SenderUserID: command.SenderUserID, - RoomID: command.RoomID, - RegionID: command.RegionID, - PacketType: command.PacketType, - TotalAmount: command.TotalAmount, - PacketCount: command.PacketCount, - RemainingAmount: command.TotalAmount, - RemainingCount: command.PacketCount, - Status: status, - OpenAtMS: openAtMS, - ExpiresAtMS: expiresAtMS, - BalanceAfter: balanceAfter, - WalletTransaction: transactionID, - CreatedAtMS: nowMS, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketCreate, requestHash, packetID, metadata, nowMS); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, sender, -command.TotalAmount, 0, nowMS); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SenderUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -command.TotalAmount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: sender.FrozenAmount, - RoomID: command.RoomID, - CreatedAtMS: nowMS, - }); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := r.insertRedPacket(ctx, tx, metadata); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := r.insertRedPacketAllocations(ctx, tx, packetID, allocations, nowMS); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -command.TotalAmount, 0, balanceAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMS), - redPacketCreatedEvent(transactionID, command.CommandID, metadata, nowMS), - }); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - return ledger.RedPacketCreateReceipt{Packet: redPacketFromMetadata(metadata), BalanceAfter: balanceAfter}, nil -} - -// ClaimRedPacket 领取一个未分配份额,并把金额原子入账到领取者 COIN 余额。 -func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error) { - if r == nil || r.db == nil { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := redPacketClaimRequestHash(command) - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketClaim, xerr.RequestConflict); err != nil || exists { - if err != nil || !exists { - return ledger.RedPacketClaimReceipt{}, err - } - return r.redPacketClaimReceiptForTransaction(ctx, tx, txRow.TransactionID) - } - nowMS := time.Now().UTC().UnixMilli() - packet, err := r.getRedPacketForUpdate(ctx, tx, command.PacketID) - if err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if packet.Status == ledger.RedPacketStatusFinished || packet.RemainingCount <= 0 || packet.RemainingAmount <= 0 { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out") - } - if packet.Status == ledger.RedPacketStatusRefunded || nowMS >= packet.ExpiresAtMS { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is expired") - } - if nowMS < packet.OpenAtMS { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketNotOpen, "red packet is not open") - } - if existingClaim, claimed, err := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID); err != nil || claimed { - if err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - return ledger.RedPacketClaimReceipt{Claim: existingClaim, Packet: packet}, nil - } - allocationNo, amount, err := r.lockOneRedPacketAllocation(ctx, tx, command.PacketID) - if err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if amount <= 0 || amount > packet.RemainingAmount { - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet allocation is invalid") - } - - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMS) - if err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - claimID := redPacketClaimID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + amount - metadata := redPacketClaimMetadata{ - AppCode: command.AppCode, - ClaimID: claimID, - CommandID: command.CommandID, - PacketID: command.PacketID, - UserID: command.UserID, - Amount: amount, - RoomID: packet.RoomID, - RegionID: packet.RegionID, - PacketType: packet.PacketType, - RemainingAmount: packet.RemainingAmount - amount, - RemainingCount: packet.RemainingCount - 1, - BalanceAfter: balanceAfter, - WalletTransaction: transactionID, - ClaimedAtMS: nowMS, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketClaim, requestHash, command.PacketID, metadata, nowMS); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMS); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CounterpartyUserID: packet.SenderUserID, - RoomID: packet.RoomID, - CreatedAtMS: nowMS, - }); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if err := r.insertRedPacketClaim(ctx, tx, metadata); err != nil { - if isMySQLDuplicateError(err) { - existingClaim, claimed, lookupErr := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID) - if lookupErr != nil { - return ledger.RedPacketClaimReceipt{}, lookupErr - } - if claimed { - return r.redPacketClaimReceiptForTransaction(ctx, tx, existingClaim.WalletTransactionID) - } - return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet claim already exists") - } - return ledger.RedPacketClaimReceipt{}, err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE red_packet_allocations - SET status = ?, claimed_by_user_id = ?, claim_id = ?, claimed_at_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND packet_id = ? AND allocation_no = ? AND status = ?`, - redPacketAllocationClaimed, command.UserID, claimID, nowMS, nowMS, - appcode.FromContext(ctx), command.PacketID, allocationNo, redPacketAllocationUnclaimed, - ); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - newRemainingAmount := packet.RemainingAmount - amount - newRemainingCount := packet.RemainingCount - 1 - newStatus := ledger.RedPacketStatusActive - if newRemainingCount == 0 { - newStatus = ledger.RedPacketStatusFinished - } - metadata.PacketStatus = newStatus - if _, err := tx.ExecContext(ctx, ` - UPDATE red_packets - SET remaining_amount = ?, remaining_count = ?, status = ?, updated_at_ms = ? - WHERE app_code = ? AND packet_id = ?`, - newRemainingAmount, newRemainingCount, newStatus, nowMS, appcode.FromContext(ctx), command.PacketID, - ); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS), - redPacketClaimedEvent(transactionID, command.CommandID, metadata, nowMS), - }); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - packet.RemainingAmount = newRemainingAmount - packet.RemainingCount = newRemainingCount - packet.Status = newStatus - packet.UpdatedAtMS = nowMS - return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: balanceAfter}, nil -} - -func (r *Repository) ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - if query.Page <= 0 { - query.Page = 1 - } - if query.PageSize <= 0 { - query.PageSize = 20 - } - if query.PageSize > 100 { - query.PageSize = 100 - } - where, args := redPacketWhereSQL(query) - var total int64 - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM red_packets `+where, args...).Scan(&total); err != nil { - return nil, 0, err - } - args = append(args, int(query.PageSize), int((query.Page-1)*query.PageSize)) - rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, - total_amount, packet_count, remaining_amount, remaining_count, status, - open_at_ms, expires_at_ms, created_at_ms, updated_at_ms - FROM red_packets `+where+` - ORDER BY created_at_ms DESC, packet_id DESC - LIMIT ? OFFSET ?`, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - packets := make([]ledger.RedPacket, 0, query.PageSize) - for rows.Next() { - packet, err := scanRedPacket(rows) - if err != nil { - return nil, 0, err - } - packets = append(packets, packet) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - if err := r.attachRefunds(ctx, packets); err != nil { - return nil, 0, err - } - if query.ViewerUserID > 0 { - if err := r.attachViewerClaims(ctx, packets, query.ViewerUserID); err != nil { - return nil, 0, err - } - } - return packets, total, nil -} - -func (r *Repository) GetRedPacket(ctx context.Context, app string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) { - if r == nil || r.db == nil { - return ledger.RedPacket{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, app) - packet, err := r.getRedPacket(ctx, r.db, packetID) - if err != nil { - return ledger.RedPacket{}, err - } - if viewerUserID > 0 { - packets := []ledger.RedPacket{packet} - if err := r.attachViewerClaims(ctx, packets, viewerUserID); err != nil { - return ledger.RedPacket{}, err - } - if err := r.attachRefunds(ctx, packets); err != nil { - return ledger.RedPacket{}, err - } - packet = packets[0] - } else { - packets := []ledger.RedPacket{packet} - if err := r.attachRefunds(ctx, packets); err != nil { - return ledger.RedPacket{}, err - } - packet = packets[0] - } - if includeClaims { - claims, err := r.listRedPacketClaims(ctx, packetID) - if err != nil { - return ledger.RedPacket{}, err - } - packet.Claims = claims - } - return packet, nil -} - -func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize int32) (ledger.RedPacketExpireResult, error) { - if r == nil || r.db == nil { - return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, app) - if batchSize <= 0 { - batchSize = 50 - } - if batchSize > 200 { - batchSize = 200 - } - nowMS := time.Now().UTC().UnixMilli() - rows, err := r.db.QueryContext(ctx, ` - SELECT packet_id - FROM red_packets - WHERE app_code = ? AND status IN (?, ?) AND expires_at_ms <= ? - ORDER BY expires_at_ms ASC, packet_id ASC - LIMIT ?`, - appcode.FromContext(ctx), ledger.RedPacketStatusActive, ledger.RedPacketStatusWaitingOpen, nowMS, batchSize, - ) - if err != nil { - return ledger.RedPacketExpireResult{}, err - } - packetIDs := make([]string, 0, batchSize) - for rows.Next() { - var packetID string - if err := rows.Scan(&packetID); err != nil { - _ = rows.Close() - return ledger.RedPacketExpireResult{}, err - } - packetIDs = append(packetIDs, packetID) - } - if err := rows.Close(); err != nil { - return ledger.RedPacketExpireResult{}, err - } - var result ledger.RedPacketExpireResult - for _, packetID := range packetIDs { - refunded, amount, err := r.expireOneRedPacket(ctx, packetID, nowMS) - if err != nil { - return result, err - } - if refunded { - result.ExpiredCount++ - result.RefundedAmount += amount - } - } - return result, nil -} - -func (r *Repository) RetryRedPacketRefund(ctx context.Context, app string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) { - if r == nil || r.db == nil { - return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - _ = operatorUserID - ctx = contextWithCommandApp(ctx, app) - nowMS := time.Now().UTC().UnixMilli() - refunded, amount, err := r.expireOneRedPacket(ctx, strings.TrimSpace(packetID), nowMS) - if err != nil { - return ledger.RedPacketRefundReceipt{}, err - } - packet, err := r.GetRedPacket(ctx, appcode.FromContext(ctx), packetID, 0, false) - if err != nil { - return ledger.RedPacketRefundReceipt{}, err - } - if !refunded { - if packet.Status == ledger.RedPacketStatusRefunded { - return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: packet.RefundAmount}, nil - } - return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is not expired") - } - return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: amount}, nil -} - -func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, nowMS int64) (bool, int64, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return false, 0, err - } - defer func() { _ = tx.Rollback() }() - - packet, err := r.getRedPacketForUpdate(ctx, tx, packetID) - if err != nil { - return false, 0, err - } - if packet.Status != ledger.RedPacketStatusActive && packet.Status != ledger.RedPacketStatusWaitingOpen { - return false, 0, nil - } - if packet.ExpiresAtMS > nowMS { - return false, 0, nil - } - if packet.RemainingAmount <= 0 || packet.RemainingCount <= 0 { - if _, err := tx.ExecContext(ctx, ` - UPDATE red_packets - SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ? - WHERE app_code = ? AND packet_id = ?`, - ledger.RedPacketStatusFinished, nowMS, appcode.FromContext(ctx), packetID, - ); err != nil { - return false, 0, err - } - return true, 0, tx.Commit() - } - refundAmount := packet.RemainingAmount - commandID := "red_packet_refund:" + packetID - requestHash := stableHash(fmt.Sprintf("red_packet_refund|%s|%s|%d|%d", appcode.FromContext(ctx), packetID, packet.SenderUserID, refundAmount)) - if _, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizTypeRedPacketRefund, xerr.RequestConflict); err != nil || exists { - return false, 0, err - } - account, err := r.lockAccount(ctx, tx, packet.SenderUserID, ledger.AssetCoin, true, nowMS) - if err != nil { - return false, 0, err - } - transactionID := transactionID(appcode.FromContext(ctx), commandID) - refundID := redPacketRefundID(appcode.FromContext(ctx), packetID) - balanceAfter := account.AvailableAmount + refundAmount - metadata := redPacketRefundMetadata{ - AppCode: appcode.FromContext(ctx), - RefundID: refundID, - CommandID: commandID, - PacketID: packetID, - SenderUserID: packet.SenderUserID, - RoomID: packet.RoomID, - RegionID: packet.RegionID, - PacketType: packet.PacketType, - RefundAmount: refundAmount, - PacketStatus: ledger.RedPacketStatusRefunded, - BalanceAfter: balanceAfter, - WalletTransaction: transactionID, - RefundedAtMS: nowMS, - } - if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeRedPacketRefund, requestHash, packetID, metadata, nowMS); err != nil { - return false, 0, err - } - if err := r.applyAccountDelta(ctx, tx, account, refundAmount, 0, nowMS); err != nil { - return false, 0, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: packet.SenderUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: refundAmount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - RoomID: packet.RoomID, - CreatedAtMS: nowMS, - }); err != nil { - return false, 0, err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_refunds ( - app_code, refund_id, command_id, packet_id, sender_user_id, refund_amount, - wallet_transaction_id, status, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'refunded', ?, ?)`, - appcode.FromContext(ctx), refundID, commandID, packetID, packet.SenderUserID, refundAmount, transactionID, nowMS, nowMS, - ); err != nil { - return false, 0, err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE red_packet_allocations - SET status = ?, updated_at_ms = ? - WHERE app_code = ? AND packet_id = ? AND status = ?`, - redPacketAllocationExpired, nowMS, appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed, - ); err != nil { - return false, 0, err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE red_packets - SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ? - WHERE app_code = ? AND packet_id = ?`, - ledger.RedPacketStatusRefunded, nowMS, appcode.FromContext(ctx), packetID, - ); err != nil { - return false, 0, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, commandID, packet.SenderUserID, ledger.AssetCoin, refundAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS), - redPacketRefundedEvent(transactionID, commandID, metadata, nowMS), - }); err != nil { - return false, 0, err - } - return true, refundAmount, tx.Commit() -} - -func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQuerier) (ledger.RedPacketConfig, error) { - row := querier.QueryRowContext(ctx, ` - SELECT app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, - updated_by_admin_id, COALESCE(rule_url, ''), created_at_ms, updated_at_ms - FROM red_packet_configs - WHERE app_code = ?`, - appcode.FromContext(ctx), - ) - config := ledger.RedPacketConfig{AppCode: appcode.FromContext(ctx), ExpireSeconds: ledger.RedPacketExpireSeconds} - if err := row.Scan( - &config.AppCode, - &config.Enabled, - &config.DelayedOpenSeconds, - &config.ExpireSeconds, - &config.DailySendLimit, - &config.UpdatedByAdminID, - &config.RuleURL, - &config.CreatedAtMS, - &config.UpdatedAtMS, - ); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return ledger.RedPacketConfig{}, err - } - return config, nil - } - config.ExpireSeconds = ledger.RedPacketExpireSeconds - counts, err := r.listRedPacketCountTiers(ctx, querier) - if err != nil { - return ledger.RedPacketConfig{}, err - } - amounts, err := r.listRedPacketAmountTiers(ctx, querier) - if err != nil { - return ledger.RedPacketConfig{}, err - } - config.CountTiers = counts - config.AmountTiers = amounts - return config, nil -} - -func (r *Repository) listRedPacketCountTiers(ctx context.Context, querier redPacketQuerier) ([]int32, error) { - rows, err := querier.QueryContext(ctx, ` - SELECT packet_count - FROM red_packet_count_tiers - WHERE app_code = ? AND status = 'active' - ORDER BY sort_order ASC, packet_count ASC`, - appcode.FromContext(ctx), - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]int32, 0) - for rows.Next() { - var value int32 - if err := rows.Scan(&value); err != nil { - return nil, err - } - items = append(items, value) - } - return items, rows.Err() -} - -func (r *Repository) listRedPacketAmountTiers(ctx context.Context, querier redPacketQuerier) ([]int64, error) { - rows, err := querier.QueryContext(ctx, ` - SELECT total_amount - FROM red_packet_amount_tiers - WHERE app_code = ? AND status = 'active' - ORDER BY sort_order ASC, total_amount ASC`, - appcode.FromContext(ctx), - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]int64, 0) - for rows.Next() { - var value int64 - if err := rows.Scan(&value); err != nil { - return nil, err - } - items = append(items, value) - } - return items, rows.Err() -} - -func (r *Repository) incrementRedPacketDailyCounter(ctx context.Context, tx *sql.Tx, userID int64, limit int32, nowMS int64) error { - if limit <= 0 { - return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached") - } - day := time.UnixMilli(nowMS).UTC().Format("2006-01-02") - var current int32 - err := tx.QueryRowContext(ctx, ` - SELECT sent_count - FROM red_packet_user_daily_counters - WHERE app_code = ? AND user_id = ? AND send_day = ? - FOR UPDATE`, - appcode.FromContext(ctx), userID, day, - ).Scan(¤t) - if errors.Is(err, sql.ErrNoRows) { - _, err = tx.ExecContext(ctx, ` - INSERT INTO red_packet_user_daily_counters ( - app_code, user_id, send_day, sent_count, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 1, ?, ?)`, - appcode.FromContext(ctx), userID, day, nowMS, nowMS, - ) - return err - } - if err != nil { - return err - } - if current >= limit { - return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached") - } - _, err = tx.ExecContext(ctx, ` - UPDATE red_packet_user_daily_counters - SET sent_count = sent_count + 1, updated_at_ms = ? - WHERE app_code = ? AND user_id = ? AND send_day = ?`, - nowMS, appcode.FromContext(ctx), userID, day, - ) - return err -} - -func (r *Repository) insertRedPacket(ctx context.Context, tx *sql.Tx, metadata redPacketMetadata) error { - _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packets ( - app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, - total_amount, packet_count, remaining_amount, remaining_count, status, - open_at_ms, expires_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), metadata.PacketID, metadata.CommandID, metadata.SenderUserID, metadata.RoomID, metadata.RegionID, - metadata.PacketType, metadata.TotalAmount, metadata.PacketCount, metadata.RemainingAmount, metadata.RemainingCount, - metadata.Status, metadata.OpenAtMS, metadata.ExpiresAtMS, metadata.CreatedAtMS, metadata.CreatedAtMS, - ) - return err -} - -func (r *Repository) insertRedPacketAllocations(ctx context.Context, tx *sql.Tx, packetID string, allocations []int64, nowMS int64) error { - for index, amount := range allocations { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_allocations ( - app_code, packet_id, allocation_no, amount, status, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), packetID, index+1, amount, redPacketAllocationUnclaimed, nowMS, nowMS, - ); err != nil { - return err - } - } - return nil -} - -func (r *Repository) insertRedPacketClaim(ctx context.Context, tx *sql.Tx, metadata redPacketClaimMetadata) error { - _, err := tx.ExecContext(ctx, ` - INSERT INTO red_packet_claims ( - app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, - status, failure_reason, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`, - appcode.FromContext(ctx), metadata.ClaimID, metadata.CommandID, metadata.PacketID, metadata.UserID, - metadata.Amount, metadata.WalletTransaction, ledger.RedPacketClaimStatusClaimed, metadata.ClaimedAtMS, metadata.ClaimedAtMS, - ) - return err -} - -func (r *Repository) getRedPacketForUpdate(ctx context.Context, tx *sql.Tx, packetID string) (ledger.RedPacket, error) { - row := tx.QueryRowContext(ctx, redPacketSelectSQL()+` - WHERE app_code = ? AND packet_id = ? - FOR UPDATE`, - appcode.FromContext(ctx), packetID, - ) - packet, err := scanRedPacket(row) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found") - } - return ledger.RedPacket{}, err - } - return packet, nil -} - -func (r *Repository) getRedPacket(ctx context.Context, querier sqlRowQuerier, packetID string) (ledger.RedPacket, error) { - row := querier.QueryRowContext(ctx, redPacketSelectSQL()+`WHERE app_code = ? AND packet_id = ?`, appcode.FromContext(ctx), packetID) - packet, err := scanRedPacket(row) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found") - } - return ledger.RedPacket{}, err - } - return packet, nil -} - -func redPacketSelectSQL() string { - return ` - SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type, - total_amount, packet_count, remaining_amount, remaining_count, status, - open_at_ms, expires_at_ms, created_at_ms, updated_at_ms - FROM red_packets ` -} - -type redPacketScanner interface { - Scan(dest ...any) error -} - -func scanRedPacket(row redPacketScanner) (ledger.RedPacket, error) { - var packet ledger.RedPacket - err := row.Scan( - &packet.AppCode, - &packet.PacketID, - &packet.CommandID, - &packet.SenderUserID, - &packet.RoomID, - &packet.RegionID, - &packet.PacketType, - &packet.TotalAmount, - &packet.PacketCount, - &packet.RemainingAmount, - &packet.RemainingCount, - &packet.Status, - &packet.OpenAtMS, - &packet.ExpiresAtMS, - &packet.CreatedAtMS, - &packet.UpdatedAtMS, - ) - return packet, err -} - -func (r *Repository) getUserRedPacketClaim(ctx context.Context, tx *sql.Tx, packetID string, userID int64) (ledger.RedPacketClaim, bool, error) { - var claim ledger.RedPacketClaim - err := tx.QueryRowContext(ctx, ` - SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, - status, failure_reason, created_at_ms, updated_at_ms - FROM red_packet_claims - WHERE app_code = ? AND packet_id = ? AND user_id = ? - LIMIT 1`, - appcode.FromContext(ctx), packetID, userID, - ).Scan( - &claim.AppCode, - &claim.ClaimID, - &claim.CommandID, - &claim.PacketID, - &claim.UserID, - &claim.Amount, - &claim.WalletTransactionID, - &claim.Status, - &claim.FailureReason, - &claim.CreatedAtMS, - &claim.UpdatedAtMS, - ) - if errors.Is(err, sql.ErrNoRows) { - return ledger.RedPacketClaim{}, false, nil - } - return claim, err == nil, err -} - -func (r *Repository) lockOneRedPacketAllocation(ctx context.Context, tx *sql.Tx, packetID string) (int32, int64, error) { - row := tx.QueryRowContext(ctx, ` - SELECT allocation_no, amount - FROM red_packet_allocations - WHERE app_code = ? AND packet_id = ? AND status = ? - ORDER BY allocation_no ASC - LIMIT 1 - FOR UPDATE`, - appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed, - ) - var allocationNo int32 - var amount int64 - if err := row.Scan(&allocationNo, &amount); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return 0, 0, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out") - } - return 0, 0, err - } - return allocationNo, amount, nil -} - -func (r *Repository) attachViewerClaims(ctx context.Context, packets []ledger.RedPacket, viewerUserID int64) error { - if len(packets) == 0 || viewerUserID <= 0 { - return nil - } - packetIDs := make([]string, 0, len(packets)) - indexByID := make(map[string]int, len(packets)) - for index := range packets { - packetIDs = append(packetIDs, packets[index].PacketID) - indexByID[packets[index].PacketID] = index - } - placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",") - args := []any{appcode.FromContext(ctx), viewerUserID} - for _, packetID := range packetIDs { - args = append(args, packetID) - } - rows, err := r.db.QueryContext(ctx, ` - SELECT packet_id, amount - FROM red_packet_claims - WHERE app_code = ? AND user_id = ? AND packet_id IN (`+placeholders+`)`, - args..., - ) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var packetID string - var amount int64 - if err := rows.Scan(&packetID, &amount); err != nil { - return err - } - if index, ok := indexByID[packetID]; ok { - packets[index].ClaimedByViewer = true - packets[index].ViewerClaimAmount = amount - } - } - return rows.Err() -} - -func (r *Repository) attachRefunds(ctx context.Context, packets []ledger.RedPacket) error { - if len(packets) == 0 { - return nil - } - packetIDs := make([]string, 0, len(packets)) - indexByID := make(map[string]int, len(packets)) - for index := range packets { - packetIDs = append(packetIDs, packets[index].PacketID) - indexByID[packets[index].PacketID] = index - } - placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",") - args := []any{appcode.FromContext(ctx)} - for _, packetID := range packetIDs { - args = append(args, packetID) - } - rows, err := r.db.QueryContext(ctx, ` - SELECT packet_id, refund_amount, status, updated_at_ms - FROM red_packet_refunds - WHERE app_code = ? AND packet_id IN (`+placeholders+`)`, - args..., - ) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var packetID string - var amount int64 - var status string - var updatedAtMS int64 - if err := rows.Scan(&packetID, &amount, &status, &updatedAtMS); err != nil { - return err - } - if index, ok := indexByID[packetID]; ok { - packets[index].RefundAmount = amount - packets[index].RefundStatus = status - if status == ledger.RedPacketStatusRefunded { - packets[index].RefundedAtMS = updatedAtMS - } - } - } - return rows.Err() -} - -func (r *Repository) listRedPacketClaims(ctx context.Context, packetID string) ([]ledger.RedPacketClaim, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id, - status, failure_reason, created_at_ms, updated_at_ms - FROM red_packet_claims - WHERE app_code = ? AND packet_id = ? - ORDER BY created_at_ms ASC, claim_id ASC`, - appcode.FromContext(ctx), packetID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - claims := make([]ledger.RedPacketClaim, 0) - for rows.Next() { - var claim ledger.RedPacketClaim - if err := rows.Scan( - &claim.AppCode, - &claim.ClaimID, - &claim.CommandID, - &claim.PacketID, - &claim.UserID, - &claim.Amount, - &claim.WalletTransactionID, - &claim.Status, - &claim.FailureReason, - &claim.CreatedAtMS, - &claim.UpdatedAtMS, - ); err != nil { - return nil, err - } - claims = append(claims, claim) - } - return claims, rows.Err() -} - -func (r *Repository) redPacketCreateReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketCreateReceipt, error) { - var raw string - if err := tx.QueryRowContext(ctx, ` - SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), transactionID, - ).Scan(&raw); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - var metadata redPacketMetadata - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return ledger.RedPacketCreateReceipt{}, err - } - packet, err := r.getRedPacket(ctx, tx, metadata.PacketID) - if err != nil { - packet = redPacketFromMetadata(metadata) - } - return ledger.RedPacketCreateReceipt{Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil -} - -func (r *Repository) redPacketClaimReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketClaimReceipt, error) { - var raw string - if err := tx.QueryRowContext(ctx, ` - SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), transactionID, - ).Scan(&raw); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - var metadata redPacketClaimMetadata - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return ledger.RedPacketClaimReceipt{}, err - } - packet, err := r.getRedPacket(ctx, tx, metadata.PacketID) - if err != nil { - return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: metadata.BalanceAfter}, nil - } - return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil -} - -func redPacketFromMetadata(metadata redPacketMetadata) ledger.RedPacket { - return ledger.RedPacket{ - AppCode: metadata.AppCode, - PacketID: metadata.PacketID, - CommandID: metadata.CommandID, - SenderUserID: metadata.SenderUserID, - RoomID: metadata.RoomID, - RegionID: metadata.RegionID, - PacketType: metadata.PacketType, - TotalAmount: metadata.TotalAmount, - PacketCount: metadata.PacketCount, - RemainingAmount: metadata.RemainingAmount, - RemainingCount: metadata.RemainingCount, - Status: metadata.Status, - OpenAtMS: metadata.OpenAtMS, - ExpiresAtMS: metadata.ExpiresAtMS, - CreatedAtMS: metadata.CreatedAtMS, - UpdatedAtMS: metadata.CreatedAtMS, - } -} - -func redPacketClaimFromMetadata(metadata redPacketClaimMetadata) ledger.RedPacketClaim { - return ledger.RedPacketClaim{ - AppCode: metadata.AppCode, - ClaimID: metadata.ClaimID, - CommandID: metadata.CommandID, - PacketID: metadata.PacketID, - UserID: metadata.UserID, - Amount: metadata.Amount, - WalletTransactionID: metadata.WalletTransaction, - Status: ledger.RedPacketClaimStatusClaimed, - CreatedAtMS: metadata.ClaimedAtMS, - UpdatedAtMS: metadata.ClaimedAtMS, - } -} - -func redPacketWhereSQL(query ledger.RedPacketQuery) (string, []any) { - where := "WHERE app_code = ?" - args := []any{appcode.Normalize(query.AppCode)} - if strings.TrimSpace(query.RoomID) != "" { - where += " AND room_id = ?" - args = append(args, strings.TrimSpace(query.RoomID)) - } - if query.SenderUserID > 0 { - where += " AND sender_user_id = ?" - args = append(args, query.SenderUserID) - } - if query.RegionID > 0 { - where += " AND region_id = ?" - args = append(args, query.RegionID) - } - if query.PacketType != "" { - where += " AND packet_type = ?" - args = append(args, query.PacketType) - } - if query.Status != "" { - where += " AND status = ?" - args = append(args, query.Status) - } - if query.StartAtMS > 0 { - where += " AND created_at_ms >= ?" - args = append(args, query.StartAtMS) - } - if query.EndAtMS > 0 { - where += " AND created_at_ms < ?" - args = append(args, query.EndAtMS) - } - return where, args -} - -func redPacketCreatedEvent(transactionID string, commandID string, metadata redPacketMetadata, nowMS int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, eventTypeRedPacketCreated, metadata.SenderUserID, ledger.AssetCoin), - EventType: eventTypeRedPacketCreated, - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.SenderUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -metadata.TotalAmount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "packet_id": metadata.PacketID, - "packet_type": metadata.PacketType, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "region_id": metadata.RegionID, - "total_amount": metadata.TotalAmount, - "packet_count": metadata.PacketCount, - "remaining_amount": metadata.RemainingAmount, - "remaining_count": metadata.RemainingCount, - "status": metadata.Status, - "open_at_ms": metadata.OpenAtMS, - "expires_at_ms": metadata.ExpiresAtMS, - "created_at_ms": metadata.CreatedAtMS, - }, - CreatedAtMS: nowMS, - } -} - -func redPacketClaimedEvent(transactionID string, commandID string, metadata redPacketClaimMetadata, nowMS int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, eventTypeRedPacketClaimed, metadata.UserID, ledger.AssetCoin), - EventType: eventTypeRedPacketClaimed, - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: metadata, - CreatedAtMS: nowMS, - } -} - -func redPacketRefundedEvent(transactionID string, commandID string, metadata redPacketRefundMetadata, nowMS int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, eventTypeRedPacketRefunded, metadata.SenderUserID, ledger.AssetCoin), - EventType: eventTypeRedPacketRefunded, - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.SenderUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.RefundAmount, - FrozenDelta: 0, - Payload: metadata, - CreatedAtMS: nowMS, - } -} - -func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string { - return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d", - appcode.Normalize(command.AppCode), - command.CommandID, - command.SenderUserID, - strings.TrimSpace(command.RoomID), - command.RegionID, - command.PacketType, - command.TotalAmount, - command.PacketCount, - )) -} - -func redPacketClaimRequestHash(command ledger.RedPacketClaimCommand) string { - return stableHash(fmt.Sprintf("red_packet_claim|%s|%s|%d", - appcode.Normalize(command.AppCode), - strings.TrimSpace(command.PacketID), - command.UserID, - )) -} - -func redPacketID(app string, commandID string) string { - return "rp_" + stableHash(appcode.Normalize(app)+"|"+commandID) -} - -func redPacketClaimID(app string, commandID string) string { - return "rpc_" + stableHash(appcode.Normalize(app)+"|"+commandID) -} - -func redPacketRefundID(app string, packetID string) string { - return "rpr_" + stableHash(appcode.Normalize(app)+"|"+packetID) -} - -func int64InSet(value int64, values []int64) bool { - for _, item := range values { - if item == value { - return true - } - } - return false -} - -func int32InSet(value int32, values []int32) bool { - for _, item := range values { - if item == value { - return true - } - } - return false -} - -func splitRedPacketAmounts(total int64, count int32) ([]int64, error) { - if total < int64(count) || count <= 0 { - return nil, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small") - } - remainingAmount := total - remainingCount := int64(count) - amounts := make([]int64, 0, count) - for remainingCount > 0 { - if remainingCount == 1 { - amounts = append(amounts, remainingAmount) - break - } - maxAmount := remainingAmount - (remainingCount - 1) - avg := remainingAmount / remainingCount - drawMax := avg * 2 - if drawMax < 1 { - drawMax = 1 - } - if drawMax > maxAmount { - drawMax = maxAmount - } - drawn, err := secureRandomInt64(1, drawMax) - if err != nil { - return nil, err - } - amounts = append(amounts, drawn) - remainingAmount -= drawn - remainingCount-- - } - if err := shuffleInt64(amounts); err != nil { - return nil, err - } - return amounts, nil -} - -func secureRandomInt64(min int64, max int64) (int64, error) { - if min > max { - return 0, xerr.New(xerr.InvalidArgument, "random range is invalid") - } - span := big.NewInt(max - min + 1) - value, err := rand.Int(rand.Reader, span) - if err != nil { - return 0, err - } - return min + value.Int64(), nil -} - -func shuffleInt64(values []int64) error { - for i := len(values) - 1; i > 0; i-- { - j, err := secureRandomInt64(0, int64(i)) - if err != nil { - return err - } - values[i], values[int(j)] = values[int(j)], values[i] - } - return nil -} - -func normalizeRedPacketConfig(config ledger.RedPacketConfig) ledger.RedPacketConfig { - config.AppCode = appcode.Normalize(config.AppCode) - config.CountTiers = normalizeRedPacketCountTiers(config.CountTiers) - config.AmountTiers = normalizeRedPacketAmountTiers(config.AmountTiers) - return config -} - -func normalizeRedPacketCountTiers(values []int32) []int32 { - seen := make(map[int32]bool, len(values)) - out := make([]int32, 0, len(values)) - for _, value := range values { - if value <= 0 || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func normalizeRedPacketAmountTiers(values []int64) []int64 { - seen := make(map[int64]bool, len(values)) - out := make([]int64, 0, len(values)) - for _, value := range values { - if value <= 0 || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 750d6dfb..e952998d 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -2,60 +2,10 @@ package mysql import ( "context" - "crypto/sha256" "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "sort" "strings" - "time" - "hyapp/pkg/appcode" "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - resourcedomain "hyapp/services/wallet-service/internal/domain/resource" - - mysqlDriver "github.com/go-sql-driver/mysql" -) - -const ( - transactionStatusSucceeded = "succeeded" - bizTypeGiftDebit = "gift_debit" - bizTypeRobotGiftDebit = "robot_gift_debit" - bizTypeManualCredit = "manual_credit" - bizTypeTaskReward = "task_reward" - bizTypeLuckyGiftReward = "lucky_gift_reward" - bizTypeWheelReward = "wheel_reward" - bizTypeRoomTurnoverReward = "room_turnover_reward" - bizTypeInviteActivityReward = "invite_activity_reward" - bizTypeAgencyOpeningReward = "agency_opening_reward" - bizTypeCoinSellerTransfer = "coin_seller_transfer" - bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase" - bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation" - bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin" - bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller" - bizTypeGooglePlayRecharge = "google_play_recharge" - bizTypeMifaPayRecharge = "mifapay" - bizTypeV5PayRecharge = "v5pay" - bizTypeUSDTTRC20Recharge = "usdt_trc20" - bizTypeGameDebit = "game_debit" - bizTypeGameCredit = "game_credit" - bizTypeGameRefund = "game_refund" - bizTypeGameReverse = "game_reverse" - bizTypeHostSalarySettlement = "host_salary_settlement" - bizTypeCPBreakupFee = "cp_breakup_fee" - bizTypeWheelDrawDebit = "wheel_draw_debit" - outboxStatusPending = "pending" - outboxStatusDelivering = "delivering" - outboxStatusDelivered = "delivered" - outboxStatusRetryable = "retryable" - outboxStatusFailed = "failed" - giftWallChargeAssetMixed = "MIXED" - giftChargeSourceCoin = "coin" - giftChargeSourceBag = "bag" ) // Repository 是 wallet-service 的 MySQL v2 账本入口。 @@ -64,33 +14,6 @@ type Repository struct { db *sql.DB } -// WalletOutboxRecord is a committed wallet fact ready for external fanout. -type WalletOutboxRecord struct { - AppCode string - EventID string - EventType string - TransactionID string - CommandID string - UserID int64 - AssetType string - AvailableDelta int64 - FrozenDelta int64 - PayloadJSON string - CreatedAtMS int64 - RetryCount int - LastError string - WorkerID string - LockUntilMS int64 -} - -// WalletOutboxClaimFilter narrows one MQ fanout lane by event type. -type WalletOutboxClaimFilter struct { - // IncludeEventTypes lets a realtime worker own only the configured UI event facts. - IncludeEventTypes []string - // ExcludeEventTypes lets the generic wallet worker skip facts owned by the realtime lane. - ExcludeEventTypes []string -} - // Open 创建 MySQL 连接池并做启动 ping。 func Open(ctx context.Context, dsn string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { @@ -134,268 +57,6 @@ func Open(ctx context.Context, dsn string) (*Repository, error) { return &Repository{db: db}, nil } -func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) error { - // 后台售卖记录默认按购买时间倒序翻页;旧表只有用户维度索引,未筛选时容易退化成全表排序。 - if _, err := db.ExecContext(ctx, ` - ALTER TABLE resource_shop_purchase_orders - ADD INDEX idx_resource_shop_purchase_time (app_code, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { - return err - } - // 道具类型筛选会先通过 resources 过滤出 resource_id,再回到订单表按时间分页;补 resource/time 组合索引避免回表扫描扩大。 - if _, err := db.ExecContext(ctx, ` - ALTER TABLE resource_shop_purchase_orders - ADD INDEX idx_resource_shop_purchase_resource_time (app_code, resource_id, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { - return err - } - return nil -} - -func isDuplicateKeyNameError(err error) bool { - var mysqlErr *mysqlDriver.MySQLError - return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061 -} - -func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { - // Host/Agency 工资记录需要区分自动和手动来源;旧库没有该列时启动期补齐,避免手动结算上线后插入失败。 - if _, err := db.ExecContext(ctx, ` - ALTER TABLE host_salary_settlement_records - ADD COLUMN trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '触发方式:automatic/manual' AFTER settlement_type`); err != nil && !isDuplicateColumnError(err) { - return err - } - // 人工结算可以按 Host 或 Agency 身份拆分钱包;旧记录默认 all,表示一次完整 Host+Agency 结算。 - if _, err := db.ExecContext(ctx, ` - ALTER TABLE host_salary_settlement_records - ADD COLUMN settlement_role VARCHAR(24) NOT NULL DEFAULT 'all' COMMENT '结算身份:all/host/agency' AFTER trigger_mode`); err != nil && !isDuplicateColumnError(err) { - return err - } - return nil -} - -func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error { - if _, err := db.ExecContext(ctx, ` - ALTER TABLE wallet_recharge_products - ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型:normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) { - return err - } - if _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS third_party_payment_channels ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', - provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称', - status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', - sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, provider_code), - KEY idx_third_party_payment_channels_status (app_code, status, sort_order) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道配置表'`); err != nil { - return err - } - if _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS third_party_payment_methods ( - method_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '支付方式 ID', - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', - country_code VARCHAR(16) NOT NULL COMMENT 'MiFaPay countryCode,GLOBAL 表示全球方式', - country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家/地区名称', - currency_code VARCHAR(8) NOT NULL COMMENT '下单币种', - pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay', - pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType', - method_name VARCHAR(128) NOT NULL COMMENT '支付方式展示名', - logo_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付方式 logo', - status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', - usd_to_currency_rate DECIMAL(24,8) NOT NULL DEFAULT 0 COMMENT '1 USD 可兑换本币金额;0 表示未配置,不在 H5 展示', - sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (method_id), - UNIQUE KEY uk_third_party_payment_method (app_code, provider_code, country_code, pay_way, pay_type), - KEY idx_third_party_payment_methods_country (app_code, provider_code, country_code, status, sort_order) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家和方式配置表'`); err != nil { - return err - } - if _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS external_recharge_orders ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - order_id VARCHAR(96) NOT NULL COMMENT '平台外部充值订单 ID', - command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID', - target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID', - target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID', - target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码', - audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型', - product_id BIGINT NOT NULL COMMENT '充值商品 ID', - product_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品编码快照', - product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品名称快照', - coin_amount BIGINT NOT NULL COMMENT '到账金币数量', - usd_minor_amount BIGINT NOT NULL COMMENT '美元最小单位金额', - provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道:mifapay/usdt_trc20', - payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID', - country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '支付国家码', - currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种', - provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方下单金额最小单位', - pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay', - pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType', - pay_url TEXT NULL COMMENT '三方收银台地址', - provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方订单号', - tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT-TRC20 交易哈希', - receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址', - status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/redirected/credited/failed', - failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因', - wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '入账钱包交易 ID', - provider_payload JSON NULL COMMENT '三方原始响应或链上交易快照', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, order_id), - UNIQUE KEY uk_external_recharge_command (app_code, command_id), - KEY idx_external_recharge_user_time (app_code, target_user_id, created_at_ms), - KEY idx_external_recharge_provider_order (app_code, provider_code, provider_order_id), - KEY idx_external_recharge_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms), - KEY idx_external_recharge_tx (app_code, provider_code, tx_hash) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil { - return err - } - return seedThirdPartyPaymentDefaults(ctx, db) -} - -func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { - if _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域,0 表示全局默认', - gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', - status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', - ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比', - coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比', - created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', - updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, region_id, gift_type_code), - KEY idx_gift_diamond_ratio_region (app_code, region_id, status) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil { - return err - } - if added, err := ensureGiftReturnCoinRatioColumn(ctx, db); err != nil { - return err - } else if added { - // 老库新增列后按礼物类型补业务默认值;只在列首次创建时执行,避免覆盖后台后续配置。 - if err := seedGiftReturnCoinRatioDefaults(ctx, db); err != nil { - return err - } - } - _, err := db.ExecContext(ctx, ` - INSERT IGNORE INTO gift_diamond_ratio_configs ( - app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, - created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES - ('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0), - ('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0), - ('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`) - return err -} - -func ensureGiftReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) { - // 收礼金币返还和主播周期钻石是两套业务口径,同表按礼物类型和区域管理,但不能复用同一个比例列。 - if _, err := db.ExecContext(ctx, ` - ALTER TABLE gift_diamond_ratio_configs - ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比' AFTER ratio_percent`); err != nil { - if isDuplicateColumnError(err) { - return false, nil - } - return false, err - } - return true, nil -} - -func seedGiftReturnCoinRatioDefaults(ctx context.Context, db *sql.DB) error { - // 新列上线时历史区域行也要拿到对应礼物类型的默认返还比例,否则已有区域配置会把收礼收入算成 0。 - _, err := db.ExecContext(ctx, ` - UPDATE gift_diamond_ratio_configs - SET coin_return_ratio_percent = CASE gift_type_code - WHEN 'lucky' THEN 10.00 - WHEN 'super_lucky' THEN 1.00 - ELSE 30.00 - END`) - return err -} - -func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - region_id BIGINT NOT NULL COMMENT '币商所属区域 ID', - tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID', - min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含', - max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含', - coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量', - status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', - sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', - created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID', - updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID', - created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (tier_id), - KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor), - KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`) - return err -} - -func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error { - // 徽章现在是“拥有即佩戴”的多选资源,装备表必须允许同一用户同一类型下存在多条 entitlement。 - // 非徽章仍由写入逻辑先删同类型旧装备再插入新装备,避免把 schema 的多行能力泄漏成其它装扮多穿。 - if _, err := db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS user_resource_equipment ( - app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', - user_id BIGINT NOT NULL COMMENT '用户 ID', - resource_type VARCHAR(32) NOT NULL COMMENT '资源类型', - entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID', - resource_id BIGINT NOT NULL COMMENT '资源 ID', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, user_id, resource_type, entitlement_id), - KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'`); err != nil { - return err - } - - primaryColumns, err := tablePrimaryKeyColumns(ctx, db, "user_resource_equipment") - if err != nil { - return err - } - if strings.Join(primaryColumns, ",") == "app_code,user_id,resource_type" { - _, err = db.ExecContext(ctx, ` - ALTER TABLE user_resource_equipment - DROP PRIMARY KEY, - ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`) - return err - } - return nil -} - -func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ([]string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT COLUMN_NAME - FROM information_schema.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = ? - AND CONSTRAINT_NAME = 'PRIMARY' - ORDER BY ORDINAL_POSITION ASC`, tableName) - if err != nil { - return nil, err - } - defer rows.Close() - - columns := make([]string, 0, 4) - for rows.Next() { - var column string - if err := rows.Scan(&column); err != nil { - return nil, err - } - columns = append(columns, column) - } - return columns, rows.Err() -} - // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { @@ -413,4762 +74,3 @@ func (r *Repository) Ping(ctx context.Context) error { return r.db.PingContext(ctx) } - -// DebitGift 在一个 MySQL 事务内完成 sender 扣费、主播周期钻石入账、分录和 outbox;历史 GIFT_POINT 回执字段保留但不再入账。 -func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { - if r == nil || r.db == nil { - return ledger.Receipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.Receipt{}, err - } - defer func() { - // Commit 成功后 Rollback 会返回 sql.ErrTxDone;忽略它可保证所有早退路径释放事务。 - _ = tx.Rollback() - }() - - requestHash := debitRequestHash(command) - bizType := giftDebitBizType(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizType); err != nil || exists { - if err != nil || !exists { - return ledger.Receipt{}, err - } - receipt, receiptErr := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) - return receipt, receiptErr - } - - nowMs := time.Now().UnixMilli() - if command.RegionID < 0 { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") - } - if command.SenderRegionID < 0 { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid") - } - giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) - if err != nil { - return ledger.Receipt{}, err - } - price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) - if command.RobotGift && chargeSource == giftChargeSourceBag { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "robot gift cannot use bag entitlement") - } - chargeAssetType := price.ChargeAssetType - if command.RobotGift { - // 机器人礼物复用真实礼物价格和素材快照,但扣专用 ROBOT_COIN,避免污染真人 COIN 充值消费口径。 - chargeAssetType = ledger.AssetRobotCoin - } else if chargeSource == giftChargeSourceBag { - chargeAssetType = ledger.AssetBagGift - } - chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount)) - if err != nil { - return ledger.Receipt{}, err - } - // 礼物积分已经从运营配置和收礼收益里下线;回执字段只保留 0,兼容旧客户端和历史事件解析。 - roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) - if err != nil { - return ledger.Receipt{}, err - } - // 房间贡献只以真实扣费 COIN 为基数;旧 gift price heat_value 不再参与结算,避免后台热度配置覆盖实际消费。 - heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM) - if err != nil { - return ledger.Receipt{}, err - } - giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) - if err != nil { - return ledger.Receipt{}, err - } - giftIncomeCoinAmount := int64(0) - if !command.RobotGift { - // 收礼金币收入只属于真人送礼经济账;比例优先使用房间可见区域配置,背包礼物也按礼物服务端价值发放。 - giftIncomeCoinAmount, err = giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM) - if err != nil { - return ledger.Receipt{}, err - } - } - hostPeriodDiamondAdded := int64(0) - hostPeriodCycleKey := "" - giftDiamondRatioPercent := "0.00" - giftDiamondRatioRegionID := int64(0) - if command.TargetIsHost && !command.RobotGift { - ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) - if err != nil { - return ledger.Receipt{}, err - } - // 非主播不会进入该分支,因此普通用户收礼不会产生工资周期钻石。 - hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM) - if err != nil { - return ledger.Receipt{}, err - } - giftDiamondRatioPercent = ratio.Percent - giftDiamondRatioRegionID = ratioRegionID - hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) - } - - accountAssetType := chargeAssetType - if chargeSource == giftChargeSourceBag { - accountAssetType = ledger.AssetCoin - } - var senderState *walletAccountState - var targetCoinState *walletAccountState - if command.RobotGift { - // 机器人礼物扣的是展示专用 ROBOT_COIN;它不产生收礼 COIN 收入,因此继续沿用单账户锁定路径。 - sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - senderState = &walletAccountState{account: sender} - } else { - lockRequests := []walletAccountLockRequest{{ - UserID: command.SenderUserID, - AssetType: accountAssetType, - CreateIfMissing: chargeAmount == 0 || chargeSource == giftChargeSourceBag, - }} - if giftIncomeCoinAmount > 0 { - lockRequests = append(lockRequests, walletAccountLockRequest{ - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - CreateIfMissing: true, - }) - } - states, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - senderState = states[walletAccountLockKey(command.SenderUserID, accountAssetType)] - if giftIncomeCoinAmount > 0 { - targetCoinState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetCoin)] - } - } - sender := senderState.account - robotCoinTopUp := int64(0) - if command.RobotGift && sender.AvailableAmount < chargeAmount { - // 机器人房间使用专用 ROBOT_COIN 做展示账务,按次补足后立刻扣减,不要求真实充值预算。 - robotCoinTopUp = chargeAmount - sender.AvailableAmount - } - if !command.RobotGift && chargeSource != giftChargeSourceBag && sender.AvailableAmount < chargeAmount { - return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount - if chargeSource == giftChargeSourceBag { - // Bag 礼物不扣 COIN;balance_after 只给客户端刷新余额使用,必须保持当前 COIN 余额。 - senderAfter = sender.AvailableAmount - } - giftIncomeBalanceAfter := int64(0) - if giftIncomeCoinAmount > 0 { - if targetCoinState == nil { - return ledger.Receipt{}, xerr.New(xerr.Internal, "gift income account is missing") - } - giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount - if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag { - // 自己送自己时同一个 COIN 账户先扣礼物价值、再加收礼收入;回执余额必须反映最终净变更。 - giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount - senderAfter = giftIncomeBalanceAfter - } - } - transactionID := transactionID(command.AppCode, command.CommandID) - metadata := giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - GiftName: giftConfig.Name, - GiftIconURL: giftDisplayIconURL(giftConfig.Resource), - GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), - // effect_types 是后台控制动画、音乐、全局广播的单一来源,账务快照保存后可以被房间事件稳定复用。 - GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - ChargeSource: chargeSource, - EntitlementID: strings.TrimSpace(command.EntitlementID), - GiftPointAdded: 0, - HeatValue: heatValue, - BalanceAfter: senderAfter, - GiftIncomeCoinAmount: giftIncomeCoinAmount, - GiftIncomeRatioPercent: giftIncomeRatio.Percent, - GiftIncomeRatioRegionID: giftIncomeRatioRegionID, - GiftIncomeBalanceAfter: giftIncomeBalanceAfter, - BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), - SenderUserID: command.SenderUserID, - SenderRegionID: command.SenderRegionID, - TargetUserID: command.TargetUserID, - TargetIsHost: command.TargetIsHost, - TargetHostRegionID: command.TargetHostRegionID, - TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, - HostPeriodDiamondAdded: hostPeriodDiamondAdded, - GiftDiamondRatioPercent: giftDiamondRatioPercent, - GiftDiamondRatioRegionID: giftDiamondRatioRegionID, - HostPeriodCycleKey: hostPeriodCycleKey, - RoomID: command.RoomID, - RoomRegionID: command.RegionID, - RoomContributionRatioPercent: roomContributionRatio.Percent, - RoomContributionRatioRegionID: roomContributionRatioRegionID, - CoinPrice: price.CoinPrice, - GiftPointAmount: 0, - HeatUnitValue: price.CoinPrice, - RobotGift: command.RobotGift, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { - return ledger.Receipt{}, err - } - events := make([]walletOutboxEvent, 0, 5) - if robotCoinTopUp > 0 { - topUpAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, robotCoinTopUp, 0, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SenderUserID, - AssetType: chargeAssetType, - AvailableDelta: robotCoinTopUp, - FrozenDelta: 0, - AvailableAfter: topUpAccount.AvailableAmount, - FrozenAfter: topUpAccount.FrozenAmount, - CounterpartyUserID: 0, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.Receipt{}, err - } - // 自动补足只作为机器人展示账本的内部分录;对外余额事件只发布最终扣减后的余额, - // 避免同一 transaction/user/asset 生成两个 WalletBalanceChanged 事件 ID。 - sender = senderState.account - } - if chargeSource == giftChargeSourceBag { - resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, giftConfig.ResourceID, int64(command.GiftCount), metadata, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - events = append(events, resourceEvent) - } else { - debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SenderUserID, - AssetType: chargeAssetType, - AvailableDelta: -chargeAmount, - FrozenDelta: 0, - AvailableAfter: debitAccount.AvailableAmount, - FrozenAfter: debitAccount.FrozenAmount, - CounterpartyUserID: command.TargetUserID, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.Receipt{}, err - } - } - if giftIncomeCoinAmount > 0 { - incomeState := targetCoinState - if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { - // 自送同一个 COIN 账户时,收入必须基于扣费后的版本继续加账,避免用锁定初始版本写出版本冲突。 - incomeState = senderState - } - incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount - if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { - metadata.BalanceAfter = incomeAccount.AvailableAmount - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: giftIncomeCoinAmount, - FrozenDelta: 0, - AvailableAfter: incomeAccount.AvailableAmount, - FrozenAfter: incomeAccount.FrozenAmount, - CounterpartyUserID: command.SenderUserID, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.Receipt{}, err - } - } - if hostPeriodDiamondAdded > 0 { - // 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币和主播工资周期钻石会一起回滚。 - hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter - metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion - } - - if !command.RobotGift { - // 机器人礼物只服务房间展示,不需要客户端余额私信、礼物墙投影或真实消费类下游事件。 - if chargeSource != giftChargeSourceBag { - if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) - } else { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) - } - } - if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs)) - } - events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs)) - if giftIncomeCoinAmount > 0 { - events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) - } - } - if hostPeriodDiamondAdded > 0 { - events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) - } - if err := r.insertWalletOutbox(ctx, tx, events); err != nil { - return ledger.Receipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.Receipt{}, err - } - - return receiptFromGiftMetadata(transactionID, metadata), nil -} - -// BatchDebitGift 在一个 MySQL 事务内完成多目标送礼;任一目标失败时整批回滚,避免房间命令和账务出现部分成功。 -func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) { - if r == nil || r.db == nil { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - defer func() { - // Commit 成功后 Rollback 会返回 sql.ErrTxDone;忽略它可保证所有早退路径释放事务。 - _ = tx.Rollback() - }() - - nowMs := time.Now().UnixMilli() - if command.RegionID < 0 { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") - } - if command.SenderRegionID < 0 { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid") - } - giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) - chargeAssetType := price.ChargeAssetType - if chargeSource == giftChargeSourceBag { - chargeAssetType = ledger.AssetBagGift - } - chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount)) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - // 礼物积分已经下线;批量回执仍保留字段,但每个目标都固定返回 0。 - roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - // 批量多目标仍然是每个目标各送一份同款礼物;每份房间贡献先按真实扣费折算,再由聚合回执累加。 - heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - giftIncomeCoinAmount, err := giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets))) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - - // 批量命令的每个目标都有独立 wallet command_id;幂等重试必须是“全部已存在”或“全部未存在”,不接受半批历史。 - existing := make(map[string]ledger.BatchGiftTargetReceipt, len(command.Targets)) - for _, target := range command.Targets { - targetCommand := debitGiftCommandFromBatchTarget(command, target) - requestHash := debitRequestHash(targetCommand) - txRow, exists, err := r.lookupTransaction(ctx, tx, target.CommandID, requestHash, bizTypeGiftDebit) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - if !exists { - continue - } - receipt, err := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - existing[target.CommandID] = ledger.BatchGiftTargetReceipt{ - TargetUserID: target.TargetUserID, - CommandID: target.CommandID, - Receipt: receipt, - } - } - if len(existing) > 0 { - if len(existing) != len(command.Targets) { - // 同一个 room 命令不应该只存在部分 wallet 子交易;直接 fail-close,等待人工核对账本。 - return ledger.BatchGiftReceipt{}, xerr.New(xerr.IdempotencyConflict, "wallet batch command idempotency conflict") - } - targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) - for _, target := range command.Targets { - targetReceipts = append(targetReceipts, existing[target.CommandID]) - } - aggregate, err := aggregateGiftReceipts(targetReceipts) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil - } - - accountAssetType := chargeAssetType - if chargeSource == giftChargeSourceBag { - accountAssetType = ledger.AssetCoin - } - lockRequests := []walletAccountLockRequest{{ - UserID: command.SenderUserID, - AssetType: accountAssetType, - CreateIfMissing: totalChargeAmount == 0 || chargeSource == giftChargeSourceBag, - }} - if giftIncomeCoinAmount > 0 { - for _, target := range command.Targets { - lockRequests = append(lockRequests, walletAccountLockRequest{ - UserID: target.TargetUserID, - AssetType: ledger.AssetCoin, - CreateIfMissing: true, - }) - } - } - accountStates, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - senderState := accountStates[walletAccountLockKey(command.SenderUserID, accountAssetType)] - sender := senderState.account - if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - events := make([]walletOutboxEvent, 0, len(command.Targets)*5+1) - if chargeSource == giftChargeSourceBag { - resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), giftConfig.ResourceID, int64(command.GiftCount)*int64(len(command.Targets)), giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - ResourceID: giftConfig.ResourceID, - GiftCount: command.GiftCount, - ChargeAssetType: chargeAssetType, - ChargeSource: chargeSource, - EntitlementID: strings.TrimSpace(command.EntitlementID), - CoinSpent: totalChargeAmount, - SenderUserID: command.SenderUserID, - RoomID: command.RoomID, - }, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - events = append(events, resourceEvent) - } - - anyHostTarget := false - for _, target := range command.Targets { - if target.TargetIsHost { - anyHostTarget = true - break - } - } - hostRatio := giftDiamondRatioSnapshot{Percent: "0.00", PPM: 0} - hostRatioRegionID := int64(0) - if anyHostTarget { - hostRatio, hostRatioRegionID, err = r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - } - - targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) - for _, target := range command.Targets { - hostPeriodDiamondAdded := int64(0) - hostPeriodCycleKey := "" - giftDiamondRatioPercent := "0.00" - giftDiamondRatioRegionID := int64(0) - if target.TargetIsHost { - // 每个目标只使用自己的 host 快照;不能把某个主播的 agency/region 套给批量里的其他用户。 - hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, hostRatio.PPM) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - giftDiamondRatioPercent = hostRatio.Percent - giftDiamondRatioRegionID = hostRatioRegionID - hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) - } - - transactionID := transactionID(command.AppCode, target.CommandID) - senderAfter := senderState.account.AvailableAmount - chargeAmount - if chargeSource == giftChargeSourceBag { - // Bag 批量送礼按总数量扣库存,COIN 余额不变;每个目标回执仍保留原礼物价格,供房间贡献和麦位热度复用。 - senderAfter = senderState.account.AvailableAmount - } - giftIncomeBalanceAfter := int64(0) - targetCoinState := accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetCoin)] - if giftIncomeCoinAmount > 0 { - if targetCoinState == nil { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "gift income account is missing") - } - giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount - if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { - // 批量里如果包含自己,当前目标的回执余额必须体现本目标扣费和收入后的净结果。 - giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount - senderAfter = giftIncomeBalanceAfter - } - } - metadata := giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - GiftName: giftConfig.Name, - GiftIconURL: giftDisplayIconURL(giftConfig.Resource), - GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), - // 批量送礼复用单礼物配置快照,确保每个接收方产生的 RoomGiftSent 都带同一份 effect_types。 - GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - ChargeSource: chargeSource, - EntitlementID: strings.TrimSpace(command.EntitlementID), - GiftPointAdded: 0, - HeatValue: heatValue, - BalanceAfter: senderAfter, - GiftIncomeCoinAmount: giftIncomeCoinAmount, - GiftIncomeRatioPercent: giftIncomeRatio.Percent, - GiftIncomeRatioRegionID: giftIncomeRatioRegionID, - GiftIncomeBalanceAfter: giftIncomeBalanceAfter, - BillingReceipt: billingReceiptID(command.AppCode, target.CommandID), - SenderUserID: command.SenderUserID, - SenderRegionID: command.SenderRegionID, - TargetUserID: target.TargetUserID, - TargetIsHost: target.TargetIsHost, - TargetHostRegionID: target.TargetHostRegionID, - TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, - HostPeriodDiamondAdded: hostPeriodDiamondAdded, - GiftDiamondRatioPercent: giftDiamondRatioPercent, - GiftDiamondRatioRegionID: giftDiamondRatioRegionID, - HostPeriodCycleKey: hostPeriodCycleKey, - RoomID: command.RoomID, - RoomRegionID: command.RegionID, - RoomContributionRatioPercent: roomContributionRatio.Percent, - RoomContributionRatioRegionID: roomContributionRatioRegionID, - CoinPrice: price.CoinPrice, - GiftPointAmount: 0, - HeatUnitValue: price.CoinPrice, - } - if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { - return ledger.BatchGiftReceipt{}, err - } - if chargeSource != giftChargeSourceBag { - debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SenderUserID, - AssetType: price.ChargeAssetType, - AvailableDelta: -chargeAmount, - FrozenDelta: 0, - AvailableAfter: debitAccount.AvailableAmount, - FrozenAfter: debitAccount.FrozenAmount, - CounterpartyUserID: target.TargetUserID, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.BatchGiftReceipt{}, err - } - } - if giftIncomeCoinAmount > 0 { - incomeState := targetCoinState - if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { - // 自送目标复用 senderState 的当前版本,保证同一个 COIN 账户的扣费和收入顺序可审计、可重放。 - incomeState = senderState - } - incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount - if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin { - metadata.BalanceAfter = incomeAccount.AvailableAmount - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: target.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: giftIncomeCoinAmount, - FrozenDelta: 0, - AvailableAfter: incomeAccount.AvailableAmount, - FrozenAfter: incomeAccount.FrozenAmount, - CounterpartyUserID: command.SenderUserID, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.BatchGiftReceipt{}, err - } - } - if hostPeriodDiamondAdded > 0 { - hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter - metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion - } - - if chargeSource != giftChargeSourceBag { - if target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) - } else { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs)) - } - } - if giftIncomeCoinAmount > 0 && !(target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs)) - } - events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs)) - if giftIncomeCoinAmount > 0 { - events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) - } - if hostPeriodDiamondAdded > 0 { - events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) - } - targetReceipts = append(targetReceipts, ledger.BatchGiftTargetReceipt{ - TargetUserID: target.TargetUserID, - CommandID: target.CommandID, - Receipt: receiptFromGiftMetadata(transactionID, metadata), - }) - } - - if err := r.insertWalletOutbox(ctx, tx, events); err != nil { - return ledger.BatchGiftReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.BatchGiftReceipt{}, err - } - - aggregate, err := aggregateGiftReceipts(targetReceipts) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil -} - -// GetBalances 读取用户多资产余额;指定资产不存在时返回 0 投影,便于客户端稳定渲染。 -func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - assetTypes = normalizeAssetTypes(assetTypes) - appCode := appcode.FromContext(ctx) - - query := `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms - FROM wallet_accounts WHERE app_code = ? AND user_id = ?` - args := []any{appCode, userID} - if len(assetTypes) > 0 { - placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",") - query += " AND asset_type IN (" + placeholders + ")" - for _, assetType := range assetTypes { - args = append(args, assetType) - } - } - query += " ORDER BY asset_type" - - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - byAsset := make(map[string]ledger.AssetBalance) - for rows.Next() { - var balance ledger.AssetBalance - if err := rows.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount, &balance.Version, &balance.UpdatedAtMs); err != nil { - return nil, err - } - byAsset[balance.AssetType] = balance - } - if err := rows.Err(); err != nil { - return nil, err - } - - if len(assetTypes) == 0 { - balances := make([]ledger.AssetBalance, 0, len(byAsset)) - for _, balance := range byAsset { - balances = append(balances, balance) - } - sort.Slice(balances, func(i, j int) bool { return balances[i].AssetType < balances[j].AssetType }) - return balances, nil - } - - balances := make([]ledger.AssetBalance, 0, len(assetTypes)) - for _, assetType := range assetTypes { - if balance, ok := byAsset[assetType]; ok { - balances = append(balances, balance) - continue - } - balances = append(balances, ledger.AssetBalance{AppCode: appCode, UserID: userID, AssetType: assetType}) - } - return balances, nil -} - -func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error { - if metadata.RobotGift { - // 机器人房间礼物只服务房间展示,不进入真人用户礼物墙。 - return nil - } - chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType) - chargeAmount := metadata.ChargeAmount - if chargeAmount == 0 { - // metadata 同时保留 coin_spent 和 charge_amount;投影使用最终收费金额作为礼物价值。 - chargeAmount = metadata.CoinSpent - } - totalCoinValue, totalDiamondValue := chargeAmount, int64(0) - resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot) - if resourceSnapshotJSON == "" { - resourceSnapshotJSON = "{}" - } - presentationJSON := strings.TrimSpace(metadata.PresentationJSON) - if presentationJSON == "" { - presentationJSON = "{}" - } - giftTypeCode := strings.TrimSpace(metadata.GiftTypeCode) - if giftTypeCode == "" { - giftTypeCode = "normal" - } - - _, err := tx.ExecContext(ctx, ` - INSERT INTO user_gift_wall ( - app_code, user_id, gift_id, gift_name, resource_id, resource_snapshot_json, - gift_type_code, presentation_json, gift_count, total_value, total_coin_value, - total_diamond_value, total_gift_point, total_heat_value, charge_asset_type, - last_price_version, first_received_at_ms, last_received_at_ms, sort_order, - created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - gift_name = VALUES(gift_name), - resource_id = VALUES(resource_id), - resource_snapshot_json = VALUES(resource_snapshot_json), - gift_type_code = VALUES(gift_type_code), - presentation_json = VALUES(presentation_json), - gift_count = gift_count + VALUES(gift_count), - total_value = total_value + VALUES(total_value), - total_coin_value = total_coin_value + VALUES(total_coin_value), - total_diamond_value = total_diamond_value + VALUES(total_diamond_value), - total_gift_point = total_gift_point + VALUES(total_gift_point), - total_heat_value = total_heat_value + VALUES(total_heat_value), - charge_asset_type = IF(charge_asset_type = VALUES(charge_asset_type), charge_asset_type, ?), - last_price_version = VALUES(last_price_version), - last_received_at_ms = VALUES(last_received_at_ms), - sort_order = VALUES(sort_order), - updated_at_ms = VALUES(updated_at_ms) - `, - appcode.FromContext(ctx), - metadata.TargetUserID, - metadata.GiftID, - metadata.GiftName, - metadata.ResourceID, - resourceSnapshotJSON, - giftTypeCode, - presentationJSON, - int64(metadata.GiftCount), - chargeAmount, - totalCoinValue, - totalDiamondValue, - 0, - metadata.HeatValue, - chargeAssetType, - metadata.PriceVersion, - nowMs, - nowMs, - metadata.SortOrder, - nowMs, - nowMs, - giftWallChargeAssetMixed, - ) - return err -} - -// AdminCreditAsset 在一个事务内写入后台调账交易、分录和 outbox。 -func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) { - if r == nil || r.db == nil { - return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.AssetBalance{}, "", err - } - defer func() { _ = tx.Rollback() }() - - requestHash := adminCreditRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeManualCredit); err != nil || exists { - if err != nil || !exists { - return ledger.AssetBalance{}, "", err - } - balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.TargetUserID, command.AssetType) - return balance, txRow.TransactionID, balanceErr - } - - nowMs := time.Now().UnixMilli() - // 扣账不能隐式创建 0 余额账户;入账可以创建账户,保证运营补发能落到新用户。 - account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, command.Amount > 0, nowMs) - if err != nil { - return ledger.AssetBalance{}, "", err - } - if command.Amount < 0 && (command.Amount == math.MinInt64 || account.AvailableAmount < -command.Amount) { - return ledger.AssetBalance{}, "", xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - transactionID := transactionID(command.AppCode, command.CommandID) - metadata := adminCreditMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: command.AssetType, - Amount: command.Amount, - OperatorUserID: command.OperatorUserID, - Reason: command.Reason, - EvidenceRef: command.EvidenceRef, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeManualCredit, requestHash, command.EvidenceRef, metadata, nowMs); err != nil { - return ledger.AssetBalance{}, "", err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.AssetBalance{}, "", err - } - balance := ledger.AssetBalance{ - AppCode: command.AppCode, - UserID: command.TargetUserID, - AssetType: command.AssetType, - AvailableAmount: account.AvailableAmount + command.Amount, - FrozenAmount: account.FrozenAmount, - Version: account.Version + 1, - UpdatedAtMs: nowMs, - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: command.AssetType, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balance.AvailableAmount, - FrozenAfter: balance.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.AssetBalance{}, "", err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), - }); err != nil { - return ledger.AssetBalance{}, "", err - } - if err := tx.Commit(); err != nil { - return ledger.AssetBalance{}, "", err - } - - return balance, transactionID, nil -} - -// DebitCPBreakupFee 在钱包账本中扣除解除关系费用;关系状态不在 wallet 中修改,避免 wallet 反向拥有用户关系事实。 -func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) { - if r == nil || r.db == nil { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := cpBreakupFeeRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCPBreakupFee); err != nil || exists { - if err != nil || !exists { - return ledger.CPBreakupFeeReceipt{}, err - } - balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin) - if balanceErr != nil { - return ledger.CPBreakupFeeReceipt{}, balanceErr - } - return ledger.CPBreakupFeeReceipt{ - TransactionID: txRow.TransactionID, - CoinSpent: command.Amount, - CoinBalanceAfter: balance.AvailableAmount, - Balance: balance, - }, nil - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) - if err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - if account.AvailableAmount < command.Amount { - return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount - command.Amount - metadata := cpBreakupFeeMetadata{ - AppCode: command.AppCode, - UserID: command.UserID, - RelationshipID: command.RelationshipID, - RelationType: command.RelationType, - Amount: command.Amount, - CoinBalanceAfter: balanceAfter, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCPBreakupFee, requestHash, command.RelationshipID, metadata, nowMs); err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - balance := ledger.AssetBalance{ - AppCode: command.AppCode, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableAmount: balanceAfter, - FrozenAmount: account.FrozenAmount, - Version: account.Version + 1, - UpdatedAtMs: nowMs, - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -command.Amount, - FrozenDelta: 0, - AvailableAfter: balance.AvailableAmount, - FrozenAfter: balance.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), - }); err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.CPBreakupFeeReceipt{}, err - } - return ledger.CPBreakupFeeReceipt{ - TransactionID: transactionID, - CoinSpent: command.Amount, - CoinBalanceAfter: balance.AvailableAmount, - Balance: balance, - }, nil -} - -// DebitWheelDraw 在钱包账本中扣除转盘抽奖金币;中奖和返奖由 activity-service 后续处理,钱包只保证付费事实可审计、可幂等。 -func (r *Repository) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) { - if r == nil || r.db == nil { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := wheelDrawDebitRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelDrawDebit); err != nil || exists { - if err != nil || !exists { - return ledger.WheelDrawDebitReceipt{}, err - } - balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin) - if balanceErr != nil { - return ledger.WheelDrawDebitReceipt{}, balanceErr - } - return ledger.WheelDrawDebitReceipt{ - TransactionID: txRow.TransactionID, - CoinSpent: command.Amount, - CoinBalanceAfter: balance.AvailableAmount, - Balance: balance, - }, nil - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) - if err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - if account.AvailableAmount < command.Amount { - return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount - command.Amount - metadata := wheelDrawDebitMetadata{ - AppCode: command.AppCode, - UserID: command.UserID, - WheelID: command.WheelID, - DrawCount: command.DrawCount, - Amount: command.Amount, - CoinBalanceAfter: balanceAfter, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelDrawDebit, requestHash, command.WheelID, metadata, nowMs); err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - balance := ledger.AssetBalance{ - AppCode: command.AppCode, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableAmount: balanceAfter, - FrozenAmount: account.FrozenAmount, - Version: account.Version + 1, - UpdatedAtMs: nowMs, - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -command.Amount, - FrozenDelta: 0, - AvailableAfter: balance.AvailableAmount, - FrozenAfter: balance.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), - }); err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.WheelDrawDebitReceipt{}, err - } - return ledger.WheelDrawDebitReceipt{ - TransactionID: transactionID, - CoinSpent: command.Amount, - CoinBalanceAfter: balance.AvailableAmount, - Balance: balance, - }, nil -} - -// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。 -func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.TaskRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := taskRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeTaskReward); err != nil || exists { - if err != nil || !exists { - return ledger.TaskRewardReceipt{}, err - } - return r.receiptForTaskRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.TaskRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - metadata := taskRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - TaskType: command.TaskType, - TaskID: command.TaskID, - CycleKey: command.CycleKey, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeTaskReward, requestHash, fmt.Sprintf("%s:%s", command.TaskID, command.CycleKey), metadata, nowMs); err != nil { - return ledger.TaskRewardReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.TaskRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.TaskRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.TaskRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.TaskRewardReceipt{}, err - } - - return receiptFromTaskRewardMetadata(transactionID, metadata), nil -} - -// CreditLuckyGiftReward 在同一事务里完成幸运礼物中奖 COIN 入账、交易分录和钱包 outbox。 -func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := luckyGiftRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeLuckyGiftReward); err != nil || exists { - if err != nil || !exists { - return ledger.LuckyGiftRewardReceipt{}, err - } - return r.receiptForLuckyGiftRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - metadata := luckyGiftRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - DrawID: command.DrawID, - RoomID: command.RoomID, - VisibleRegionID: command.VisibleRegionID, - CountryID: command.CountryID, - GiftID: command.GiftID, - PoolID: command.PoolID, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - luckyGiftRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - - return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil -} - -// CreditWheelReward 在同一事务里完成转盘金币奖品入账、交易分录和钱包 outbox。 -func (r *Repository) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.WheelRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := wheelRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelReward); err != nil || exists { - if err != nil || !exists { - return ledger.WheelRewardReceipt{}, err - } - return r.receiptForWheelRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.WheelRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - metadata := wheelRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - DrawID: command.DrawID, - WheelID: command.WheelID, - SelectedTierID: command.SelectedTierID, - VisibleRegionID: command.VisibleRegionID, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelReward, requestHash, command.DrawID, metadata, nowMs); err != nil { - return ledger.WheelRewardReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.WheelRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.WheelRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - wheelRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.WheelRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.WheelRewardReceipt{}, err - } - - return receiptFromWheelRewardMetadata(transactionID, metadata), nil -} - -// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。 -func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := roomTurnoverRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeRoomTurnoverReward); err != nil || exists { - if err != nil || !exists { - return ledger.RoomTurnoverRewardReceipt{}, err - } - // command_id 相同且请求摘要一致说明是 activity-service 的重复发奖调用;直接返回原交易回执,不再改余额。 - return r.receiptForRoomTurnoverRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - // 锁定房主 COIN 账户后再计算余额,保证同一用户同时收到多笔奖励时 available_after 仍是线性账本结果。 - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - // metadata 是交易、分录和 outbox 的共享事实快照;后续补发事件或幂等查询都从这里还原原始结算上下文。 - metadata := roomTurnoverRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - SettlementID: command.SettlementID, - RoomID: command.RoomID, - PeriodStartMS: command.PeriodStartMS, - PeriodEndMS: command.PeriodEndMS, - CoinSpent: command.CoinSpent, - TierID: command.TierID, - TierCode: command.TierCode, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRoomTurnoverReward, requestHash, command.SettlementID, metadata, nowMs); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - // 交易、账户余额、账本分录和 outbox 必须在同一事务提交;任意一步失败都会回滚,避免出现发奖记录和余额不一致。 - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - roomTurnoverRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - - return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil -} - -// CreditInviteActivityReward 在同一事务内完成邀请活动 COIN 奖励入账、交易分录和钱包 outbox。 -func (r *Repository) CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := inviteActivityRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeInviteActivityReward); err != nil || exists { - if err != nil || !exists { - return ledger.InviteActivityRewardReceipt{}, err - } - // 活动服务领取接口可能因为网络超时重试;相同 command_id 和语义摘要直接回放原始钱包回执。 - return r.receiptForInviteActivityRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - metadata := inviteActivityRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - ClaimID: command.ClaimID, - RewardType: command.RewardType, - TierID: command.TierID, - TierCode: command.TierCode, - CycleKey: command.CycleKey, - ReachedValue: command.ReachedValue, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeInviteActivityReward, requestHash, command.ClaimID, metadata, nowMs); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - inviteActivityRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - - return receiptFromInviteActivityRewardMetadata(transactionID, metadata), nil -} - -// CreditAgencyOpeningReward 在同一事务内完成代理开业流水档位 COIN 奖励入账、交易分录和钱包 outbox。 -func (r *Repository) CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) { - if r == nil || r.db == nil { - return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := agencyOpeningRewardRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeAgencyOpeningReward); err != nil || exists { - if err != nil || !exists { - return ledger.AgencyOpeningRewardReceipt{}, err - } - return r.receiptForAgencyOpeningRewardTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + command.Amount - metadata := agencyOpeningRewardMetadata{ - AppCode: command.AppCode, - TargetUserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - Amount: command.Amount, - ApplicationID: command.ApplicationID, - CycleID: command.CycleID, - AgencyID: command.AgencyID, - RankNo: command.RankNo, - ScoreCoins: command.ScoreCoins, - Reason: command.Reason, - BalanceAfter: balanceAfter, - GrantedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeAgencyOpeningReward, requestHash, command.ApplicationID, metadata, nowMs); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - agencyOpeningRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - - return receiptFromAgencyOpeningRewardMetadata(transactionID, metadata), nil -} - -// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。 -func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) { - if r == nil || r.db == nil { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount) - if err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { - if err != nil || !exists { - return ledger.GameCoinChangeReceipt{}, err - } - receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID) - receipt.IdempotentReplay = true - return receipt, receiptErr - } - - nowMs := time.Now().UnixMilli() - createIfMissing := delta > 0 - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs) - if err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - if delta < 0 && account.AvailableAmount < -delta { - return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - - transactionID := transactionID(command.AppCode, command.CommandID) - balanceAfter := account.AvailableAmount + delta - metadata := gameCoinMetadata{ - AppCode: command.AppCode, - UserID: command.UserID, - PlatformCode: command.PlatformCode, - GameID: command.GameID, - ProviderOrderID: command.ProviderOrderID, - ProviderRoundID: command.ProviderRoundID, - OpType: command.OpType, - AssetType: ledger.AssetCoin, - CoinAmount: command.CoinAmount, - AvailableDelta: delta, - RoomID: command.RoomID, - BalanceAfter: balanceAfter, - AppliedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: delta, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - RoomID: command.RoomID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - }); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - - return receiptFromGameMetadata(transactionID, metadata, false), nil -} - -// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。 -func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { - if r == nil || r.db == nil { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - bizType := coinSellerStockBizType(command.StockType) - if bizType == "" { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") - } - requestHash := coinSellerStockRequestHash(command) - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { - if err != nil || !exists { - return ledger.CoinSellerStockCreditReceipt{}, err - } - return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID) - } - if command.PaymentRef != "" { - if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated { - if err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") - } - } - - nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) - if err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount) - if err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - - transactionID := transactionID(command.AppCode, command.CommandID) - countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase - metadata := coinSellerStockMetadata{ - AppCode: command.AppCode, - SellerUserID: command.SellerUserID, - SellerCountryID: command.SellerCountryID, - SellerRegionID: command.SellerRegionID, - StockType: command.StockType, - CoinAmount: command.CoinAmount, - PaidCurrencyCode: command.PaidCurrencyCode, - PaidAmountMicro: command.PaidAmountMicro, - PaymentRef: command.PaymentRef, - EvidenceRef: command.EvidenceRef, - OperatorUserID: command.OperatorUserID, - Reason: command.Reason, - AssetType: ledger.AssetCoinSellerCoin, - CountsAsSellerRecharge: countsAsSellerRecharge, - BalanceAfter: balanceAfter, - CreatedAtMS: nowMs, - } - externalRef := command.EvidenceRef - if command.PaymentRef != "" { - externalRef = command.PaymentRef - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SellerUserID, - AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: command.CoinAmount, - FrozenDelta: 0, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs), - }); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - - return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil -} - -// TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。 -func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { - if r == nil || r.db == nil { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := coinSellerTransferRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCoinSellerTransfer); err != nil || exists { - if err != nil || !exists { - return ledger.CoinSellerTransferReceipt{}, err - } - return r.receiptForCoinSellerTransferTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, false, nowMs) - if err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if seller.AvailableAmount < command.Amount { - return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - - transactionID := transactionID(command.AppCode, command.CommandID) - // 币商向用户转普通金币只计普通金币到账和充值用户,不计入用户 USDT/USD 充值金额。 - rechargeUSDMinor := int64(0) - rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs) - if err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - sellerAfter := seller.AvailableAmount - command.Amount - targetAfter := target.AvailableAmount + command.Amount - metadata := coinSellerTransferMetadata{ - AppCode: command.AppCode, - SellerUserID: command.SellerUserID, - TargetUserID: command.TargetUserID, - TargetCountryID: command.TargetCountryID, - SellerRegionID: command.SellerRegionID, - TargetRegionID: command.TargetRegionID, - Amount: command.Amount, - Reason: command.Reason, - SellerAssetType: ledger.AssetCoinSellerCoin, - TargetAssetType: ledger.AssetCoin, - SellerBalanceAfter: sellerAfter, - TargetBalanceAfter: targetAfter, - RechargeSequence: rechargeSequence, - RechargeUSDMinor: rechargeUSDMinor, - RechargeCurrencyCode: "", - RechargePolicyID: 0, - RechargePolicyVersion: "", - RechargePolicyCoinAmount: 0, - RechargePolicyUSDMinorAmount: 0, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCoinSellerTransfer, requestHash, fmt.Sprintf("coin_seller:%d:%d", command.SellerUserID, command.TargetUserID), metadata, nowMs); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, seller, -command.Amount, 0, nowMs); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SellerUserID, - AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: -command.Amount, - FrozenDelta: 0, - AvailableAfter: sellerAfter, - FrozenAfter: seller.FrozenAmount, - CounterpartyUserID: command.TargetUserID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, target, command.Amount, 0, nowMs); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: command.Amount, - FrozenDelta: 0, - AvailableAfter: targetAfter, - FrozenAfter: target.FrozenAmount, - CounterpartyUserID: command.SellerUserID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMs); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), - coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs), - rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs), - }); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - - return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil -} - -// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。 -func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, appCode) - statusClause := "AND status = 'active'" - if includeDisabled { - statusClause = "" - } - query := fmt.Sprintf(` - SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms - FROM coin_seller_salary_exchange_rate_tiers - WHERE app_code = ? AND region_id = ? %s - ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause) - rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID) - if err != nil { - return nil, err - } - defer rows.Close() - - tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0) - for rows.Next() { - var tier ledger.CoinSellerSalaryExchangeRateTier - if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { - return nil, err - } - tiers = append(tiers, tier) - } - if err := rows.Err(); err != nil { - return nil, err - } - return tiers, nil -} - -// ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账,保证两边余额和幂等一起落库。 -func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) { - if r == nil || r.db == nil { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := salaryExchangeRequestHash(command) - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists { - if err != nil || !exists { - return ledger.SalaryExchangeReceipt{}, err - } - return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD) - if err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs) - if err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if salaryAccount.AvailableAmount < command.SalaryUSDMinor { - return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs) - if err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - - transactionID := transactionID(command.AppCode, command.CommandID) - salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor - coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount) - if err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - metadata := salaryExchangeMetadata{ - AppCode: command.AppCode, - UserID: command.UserID, - SalaryAssetType: command.SalaryAssetType, - CoinAssetType: ledger.AssetCoin, - SalaryUSDMinor: command.SalaryUSDMinor, - CoinPerUSD: ledger.SalaryExchangeCoinPerUSD, - CoinAmount: coinAmount, - SalaryBalanceAfter: salaryAfter, - CoinBalanceAfter: coinAfter, - Reason: command.Reason, - CreatedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: command.SalaryAssetType, - AvailableDelta: -command.SalaryUSDMinor, - FrozenDelta: 0, - AvailableAfter: salaryAfter, - FrozenAfter: salaryAccount.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: coinAmount, - FrozenDelta: 0, - AvailableAfter: coinAfter, - FrozenAfter: coinAccount.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs), - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs), - }); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - - return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil -} - -// TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。 -func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) { - if r == nil || r.db == nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := salaryTransferToCoinSellerRequestHash(command) - if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists { - if err != nil || !exists { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID) - } - - nowMs := time.Now().UnixMilli() - rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if source.AvailableAmount < command.SalaryUSDMinor { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - - transactionID := transactionID(command.AppCode, command.CommandID) - sourceAfter := source.AvailableAmount - command.SalaryUSDMinor - sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount) - if err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - metadata := salaryTransferToCoinSellerMetadata{ - AppCode: command.AppCode, - SourceUserID: command.SourceUserID, - SellerUserID: command.SellerUserID, - RegionID: command.RegionID, - SalaryAssetType: command.SalaryAssetType, - SellerAssetType: ledger.AssetCoinSellerCoin, - SalaryUSDMinor: command.SalaryUSDMinor, - CoinPerUSD: rate.CoinPerUSD, - CoinAmount: coinAmount, - RateMinUSDMinor: rate.MinUSDMinor, - RateMaxUSDMinor: rate.MaxUSDMinor, - SourceSalaryBalanceAfter: sourceAfter, - SellerBalanceAfter: sellerAfter, - Reason: command.Reason, - CreatedAtMS: nowMs, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SourceUserID, - AssetType: command.SalaryAssetType, - AvailableDelta: -command.SalaryUSDMinor, - FrozenDelta: 0, - AvailableAfter: sourceAfter, - FrozenAfter: source.FrozenAmount, - CounterpartyUserID: command.SellerUserID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.SellerUserID, - AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: coinAmount, - FrozenDelta: 0, - AvailableAfter: sellerAfter, - FrozenAfter: seller.FrozenAmount, - CounterpartyUserID: command.SourceUserID, - CreatedAtMS: nowMs, - }); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs), - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), - }); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - if err := tx.Commit(); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - - return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil -} - -type transactionRow struct { - TransactionID string - MetadataJSON string -} - -func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) { - return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict) -} - -func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (transactionRow, bool, error) { - row := tx.QueryRowContext(ctx, - `SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND command_id = ? - FOR UPDATE`, - appcode.FromContext(ctx), - commandID, - ) - - var txRow transactionRow - var storedHash string - var storedBizType string - if err := row.Scan(&txRow.TransactionID, &storedHash, &storedBizType, &txRow.MetadataJSON); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return transactionRow{}, false, nil - } - return transactionRow{}, false, err - } - if storedHash != requestHash || storedBizType != bizType { - // command_id 是钱包幂等主键,业务 payload 或命令类型变化必须 fail-closed。 - return transactionRow{}, true, xerr.New(conflictCode, "wallet command idempotency conflict") - } - return txRow, true, nil -} - -type walletAccount struct { - AppCode string - UserID int64 - AssetType string - AvailableAmount int64 - FrozenAmount int64 - Version int64 - UpdatedAtMS int64 -} - -type walletAccountLockRequest struct { - UserID int64 - AssetType string - CreateIfMissing bool -} - -type walletAccountState struct { - account walletAccount -} - -func walletAccountLockKey(userID int64, assetType string) string { - return fmt.Sprintf("%s:%d", strings.ToUpper(strings.TrimSpace(assetType)), userID) -} - -func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, requests []walletAccountLockRequest, nowMs int64) (map[string]*walletAccountState, error) { - merged := make(map[string]walletAccountLockRequest, len(requests)) - for _, request := range requests { - request.AssetType = ledger.NormalizeGiftChargeAssetType(request.AssetType) - if request.UserID <= 0 || strings.TrimSpace(request.AssetType) == "" { - return nil, xerr.New(xerr.InvalidArgument, "wallet account lock request is invalid") - } - key := walletAccountLockKey(request.UserID, request.AssetType) - if existing, ok := merged[key]; ok { - // 同一事务内同一用户同一资产只锁一次;任一调用方需要自动建账时必须保留 createIfMissing, - // 否则自送或背包送礼会因为去重丢掉收礼账户初始化语义。 - existing.CreateIfMissing = existing.CreateIfMissing || request.CreateIfMissing - merged[key] = existing - continue - } - merged[key] = request - } - - ordered := make([]walletAccountLockRequest, 0, len(merged)) - for _, request := range merged { - ordered = append(ordered, request) - } - sort.Slice(ordered, func(i, j int) bool { - if ordered[i].AssetType == ordered[j].AssetType { - return ordered[i].UserID < ordered[j].UserID - } - return ordered[i].AssetType < ordered[j].AssetType - }) - - states := make(map[string]*walletAccountState, len(ordered)) - for _, request := range ordered { - account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs) - if err != nil { - return nil, err - } - states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account} - } - return states, nil -} - -func (r *Repository) applyTrackedAccountDelta(ctx context.Context, tx *sql.Tx, state *walletAccountState, availableDelta int64, frozenDelta int64, nowMs int64) (walletAccount, error) { - if state == nil { - return walletAccount{}, xerr.New(xerr.Internal, "wallet account state is missing") - } - before := state.account - if err := r.applyAccountDelta(ctx, tx, before, availableDelta, frozenDelta, nowMs); err != nil { - return walletAccount{}, err - } - after := before - after.AvailableAmount += availableDelta - after.FrozenAmount += frozenDelta - after.Version++ - after.UpdatedAtMS = nowMs - state.account = after - return after, nil -} - -func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64, assetType string, createIfMissing bool, nowMs int64) (walletAccount, error) { - account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) - if err != nil || exists || !createIfMissing { - if err != nil { - return walletAccount{}, err - } - if !exists { - return walletAccount{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - return account, nil - } - - if _, err := tx.ExecContext(ctx, - `INSERT INTO wallet_accounts (app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, - appcode.FromContext(ctx), userID, assetType, nowMs, nowMs, - ); err != nil { - return walletAccount{}, err - } - return r.queryRequiredAccountForUpdate(ctx, tx, userID, assetType) -} - -func (r *Repository) queryAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, bool, error) { - row := tx.QueryRowContext(ctx, - `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms - FROM wallet_accounts - WHERE app_code = ? AND user_id = ? AND asset_type = ? - FOR UPDATE`, - appcode.FromContext(ctx), - userID, assetType, - ) - var account walletAccount - if err := row.Scan(&account.AppCode, &account.UserID, &account.AssetType, &account.AvailableAmount, &account.FrozenAmount, &account.Version, &account.UpdatedAtMS); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return walletAccount{}, false, nil - } - return walletAccount{}, false, err - } - return account, true, nil -} - -func (r *Repository) queryRequiredAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, error) { - account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) - if err != nil { - return walletAccount{}, err - } - if !exists { - return walletAccount{}, xerr.New(xerr.Internal, "wallet account creation failed") - } - return account, nil -} - -func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account walletAccount, availableDelta int64, frozenDelta int64, nowMs int64) error { - availableAfter := account.AvailableAmount + availableDelta - frozenAfter := account.FrozenAmount + frozenDelta - if availableAfter < 0 || frozenAfter < 0 || availableAfter > math.MaxInt64-frozenAfter { - // 钱包负债不能出现负数,也不能因溢出绕过余额约束。 - return xerr.New(xerr.LedgerConflict, "wallet balance delta is invalid") - } - - result, err := tx.ExecContext(ctx, - `UPDATE wallet_accounts - SET available_amount = ?, frozen_amount = ?, version = version + 1, updated_at_ms = ? - WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`, - availableAfter, - frozenAfter, - nowMs, - account.AppCode, - account.UserID, - account.AssetType, - account.Version, - ) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil { - return err - } - if rows != 1 { - return xerr.New(xerr.LedgerConflict, "wallet account version conflict") - } - return nil -} - -type giftPrice struct { - GiftID string - PriceVersion string - ChargeAssetType string - CoinPrice int64 - // GiftPointAmount 是 wallet_gift_prices 的历史列;送礼结算只按 CoinPrice 和比例计算,不再读取它做收益。 - GiftPointAmount int64 - HeatValue int64 -} - -type rechargePolicy struct { - PolicyID int64 - RegionID int64 - PolicyVersion string - CurrencyCode string - CoinAmount int64 - USDMinorAmount int64 - EffectiveFromMs int64 -} - -func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID string, priceVersion string, nowMs int64) (giftPrice, error) { - var row *sql.Row - if strings.TrimSpace(priceVersion) != "" { - row = tx.QueryRowContext(ctx, - `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value - FROM wallet_gift_prices - WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`, - appcode.FromContext(ctx), giftID, priceVersion, nowMs, - ) - } else { - row = tx.QueryRowContext(ctx, - `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value - FROM wallet_gift_prices - WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? - ORDER BY effective_at_ms DESC, price_version DESC - LIMIT 1`, - appcode.FromContext(ctx), giftID, nowMs, - ) - } - - var price giftPrice - if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") - } - return giftPrice{}, err - } - if price.CoinPrice < 0 || price.HeatValue < 0 { - return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid") - } - price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) - return price, nil -} - -type giftDiamondRatioSnapshot struct { - Percent string - PPM int64 -} - -func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { - giftTypeCode = strings.TrimSpace(giftTypeCode) - if giftTypeCode == "" { - giftTypeCode = "normal" - } - regions := []int64{regionID} - if regionID != 0 { - regions = append(regions, 0) - } - for _, regionID := range regions { - ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) - if err != nil { - return giftDiamondRatioSnapshot{}, 0, err - } - if exists { - return ratio, regionID, nil - } - } - return defaultGiftDiamondRatio(giftTypeCode), 0, nil -} - -func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { - // 送礼运行链路只认后台“全局默认”行,避免房间区域或送礼人区域把贡献值和主播钻石算成不同口径。 - return r.resolveGiftDiamondRatio(ctx, tx, appCode, 0, giftTypeCode) -} - -func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { - var percent string - var ppm int64 - err := tx.QueryRowContext(ctx, ` - SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED) - FROM gift_diamond_ratio_configs - WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' - LIMIT 1`, - appcode.Normalize(appCode), regionID, giftTypeCode, - ).Scan(&percent, &ppm) - if errors.Is(err, sql.ErrNoRows) { - return giftDiamondRatioSnapshot{}, false, nil - } - if err != nil { - return giftDiamondRatioSnapshot{}, false, err - } - if ppm < 0 || ppm > 1_000_000 { - return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") - } - percent = strings.TrimSpace(percent) - if percent == "" { - percent = "100.00" - } - return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil -} - -func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { - giftTypeCode = strings.TrimSpace(giftTypeCode) - if giftTypeCode == "" { - giftTypeCode = "normal" - } - regions := []int64{regionID} - if regionID != 0 { - regions = append(regions, 0) - } - for _, regionID := range regions { - ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, giftTypeCode) - if err != nil { - return giftDiamondRatioSnapshot{}, 0, err - } - if exists { - return ratio, regionID, nil - } - } - return defaultGiftReturnCoinRatio(giftTypeCode), 0, nil -} - -func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { - var percent string - var ppm int64 - err := tx.QueryRowContext(ctx, ` - SELECT CAST(coin_return_ratio_percent AS CHAR), CAST(ROUND(coin_return_ratio_percent * 10000) AS SIGNED) - FROM gift_diamond_ratio_configs - WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' - LIMIT 1`, - appcode.Normalize(appCode), regionID, giftTypeCode, - ).Scan(&percent, &ppm) - if errors.Is(err, sql.ErrNoRows) { - return giftDiamondRatioSnapshot{}, false, nil - } - if err != nil { - return giftDiamondRatioSnapshot{}, false, err - } - if ppm < 0 || ppm > 1_000_000 { - return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift return coin ratio is invalid") - } - percent = strings.TrimSpace(percent) - if percent == "" { - percent = defaultGiftReturnCoinRatio(giftTypeCode).Percent - } - return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil -} - -func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { - if chargeAmount < 0 || ratioPPM < 0 { - return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid") - } - product, err := checkedMul(chargeAmount, ratioPPM) - if err != nil { - return 0, err - } - return product / 1_000_000, nil -} - -func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot { - switch strings.TrimSpace(giftTypeCode) { - case "lucky": - return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} - case "super_lucky": - return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} - default: - return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} - } -} - -func defaultGiftReturnCoinRatio(giftTypeCode string) giftDiamondRatioSnapshot { - switch strings.TrimSpace(giftTypeCode) { - case resourcedomain.GiftTypeLucky: - return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} - case resourcedomain.GiftTypeSuperLucky: - return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} - default: - return giftDiamondRatioSnapshot{Percent: "30.00", PPM: 300_000} - } -} - -func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { - row := tx.QueryRowContext(ctx, - `SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms - FROM wallet_recharge_policies - WHERE app_code = ? AND region_id = ? AND status = 'active' - AND effective_from_ms <= ? - AND (effective_to_ms IS NULL OR effective_to_ms > ?) - ORDER BY effective_from_ms DESC, policy_id DESC - LIMIT 1`, - appcode.FromContext(ctx), - regionID, - nowMs, - nowMs, - ) - var policy rechargePolicy - if err := row.Scan(&policy.PolicyID, &policy.RegionID, &policy.PolicyVersion, &policy.CurrencyCode, &policy.CoinAmount, &policy.USDMinorAmount, &policy.EffectiveFromMs); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return rechargePolicy{}, xerr.New(xerr.NotFound, "recharge policy not found") - } - return rechargePolicy{}, err - } - if policy.CoinAmount <= 0 || policy.USDMinorAmount <= 0 || strings.TrimSpace(policy.CurrencyCode) == "" { - return rechargePolicy{}, xerr.New(xerr.Internal, "recharge policy is invalid") - } - return policy, nil -} - -func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64, error) { - numerator, err := checkedMul(coinAmount, policy.USDMinorAmount) - if err != nil { - return 0, err - } - // 金币必须完整到账;充值 USD 统计只能落到最小货币单位,不能表示的尾差向下取整。 - return numerator / policy.CoinAmount, nil -} - -func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) { - row := tx.QueryRowContext(ctx, - `SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms - FROM coin_seller_salary_exchange_rate_tiers - WHERE app_code = ? AND region_id = ? AND status = 'active' - AND min_usd_minor <= ? AND max_usd_minor >= ? - ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC - LIMIT 1 - FOR UPDATE`, - appcode.FromContext(ctx), - regionID, - salaryUSDMinor, - salaryUSDMinor, - ) - var tier ledger.CoinSellerSalaryExchangeRateTier - if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured") - } - return ledger.CoinSellerSalaryExchangeRateTier{}, err - } - if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 { - // 工资以美分入账,coin_per_usd 必须能被 100 整除,避免转账金币出现隐式四舍五入。 - return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid") - } - return tier, nil -} - -func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) { - numerator, err := checkedMul(salaryUSDMinor, coinPerUSD) - if err != nil { - return 0, err - } - if numerator%100 != 0 { - return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate") - } - return numerator / 100, nil -} - -func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, bizType string, requestHash string, externalRef string, metadata any, nowMs int64) error { - metadataJSON, err := json.Marshal(metadata) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, - `INSERT INTO wallet_transactions ( - app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref, metadata_json, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), - transactionID, - commandID, - bizType, - transactionStatusSucceeded, - requestHash, - externalRef, - string(metadataJSON), - nowMs, - nowMs, - ) - return err -} - -func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO wallet_recharge_records ( - app_code, transaction_id, user_id, recharge_sequence, seller_user_id, seller_region_id, target_region_id, - policy_id, policy_version, currency_code, coin_amount, usd_minor_amount, - exchange_coin_amount, exchange_usd_minor_amount, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - normalizedEntryAppCode(ctx, metadata.AppCode), - transactionID, - metadata.TargetUserID, - metadata.RechargeSequence, - metadata.SellerUserID, - metadata.SellerRegionID, - metadata.TargetRegionID, - metadata.RechargePolicyID, - metadata.RechargePolicyVersion, - metadata.RechargeCurrencyCode, - metadata.Amount, - metadata.RechargeUSDMinor, - metadata.Amount, - metadata.RechargePolicyUSDMinorAmount, - nowMs, - ) - return err -} - -func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) { - normalizedApp := normalizedEntryAppCode(ctx, appCode) - var count int64 - err := tx.QueryRowContext(ctx, ` - SELECT recharge_count - FROM wallet_user_recharge_stats - WHERE app_code = ? AND user_id = ? - FOR UPDATE`, - normalizedApp, userID, - ).Scan(&count) - if errors.Is(err, sql.ErrNoRows) { - _, err = tx.ExecContext(ctx, ` - INSERT INTO wallet_user_recharge_stats ( - app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms, - total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms - ) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`, - normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs, - ) - if err != nil { - return 0, err - } - return 1, nil - } - if err != nil { - return 0, err - } - next := count + 1 - _, err = tx.ExecContext(ctx, ` - UPDATE wallet_user_recharge_stats - SET recharge_count = ?, - total_coin_amount = total_coin_amount + ?, - total_usd_minor_amount = total_usd_minor_amount + ?, - updated_at_ms = ? - WHERE app_code = ? AND user_id = ?`, - next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID, - ) - if err != nil { - return 0, err - } - return next, nil -} - -func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error { - metadataJSON, err := json.Marshal(metadata) - if err != nil { - return err - } - var paymentRef any - if metadata.PaymentRef != "" { - paymentRef = metadata.PaymentRef - } - _, err = tx.ExecContext(ctx, - `INSERT INTO coin_seller_stock_records ( - app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type, - counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro, - payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - normalizedEntryAppCode(ctx, metadata.AppCode), - coinSellerStockID(metadata.AppCode, commandID), - transactionID, - commandID, - metadata.SellerUserID, - metadata.StockType, - metadata.CountsAsSellerRecharge, - metadata.CoinAmount, - metadata.PaidCurrencyCode, - metadata.PaidAmountMicro, - paymentRef, - metadata.EvidenceRef, - metadata.OperatorUserID, - metadata.Reason, - metadata.BalanceAfter, - string(metadataJSON), - nowMs, - ) - if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" { - return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") - } - return err -} - -func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) { - var stockID string - err := tx.QueryRowContext(ctx, - `SELECT stock_id - FROM coin_seller_stock_records - WHERE app_code = ? AND stock_type = ? AND payment_ref = ? - LIMIT 1`, - appcode.FromContext(ctx), - stockType, - paymentRef, - ).Scan(&stockID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - return true, nil -} - -type walletEntry struct { - AppCode string - TransactionID string - UserID int64 - AssetType string - AvailableDelta int64 - FrozenDelta int64 - AvailableAfter int64 - FrozenAfter int64 - CounterpartyUserID int64 - RoomID string - CreatedAtMS int64 -} - -func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEntry) error { - _, err := tx.ExecContext(ctx, - `INSERT INTO wallet_entries ( - app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, - available_after, frozen_after, counterparty_user_id, room_id, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - normalizedEntryAppCode(ctx, entry.AppCode), - entry.TransactionID, - entry.UserID, - entry.AssetType, - entry.AvailableDelta, - entry.FrozenDelta, - entry.AvailableAfter, - entry.FrozenAfter, - entry.CounterpartyUserID, - entry.RoomID, - entry.CreatedAtMS, - ) - return err -} - -func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) { - // host_period_diamond_accounts 是工资引擎读取的投影表,不是可提现钱包资产;只有主播身份收礼会写入。 - if metadata.HostPeriodDiamondAdded <= 0 { - return 0, 0, nil - } - after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) - if err != nil { - return 0, 0, err - } - if !exists { - // 周期账户只在主播首次收礼时创建;工资结算任务按 cycle_key 扫描,不依赖通用 wallet_accounts。 - // 首笔送礼写入当时区域和代理;同周期后续收礼会刷新快照,结算以周期账户最终快照为准。 - after = metadata.HostPeriodDiamondAdded - version = 1 - _, err = tx.ExecContext(ctx, - `INSERT INTO host_period_diamond_accounts ( - app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total, - version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), - metadata.TargetUserID, - metadata.HostPeriodCycleKey, - metadata.TargetHostRegionID, - metadata.TargetAgencyOwnerUserID, - after, - metadata.HostPeriodDiamondAdded, - version, - nowMs, - nowMs, - nowMs, - nowMs, - ) - if err != nil && isMySQLDuplicateError(err) { - // 并发首笔送礼可能同时发现账户不存在;退回锁读更新路径,保证不丢钻石。 - after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey) - } - if err != nil { - return 0, 0, err - } - if !exists { - if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { - return 0, 0, err - } - return after, version, nil - } - } - - after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded) - if err != nil { - return 0, 0, err - } - version++ - // 已存在账户时更新累计钻石,同时刷新区域/代理快照为最近一次收礼状态;这符合“当月当前归属”口径。 - result, err := tx.ExecContext(ctx, - `UPDATE host_period_diamond_accounts - SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?, - version = ?, last_gift_at_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND user_id = ? AND cycle_key = ?`, - metadata.TargetHostRegionID, - metadata.TargetAgencyOwnerUserID, - after, - metadata.HostPeriodDiamondAdded, - version, - nowMs, - nowMs, - appcode.FromContext(ctx), - metadata.TargetUserID, - metadata.HostPeriodCycleKey, - ) - if err != nil { - return 0, 0, err - } - rows, err := result.RowsAffected() - if err != nil { - return 0, 0, err - } - if rows != 1 { - return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict") - } - if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil { - return 0, 0, err - } - return after, version, nil -} - -func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) { - // FOR UPDATE 串行化同主播同周期的并发送礼,保证 total_diamonds 不会被覆盖丢失。 - row := tx.QueryRowContext(ctx, - `SELECT total_diamonds, version - FROM host_period_diamond_accounts - WHERE app_code = ? AND user_id = ? AND cycle_key = ? - FOR UPDATE`, - appcode.FromContext(ctx), - userID, - cycleKey, - ) - var totalDiamonds int64 - var version int64 - if err := row.Scan(&totalDiamonds, &version); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return 0, 0, false, nil - } - return 0, 0, false, err - } - return totalDiamonds, version, true, nil -} - -func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error { - // 明细表记录每一笔送礼带来的工资钻石,后续核对周期账户累计值时不需要解析钱包 metadata。 - _, err := tx.ExecContext(ctx, - `INSERT INTO host_period_diamond_entries ( - app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id, - diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount, - gift_id, gift_count, sender_user_id, room_id, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), - transactionID, - commandID, - metadata.TargetUserID, - metadata.HostPeriodCycleKey, - metadata.TargetHostRegionID, - metadata.TargetAgencyOwnerUserID, - metadata.HostPeriodDiamondAdded, - diamondAfter, - metadata.ChargeAssetType, - metadata.ChargeAmount, - metadata.GiftID, - metadata.GiftCount, - metadata.SenderUserID, - metadata.RoomID, - nowMs, - ) - return err -} - -type walletOutboxEvent struct { - AppCode string - EventID string - EventType string - TransactionID string - CommandID string - UserID int64 - AssetType string - AvailableDelta int64 - FrozenDelta int64 - Payload any - CreatedAtMS int64 -} - -func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events []walletOutboxEvent) error { - for _, event := range events { - payloadJSON, err := json.Marshal(event.Payload) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, - `INSERT INTO wallet_outbox ( - app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, - available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, - normalizedEntryAppCode(ctx, event.AppCode), - event.EventID, - event.EventType, - event.TransactionID, - event.CommandID, - event.UserID, - event.AssetType, - event.AvailableDelta, - event.FrozenDelta, - string(payloadJSON), - outboxStatusPending, - event.CreatedAtMS, - event.CreatedAtMS, - ); err != nil { - return err - } - } - return nil -} - -// ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout. -func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) { - return r.ClaimPendingWalletOutboxFiltered(ctx, workerID, limit, lockUntilMS, WalletOutboxClaimFilter{}) -} - -// ClaimPendingWalletOutboxFiltered atomically claims committed wallet facts for one MQ fanout lane. -func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, workerID string, limit int, lockUntilMS int64, filter WalletOutboxClaimFilter) ([]WalletOutboxRecord, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - workerID = strings.TrimSpace(workerID) - if workerID == "" { - return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") - } - if limit <= 0 { - limit = 100 - } - if limit > 500 { - limit = 500 - } - nowMS := time.Now().UTC().UnixMilli() - if lockUntilMS <= nowMS { - lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli() - } - - appCode := appcode.FromContext(ctx) - eventFilterSQL, eventFilterArgs := walletOutboxEventFilterSQL(filter) - pendingIndex, claimIndex := walletOutboxClaimIndexes(filter) - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - - records := make([]WalletOutboxRecord, 0, limit) - claimBranches := []struct { - query string - args []any - }{ - { - query: walletOutboxClaimQuery(pendingIndex, ` - AND next_retry_at_ms IS NULL - AND lock_until_ms IS NULL`, eventFilterSQL), - args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...), - }, - { - query: walletOutboxClaimQuery(pendingIndex, ` - AND next_retry_at_ms <= ? - AND lock_until_ms IS NULL`, eventFilterSQL), - args: append([]any{appCode, outboxStatusRetryable, nowMS}, eventFilterArgs...), - }, - { - query: walletOutboxClaimQuery(claimIndex, ` - AND lock_until_ms <= ?`, eventFilterSQL), - args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...), - }, - } - for _, branch := range claimBranches { - remaining := limit - len(records) - if remaining <= 0 { - break - } - args := append(append([]any{}, branch.args...), remaining) - branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...) - if err != nil { - return nil, err - } - records = append(records, branchRecords...) - } - - for _, record := range records { - if _, err := tx.ExecContext(ctx, ` - UPDATE wallet_outbox - SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND event_id = ?`, - outboxStatusDelivering, - workerID, - lockUntilMS, - nowMS, - record.AppCode, - record.EventID, - ); err != nil { - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - for index := range records { - records[index].WorkerID = workerID - records[index].LockUntilMS = lockUntilMS - } - return records, nil -} - -func walletOutboxClaimQuery(indexName string, statusCondition string, eventFilterSQL string) string { - return ` - SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, - available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, - retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0) - FROM wallet_outbox FORCE INDEX (` + indexName + `) - WHERE app_code = ? AND status = ?` + statusCondition + eventFilterSQL + ` - ORDER BY created_at_ms ASC, event_id ASC - LIMIT ? - FOR UPDATE SKIP LOCKED` -} - -func walletOutboxEventFilterSQL(filter WalletOutboxClaimFilter) (string, []any) { - include := normalizeWalletOutboxEventTypes(filter.IncludeEventTypes) - if len(include) > 0 { - return " AND event_type IN (" + walletOutboxPlaceholders(len(include)) + ")", walletOutboxEventArgs(include) - } - exclude := normalizeWalletOutboxEventTypes(filter.ExcludeEventTypes) - if len(exclude) > 0 { - return " AND event_type NOT IN (" + walletOutboxPlaceholders(len(exclude)) + ")", walletOutboxEventArgs(exclude) - } - return "", nil -} - -func walletOutboxClaimIndexes(filter WalletOutboxClaimFilter) (string, string) { - if len(normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)) > 0 { - return "idx_wallet_outbox_event_pending", "idx_wallet_outbox_event_claim" - } - return "idx_wallet_outbox_pending", "idx_wallet_outbox_claim" -} - -func normalizeWalletOutboxEventTypes(values []string) []string { - normalized := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - normalized = append(normalized, value) - } - return normalized -} - -func walletOutboxPlaceholders(count int) string { - if count <= 0 { - return "" - } - placeholders := make([]string, count) - for index := range placeholders { - placeholders[index] = "?" - } - return strings.Join(placeholders, ",") -} - -func walletOutboxEventArgs(values []string) []any { - args := make([]any, 0, len(values)) - for _, value := range values { - args = append(args, value) - } - return args -} - -func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) { - rows, err := tx.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - records := make([]WalletOutboxRecord, 0) - for rows.Next() { - var record WalletOutboxRecord - if err := rows.Scan( - &record.AppCode, - &record.EventID, - &record.EventType, - &record.TransactionID, - &record.CommandID, - &record.UserID, - &record.AssetType, - &record.AvailableDelta, - &record.FrozenDelta, - &record.PayloadJSON, - &record.CreatedAtMS, - &record.RetryCount, - &record.LastError, - &record.WorkerID, - &record.LockUntilMS, - ); err != nil { - return nil, err - } - records = append(records, record) - } - if err := rows.Err(); err != nil { - return nil, err - } - return records, nil -} - -// MarkWalletOutboxDelivered marks one wallet fact as accepted by the MQ broker. -func (r *Repository) MarkWalletOutboxDelivered(ctx context.Context, eventID string) error { - if r == nil || r.db == nil { - return xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - _, err := r.db.ExecContext(ctx, ` - UPDATE wallet_outbox - SET status = ?, worker_id = '', lock_until_ms = NULL, next_retry_at_ms = NULL, last_error = NULL, updated_at_ms = ? - WHERE app_code = ? AND event_id = ?`, - outboxStatusDelivered, - time.Now().UTC().UnixMilli(), - appcode.FromContext(ctx), - eventID, - ) - return err -} - -// MarkWalletOutboxRetryable releases one failed MQ publish with a bounded retry schedule. -func (r *Repository) MarkWalletOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { - if r == nil || r.db == nil { - return xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - _, err := r.db.ExecContext(ctx, ` - UPDATE wallet_outbox - SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? - WHERE app_code = ? AND event_id = ?`, - outboxStatusRetryable, - nextRetryAtMS, - trimWalletOutboxError(lastErr), - time.Now().UTC().UnixMilli(), - appcode.FromContext(ctx), - eventID, - ) - return err -} - -// MarkWalletOutboxDead stops retrying a poison wallet fact after the configured retry ceiling. -func (r *Repository) MarkWalletOutboxDead(ctx context.Context, eventID string, lastErr string) error { - if r == nil || r.db == nil { - return xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - _, err := r.db.ExecContext(ctx, ` - UPDATE wallet_outbox - SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ? - WHERE app_code = ? AND event_id = ?`, - outboxStatusFailed, - trimWalletOutboxError(lastErr), - time.Now().UTC().UnixMilli(), - appcode.FromContext(ctx), - eventID, - ) - return err -} - -func trimWalletOutboxError(value string) string { - value = strings.TrimSpace(value) - if len(value) > 1024 { - return value[:1024] - } - return value -} - -func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.Receipt{}, err - } - var metadata giftMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.Receipt{}, err - } - return receiptFromGiftMetadata(transactionID, metadata), nil -} - -func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string) (ledger.AssetBalance, error) { - row := tx.QueryRowContext(ctx, - `SELECT app_code, user_id, asset_type, available_after, frozen_after - FROM wallet_entries - WHERE app_code = ? AND transaction_id = ? AND user_id = ? AND asset_type = ? - ORDER BY entry_id DESC LIMIT 1`, - appcode.FromContext(ctx), transactionID, userID, assetType, - ) - var balance ledger.AssetBalance - if err := row.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount); err != nil { - return ledger.AssetBalance{}, err - } - return balance, nil -} - -type giftMetadata struct { - AppCode string `json:"app_code"` - GiftID string `json:"gift_id"` - GiftName string `json:"gift_name"` - GiftIconURL string `json:"gift_icon_url"` - GiftAnimationURL string `json:"gift_animation_url"` - GiftEffectTypes []string `json:"gift_effect_types"` - ResourceID int64 `json:"resource_id"` - ResourceSnapshot string `json:"resource_snapshot_json"` - GiftTypeCode string `json:"gift_type_code"` - CPRelationType string `json:"cp_relation_type"` - PresentationJSON string `json:"presentation_json"` - SortOrder int32 `json:"sort_order"` - GiftCount int32 `json:"gift_count"` - PriceVersion string `json:"price_version"` - CoinPrice int64 `json:"coin_price"` - // GiftPointAmount 是历史礼物积分单价字段,新送礼固定写 0,业务统计只读真实扣费和热度。 - GiftPointAmount int64 `json:"gift_point_amount"` - HeatUnitValue int64 `json:"heat_unit_value"` - ChargeAssetType string `json:"charge_asset_type"` - ChargeAmount int64 `json:"charge_amount"` - CoinSpent int64 `json:"coin_spent"` - // ChargeSource 记录本次送礼从哪里扣费;bag 会扣用户资源库存,但 CoinSpent 仍保留礼物价值用于房间贡献。 - ChargeSource string `json:"charge_source,omitempty"` - // EntitlementID 只在背包送礼时存在,用于排查某次送礼具体消耗了哪条用户权益。 - EntitlementID string `json:"entitlement_id,omitempty"` - // GiftPointAdded 是历史收礼积分字段,新送礼固定写 0,保留 JSON 字段只为旧事件解析兼容。 - GiftPointAdded int64 `json:"gift_point_added"` - HeatValue int64 `json:"heat_value"` - BalanceAfter int64 `json:"balance_after"` - GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"` - GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"` - GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"` - GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"` - BillingReceipt string `json:"billing_receipt_id"` - SenderUserID int64 `json:"sender_user_id"` - SenderRegionID int64 `json:"sender_region_id"` - TargetUserID int64 `json:"target_user_id"` - TargetIsHost bool `json:"target_is_host"` - TargetHostRegionID int64 `json:"target_host_region_id"` - TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` - HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` - GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` - GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` - HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` - HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` - HostPeriodCycleKey string `json:"host_period_cycle_key"` - RoomID string `json:"room_id"` - RoomRegionID int64 `json:"room_region_id"` - RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"` - RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"` - RobotGift bool `json:"robot_gift,omitempty"` -} - -type adminCreditMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - OperatorUserID int64 `json:"operator_user_id"` - Reason string `json:"reason"` - EvidenceRef string `json:"evidence_ref"` -} - -type cpBreakupFeeMetadata struct { - AppCode string `json:"app_code"` - UserID int64 `json:"user_id"` - RelationshipID string `json:"relationship_id"` - RelationType string `json:"relation_type"` - Amount int64 `json:"amount"` - CoinBalanceAfter int64 `json:"coin_balance_after"` -} - -type wheelDrawDebitMetadata struct { - AppCode string `json:"app_code"` - UserID int64 `json:"user_id"` - WheelID string `json:"wheel_id"` - DrawCount int32 `json:"draw_count"` - Amount int64 `json:"amount"` - CoinBalanceAfter int64 `json:"coin_balance_after"` -} - -type taskRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - TaskType string `json:"task_type"` - TaskID string `json:"task_id"` - CycleKey string `json:"cycle_key"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type luckyGiftRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - DrawID string `json:"draw_id"` - RoomID string `json:"room_id"` - VisibleRegionID int64 `json:"visible_region_id"` - CountryID int64 `json:"country_id"` - GiftID string `json:"gift_id"` - PoolID string `json:"pool_id"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type wheelRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - DrawID string `json:"draw_id"` - WheelID string `json:"wheel_id"` - SelectedTierID string `json:"selected_tier_id"` - VisibleRegionID int64 `json:"visible_region_id"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type roomTurnoverRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - SettlementID string `json:"settlement_id"` - RoomID string `json:"room_id"` - PeriodStartMS int64 `json:"period_start_ms"` - PeriodEndMS int64 `json:"period_end_ms"` - CoinSpent int64 `json:"coin_spent"` - TierID int64 `json:"tier_id"` - TierCode string `json:"tier_code"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type inviteActivityRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - ClaimID string `json:"claim_id"` - RewardType string `json:"reward_type"` - TierID int64 `json:"tier_id"` - TierCode string `json:"tier_code"` - CycleKey string `json:"cycle_key"` - ReachedValue int64 `json:"reached_value"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type agencyOpeningRewardMetadata struct { - AppCode string `json:"app_code"` - TargetUserID int64 `json:"target_user_id"` - AssetType string `json:"asset_type"` - Amount int64 `json:"amount"` - ApplicationID string `json:"application_id"` - CycleID string `json:"cycle_id"` - AgencyID int64 `json:"agency_id"` - RankNo int32 `json:"rank_no"` - ScoreCoins int64 `json:"score_coins"` - Reason string `json:"reason"` - BalanceAfter int64 `json:"balance_after"` - GrantedAtMS int64 `json:"granted_at_ms"` -} - -type gameCoinMetadata struct { - AppCode string `json:"app_code"` - UserID int64 `json:"user_id"` - PlatformCode string `json:"platform_code"` - GameID string `json:"game_id"` - ProviderOrderID string `json:"provider_order_id"` - ProviderRoundID string `json:"provider_round_id"` - OpType string `json:"op_type"` - AssetType string `json:"asset_type"` - CoinAmount int64 `json:"coin_amount"` - AvailableDelta int64 `json:"available_delta"` - RoomID string `json:"room_id"` - BalanceAfter int64 `json:"balance_after"` - AppliedAtMS int64 `json:"applied_at_ms"` -} - -type coinSellerTransferMetadata struct { - AppCode string `json:"app_code"` - PaymentOrderID string `json:"payment_order_id,omitempty"` - ProviderCode string `json:"provider,omitempty"` - PaymentMethodID int64 `json:"payment_method_id,omitempty"` - SellerUserID int64 `json:"seller_user_id"` - TargetUserID int64 `json:"target_user_id"` - TargetCountryID int64 `json:"target_country_id"` - SellerRegionID int64 `json:"seller_region_id"` - TargetRegionID int64 `json:"target_region_id"` - Amount int64 `json:"amount"` - Reason string `json:"reason"` - SellerAssetType string `json:"seller_asset_type"` - TargetAssetType string `json:"target_asset_type"` - SellerBalanceAfter int64 `json:"seller_balance_after"` - TargetBalanceAfter int64 `json:"target_balance_after"` - RechargeSequence int64 `json:"recharge_sequence"` - RechargeUSDMinor int64 `json:"recharge_usd_minor"` - RechargeCurrencyCode string `json:"recharge_currency_code"` - RechargePolicyID int64 `json:"recharge_policy_id"` - RechargePolicyVersion string `json:"recharge_policy_version"` - RechargePolicyCoinAmount int64 `json:"recharge_policy_coin_amount"` - RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"` - RechargeType string `json:"recharge_type,omitempty"` -} - -type salaryExchangeMetadata struct { - AppCode string `json:"app_code"` - UserID int64 `json:"user_id"` - SalaryAssetType string `json:"salary_asset_type"` - CoinAssetType string `json:"coin_asset_type"` - SalaryUSDMinor int64 `json:"salary_usd_minor"` - CoinPerUSD int64 `json:"coin_per_usd"` - CoinAmount int64 `json:"coin_amount"` - SalaryBalanceAfter int64 `json:"salary_balance_after"` - CoinBalanceAfter int64 `json:"coin_balance_after"` - Reason string `json:"reason"` - CreatedAtMS int64 `json:"created_at_ms"` -} - -type salaryTransferToCoinSellerMetadata struct { - AppCode string `json:"app_code"` - SourceUserID int64 `json:"source_user_id"` - SellerUserID int64 `json:"seller_user_id"` - RegionID int64 `json:"region_id"` - SalaryAssetType string `json:"salary_asset_type"` - SellerAssetType string `json:"seller_asset_type"` - SalaryUSDMinor int64 `json:"salary_usd_minor"` - CoinPerUSD int64 `json:"coin_per_usd"` - CoinAmount int64 `json:"coin_amount"` - RateMinUSDMinor int64 `json:"rate_min_usd_minor"` - RateMaxUSDMinor int64 `json:"rate_max_usd_minor"` - SourceSalaryBalanceAfter int64 `json:"source_salary_balance_after"` - SellerBalanceAfter int64 `json:"seller_balance_after"` - Reason string `json:"reason"` - CreatedAtMS int64 `json:"created_at_ms"` -} - -type coinSellerStockMetadata struct { - AppCode string `json:"app_code"` - PaymentOrderID string `json:"payment_order_id,omitempty"` - ProviderCode string `json:"provider,omitempty"` - PaymentMethodID int64 `json:"payment_method_id,omitempty"` - SellerUserID int64 `json:"seller_user_id"` - SellerCountryID int64 `json:"seller_country_id"` - SellerRegionID int64 `json:"seller_region_id"` - StockType string `json:"stock_type"` - CoinAmount int64 `json:"coin_amount"` - PaidCurrencyCode string `json:"paid_currency_code"` - PaidAmountMicro int64 `json:"paid_amount_micro"` - PaymentRef string `json:"payment_ref"` - EvidenceRef string `json:"evidence_ref"` - OperatorUserID int64 `json:"operator_user_id"` - Reason string `json:"reason"` - AssetType string `json:"asset_type"` - CountsAsSellerRecharge bool `json:"counts_as_seller_recharge"` - BalanceAfter int64 `json:"balance_after"` - CreatedAtMS int64 `json:"created_at_ms"` -} - -func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt { - chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType) - if metadata.RobotGift { - chargeAssetType = ledger.AssetRobotCoin - } - chargeAmount := metadata.ChargeAmount - if chargeAmount == 0 { - chargeAmount = metadata.CoinSpent - } - return ledger.Receipt{ - BillingReceiptID: metadata.BillingReceipt, - TransactionID: transactionID, - CoinSpent: metadata.CoinSpent, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - GiftPointAdded: 0, - HeatValue: metadata.HeatValue, - GiftTypeCode: metadata.GiftTypeCode, - CPRelationType: metadata.CPRelationType, - GiftName: metadata.GiftName, - GiftIconURL: metadata.GiftIconURL, - GiftAnimationURL: metadata.GiftAnimationURL, - GiftEffectTypes: normalizeGiftEffectTypes(metadata.GiftEffectTypes), - PriceVersion: metadata.PriceVersion, - BalanceAfter: metadata.BalanceAfter, - HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded, - HostPeriodCycleKey: metadata.HostPeriodCycleKey, - EntitlementID: strings.TrimSpace(metadata.EntitlementID), - ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID), - } -} - -func giftDisplayIconURL(resource resourcedomain.Resource) string { - // preview_url 是面板和 IM 的首选静态图;旧资源没有 preview 时回退 asset_url,避免关系广播缺图。 - if value := strings.TrimSpace(resource.PreviewURL); value != "" { - return value - } - return strings.TrimSpace(resource.AssetURL) -} - -func (r *Repository) receiptForTaskRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.TaskRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.TaskRewardReceipt{}, err - } - var metadata taskRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.TaskRewardReceipt{}, err - } - return receiptFromTaskRewardMetadata(transactionID, metadata), nil -} - -func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMetadata) ledger.TaskRewardReceipt { - return ledger.TaskRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForLuckyGiftRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.LuckyGiftRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - var metadata luckyGiftRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.LuckyGiftRewardReceipt{}, err - } - return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil -} - -func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGiftRewardMetadata) ledger.LuckyGiftRewardReceipt { - return ledger.LuckyGiftRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForWheelRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.WheelRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.WheelRewardReceipt{}, err - } - var metadata wheelRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.WheelRewardReceipt{}, err - } - return receiptFromWheelRewardMetadata(transactionID, metadata), nil -} - -func receiptFromWheelRewardMetadata(transactionID string, metadata wheelRewardMetadata) ledger.WheelRewardReceipt { - return ledger.WheelRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - var metadata roomTurnoverRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.RoomTurnoverRewardReceipt{}, err - } - return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil -} - -func receiptFromRoomTurnoverRewardMetadata(transactionID string, metadata roomTurnoverRewardMetadata) ledger.RoomTurnoverRewardReceipt { - return ledger.RoomTurnoverRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForInviteActivityRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.InviteActivityRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - var metadata inviteActivityRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.InviteActivityRewardReceipt{}, err - } - return receiptFromInviteActivityRewardMetadata(transactionID, metadata), nil -} - -func receiptFromInviteActivityRewardMetadata(transactionID string, metadata inviteActivityRewardMetadata) ledger.InviteActivityRewardReceipt { - return ledger.InviteActivityRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForAgencyOpeningRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.AgencyOpeningRewardReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - var metadata agencyOpeningRewardMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.AgencyOpeningRewardReceipt{}, err - } - return receiptFromAgencyOpeningRewardMetadata(transactionID, metadata), nil -} - -func receiptFromAgencyOpeningRewardMetadata(transactionID string, metadata agencyOpeningRewardMetadata) ledger.AgencyOpeningRewardReceipt { - return ledger.AgencyOpeningRewardReceipt{ - TransactionID: transactionID, - Balance: ledger.AssetBalance{ - AppCode: metadata.AppCode, - UserID: metadata.TargetUserID, - AssetType: metadata.AssetType, - AvailableAmount: metadata.BalanceAfter, - }, - Amount: metadata.Amount, - GrantedAtMS: metadata.GrantedAtMS, - } -} - -func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - var metadata gameCoinMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.GameCoinChangeReceipt{}, err - } - return receiptFromGameMetadata(transactionID, metadata, false), nil -} - -func receiptFromGameMetadata(transactionID string, metadata gameCoinMetadata, idempotentReplay bool) ledger.GameCoinChangeReceipt { - return ledger.GameCoinChangeReceipt{ - TransactionID: transactionID, - BalanceAfter: metadata.BalanceAfter, - IdempotentReplay: idempotentReplay, - } -} - -func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) { - row := tx.QueryRowContext(ctx, - `SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro, - counts_as_seller_recharge, balance_after, metadata_json, created_at_ms - FROM coin_seller_stock_records - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ) - var receipt ledger.CoinSellerStockCreditReceipt - var metadataJSON string - if err := row.Scan( - &receipt.SellerUserID, - &receipt.StockType, - &receipt.CoinAmount, - &receipt.PaidCurrencyCode, - &receipt.PaidAmountMicro, - &receipt.CountsAsSellerRecharge, - &receipt.BalanceAfter, - &metadataJSON, - &receipt.CreatedAtMS, - ); err != nil { - return ledger.CoinSellerStockCreditReceipt{}, err - } - if metadataJSON != "" { - var metadata coinSellerStockMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err == nil { - receipt.SellerCountryID = metadata.SellerCountryID - receipt.SellerRegionID = metadata.SellerRegionID - } - } - receipt.TransactionID = transactionID - return receipt, nil -} - -func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt { - return ledger.CoinSellerStockCreditReceipt{ - TransactionID: transactionID, - SellerUserID: metadata.SellerUserID, - SellerCountryID: metadata.SellerCountryID, - SellerRegionID: metadata.SellerRegionID, - StockType: metadata.StockType, - CoinAmount: metadata.CoinAmount, - PaidCurrencyCode: metadata.PaidCurrencyCode, - PaidAmountMicro: metadata.PaidAmountMicro, - CountsAsSellerRecharge: metadata.CountsAsSellerRecharge, - BalanceAfter: metadata.BalanceAfter, - CreatedAtMS: metadata.CreatedAtMS, - } -} - -func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - var metadata coinSellerTransferMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.CoinSellerTransferReceipt{}, err - } - return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil -} - -func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSellerTransferMetadata) ledger.CoinSellerTransferReceipt { - return ledger.CoinSellerTransferReceipt{ - TransactionID: transactionID, - SellerBalanceAfter: metadata.SellerBalanceAfter, - TargetBalanceAfter: metadata.TargetBalanceAfter, - Amount: metadata.Amount, - RechargeSequence: metadata.RechargeSequence, - RechargeUSDMinor: metadata.RechargeUSDMinor, - RechargeCurrencyCode: metadata.RechargeCurrencyCode, - RechargePolicyID: metadata.RechargePolicyID, - RechargePolicyVersion: metadata.RechargePolicyVersion, - RechargePolicyCoinAmount: metadata.RechargePolicyCoinAmount, - RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount, - } -} - -func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - var metadata salaryExchangeMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.SalaryExchangeReceipt{}, err - } - return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil -} - -func receiptFromSalaryExchangeMetadata(transactionID string, metadata salaryExchangeMetadata) ledger.SalaryExchangeReceipt { - return ledger.SalaryExchangeReceipt{ - TransactionID: transactionID, - UserID: metadata.UserID, - SalaryAssetType: metadata.SalaryAssetType, - SalaryBalanceAfter: metadata.SalaryBalanceAfter, - CoinBalanceAfter: metadata.CoinBalanceAfter, - SalaryUSDMinor: metadata.SalaryUSDMinor, - CoinAmount: metadata.CoinAmount, - CoinPerUSD: metadata.CoinPerUSD, - CreatedAtMS: metadata.CreatedAtMS, - } -} - -func (r *Repository) receiptForSalaryTransferToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryTransferToCoinSellerReceipt, error) { - var metadataJSON string - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') - FROM wallet_transactions - WHERE app_code = ? AND transaction_id = ?`, - appcode.FromContext(ctx), - transactionID, - ).Scan(&metadataJSON); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - var metadata salaryTransferToCoinSellerMetadata - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - return ledger.SalaryTransferToCoinSellerReceipt{}, err - } - return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil -} - -func receiptFromSalaryTransferToCoinSellerMetadata(transactionID string, metadata salaryTransferToCoinSellerMetadata) ledger.SalaryTransferToCoinSellerReceipt { - return ledger.SalaryTransferToCoinSellerReceipt{ - TransactionID: transactionID, - SourceUserID: metadata.SourceUserID, - SellerUserID: metadata.SellerUserID, - SalaryAssetType: metadata.SalaryAssetType, - SourceSalaryBalanceAfter: metadata.SourceSalaryBalanceAfter, - SellerBalanceAfter: metadata.SellerBalanceAfter, - SalaryUSDMinor: metadata.SalaryUSDMinor, - CoinAmount: metadata.CoinAmount, - CoinPerUSD: metadata.CoinPerUSD, - RateMinUSDMinor: metadata.RateMinUSDMinor, - RateMaxUSDMinor: metadata.RateMaxUSDMinor, - CreatedAtMS: metadata.CreatedAtMS, - } -} - -func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType), - EventType: "WalletBalanceChanged", - TransactionID: transactionID, - CommandID: commandID, - UserID: userID, - AssetType: assetType, - AvailableDelta: availableDelta, - FrozenDelta: frozenDelta, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": userID, - "asset_type": assetType, - "available_delta": availableDelta, - "frozen_delta": frozenDelta, - "available_after": availableAfter, - "frozen_after": frozenAfter, - "balance_version": balanceVersion, - "metadata": payload, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin), - EventType: "WalletCoinSellerTransferred", - TransactionID: transactionID, - CommandID: commandID, - UserID: sellerUserID, - AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: availableDelta, - FrozenDelta: 0, - Payload: payload, - CreatedAtMS: nowMs, - } -} - -func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { - assetType := ledger.AssetCoin - if metadata, ok := payload.(coinSellerTransferMetadata); ok { - if strings.TrimSpace(metadata.TargetAssetType) != "" { - assetType = metadata.TargetAssetType - } - rechargeType := strings.TrimSpace(metadata.RechargeType) - if rechargeType == "" { - rechargeType = "coin_seller_transfer" - } - // WalletRechargeRecorded 是统计服务的充值入口;币商转账没有 USDT 金额,但必须带目标用户真实国家/区域,避免统计继续把 region 当 country。 - payload = map[string]any{ - "app_code": metadata.AppCode, - "payment_order_id": metadata.PaymentOrderID, - "provider": metadata.ProviderCode, - "payment_method_id": metadata.PaymentMethodID, - "seller_user_id": metadata.SellerUserID, - "target_user_id": metadata.TargetUserID, - "target_country_id": metadata.TargetCountryID, - "country_id": metadata.TargetCountryID, - "seller_region_id": metadata.SellerRegionID, - "target_region_id": metadata.TargetRegionID, - "region_id": metadata.TargetRegionID, - "amount": metadata.Amount, - "reason": metadata.Reason, - "seller_asset_type": metadata.SellerAssetType, - "target_asset_type": metadata.TargetAssetType, - "seller_balance_after": metadata.SellerBalanceAfter, - "target_balance_after": metadata.TargetBalanceAfter, - "recharge_sequence": metadata.RechargeSequence, - "recharge_usd_minor": metadata.RechargeUSDMinor, - "recharge_currency_code": metadata.RechargeCurrencyCode, - "recharge_policy_id": metadata.RechargePolicyID, - "recharge_policy_version": metadata.RechargePolicyVersion, - "recharge_policy_coin_amount": metadata.RechargePolicyCoinAmount, - "recharge_policy_usd_minor_amount": metadata.RechargePolicyUSDMinorAmount, - "recharge_type": rechargeType, - "created_at_ms": nowMs, - } - } - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletRechargeRecorded", targetUserID, assetType), - EventType: "WalletRechargeRecorded", - TransactionID: transactionID, - CommandID: commandID, - UserID: targetUserID, - AssetType: assetType, - AvailableDelta: availableDelta, - FrozenDelta: 0, - Payload: payload, - CreatedAtMS: nowMs, - } -} - -func taskRewardCreditedEvent(transactionID string, commandID string, metadata taskRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletTaskRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletTaskRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "task_type": metadata.TaskType, - "task_id": metadata.TaskID, - "cycle_key": metadata.CycleKey, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metadata luckyGiftRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletLuckyGiftRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletLuckyGiftRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "draw_id": metadata.DrawID, - "room_id": metadata.RoomID, - "visible_region_id": metadata.VisibleRegionID, - "region_id": metadata.VisibleRegionID, - "country_id": metadata.CountryID, - "gift_id": metadata.GiftID, - "pool_id": metadata.PoolID, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func wheelRewardCreditedEvent(transactionID string, commandID string, metadata wheelRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletWheelRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletWheelRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "draw_id": metadata.DrawID, - "wheel_id": metadata.WheelID, - "selected_tier_id": metadata.SelectedTierID, - "visible_region_id": metadata.VisibleRegionID, - "region_id": metadata.VisibleRegionID, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletRoomTurnoverRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "settlement_id": metadata.SettlementID, - "room_id": metadata.RoomID, - "period_start_ms": metadata.PeriodStartMS, - "period_end_ms": metadata.PeriodEndMS, - "coin_spent": metadata.CoinSpent, - "tier_id": metadata.TierID, - "tier_code": metadata.TierCode, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func inviteActivityRewardCreditedEvent(transactionID string, commandID string, metadata inviteActivityRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletInviteActivityRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletInviteActivityRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "claim_id": metadata.ClaimID, - "reward_type": metadata.RewardType, - "tier_id": metadata.TierID, - "tier_code": metadata.TierCode, - "cycle_key": metadata.CycleKey, - "reached_value": metadata.ReachedValue, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func agencyOpeningRewardCreditedEvent(transactionID string, commandID string, metadata agencyOpeningRewardMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletAgencyOpeningRewardCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletAgencyOpeningRewardCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.Amount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "application_id": metadata.ApplicationID, - "cycle_id": metadata.CycleID, - "agency_id": metadata.AgencyID, - "rank_no": metadata.RankNo, - "score_coins": metadata.ScoreCoins, - "amount": metadata.Amount, - "reason": metadata.Reason, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent { - eventType := "WalletCoinSellerCoinCompensated" - if metadata.StockType == ledger.StockTypeUSDTPurchase { - eventType = "WalletCoinSellerStockPurchased" - } - return walletOutboxEvent{ - EventID: eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin), - EventType: eventType, - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.SellerUserID, - AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: metadata.CoinAmount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "seller_user_id": metadata.SellerUserID, - "seller_country_id": metadata.SellerCountryID, - "seller_region_id": metadata.SellerRegionID, - "country_id": metadata.SellerCountryID, - "region_id": metadata.SellerRegionID, - "stock_type": metadata.StockType, - "coin_amount": metadata.CoinAmount, - "paid_currency_code": metadata.PaidCurrencyCode, - "paid_amount_micro": metadata.PaidAmountMicro, - "counts_as_seller_recharge": metadata.CountsAsSellerRecharge, - "operator_user_id": metadata.OperatorUserID, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType), - EventType: "WalletGiftDebited", - TransactionID: transactionID, - CommandID: commandID, - UserID: userID, - AssetType: assetType, - AvailableDelta: availableDelta, - FrozenDelta: frozenDelta, - Payload: payload, - CreatedAtMS: nowMs, - } -} - -func giftIncomeCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "WalletGiftIncomeCredited", metadata.TargetUserID, ledger.AssetCoin), - EventType: "WalletGiftIncomeCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetCoin, - AvailableDelta: metadata.GiftIncomeCoinAmount, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "user_id": metadata.TargetUserID, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "gift_type_code": metadata.GiftTypeCode, - "gift_charge_amount": metadata.ChargeAmount, - "charge_source": metadata.ChargeSource, - "amount": metadata.GiftIncomeCoinAmount, - "ratio_percent": metadata.GiftIncomeRatioPercent, - "ratio_region_id": metadata.GiftIncomeRatioRegionID, - "gift_income_balance_after": metadata.GiftIncomeBalanceAfter, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond), - EventType: "HostPeriodDiamondCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetHostPeriodDiamond, - AvailableDelta: metadata.HostPeriodDiamondAdded, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "cycle_key": metadata.HostPeriodCycleKey, - "region_id": metadata.TargetHostRegionID, - "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, - "diamond_delta": metadata.HostPeriodDiamondAdded, - "diamond_after": metadata.HostPeriodDiamondAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "sender_user_id": metadata.SenderUserID, - "sender_region_id": metadata.SenderRegionID, - "room_id": metadata.RoomID, - "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, - "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func normalizeAssetTypes(assetTypes []string) []string { - seen := make(map[string]bool, len(assetTypes)) - result := make([]string, 0, len(assetTypes)) - for _, assetType := range assetTypes { - assetType = strings.ToUpper(strings.TrimSpace(assetType)) - if assetType == "" || seen[assetType] { - continue - } - seen[assetType] = true - result = append(result, assetType) - } - return result -} - -func contextWithCommandApp(ctx context.Context, value string) context.Context { - if strings.TrimSpace(value) == "" { - return appcode.WithContext(ctx, appcode.FromContext(ctx)) - } - - return appcode.WithContext(ctx, value) -} - -func normalizedEntryAppCode(ctx context.Context, value string) string { - if strings.TrimSpace(value) == "" { - return appcode.FromContext(ctx) - } - - return appcode.Normalize(value) -} - -func checkedMul(unit int64, count int64) (int64, error) { - if unit < 0 || count <= 0 { - return 0, xerr.New(xerr.InvalidArgument, "amount is invalid") - } - if unit != 0 && count > math.MaxInt64/unit { - return 0, xerr.New(xerr.InvalidArgument, "amount overflow") - } - return unit * count, nil -} - -func checkedAdd(left int64, right int64) (int64, error) { - if right < 0 || left > math.MaxInt64-right { - return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow") - } - return left + right, nil -} - -func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, target ledger.DebitGiftTargetCommand) ledger.DebitGiftCommand { - // 批量目标最终仍落成独立 gift_debit 交易;复用单目标 hash 可以保证账务语义变化时照样触发幂等冲突。 - return ledger.DebitGiftCommand{ - AppCode: command.AppCode, - CommandID: target.CommandID, - RoomID: command.RoomID, - SenderUserID: command.SenderUserID, - TargetUserID: target.TargetUserID, - GiftID: command.GiftID, - GiftCount: command.GiftCount, - PriceVersion: command.PriceVersion, - RegionID: command.RegionID, - SenderRegionID: command.SenderRegionID, - TargetIsHost: target.TargetIsHost, - TargetHostRegionID: target.TargetHostRegionID, - TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, - EntitlementID: command.EntitlementID, - ChargeSource: command.ChargeSource, - } -} - -func debitGiftCommandFromBatchCommand(command ledger.BatchDebitGiftCommand) ledger.DebitGiftCommand { - // 背包批量送礼的库存扣减只发生一次;这里构造共享命令快照用于统一校验 sender、gift 和 entitlement。 - return ledger.DebitGiftCommand{ - AppCode: command.AppCode, - CommandID: command.CommandID, - RoomID: command.RoomID, - SenderUserID: command.SenderUserID, - GiftID: command.GiftID, - GiftCount: command.GiftCount, - PriceVersion: command.PriceVersion, - RegionID: command.RegionID, - SenderRegionID: command.SenderRegionID, - EntitlementID: command.EntitlementID, - ChargeSource: command.ChargeSource, - } -} - -func normalizeGiftChargeSource(source string, entitlementID string) string { - normalized := strings.ToLower(strings.TrimSpace(source)) - if normalized == giftChargeSourceBag || strings.TrimSpace(entitlementID) != "" { - return giftChargeSourceBag - } - return giftChargeSourceCoin -} - -func (r *Repository) consumeGiftEntitlementForSend(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, giftResourceID int64, quantity int64, metadata giftMetadata, nowMs int64) (walletOutboxEvent, error) { - entitlementID := strings.TrimSpace(command.EntitlementID) - if entitlementID == "" { - return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "entitlement_id is required") - } - if command.SenderUserID <= 0 || giftResourceID <= 0 || quantity <= 0 { - return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement command is invalid") - } - row := tx.QueryRowContext(ctx, userResourceSelectSQL()+` - WHERE e.app_code = ? AND e.entitlement_id = ? - FOR UPDATE`, - appcode.FromContext(ctx), entitlementID, - ) - entitlement, err := scanUserResourceEntitlement(row) - if errors.Is(err, sql.ErrNoRows) { - return walletOutboxEvent{}, xerr.New(xerr.NotFound, "bag gift entitlement not found") - } - if err != nil { - return walletOutboxEvent{}, err - } - if entitlement.UserID != command.SenderUserID { - return walletOutboxEvent{}, xerr.New(xerr.PermissionDenied, "bag gift entitlement does not belong to sender") - } - if entitlement.ResourceID != giftResourceID { - return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement resource mismatch") - } - if entitlement.Status != resourcedomain.StatusActive || entitlement.Resource.Status != resourcedomain.StatusActive { - return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is not active") - } - if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeGift { - return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag entitlement resource_type must be gift") - } - if entitlement.EffectiveAtMS > nowMs || (entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMs) { - return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is expired") - } - if entitlement.RemainingQuantity < quantity { - return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient") - } - result, err := tx.ExecContext(ctx, ` - UPDATE user_resource_entitlements - SET remaining_quantity = remaining_quantity - ?, updated_at_ms = ? - WHERE app_code = ? AND entitlement_id = ? AND remaining_quantity >= ?`, - quantity, nowMs, appcode.FromContext(ctx), entitlementID, quantity, - ) - if err != nil { - return walletOutboxEvent{}, err - } - affected, err := result.RowsAffected() - if err != nil { - return walletOutboxEvent{}, err - } - if affected != 1 { - // 行锁后的二次条件正常不会失败;保留该分支用于防并发或手工修数后的 fail-close。 - return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient") - } - entitlement.RemainingQuantity -= quantity - entitlement.UpdatedAtMS = nowMs - return resourceOutboxEvent("UserResourceChanged", command.CommandID, command.SenderUserID, giftResourceID, map[string]any{ - "action": "consume_gift_send", - "app_code": appcode.FromContext(ctx), - "user_id": command.SenderUserID, - "resource_id": giftResourceID, - "resource_type": entitlement.Resource.ResourceType, - "entitlement_id": entitlementID, - "consumed_quantity": quantity, - "remaining_quantity": entitlement.RemainingQuantity, - "room_id": command.RoomID, - "gift_id": command.GiftID, - "charge_source": giftChargeSourceBag, - "gift_value_coins": metadata.CoinSpent, - "updated_at_ms": nowMs, - "entitlement": entitlement, - }, nowMs), nil -} - -func giftDebitBizType(command ledger.DebitGiftCommand) string { - if command.RobotGift { - return bizTypeRobotGiftDebit - } - return bizTypeGiftDebit -} - -func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Receipt, error) { - aggregate := ledger.Receipt{} - billingReceiptIDs := make([]string, 0, len(targets)) - transactionIDs := make([]string, 0, len(targets)) - for _, target := range targets { - receipt := target.Receipt - if receipt.BillingReceiptID != "" { - billingReceiptIDs = append(billingReceiptIDs, receipt.BillingReceiptID) - } - if receipt.TransactionID != "" { - transactionIDs = append(transactionIDs, receipt.TransactionID) - } - var err error - if aggregate.CoinSpent, err = checkedAdd(aggregate.CoinSpent, receipt.CoinSpent); err != nil { - return ledger.Receipt{}, err - } - if aggregate.ChargeAmount, err = checkedAdd(aggregate.ChargeAmount, receipt.ChargeAmount); err != nil { - return ledger.Receipt{}, err - } - if aggregate.HeatValue, err = checkedAdd(aggregate.HeatValue, receipt.HeatValue); err != nil { - return ledger.Receipt{}, err - } - if aggregate.HostPeriodDiamondAdded, err = checkedAdd(aggregate.HostPeriodDiamondAdded, receipt.HostPeriodDiamondAdded); err != nil { - return ledger.Receipt{}, err - } - if aggregate.ChargeAssetType == "" { - aggregate.ChargeAssetType = receipt.ChargeAssetType - } else if receipt.ChargeAssetType != "" && aggregate.ChargeAssetType != receipt.ChargeAssetType { - aggregate.ChargeAssetType = giftWallChargeAssetMixed - } - if aggregate.GiftTypeCode == "" { - aggregate.GiftTypeCode = receipt.GiftTypeCode - } - if aggregate.CPRelationType == "" { - aggregate.CPRelationType = receipt.CPRelationType - } - if aggregate.GiftName == "" { - aggregate.GiftName = receipt.GiftName - } - if aggregate.GiftIconURL == "" { - aggregate.GiftIconURL = receipt.GiftIconURL - } - if aggregate.GiftAnimationURL == "" { - aggregate.GiftAnimationURL = receipt.GiftAnimationURL - } - if aggregate.PriceVersion == "" { - aggregate.PriceVersion = receipt.PriceVersion - } - if aggregate.HostPeriodCycleKey == "" { - aggregate.HostPeriodCycleKey = receipt.HostPeriodCycleKey - } - // sender 账后余额随目标顺序逐笔递减;聚合响应返回最后一笔后的余额,表示整批完成后的余额。 - aggregate.BalanceAfter = receipt.BalanceAfter - } - aggregate.BillingReceiptID = strings.Join(billingReceiptIDs, ",") - aggregate.TransactionID = strings.Join(transactionIDs, ",") - return aggregate, nil -} - -func hostPeriodCycleKeyFromMS(ms int64) string { - // 工资周期用 UTC 月份锚定,避免服务节点本地时区差异导致月底礼物落到不同周期。 - return time.UnixMilli(ms).UTC().Format("2006-01") -} - -func debitRequestHash(command ledger.DebitGiftCommand) string { - // 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。 - return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s", - appcode.Normalize(command.AppCode), - command.RoomID, - command.SenderUserID, - command.TargetUserID, - command.GiftID, - command.GiftCount, - strings.TrimSpace(command.PriceVersion), - command.RegionID, - command.SenderRegionID, - command.TargetIsHost, - command.TargetHostRegionID, - command.TargetAgencyOwnerUserID, - command.RobotGift, - strings.TrimSpace(command.EntitlementID), - normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID), - )) -} - -func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string { - 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, - )) -} - -func cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string { - // 解除扣费幂等覆盖关系、关系类型、用户和费用;同 command_id 换任何字段都必须拒绝。 - return stableHash(fmt.Sprintf("cp_breakup_fee|%s|%d|%s|%s|%d", - appcode.Normalize(command.AppCode), - command.UserID, - strings.TrimSpace(command.RelationshipID), - strings.ToLower(strings.TrimSpace(command.RelationType)), - command.Amount, - )) -} - -func wheelDrawDebitRequestHash(command ledger.WheelDrawDebitCommand) string { - // 转盘扣费幂等覆盖池子、抽数和金额;同 command_id 如果价格或档位变了,必须按冲突处理而不是复用旧扣费。 - return stableHash(fmt.Sprintf("wheel_draw_debit|%s|%d|%s|%d|%d", - appcode.Normalize(command.AppCode), - command.UserID, - strings.TrimSpace(command.WheelID), - command.DrawCount, - command.Amount, - )) -} - -func taskRewardRequestHash(command ledger.TaskRewardCommand) string { - return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - command.TaskType, - command.TaskID, - command.CycleKey, - strings.TrimSpace(command.Reason), - )) -} - -func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string { - return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%d|%d|%s|%s|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - strings.TrimSpace(command.DrawID), - strings.TrimSpace(command.RoomID), - command.VisibleRegionID, - command.CountryID, - strings.TrimSpace(command.GiftID), - strings.TrimSpace(command.PoolID), - strings.TrimSpace(command.Reason), - )) -} - -func wheelRewardRequestHash(command ledger.WheelRewardCommand) string { - return stableHash(fmt.Sprintf("wheel_reward|%s|%d|%d|%s|%s|%s|%d|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - strings.TrimSpace(command.DrawID), - strings.TrimSpace(command.WheelID), - strings.TrimSpace(command.SelectedTierID), - command.VisibleRegionID, - strings.TrimSpace(command.Reason), - )) -} - -func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string { - return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - strings.TrimSpace(command.SettlementID), - strings.TrimSpace(command.RoomID), - command.PeriodStartMS, - command.PeriodEndMS, - command.CoinSpent, - command.TierID, - strings.TrimSpace(command.TierCode), - strings.TrimSpace(command.Reason), - )) -} - -func inviteActivityRewardRequestHash(command ledger.InviteActivityRewardCommand) string { - return stableHash(fmt.Sprintf("invite_activity_reward|%s|%d|%d|%s|%s|%d|%s|%s|%d|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - strings.TrimSpace(command.ClaimID), - strings.TrimSpace(command.RewardType), - command.TierID, - strings.TrimSpace(command.TierCode), - strings.TrimSpace(command.CycleKey), - command.ReachedValue, - strings.TrimSpace(command.Reason), - )) -} - -func agencyOpeningRewardRequestHash(command ledger.AgencyOpeningRewardCommand) string { - return stableHash(fmt.Sprintf("agency_opening_reward|%s|%d|%d|%s|%s|%d|%d|%d|%s", - appcode.Normalize(command.AppCode), - command.TargetUserID, - command.Amount, - strings.TrimSpace(command.ApplicationID), - strings.TrimSpace(command.CycleID), - command.AgencyID, - command.RankNo, - command.ScoreCoins, - strings.TrimSpace(command.Reason), - )) -} - -func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) { - switch ledger.NormalizeGameOpType(opType) { - case ledger.GameOpDebit: - return bizTypeGameDebit, -coinAmount, nil - case ledger.GameOpCredit: - return bizTypeGameCredit, coinAmount, nil - case ledger.GameOpRefund: - return bizTypeGameRefund, coinAmount, nil - case ledger.GameOpReverse: - // 冲正首版按“从用户 COIN 扣回”处理;后续接入平台若语义不同,必须在 adapter 层拆成 debit/refund。 - return bizTypeGameReverse, -coinAmount, nil - default: - return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid") - } -} - -func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string { - return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%d|%d|%s|%d|%s|%d|%s|%s|%d|%s", - appcode.Normalize(command.AppCode), - command.SellerUserID, - command.SellerCountryID, - command.SellerRegionID, - command.StockType, - command.CoinAmount, - command.PaidCurrencyCode, - command.PaidAmountMicro, - command.PaymentRef, - command.EvidenceRef, - command.OperatorUserID, - command.Reason, - )) -} - -func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string { - return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s", - appcode.Normalize(command.AppCode), - command.SellerUserID, - command.TargetUserID, - command.SellerRegionID, - command.TargetRegionID, - command.Amount, - strings.TrimSpace(command.Reason), - )) -} - -func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string { - return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s", - appcode.Normalize(command.AppCode), - command.UserID, - strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), - command.SalaryUSDMinor, - strings.TrimSpace(command.Reason), - )) -} - -func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string { - return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s", - appcode.Normalize(command.AppCode), - command.SourceUserID, - command.SellerUserID, - strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), - command.SalaryUSDMinor, - command.RegionID, - strings.TrimSpace(command.Reason), - )) -} - -func coinSellerStockBizType(stockType string) string { - switch stockType { - case ledger.StockTypeUSDTPurchase: - return bizTypeCoinSellerStockPurchase - case ledger.StockTypeCoinCompensation: - return bizTypeCoinSellerCoinCompensation - default: - return "" - } -} - -func stableHash(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:]) -} - -func mustJSON(value any) string { - body, err := json.Marshal(value) - if err != nil { - return "{}" - } - return string(body) -} - -func transactionID(appCode string, commandID string) string { - return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) -} - -func coinSellerStockID(appCode string, commandID string) string { - return "cstock_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) -} - -func billingReceiptID(appCode string, commandID string) string { - return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) -} - -func eventID(transactionID string, eventType string, userID int64, assetType string) string { - return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType)) -} - -func isMySQLDuplicateError(err error) bool { - var mysqlErr *mysqlDriver.MySQLError - return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 -} diff --git a/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go new file mode 100644 index 00000000..997c998b --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go @@ -0,0 +1,582 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// ListResources 返回后台资源库列表;App 入口会设置 active_only 只读上架资源。 +func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeResourceListQuery(query) + + where, args := resourceWhereSQL(query) + total, err := r.countResourceRows(ctx, "resources", where, args...) + if err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+` + `+where+` + ORDER BY sort_order ASC, resource_id DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]resourcedomain.Resource, 0, query.PageSize) + for rows.Next() { + item, err := scanResource(rows) + if err != nil { + return nil, 0, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return items, total, nil +} + +// GetResource 读取单个资源详情。 +func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) { + if r == nil || r.db == nil { + return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID) + resource, err := scanResource(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") + } + return resource, err +} + +// CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。 +func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { + if r == nil || r.db == nil { + return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeResourceCommand(command) + if err := validateResourceCommand(command); err != nil { + return resourcedomain.Resource{}, err + } + + nowMs := time.Now().UnixMilli() + usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) + if err != nil { + return resourcedomain.Resource{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.Resource{}, err + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.ExecContext(ctx, ` + INSERT INTO resources ( + app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, + wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, + animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + command.AppCode, + command.ResourceCode, + command.ResourceType, + command.Name, + command.Status, + command.Grantable, + command.ManagerGrantEnabled, + command.GrantStrategy, + command.WalletAssetType, + command.WalletAssetAmount, + command.PriceType, + command.CoinPrice, + command.GiftPointAmount, + usageJSON, + command.AssetURL, + command.PreviewURL, + command.AnimationURL, + metadataJSON, + command.SortOrder, + command.OperatorUserID, + command.OperatorUserID, + nowMs, + nowMs, + ) + if err != nil { + return resourcedomain.Resource{}, mapResourceWriteError(err) + } + resourceID, err := result.LastInsertId() + if err != nil { + return resourcedomain.Resource{}, err + } + resource, err := r.getResourceForUpdate(ctx, tx, resourceID) + if err != nil { + return resourcedomain.Resource{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs), + }); err != nil { + return resourcedomain.Resource{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.Resource{}, err + } + return resource, nil +} + +// UpdateResource 全量更新资源配置; +func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { + if r == nil || r.db == nil { + return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if command.ResourceID <= 0 { + return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeResourceCommand(command) + if err := validateResourceCommand(command); err != nil { + return resourcedomain.Resource{}, err + } + + nowMs := time.Now().UnixMilli() + usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) + if err != nil { + return resourcedomain.Resource{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.Resource{}, err + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.ExecContext(ctx, ` + UPDATE resources + SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?, + grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?, + price_type = ?, coin_price = ?, gift_point_amount = ?, + usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?, + metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? + WHERE app_code = ? AND resource_id = ?`, + command.ResourceCode, + command.ResourceType, + command.Name, + command.Status, + command.Grantable, + command.ManagerGrantEnabled, + command.GrantStrategy, + command.WalletAssetType, + command.WalletAssetAmount, + command.PriceType, + command.CoinPrice, + command.GiftPointAmount, + usageJSON, + command.AssetURL, + command.PreviewURL, + command.AnimationURL, + metadataJSON, + command.SortOrder, + command.OperatorUserID, + nowMs, + command.AppCode, + command.ResourceID, + ) + if err != nil { + return resourcedomain.Resource{}, mapResourceWriteError(err) + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.Resource{}, err + } else if affected == 0 { + return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") + } + resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) + if err != nil { + return resourcedomain.Resource{}, err + } + events := []walletOutboxEvent{ + resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs), + } + giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs) + if err != nil { + return resourcedomain.Resource{}, err + } + events = append(events, giftEvents...) + if err := r.insertWalletOutbox(ctx, tx, events); err != nil { + return resourcedomain.Resource{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.Resource{}, err + } + return resource, nil +} + +func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) { + + if resource.ResourceType != resourcedomain.TypeGift { + return nil, nil + } + priceType := resourcedomain.NormalizePriceType(resource.PriceType) + if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree { + return nil, nil + } + rows, err := tx.QueryContext(ctx, ` + SELECT gift_id + FROM gift_configs + WHERE app_code = ? AND resource_id = ? + ORDER BY gift_id`, + appcode.FromContext(ctx), resource.ResourceID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + giftIDs := make([]string, 0) + for rows.Next() { + var giftID string + if err := rows.Scan(&giftID); err != nil { + return nil, err + } + giftIDs = append(giftIDs, giftID) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(giftIDs) == 0 { + return nil, nil + } + + coinPrice := int64(0) + if priceType == resourcedomain.PriceTypeCoin { + coinPrice = resource.CoinPrice + } + + giftPointAmount := int64(0) + if _, err := tx.ExecContext(ctx, ` + UPDATE wallet_gift_prices gp + JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id + SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ? + WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`, + ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID, + ); err != nil { + return nil, err + } + events := make([]walletOutboxEvent, 0, len(giftIDs)) + for _, giftID := range giftIDs { + events = append(events, resourceOutboxEvent( + "GiftConfigChanged", + fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs), + 0, + resource.ResourceID, + map[string]any{ + "gift_id": giftID, + "resource_id": resource.ResourceID, + "coin_price": coinPrice, + "gift_point_amount": giftPointAmount, + }, + nowMs, + )) + } + return events, nil +} + +func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) { + if r == nil || r.db == nil { + return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if command.ID <= 0 { + return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.Status = resourcedomain.NormalizeStatus(command.Status) + if !resourcedomain.ValidStatus(command.Status) { + return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid") + } + nowMs := time.Now().UnixMilli() + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.Resource{}, err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, + `UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`, + command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, + ) + if err != nil { + return resourcedomain.Resource{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.Resource{}, err + } else if affected == 0 { + return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") + } + resource, err := r.getResourceForUpdate(ctx, tx, command.ID) + if err != nil { + return resourcedomain.Resource{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs), + }); err != nil { + return resourcedomain.Resource{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.Resource{}, err + } + return resource, nil +} + +func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) { + row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID) + resource, err := scanResource(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") + } + return resource, err +} + +func scanResource(scanner scanTarget) (resourcedomain.Resource, error) { + var resource resourcedomain.Resource + var scopesJSON string + if err := scanner.Scan( + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + ); err != nil { + return resourcedomain.Resource{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + return resource, nil +} + +func resourceSelectSQL() string { + return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources` +} + +func resourceColumnsWithAlias(alias string) string { + prefix := "" + if alias != "" { + prefix = alias + "." + } + return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` + + prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` + + prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + + prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` + + prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms` +} + +func nullableResourceColumnsWithAlias(alias string) string { + prefix := "" + if alias != "" { + prefix = alias + "." + } + return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` + + `COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` + + `COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` + + `COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + + `COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` + + `COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` + + `COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` + + `COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)` +} + +func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) { + where := `WHERE app_code = ?` + args := []any{query.AppCode} + if query.ManagerGrantOnly { + where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE` + } else if query.ActiveOnly { + where += ` AND status = 'active'` + } else if query.Status != "" { + where += ` AND status = ?` + args = append(args, query.Status) + } + if query.ResourceType != "" { + where += ` AND resource_type = ?` + args = append(args, query.ResourceType) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where += ` AND (resource_code LIKE ? OR name LIKE ?)` + args = append(args, like, like) + } + return where, args +} + +func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery { + query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) + if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) { + query.ResourceType = "__invalid__" + } + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Keyword = strings.TrimSpace(query.Keyword) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} + +func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand { + command.ResourceCode = strings.TrimSpace(command.ResourceCode) + command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) + command.Name = strings.TrimSpace(command.Name) + command.Status = resourcedomain.NormalizeStatus(command.Status) + command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy) + command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType) + command.PriceType = resourcedomain.NormalizePriceType(command.PriceType) + if command.PriceType == resourcedomain.PriceTypeFree { + command.CoinPrice = 0 + command.GiftPointAmount = 0 + } else if command.PriceType == resourcedomain.PriceTypeCoin { + + command.GiftPointAmount = 0 + } + command.AssetURL = strings.TrimSpace(command.AssetURL) + command.PreviewURL = strings.TrimSpace(command.PreviewURL) + command.AnimationURL = strings.TrimSpace(command.AnimationURL) + command.MetadataJSON = normalizeJSONObject(command.MetadataJSON) + command.UsageScopes = normalizeStringList(command.UsageScopes) + return command +} + +func validateResourceCommand(command resourcedomain.ResourceCommand) error { + if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 { + return xerr.New(xerr.InvalidArgument, "resource command is incomplete") + } + if !resourcedomain.ValidResourceType(command.ResourceType) { + return xerr.New(xerr.InvalidArgument, "resource_type is invalid") + } + if !resourcedomain.ValidStatus(command.Status) { + return xerr.New(xerr.InvalidArgument, "status is invalid") + } + if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) { + return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid") + } + if command.ResourceType == resourcedomain.TypeCoin { + if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 { + return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN") + } + } else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 { + return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit") + } + if command.PriceType == "" { + if command.CoinPrice != 0 || command.GiftPointAmount != 0 { + return xerr.New(xerr.InvalidArgument, "resource price type is required") + } + return nil + } + if !resourcedomain.ValidPriceType(command.PriceType) { + return xerr.New(xerr.InvalidArgument, "resource price type is invalid") + } + if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 { + return xerr.New(xerr.InvalidArgument, "resource coin price is invalid") + } + if command.CoinPrice < 0 { + return xerr.New(xerr.InvalidArgument, "resource price is invalid") + } + return nil +} + +func mapResourceWriteError(err error) error { + if isMySQLDuplicateError(err) { + return xerr.New(xerr.Conflict, "resource_code already exists") + } + return err +} + +func resourceJSONPayload(scopes []string, metadata string) (string, string, error) { + scopes = normalizeStringList(scopes) + scopeJSON, err := json.Marshal(scopes) + if err != nil { + return "", "", err + } + return string(scopeJSON), normalizeJSONObject(metadata), nil +} + +func normalizeStringList(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func normalizeJSONObject(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "{}" + } + var raw any + if err := json.Unmarshal([]byte(value), &raw); err != nil { + return "{}" + } + if _, ok := raw.(map[string]any); !ok { + return "{}" + } + return value +} + +func resourceFromSnapshotJSON(snapshotJSON string, fallback resourcedomain.Resource) resourcedomain.Resource { + snapshotJSON = strings.TrimSpace(snapshotJSON) + if snapshotJSON == "" || snapshotJSON == "{}" { + return fallback + } + var snapshot resourcedomain.Resource + if err := json.Unmarshal([]byte(snapshotJSON), &snapshot); err != nil || snapshot.ResourceID <= 0 { + return fallback + } + if snapshot.AppCode == "" { + snapshot.AppCode = fallback.AppCode + } + if snapshot.ResourceCode == "" { + snapshot.ResourceCode = fallback.ResourceCode + } + if snapshot.ResourceType == "" { + snapshot.ResourceType = fallback.ResourceType + } + return snapshot +} diff --git a/services/wallet-service/internal/storage/mysql/resource_constants.go b/services/wallet-service/internal/storage/mysql/resource_constants.go new file mode 100644 index 00000000..7283c762 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_constants.go @@ -0,0 +1,28 @@ +package mysql + +import ( + "context" + "database/sql" +) + +const ( + bizTypeResourceGrant = "resource_grant" + bizTypeResourceShopPurchase = "resource_shop_purchase" + resourceOutboxAsset = "RESOURCE" +) + +type scanTarget interface { + Scan(dest ...any) error +} + +type sqlRowQuerier interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +type sqlQuerier interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +type sqlExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} diff --git a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go new file mode 100644 index 00000000..1031725b --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go @@ -0,0 +1,600 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "math" + "strings" + "time" +) + +func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + if query.UserID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "user_id is required") + } + nowMs := time.Now().UnixMilli() + + pruneResourceTypes := []string{} + where := `WHERE e.app_code = ? AND e.user_id = ?` + args := []any{appcode.FromContext(ctx), query.UserID} + if strings.TrimSpace(query.ResourceType) != "" { + resourceType := resourcedomain.NormalizeResourceType(query.ResourceType) + if !resourcedomain.ValidResourceType(resourceType) { + return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid") + } + + if isEquipableResourceType(resourceType) { + pruneResourceTypes = []string{resourceType} + } + where += ` AND r.resource_type = ?` + args = append(args, resourceType) + } + if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil { + return nil, err + } + if query.ActiveOnly { + + where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0` + args = append(args, nowMs, nowMs) + } + rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]resourcedomain.UserResourceEntitlement, 0) + for rows.Next() { + item, err := scanUserResourceEntitlement(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) { + if r == nil || r.db == nil { + return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.EntitlementID = strings.TrimSpace(command.EntitlementID) + if command.UserID <= 0 || command.ResourceID <= 0 { + return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user_id and resource_id are required") + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs) + if err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + if !isEquipableResourceType(entitlement.Resource.ResourceType) { + return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") + } + if err := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + entitlement.Equipped = true + eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{ + "action": "equip", + "app_code": command.AppCode, + "user_id": command.UserID, + "resource_id": command.ResourceID, + "resource_type": entitlement.Resource.ResourceType, + "entitlement": entitlement, + "updated_at_ms": nowMs, + "event_command": eventCommandID, + }, nowMs), + }); err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + return entitlement, nil +} + +func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) { + if r == nil || r.db == nil { + return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) + if command.UserID <= 0 || command.ResourceType == "" { + return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required") + } + if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) { + return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") + } + if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge { + + return resourcedomain.UnequipUserResourceResult{ + ResourceType: command.ResourceType, + Unequipped: false, + UpdatedAtMS: time.Now().UnixMilli(), + }, nil + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.UnequipUserResourceResult{}, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + result, err := tx.ExecContext(ctx, ` + DELETE FROM user_resource_equipment + WHERE app_code = ? AND user_id = ? AND resource_type = ?`, + command.AppCode, command.UserID, command.ResourceType, + ) + if err != nil { + return resourcedomain.UnequipUserResourceResult{}, err + } + + affected, err := result.RowsAffected() + if err != nil { + return resourcedomain.UnequipUserResourceResult{}, err + } + if affected > 0 { + + eventCommandID := fmt.Sprintf("unequip:%d:%s:%d", command.UserID, command.ResourceType, time.Now().UnixNano()) + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, 0, map[string]any{ + "action": "unequip", + "app_code": command.AppCode, + "user_id": command.UserID, + "resource_type": command.ResourceType, + "updated_at_ms": nowMs, + "event_command": eventCommandID, + }, nowMs), + }); err != nil { + return resourcedomain.UnequipUserResourceResult{}, err + } + } + if err := tx.Commit(); err != nil { + return resourcedomain.UnequipUserResourceResult{}, err + } + return resourcedomain.UnequipUserResourceResult{ + ResourceType: command.ResourceType, + Unequipped: affected > 0, + UpdatedAtMS: nowMs, + }, nil +} + +func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + userIDs := compactPositiveInt64s(query.UserIDs) + if len(userIDs) == 0 { + return []resourcedomain.UserEquippedResources{}, nil + } + + resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes) + if err != nil { + return nil, err + } + nowMs := time.Now().UnixMilli() + + if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil { + return nil, err + } + + args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) + args = append(args, nowMs, nowMs) + typeFilter := "" + if len(resourceTypes) > 0 { + + typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` + args = append(args, stringAnyArgs(resourceTypes)...) + } + + rows, err := r.db.QueryContext(ctx, ` + SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, + e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, + e.source_grant_id, e.created_at_ms, e.updated_at_ms, + TRUE AS equipped, + `+resourceColumnsWithAlias("r")+` + FROM user_resource_equipment eq + JOIN user_resource_entitlements e ON e.app_code = eq.app_code + AND e.user_id = eq.user_id + AND e.resource_id = eq.resource_id + AND e.entitlement_id = eq.entitlement_id + JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id + WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) + AND e.status = 'active' + AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) + AND e.remaining_quantity > 0 + AND r.status = 'active'`+typeFilter+` + ORDER BY eq.user_id ASC, eq.updated_at_ms DESC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs)) + for rows.Next() { + item, err := scanUserResourceEntitlement(rows) + if err != nil { + return nil, err + } + item.Equipped = true + grouped[item.UserID] = append(grouped[item.UserID], item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs)) + for _, userID := range userIDs { + result = append(result, resourcedomain.UserEquippedResources{ + UserID: userID, + Resources: grouped[userID], + }) + } + return result, nil +} + +func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (string, error) { + expiresAt := int64(0) + if durationMS > 0 { + if durationMS > math.MaxInt64-nowMs { + return "", xerr.New(xerr.InvalidArgument, "duration overflow") + } + expiresAt = nowMs + durationMS + } + strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) + if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag { + existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, nowMs) + if err != nil { + return "", err + } + if exists { + newQuantity := existing.Quantity + newRemaining := existing.RemainingQuantity + newExpiresAt := existing.ExpiresAtMS + switch strategy { + case resourcedomain.GrantStrategyExtendExpiry: + newRemaining += quantity + newQuantity += quantity + if durationMS > 0 { + base := max(existing.ExpiresAtMS, nowMs) + if durationMS > math.MaxInt64-base { + return "", xerr.New(xerr.InvalidArgument, "duration overflow") + } + newExpiresAt = base + durationMS + } else { + newExpiresAt = 0 + } + case resourcedomain.GrantStrategyIncreaseQuantity: + newQuantity += quantity + newRemaining += quantity + case resourcedomain.GrantStrategySetActiveFlag: + newQuantity = 1 + newRemaining = 1 + newExpiresAt = expiresAt + } + if _, err := tx.ExecContext(ctx, ` + UPDATE user_resource_entitlements + SET status = 'active', quantity = ?, remaining_quantity = ?, expires_at_ms = ?, + source_grant_id = ?, updated_at_ms = ? + WHERE app_code = ? AND entitlement_id = ?`, + newQuantity, newRemaining, newExpiresAt, grantID, nowMs, appcode.FromContext(ctx), existing.EntitlementID, + ); err != nil { + return "", err + } + return existing.EntitlementID, nil + } + } + entitlementID := entitlementID(appcode.FromContext(ctx), grantID, resource.ResourceID, nowMs) + if _, err := tx.ExecContext(ctx, ` + INSERT INTO user_resource_entitlements ( + app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity, + effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID, nowMs, nowMs, + ); err != nil { + return "", err + } + return entitlementID, nil +} + +func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error { + if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" { + return nil + } + entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) + if err != nil { + return err + } + if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil { + return err + } + entitlement.Equipped = true + + eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) + return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("UserResourceChanged", eventCommandID, userID, resource.ResourceID, map[string]any{ + "action": "auto_equip", + "app_code": appcode.FromContext(ctx), + "user_id": userID, + "resource_id": resource.ResourceID, + "resource_type": resource.ResourceType, + "entitlement": entitlement, + "updated_at_ms": nowMs, + "event_command": eventCommandID, + }, nowMs), + }) +} + +func (r *Repository) equipUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement, nowMs int64) error { + resourceType := resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) + if !isEquipableResourceType(resourceType) { + return xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") + } + if !allowsMultipleEquippedResources(resourceType) { + + if _, err := tx.ExecContext(ctx, ` + DELETE FROM user_resource_equipment + WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id <> ?`, + appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, + ); err != nil { + return err + } + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_resource_equipment ( + app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = VALUES(resource_id), + updated_at_ms = VALUES(updated_at_ms)`, + appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs, + ) + return err +} + +func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error { + if strings.TrimSpace(entitlementID) == "" { + return nil + } + _, err := tx.ExecContext(ctx, ` + UPDATE user_resource_entitlements + SET expires_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND entitlement_id = ?`, + expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID, + ) + return err +} + +func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) { + row := tx.QueryRowContext(ctx, ` + SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, + e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, + e.source_grant_id, e.created_at_ms, e.updated_at_ms, + FALSE AS equipped, + `+resourceColumnsWithAlias("r")+` + FROM user_resource_entitlements e + JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id + WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' + AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) + ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC + LIMIT 1 + FOR UPDATE`, + appcode.FromContext(ctx), userID, resourceID, nowMs, nowMs, + ) + item, err := scanUserResourceEntitlement(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.UserResourceEntitlement{}, false, nil + } + return item, err == nil, err +} + +func (r *Repository) queryEquipableEntitlementForUpdate(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, error) { + where := ` + WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' + AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) + AND e.remaining_quantity > 0 AND r.status = 'active'` + args := []any{appcode.FromContext(ctx), command.UserID, command.ResourceID, nowMs, nowMs} + if command.EntitlementID != "" { + where += ` AND e.entitlement_id = ?` + args = append(args, command.EntitlementID) + } + query := userResourceSelectSQL() + where + ` + ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC + LIMIT 1 + FOR UPDATE` + item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, query, args...)) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") + } + return item, err +} + +func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) { + item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, + userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`, + appcode.FromContext(ctx), entitlementID, + )) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") + } + return item, err +} + +func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) { + var item resourcedomain.UserResourceEntitlement + var resource resourcedomain.Resource + var scopesJSON string + if err := scanner.Scan( + &item.AppCode, + &item.EntitlementID, + &item.UserID, + &item.ResourceID, + &item.Status, + &item.Quantity, + &item.RemainingQuantity, + &item.EffectiveAtMS, + &item.ExpiresAtMS, + &item.SourceGrantID, + &item.CreatedAtMS, + &item.UpdatedAtMS, + &item.Equipped, + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + ); err != nil { + return resourcedomain.UserResourceEntitlement{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + item.Resource = resource + return item, nil +} + +func userResourceSelectSQL() string { + return ` + SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, + e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, + e.source_grant_id, e.created_at_ms, e.updated_at_ms, + CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped, + ` + resourceColumnsWithAlias("r") + ` + FROM user_resource_entitlements e + JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id + LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code + AND eq.user_id = e.user_id + AND eq.resource_type = r.resource_type + AND eq.entitlement_id = e.entitlement_id + ` +} + +func normalizeEquipableResourceTypes(values []string) ([]string, error) { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + resourceType := resourcedomain.NormalizeResourceType(value) + if resourceType == "" { + + continue + } + if !resourcedomain.ValidResourceType(resourceType) || !isEquipableResourceType(resourceType) { + return nil, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") + } + if _, exists := seen[resourceType]; exists { + + continue + } + seen[resourceType] = struct{}{} + out = append(out, resourceType) + } + return out, nil +} + +func allowsMultipleEquippedResources(resourceType string) bool { + return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge +} + +func isAutoEquippedOnGrantResourceType(resourceType string) bool { + switch resourcedomain.NormalizeResourceType(resourceType) { + case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: + return true + default: + return false + } +} + +func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error { + userIDs = compactPositiveInt64s(userIDs) + if r == nil || r.db == nil || len(userIDs) == 0 { + return nil + } + + resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes) + if err != nil { + return err + } + args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) + args = append(args, nowMs, nowMs) + typeFilter := "" + if len(resourceTypes) > 0 { + typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` + args = append(args, stringAnyArgs(resourceTypes)...) + } + + _, err = r.db.ExecContext(ctx, ` + DELETE eq + FROM user_resource_equipment eq + LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code + AND e.user_id = eq.user_id + AND e.resource_id = eq.resource_id + AND e.entitlement_id = eq.entitlement_id + LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id + WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) + AND ( + e.entitlement_id IS NULL + OR e.status <> 'active' + OR e.effective_at_ms > ? + OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?) + OR e.remaining_quantity <= 0 + OR r.resource_id IS NULL + OR r.status <> 'active' + )`+typeFilter, + args..., + ) + return err +} + +func isEquipableResourceType(resourceType string) bool { + switch resourcedomain.NormalizeResourceType(resourceType) { + case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: + return true + default: + return false + } +} + +func entitlementID(appCode string, grantID string, resourceID int64, nowMs int64) string { + return "ent_" + stableHash(fmt.Sprintf("%s|%s|%d|%d", appcode.Normalize(appCode), grantID, resourceID, nowMs)) +} diff --git a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go new file mode 100644 index 00000000..769ffe75 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go @@ -0,0 +1,584 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// GrantResource 发放单个资源;金币资源和非金币权益在同一个事务里处理。 +func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeGrantResourceCommand(command) + if err := validateGrantResourceCommand(command); err != nil { + return resourcedomain.ResourceGrant{}, err + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := grantResourceRequestHash(command) + if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + return r.getResourceGrantTx(ctx, tx, grantID) + } + nowMs := time.Now().UnixMilli() + resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil { + return resourcedomain.ResourceGrant{}, err + } + grantID := resourceGrantID(command.AppCode, command.CommandID) + if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil { + return resourcedomain.ResourceGrant{}, err + } + if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil { + return resourcedomain.ResourceGrant{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs), + }); err != nil { + return resourcedomain.ResourceGrant{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceGrant{}, err + } + return r.GetResourceGrant(ctx, grantID) +} + +// GrantResourceGroup 锁定资源组快照并原子发放组内所有资源。 +func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeGrantResourceGroupCommand(command) + if err := validateGrantResourceGroupCommand(command); err != nil { + return resourcedomain.ResourceGrant{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := grantResourceGroupRequestHash(command) + if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + return r.getResourceGrantTx(ctx, tx, grantID) + } + nowMs := time.Now().UnixMilli() + group, err := r.getResourceGroup(ctx, tx, command.GroupID, true) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + if group.Status != resourcedomain.StatusActive { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group is disabled") + } + group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.GroupID, true) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + if len(group.Items) == 0 { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group has no items") + } + groupSnapshot, err := json.Marshal(group) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + grantID := resourceGrantID(command.AppCode, command.CommandID) + if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil { + return resourcedomain.ResourceGrant{}, err + } + for _, item := range group.Items { + if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset { + if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil { + return resourcedomain.ResourceGrant{}, err + } + continue + } + if err := validateActiveResourceGroupResource(item.Resource); err != nil { + return resourcedomain.ResourceGrant{}, err + } + if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil { + return resourcedomain.ResourceGrant{}, err + } + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs), + }); err != nil { + return resourcedomain.ResourceGrant{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceGrant{}, err + } + return r.GetResourceGrant(ctx, grantID) +} + +func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeResourceGrantsQuery(query) + where := `WHERE app_code = ?` + args := []any{query.AppCode} + if query.TargetUserID > 0 { + where += ` AND target_user_id = ?` + args = append(args, query.TargetUserID) + } + if strings.TrimSpace(query.Status) != "" { + where += ` AND status = ?` + args = append(args, strings.ToLower(strings.TrimSpace(query.Status))) + } + total, err := r.countResourceRows(ctx, "resource_grants", where, args...) + if err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, resourceGrantSelectSQL()+where+` ORDER BY created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + items := make([]resourcedomain.ResourceGrant, 0, query.PageSize) + for rows.Next() { + grant, err := scanResourceGrant(rows) + if err != nil { + return nil, 0, err + } + grant.Items, err = r.listResourceGrantItems(ctx, grant.GrantID) + if err != nil { + return nil, 0, err + } + items = append(items, grant) + } + return items, total, rows.Err() +} + +func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + row := r.db.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) + grant, err := scanResourceGrant(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found") + } + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + grant.Items, err = r.listResourceGrantItems(ctx, grantID) + return grant, err +} + +func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) { + if quantity <= 0 { + return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive") + } + snapshot, err := json.Marshal(resource) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + item := resourcedomain.ResourceGrantItem{ + GrantID: grantID, + ResourceID: resource.ResourceID, + ResourceSnapshotJSON: string(snapshot), + Quantity: quantity, + DurationMS: durationMS, + CreatedAtMS: nowMs, + } + if resourcedomain.IsWalletCredit(resource) { + assetType := resourcedomain.NormalizeWalletAssetType(resource.WalletAssetType) + amount, err := checkedMul(resource.WalletAssetAmount, quantity) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + walletCommandID := fmt.Sprintf("resource_grant:%s:%d", commandID, resource.ResourceID) + transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) + metadata := map[string]any{ + "app_code": appcode.FromContext(ctx), + "grant_id": grantID, + "command_id": commandID, + "target_user_id": targetUserID, + "resource_id": resource.ResourceID, + "resource_code": resource.ResourceCode, + "resource_type": resource.ResourceType, + "quantity": quantity, + "wallet_amount": amount, + "wallet_asset": assetType, + "operator_reason": "resource grant", + } + if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, resourceWalletCreditHash(grantID, resource.ResourceID, quantity), grantID, metadata, nowMs); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + availableAfter := account.AvailableAmount + amount + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: targetUserID, + AssetType: assetType, + AvailableDelta: amount, + FrozenDelta: 0, + AvailableAfter: availableAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + }); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + item.ResultType = resourcedomain.ResultWalletCredit + item.WalletTransactionID = transactionID + } else { + entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + item.ResultType = resourcedomain.ResultEntitlement + item.EntitlementID = entitlementID + if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + } + inserted, err := r.insertResourceGrantItem(ctx, tx, item) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + return inserted, nil +} + +func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, groupItem resourcedomain.ResourceGroupItem, nowMs int64) (resourcedomain.ResourceGrantItem, error) { + assetType := resourcedomain.NormalizeWalletAssetType(groupItem.WalletAssetType) + if !validGroupWalletAssetType(assetType) || groupItem.WalletAssetAmount <= 0 { + return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") + } + snapshot, err := json.Marshal(map[string]any{ + "group_item_id": groupItem.GroupItemID, + "group_id": groupItem.GroupID, + "item_type": resourcedomain.GroupItemTypeWalletAsset, + "wallet_asset_type": assetType, + "wallet_asset_amount": groupItem.WalletAssetAmount, + "sort_order": groupItem.SortOrder, + }) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType) + transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) + metadata := map[string]any{ + "app_code": appcode.FromContext(ctx), + "grant_id": grantID, + "command_id": commandID, + "target_user_id": targetUserID, + "group_id": groupItem.GroupID, + "group_item_id": groupItem.GroupItemID, + "item_type": resourcedomain.GroupItemTypeWalletAsset, + "wallet_amount": groupItem.WalletAssetAmount, + "wallet_asset": assetType, + "operator_reason": "resource group grant", + } + if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, groupItem.WalletAssetAmount, 0, nowMs); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + availableAfter := account.AvailableAmount + groupItem.WalletAssetAmount + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: targetUserID, + AssetType: assetType, + AvailableDelta: groupItem.WalletAssetAmount, + FrozenDelta: 0, + AvailableAfter: availableAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + }); err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + inserted, err := r.insertResourceGrantItem(ctx, tx, resourcedomain.ResourceGrantItem{ + GrantID: grantID, + ResourceID: 0, + ResourceSnapshotJSON: string(snapshot), + Quantity: groupItem.WalletAssetAmount, + DurationMS: 0, + ResultType: resourcedomain.ResultWalletCredit, + WalletTransactionID: transactionID, + CreatedAtMS: nowMs, + }) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + return inserted, nil +} + +func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) { + result, err := tx.ExecContext(ctx, ` + INSERT INTO resource_grant_items ( + app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms, + result_type, wallet_transaction_id, entitlement_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), item.GrantID, item.ResourceID, item.ResourceSnapshotJSON, + item.Quantity, item.DurationMS, item.ResultType, item.WalletTransactionID, item.EntitlementID, item.CreatedAtMS, + ) + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + id, err := result.LastInsertId() + if err != nil { + return resourcedomain.ResourceGrantItem{}, err + } + item.GrantItemID = id + return item, nil +} + +func (r *Repository) insertResourceGrant(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, grantSource string, subjectType string, subjectID string, requestHash string, groupSnapshotJSON string, reason string, operatorUserID int64, nowMs int64) error { + var snapshot any + if strings.TrimSpace(groupSnapshotJSON) == "" { + snapshot = nil + } else { + snapshot = groupSnapshotJSON + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO resource_grants ( + app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, + grant_subject_id, status, request_hash, group_snapshot_json, reason, + operator_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), grantID, commandID, targetUserID, grantSource, subjectType, + subjectID, resourcedomain.GrantStatusDone, requestHash, snapshot, reason, operatorUserID, nowMs, nowMs, + ) + return err +} + +func (r *Repository) lookupResourceGrant(ctx context.Context, tx *sql.Tx, commandID string, requestHash string) (string, bool, error) { + row := tx.QueryRowContext(ctx, + `SELECT grant_id, request_hash FROM resource_grants WHERE app_code = ? AND command_id = ? FOR UPDATE`, + appcode.FromContext(ctx), commandID, + ) + var grantID string + var storedHash string + if err := row.Scan(&grantID, &storedHash); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + if storedHash != requestHash { + return "", true, xerr.New(xerr.LedgerConflict, "resource grant idempotency conflict") + } + return grantID, true, nil +} + +func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) { + row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) + grant, err := scanResourceGrant(row) + if err != nil { + return resourcedomain.ResourceGrant{}, err + } + grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grantID) + return grant, err +} + +func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) { + return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID) +} + +func (r *Repository) listResourceGrantItemsTx(ctx context.Context, tx *sql.Tx, grantID string) ([]resourcedomain.ResourceGrantItem, error) { + return r.listResourceGrantItemsWithQuery(ctx, tx, grantID) +} + +func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +}, grantID string) ([]resourcedomain.ResourceGrantItem, error) { + rows, err := querier.QueryContext(ctx, ` + SELECT grant_item_id, grant_id, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'), + quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms + FROM resource_grant_items + WHERE app_code = ? AND grant_id = ? + ORDER BY grant_item_id ASC`, + appcode.FromContext(ctx), grantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]resourcedomain.ResourceGrantItem, 0) + for rows.Next() { + var item resourcedomain.ResourceGrantItem + if err := rows.Scan(&item.GrantItemID, &item.GrantID, &item.ResourceID, &item.ResourceSnapshotJSON, &item.Quantity, &item.DurationMS, &item.ResultType, &item.WalletTransactionID, &item.EntitlementID, &item.CreatedAtMS); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error) { + var grant resourcedomain.ResourceGrant + err := scanner.Scan( + &grant.AppCode, + &grant.GrantID, + &grant.CommandID, + &grant.TargetUserID, + &grant.GrantSource, + &grant.GrantSubjectType, + &grant.GrantSubjectID, + &grant.Status, + &grant.Reason, + &grant.OperatorUserID, + &grant.CreatedAtMS, + &grant.UpdatedAtMS, + ) + return grant, err +} + +func resourceGrantSelectSQL() string { + return ` + SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, + grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms + FROM resource_grants + ` +} + +func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery { + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} + +func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand { + command.CommandID = strings.TrimSpace(command.CommandID) + command.Reason = strings.TrimSpace(command.Reason) + command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) + if command.Quantity == 0 { + command.Quantity = 1 + } + return command +} + +func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) error { + if command.CommandID == "" || command.TargetUserID <= 0 || command.ResourceID <= 0 || command.Quantity <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { + return xerr.New(xerr.InvalidArgument, "resource grant command is incomplete") + } + if command.DurationMS < 0 { + return xerr.New(xerr.InvalidArgument, "duration_ms is invalid") + } + return nil +} + +func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand { + command.CommandID = strings.TrimSpace(command.CommandID) + command.Reason = strings.TrimSpace(command.Reason) + command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) + return command +} + +func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) error { + if command.CommandID == "" || command.TargetUserID <= 0 || command.GroupID <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { + return xerr.New(xerr.InvalidArgument, "resource group grant command is incomplete") + } + if command.GrantSource == resourcedomain.GrantSourceManagerCenter { + return xerr.New(xerr.InvalidArgument, "manager center cannot grant resource group") + } + return nil +} + +func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error { + + if grantSource == resourcedomain.GrantSourceGameRobotInit { + if resource.Status != resourcedomain.StatusActive { + return xerr.New(xerr.Conflict, "resource is disabled") + } + if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) { + return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin") + } + return nil + } + if err := validateGrantableResource(resource); err != nil { + return err + } + if grantSource == resourcedomain.GrantSourceManagerCenter && !resource.ManagerGrantEnabled { + return xerr.New(xerr.PermissionDenied, "resource is not enabled for manager center grant") + } + return nil +} + +func validateGrantableResource(resource resourcedomain.Resource) error { + if resource.Status != resourcedomain.StatusActive { + return xerr.New(xerr.Conflict, "resource is disabled") + } + if !resource.Grantable { + return xerr.New(xerr.Conflict, "resource is not grantable") + } + if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) { + return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin") + } + return nil +} + +func grantResourceRequestHash(command resourcedomain.GrantResourceCommand) string { + return stableHash(fmt.Sprintf("resource_grant|%s|%d|%d|%d|%d|%s|%d|%s", + appcode.Normalize(command.AppCode), command.TargetUserID, command.ResourceID, command.Quantity, command.DurationMS, + command.Reason, command.OperatorUserID, command.GrantSource, + )) +} + +func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupCommand) string { + return stableHash(fmt.Sprintf("resource_group_grant|%s|%d|%d|%s|%d|%s", + appcode.Normalize(command.AppCode), command.TargetUserID, command.GroupID, + command.Reason, command.OperatorUserID, command.GrantSource, + )) +} + +func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string { + return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity)) +} + +func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType string, amount int64) string { + return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount)) +} + +func resourceGrantID(appCode string, commandID string) string { + return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) +} diff --git a/services/wallet-service/internal/storage/mysql/resource_group_repository.go b/services/wallet-service/internal/storage/mysql/resource_group_repository.go new file mode 100644 index 00000000..17c73336 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_group_repository.go @@ -0,0 +1,572 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeResourceGroupListQuery(query) + + where, args := resourceGroupWhereSQL(query) + total, err := r.countResourceRows(ctx, "resource_groups", where, args...) + if err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code, group_id, group_code, name, status, description, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + FROM resource_groups + `+where+` + ORDER BY sort_order ASC, group_id DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + groups := make([]resourcedomain.ResourceGroup, 0, query.PageSize) + for rows.Next() { + group, err := scanResourceGroup(rows) + if err != nil { + return nil, 0, err + } + group.Items, err = r.listResourceGroupItems(ctx, group.GroupID) + if err != nil { + return nil, 0, err + } + groups = append(groups, group) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return groups, total, nil +} + +func (r *Repository) GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + group, err := r.getResourceGroup(ctx, r.db, groupID, false) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + group.Items, err = r.listResourceGroupItems(ctx, groupID) + return group, err +} + +func (r *Repository) CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeResourceGroupCommand(command) + if err := validateResourceGroupCommand(command, true); err != nil { + return resourcedomain.ResourceGroup{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + result, err := tx.ExecContext(ctx, ` + INSERT INTO resource_groups ( + app_code, group_code, name, status, description, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + command.AppCode, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, + command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, + ) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + groupID, err := result.LastInsertId() + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := r.replaceResourceGroupItemsTx(ctx, tx, groupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:create:%d", groupID, nowMs), 0, groupID, command, nowMs), + }); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceGroup{}, err + } + return r.GetResourceGroup(ctx, groupID) +} + +func (r *Repository) UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if command.GroupID <= 0 { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeResourceGroupCommand(command) + if err := validateResourceGroupCommand(command, true); err != nil { + return resourcedomain.ResourceGroup{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + result, err := tx.ExecContext(ctx, ` + UPDATE resource_groups + SET group_code = ?, name = ?, status = ?, description = ?, sort_order = ?, + updated_by_user_id = ?, updated_at_ms = ? + WHERE app_code = ? AND group_id = ?`, + command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, + command.OperatorUserID, nowMs, command.AppCode, command.GroupID, + ) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.ResourceGroup{}, err + } else if affected == 0 { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") + } + if err := r.replaceResourceGroupItemsTx(ctx, tx, command.GroupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:update:%d", command.GroupID, nowMs), 0, command.GroupID, command, nowMs), + }); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceGroup{}, err + } + return r.GetResourceGroup(ctx, command.GroupID) +} + +func (r *Repository) SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if command.ID <= 0 { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.Status = resourcedomain.NormalizeStatus(command.Status) + if !resourcedomain.ValidStatus(command.Status) { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "status is invalid") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + defer func() { _ = tx.Rollback() }() + + if _, err := r.getResourceGroup(ctx, tx, command.ID, true); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if command.Status == resourcedomain.StatusActive { + items, err := r.listResourceGroupItemsTx(ctx, tx, command.ID, true) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := validateActiveResourceGroupItems(items); err != nil { + return resourcedomain.ResourceGroup{}, err + } + } + nowMs := time.Now().UnixMilli() + result, err := tx.ExecContext(ctx, + `UPDATE resource_groups SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`, + command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, + ) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.ResourceGroup{}, err + } else if affected == 0 { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") + } + group, err := r.getResourceGroup(ctx, tx, command.ID, false) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.ID, false) + if err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, group, nowMs), + }); err != nil { + return resourcedomain.ResourceGroup{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceGroup{}, err + } + return group, nil +} + +func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) { + query := ` + SELECT app_code, group_id, group_code, name, status, description, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + FROM resource_groups + WHERE app_code = ? AND group_id = ?` + if lock { + query += ` FOR UPDATE` + } + row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), groupID) + group, err := scanResourceGroup(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") + } + return group, err +} + +func (r *Repository) listResourceGroupItems(ctx context.Context, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { + return r.listResourceGroupItemsWithQuery(ctx, r.db, groupID) +} + +func (r *Repository) listResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, lock bool) ([]resourcedomain.ResourceGroupItem, error) { + if lock { + if err := r.lockResourceGroupItemRows(ctx, tx, groupID); err != nil { + return nil, err + } + } + items, err := r.listResourceGroupItemsWithQuery(ctx, tx, groupID) + if err != nil || !lock { + return items, err + } + for index := range items { + if resourcedomain.NormalizeGroupItemType(items[index].ItemType) != resourcedomain.GroupItemTypeResource { + continue + } + resource, err := r.getResourceForUpdate(ctx, tx, items[index].ResourceID) + if err != nil { + return nil, err + } + items[index].Resource = resource + } + return items, nil +} + +func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +}, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { + query := ` + SELECT i.group_item_id, i.group_id, i.item_type, i.resource_id, + i.wallet_asset_type, i.wallet_asset_amount, + ` + nullableResourceColumnsWithAlias("r") + `, + i.quantity, i.duration_ms, i.sort_order, i.created_at_ms, i.updated_at_ms + FROM resource_group_items i + LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id + WHERE i.app_code = ? AND i.group_id = ? + ORDER BY i.sort_order ASC, i.group_item_id ASC` + rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]resourcedomain.ResourceGroupItem, 0) + for rows.Next() { + item, err := scanResourceGroupItem(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx, groupID int64) error { + rows, err := tx.QueryContext(ctx, ` + SELECT group_item_id + FROM resource_group_items + WHERE app_code = ? AND group_id = ? + ORDER BY sort_order ASC, group_item_id ASC + FOR UPDATE`, + appcode.FromContext(ctx), groupID, + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return err + } + } + return rows.Err() +} + +func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, items []resourcedomain.ResourceGroupItemInput, requireGrantable bool, nowMs int64) error { + seenResources := make(map[int64]struct{}, len(items)) + seenWalletAssets := make(map[string]struct{}, len(items)) + if _, err := tx.ExecContext(ctx, `DELETE FROM resource_group_items WHERE app_code = ? AND group_id = ?`, appcode.FromContext(ctx), groupID); err != nil { + return err + } + for _, item := range items { + item = normalizeResourceGroupItemInput(item) + switch item.ItemType { + case resourcedomain.GroupItemTypeResource: + if item.ResourceID <= 0 || item.Quantity <= 0 { + return xerr.New(xerr.InvalidArgument, "resource group item is invalid") + } + if _, ok := seenResources[item.ResourceID]; ok { + return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") + } + seenResources[item.ResourceID] = struct{}{} + resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) + if err != nil { + return err + } + if requireGrantable { + if err := validateActiveResourceGroupResource(resource); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO resource_group_items ( + app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, + quantity, duration_ms, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), groupID, item.ItemType, item.ResourceID, "", int64(0), + item.Quantity, item.DurationMS, item.SortOrder, nowMs, nowMs, + ); err != nil { + return err + } + case resourcedomain.GroupItemTypeWalletAsset: + if !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { + return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") + } + if _, ok := seenWalletAssets[item.WalletAssetType]; ok { + return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") + } + seenWalletAssets[item.WalletAssetType] = struct{}{} + if _, err := tx.ExecContext(ctx, ` + INSERT INTO resource_group_items ( + app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, + quantity, duration_ms, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), groupID, item.ItemType, int64(0), item.WalletAssetType, item.WalletAssetAmount, + int64(1), int64(0), item.SortOrder, nowMs, nowMs, + ); err != nil { + return err + } + default: + return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") + } + } + return nil +} + +func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) { + var group resourcedomain.ResourceGroup + err := scanner.Scan( + &group.AppCode, + &group.GroupID, + &group.GroupCode, + &group.Name, + &group.Status, + &group.Description, + &group.SortOrder, + &group.CreatedByUserID, + &group.UpdatedByUserID, + &group.CreatedAtMS, + &group.UpdatedAtMS, + ) + return group, err +} + +func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem, error) { + var item resourcedomain.ResourceGroupItem + var resource resourcedomain.Resource + var scopesJSON string + if err := scanner.Scan( + &item.GroupItemID, + &item.GroupID, + &item.ItemType, + &item.ResourceID, + &item.WalletAssetType, + &item.WalletAssetAmount, + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + &item.Quantity, + &item.DurationMS, + &item.SortOrder, + &item.CreatedAtMS, + &item.UpdatedAtMS, + ); err != nil { + return resourcedomain.ResourceGroupItem{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + item.Resource = resource + return item, nil +} + +func resourceGroupWhereSQL(query resourcedomain.ListResourceGroupsQuery) (string, []any) { + where := `WHERE app_code = ?` + args := []any{query.AppCode} + if query.ActiveOnly { + where += ` AND status = 'active'` + } else if query.Status != "" { + where += ` AND status = ?` + args = append(args, query.Status) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where += ` AND (group_code LIKE ? OR name LIKE ?)` + args = append(args, like, like) + } + return where, args +} + +func normalizeResourceGroupListQuery(query resourcedomain.ListResourceGroupsQuery) resourcedomain.ListResourceGroupsQuery { + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Keyword = strings.TrimSpace(query.Keyword) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} + +func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand { + command.GroupCode = strings.TrimSpace(command.GroupCode) + command.Name = strings.TrimSpace(command.Name) + command.Status = resourcedomain.NormalizeStatus(command.Status) + command.Description = strings.TrimSpace(command.Description) + for index := range command.Items { + command.Items[index] = normalizeResourceGroupItemInput(command.Items[index]) + } + return command +} + +func validateResourceGroupCommand(command resourcedomain.ResourceGroupCommand, requireItems bool) error { + if command.GroupCode == "" || command.Name == "" { + return xerr.New(xerr.InvalidArgument, "resource group command is incomplete") + } + if !resourcedomain.ValidStatus(command.Status) { + return xerr.New(xerr.InvalidArgument, "status is invalid") + } + if requireItems && len(command.Items) == 0 { + return xerr.New(xerr.InvalidArgument, "resource group has no items") + } + seenResources := make(map[int64]struct{}, len(command.Items)) + seenWalletAssets := make(map[string]struct{}, len(command.Items)) + for _, item := range command.Items { + if !resourcedomain.ValidGroupItemType(item.ItemType) { + return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") + } + switch item.ItemType { + case resourcedomain.GroupItemTypeResource: + if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 { + return xerr.New(xerr.InvalidArgument, "resource group item is invalid") + } + if _, ok := seenResources[item.ResourceID]; ok { + return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") + } + seenResources[item.ResourceID] = struct{}{} + case resourcedomain.GroupItemTypeWalletAsset: + if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { + return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") + } + if _, ok := seenWalletAssets[item.WalletAssetType]; ok { + return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") + } + seenWalletAssets[item.WalletAssetType] = struct{}{} + } + } + return nil +} + +func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) resourcedomain.ResourceGroupItemInput { + item.ItemType = resourcedomain.NormalizeGroupItemType(item.ItemType) + item.WalletAssetType = resourcedomain.NormalizeWalletAssetType(item.WalletAssetType) + if item.ItemType == resourcedomain.GroupItemTypeWalletAsset { + item.ResourceID = 0 + item.Quantity = 1 + item.DurationMS = 0 + return item + } + item.ItemType = resourcedomain.GroupItemTypeResource + item.WalletAssetType = "" + item.WalletAssetAmount = 0 + return item +} + +func validGroupWalletAssetType(assetType string) bool { + switch resourcedomain.NormalizeWalletAssetType(assetType) { + case ledger.AssetCoin: + return true + default: + return false + } +} + +func validateActiveResourceGroupItems(items []resourcedomain.ResourceGroupItem) error { + if len(items) == 0 { + return xerr.New(xerr.InvalidArgument, "resource group has no items") + } + for _, item := range items { + if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource { + continue + } + if err := validateActiveResourceGroupResource(item.Resource); err != nil { + return err + } + } + return nil +} + +func validateActiveResourceGroupResource(resource resourcedomain.Resource) error { + if err := validateGrantableResource(resource); err != nil { + return err + } + if resourcedomain.IsCoinResource(resource) { + return xerr.New(xerr.InvalidArgument, "coin resource must use wallet asset item") + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/resource_outbox.go b/services/wallet-service/internal/storage/mysql/resource_outbox.go new file mode 100644 index 00000000..fbf3260f --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_outbox.go @@ -0,0 +1,34 @@ +package mysql + +import ( + "context" + "fmt" + "time" +) + +func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent { + eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID) + return walletOutboxEvent{ + EventID: "wev_" + stableHash(eventKey), + EventType: eventType, + TransactionID: "", + CommandID: commandID, + UserID: userID, + AssetType: resourceOutboxAsset, + Payload: payload, + CreatedAtMS: nowMs, + } +} + +func (r *Repository) insertResourceOutboxNoTx(ctx context.Context, eventType string, commandID string, userID int64, resourceID int64, payload any) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + nowMs := time.Now().UnixMilli() + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{resourceOutboxEvent(eventType, commandID, userID, resourceID, payload, nowMs)}); err != nil { + return err + } + return tx.Commit() +} diff --git a/services/wallet-service/internal/storage/mysql/resource_query_helpers.go b/services/wallet-service/internal/storage/mysql/resource_query_helpers.go new file mode 100644 index 00000000..cdff8279 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_query_helpers.go @@ -0,0 +1,105 @@ +package mysql + +import ( + "context" + "encoding/json" + "slices" + "strings" +) + +func (r *Repository) countResourceRows(ctx context.Context, table string, where string, args ...any) (int64, error) { + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` `+where, args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + +func normalizePage(page int32, pageSize int32) (int32, int32) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func resourceOffset(page int32, pageSize int32) int32 { + return (page - 1) * pageSize +} + +func placeholders(count int) string { + if count <= 0 { + return "" + } + items := make([]string, count) + for index := range items { + items[index] = "?" + } + return strings.Join(items, ",") +} + +func int64AnyArgs(values []int64) []any { + args := make([]any, 0, len(values)) + for _, value := range values { + args = append(args, value) + } + return args +} + +func stringAnyArgs(values []string) []any { + args := make([]any, 0, len(values)) + for _, value := range values { + args = append(args, value) + } + return args +} + +func compactPositiveInt64s(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func parseStringArray(value string) []string { + var out []string + if err := json.Unmarshal([]byte(value), &out); err != nil { + return nil + } + return normalizeStringList(out) +} + +func normalizeRegionIDs(values []int64) []int64 { + if len(values) == 0 { + return []int64{0} + } + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value < 0 { + out = append(out, value) + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + slices.Sort(out) + return out +} diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go deleted file mode 100644 index b91c19f5..00000000 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ /dev/null @@ -1,3987 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "math" - "slices" - "sort" - "strings" - "time" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - resourcedomain "hyapp/services/wallet-service/internal/domain/resource" -) - -const ( - bizTypeResourceGrant = "resource_grant" - bizTypeResourceShopPurchase = "resource_shop_purchase" - resourceOutboxAsset = "RESOURCE" -) - -type scanTarget interface { - Scan(dest ...any) error -} - -type sqlRowQuerier interface { - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - -type sqlQuerier interface { - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) -} - -type sqlExecer interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) -} - -// ListResources 返回后台资源库列表;App 入口会设置 active_only 只读上架资源。 -func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeResourceListQuery(query) - - where, args := resourceWhereSQL(query) - total, err := r.countResourceRows(ctx, "resources", where, args...) - if err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+` - `+where+` - ORDER BY sort_order ASC, resource_id DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items := make([]resourcedomain.Resource, 0, query.PageSize) - for rows.Next() { - item, err := scanResource(rows) - if err != nil { - return nil, 0, err - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - return items, total, nil -} - -// GetResource 读取单个资源详情。 -func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) { - if r == nil || r.db == nil { - return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID) - resource, err := scanResource(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") - } - return resource, err -} - -// CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。 -func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { - if r == nil || r.db == nil { - return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeResourceCommand(command) - if err := validateResourceCommand(command); err != nil { - return resourcedomain.Resource{}, err - } - - nowMs := time.Now().UnixMilli() - usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) - if err != nil { - return resourcedomain.Resource{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.Resource{}, err - } - defer func() { _ = tx.Rollback() }() - - result, err := tx.ExecContext(ctx, ` - INSERT INTO resources ( - app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, - wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, - animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, - created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - command.AppCode, - command.ResourceCode, - command.ResourceType, - command.Name, - command.Status, - command.Grantable, - command.ManagerGrantEnabled, - command.GrantStrategy, - command.WalletAssetType, - command.WalletAssetAmount, - command.PriceType, - command.CoinPrice, - command.GiftPointAmount, - usageJSON, - command.AssetURL, - command.PreviewURL, - command.AnimationURL, - metadataJSON, - command.SortOrder, - command.OperatorUserID, - command.OperatorUserID, - nowMs, - nowMs, - ) - if err != nil { - return resourcedomain.Resource{}, mapResourceWriteError(err) - } - resourceID, err := result.LastInsertId() - if err != nil { - return resourcedomain.Resource{}, err - } - resource, err := r.getResourceForUpdate(ctx, tx, resourceID) - if err != nil { - return resourcedomain.Resource{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs), - }); err != nil { - return resourcedomain.Resource{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.Resource{}, err - } - return resource, nil -} - -// UpdateResource 全量更新资源配置; -func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { - if r == nil || r.db == nil { - return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if command.ResourceID <= 0 { - return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeResourceCommand(command) - if err := validateResourceCommand(command); err != nil { - return resourcedomain.Resource{}, err - } - - nowMs := time.Now().UnixMilli() - usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) - if err != nil { - return resourcedomain.Resource{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.Resource{}, err - } - defer func() { _ = tx.Rollback() }() - - result, err := tx.ExecContext(ctx, ` - UPDATE resources - SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?, - grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?, - price_type = ?, coin_price = ?, gift_point_amount = ?, - usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?, - metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? - WHERE app_code = ? AND resource_id = ?`, - command.ResourceCode, - command.ResourceType, - command.Name, - command.Status, - command.Grantable, - command.ManagerGrantEnabled, - command.GrantStrategy, - command.WalletAssetType, - command.WalletAssetAmount, - command.PriceType, - command.CoinPrice, - command.GiftPointAmount, - usageJSON, - command.AssetURL, - command.PreviewURL, - command.AnimationURL, - metadataJSON, - command.SortOrder, - command.OperatorUserID, - nowMs, - command.AppCode, - command.ResourceID, - ) - if err != nil { - return resourcedomain.Resource{}, mapResourceWriteError(err) - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.Resource{}, err - } else if affected == 0 { - return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") - } - resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) - if err != nil { - return resourcedomain.Resource{}, err - } - events := []walletOutboxEvent{ - resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs), - } - giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs) - if err != nil { - return resourcedomain.Resource{}, err - } - events = append(events, giftEvents...) - if err := r.insertWalletOutbox(ctx, tx, events); err != nil { - return resourcedomain.Resource{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.Resource{}, err - } - return resource, nil -} - -func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) { - // 礼物资源的价格是后台编辑入口,实际送礼扣费读取 wallet_gift_prices。 - // 因此资源价格变更后,必须在同一个事务内把已绑定礼物的 active 价格同步过去, - // 同时发 GiftConfigChanged outbox,让依赖礼物配置快照的下游可以刷新缓存。 - if resource.ResourceType != resourcedomain.TypeGift { - return nil, nil - } - priceType := resourcedomain.NormalizePriceType(resource.PriceType) - if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree { - return nil, nil - } - rows, err := tx.QueryContext(ctx, ` - SELECT gift_id - FROM gift_configs - WHERE app_code = ? AND resource_id = ? - ORDER BY gift_id`, - appcode.FromContext(ctx), resource.ResourceID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - giftIDs := make([]string, 0) - for rows.Next() { - var giftID string - if err := rows.Scan(&giftID); err != nil { - return nil, err - } - giftIDs = append(giftIDs, giftID) - } - if err := rows.Err(); err != nil { - return nil, err - } - if len(giftIDs) == 0 { - return nil, nil - } - - coinPrice := int64(0) - if priceType == resourcedomain.PriceTypeCoin { - coinPrice = resource.CoinPrice - } - // gift_point_amount 是历史字段,资源改价同步礼物价格时固定写 0,避免旧积分口径重新进入送礼链路。 - giftPointAmount := int64(0) - if _, err := tx.ExecContext(ctx, ` - UPDATE wallet_gift_prices gp - JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id - SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ? - WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`, - ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID, - ); err != nil { - return nil, err - } - events := make([]walletOutboxEvent, 0, len(giftIDs)) - for _, giftID := range giftIDs { - events = append(events, resourceOutboxEvent( - "GiftConfigChanged", - fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs), - 0, - resource.ResourceID, - map[string]any{ - "gift_id": giftID, - "resource_id": resource.ResourceID, - "coin_price": coinPrice, - "gift_point_amount": giftPointAmount, - }, - nowMs, - )) - } - return events, nil -} - -func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) { - if r == nil || r.db == nil { - return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if command.ID <= 0 { - return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.Status = resourcedomain.NormalizeStatus(command.Status) - if !resourcedomain.ValidStatus(command.Status) { - return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid") - } - nowMs := time.Now().UnixMilli() - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.Resource{}, err - } - defer func() { _ = tx.Rollback() }() - result, err := tx.ExecContext(ctx, - `UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`, - command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, - ) - if err != nil { - return resourcedomain.Resource{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.Resource{}, err - } else if affected == 0 { - return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") - } - resource, err := r.getResourceForUpdate(ctx, tx, command.ID) - if err != nil { - return resourcedomain.Resource{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs), - }); err != nil { - return resourcedomain.Resource{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.Resource{}, err - } - return resource, nil -} - -func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeResourceGroupListQuery(query) - - where, args := resourceGroupWhereSQL(query) - total, err := r.countResourceRows(ctx, "resource_groups", where, args...) - if err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, group_id, group_code, name, status, description, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - FROM resource_groups - `+where+` - ORDER BY sort_order ASC, group_id DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - groups := make([]resourcedomain.ResourceGroup, 0, query.PageSize) - for rows.Next() { - group, err := scanResourceGroup(rows) - if err != nil { - return nil, 0, err - } - group.Items, err = r.listResourceGroupItems(ctx, group.GroupID) - if err != nil { - return nil, 0, err - } - groups = append(groups, group) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - return groups, total, nil -} - -func (r *Repository) GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - group, err := r.getResourceGroup(ctx, r.db, groupID, false) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - group.Items, err = r.listResourceGroupItems(ctx, groupID) - return group, err -} - -func (r *Repository) CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeResourceGroupCommand(command) - if err := validateResourceGroupCommand(command, true); err != nil { - return resourcedomain.ResourceGroup{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - result, err := tx.ExecContext(ctx, ` - INSERT INTO resource_groups ( - app_code, group_code, name, status, description, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - command.AppCode, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, - command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, - ) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - groupID, err := result.LastInsertId() - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := r.replaceResourceGroupItemsTx(ctx, tx, groupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:create:%d", groupID, nowMs), 0, groupID, command, nowMs), - }); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceGroup{}, err - } - return r.GetResourceGroup(ctx, groupID) -} - -func (r *Repository) UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if command.GroupID <= 0 { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeResourceGroupCommand(command) - if err := validateResourceGroupCommand(command, true); err != nil { - return resourcedomain.ResourceGroup{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - result, err := tx.ExecContext(ctx, ` - UPDATE resource_groups - SET group_code = ?, name = ?, status = ?, description = ?, sort_order = ?, - updated_by_user_id = ?, updated_at_ms = ? - WHERE app_code = ? AND group_id = ?`, - command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, - command.OperatorUserID, nowMs, command.AppCode, command.GroupID, - ) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.ResourceGroup{}, err - } else if affected == 0 { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") - } - if err := r.replaceResourceGroupItemsTx(ctx, tx, command.GroupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:update:%d", command.GroupID, nowMs), 0, command.GroupID, command, nowMs), - }); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceGroup{}, err - } - return r.GetResourceGroup(ctx, command.GroupID) -} - -func (r *Repository) SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if command.ID <= 0 { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.Status = resourcedomain.NormalizeStatus(command.Status) - if !resourcedomain.ValidStatus(command.Status) { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "status is invalid") - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - defer func() { _ = tx.Rollback() }() - - if _, err := r.getResourceGroup(ctx, tx, command.ID, true); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if command.Status == resourcedomain.StatusActive { - items, err := r.listResourceGroupItemsTx(ctx, tx, command.ID, true) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := validateActiveResourceGroupItems(items); err != nil { - return resourcedomain.ResourceGroup{}, err - } - } - nowMs := time.Now().UnixMilli() - result, err := tx.ExecContext(ctx, - `UPDATE resource_groups SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`, - command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, - ) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.ResourceGroup{}, err - } else if affected == 0 { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") - } - group, err := r.getResourceGroup(ctx, tx, command.ID, false) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.ID, false) - if err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, group, nowMs), - }); err != nil { - return resourcedomain.ResourceGroup{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceGroup{}, err - } - return group, nil -} - -func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeGiftConfigListQuery(query) - where, args := giftConfigWhereSQL(query) - var total int64 - if err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM gift_configs gc - JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id - `+where, args...).Scan(&total); err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+` - ORDER BY gc.sort_order ASC, gc.created_at_ms DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - nowMs := time.Now().UnixMilli() - items := make([]resourcedomain.GiftConfig, 0, query.PageSize) - for rows.Next() { - item, err := scanGiftConfig(rows) - if err != nil { - return nil, 0, err - } - item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID) - if err != nil { - return nil, 0, err - } - price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs) - if priceErr == nil { - item.PriceVersion = price.PriceVersion - item.ChargeAssetType = price.ChargeAssetType - item.CoinPrice = price.CoinPrice - item.GiftPointAmount = 0 - item.HeatValue = price.CoinPrice - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - return items, total, nil -} - -// ListGiftTypeConfigs 返回礼物面板 tab 类型配置;默认类型会按 app_code 自动补齐,避免新租户缺配置。 -func (r *Repository) ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - if err := r.ensureDefaultGiftTypeConfigs(ctx, r.db); err != nil { - return nil, err - } - query.AppCode = appcode.FromContext(ctx) - query = normalizeGiftTypeConfigListQuery(query) - where, args := giftTypeConfigWhereSQL(query) - rows, err := r.db.QueryContext(ctx, giftTypeConfigSelectSQL()+where+` - ORDER BY sort_order ASC, type_code ASC`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - items := make([]resourcedomain.GiftTypeConfig, 0, len(resourcedomain.DefaultGiftTypeConfigs(query.AppCode))) - for rows.Next() { - item, err := scanGiftTypeConfig(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -func (r *Repository) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { - return r.upsertGiftConfig(ctx, command, false) -} - -func (r *Repository) UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { - return r.upsertGiftConfig(ctx, command, true) -} - -func (r *Repository) SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) { - if r == nil || r.db == nil { - return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if strings.TrimSpace(command.StringID) == "" { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - status := resourcedomain.NormalizeStatus(command.Status) - if !resourcedomain.ValidStatus(status) { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "status is invalid") - } - nowMs := time.Now().UnixMilli() - result, err := r.db.ExecContext(ctx, - `UPDATE gift_configs SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`, - status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), strings.TrimSpace(command.StringID), - ) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.GiftConfig{}, err - } else if affected == 0 { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") - } - gift, err := r.getGiftConfig(ctx, strings.TrimSpace(command.StringID)) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - _ = r.insertResourceOutboxNoTx(ctx, "GiftConfigChanged", fmt.Sprintf("gift:%s:status:%s:%d", gift.GiftID, status, nowMs), 0, gift.ResourceID, gift) - return gift, nil -} - -func (r *Repository) DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) { - if r == nil || r.db == nil { - return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - if strings.TrimSpace(command.StringID) == "" { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - giftID := strings.TrimSpace(command.StringID) - nowMs := time.Now().UnixMilli() - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - defer func() { _ = tx.Rollback() }() - - // 删除礼物配置前先锁定并读取完整快照:送礼链路也会锁 gift_configs, - // 因此后台删除和正在结算的同一礼物不会交叉执行成半删状态。 - gift, err := r.getGiftConfigForUpdateTx(ctx, tx, giftID) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { - return resourcedomain.GiftConfig{}, err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { - return resourcedomain.GiftConfig{}, err - } - result, err := tx.ExecContext(ctx, `DELETE FROM gift_configs WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.GiftConfig{}, err - } else if affected == 0 { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") - } - - deletedGift := gift - deletedGift.Status = "deleted" - // 只删除礼物列表配置和配置附属表,不删除 resources;资源素材仍留在资源列表,可重新绑定或复用。 - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:delete:%d", giftID, nowMs), 0, gift.ResourceID, deletedGift, nowMs), - }); err != nil { - return resourcedomain.GiftConfig{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.GiftConfig{}, err - } - return gift, nil -} - -// UpsertGiftTypeConfig 更新礼物类型 tab 展示配置;type_code 是礼物表引用的稳定业务键。 -func (r *Repository) UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) { - if r == nil || r.db == nil { - return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeGiftTypeConfigCommand(command) - if err := validateGiftTypeConfigCommand(command); err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - defer func() { _ = tx.Rollback() }() - if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - nowMs := time.Now().UnixMilli() - if _, err := tx.ExecContext(ctx, ` - INSERT INTO gift_type_configs ( - app_code, type_code, name, tab_key, status, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - name = VALUES(name), - tab_key = VALUES(tab_key), - status = VALUES(status), - sort_order = VALUES(sort_order), - updated_by_user_id = VALUES(updated_by_user_id), - updated_at_ms = VALUES(updated_at_ms)`, - command.AppCode, command.TypeCode, command.Name, command.TabKey, command.Status, command.SortOrder, - command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, - ); err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - item, err := r.getGiftTypeConfigTx(ctx, tx, command.TypeCode) - if err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.GiftTypeConfig{}, err - } - return item, nil -} - -// GrantResource 发放单个资源;金币资源和非金币权益在同一个事务里处理。 -func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeGrantResourceCommand(command) - if err := validateGrantResourceCommand(command); err != nil { - return resourcedomain.ResourceGrant{}, err - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := grantResourceRequestHash(command) - if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - return r.getResourceGrantTx(ctx, tx, grantID) - } - nowMs := time.Now().UnixMilli() - resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil { - return resourcedomain.ResourceGrant{}, err - } - grantID := resourceGrantID(command.AppCode, command.CommandID) - if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil { - return resourcedomain.ResourceGrant{}, err - } - if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil { - return resourcedomain.ResourceGrant{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs), - }); err != nil { - return resourcedomain.ResourceGrant{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceGrant{}, err - } - return r.GetResourceGrant(ctx, grantID) -} - -// GrantResourceGroup 锁定资源组快照并原子发放组内所有资源。 -func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeGrantResourceGroupCommand(command) - if err := validateGrantResourceGroupCommand(command); err != nil { - return resourcedomain.ResourceGrant{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := grantResourceGroupRequestHash(command) - if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - return r.getResourceGrantTx(ctx, tx, grantID) - } - nowMs := time.Now().UnixMilli() - group, err := r.getResourceGroup(ctx, tx, command.GroupID, true) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - if group.Status != resourcedomain.StatusActive { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group is disabled") - } - group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.GroupID, true) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - if len(group.Items) == 0 { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group has no items") - } - groupSnapshot, err := json.Marshal(group) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - grantID := resourceGrantID(command.AppCode, command.CommandID) - if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil { - return resourcedomain.ResourceGrant{}, err - } - for _, item := range group.Items { - if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset { - if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil { - return resourcedomain.ResourceGrant{}, err - } - continue - } - if err := validateActiveResourceGroupResource(item.Resource); err != nil { - return resourcedomain.ResourceGrant{}, err - } - if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil { - return resourcedomain.ResourceGrant{}, err - } - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs), - }); err != nil { - return resourcedomain.ResourceGrant{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceGrant{}, err - } - return r.GetResourceGrant(ctx, grantID) -} - -func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - if query.UserID <= 0 { - return nil, xerr.New(xerr.InvalidArgument, "user_id is required") - } - nowMs := time.Now().UnixMilli() - // equipment 表只记录“当前佩戴指针”,真正的有效期仍以 entitlement 为准。 - // 所以背包列表读取前先清理当前用户已失效的佩戴指针,避免过期道具继续显示 equipped=true。 - pruneResourceTypes := []string{} - where := `WHERE e.app_code = ? AND e.user_id = ?` - args := []any{appcode.FromContext(ctx), query.UserID} - if strings.TrimSpace(query.ResourceType) != "" { - resourceType := resourcedomain.NormalizeResourceType(query.ResourceType) - if !resourcedomain.ValidResourceType(resourceType) { - return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid") - } - // 只有可佩戴资源才可能在 equipment 表中留下指针;礼物/金币等非佩戴资源不参与清理。 - if isEquipableResourceType(resourceType) { - pruneResourceTypes = []string{resourceType} - } - where += ` AND r.resource_type = ?` - args = append(args, resourceType) - } - if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil { - return nil, err - } - if query.ActiveOnly { - // App 背包只需要当前可用权益;后台或排障读取可关闭 ActiveOnly 看历史/过期记录。 - where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0` - args = append(args, nowMs, nowMs) - } - rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]resourcedomain.UserResourceEntitlement, 0) - for rows.Next() { - item, err := scanUserResourceEntitlement(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) { - if r == nil || r.db == nil { - return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command.EntitlementID = strings.TrimSpace(command.EntitlementID) - if command.UserID <= 0 || command.ResourceID <= 0 { - return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user_id and resource_id are required") - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs) - if err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - if !isEquipableResourceType(entitlement.Resource.ResourceType) { - return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") - } - if err := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - entitlement.Equipped = true - eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{ - "action": "equip", - "app_code": command.AppCode, - "user_id": command.UserID, - "resource_id": command.ResourceID, - "resource_type": entitlement.Resource.ResourceType, - "entitlement": entitlement, - "updated_at_ms": nowMs, - "event_command": eventCommandID, - }, nowMs), - }); err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - return entitlement, nil -} - -func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) { - if r == nil || r.db == nil { - return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) - if command.UserID <= 0 || command.ResourceType == "" { - return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required") - } - if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) { - return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") - } - if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge { - // 徽章是“拥有即佩戴”的展示资源,不再支持按类型整类卸下。 - // 旧客户端如果仍调用 badge unequip,按幂等无变更返回,避免把用户全部徽章一次删掉。 - return resourcedomain.UnequipUserResourceResult{ - ResourceType: command.ResourceType, - Unequipped: false, - UpdatedAtMS: time.Now().UnixMilli(), - }, nil - } - - // 取消佩戴要和 outbox 事件在同一事务内提交: - // UI 投影可以监听 UserResourceChanged,但不能在 equipment 删除失败时收到“已卸下”的假事件。 - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.UnequipUserResourceResult{}, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - result, err := tx.ExecContext(ctx, ` - DELETE FROM user_resource_equipment - WHERE app_code = ? AND user_id = ? AND resource_type = ?`, - command.AppCode, command.UserID, command.ResourceType, - ) - if err != nil { - return resourcedomain.UnequipUserResourceResult{}, err - } - // RowsAffected=0 表示用户本来就没佩戴该类型;这个场景按幂等取消处理,不抛业务错误。 - affected, err := result.RowsAffected() - if err != nil { - return resourcedomain.UnequipUserResourceResult{}, err - } - if affected > 0 { - // resource_id 对“取消某一类佩戴”没有单一值,payload 用 resource_type 表示变更维度。 - eventCommandID := fmt.Sprintf("unequip:%d:%s:%d", command.UserID, command.ResourceType, time.Now().UnixNano()) - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, 0, map[string]any{ - "action": "unequip", - "app_code": command.AppCode, - "user_id": command.UserID, - "resource_type": command.ResourceType, - "updated_at_ms": nowMs, - "event_command": eventCommandID, - }, nowMs), - }); err != nil { - return resourcedomain.UnequipUserResourceResult{}, err - } - } - if err := tx.Commit(); err != nil { - return resourcedomain.UnequipUserResourceResult{}, err - } - return resourcedomain.UnequipUserResourceResult{ - ResourceType: command.ResourceType, - Unequipped: affected > 0, - UpdatedAtMS: nowMs, - }, nil -} - -func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - userIDs := compactPositiveInt64s(query.UserIDs) - if len(userIDs) == 0 { - return []resourcedomain.UserEquippedResources{}, nil - } - // 批量查询只允许可佩戴类型,避免把背包里不可展示为装扮的资源混进房间资料。 - resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes) - if err != nil { - return nil, err - } - nowMs := time.Now().UnixMilli() - // 先清理再查,保证调用方不需要理解“equipment 只是指针”的存储细节。 - if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil { - return nil, err - } - - args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) - args = append(args, nowMs, nowMs) - typeFilter := "" - if len(resourceTypes) > 0 { - // resourceTypes 为空表示查询所有可佩戴类型;非空时只返回调用方关心的展示维度。 - typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` - args = append(args, stringAnyArgs(resourceTypes)...) - } - // 从 equipment 反查 entitlement/resource,而不是从 entitlement 扫描 equipped 标记: - // 这样非徽章类型仍由写入侧保证每类一个,徽章则可以按同一个 resource_type 返回多个已佩戴权益。 - rows, err := r.db.QueryContext(ctx, ` - SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, - e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, - e.source_grant_id, e.created_at_ms, e.updated_at_ms, - TRUE AS equipped, - `+resourceColumnsWithAlias("r")+` - FROM user_resource_equipment eq - JOIN user_resource_entitlements e ON e.app_code = eq.app_code - AND e.user_id = eq.user_id - AND e.resource_id = eq.resource_id - AND e.entitlement_id = eq.entitlement_id - JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id - WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) - AND e.status = 'active' - AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) - AND e.remaining_quantity > 0 - AND r.status = 'active'`+typeFilter+` - ORDER BY eq.user_id ASC, eq.updated_at_ms DESC`, - args..., - ) - if err != nil { - return nil, err - } - defer rows.Close() - - grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs)) - for rows.Next() { - item, err := scanUserResourceEntitlement(rows) - if err != nil { - return nil, err - } - item.Equipped = true - grouped[item.UserID] = append(grouped[item.UserID], item) - } - if err := rows.Err(); err != nil { - return nil, err - } - - // 返回顺序按请求 user_ids 保持稳定,调用方可以直接和入参用户列表对齐。 - result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs)) - for _, userID := range userIDs { - result = append(result, resourcedomain.UserEquippedResources{ - UserID: userID, - Resources: grouped[userID], - }) - } - return result, nil -} - -func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeResourceGrantsQuery(query) - where := `WHERE app_code = ?` - args := []any{query.AppCode} - if query.TargetUserID > 0 { - where += ` AND target_user_id = ?` - args = append(args, query.TargetUserID) - } - if strings.TrimSpace(query.Status) != "" { - where += ` AND status = ?` - args = append(args, strings.ToLower(strings.TrimSpace(query.Status))) - } - total, err := r.countResourceRows(ctx, "resource_grants", where, args...) - if err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, resourceGrantSelectSQL()+where+` ORDER BY created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - items := make([]resourcedomain.ResourceGrant, 0, query.PageSize) - for rows.Next() { - grant, err := scanResourceGrant(rows) - if err != nil { - return nil, 0, err - } - grant.Items, err = r.listResourceGrantItems(ctx, grant.GrantID) - if err != nil { - return nil, 0, err - } - items = append(items, grant) - } - return items, total, rows.Err() -} - -// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。 -func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeResourceShopItemsQuery(query) - where, args := resourceShopItemsWhereSQL(query) - var total int64 - if err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM resource_shop_items si - JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id - `+where, args...).Scan(&total); err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+` - ORDER BY si.sort_order ASC, si.shop_item_id DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize) - for rows.Next() { - item, err := scanResourceShopItem(rows) - if err != nil { - return nil, 0, err - } - items = append(items, item) - } - return items, total, rows.Err() -} - -// ListResourceShopPurchaseOrders 返回后台售卖记录;订单事实取购买表,素材展示优先使用发放时的资源快照。 -func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) { - if r == nil || r.db == nil { - return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - query.AppCode = appcode.FromContext(ctx) - query = normalizeResourceShopPurchaseOrdersQuery(query) - where, args := resourceShopPurchaseOrdersWhereSQL(query) - - var total int64 - if err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM resource_shop_purchase_orders po - JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id - `+where, args...).Scan(&total); err != nil { - return nil, 0, err - } - rows, err := r.db.QueryContext(ctx, resourceShopPurchaseOrderSelectSQL()+where+` - ORDER BY po.created_at_ms DESC, po.order_id DESC - LIMIT ? OFFSET ?`, - append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items := make([]resourcedomain.ResourceShopPurchaseOrder, 0, query.PageSize) - for rows.Next() { - item, err := scanResourceShopPurchaseOrder(rows) - if err != nil { - return nil, 0, err - } - items = append(items, item) - } - return items, total, rows.Err() -} - -// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。 -func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeResourceShopItemsCommand(command) - if err := validateResourceShopItemsCommand(command); err != nil { - return nil, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - - nowMs := time.Now().UnixMilli() - resourceIDs := make([]int64, 0, len(command.Items)) - seen := make(map[int64]struct{}, len(command.Items)) - for _, item := range command.Items { - if _, exists := seen[item.ResourceID]; exists { - return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource") - } - seen[item.ResourceID] = struct{}{} - resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) - if err != nil { - return nil, err - } - if err := validateResourceShopResource(resource); err != nil { - return nil, err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO resource_shop_items ( - app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - status = VALUES(status), - duration_days = VALUES(duration_days), - effective_from_ms = VALUES(effective_from_ms), - effective_to_ms = VALUES(effective_to_ms), - sort_order = VALUES(sort_order), - updated_by_user_id = VALUES(updated_by_user_id), - updated_at_ms = VALUES(updated_at_ms)`, - command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder, - command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, - ); err != nil { - return nil, err - } - resourceIDs = append(resourceIDs, item.ResourceID) - } - if err := tx.Commit(); err != nil { - return nil, err - } - return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs) -} - -func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command.Status = resourcedomain.NormalizeStatus(command.Status) - if command.ShopItemID <= 0 { - return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required") - } - if !resourcedomain.ValidStatus(command.Status) { - return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid") - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceShopItem{}, err - } - defer func() { _ = tx.Rollback() }() - - item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) - if err != nil { - return resourcedomain.ResourceShopItem{}, err - } - if command.Status == resourcedomain.StatusActive { - if err := validateResourceShopResource(item.Resource); err != nil { - return resourcedomain.ResourceShopItem{}, err - } - } - nowMs := time.Now().UnixMilli() - result, err := tx.ExecContext(ctx, ` - UPDATE resource_shop_items - SET status = ?, updated_by_user_id = ?, updated_at_ms = ? - WHERE app_code = ? AND shop_item_id = ?`, - command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID, - ) - if err != nil { - return resourcedomain.ResourceShopItem{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.ResourceShopItem{}, err - } else if affected == 0 { - return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceShopItem{}, err - } - return r.getResourceShopItem(ctx, command.ShopItemID) -} - -// PurchaseResourceShopItem 是 App 道具商店的唯一购买入口;扣金币、写订单和发资源权益必须共享同一事务。 -func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command.CommandID = strings.TrimSpace(command.CommandID) - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - defer func() { _ = tx.Rollback() }() - - requestHash := resourceShopPurchaseRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists { - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) - } - - nowMs := time.Now().UnixMilli() - item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := validatePurchasableResourceShopItem(item, nowMs); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if account.AvailableAmount < item.CoinPrice { - return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") - } - - transactionID := transactionID(command.AppCode, command.CommandID) - orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID) - grantID := resourceGrantID(command.AppCode, command.CommandID) - durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond) - coinBalanceAfter := account.AvailableAmount - item.CoinPrice - metadata := map[string]any{ - "app_code": command.AppCode, - "order_id": orderID, - "user_id": command.UserID, - "shop_item_id": item.ShopItemID, - "resource_id": item.ResourceID, - "resource_code": item.Resource.ResourceCode, - "resource_type": item.Resource.ResourceType, - "duration_days": item.DurationDays, - "duration_ms": durationMS, - "price_type": item.PriceType, - "coin_spent": item.CoinPrice, - "balance_after": coinBalanceAfter, - "resource_grant_id": grantID, - } - if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableDelta: -item.CoinPrice, - FrozenDelta: 0, - AvailableAfter: coinBalanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if grantItem.EntitlementID == "" { - // 道具商店只出售权益类资源;如果配置成钱包入账资源,必须阻断而不是产生无背包道具的订单。 - return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement") - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO resource_shop_purchase_orders ( - app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin, - status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`, - command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice, - transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs, - ); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs), - { - EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset), - EventType: "ResourceShopItemPurchased", - TransactionID: transactionID, - CommandID: command.CommandID, - UserID: command.UserID, - AssetType: resourceOutboxAsset, - Payload: metadata, - CreatedAtMS: nowMs, - }, - }); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - - return resourcedomain.ResourceShopPurchaseReceipt{ - OrderID: orderID, - TransactionID: transactionID, - ShopItem: item, - Resource: resource, - Balance: ledger.AssetBalance{ - AppCode: command.AppCode, - UserID: command.UserID, - AssetType: ledger.AssetCoin, - AvailableAmount: coinBalanceAfter, - FrozenAmount: account.FrozenAmount, - Version: account.Version + 1, - }, - CoinSpent: item.CoinPrice, - GrantID: grantID, - }, nil -} - -func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) { - if r == nil || r.db == nil { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - row := r.db.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) - grant, err := scanResourceGrant(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found") - } - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - grant.Items, err = r.listResourceGrantItems(ctx, grantID) - return grant, err -} - -func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand, update bool) (resourcedomain.GiftConfig, error) { - if r == nil || r.db == nil { - return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - command = normalizeGiftConfigCommand(command) - if command.ResourceID <= 0 { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config command is incomplete") - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - defer func() { _ = tx.Rollback() }() - - resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if resource.ResourceType != resourcedomain.TypeGift { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource") - } - command = applyResourcePricingToGiftCommand(command, resource) - if resourcedomain.NormalizePriceType(resource.PriceType) == "" && command.CoinPrice <= 0 { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift price is invalid") - } - if err := validateGiftConfigCommand(command); err != nil { - return resourcedomain.GiftConfig{}, err - } - if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive { - return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled") - } - if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { - return resourcedomain.GiftConfig{}, err - } - if ok, err := r.giftTypeConfigExistsTx(ctx, tx, command.GiftTypeCode); err != nil { - return resourcedomain.GiftConfig{}, err - } else if !ok { - return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift type is not configured") - } - nowMs := time.Now().UnixMilli() - // CP 关系类型存放在 presentation_json 内,保存前统一合并,避免 gift_configs 为单个业务展示字段扩表。 - presentation := giftPresentationWithCPRelationType(command.PresentationJSON, command.CPRelationType) - effectTypesJSON, err := json.Marshal(command.EffectTypes) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if !update { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO gift_configs ( - app_code, gift_id, resource_id, status, name, sort_order, presentation_json, - gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, - command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), - command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, - ); err != nil { - return resourcedomain.GiftConfig{}, err - } - } else { - result, err := tx.ExecContext(ctx, ` - UPDATE gift_configs - SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?, - gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?, - updated_by_user_id = ?, updated_at_ms = ? - WHERE app_code = ? AND gift_id = ?`, - command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, - command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), - command.OperatorUserID, nowMs, command.AppCode, command.GiftID, - ) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return resourcedomain.GiftConfig{}, err - } else if affected == 0 { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") - } - } - if err := r.upsertGiftPriceTx(ctx, tx, command, nowMs); err != nil { - return resourcedomain.GiftConfig{}, err - } - if err := r.replaceGiftConfigRegionsTx(ctx, tx, command.GiftID, command.RegionIDs, nowMs); err != nil { - return resourcedomain.GiftConfig{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:config:%d", command.GiftID, nowMs), 0, command.ResourceID, command, nowMs), - }); err != nil { - return resourcedomain.GiftConfig{}, err - } - if err := tx.Commit(); err != nil { - return resourcedomain.GiftConfig{}, err - } - return r.getGiftConfig(ctx, command.GiftID) -} - -func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) { - if quantity <= 0 { - return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive") - } - snapshot, err := json.Marshal(resource) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - item := resourcedomain.ResourceGrantItem{ - GrantID: grantID, - ResourceID: resource.ResourceID, - ResourceSnapshotJSON: string(snapshot), - Quantity: quantity, - DurationMS: durationMS, - CreatedAtMS: nowMs, - } - if resourcedomain.IsWalletCredit(resource) { - assetType := resourcedomain.NormalizeWalletAssetType(resource.WalletAssetType) - amount, err := checkedMul(resource.WalletAssetAmount, quantity) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - walletCommandID := fmt.Sprintf("resource_grant:%s:%d", commandID, resource.ResourceID) - transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) - metadata := map[string]any{ - "app_code": appcode.FromContext(ctx), - "grant_id": grantID, - "command_id": commandID, - "target_user_id": targetUserID, - "resource_id": resource.ResourceID, - "resource_code": resource.ResourceCode, - "resource_type": resource.ResourceType, - "quantity": quantity, - "wallet_amount": amount, - "wallet_asset": assetType, - "operator_reason": "resource grant", - } - if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, resourceWalletCreditHash(grantID, resource.ResourceID, quantity), grantID, metadata, nowMs); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - availableAfter := account.AvailableAmount + amount - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: targetUserID, - AssetType: assetType, - AvailableDelta: amount, - FrozenDelta: 0, - AvailableAfter: availableAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - }); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - item.ResultType = resourcedomain.ResultWalletCredit - item.WalletTransactionID = transactionID - } else { - entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - item.ResultType = resourcedomain.ResultEntitlement - item.EntitlementID = entitlementID - if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - } - inserted, err := r.insertResourceGrantItem(ctx, tx, item) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - return inserted, nil -} - -func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, groupItem resourcedomain.ResourceGroupItem, nowMs int64) (resourcedomain.ResourceGrantItem, error) { - assetType := resourcedomain.NormalizeWalletAssetType(groupItem.WalletAssetType) - if !validGroupWalletAssetType(assetType) || groupItem.WalletAssetAmount <= 0 { - return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") - } - snapshot, err := json.Marshal(map[string]any{ - "group_item_id": groupItem.GroupItemID, - "group_id": groupItem.GroupID, - "item_type": resourcedomain.GroupItemTypeWalletAsset, - "wallet_asset_type": assetType, - "wallet_asset_amount": groupItem.WalletAssetAmount, - "sort_order": groupItem.SortOrder, - }) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType) - transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) - metadata := map[string]any{ - "app_code": appcode.FromContext(ctx), - "grant_id": grantID, - "command_id": commandID, - "target_user_id": targetUserID, - "group_id": groupItem.GroupID, - "group_item_id": groupItem.GroupItemID, - "item_type": resourcedomain.GroupItemTypeWalletAsset, - "wallet_amount": groupItem.WalletAssetAmount, - "wallet_asset": assetType, - "operator_reason": "resource group grant", - } - if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, groupItem.WalletAssetAmount, 0, nowMs); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - availableAfter := account.AvailableAmount + groupItem.WalletAssetAmount - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: targetUserID, - AssetType: assetType, - AvailableDelta: groupItem.WalletAssetAmount, - FrozenDelta: 0, - AvailableAfter: availableAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMs, - }); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), - }); err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - inserted, err := r.insertResourceGrantItem(ctx, tx, resourcedomain.ResourceGrantItem{ - GrantID: grantID, - ResourceID: 0, - ResourceSnapshotJSON: string(snapshot), - Quantity: groupItem.WalletAssetAmount, - DurationMS: 0, - ResultType: resourcedomain.ResultWalletCredit, - WalletTransactionID: transactionID, - CreatedAtMS: nowMs, - }) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - return inserted, nil -} - -func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (string, error) { - expiresAt := int64(0) - if durationMS > 0 { - if durationMS > math.MaxInt64-nowMs { - return "", xerr.New(xerr.InvalidArgument, "duration overflow") - } - expiresAt = nowMs + durationMS - } - strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) - if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag { - existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, nowMs) - if err != nil { - return "", err - } - if exists { - newQuantity := existing.Quantity - newRemaining := existing.RemainingQuantity - newExpiresAt := existing.ExpiresAtMS - switch strategy { - case resourcedomain.GrantStrategyExtendExpiry: - newRemaining += quantity - newQuantity += quantity - if durationMS > 0 { - base := max(existing.ExpiresAtMS, nowMs) - if durationMS > math.MaxInt64-base { - return "", xerr.New(xerr.InvalidArgument, "duration overflow") - } - newExpiresAt = base + durationMS - } else { - newExpiresAt = 0 - } - case resourcedomain.GrantStrategyIncreaseQuantity: - newQuantity += quantity - newRemaining += quantity - case resourcedomain.GrantStrategySetActiveFlag: - newQuantity = 1 - newRemaining = 1 - newExpiresAt = expiresAt - } - if _, err := tx.ExecContext(ctx, ` - UPDATE user_resource_entitlements - SET status = 'active', quantity = ?, remaining_quantity = ?, expires_at_ms = ?, - source_grant_id = ?, updated_at_ms = ? - WHERE app_code = ? AND entitlement_id = ?`, - newQuantity, newRemaining, newExpiresAt, grantID, nowMs, appcode.FromContext(ctx), existing.EntitlementID, - ); err != nil { - return "", err - } - return existing.EntitlementID, nil - } - } - entitlementID := entitlementID(appcode.FromContext(ctx), grantID, resource.ResourceID, nowMs) - if _, err := tx.ExecContext(ctx, ` - INSERT INTO user_resource_entitlements ( - app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity, - effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID, nowMs, nowMs, - ); err != nil { - return "", err - } - return entitlementID, nil -} - -func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error { - if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" { - return nil - } - entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) - if err != nil { - return err - } - if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil { - return err - } - entitlement.Equipped = true - // 徽章和麦位声波都属于“拿到即可默认展示”的装扮资源;写 equipment 必须和授权在同一事务内完成, - // 否则发放成功但佩戴失败会让背包、麦位表现和后续投影读到不一致的状态。 - // 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。 - eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) - return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - resourceOutboxEvent("UserResourceChanged", eventCommandID, userID, resource.ResourceID, map[string]any{ - "action": "auto_equip", - "app_code": appcode.FromContext(ctx), - "user_id": userID, - "resource_id": resource.ResourceID, - "resource_type": resource.ResourceType, - "entitlement": entitlement, - "updated_at_ms": nowMs, - "event_command": eventCommandID, - }, nowMs), - }) -} - -func (r *Repository) equipUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement, nowMs int64) error { - resourceType := resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) - if !isEquipableResourceType(resourceType) { - return xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") - } - if !allowsMultipleEquippedResources(resourceType) { - // schema 已经允许同类型多条 equipment;非徽章类型仍要在写入侧清掉旧指针,保持头像框/资料卡/座驾等单选语义。 - if _, err := tx.ExecContext(ctx, ` - DELETE FROM user_resource_equipment - WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id <> ?`, - appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, - ); err != nil { - return err - } - } - _, err := tx.ExecContext(ctx, ` - INSERT INTO user_resource_equipment ( - app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - resource_id = VALUES(resource_id), - updated_at_ms = VALUES(updated_at_ms)`, - appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs, - ) - return err -} - -func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error { - if strings.TrimSpace(entitlementID) == "" { - return nil - } - _, err := tx.ExecContext(ctx, ` - UPDATE user_resource_entitlements - SET expires_at_ms = ?, updated_at_ms = ? - WHERE app_code = ? AND entitlement_id = ?`, - expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID, - ) - return err -} - -func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) { - row := tx.QueryRowContext(ctx, ` - SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, - e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, - e.source_grant_id, e.created_at_ms, e.updated_at_ms, - FALSE AS equipped, - `+resourceColumnsWithAlias("r")+` - FROM user_resource_entitlements e - JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id - WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' - AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) - ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC - LIMIT 1 - FOR UPDATE`, - appcode.FromContext(ctx), userID, resourceID, nowMs, nowMs, - ) - item, err := scanUserResourceEntitlement(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.UserResourceEntitlement{}, false, nil - } - return item, err == nil, err -} - -func (r *Repository) queryEquipableEntitlementForUpdate(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, error) { - where := ` - WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' - AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) - AND e.remaining_quantity > 0 AND r.status = 'active'` - args := []any{appcode.FromContext(ctx), command.UserID, command.ResourceID, nowMs, nowMs} - if command.EntitlementID != "" { - where += ` AND e.entitlement_id = ?` - args = append(args, command.EntitlementID) - } - query := userResourceSelectSQL() + where + ` - ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC - LIMIT 1 - FOR UPDATE` - item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, query, args...)) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") - } - return item, err -} - -func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) { - result, err := tx.ExecContext(ctx, ` - INSERT INTO resource_grant_items ( - app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms, - result_type, wallet_transaction_id, entitlement_id, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), item.GrantID, item.ResourceID, item.ResourceSnapshotJSON, - item.Quantity, item.DurationMS, item.ResultType, item.WalletTransactionID, item.EntitlementID, item.CreatedAtMS, - ) - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - id, err := result.LastInsertId() - if err != nil { - return resourcedomain.ResourceGrantItem{}, err - } - item.GrantItemID = id - return item, nil -} - -func (r *Repository) insertResourceGrant(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, grantSource string, subjectType string, subjectID string, requestHash string, groupSnapshotJSON string, reason string, operatorUserID int64, nowMs int64) error { - var snapshot any - if strings.TrimSpace(groupSnapshotJSON) == "" { - snapshot = nil - } else { - snapshot = groupSnapshotJSON - } - _, err := tx.ExecContext(ctx, ` - INSERT INTO resource_grants ( - app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, - grant_subject_id, status, request_hash, group_snapshot_json, reason, - operator_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), grantID, commandID, targetUserID, grantSource, subjectType, - subjectID, resourcedomain.GrantStatusDone, requestHash, snapshot, reason, operatorUserID, nowMs, nowMs, - ) - return err -} - -func (r *Repository) lookupResourceGrant(ctx context.Context, tx *sql.Tx, commandID string, requestHash string) (string, bool, error) { - row := tx.QueryRowContext(ctx, - `SELECT grant_id, request_hash FROM resource_grants WHERE app_code = ? AND command_id = ? FOR UPDATE`, - appcode.FromContext(ctx), commandID, - ) - var grantID string - var storedHash string - if err := row.Scan(&grantID, &storedHash); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", false, nil - } - return "", false, err - } - if storedHash != requestHash { - return "", true, xerr.New(xerr.LedgerConflict, "resource grant idempotency conflict") - } - return grantID, true, nil -} - -func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) { - row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) - grant, err := scanResourceGrant(row) - if err != nil { - return resourcedomain.ResourceGrant{}, err - } - grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grantID) - return grant, err -} - -func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) { - return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID) -} - -func (r *Repository) listResourceGrantItemsTx(ctx context.Context, tx *sql.Tx, grantID string) ([]resourcedomain.ResourceGrantItem, error) { - return r.listResourceGrantItemsWithQuery(ctx, tx, grantID) -} - -func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querier interface { - QueryContext(context.Context, string, ...any) (*sql.Rows, error) -}, grantID string) ([]resourcedomain.ResourceGrantItem, error) { - rows, err := querier.QueryContext(ctx, ` - SELECT grant_item_id, grant_id, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'), - quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms - FROM resource_grant_items - WHERE app_code = ? AND grant_id = ? - ORDER BY grant_item_id ASC`, - appcode.FromContext(ctx), grantID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]resourcedomain.ResourceGrantItem, 0) - for rows.Next() { - var item resourcedomain.ResourceGrantItem - if err := rows.Scan(&item.GrantItemID, &item.GrantID, &item.ResourceID, &item.ResourceSnapshotJSON, &item.Quantity, &item.DurationMS, &item.ResultType, &item.WalletTransactionID, &item.EntitlementID, &item.CreatedAtMS); err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error { - legacyHeatValue := command.CoinPrice - _, err := tx.ExecContext(ctx, ` - INSERT INTO wallet_gift_prices ( - app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, - heat_value, effective_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - status = VALUES(status), - charge_asset_type = VALUES(charge_asset_type), - coin_price = VALUES(coin_price), - gift_point_amount = VALUES(gift_point_amount), - heat_value = VALUES(heat_value), - effective_at_ms = VALUES(effective_at_ms), - updated_at_ms = VALUES(updated_at_ms)`, - appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice, - command.GiftPointAmount, legacyHeatValue, command.EffectiveAtMS, nowMs, nowMs, - ) - return err -} - -func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) { - row := querier.QueryRowContext(ctx, - `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value - FROM wallet_gift_prices - WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? - ORDER BY effective_at_ms DESC, price_version DESC - LIMIT 1`, - appcode.FromContext(ctx), giftID, nowMs, - ) - var price giftPrice - if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") - } - return giftPrice{}, err - } - price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) - return price, nil -} - -func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) { - row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID) - gift, err := scanGiftConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") - } - if err != nil { - return resourcedomain.GiftConfig{}, err - } - price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli()) - if priceErr == nil { - gift.PriceVersion = price.PriceVersion - gift.ChargeAssetType = price.ChargeAssetType - gift.CoinPrice = price.CoinPrice - // gift_point_amount 是历史列,后台和送礼主链路只暴露真实 COIN 价格。 - gift.GiftPointAmount = 0 - // heat_value 是旧配置列,后台不再读写;对兼容调用方只回真实价格,房间贡献也按真实扣费结算。 - gift.HeatValue = price.CoinPrice - } - gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - return gift, nil -} - -func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, giftID string) (resourcedomain.GiftConfig, error) { - row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ? FOR UPDATE`, appcode.FromContext(ctx), giftID) - gift, err := scanGiftConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") - } - if err != nil { - return resourcedomain.GiftConfig{}, err - } - // 删除前快照需要带当前有效价格和投放区域,便于审计和 outbox 消费者判断被删除的是哪条配置。 - price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli()) - if priceErr == nil { - gift.PriceVersion = price.PriceVersion - gift.ChargeAssetType = price.ChargeAssetType - gift.CoinPrice = price.CoinPrice - gift.GiftPointAmount = 0 - gift.HeatValue = price.CoinPrice - } - gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) - if err != nil { - return resourcedomain.GiftConfig{}, err - } - return gift, nil -} - -func (r *Repository) getGiftTypeConfigTx(ctx context.Context, tx *sql.Tx, typeCode string) (resourcedomain.GiftTypeConfig, error) { - row := tx.QueryRowContext(ctx, giftTypeConfigSelectSQL()+`WHERE app_code = ? AND type_code = ?`, appcode.FromContext(ctx), typeCode) - item, err := scanGiftTypeConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.NotFound, "gift type config not found") - } - return item, err -} - -func (r *Repository) giftTypeConfigExistsTx(ctx context.Context, tx *sql.Tx, typeCode string) (bool, error) { - var exists int - err := tx.QueryRowContext(ctx, ` - SELECT 1 - FROM gift_type_configs - WHERE app_code = ? AND type_code = ? - LIMIT 1`, appcode.FromContext(ctx), typeCode).Scan(&exists) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return err == nil, err -} - -func (r *Repository) ensureDefaultGiftTypeConfigs(ctx context.Context, execer sqlExecer) error { - nowMs := time.Now().UnixMilli() - for _, item := range resourcedomain.DefaultGiftTypeConfigs(appcode.FromContext(ctx)) { - if _, err := execer.ExecContext(ctx, ` - INSERT IGNORE INTO gift_type_configs ( - app_code, type_code, name, tab_key, status, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`, - item.AppCode, item.TypeCode, item.Name, item.TabKey, item.Status, item.SortOrder, nowMs, nowMs, - ); err != nil { - return err - } - } - return nil -} - -func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) { - nowMs := time.Now().UnixMilli() - row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+` - WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' - AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) - AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?) - AND EXISTS ( - SELECT 1 - FROM gift_config_regions gcr - WHERE gcr.app_code = gc.app_code - AND gcr.gift_id = gc.gift_id - AND (gcr.region_id = 0 OR gcr.region_id = ?) - ) - FOR UPDATE`, - appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID, - ) - gift, err := scanGiftConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config is not active") - } - if err != nil { - return resourcedomain.GiftConfig{}, err - } - gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) - return gift, err -} - -func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) { - row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID) - resource, err := scanResource(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") - } - return resource, err -} - -func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) { - return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false) -} - -func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { - return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock) -} - -func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { - query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?` - if lock { - query += ` FOR UPDATE` - } - row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID) - item, err := scanResourceShopItem(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") - } - return item, err -} - -func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) { - item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, - userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`, - appcode.FromContext(ctx), entitlementID, - )) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") - } - return item, err -} - -func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) { - var orderID string - var shopItemID int64 - var priceCoin int64 - var grantID string - var entitlementID string - err := tx.QueryRowContext(ctx, ` - SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id - FROM resource_shop_purchase_orders - WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`, - appcode.FromContext(ctx), transactionID, userID, - ).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) - if err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } - if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil { - return resourcedomain.ResourceShopPurchaseReceipt{}, err - } else if exists { - balance.Version = account.Version - } - return resourcedomain.ResourceShopPurchaseReceipt{ - OrderID: orderID, - TransactionID: transactionID, - ShopItem: item, - Resource: resource, - Balance: balance, - CoinSpent: priceCoin, - GrantID: grantID, - }, nil -} - -func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) { - resourceIDs = compactPositiveInt64s(resourceIDs) - if len(resourceIDs) == 0 { - return []resourcedomain.ResourceShopItem{}, nil - } - args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...) - rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+` - WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`) - ORDER BY si.sort_order ASC, si.shop_item_id DESC`, - args..., - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs)) - for rows.Next() { - item, err := scanResourceShopItem(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) { - query := ` - SELECT app_code, group_id, group_code, name, status, description, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - FROM resource_groups - WHERE app_code = ? AND group_id = ?` - if lock { - query += ` FOR UPDATE` - } - row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), groupID) - group, err := scanResourceGroup(row) - if errors.Is(err, sql.ErrNoRows) { - return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") - } - return group, err -} - -func (r *Repository) listResourceGroupItems(ctx context.Context, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { - return r.listResourceGroupItemsWithQuery(ctx, r.db, groupID) -} - -func (r *Repository) listResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, lock bool) ([]resourcedomain.ResourceGroupItem, error) { - if lock { - if err := r.lockResourceGroupItemRows(ctx, tx, groupID); err != nil { - return nil, err - } - } - items, err := r.listResourceGroupItemsWithQuery(ctx, tx, groupID) - if err != nil || !lock { - return items, err - } - for index := range items { - if resourcedomain.NormalizeGroupItemType(items[index].ItemType) != resourcedomain.GroupItemTypeResource { - continue - } - resource, err := r.getResourceForUpdate(ctx, tx, items[index].ResourceID) - if err != nil { - return nil, err - } - items[index].Resource = resource - } - return items, nil -} - -func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querier interface { - QueryContext(context.Context, string, ...any) (*sql.Rows, error) -}, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { - query := ` - SELECT i.group_item_id, i.group_id, i.item_type, i.resource_id, - i.wallet_asset_type, i.wallet_asset_amount, - ` + nullableResourceColumnsWithAlias("r") + `, - i.quantity, i.duration_ms, i.sort_order, i.created_at_ms, i.updated_at_ms - FROM resource_group_items i - LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id - WHERE i.app_code = ? AND i.group_id = ? - ORDER BY i.sort_order ASC, i.group_item_id ASC` - rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]resourcedomain.ResourceGroupItem, 0) - for rows.Next() { - item, err := scanResourceGroupItem(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx, groupID int64) error { - rows, err := tx.QueryContext(ctx, ` - SELECT group_item_id - FROM resource_group_items - WHERE app_code = ? AND group_id = ? - ORDER BY sort_order ASC, group_item_id ASC - FOR UPDATE`, - appcode.FromContext(ctx), groupID, - ) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return err - } - } - return rows.Err() -} - -func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, items []resourcedomain.ResourceGroupItemInput, requireGrantable bool, nowMs int64) error { - seenResources := make(map[int64]struct{}, len(items)) - seenWalletAssets := make(map[string]struct{}, len(items)) - if _, err := tx.ExecContext(ctx, `DELETE FROM resource_group_items WHERE app_code = ? AND group_id = ?`, appcode.FromContext(ctx), groupID); err != nil { - return err - } - for _, item := range items { - item = normalizeResourceGroupItemInput(item) - switch item.ItemType { - case resourcedomain.GroupItemTypeResource: - if item.ResourceID <= 0 || item.Quantity <= 0 { - return xerr.New(xerr.InvalidArgument, "resource group item is invalid") - } - if _, ok := seenResources[item.ResourceID]; ok { - return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") - } - seenResources[item.ResourceID] = struct{}{} - resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) - if err != nil { - return err - } - if requireGrantable { - if err := validateActiveResourceGroupResource(resource); err != nil { - return err - } - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO resource_group_items ( - app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, - quantity, duration_ms, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), groupID, item.ItemType, item.ResourceID, "", int64(0), - item.Quantity, item.DurationMS, item.SortOrder, nowMs, nowMs, - ); err != nil { - return err - } - case resourcedomain.GroupItemTypeWalletAsset: - if !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { - return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") - } - if _, ok := seenWalletAssets[item.WalletAssetType]; ok { - return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") - } - seenWalletAssets[item.WalletAssetType] = struct{}{} - if _, err := tx.ExecContext(ctx, ` - INSERT INTO resource_group_items ( - app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, - quantity, duration_ms, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), groupID, item.ItemType, int64(0), item.WalletAssetType, item.WalletAssetAmount, - int64(1), int64(0), item.SortOrder, nowMs, nowMs, - ); err != nil { - return err - } - default: - return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") - } - } - return nil -} - -func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error { - if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { - return err - } - for _, regionID := range regionIDs { - if regionID < 0 { - return xerr.New(xerr.InvalidArgument, "gift region is invalid") - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO gift_config_regions ( - app_code, gift_id, region_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs, - ); err != nil { - return err - } - } - return nil -} - -func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) { - rows, err := querier.QueryContext(ctx, ` - SELECT region_id - FROM gift_config_regions - WHERE app_code = ? AND gift_id = ? - ORDER BY region_id ASC`, - appcode.FromContext(ctx), giftID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - regionIDs := make([]int64, 0) - for rows.Next() { - var regionID int64 - if err := rows.Scan(®ionID); err != nil { - return nil, err - } - regionIDs = append(regionIDs, regionID) - } - return regionIDs, rows.Err() -} - -func (r *Repository) countResourceRows(ctx context.Context, table string, where string, args ...any) (int64, error) { - var total int64 - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` `+where, args...).Scan(&total); err != nil { - return 0, err - } - return total, nil -} - -func scanResource(scanner scanTarget) (resourcedomain.Resource, error) { - var resource resourcedomain.Resource - var scopesJSON string - if err := scanner.Scan( - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - ); err != nil { - return resourcedomain.Resource{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - return resource, nil -} - -func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) { - var item resourcedomain.ResourceShopItem - var resource resourcedomain.Resource - var scopesJSON string - if err := scanner.Scan( - &item.AppCode, - &item.ShopItemID, - &item.ResourceID, - &item.Status, - &item.DurationDays, - &item.PriceType, - &item.CoinPrice, - &item.EffectiveFromMS, - &item.EffectiveToMS, - &item.SortOrder, - &item.CreatedByUserID, - &item.UpdatedByUserID, - &item.CreatedAtMS, - &item.UpdatedAtMS, - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - ); err != nil { - return resourcedomain.ResourceShopItem{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - item.Resource = resource - return item, nil -} - -func scanResourceShopPurchaseOrder(scanner scanTarget) (resourcedomain.ResourceShopPurchaseOrder, error) { - var item resourcedomain.ResourceShopPurchaseOrder - var resource resourcedomain.Resource - var scopesJSON string - if err := scanner.Scan( - &item.AppCode, - &item.OrderID, - &item.CommandID, - &item.UserID, - &item.ShopItemID, - &item.ResourceID, - &item.DurationDays, - &item.PriceCoin, - &item.Status, - &item.WalletTransactionID, - &item.ResourceGrantID, - &item.EntitlementID, - &item.ResourceSnapshotJSON, - &item.CreatedAtMS, - &item.UpdatedAtMS, - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - ); err != nil { - return resourcedomain.ResourceShopPurchaseOrder{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - item.Resource = resourceFromSnapshotJSON(item.ResourceSnapshotJSON, resource) - return item, nil -} - -func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) { - var group resourcedomain.ResourceGroup - err := scanner.Scan( - &group.AppCode, - &group.GroupID, - &group.GroupCode, - &group.Name, - &group.Status, - &group.Description, - &group.SortOrder, - &group.CreatedByUserID, - &group.UpdatedByUserID, - &group.CreatedAtMS, - &group.UpdatedAtMS, - ) - return group, err -} - -func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem, error) { - var item resourcedomain.ResourceGroupItem - var resource resourcedomain.Resource - var scopesJSON string - if err := scanner.Scan( - &item.GroupItemID, - &item.GroupID, - &item.ItemType, - &item.ResourceID, - &item.WalletAssetType, - &item.WalletAssetAmount, - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - &item.Quantity, - &item.DurationMS, - &item.SortOrder, - &item.CreatedAtMS, - &item.UpdatedAtMS, - ); err != nil { - return resourcedomain.ResourceGroupItem{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - item.Resource = resource - return item, nil -} - -func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) { - var gift resourcedomain.GiftConfig - var resource resourcedomain.Resource - var scopesJSON string - var effectTypesJSON string - if err := scanner.Scan( - &gift.AppCode, - &gift.GiftID, - &gift.ResourceID, - &gift.Status, - &gift.Name, - &gift.SortOrder, - &gift.PresentationJSON, - &gift.GiftTypeCode, - &gift.EffectiveFromMS, - &gift.EffectiveToMS, - &effectTypesJSON, - &gift.CreatedByUserID, - &gift.UpdatedByUserID, - &gift.CreatedAtMS, - &gift.UpdatedAtMS, - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - ); err != nil { - return resourcedomain.GiftConfig{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - gift.Resource = resource - gift.EffectTypes = parseStringArray(effectTypesJSON) - // CPRelationType 是展示 JSON 的结构化投影,gift panel 和送礼回执不再让调用方自己解析 JSON。 - gift.CPRelationType = cpRelationTypeFromPresentationJSON(gift.PresentationJSON) - return gift, nil -} - -func scanGiftTypeConfig(scanner scanTarget) (resourcedomain.GiftTypeConfig, error) { - var item resourcedomain.GiftTypeConfig - err := scanner.Scan( - &item.AppCode, - &item.TypeCode, - &item.Name, - &item.TabKey, - &item.Status, - &item.SortOrder, - &item.CreatedByUserID, - &item.UpdatedByUserID, - &item.CreatedAtMS, - &item.UpdatedAtMS, - ) - return item, err -} - -func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) { - var item resourcedomain.UserResourceEntitlement - var resource resourcedomain.Resource - var scopesJSON string - if err := scanner.Scan( - &item.AppCode, - &item.EntitlementID, - &item.UserID, - &item.ResourceID, - &item.Status, - &item.Quantity, - &item.RemainingQuantity, - &item.EffectiveAtMS, - &item.ExpiresAtMS, - &item.SourceGrantID, - &item.CreatedAtMS, - &item.UpdatedAtMS, - &item.Equipped, - &resource.AppCode, - &resource.ResourceID, - &resource.ResourceCode, - &resource.ResourceType, - &resource.Name, - &resource.Status, - &resource.Grantable, - &resource.ManagerGrantEnabled, - &resource.GrantStrategy, - &resource.WalletAssetType, - &resource.WalletAssetAmount, - &resource.PriceType, - &resource.CoinPrice, - &resource.GiftPointAmount, - &scopesJSON, - &resource.AssetURL, - &resource.PreviewURL, - &resource.AnimationURL, - &resource.MetadataJSON, - &resource.SortOrder, - &resource.CreatedByUserID, - &resource.UpdatedByUserID, - &resource.CreatedAtMS, - &resource.UpdatedAtMS, - ); err != nil { - return resourcedomain.UserResourceEntitlement{}, err - } - resource.UsageScopes = parseStringArray(scopesJSON) - item.Resource = resource - return item, nil -} - -func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error) { - var grant resourcedomain.ResourceGrant - err := scanner.Scan( - &grant.AppCode, - &grant.GrantID, - &grant.CommandID, - &grant.TargetUserID, - &grant.GrantSource, - &grant.GrantSubjectType, - &grant.GrantSubjectID, - &grant.Status, - &grant.Reason, - &grant.OperatorUserID, - &grant.CreatedAtMS, - &grant.UpdatedAtMS, - ) - return grant, err -} - -func resourceSelectSQL() string { - return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources` -} - -func resourceColumnsWithAlias(alias string) string { - prefix := "" - if alias != "" { - prefix = alias + "." - } - return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` + - prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` + - prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + - prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` + - prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms` -} - -func nullableResourceColumnsWithAlias(alias string) string { - prefix := "" - if alias != "" { - prefix = alias + "." - } - return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` + - `COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` + - `COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` + - `COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + - `COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` + - `COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` + - `COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` + - `COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)` -} - -func giftConfigSelectSQL() string { - return ` - SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order, - COALESCE(CAST(gc.presentation_json AS CHAR), '{}'), - COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0), - COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'), - gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms, - ` + resourceColumnsWithAlias("r") + ` - FROM gift_configs gc - JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id - ` -} - -func giftTypeConfigSelectSQL() string { - return ` - SELECT app_code, type_code, name, tab_key, status, sort_order, - created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - FROM gift_type_configs - ` -} - -func resourceShopItemSelectSQL() string { - return ` - SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days, - r.price_type, - CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price, - si.effective_from_ms, si.effective_to_ms, si.sort_order, - si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms, - ` + resourceColumnsWithAlias("r") + ` - FROM resource_shop_items si - JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id - ` -} - -func resourceShopPurchaseOrderSelectSQL() string { - return ` - SELECT po.app_code, po.order_id, po.command_id, po.user_id, po.shop_item_id, po.resource_id, - po.duration_days, po.price_coin, po.status, po.wallet_transaction_id, po.resource_grant_id, - po.entitlement_id, COALESCE(CAST(gi.resource_snapshot_json AS CHAR), ''), po.created_at_ms, po.updated_at_ms, - ` + resourceColumnsWithAlias("r") + ` - FROM resource_shop_purchase_orders po - JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id - LEFT JOIN resource_grant_items gi ON gi.app_code = po.app_code - AND gi.grant_id = po.resource_grant_id - AND gi.resource_id = po.resource_id - ` -} - -func userResourceSelectSQL() string { - return ` - SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, - e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, - e.source_grant_id, e.created_at_ms, e.updated_at_ms, - CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped, - ` + resourceColumnsWithAlias("r") + ` - FROM user_resource_entitlements e - JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id - LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code - AND eq.user_id = e.user_id - AND eq.resource_type = r.resource_type - AND eq.entitlement_id = e.entitlement_id - ` -} - -func resourceGrantSelectSQL() string { - return ` - SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, - grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms - FROM resource_grants - ` -} - -func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) { - where := `WHERE app_code = ?` - args := []any{query.AppCode} - if query.ManagerGrantOnly { - where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE` - } else if query.ActiveOnly { - where += ` AND status = 'active'` - } else if query.Status != "" { - where += ` AND status = ?` - args = append(args, query.Status) - } - if query.ResourceType != "" { - where += ` AND resource_type = ?` - args = append(args, query.ResourceType) - } - if query.Keyword != "" { - like := "%" + query.Keyword + "%" - where += ` AND (resource_code LIKE ? OR name LIKE ?)` - args = append(args, like, like) - } - return where, args -} - -func resourceGroupWhereSQL(query resourcedomain.ListResourceGroupsQuery) (string, []any) { - where := `WHERE app_code = ?` - args := []any{query.AppCode} - if query.ActiveOnly { - where += ` AND status = 'active'` - } else if query.Status != "" { - where += ` AND status = ?` - args = append(args, query.Status) - } - if query.Keyword != "" { - like := "%" + query.Keyword + "%" - where += ` AND (group_code LIKE ? OR name LIKE ?)` - args = append(args, like, like) - } - return where, args -} - -func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) { - where := `WHERE gc.app_code = ?` - args := []any{query.AppCode} - if query.ActiveOnly { - where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' - AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) - AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)` - nowMs := time.Now().UnixMilli() - args = append(args, nowMs, nowMs) - } else if query.Status != "" { - where += ` AND gc.status = ?` - args = append(args, query.Status) - } - if query.Keyword != "" { - like := "%" + query.Keyword + "%" - where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)` - args = append(args, like, like, like) - } - if query.GiftTypeCode != "" { - where += ` AND gc.gift_type_code = ?` - args = append(args, query.GiftTypeCode) - } - if query.FilterRegion { - where += ` AND EXISTS ( - SELECT 1 - FROM gift_config_regions gcr - WHERE gcr.app_code = gc.app_code - AND gcr.gift_id = gc.gift_id - AND (gcr.region_id = 0 OR gcr.region_id = ?) - )` - args = append(args, query.RegionID) - } - return where, args -} - -func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (string, []any) { - where := `WHERE app_code = ?` - args := []any{query.AppCode} - if query.ActiveOnly { - where += ` AND status = 'active'` - } else if query.Status != "" { - where += ` AND status = ?` - args = append(args, query.Status) - } - return where, args -} - -func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) { - where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')` - args := []any{query.AppCode} - if query.ActiveOnly { - nowMs := time.Now().UnixMilli() - where += ` AND si.status = 'active' AND r.status = 'active' - AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?) - AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)` - args = append(args, nowMs, nowMs) - } else if query.Status != "" { - where += ` AND si.status = ?` - args = append(args, query.Status) - } - if query.ResourceType != "" { - where += ` AND r.resource_type = ?` - args = append(args, query.ResourceType) - } - if query.Keyword != "" { - like := "%" + query.Keyword + "%" - where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)` - args = append(args, like, like) - } - return where, args -} - -func resourceShopPurchaseOrdersWhereSQL(query resourcedomain.ListResourceShopPurchaseOrdersQuery) (string, []any) { - where := `WHERE po.app_code = ?` - args := []any{query.AppCode} - if query.UserID > 0 { - where += ` AND po.user_id = ?` - args = append(args, query.UserID) - } - if query.Status != "" { - where += ` AND po.status = ?` - args = append(args, query.Status) - } - if query.ResourceType != "" { - where += ` AND r.resource_type = ?` - args = append(args, query.ResourceType) - } - if query.Keyword != "" { - like := "%" + query.Keyword + "%" - where += ` AND (po.order_id LIKE ? OR po.command_id LIKE ? OR r.resource_code LIKE ? OR r.name LIKE ?)` - args = append(args, like, like, like, like) - } - return where, args -} - -func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery { - query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) - if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) { - query.ResourceType = "__invalid__" - } - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Keyword = strings.TrimSpace(query.Keyword) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizeResourceGroupListQuery(query resourcedomain.ListResourceGroupsQuery) resourcedomain.ListResourceGroupsQuery { - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Keyword = strings.TrimSpace(query.Keyword) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery { - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Keyword = strings.TrimSpace(query.Keyword) - // 列表查询的 gift_type_code 是可选过滤条件;空值必须保持为空,避免礼物面板加载全部礼物时被默认收窄到 normal。 - if strings.TrimSpace(query.GiftTypeCode) != "" { - query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode) - } - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizeGiftTypeConfigListQuery(query resourcedomain.ListGiftTypeConfigsQuery) resourcedomain.ListGiftTypeConfigsQuery { - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - if query.Status != "" && !resourcedomain.ValidStatus(query.Status) { - query.Status = "__invalid__" - } - return query -} - -func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery { - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery { - query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) - if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) { - query.ResourceType = "__invalid__" - } - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Keyword = strings.TrimSpace(query.Keyword) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizeResourceShopPurchaseOrdersQuery(query resourcedomain.ListResourceShopPurchaseOrdersQuery) resourcedomain.ListResourceShopPurchaseOrdersQuery { - if query.UserID < 0 { - query.UserID = 0 - } - query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) - if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) { - query.ResourceType = "__invalid__" - } - query.Status = strings.ToLower(strings.TrimSpace(query.Status)) - query.Keyword = strings.TrimSpace(query.Keyword) - query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) - return query -} - -func normalizePage(page int32, pageSize int32) (int32, int32) { - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - if pageSize > 100 { - pageSize = 100 - } - return page, pageSize -} - -func resourceOffset(page int32, pageSize int32) int32 { - return (page - 1) * pageSize -} - -func placeholders(count int) string { - if count <= 0 { - return "" - } - items := make([]string, count) - for index := range items { - items[index] = "?" - } - return strings.Join(items, ",") -} - -func int64AnyArgs(values []int64) []any { - args := make([]any, 0, len(values)) - for _, value := range values { - args = append(args, value) - } - return args -} - -func stringAnyArgs(values []string) []any { - args := make([]any, 0, len(values)) - for _, value := range values { - args = append(args, value) - } - return args -} - -func compactPositiveInt64s(values []int64) []int64 { - seen := make(map[int64]struct{}, len(values)) - out := make([]int64, 0, len(values)) - for _, value := range values { - if value <= 0 { - continue - } - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - return out -} - -func normalizeEquipableResourceTypes(values []string) ([]string, error) { - seen := make(map[string]struct{}, len(values)) - out := make([]string, 0, len(values)) - for _, value := range values { - resourceType := resourcedomain.NormalizeResourceType(value) - if resourceType == "" { - // 空类型不限制查询范围,保持 batch API 可以表达“查全部可佩戴资源”。 - continue - } - if !resourcedomain.ValidResourceType(resourceType) || !isEquipableResourceType(resourceType) { - return nil, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") - } - if _, exists := seen[resourceType]; exists { - // 去重后拼 IN (...),避免无意义扩大 SQL 参数和破坏调用方顺序稳定性。 - continue - } - seen[resourceType] = struct{}{} - out = append(out, resourceType) - } - return out, nil -} - -func allowsMultipleEquippedResources(resourceType string) bool { - return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge -} - -func isAutoEquippedOnGrantResourceType(resourceType string) bool { - switch resourcedomain.NormalizeResourceType(resourceType) { - case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: - return true - default: - return false - } -} - -func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error { - userIDs = compactPositiveInt64s(userIDs) - if r == nil || r.db == nil || len(userIDs) == 0 { - return nil - } - // 清理函数和查询函数复用同一套类型校验,避免查询拒绝的类型被清理 SQL 接受。 - resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes) - if err != nil { - return err - } - args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) - args = append(args, nowMs, nowMs) - typeFilter := "" - if len(resourceTypes) > 0 { - typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` - args = append(args, stringAnyArgs(resourceTypes)...) - } - // 删除条件覆盖三类 stale 指针: - // 1. entitlement/resource 被删或不可用; - // 2. entitlement 未生效、过期、剩余次数耗尽; - // 3. resource 被下架。查询端随后只会看到仍可展示的佩戴。 - _, err = r.db.ExecContext(ctx, ` - DELETE eq - FROM user_resource_equipment eq - LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code - AND e.user_id = eq.user_id - AND e.resource_id = eq.resource_id - AND e.entitlement_id = eq.entitlement_id - LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id - WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) - AND ( - e.entitlement_id IS NULL - OR e.status <> 'active' - OR e.effective_at_ms > ? - OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?) - OR e.remaining_quantity <= 0 - OR r.resource_id IS NULL - OR r.status <> 'active' - )`+typeFilter, - args..., - ) - return err -} - -func mapResourceWriteError(err error) error { - if isMySQLDuplicateError(err) { - return xerr.New(xerr.Conflict, "resource_code already exists") - } - return err -} - -func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand { - command.ResourceCode = strings.TrimSpace(command.ResourceCode) - command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) - command.Name = strings.TrimSpace(command.Name) - command.Status = resourcedomain.NormalizeStatus(command.Status) - command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy) - command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType) - command.PriceType = resourcedomain.NormalizePriceType(command.PriceType) - if command.PriceType == resourcedomain.PriceTypeFree { - command.CoinPrice = 0 - command.GiftPointAmount = 0 - } else if command.PriceType == resourcedomain.PriceTypeCoin { - // 资源价格只保留真实 COIN 价格;gift_point_amount 是历史字段,写入 0 兼容旧表结构。 - command.GiftPointAmount = 0 - } - command.AssetURL = strings.TrimSpace(command.AssetURL) - command.PreviewURL = strings.TrimSpace(command.PreviewURL) - command.AnimationURL = strings.TrimSpace(command.AnimationURL) - command.MetadataJSON = normalizeJSONObject(command.MetadataJSON) - command.UsageScopes = normalizeStringList(command.UsageScopes) - return command -} - -func validateResourceCommand(command resourcedomain.ResourceCommand) error { - if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 { - return xerr.New(xerr.InvalidArgument, "resource command is incomplete") - } - if !resourcedomain.ValidResourceType(command.ResourceType) { - return xerr.New(xerr.InvalidArgument, "resource_type is invalid") - } - if !resourcedomain.ValidStatus(command.Status) { - return xerr.New(xerr.InvalidArgument, "status is invalid") - } - if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) { - return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid") - } - if command.ResourceType == resourcedomain.TypeCoin { - if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 { - return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN") - } - } else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 { - return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit") - } - if command.PriceType == "" { - if command.CoinPrice != 0 || command.GiftPointAmount != 0 { - return xerr.New(xerr.InvalidArgument, "resource price type is required") - } - return nil - } - if !resourcedomain.ValidPriceType(command.PriceType) { - return xerr.New(xerr.InvalidArgument, "resource price type is invalid") - } - if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 { - return xerr.New(xerr.InvalidArgument, "resource coin price is invalid") - } - if command.CoinPrice < 0 { - return xerr.New(xerr.InvalidArgument, "resource price is invalid") - } - return nil -} - -func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand { - for index := range command.Items { - command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status) - } - return command -} - -func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error { - if len(command.Items) == 0 || command.OperatorUserID < 0 { - return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete") - } - for _, item := range command.Items { - if item.ResourceID <= 0 { - return xerr.New(xerr.InvalidArgument, "resource_id is required") - } - if !resourcedomain.ValidStatus(item.Status) { - return xerr.New(xerr.InvalidArgument, "status is invalid") - } - if !resourcedomain.ValidShopDurationDays(item.DurationDays) { - return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid") - } - if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) { - return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid") - } - } - return nil -} - -func validateResourceShopResource(resource resourcedomain.Resource) error { - if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) { - return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop") - } - if err := validateGrantableResource(resource); err != nil { - return err - } - if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 { - return xerr.New(xerr.InvalidArgument, "resource coin price is required") - } - return nil -} - -func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error { - if item.Status != resourcedomain.StatusActive { - return xerr.New(xerr.Conflict, "resource shop item is disabled") - } - if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs { - return xerr.New(xerr.Conflict, "resource shop item is not effective") - } - if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs { - return xerr.New(xerr.Conflict, "resource shop item is expired") - } - if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 { - return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid") - } - return validateResourceShopResource(item.Resource) -} - -func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand { - command.GroupCode = strings.TrimSpace(command.GroupCode) - command.Name = strings.TrimSpace(command.Name) - command.Status = resourcedomain.NormalizeStatus(command.Status) - command.Description = strings.TrimSpace(command.Description) - for index := range command.Items { - command.Items[index] = normalizeResourceGroupItemInput(command.Items[index]) - } - return command -} - -func validateResourceGroupCommand(command resourcedomain.ResourceGroupCommand, requireItems bool) error { - if command.GroupCode == "" || command.Name == "" { - return xerr.New(xerr.InvalidArgument, "resource group command is incomplete") - } - if !resourcedomain.ValidStatus(command.Status) { - return xerr.New(xerr.InvalidArgument, "status is invalid") - } - if requireItems && len(command.Items) == 0 { - return xerr.New(xerr.InvalidArgument, "resource group has no items") - } - seenResources := make(map[int64]struct{}, len(command.Items)) - seenWalletAssets := make(map[string]struct{}, len(command.Items)) - for _, item := range command.Items { - if !resourcedomain.ValidGroupItemType(item.ItemType) { - return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") - } - switch item.ItemType { - case resourcedomain.GroupItemTypeResource: - if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 { - return xerr.New(xerr.InvalidArgument, "resource group item is invalid") - } - if _, ok := seenResources[item.ResourceID]; ok { - return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") - } - seenResources[item.ResourceID] = struct{}{} - case resourcedomain.GroupItemTypeWalletAsset: - if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { - return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") - } - if _, ok := seenWalletAssets[item.WalletAssetType]; ok { - return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") - } - seenWalletAssets[item.WalletAssetType] = struct{}{} - } - } - return nil -} - -func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) resourcedomain.ResourceGroupItemInput { - item.ItemType = resourcedomain.NormalizeGroupItemType(item.ItemType) - item.WalletAssetType = resourcedomain.NormalizeWalletAssetType(item.WalletAssetType) - if item.ItemType == resourcedomain.GroupItemTypeWalletAsset { - item.ResourceID = 0 - item.Quantity = 1 - item.DurationMS = 0 - return item - } - item.ItemType = resourcedomain.GroupItemTypeResource - item.WalletAssetType = "" - item.WalletAssetAmount = 0 - return item -} - -func validGroupWalletAssetType(assetType string) bool { - switch resourcedomain.NormalizeWalletAssetType(assetType) { - case ledger.AssetCoin: - return true - default: - return false - } -} - -func applyResourcePricingToGiftCommand(command resourcedomain.GiftConfigCommand, resource resourcedomain.Resource) resourcedomain.GiftConfigCommand { - switch resourcedomain.NormalizePriceType(resource.PriceType) { - case resourcedomain.PriceTypeCoin: - command.ChargeAssetType = ledger.AssetCoin - command.CoinPrice = resource.CoinPrice - command.GiftPointAmount = 0 - case resourcedomain.PriceTypeFree: - command.ChargeAssetType = ledger.AssetCoin - command.CoinPrice = 0 - command.GiftPointAmount = 0 - } - // 后台已取消热度配置;旧列只保留兼容写入,数值始终跟真实 COIN 价格一致。 - command.HeatValue = command.CoinPrice - return command -} - -func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand { - command.GiftID = strings.TrimSpace(command.GiftID) - command.Status = resourcedomain.NormalizeStatus(command.Status) - command.Name = strings.TrimSpace(command.Name) - command.PresentationJSON = normalizeJSONObject(command.PresentationJSON) - command.CPRelationType = normalizeCPRelationType(command.CPRelationType) - command.PriceVersion = strings.TrimSpace(command.PriceVersion) - command.RegionIDs = normalizeRegionIDs(command.RegionIDs) - command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode) - command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType)) - if command.ChargeAssetType == "" { - command.ChargeAssetType = ledger.AssetCoin - } - command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes) - // gift_point_amount 是历史字段,礼物配置保存时固定归零,送礼结算只读取真实 COIN 价格。 - command.GiftPointAmount = 0 - if command.EffectiveAtMS < 0 { - command.EffectiveAtMS = 0 - } - if command.EffectiveFromMS < 0 { - command.EffectiveFromMS = 0 - } - if command.EffectiveToMS < 0 { - command.EffectiveToMS = 0 - } - return command -} - -func normalizeGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) resourcedomain.GiftTypeConfigCommand { - command.TypeCode = resourcedomain.NormalizeGiftTypeCode(command.TypeCode) - command.Name = strings.TrimSpace(command.Name) - command.TabKey = resourcedomain.NormalizeGiftTypeTabKey(command.TabKey) - command.Status = resourcedomain.NormalizeStatus(command.Status) - return command -} - -func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error { - if command.GiftID == "" || command.ResourceID <= 0 || command.Name == "" || command.PriceVersion == "" { - return xerr.New(xerr.InvalidArgument, "gift config command is incomplete") - } - if !resourcedomain.ValidStatus(command.Status) { - return xerr.New(xerr.InvalidArgument, "status is invalid") - } - if command.CoinPrice < 0 || command.HeatValue < 0 { - return xerr.New(xerr.InvalidArgument, "gift price is invalid") - } - if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) { - return xerr.New(xerr.InvalidArgument, "gift type is invalid") - } - if command.CPRelationType != "" && command.GiftTypeCode != resourcedomain.GiftTypeCP { - return xerr.New(xerr.InvalidArgument, "cp relation type requires cp gift type") - } - if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) { - return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid") - } - if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS { - return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid") - } - for _, effectType := range command.EffectTypes { - if !resourcedomain.ValidGiftEffectType(effectType) { - return xerr.New(xerr.InvalidArgument, "gift effect type is invalid") - } - } - if len(command.RegionIDs) == 0 { - return xerr.New(xerr.InvalidArgument, "gift regions are required") - } - for _, regionID := range command.RegionIDs { - if regionID < 0 { - return xerr.New(xerr.InvalidArgument, "gift region is invalid") - } - } - return nil -} - -func validateGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) error { - if !resourcedomain.ValidGiftTypeCode(command.TypeCode) || command.Name == "" || !resourcedomain.ValidGiftTypeTabKey(command.TabKey) { - return xerr.New(xerr.InvalidArgument, "gift type config is incomplete") - } - if !resourcedomain.ValidStatus(command.Status) { - return xerr.New(xerr.InvalidArgument, "status is invalid") - } - return nil -} - -func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand { - command.CommandID = strings.TrimSpace(command.CommandID) - command.Reason = strings.TrimSpace(command.Reason) - command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) - if command.Quantity == 0 { - command.Quantity = 1 - } - return command -} - -func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) error { - if command.CommandID == "" || command.TargetUserID <= 0 || command.ResourceID <= 0 || command.Quantity <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { - return xerr.New(xerr.InvalidArgument, "resource grant command is incomplete") - } - if command.DurationMS < 0 { - return xerr.New(xerr.InvalidArgument, "duration_ms is invalid") - } - return nil -} - -func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand { - command.CommandID = strings.TrimSpace(command.CommandID) - command.Reason = strings.TrimSpace(command.Reason) - command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) - return command -} - -func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) error { - if command.CommandID == "" || command.TargetUserID <= 0 || command.GroupID <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { - return xerr.New(xerr.InvalidArgument, "resource group grant command is incomplete") - } - if command.GrantSource == resourcedomain.GrantSourceManagerCenter { - return xerr.New(xerr.InvalidArgument, "manager center cannot grant resource group") - } - return nil -} - -func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error { - // 机器人初始化只从后台维护的 active 装扮池里挑展示资源;它不是人工管理端发放, - // 因此不继承 grantable 和 manager_grant_enabled 这两个运营开关,避免测试/运营库 - // 已上架资源因为未显式打开管理端发放而无法生成机器人。 - if grantSource == resourcedomain.GrantSourceGameRobotInit { - if resource.Status != resourcedomain.StatusActive { - return xerr.New(xerr.Conflict, "resource is disabled") - } - if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) { - return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin") - } - return nil - } - if err := validateGrantableResource(resource); err != nil { - return err - } - if grantSource == resourcedomain.GrantSourceManagerCenter && !resource.ManagerGrantEnabled { - return xerr.New(xerr.PermissionDenied, "resource is not enabled for manager center grant") - } - return nil -} - -func validateGrantableResource(resource resourcedomain.Resource) error { - if resource.Status != resourcedomain.StatusActive { - return xerr.New(xerr.Conflict, "resource is disabled") - } - if !resource.Grantable { - return xerr.New(xerr.Conflict, "resource is not grantable") - } - if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) { - return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin") - } - return nil -} - -func validateActiveResourceGroupItems(items []resourcedomain.ResourceGroupItem) error { - if len(items) == 0 { - return xerr.New(xerr.InvalidArgument, "resource group has no items") - } - for _, item := range items { - if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource { - continue - } - if err := validateActiveResourceGroupResource(item.Resource); err != nil { - return err - } - } - return nil -} - -func validateActiveResourceGroupResource(resource resourcedomain.Resource) error { - if err := validateGrantableResource(resource); err != nil { - return err - } - if resourcedomain.IsCoinResource(resource) { - return xerr.New(xerr.InvalidArgument, "coin resource must use wallet asset item") - } - return nil -} - -func isEquipableResourceType(resourceType string) bool { - switch resourcedomain.NormalizeResourceType(resourceType) { - case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: - return true - default: - return false - } -} - -func resourceJSONPayload(scopes []string, metadata string) (string, string, error) { - scopes = normalizeStringList(scopes) - scopeJSON, err := json.Marshal(scopes) - if err != nil { - return "", "", err - } - return string(scopeJSON), normalizeJSONObject(metadata), nil -} - -func normalizeStringList(values []string) []string { - seen := make(map[string]struct{}, len(values)) - out := make([]string, 0, len(values)) - for _, value := range values { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - return out -} - -func normalizeRegionIDs(values []int64) []int64 { - if len(values) == 0 { - return []int64{0} - } - seen := make(map[int64]struct{}, len(values)) - out := make([]int64, 0, len(values)) - for _, value := range values { - if value < 0 { - out = append(out, value) - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - slices.Sort(out) - return out -} - -func normalizeGiftEffectTypes(values []string) []string { - seen := make(map[string]struct{}, len(values)) - out := make([]string, 0, len(values)) - for _, value := range values { - value = resourcedomain.NormalizeGiftEffectType(value) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - out = append(out, value) - } - sort.Strings(out) - return out -} - -func parseStringArray(value string) []string { - var out []string - if err := json.Unmarshal([]byte(value), &out); err != nil { - return nil - } - return normalizeStringList(out) -} - -func normalizeJSONObject(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "{}" - } - var raw any - if err := json.Unmarshal([]byte(value), &raw); err != nil { - return "{}" - } - if _, ok := raw.(map[string]any); !ok { - return "{}" - } - return value -} - -func resourceFromSnapshotJSON(snapshotJSON string, fallback resourcedomain.Resource) resourcedomain.Resource { - snapshotJSON = strings.TrimSpace(snapshotJSON) - if snapshotJSON == "" || snapshotJSON == "{}" { - return fallback - } - var snapshot resourcedomain.Resource - if err := json.Unmarshal([]byte(snapshotJSON), &snapshot); err != nil || snapshot.ResourceID <= 0 { - return fallback - } - if snapshot.AppCode == "" { - snapshot.AppCode = fallback.AppCode - } - if snapshot.ResourceCode == "" { - snapshot.ResourceCode = fallback.ResourceCode - } - if snapshot.ResourceType == "" { - snapshot.ResourceType = fallback.ResourceType - } - return snapshot -} - -func cpRelationTypeFromPresentationJSON(value string) string { - var payload map[string]any - if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil { - return "" - } - if raw, ok := payload["cp_relation_type"].(string); ok { - return normalizeCPRelationType(raw) - } - return "" -} - -func giftPresentationWithCPRelationType(value string, relationType string) string { - relationType = normalizeCPRelationType(relationType) - var payload map[string]any - if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil || payload == nil { - payload = map[string]any{} - } - // 空值表示清除 CP 关系类型;非 CP 礼物保存时不能残留旧的 cp_relation_type。 - if relationType == "" { - delete(payload, "cp_relation_type") - } else { - payload["cp_relation_type"] = relationType - } - encoded, err := json.Marshal(payload) - if err != nil { - return "{}" - } - return string(encoded) -} - -func normalizeCPRelationType(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "cp": - return "cp" - case "brother": - return "brother" - case "sister": - return "sister" - default: - return "" - } -} - -func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent { - eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID) - return walletOutboxEvent{ - EventID: "wev_" + stableHash(eventKey), - EventType: eventType, - TransactionID: "", - CommandID: commandID, - UserID: userID, - AssetType: resourceOutboxAsset, - Payload: payload, - CreatedAtMS: nowMs, - } -} - -func (r *Repository) insertResourceOutboxNoTx(ctx context.Context, eventType string, commandID string, userID int64, resourceID int64, payload any) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - nowMs := time.Now().UnixMilli() - if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{resourceOutboxEvent(eventType, commandID, userID, resourceID, payload, nowMs)}); err != nil { - return err - } - return tx.Commit() -} - -func grantResourceRequestHash(command resourcedomain.GrantResourceCommand) string { - return stableHash(fmt.Sprintf("resource_grant|%s|%d|%d|%d|%d|%s|%d|%s", - appcode.Normalize(command.AppCode), command.TargetUserID, command.ResourceID, command.Quantity, command.DurationMS, - command.Reason, command.OperatorUserID, command.GrantSource, - )) -} - -func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupCommand) string { - return stableHash(fmt.Sprintf("resource_group_grant|%s|%d|%d|%s|%d|%s", - appcode.Normalize(command.AppCode), command.TargetUserID, command.GroupID, - command.Reason, command.OperatorUserID, command.GrantSource, - )) -} - -func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string { - return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d", - appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID, - )) -} - -func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string { - return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity)) -} - -func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType string, amount int64) string { - return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount)) -} - -func resourceGrantID(appCode string, commandID string) string { - return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) -} - -func entitlementID(appCode string, grantID string, resourceID int64, nowMs int64) string { - return "ent_" + stableHash(fmt.Sprintf("%s|%s|%d|%d", appcode.Normalize(appCode), grantID, resourceID, nowMs)) -} diff --git a/services/wallet-service/internal/storage/mysql/resource_shop_repository.go b/services/wallet-service/internal/storage/mysql/resource_shop_repository.go new file mode 100644 index 00000000..42706a65 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/resource_shop_repository.go @@ -0,0 +1,694 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。 +func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeResourceShopItemsQuery(query) + where, args := resourceShopItemsWhereSQL(query) + var total int64 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM resource_shop_items si + JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id + `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+` + ORDER BY si.sort_order ASC, si.shop_item_id DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize) + for rows.Next() { + item, err := scanResourceShopItem(rows) + if err != nil { + return nil, 0, err + } + items = append(items, item) + } + return items, total, rows.Err() +} + +// ListResourceShopPurchaseOrders 返回后台售卖记录;订单事实取购买表,素材展示优先使用发放时的资源快照。 +func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query = normalizeResourceShopPurchaseOrdersQuery(query) + where, args := resourceShopPurchaseOrdersWhereSQL(query) + + var total int64 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM resource_shop_purchase_orders po + JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id + `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, resourceShopPurchaseOrderSelectSQL()+where+` + ORDER BY po.created_at_ms DESC, po.order_id DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]resourcedomain.ResourceShopPurchaseOrder, 0, query.PageSize) + for rows.Next() { + item, err := scanResourceShopPurchaseOrder(rows) + if err != nil { + return nil, 0, err + } + items = append(items, item) + } + return items, total, rows.Err() +} + +// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。 +func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command = normalizeResourceShopItemsCommand(command) + if err := validateResourceShopItemsCommand(command); err != nil { + return nil, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + resourceIDs := make([]int64, 0, len(command.Items)) + seen := make(map[int64]struct{}, len(command.Items)) + for _, item := range command.Items { + if _, exists := seen[item.ResourceID]; exists { + return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource") + } + seen[item.ResourceID] = struct{}{} + resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) + if err != nil { + return nil, err + } + if err := validateResourceShopResource(resource); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO resource_shop_items ( + app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order, + created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + duration_days = VALUES(duration_days), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + sort_order = VALUES(sort_order), + updated_by_user_id = VALUES(updated_by_user_id), + updated_at_ms = VALUES(updated_at_ms)`, + command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder, + command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, + ); err != nil { + return nil, err + } + resourceIDs = append(resourceIDs, item.ResourceID) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs) +} + +func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.Status = resourcedomain.NormalizeStatus(command.Status) + if command.ShopItemID <= 0 { + return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required") + } + if !resourcedomain.ValidStatus(command.Status) { + return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceShopItem{}, err + } + defer func() { _ = tx.Rollback() }() + + item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) + if err != nil { + return resourcedomain.ResourceShopItem{}, err + } + if command.Status == resourcedomain.StatusActive { + if err := validateResourceShopResource(item.Resource); err != nil { + return resourcedomain.ResourceShopItem{}, err + } + } + nowMs := time.Now().UnixMilli() + result, err := tx.ExecContext(ctx, ` + UPDATE resource_shop_items + SET status = ?, updated_by_user_id = ?, updated_at_ms = ? + WHERE app_code = ? AND shop_item_id = ?`, + command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID, + ) + if err != nil { + return resourcedomain.ResourceShopItem{}, err + } + if affected, err := result.RowsAffected(); err != nil { + return resourcedomain.ResourceShopItem{}, err + } else if affected == 0 { + return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceShopItem{}, err + } + return r.getResourceShopItem(ctx, command.ShopItemID) +} + +// PurchaseResourceShopItem 是 App 道具商店的唯一购买入口;扣金币、写订单和发资源权益必须共享同一事务。 +func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) { + if r == nil || r.db == nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.CommandID = strings.TrimSpace(command.CommandID) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := resourceShopPurchaseRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists { + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) + } + + nowMs := time.Now().UnixMilli() + item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := validatePurchasableResourceShopItem(item, nowMs); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if account.AvailableAmount < item.CoinPrice { + return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + + transactionID := transactionID(command.AppCode, command.CommandID) + orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID) + grantID := resourceGrantID(command.AppCode, command.CommandID) + durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond) + coinBalanceAfter := account.AvailableAmount - item.CoinPrice + metadata := map[string]any{ + "app_code": command.AppCode, + "order_id": orderID, + "user_id": command.UserID, + "shop_item_id": item.ShopItemID, + "resource_id": item.ResourceID, + "resource_code": item.Resource.ResourceCode, + "resource_type": item.Resource.ResourceType, + "duration_days": item.DurationDays, + "duration_ms": durationMS, + "price_type": item.PriceType, + "coin_spent": item.CoinPrice, + "balance_after": coinBalanceAfter, + "resource_grant_id": grantID, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -item.CoinPrice, + FrozenDelta: 0, + AvailableAfter: coinBalanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if grantItem.EntitlementID == "" { + + return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement") + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO resource_shop_purchase_orders ( + app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin, + status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`, + command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice, + transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs, + ); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs), + { + EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset), + EventType: "ResourceShopItemPurchased", + TransactionID: transactionID, + CommandID: command.CommandID, + UserID: command.UserID, + AssetType: resourceOutboxAsset, + Payload: metadata, + CreatedAtMS: nowMs, + }, + }); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if err := tx.Commit(); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + + return resourcedomain.ResourceShopPurchaseReceipt{ + OrderID: orderID, + TransactionID: transactionID, + ShopItem: item, + Resource: resource, + Balance: ledger.AssetBalance{ + AppCode: command.AppCode, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableAmount: coinBalanceAfter, + FrozenAmount: account.FrozenAmount, + Version: account.Version + 1, + }, + CoinSpent: item.CoinPrice, + GrantID: grantID, + }, nil +} + +func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) { + return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false) +} + +func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { + return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock) +} + +func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { + query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?` + if lock { + query += ` FOR UPDATE` + } + row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID) + item, err := scanResourceShopItem(row) + if errors.Is(err, sql.ErrNoRows) { + return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") + } + return item, err +} + +func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) { + var orderID string + var shopItemID int64 + var priceCoin int64 + var grantID string + var entitlementID string + err := tx.QueryRowContext(ctx, ` + SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id + FROM resource_shop_purchase_orders + WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`, + appcode.FromContext(ctx), transactionID, userID, + ).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } else if exists { + balance.Version = account.Version + } + return resourcedomain.ResourceShopPurchaseReceipt{ + OrderID: orderID, + TransactionID: transactionID, + ShopItem: item, + Resource: resource, + Balance: balance, + CoinSpent: priceCoin, + GrantID: grantID, + }, nil +} + +func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) { + resourceIDs = compactPositiveInt64s(resourceIDs) + if len(resourceIDs) == 0 { + return []resourcedomain.ResourceShopItem{}, nil + } + args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...) + rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+` + WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`) + ORDER BY si.sort_order ASC, si.shop_item_id DESC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs)) + for rows.Next() { + item, err := scanResourceShopItem(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) { + var item resourcedomain.ResourceShopItem + var resource resourcedomain.Resource + var scopesJSON string + if err := scanner.Scan( + &item.AppCode, + &item.ShopItemID, + &item.ResourceID, + &item.Status, + &item.DurationDays, + &item.PriceType, + &item.CoinPrice, + &item.EffectiveFromMS, + &item.EffectiveToMS, + &item.SortOrder, + &item.CreatedByUserID, + &item.UpdatedByUserID, + &item.CreatedAtMS, + &item.UpdatedAtMS, + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + ); err != nil { + return resourcedomain.ResourceShopItem{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + item.Resource = resource + return item, nil +} + +func scanResourceShopPurchaseOrder(scanner scanTarget) (resourcedomain.ResourceShopPurchaseOrder, error) { + var item resourcedomain.ResourceShopPurchaseOrder + var resource resourcedomain.Resource + var scopesJSON string + if err := scanner.Scan( + &item.AppCode, + &item.OrderID, + &item.CommandID, + &item.UserID, + &item.ShopItemID, + &item.ResourceID, + &item.DurationDays, + &item.PriceCoin, + &item.Status, + &item.WalletTransactionID, + &item.ResourceGrantID, + &item.EntitlementID, + &item.ResourceSnapshotJSON, + &item.CreatedAtMS, + &item.UpdatedAtMS, + &resource.AppCode, + &resource.ResourceID, + &resource.ResourceCode, + &resource.ResourceType, + &resource.Name, + &resource.Status, + &resource.Grantable, + &resource.ManagerGrantEnabled, + &resource.GrantStrategy, + &resource.WalletAssetType, + &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, + &scopesJSON, + &resource.AssetURL, + &resource.PreviewURL, + &resource.AnimationURL, + &resource.MetadataJSON, + &resource.SortOrder, + &resource.CreatedByUserID, + &resource.UpdatedByUserID, + &resource.CreatedAtMS, + &resource.UpdatedAtMS, + ); err != nil { + return resourcedomain.ResourceShopPurchaseOrder{}, err + } + resource.UsageScopes = parseStringArray(scopesJSON) + item.Resource = resourceFromSnapshotJSON(item.ResourceSnapshotJSON, resource) + return item, nil +} + +func resourceShopItemSelectSQL() string { + return ` + SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days, + r.price_type, + CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price, + si.effective_from_ms, si.effective_to_ms, si.sort_order, + si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms, + ` + resourceColumnsWithAlias("r") + ` + FROM resource_shop_items si + JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id + ` +} + +func resourceShopPurchaseOrderSelectSQL() string { + return ` + SELECT po.app_code, po.order_id, po.command_id, po.user_id, po.shop_item_id, po.resource_id, + po.duration_days, po.price_coin, po.status, po.wallet_transaction_id, po.resource_grant_id, + po.entitlement_id, COALESCE(CAST(gi.resource_snapshot_json AS CHAR), ''), po.created_at_ms, po.updated_at_ms, + ` + resourceColumnsWithAlias("r") + ` + FROM resource_shop_purchase_orders po + JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id + LEFT JOIN resource_grant_items gi ON gi.app_code = po.app_code + AND gi.grant_id = po.resource_grant_id + AND gi.resource_id = po.resource_id + ` +} + +func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) { + where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')` + args := []any{query.AppCode} + if query.ActiveOnly { + nowMs := time.Now().UnixMilli() + where += ` AND si.status = 'active' AND r.status = 'active' + AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?) + AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)` + args = append(args, nowMs, nowMs) + } else if query.Status != "" { + where += ` AND si.status = ?` + args = append(args, query.Status) + } + if query.ResourceType != "" { + where += ` AND r.resource_type = ?` + args = append(args, query.ResourceType) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)` + args = append(args, like, like) + } + return where, args +} + +func resourceShopPurchaseOrdersWhereSQL(query resourcedomain.ListResourceShopPurchaseOrdersQuery) (string, []any) { + where := `WHERE po.app_code = ?` + args := []any{query.AppCode} + if query.UserID > 0 { + where += ` AND po.user_id = ?` + args = append(args, query.UserID) + } + if query.Status != "" { + where += ` AND po.status = ?` + args = append(args, query.Status) + } + if query.ResourceType != "" { + where += ` AND r.resource_type = ?` + args = append(args, query.ResourceType) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where += ` AND (po.order_id LIKE ? OR po.command_id LIKE ? OR r.resource_code LIKE ? OR r.name LIKE ?)` + args = append(args, like, like, like, like) + } + return where, args +} + +func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery { + query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) + if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) { + query.ResourceType = "__invalid__" + } + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Keyword = strings.TrimSpace(query.Keyword) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} + +func normalizeResourceShopPurchaseOrdersQuery(query resourcedomain.ListResourceShopPurchaseOrdersQuery) resourcedomain.ListResourceShopPurchaseOrdersQuery { + if query.UserID < 0 { + query.UserID = 0 + } + query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) + if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) { + query.ResourceType = "__invalid__" + } + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Keyword = strings.TrimSpace(query.Keyword) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + return query +} + +func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand { + for index := range command.Items { + command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status) + } + return command +} + +func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error { + if len(command.Items) == 0 || command.OperatorUserID < 0 { + return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete") + } + for _, item := range command.Items { + if item.ResourceID <= 0 { + return xerr.New(xerr.InvalidArgument, "resource_id is required") + } + if !resourcedomain.ValidStatus(item.Status) { + return xerr.New(xerr.InvalidArgument, "status is invalid") + } + if !resourcedomain.ValidShopDurationDays(item.DurationDays) { + return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid") + } + if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) { + return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid") + } + } + return nil +} + +func validateResourceShopResource(resource resourcedomain.Resource) error { + if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) { + return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop") + } + if err := validateGrantableResource(resource); err != nil { + return err + } + if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 { + return xerr.New(xerr.InvalidArgument, "resource coin price is required") + } + return nil +} + +func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error { + if item.Status != resourcedomain.StatusActive { + return xerr.New(xerr.Conflict, "resource shop item is disabled") + } + if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs { + return xerr.New(xerr.Conflict, "resource shop item is not effective") + } + if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs { + return xerr.New(xerr.Conflict, "resource shop item is expired") + } + if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 { + return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid") + } + return validateResourceShopResource(item.Resource) +} + +func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string { + return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d", + appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID, + )) +} diff --git a/services/wallet-service/internal/storage/mysql/reward_ledger.go b/services/wallet-service/internal/storage/mysql/reward_ledger.go new file mode 100644 index 00000000..181a00a6 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/reward_ledger.go @@ -0,0 +1,755 @@ +package mysql + +import ( + "context" + "fmt" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// DebitCPBreakupFee 在钱包账本中扣除解除关系费用;关系状态不在 wallet 中修改,避免 wallet 反向拥有用户关系事实。 +func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) { + if r == nil || r.db == nil { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := cpBreakupFeeRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCPBreakupFee); err != nil || exists { + if err != nil || !exists { + return ledger.CPBreakupFeeReceipt{}, err + } + balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin) + if balanceErr != nil { + return ledger.CPBreakupFeeReceipt{}, balanceErr + } + return ledger.CPBreakupFeeReceipt{ + TransactionID: txRow.TransactionID, + CoinSpent: command.Amount, + CoinBalanceAfter: balance.AvailableAmount, + Balance: balance, + }, nil + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) + if err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + if account.AvailableAmount < command.Amount { + return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount - command.Amount + metadata := cpBreakupFeeMetadata{ + AppCode: command.AppCode, + UserID: command.UserID, + RelationshipID: command.RelationshipID, + RelationType: command.RelationType, + Amount: command.Amount, + CoinBalanceAfter: balanceAfter, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCPBreakupFee, requestHash, command.RelationshipID, metadata, nowMs); err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + balance := ledger.AssetBalance{ + AppCode: command.AppCode, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableAmount: balanceAfter, + FrozenAmount: account.FrozenAmount, + Version: account.Version + 1, + UpdatedAtMs: nowMs, + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -command.Amount, + FrozenDelta: 0, + AvailableAfter: balance.AvailableAmount, + FrozenAfter: balance.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), + }); err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.CPBreakupFeeReceipt{}, err + } + return ledger.CPBreakupFeeReceipt{ + TransactionID: transactionID, + CoinSpent: command.Amount, + CoinBalanceAfter: balance.AvailableAmount, + Balance: balance, + }, nil +} + +// DebitWheelDraw 在钱包账本中扣除转盘抽奖金币;中奖和返奖由 activity-service 后续处理,钱包只保证付费事实可审计、可幂等。 +func (r *Repository) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) { + if r == nil || r.db == nil { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := wheelDrawDebitRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelDrawDebit); err != nil || exists { + if err != nil || !exists { + return ledger.WheelDrawDebitReceipt{}, err + } + balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin) + if balanceErr != nil { + return ledger.WheelDrawDebitReceipt{}, balanceErr + } + return ledger.WheelDrawDebitReceipt{ + TransactionID: txRow.TransactionID, + CoinSpent: command.Amount, + CoinBalanceAfter: balance.AvailableAmount, + Balance: balance, + }, nil + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) + if err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + if account.AvailableAmount < command.Amount { + return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount - command.Amount + metadata := wheelDrawDebitMetadata{ + AppCode: command.AppCode, + UserID: command.UserID, + WheelID: command.WheelID, + DrawCount: command.DrawCount, + Amount: command.Amount, + CoinBalanceAfter: balanceAfter, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelDrawDebit, requestHash, command.WheelID, metadata, nowMs); err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + balance := ledger.AssetBalance{ + AppCode: command.AppCode, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableAmount: balanceAfter, + FrozenAmount: account.FrozenAmount, + Version: account.Version + 1, + UpdatedAtMs: nowMs, + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -command.Amount, + FrozenDelta: 0, + AvailableAfter: balance.AvailableAmount, + FrozenAfter: balance.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), + }); err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.WheelDrawDebitReceipt{}, err + } + return ledger.WheelDrawDebitReceipt{ + TransactionID: transactionID, + CoinSpent: command.Amount, + CoinBalanceAfter: balance.AvailableAmount, + Balance: balance, + }, nil +} + +// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。 +func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.TaskRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := taskRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeTaskReward); err != nil || exists { + if err != nil || !exists { + return ledger.TaskRewardReceipt{}, err + } + return r.receiptForTaskRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.TaskRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + metadata := taskRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + TaskType: command.TaskType, + TaskID: command.TaskID, + CycleKey: command.CycleKey, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeTaskReward, requestHash, fmt.Sprintf("%s:%s", command.TaskID, command.CycleKey), metadata, nowMs); err != nil { + return ledger.TaskRewardReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.TaskRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.TaskRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.TaskRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.TaskRewardReceipt{}, err + } + + return receiptFromTaskRewardMetadata(transactionID, metadata), nil +} + +// CreditLuckyGiftReward 在同一事务里完成幸运礼物中奖 COIN 入账、交易分录和钱包 outbox。 +func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.LuckyGiftRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := luckyGiftRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeLuckyGiftReward); err != nil || exists { + if err != nil || !exists { + return ledger.LuckyGiftRewardReceipt{}, err + } + return r.receiptForLuckyGiftRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + metadata := luckyGiftRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + DrawID: command.DrawID, + RoomID: command.RoomID, + VisibleRegionID: command.VisibleRegionID, + CountryID: command.CountryID, + GiftID: command.GiftID, + PoolID: command.PoolID, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + luckyGiftRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.LuckyGiftRewardReceipt{}, err + } + + return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil +} + +// CreditWheelReward 在同一事务里完成转盘金币奖品入账、交易分录和钱包 outbox。 +func (r *Repository) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.WheelRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := wheelRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelReward); err != nil || exists { + if err != nil || !exists { + return ledger.WheelRewardReceipt{}, err + } + return r.receiptForWheelRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.WheelRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + metadata := wheelRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + DrawID: command.DrawID, + WheelID: command.WheelID, + SelectedTierID: command.SelectedTierID, + VisibleRegionID: command.VisibleRegionID, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelReward, requestHash, command.DrawID, metadata, nowMs); err != nil { + return ledger.WheelRewardReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.WheelRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.WheelRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + wheelRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.WheelRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.WheelRewardReceipt{}, err + } + + return receiptFromWheelRewardMetadata(transactionID, metadata), nil +} + +// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。 +func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.RoomTurnoverRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := roomTurnoverRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeRoomTurnoverReward); err != nil || exists { + if err != nil || !exists { + return ledger.RoomTurnoverRewardReceipt{}, err + } + + return r.receiptForRoomTurnoverRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + + metadata := roomTurnoverRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + SettlementID: command.SettlementID, + RoomID: command.RoomID, + PeriodStartMS: command.PeriodStartMS, + PeriodEndMS: command.PeriodEndMS, + CoinSpent: command.CoinSpent, + TierID: command.TierID, + TierCode: command.TierCode, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRoomTurnoverReward, requestHash, command.SettlementID, metadata, nowMs); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + RoomID: command.RoomID, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + roomTurnoverRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.RoomTurnoverRewardReceipt{}, err + } + + return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil +} + +// CreditInviteActivityReward 在同一事务内完成邀请活动 COIN 奖励入账、交易分录和钱包 outbox。 +func (r *Repository) CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.InviteActivityRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := inviteActivityRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeInviteActivityReward); err != nil || exists { + if err != nil || !exists { + return ledger.InviteActivityRewardReceipt{}, err + } + + return r.receiptForInviteActivityRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + metadata := inviteActivityRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + ClaimID: command.ClaimID, + RewardType: command.RewardType, + TierID: command.TierID, + TierCode: command.TierCode, + CycleKey: command.CycleKey, + ReachedValue: command.ReachedValue, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeInviteActivityReward, requestHash, command.ClaimID, metadata, nowMs); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + inviteActivityRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.InviteActivityRewardReceipt{}, err + } + + return receiptFromInviteActivityRewardMetadata(transactionID, metadata), nil +} + +// CreditAgencyOpeningReward 在同一事务内完成代理开业流水档位 COIN 奖励入账、交易分录和钱包 outbox。 +func (r *Repository) CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error) { + if r == nil || r.db == nil { + return ledger.AgencyOpeningRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := agencyOpeningRewardRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeAgencyOpeningReward); err != nil || exists { + if err != nil || !exists { + return ledger.AgencyOpeningRewardReceipt{}, err + } + return r.receiptForAgencyOpeningRewardTransaction(ctx, tx, txRow.TransactionID) + } + + nowMs := time.Now().UnixMilli() + account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) + if err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + balanceAfter := account.AvailableAmount + command.Amount + metadata := agencyOpeningRewardMetadata{ + AppCode: command.AppCode, + TargetUserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + Amount: command.Amount, + ApplicationID: command.ApplicationID, + CycleID: command.CycleID, + AgencyID: command.AgencyID, + RankNo: command.RankNo, + ScoreCoins: command.ScoreCoins, + Reason: command.Reason, + BalanceAfter: balanceAfter, + GrantedAtMS: nowMs, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeAgencyOpeningReward, requestHash, command.ApplicationID, metadata, nowMs); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.TargetUserID, + AssetType: ledger.AssetCoin, + AvailableDelta: command.Amount, + FrozenDelta: 0, + AvailableAfter: balanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + agencyOpeningRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), + }); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.AgencyOpeningRewardReceipt{}, err + } + + return receiptFromAgencyOpeningRewardMetadata(transactionID, metadata), nil +} + +func cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string { + + return stableHash(fmt.Sprintf("cp_breakup_fee|%s|%d|%s|%s|%d", + appcode.Normalize(command.AppCode), + command.UserID, + strings.TrimSpace(command.RelationshipID), + strings.ToLower(strings.TrimSpace(command.RelationType)), + command.Amount, + )) +} + +func wheelDrawDebitRequestHash(command ledger.WheelDrawDebitCommand) string { + + return stableHash(fmt.Sprintf("wheel_draw_debit|%s|%d|%s|%d|%d", + appcode.Normalize(command.AppCode), + command.UserID, + strings.TrimSpace(command.WheelID), + command.DrawCount, + command.Amount, + )) +} + +func taskRewardRequestHash(command ledger.TaskRewardCommand) string { + return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + command.TaskType, + command.TaskID, + command.CycleKey, + strings.TrimSpace(command.Reason), + )) +} + +func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string { + return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%d|%d|%s|%s|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + strings.TrimSpace(command.DrawID), + strings.TrimSpace(command.RoomID), + command.VisibleRegionID, + command.CountryID, + strings.TrimSpace(command.GiftID), + strings.TrimSpace(command.PoolID), + strings.TrimSpace(command.Reason), + )) +} + +func wheelRewardRequestHash(command ledger.WheelRewardCommand) string { + return stableHash(fmt.Sprintf("wheel_reward|%s|%d|%d|%s|%s|%s|%d|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + strings.TrimSpace(command.DrawID), + strings.TrimSpace(command.WheelID), + strings.TrimSpace(command.SelectedTierID), + command.VisibleRegionID, + strings.TrimSpace(command.Reason), + )) +} + +func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string { + return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + strings.TrimSpace(command.SettlementID), + strings.TrimSpace(command.RoomID), + command.PeriodStartMS, + command.PeriodEndMS, + command.CoinSpent, + command.TierID, + strings.TrimSpace(command.TierCode), + strings.TrimSpace(command.Reason), + )) +} + +func inviteActivityRewardRequestHash(command ledger.InviteActivityRewardCommand) string { + return stableHash(fmt.Sprintf("invite_activity_reward|%s|%d|%d|%s|%s|%d|%s|%s|%d|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + strings.TrimSpace(command.ClaimID), + strings.TrimSpace(command.RewardType), + command.TierID, + strings.TrimSpace(command.TierCode), + strings.TrimSpace(command.CycleKey), + command.ReachedValue, + strings.TrimSpace(command.Reason), + )) +} + +func agencyOpeningRewardRequestHash(command ledger.AgencyOpeningRewardCommand) string { + return stableHash(fmt.Sprintf("agency_opening_reward|%s|%d|%d|%s|%s|%d|%d|%d|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Amount, + strings.TrimSpace(command.ApplicationID), + strings.TrimSpace(command.CycleID), + command.AgencyID, + command.RankNo, + command.ScoreCoins, + strings.TrimSpace(command.Reason), + )) +} diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go new file mode 100644 index 00000000..163671a6 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -0,0 +1,271 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + mysqlDriver "github.com/go-sql-driver/mysql" +) + +func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) error { + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE resource_shop_purchase_orders + ADD INDEX idx_resource_shop_purchase_time (app_code, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { + return err + } + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE resource_shop_purchase_orders + ADD INDEX idx_resource_shop_purchase_resource_time (app_code, resource_id, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) { + return err + } + return nil +} + +func isDuplicateKeyNameError(err error) bool { + var mysqlErr *mysqlDriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061 +} + +func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE host_salary_settlement_records + ADD COLUMN trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '触发方式:automatic/manual' AFTER settlement_type`); err != nil && !isDuplicateColumnError(err) { + return err + } + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE host_salary_settlement_records + ADD COLUMN settlement_role VARCHAR(24) NOT NULL DEFAULT 'all' COMMENT '结算身份:all/host/agency' AFTER trigger_mode`); err != nil && !isDuplicateColumnError(err) { + return err + } + return nil +} + +func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, ` + ALTER TABLE wallet_recharge_products + ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型:normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS third_party_payment_channels ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', + provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, provider_code), + KEY idx_third_party_payment_channels_status (app_code, status, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道配置表'`); err != nil { + return err + } + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS third_party_payment_methods ( + method_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '支付方式 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码', + country_code VARCHAR(16) NOT NULL COMMENT 'MiFaPay countryCode,GLOBAL 表示全球方式', + country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家/地区名称', + currency_code VARCHAR(8) NOT NULL COMMENT '下单币种', + pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay', + pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType', + method_name VARCHAR(128) NOT NULL COMMENT '支付方式展示名', + logo_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付方式 logo', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', + usd_to_currency_rate DECIMAL(24,8) NOT NULL DEFAULT 0 COMMENT '1 USD 可兑换本币金额;0 表示未配置,不在 H5 展示', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (method_id), + UNIQUE KEY uk_third_party_payment_method (app_code, provider_code, country_code, pay_way, pay_type), + KEY idx_third_party_payment_methods_country (app_code, provider_code, country_code, status, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家和方式配置表'`); err != nil { + return err + } + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS external_recharge_orders ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + order_id VARCHAR(96) NOT NULL COMMENT '平台外部充值订单 ID', + command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID', + target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID', + target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID', + target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码', + audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型', + product_id BIGINT NOT NULL COMMENT '充值商品 ID', + product_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品编码快照', + product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品名称快照', + coin_amount BIGINT NOT NULL COMMENT '到账金币数量', + usd_minor_amount BIGINT NOT NULL COMMENT '美元最小单位金额', + provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道:mifapay/usdt_trc20', + payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID', + country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '支付国家码', + currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种', + provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方下单金额最小单位', + pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay', + pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType', + pay_url TEXT NULL COMMENT '三方收银台地址', + provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方订单号', + tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT-TRC20 交易哈希', + receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址', + status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/redirected/credited/failed', + failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因', + wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '入账钱包交易 ID', + provider_payload JSON NULL COMMENT '三方原始响应或链上交易快照', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, order_id), + UNIQUE KEY uk_external_recharge_command (app_code, command_id), + KEY idx_external_recharge_user_time (app_code, target_user_id, created_at_ms), + KEY idx_external_recharge_provider_order (app_code, provider_code, provider_order_id), + KEY idx_external_recharge_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms), + KEY idx_external_recharge_tx (app_code, provider_code, tx_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil { + return err + } + return seedThirdPartyPaymentDefaults(ctx, db) +} + +func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域,0 表示全局默认', + gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', + ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比', + coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', + updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, region_id, gift_type_code), + KEY idx_gift_diamond_ratio_region (app_code, region_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil { + return err + } + if added, err := ensureGiftReturnCoinRatioColumn(ctx, db); err != nil { + return err + } else if added { + + if err := seedGiftReturnCoinRatioDefaults(ctx, db); err != nil { + return err + } + } + _, err := db.ExecContext(ctx, ` + INSERT IGNORE INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES + ('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0), + ('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0), + ('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`) + return err +} + +func ensureGiftReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) { + + if _, err := db.ExecContext(ctx, ` + ALTER TABLE gift_diamond_ratio_configs + ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比' AFTER ratio_percent`); err != nil { + if isDuplicateColumnError(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func seedGiftReturnCoinRatioDefaults(ctx context.Context, db *sql.DB) error { + + _, err := db.ExecContext(ctx, ` + UPDATE gift_diamond_ratio_configs + SET coin_return_ratio_percent = CASE gift_type_code + WHEN 'lucky' THEN 10.00 + WHEN 'super_lucky' THEN 1.00 + ELSE 30.00 + END`) + return err +} + +func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + region_id BIGINT NOT NULL COMMENT '币商所属区域 ID', + tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID', + min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含', + max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含', + coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID', + updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (tier_id), + KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor), + KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`) + return err +} + +func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error { + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS user_resource_equipment ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL COMMENT '用户 ID', + resource_type VARCHAR(32) NOT NULL COMMENT '资源类型', + entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID', + resource_id BIGINT NOT NULL COMMENT '资源 ID', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, user_id, resource_type, entitlement_id), + KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'`); err != nil { + return err + } + + primaryColumns, err := tablePrimaryKeyColumns(ctx, db, "user_resource_equipment") + if err != nil { + return err + } + if strings.Join(primaryColumns, ",") == "app_code,user_id,resource_type" { + _, err = db.ExecContext(ctx, ` + ALTER TABLE user_resource_equipment + DROP PRIMARY KEY, + ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`) + return err + } + return nil +} + +func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION ASC`, tableName) + if err != nil { + return nil, err + } + defer rows.Close() + + columns := make([]string, 0, 4) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return nil, err + } + columns = append(columns, column) + } + return columns, rows.Err() +} diff --git a/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go b/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go deleted file mode 100644 index 71fbc7e7..00000000 --- a/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go +++ /dev/null @@ -1,941 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - - mysqlDriver "github.com/go-sql-driver/mysql" -) - -func isDuplicateColumnError(err error) bool { - var mysqlErr *mysqlDriver.MySQLError - return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 -} - -type seedPaymentMethod struct { - CountryCode string - CountryName string - CurrencyCode string - PayWay string - PayType string - MethodName string - Rate string - SortOrder int -} - -// defaultMifaPayLogoURL 按 MiFaPay 的国家、payWay、payType 精确回填 COS logo;key 全部归一化,兼容存量库里的大小写差异。 -func defaultMifaPayLogoURL(countryCode string, payWay string, payType string) string { - logos := map[string]string{ - "AE|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png", - "BD|ewallet|nagad": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png", - "BD|ewallet|bkash": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png", - "BH|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png", - "EG|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png", - "EG|ewallet|fawrypay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png", - "EG|ewallet|meeza": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png", - "EG|ewallet|opay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png", - "EG|ewallet|orange": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png", - "EG|ewallet|vodafone": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png", - "GLOBAL|banktransfer|unionpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png", - "GLOBAL|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png", - "GLOBAL|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png", - "GLOBAL|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png", - "ID|banktransfer|bca": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png", - "ID|banktransfer|bni": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png", - "ID|banktransfer|bri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png", - "ID|banktransfer|mandiri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png", - "ID|banktransfer|permata": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png", - "ID|banktransfer|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png", - "ID|ewallet|dana": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png", - "ID|ewallet|linkaja": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png", - "ID|ewallet|ovo": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png", - "ID|ewallet|shopeepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png", - "ID|qr|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png", - "IN|ewallet|bhim": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png", - "IN|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png", - "IN|ewallet|mobikwik": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png", - "IN|ewallet|paytm": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png", - "IN|ewallet|phonepe": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png", - "IN|ewallet|upi": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png", - "KW|card|kent": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png", - "KW|card|knet": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png", - "MY|banktransfer|fpx": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png", - "MY|ewallet|tng": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png", - "OM|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png", - "PH|banktransfer|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png", - "PH|ewallet|gcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png", - "PH|ewallet|paymaya": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png", - "PH|qr|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png", - "PK|ewallet|easypaisa": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png", - "PK|ewallet|jazzcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png", - "QA|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png", - "SA|card|mada": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png", - "SA|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png", - "SA|ewallet|stcpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png", - "TH|banktransfer|promptpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png", - "TH|ewallet|truemoney": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png", - "VN|banktransfer|vn_banktransfer": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png", - "VN|qr|vietqr": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png", - } - key := strings.ToUpper(strings.TrimSpace(countryCode)) + "|" + strings.ToLower(strings.TrimSpace(payWay)) + "|" + strings.ToLower(strings.TrimSpace(payType)) - return logos[key] -} - -func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error { - nowMS := time.Now().UnixMilli() - if _, err := db.ExecContext(ctx, ` - INSERT INTO third_party_payment_channels ( - app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms - ) VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, ?, ?) - ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil { - return err - } - if _, err := db.ExecContext(ctx, ` - INSERT INTO third_party_payment_channels ( - app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms - ) VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, ?, ?) - ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil { - return err - } - // MiFaPay 是 H5 的统一三方收银台入口:后台只管理 MiFaPay 自己支持的国家和支付产品,不再关心任何下游通道名称。 - // 这里只 seed 可运营的 payWay/payType 清单;商户号、商户标识、商户私钥、平台公钥都属于运行环境密钥,不进入数据库 seed。 - methods := []seedPaymentMethod{ - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BCA", MethodName: "BCA Bank", Rate: "1.00000000", SortOrder: 110}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BNI", MethodName: "BNI Bank", Rate: "1.00000000", SortOrder: 120}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BRI", MethodName: "BRI Bank", Rate: "1.00000000", SortOrder: 130}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "Mandiri", MethodName: "Mandiri Bank", Rate: "1.00000000", SortOrder: 140}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "Permata", MethodName: "Permata Bank", Rate: "1.00000000", SortOrder: 150}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "DANA", MethodName: "DANA", Rate: "1.00000000", SortOrder: 160}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "LinkAja", MethodName: "LinkAja", Rate: "1.00000000", SortOrder: 170}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "OVO", MethodName: "OVO", Rate: "1.00000000", SortOrder: 180}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "ShopeePay", MethodName: "ShopeePay", Rate: "1.00000000", SortOrder: 190}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "QR", PayType: "QRIS", MethodName: "QRIS", Rate: "1.00000000", SortOrder: 200}, - {CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", PayWay: "BankTransfer", PayType: "VN_BankTransfer", MethodName: "Vietnam Bank Transfer", Rate: "1.00000000", SortOrder: 210}, - {CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", PayWay: "QR", PayType: "VietQR", MethodName: "VietQR", Rate: "1.00000000", SortOrder: 220}, - {CountryCode: "BH", CountryName: "Bahrain", CurrencyCode: "BHD", PayWay: "Card", PayType: "CreditCard", MethodName: "Bahrain Credit Card", Rate: "1.00000000", SortOrder: 310}, - {CountryCode: "QA", CountryName: "Qatar", CurrencyCode: "QAR", PayWay: "Card", PayType: "CreditCard", MethodName: "Qatar Credit Card", Rate: "1.00000000", SortOrder: 410}, - {CountryCode: "OM", CountryName: "Oman", CurrencyCode: "OMR", PayWay: "Card", PayType: "CreditCard", MethodName: "Oman Credit Card", Rate: "1.00000000", SortOrder: 510}, - {CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", PayWay: "Card", PayType: "CreditCard", MethodName: "UAE Credit Card", Rate: "1.00000000", SortOrder: 610}, - {CountryCode: "KW", CountryName: "Kuwait", CurrencyCode: "KWD", PayWay: "Card", PayType: "Knet", MethodName: "KNET", Rate: "1.00000000", SortOrder: 710}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Rate: "1.00000000", SortOrder: 810}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "ApplePay", MethodName: "ApplePay", Rate: "1.00000000", SortOrder: 820}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPay", MethodName: "STCPay", Rate: "1.00000000", SortOrder: 830}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "BHIM", MethodName: "BHIM", Rate: "1.00000000", SortOrder: 910}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "GooglePay", MethodName: "GooglePay", Rate: "1.00000000", SortOrder: 920}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "Mobikwik", MethodName: "Mobikwik", Rate: "1.00000000", SortOrder: 930}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "Paytm", MethodName: "Paytm", Rate: "1.00000000", SortOrder: 940}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "PhonePe", MethodName: "PhonePe", Rate: "1.00000000", SortOrder: 950}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "Ewallet", PayType: "UPI", MethodName: "UPI", Rate: "1.00000000", SortOrder: 960}, - {CountryCode: "BD", CountryName: "Bangladesh", CurrencyCode: "BDT", PayWay: "Ewallet", PayType: "bKash", MethodName: "bKash", Rate: "1.00000000", SortOrder: 1010}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "Easypaisa", MethodName: "Easypaisa", Rate: "1.00000000", SortOrder: 1110}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "JazzCash", MethodName: "JazzCash", Rate: "1.00000000", SortOrder: 1120}, - {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "FawryPay", MethodName: "FawryPay", Rate: "1.00000000", SortOrder: 1210}, - {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Meeza", MethodName: "Meeza", Rate: "1.00000000", SortOrder: 1220}, - {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Orange", MethodName: "Orange", Rate: "1.00000000", SortOrder: 1230}, - {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Ewallet", PayType: "Vodafone", MethodName: "Vodafone", Rate: "1.00000000", SortOrder: 1240}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "Gcash", MethodName: "GCash", Rate: "1.00000000", SortOrder: 1310}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "PayMaya", MethodName: "PayMaya", Rate: "1.00000000", SortOrder: 1320}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "QRPH", MethodName: "QRPH", Rate: "1.00000000", SortOrder: 1330}, - } - for _, method := range methods { - rate := strings.TrimSpace(method.Rate) - if rate == "" { - rate = "1.00000000" - } - if _, err := db.ExecContext(ctx, ` - INSERT INTO third_party_payment_methods ( - app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type, - method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms - ) VALUES ('lalu', 'mifapay', ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - country_name = VALUES(country_name), - currency_code = VALUES(currency_code), - method_name = VALUES(method_name), - logo_url = CASE - WHEN logo_url = '' THEN VALUES(logo_url) - WHEN logo_url LIKE 'https://placehold.co/%' THEN VALUES(logo_url) - WHEN logo_url LIKE '%/image-preview' THEN VALUES(logo_url) - ELSE logo_url - END, - sort_order = VALUES(sort_order), - usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate), - updated_at_ms = VALUES(updated_at_ms)`, - strings.ToUpper(method.CountryCode), method.CountryName, strings.ToUpper(method.CurrencyCode), - method.PayWay, method.PayType, method.MethodName, defaultMifaPayLogoURL(method.CountryCode, method.PayWay, method.PayType), rate, method.SortOrder, nowMS, nowMS, - ); err != nil { - return err - } - } - // V5Pay 的 pay_type 直接保存 productType,H5 只展示“本国家可用方式”,下单时服务端用 method_id 还原具体通道。 - // 这里只 seed 文档列出的可收银台下单产品;RefNo 类需要线下凭证流,不接入当前 H5 立即跳转支付链路。 - v5Methods := []seedPaymentMethod{ - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UB_ONLINE", MethodName: "UnionBank Online", Rate: "1.00000000", SortOrder: 2010}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "INSTAPAY", MethodName: "InstaPay", Rate: "1.00000000", SortOrder: 2020}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PESONET", MethodName: "PESONet", Rate: "1.00000000", SortOrder: 2030}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GCASH_ONLINE", MethodName: "GCash Online", Rate: "1.00000000", SortOrder: 2040}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "COINS_ONLINE", MethodName: "Coins.ph Online", Rate: "1.00000000", SortOrder: 2050}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BPI_ONLINE", MethodName: "BPI Online", Rate: "1.00000000", SortOrder: 2060}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "RCBC_ONLINE", MethodName: "RCBC Online", Rate: "1.00000000", SortOrder: 2070}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UCPB_ONLINE", MethodName: "UCPB Online", Rate: "1.00000000", SortOrder: 2080}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "ROBINSONSBANK_ONLINE", MethodName: "Robinsons Bank Online", Rate: "1.00000000", SortOrder: 2090}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PSBANK_ONLINE", MethodName: "PSBank Online", Rate: "1.00000000", SortOrder: 2100}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "METROBANK_ONLINE", MethodName: "Metrobank Online", Rate: "1.00000000", SortOrder: 2110}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "LANDBANK_ONLINE", MethodName: "Landbank Online", Rate: "1.00000000", SortOrder: 2120}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "CHINABANK_ONLINE", MethodName: "China Bank Online", Rate: "1.00000000", SortOrder: 2130}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BDO_ONLINE", MethodName: "BDO Online", Rate: "1.00000000", SortOrder: 2140}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BOC_ONLINE", MethodName: "Bank of Commerce Online", Rate: "1.00000000", SortOrder: 2150}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GRABPAY_ONLINE", MethodName: "GrabPay Online", Rate: "1.00000000", SortOrder: 2160}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "INSTAPAY_QR", MethodName: "InstaPay QR", Rate: "1.00000000", SortOrder: 2170}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "ALIPAY_QR", MethodName: "Alipay QR", Rate: "1.00000000", SortOrder: 2180}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "ALIPAY_PLUS", MethodName: "Alipay+", Rate: "1.00000000", SortOrder: 2190}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Billing", PayType: "BILL", MethodName: "Bills Payment", Rate: "1.00000000", SortOrder: 2200}, - {CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "QRPH_GCASH", MethodName: "QRPh GCash", Rate: "1.00000000", SortOrder: 2210}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPAY", MethodName: "STC Pay", Rate: "1.00000000", SortOrder: 2310}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Rate: "1.00000000", SortOrder: 2320}, - {CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "APPLEPAY", MethodName: "Apple Pay", Rate: "1.00000000", SortOrder: 2330}, - {CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Card", PayType: "CARD", MethodName: "Egypt Card", Rate: "1.00000000", SortOrder: 2410}, - {CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", PayWay: "Card", PayType: "CARD", MethodName: "UAE Card", Rate: "1.00000000", SortOrder: 2510}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BNI_VA", MethodName: "BNI Virtual Account", Rate: "1.00000000", SortOrder: 2610}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BRI_VA", MethodName: "BRI Virtual Account", Rate: "1.00000000", SortOrder: 2620}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "CIMB_VA", MethodName: "CIMB Virtual Account", Rate: "1.00000000", SortOrder: 2630}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MANDIRI_VA", MethodName: "Mandiri Virtual Account", Rate: "1.00000000", SortOrder: 2640}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MAYBANK_VA", MethodName: "Maybank Virtual Account", Rate: "1.00000000", SortOrder: 2650}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "QR", PayType: "NOBU_BANK_QRIS", MethodName: "Nobu Bank QRIS", Rate: "1.00000000", SortOrder: 2660}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "OVO", MethodName: "OVO", Rate: "1.00000000", SortOrder: 2670}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "PERMATA_VA", MethodName: "Permata Virtual Account", Rate: "1.00000000", SortOrder: 2680}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "SHOPEEPAY_JUMPAPP", MethodName: "ShopeePay Jump App", Rate: "1.00000000", SortOrder: 2690}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "GOPAY", MethodName: "GoPay", Rate: "1.00000000", SortOrder: 2700}, - {CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "ALIPAY_CN_ALIPAYPLUS", MethodName: "Alipay+ Indonesia", Rate: "1.00000000", SortOrder: 2710}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "BANK_ONLINE", MethodName: "Bank Online", Rate: "1.00000000", SortOrder: 2810}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "UPI", PayType: "UPI", MethodName: "UPI", Rate: "1.00000000", SortOrder: 2820}, - {CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "ONLINE_BANKING", MethodName: "Online Banking", Rate: "1.00000000", SortOrder: 2830}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "EASYPAISA", MethodName: "Easypaisa", Rate: "1.00000000", SortOrder: 2910}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "JAZZCASH", MethodName: "JazzCash", Rate: "1.00000000", SortOrder: 2920}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "BankTransfer", PayType: "ALFA", MethodName: "Bank Alfalah", Rate: "1.00000000", SortOrder: 2930}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "HBL_KONNECT", MethodName: "HBL Konnect", Rate: "1.00000000", SortOrder: 2940}, - {CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Billing", PayType: "BILL_PK", MethodName: "Pakistan Bill Payment", Rate: "1.00000000", SortOrder: 2950}, - } - for _, method := range v5Methods { - rate := strings.TrimSpace(method.Rate) - if rate == "" { - rate = "1.00000000" - } - if _, err := db.ExecContext(ctx, ` - INSERT INTO third_party_payment_methods ( - app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type, - method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms - ) VALUES ('lalu', 'v5pay', ?, ?, ?, ?, ?, ?, '', 'active', ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - country_name = VALUES(country_name), - currency_code = VALUES(currency_code), - method_name = VALUES(method_name), - sort_order = VALUES(sort_order), - usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate), - updated_at_ms = VALUES(updated_at_ms)`, - strings.ToUpper(method.CountryCode), method.CountryName, strings.ToUpper(method.CurrencyCode), - method.PayWay, method.PayType, method.MethodName, rate, method.SortOrder, nowMS, nowMS, - ); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - app := appcode.FromContext(ctx) - providerCode := strings.ToLower(strings.TrimSpace(query.ProviderCode)) - status := ledger.NormalizeThirdPartyPaymentStatus(query.Status) - where := `WHERE app_code = ?` - args := []any{app} - if providerCode != "" { - where += ` AND provider_code = ?` - args = append(args, providerCode) - } - if status != "" { - where += ` AND status = ?` - args = append(args, status) - } - rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, provider_code, provider_name, status, sort_order - FROM third_party_payment_channels - `+where+` - ORDER BY sort_order ASC, provider_code ASC`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - channels := make([]ledger.ThirdPartyPaymentChannel, 0) - for rows.Next() { - var channel ledger.ThirdPartyPaymentChannel - if err := rows.Scan(&channel.AppCode, &channel.ProviderCode, &channel.ProviderName, &channel.Status, &channel.SortOrder); err != nil { - return nil, err - } - channels = append(channels, channel) - } - if err := rows.Err(); err != nil { - return nil, err - } - for index := range channels { - methods, err := r.listThirdPartyPaymentMethods(ctx, app, channels[index].ProviderCode, "", query.IncludeDisabledMethods, false) - if err != nil { - return nil, err - } - channels[index].Methods = methods - } - return channels, nil -} - -func (r *Repository) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) { - if r == nil || r.db == nil { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - status := ledger.ThirdPartyPaymentStatusDisabled - if command.Enabled { - status = ledger.ThirdPartyPaymentStatusActive - } - result, err := r.db.ExecContext(ctx, ` - UPDATE third_party_payment_methods - SET status = ?, updated_at_ms = ? - WHERE app_code = ? AND method_id = ?`, - status, time.Now().UnixMilli(), appcode.FromContext(ctx), command.MethodID, - ) - if err != nil { - return ledger.ThirdPartyPaymentMethod{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return ledger.ThirdPartyPaymentMethod{}, err - } else if affected == 0 { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") - } - return r.GetThirdPartyPaymentMethod(ctx, appcode.FromContext(ctx), command.MethodID) -} - -func (r *Repository) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) { - if r == nil || r.db == nil { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - result, err := r.db.ExecContext(ctx, ` - UPDATE third_party_payment_methods - SET usd_to_currency_rate = ?, updated_at_ms = ? - WHERE app_code = ? AND method_id = ?`, - command.USDToCurrencyRate, time.Now().UnixMilli(), appcode.FromContext(ctx), command.MethodID, - ) - if err != nil { - return ledger.ThirdPartyPaymentMethod{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return ledger.ThirdPartyPaymentMethod{}, err - } else if affected == 0 { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") - } - return r.GetThirdPartyPaymentMethod(ctx, appcode.FromContext(ctx), command.MethodID) -} - -func (r *Repository) GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error) { - ctx = contextWithCommandApp(ctx, appCode) - row := r.db.QueryRowContext(ctx, thirdPartyPaymentMethodSelectSQL()+` - WHERE m.app_code = ? AND m.method_id = ?`, - appcode.FromContext(ctx), methodID, - ) - method, err := scanThirdPartyPaymentMethod(row) - if errors.Is(err, sql.ErrNoRows) { - return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.NotFound, "payment method not found") - } - return method, err -} - -func (r *Repository) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) { - if r == nil || r.db == nil { - return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, query.AppCode) - products, _, err := r.ListRechargeProducts(ctx, query.TargetUserID, query.TargetRegionID, ledger.RechargeProductPlatformWeb, query.AudienceType) - if err != nil { - return ledger.H5RechargeOptions{}, err - } - methods, err := r.listH5ThirdPartyPaymentMethods(ctx, appcode.FromContext(ctx), strings.ToUpper(strings.TrimSpace(query.TargetCountryCode))) - if err != nil { - return ledger.H5RechargeOptions{}, err - } - return ledger.H5RechargeOptions{Products: products, PaymentMethods: methods}, nil -} - -func (r *Repository) listH5ThirdPartyPaymentMethods(ctx context.Context, appCode string, countryCode string) ([]ledger.ThirdPartyPaymentMethod, error) { - where := `WHERE m.app_code = ? AND m.status = 'active' AND m.usd_to_currency_rate > 0` - args := []any{appCode} - if countryCode != "" { - where += ` AND m.country_code IN (?, 'GLOBAL')` - args = append(args, countryCode) - } - rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+` - `+where+` - ORDER BY c.sort_order ASC, CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - methods := make([]ledger.ThirdPartyPaymentMethod, 0) - for rows.Next() { - method, err := scanThirdPartyPaymentMethod(rows) - if err != nil { - return nil, err - } - methods = append(methods, method) - } - return methods, rows.Err() -} - -func (r *Repository) listThirdPartyPaymentMethods(ctx context.Context, appCode string, providerCode string, countryCode string, includeDisabled bool, requireRate bool) ([]ledger.ThirdPartyPaymentMethod, error) { - where := `WHERE m.app_code = ? AND m.provider_code = ?` - args := []any{appCode, providerCode} - if countryCode != "" { - where += ` AND m.country_code IN (?, 'GLOBAL')` - args = append(args, countryCode) - } - if !includeDisabled { - where += ` AND m.status = 'active'` - } - if requireRate { - where += ` AND m.usd_to_currency_rate > 0` - } - rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+` - `+where+` - ORDER BY CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...) - if err != nil { - return nil, err - } - defer rows.Close() - methods := make([]ledger.ThirdPartyPaymentMethod, 0) - for rows.Next() { - method, err := scanThirdPartyPaymentMethod(rows) - if err != nil { - return nil, err - } - methods = append(methods, method) - } - return methods, rows.Err() -} - -func thirdPartyPaymentMethodSelectSQL() string { - return ` - SELECT m.method_id, m.app_code, m.provider_code, c.provider_name, m.country_code, m.country_name, - m.currency_code, m.pay_way, m.pay_type, m.method_name, m.logo_url, m.status, - CAST(m.usd_to_currency_rate AS CHAR), m.sort_order, m.created_at_ms, m.updated_at_ms - FROM third_party_payment_methods m - JOIN third_party_payment_channels c ON c.app_code = m.app_code AND c.provider_code = m.provider_code - ` -} - -func scanThirdPartyPaymentMethod(scanner scanTarget) (ledger.ThirdPartyPaymentMethod, error) { - var method ledger.ThirdPartyPaymentMethod - if err := scanner.Scan( - &method.MethodID, - &method.AppCode, - &method.ProviderCode, - &method.ProviderName, - &method.CountryCode, - &method.CountryName, - &method.CurrencyCode, - &method.PayWay, - &method.PayType, - &method.MethodName, - &method.LogoURL, - &method.Status, - &method.USDToCurrencyRate, - &method.SortOrder, - &method.CreatedAtMS, - &method.UpdatedAtMS, - ); err != nil { - return ledger.ThirdPartyPaymentMethod{}, err - } - return method, nil -} - -func (r *Repository) CreateExternalRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string) (ledger.ExternalRechargeOrder, error) { - if r == nil || r.db == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, command.AppCode) - command.AppCode = appcode.FromContext(ctx) - if existing, found, err := r.lookupExternalRechargeOrderByCommand(ctx, command.AppCode, command.CommandID); err != nil || found { - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - existing.IdempotentReplay = true - return existing, nil - } - order := externalRechargeOrderFromCommand(command, product, method, providerAmountMinor, receiveAddress, time.Now().UnixMilli()) - _, err := r.db.ExecContext(ctx, ` - INSERT INTO external_recharge_orders ( - app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type, - product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id, - country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status, - created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode, - order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount, - order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor, - order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS, - ) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return order, nil -} - -func (r *Repository) MarkExternalRechargeOrderRedirected(ctx context.Context, appCode string, orderID string, providerOrderID string, payURL string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { - ctx = contextWithCommandApp(ctx, appCode) - nowMS := time.Now().UnixMilli() - _, err := r.db.ExecContext(ctx, ` - UPDATE external_recharge_orders - SET provider_order_id = ?, pay_url = ?, provider_payload = ?, status = ?, updated_at_ms = ? - WHERE app_code = ? AND order_id = ? AND status = ?`, - strings.TrimSpace(providerOrderID), strings.TrimSpace(payURL), nullableJSON(payloadJSON), ledger.ExternalRechargeStatusRedirected, - nowMS, appcode.FromContext(ctx), strings.TrimSpace(orderID), ledger.ExternalRechargeStatusPending, - ) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), orderID) -} - -func (r *Repository) AttachExternalRechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) { - ctx = contextWithCommandApp(ctx, command.AppCode) - txHash := strings.TrimSpace(command.TxHash) - result, err := r.db.ExecContext(ctx, ` - UPDATE external_recharge_orders - SET tx_hash = ?, provider_payload = ?, updated_at_ms = ? - WHERE app_code = ? AND order_id = ? AND target_user_id = ? AND provider_code = ? AND status = ?`, - txHash, nullableJSON(payloadJSON), time.Now().UnixMilli(), appcode.FromContext(ctx), strings.TrimSpace(command.OrderID), - command.TargetUserID, ledger.PaymentProviderUSDTTRC20, ledger.ExternalRechargeStatusPending, - ) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if affected, err := result.RowsAffected(); err != nil { - return ledger.ExternalRechargeOrder{}, err - } else if affected == 0 { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") - } - return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), command.OrderID) -} - -func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { - if r == nil || r.db == nil { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, appCode) - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - defer func() { _ = tx.Rollback() }() - - order, err := r.lockExternalRechargeOrder(ctx, tx, appcode.FromContext(ctx), strings.TrimSpace(orderID)) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if order.Status == ledger.ExternalRechargeStatusCredited { - order.IdempotentReplay = true - return order, nil - } - if order.Status == ledger.ExternalRechargeStatusFailed { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is failed") - } - if strings.TrimSpace(txHash) != "" { - if err := r.ensureExternalTxHashUnused(ctx, tx, order.AppCode, order.ProviderCode, strings.TrimSpace(txHash), order.OrderID); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - order.TxHash = strings.TrimSpace(txHash) - } - if strings.TrimSpace(providerOrderID) != "" { - order.ProviderOrderID = strings.TrimSpace(providerOrderID) - } - - nowMS := time.Now().UnixMilli() - assetType := ledger.AssetCoin - rechargeType := order.ProviderCode - bizType := providerRechargeBizType(order.ProviderCode) - isCoinSellerRecharge := order.AudienceType == ledger.RechargeAudienceCoinSeller - if isCoinSellerRecharge { - assetType = ledger.AssetCoinSellerCoin - rechargeType = "coin_seller_recharge" - bizType = "coin_seller_recharge" - } - account, err := r.lockAccount(ctx, tx, order.TargetUserID, assetType, true, nowMS) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - transactionID := transactionID(order.AppCode, order.CommandID) - if _, exists, err := r.lookupTransaction(ctx, tx, order.CommandID, externalRechargeRequestHash(order, assetType), bizType); err != nil || exists { - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return r.lockExternalRechargeOrder(ctx, tx, order.AppCode, order.OrderID) - } - balanceAfter, err := checkedAdd(account.AvailableAmount, order.CoinAmount) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - metadata := coinSellerTransferMetadata{ - AppCode: order.AppCode, - PaymentOrderID: order.OrderID, - ProviderCode: order.ProviderCode, - PaymentMethodID: order.PaymentMethodID, - TargetUserID: order.TargetUserID, - TargetRegionID: order.TargetRegionID, - Amount: order.CoinAmount, - TargetAssetType: assetType, - TargetBalanceAfter: balanceAfter, - RechargeUSDMinor: order.USDMinorAmount, - RechargeCurrencyCode: order.CurrencyCode, - RechargePolicyID: order.ProductID, - RechargePolicyVersion: order.ProductCode, - RechargePolicyCoinAmount: order.CoinAmount, - RechargePolicyUSDMinorAmount: order.USDMinorAmount, - RechargeType: rechargeType, - } - var transactionMetadata any = metadata - var stockMetadata coinSellerStockMetadata - if isCoinSellerRecharge { - // 币商列表的“累充USDT”是统一经营口径,不能直接使用 MiFaPay 本地支付币种金额。 - // 这里按商品 USD 金额折成 USDT 微单位,确保 SA/SAR、ID/IDR 等三方通道都进入同一个币商累充口径。 - paidAmountMicro, err := checkedMul(order.USDMinorAmount, 10_000) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - stockMetadata = coinSellerStockMetadata{ - AppCode: order.AppCode, - PaymentOrderID: order.OrderID, - ProviderCode: order.ProviderCode, - PaymentMethodID: order.PaymentMethodID, - SellerUserID: order.TargetUserID, - SellerRegionID: order.TargetRegionID, - StockType: ledger.StockTypeUSDTPurchase, - CoinAmount: order.CoinAmount, - PaidCurrencyCode: ledger.PaidCurrencyUSDT, - PaidAmountMicro: paidAmountMicro, - PaymentRef: externalRechargePaymentRef(order), - EvidenceRef: order.OrderID, - Reason: "h5 external recharge", - AssetType: ledger.AssetCoinSellerCoin, - CountsAsSellerRecharge: true, - BalanceAfter: balanceAfter, - CreatedAtMS: nowMS, - } - transactionMetadata = stockMetadata - } else { - rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, order.AppCode, order.TargetUserID, transactionID, order.CoinAmount, order.USDMinorAmount, nowMS) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - metadata.RechargeSequence = rechargeSequence - } - if err := r.insertTransaction(ctx, tx, transactionID, order.CommandID, bizType, externalRechargeRequestHash(order, assetType), order.OrderID, transactionMetadata, nowMS); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := r.applyAccountDelta(ctx, tx, account, order.CoinAmount, 0, nowMS); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := r.insertEntry(ctx, tx, walletEntry{ - TransactionID: transactionID, - UserID: order.TargetUserID, - AssetType: assetType, - AvailableDelta: order.CoinAmount, - AvailableAfter: balanceAfter, - FrozenAfter: account.FrozenAmount, - CreatedAtMS: nowMS, - }); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - // 普通用户 H5 充值进入普通 COIN 钱包,并写普通充值记录/累计统计,供首充、累充、VIP 等用户侧口径消费。 - // 币商 H5 充值只进入 COIN_SELLER_COIN 库存,并写币商库存记录;不能写 wallet_recharge_records,避免被误算成普通用户充值。 - outboxEvents := []walletOutboxEvent{ - balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, transactionMetadata, nowMS), - } - if isCoinSellerRecharge { - if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, order.CommandID, stockMetadata, nowMS); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - outboxEvents = append(outboxEvents, coinSellerStockCreditedEvent(transactionID, order.CommandID, stockMetadata, nowMS)) - } else { - if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMS); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - outboxEvents = append(outboxEvents, rechargeRecordedEvent(transactionID, order.CommandID, order.TargetUserID, order.CoinAmount, metadata, nowMS)) - } - if _, err := tx.ExecContext(ctx, ` - UPDATE external_recharge_orders - SET status = ?, provider_order_id = ?, tx_hash = ?, wallet_transaction_id = ?, provider_payload = ?, updated_at_ms = ? - WHERE app_code = ? AND order_id = ?`, - ledger.ExternalRechargeStatusCredited, order.ProviderOrderID, order.TxHash, transactionID, nullableJSON(payloadJSON), - nowMS, order.AppCode, order.OrderID, - ); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - if err := tx.Commit(); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return r.GetExternalRechargeOrder(ctx, order.AppCode, order.OrderID) -} - -func (r *Repository) MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { - ctx = contextWithCommandApp(ctx, appCode) - _, err := r.db.ExecContext(ctx, ` - UPDATE external_recharge_orders - SET status = ?, failure_reason = ?, provider_payload = ?, updated_at_ms = ? - WHERE app_code = ? AND order_id = ? AND status <> ?`, - ledger.ExternalRechargeStatusFailed, trimMax(reason, 512), nullableJSON(payloadJSON), time.Now().UnixMilli(), - appcode.FromContext(ctx), strings.TrimSpace(orderID), ledger.ExternalRechargeStatusCredited, - ) - if err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return r.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), orderID) -} - -func (r *Repository) GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) { - ctx = contextWithCommandApp(ctx, appCode) - order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` - WHERE app_code = ? AND order_id = ?`, - appcode.FromContext(ctx), strings.TrimSpace(orderID), - )) - if errors.Is(err, sql.ErrNoRows) { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") - } - return order, err -} - -func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error) { - if r == nil || r.db == nil { - return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - ctx = contextWithCommandApp(ctx, appCode) - if limit <= 0 { - return nil, nil - } - if limit > 500 { - limit = 500 - } - rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+` - WHERE app_code = ? - AND provider_code IN (?, ?) - AND status = ? - ORDER BY updated_at_ms ASC, created_at_ms ASC - LIMIT ?`, - appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, - ledger.ExternalRechargeStatusRedirected, limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - orders := make([]ledger.ExternalRechargeOrder, 0, limit) - for rows.Next() { - order, err := scanExternalRechargeOrder(rows) - if err != nil { - return nil, err - } - orders = append(orders, order) - } - return orders, rows.Err() -} - -func (r *Repository) lookupExternalRechargeOrderByCommand(ctx context.Context, appCode string, commandID string) (ledger.ExternalRechargeOrder, bool, error) { - order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` - WHERE app_code = ? AND command_id = ?`, - appCode, strings.TrimSpace(commandID), - )) - if errors.Is(err, sql.ErrNoRows) { - return ledger.ExternalRechargeOrder{}, false, nil - } - return order, err == nil, err -} - -func (r *Repository) lockExternalRechargeOrder(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) { - order, err := scanExternalRechargeOrder(tx.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+` - WHERE app_code = ? AND order_id = ? - FOR UPDATE`, - appCode, strings.TrimSpace(orderID), - )) - if errors.Is(err, sql.ErrNoRows) { - return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") - } - return order, err -} - -func externalRechargeOrderSelectSQL() string { - return ` - SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type, - product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id, - country_code, currency_code, provider_amount_minor, pay_way, pay_type, COALESCE(pay_url, ''), - provider_order_id, tx_hash, receive_address, status, failure_reason, wallet_transaction_id, - COALESCE(CAST(provider_payload AS CHAR), ''), created_at_ms, updated_at_ms - FROM external_recharge_orders - ` -} - -func scanExternalRechargeOrder(scanner scanTarget) (ledger.ExternalRechargeOrder, error) { - var order ledger.ExternalRechargeOrder - if err := scanner.Scan( - &order.AppCode, - &order.OrderID, - &order.CommandID, - &order.TargetUserID, - &order.TargetRegionID, - &order.TargetCountryCode, - &order.AudienceType, - &order.ProductID, - &order.ProductCode, - &order.ProductName, - &order.CoinAmount, - &order.USDMinorAmount, - &order.ProviderCode, - &order.PaymentMethodID, - &order.CountryCode, - &order.CurrencyCode, - &order.ProviderAmountMinor, - &order.PayWay, - &order.PayType, - &order.PayURL, - &order.ProviderOrderID, - &order.TxHash, - &order.ReceiveAddress, - &order.Status, - &order.FailureReason, - &order.TransactionID, - &order.ProviderPayloadJSON, - &order.CreatedAtMS, - &order.UpdatedAtMS, - ); err != nil { - return ledger.ExternalRechargeOrder{}, err - } - return order, nil -} - -func externalRechargePaymentRef(order ledger.ExternalRechargeOrder) string { - for _, value := range []string{order.ProviderOrderID, order.TxHash, order.OrderID} { - if value = strings.TrimSpace(value); value != "" { - return value - } - } - return "" -} - -func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string, nowMS int64) ledger.ExternalRechargeOrder { - order := ledger.ExternalRechargeOrder{ - OrderID: externalRechargeOrderID(command.AppCode, command.CommandID), - AppCode: command.AppCode, - CommandID: strings.TrimSpace(command.CommandID), - TargetUserID: command.TargetUserID, - TargetRegionID: command.TargetRegionID, - TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)), - AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType), - ProductID: product.ProductID, - ProductCode: product.ProductCode, - ProductName: product.ProductName, - CoinAmount: product.CoinAmount, - USDMinorAmount: googleAmountMicroToUSDMinor(product.AmountMicro), - ProviderCode: ledger.NormalizePaymentProvider(command.ProviderCode), - ProviderAmountMinor: providerAmountMinor, - ReceiveAddress: strings.TrimSpace(receiveAddress), - Status: ledger.ExternalRechargeStatusPending, - CreatedAtMS: nowMS, - UpdatedAtMS: nowMS, - } - if method != nil { - order.PaymentMethodID = method.MethodID - order.CountryCode = method.CountryCode - order.CurrencyCode = method.CurrencyCode - order.PayWay = method.PayWay - order.PayType = method.PayType - } - if order.ProviderCode == ledger.PaymentProviderUSDTTRC20 { - order.CountryCode = "GLOBAL" - order.CurrencyCode = ledger.PaidCurrencyUSDT - } - return order -} - -func externalRechargeOrderID(appCode string, commandID string) string { - // MiFaPay 的 orderId 示例和通道口径都按 32 位商户订单号处理;这里截取稳定 hash 的前 32 位,既满足三方长度限制, - // 又保留 app_code + command_id 的幂等映射,避免重试同一 H5 点击时生成不同本地订单。 - return stableHash(appcode.Normalize(appCode) + "|" + strings.TrimSpace(commandID))[:32] -} - -func (r *Repository) ensureExternalTxHashUnused(ctx context.Context, tx *sql.Tx, appCode string, providerCode string, txHash string, orderID string) error { - var existing string - err := tx.QueryRowContext(ctx, ` - SELECT order_id - FROM external_recharge_orders - WHERE app_code = ? AND provider_code = ? AND tx_hash = ? AND order_id <> ? AND status = ? - LIMIT 1`, - appCode, providerCode, txHash, orderID, ledger.ExternalRechargeStatusCredited, - ).Scan(&existing) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return err - } - return xerr.New(xerr.Conflict, "tx_hash already credited") -} - -func providerRechargeBizType(providerCode string) string { - switch ledger.NormalizePaymentProvider(providerCode) { - case ledger.PaymentProviderMifaPay: - return bizTypeMifaPayRecharge - case ledger.PaymentProviderV5Pay: - return bizTypeV5PayRecharge - case ledger.PaymentProviderUSDTTRC20: - return bizTypeUSDTTRC20Recharge - default: - return strings.ToLower(strings.TrimSpace(providerCode)) - } -} - -func externalRechargeRequestHash(order ledger.ExternalRechargeOrder, assetType string) string { - return stableHash(fmt.Sprintf("external_recharge|%s|%s|%d|%d|%d|%s|%s|%s", - order.AppCode, order.OrderID, order.TargetUserID, order.ProductID, order.CoinAmount, - order.ProviderCode, order.ProviderOrderID, assetType, - )) -} - -func nullableJSON(payload string) any { - payload = strings.TrimSpace(payload) - if payload == "" { - return nil - } - return payload -} - -func trimMax(value string, max int) string { - value = strings.TrimSpace(value) - if len([]rune(value)) <= max { - return value - } - return string([]rune(value)[:max]) -} - -func decimalRateIsPositive(rate string) bool { - value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) - return err == nil && value > 0 -} diff --git a/services/wallet-service/internal/storage/mysql/transaction.go b/services/wallet-service/internal/storage/mysql/transaction.go new file mode 100644 index 00000000..8ff64214 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/transaction.go @@ -0,0 +1,69 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +type transactionRow struct { + TransactionID string + MetadataJSON string +} + +func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) { + return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict) +} + +func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (transactionRow, bool, error) { + row := tx.QueryRowContext(ctx, + `SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND command_id = ? + FOR UPDATE`, + appcode.FromContext(ctx), + commandID, + ) + + var txRow transactionRow + var storedHash string + var storedBizType string + if err := row.Scan(&txRow.TransactionID, &storedHash, &storedBizType, &txRow.MetadataJSON); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return transactionRow{}, false, nil + } + return transactionRow{}, false, err + } + if storedHash != requestHash || storedBizType != bizType { + + return transactionRow{}, true, xerr.New(conflictCode, "wallet command idempotency conflict") + } + return txRow, true, nil +} + +func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, bizType string, requestHash string, externalRef string, metadata any, nowMs int64) error { + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, + `INSERT INTO wallet_transactions ( + app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref, metadata_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + transactionID, + commandID, + bizType, + transactionStatusSucceeded, + requestHash, + externalRef, + string(metadataJSON), + nowMs, + nowMs, + ) + return err +} diff --git a/services/wallet-service/internal/storage/mysql/utils.go b/services/wallet-service/internal/storage/mysql/utils.go new file mode 100644 index 00000000..9ea354e0 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/utils.go @@ -0,0 +1,84 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + mysqlDriver "github.com/go-sql-driver/mysql" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +func contextWithCommandApp(ctx context.Context, value string) context.Context { + if strings.TrimSpace(value) == "" { + return appcode.WithContext(ctx, appcode.FromContext(ctx)) + } + + return appcode.WithContext(ctx, value) +} + +func normalizedEntryAppCode(ctx context.Context, value string) string { + if strings.TrimSpace(value) == "" { + return appcode.FromContext(ctx) + } + + return appcode.Normalize(value) +} + +func checkedMul(unit int64, count int64) (int64, error) { + if unit < 0 || count <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "amount is invalid") + } + if unit != 0 && count > math.MaxInt64/unit { + return 0, xerr.New(xerr.InvalidArgument, "amount overflow") + } + return unit * count, nil +} + +func checkedAdd(left int64, right int64) (int64, error) { + if right < 0 || left > math.MaxInt64-right { + return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow") + } + return left + right, nil +} + +func stableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func mustJSON(value any) string { + body, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(body) +} + +func transactionID(appCode string, commandID string) string { + return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) +} + +func coinSellerStockID(appCode string, commandID string) string { + return "cstock_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) +} + +func billingReceiptID(appCode string, commandID string) string { + return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) +} + +func eventID(transactionID string, eventType string, userID int64, assetType string) string { + return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType)) +} + +func isMySQLDuplicateError(err error) bool { + var mysqlErr *mysqlDriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} diff --git a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go new file mode 100644 index 00000000..1d992dd1 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go @@ -0,0 +1,147 @@ +package mysql + +import ( + "context" + "database/sql" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// UpdateAdminVipLevels 保存完整 10 级 VIP 配置。 +func (r *Repository) UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if operatorUserID <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "operator_user_id is required") + } + normalized, err := normalizeAdminVipLevelCommands(levels) + if err != nil { + return nil, err + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + nowMs := time.Now().UnixMilli() + for _, level := range normalized { + if level.Status == ledger.VipStatusActive { + if err := r.requireActiveResourceGroup(ctx, tx, level.RewardResourceGroupID); err != nil { + return nil, err + } + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO vip_levels ( + app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, + required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + status = VALUES(status), + price_coin = VALUES(price_coin), + duration_ms = VALUES(duration_ms), + reward_resource_group_id = VALUES(reward_resource_group_id), + required_recharge_coin_amount = VALUES(required_recharge_coin_amount), + sort_order = VALUES(sort_order), + updated_at_ms = VALUES(updated_at_ms)`, + appcode.FromContext(ctx), + level.Level, + level.Name, + level.Status, + level.PriceCoin, + level.DurationMS, + level.RewardResourceGroupID, + level.RequiredRechargeCoinAmount, + level.SortOrder, + nowMs, + nowMs, + ); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return r.ListAdminVipLevels(ctx) +} + +func normalizeAdminVipLevelCommands(levels []ledger.AdminVipLevelCommand) ([]ledger.AdminVipLevelCommand, error) { + if len(levels) != 10 { + return nil, xerr.New(xerr.InvalidArgument, "vip config requires exactly 10 levels") + } + byLevel := make(map[int32]ledger.AdminVipLevelCommand, 10) + for _, level := range levels { + level.Name = strings.TrimSpace(level.Name) + level.Status = strings.ToLower(strings.TrimSpace(level.Status)) + if level.Status == "" { + level.Status = ledger.VipStatusDisabled + } + if level.Level < 1 || level.Level > 10 { + return nil, xerr.New(xerr.InvalidArgument, "vip level must be between 1 and 10") + } + if _, exists := byLevel[level.Level]; exists { + return nil, xerr.New(xerr.InvalidArgument, "vip level is duplicated") + } + if level.Level <= 5 { + level.RequiredRechargeCoinAmount = 0 + } + if err := validateAdminVipLevelCommand(level); err != nil { + return nil, err + } + byLevel[level.Level] = level + } + normalized := make([]ledger.AdminVipLevelCommand, 0, 10) + for level := int32(1); level <= 10; level++ { + item, ok := byLevel[level] + if !ok { + return nil, xerr.New(xerr.InvalidArgument, "vip config requires levels 1 to 10") + } + normalized = append(normalized, item) + } + return normalized, nil +} + +func validateAdminVipLevelCommand(level ledger.AdminVipLevelCommand) error { + if level.Name == "" { + return xerr.New(xerr.InvalidArgument, "vip level name is required") + } + switch level.Status { + case ledger.VipStatusActive, ledger.VipStatusDisabled: + default: + return xerr.New(xerr.InvalidArgument, "vip level status is invalid") + } + if level.PriceCoin < 0 || level.DurationMS <= 0 || level.RewardResourceGroupID < 0 || level.RequiredRechargeCoinAmount < 0 { + return xerr.New(xerr.InvalidArgument, "vip level config is invalid") + } + if level.Status != ledger.VipStatusActive { + return nil + } + if level.PriceCoin <= 0 { + return xerr.New(xerr.InvalidArgument, "active vip level price_coin must be greater than zero") + } + if level.RewardResourceGroupID <= 0 { + return xerr.New(xerr.InvalidArgument, "active vip level reward_resource_group_id is required") + } + if level.Level >= 6 && level.RequiredRechargeCoinAmount <= 0 { + return xerr.New(xerr.InvalidArgument, "vip level 6-10 requires recharge threshold") + } + return nil +} + +func (r *Repository) requireActiveResourceGroup(ctx context.Context, tx *sql.Tx, groupID int64) error { + group, err := r.getResourceGroup(ctx, tx, groupID, true) + if err != nil { + return err + } + if group.Status != resourcedomain.StatusActive { + return xerr.New(xerr.Conflict, "vip reward group is disabled") + } + return nil +} diff --git a/services/wallet-service/internal/storage/mysql/vip_grant_repository.go b/services/wallet-service/internal/storage/mysql/vip_grant_repository.go new file mode 100644 index 00000000..ace978e9 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/vip_grant_repository.go @@ -0,0 +1,141 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" + "time" +) + +// GrantVip 是活动和后台赠送 VIP 的统一账务入口;它不扣金币,也不检查充值门槛。 +func (r *Repository) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) { + if r == nil || r.db == nil { + return ledger.GrantVipReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource) + if command.GrantSource == "" || command.GrantSource == ledger.VipGrantSourcePurchase { + return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid") + } + command.Reason = strings.TrimSpace(command.Reason) + if command.Reason == "" { + command.Reason = command.GrantSource + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := vipGrantRequestHash(command) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeVIPGrant); err != nil || exists { + if err != nil || !exists { + return ledger.GrantVipReceipt{}, err + } + return r.vipGrantReceiptForTransaction(ctx, tx, txRow, command.TargetUserID) + } + + nowMs := time.Now().UnixMilli() + level, err := r.getVipLevelForUpdate(ctx, tx, command.Level) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + if level.Status != ledger.VipStatusActive { + return ledger.GrantVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled") + } + if command.GrantSource == ledger.VipGrantSourceManagerCenter { + + level.DurationMS = managerCenterVIPGrantDurationMS + } + current, err := r.ensureUserVipForUpdate(ctx, tx, command.TargetUserID, nowMs) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + transactionID := transactionID(command.AppCode, command.CommandID) + recordID := "vip_grant_" + stableHash(command.AppCode+"|"+command.CommandID) + expectedResourceGrantID := resourceGrantID(command.AppCode, "vip_reward:"+command.CommandID) + metadata := map[string]any{ + "app_code": command.AppCode, + "grant_id": recordID, + "resource_grant_id": expectedResourceGrantID, + "user_id": command.TargetUserID, + "target_level": command.Level, + "grant_source": command.GrantSource, + "operator_user_id": command.OperatorUserID, + "reason": command.Reason, + "reward_group_id": level.RewardResourceGroupID, + "duration_ms": level.DurationMS, + "transaction_id": transactionID, + "required_recharge_bypassed": true, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPGrant, requestHash, recordID, metadata, nowMs); err != nil { + return ledger.GrantVipReceipt{}, err + } + activation, err := r.activateUserVip(ctx, tx, vipActivationCommand{ + AppCode: command.AppCode, + CommandID: command.CommandID, + UserID: command.TargetUserID, + Source: command.GrantSource, + Reason: command.Reason, + OperatorUserID: command.OperatorUserID, + TransactionID: transactionID, + RecordID: recordID, + Level: level, + Current: current, + NowMS: nowMs, + }) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + vipActivatedEvent(transactionID, command.CommandID, command.TargetUserID, command.GrantSource, activation.Action, activation.PreviousLevel, activation.VIP, level.RewardResourceGroupID, activation.ResourceGrantID, activation.RewardItems, nowMs), + }); err != nil { + return ledger.GrantVipReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.GrantVipReceipt{}, err + } + return ledger.GrantVipReceipt{ + TransactionID: transactionID, + Vip: activation.VIP, + RewardItems: activation.RewardItems, + }, nil +} + +func (r *Repository) vipGrantReceiptForTransaction(ctx context.Context, tx *sql.Tx, txRow transactionRow, userID int64) (ledger.GrantVipReceipt, error) { + var metadata struct { + ResourceGrantID string `json:"resource_grant_id"` + } + _ = json.Unmarshal([]byte(txRow.MetadataJSON), &metadata) + vip, err := r.queryUserVip(ctx, tx, userID, time.Now().UnixMilli(), false) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + rewards, err := r.rewardItemsByGrant(ctx, tx, metadata.ResourceGrantID, vip.ExpiresAtMS) + if err != nil { + return ledger.GrantVipReceipt{}, err + } + return ledger.GrantVipReceipt{ + TransactionID: txRow.TransactionID, + Vip: vip, + RewardItems: rewards, + }, nil +} + +func vipGrantRequestHash(command ledger.GrantVipCommand) string { + return stableHash(fmt.Sprintf("vip_grant|%s|%d|%d|%s|%d|%s", + appcode.Normalize(command.AppCode), + command.TargetUserID, + command.Level, + ledger.NormalizeVipGrantSource(command.GrantSource), + command.OperatorUserID, + strings.TrimSpace(command.Reason), + )) +} diff --git a/services/wallet-service/internal/storage/mysql/vip_purchase_repository.go b/services/wallet-service/internal/storage/mysql/vip_purchase_repository.go new file mode 100644 index 00000000..591542f2 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/vip_purchase_repository.go @@ -0,0 +1,425 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "strings" + "time" +) + +// PurchaseVip 在一个事务内完成金币扣费、VIP 状态更新、订单历史和资源组发放。 +func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { + if r == nil || r.db == nil { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, command.AppCode) + command.AppCode = appcode.FromContext(ctx) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + requestHash := stableHash(fmt.Sprintf("vip_purchase|%s|%d|%d", command.AppCode, command.UserID, command.Level)) + if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeVIPPurchase); err != nil || exists { + if err != nil || !exists { + return ledger.PurchaseVipReceipt{}, err + } + return r.vipPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) + } + + nowMs := time.Now().UnixMilli() + level, err := r.getVipLevelForUpdate(ctx, tx, command.Level) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if level.Status != ledger.VipStatusActive { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled") + } + current, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMs) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + previousLevel := int32(0) + baseExpiresAt := nowMs + if current.Active { + previousLevel = current.Level + baseExpiresAt = current.ExpiresAtMS + if current.Level > command.Level { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed") + } + } + if level.RechargeGateRequired { + totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, tx, command.UserID) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if totalRechargeCoin < level.RequiredRechargeCoinAmount { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPRechargeRequired, "vip recharge requirement is not met") + } + } + newExpiresAt := baseExpiresAt + level.DurationMS + account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if account.AvailableAmount < level.PriceCoin { + return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + + transactionID := transactionID(command.AppCode, command.CommandID) + orderID := "vip_order_" + stableHash(command.AppCode+"|"+command.CommandID) + coinBalanceAfter := account.AvailableAmount - level.PriceCoin + metadata := map[string]any{ + "app_code": command.AppCode, + "order_id": orderID, + "user_id": command.UserID, + "target_level": command.Level, + "previous_level": previousLevel, + "price_coin": level.PriceCoin, + "expires_at_ms": newExpiresAt, + "balance_after": coinBalanceAfter, + "reward_group_id": level.RewardResourceGroupID, + "duration_ms": level.DurationMS, + "required_recharge_coin_amount": level.RequiredRechargeCoinAmount, + } + if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPPurchase, requestHash, orderID, metadata, nowMs); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if err := r.applyAccountDelta(ctx, tx, account, -level.PriceCoin, 0, nowMs); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if err := r.insertEntry(ctx, tx, walletEntry{ + TransactionID: transactionID, + UserID: command.UserID, + AssetType: ledger.AssetCoin, + AvailableDelta: -level.PriceCoin, + FrozenDelta: 0, + AvailableAfter: coinBalanceAfter, + FrozenAfter: account.FrozenAmount, + CreatedAtMS: nowMs, + }); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + activation, err := r.activateUserVip(ctx, tx, vipActivationCommand{ + AppCode: command.AppCode, + CommandID: command.CommandID, + UserID: command.UserID, + Source: ledger.VipGrantSourcePurchase, + Reason: "vip purchase reward", + OperatorUserID: command.UserID, + TransactionID: transactionID, + RecordID: orderID, + Level: level, + Current: current, + NowMS: nowMs, + }) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO vip_purchase_orders ( + app_code, order_id, command_id, user_id, target_level, previous_level, price_coin, + status, wallet_transaction_id, resource_grant_id, request_hash, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`, + command.AppCode, orderID, command.CommandID, command.UserID, command.Level, previousLevel, + level.PriceCoin, transactionID, activation.ResourceGrantID, requestHash, nowMs, nowMs, + ); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + vipEventType := "VipPurchased" + if activation.Action == "renew" { + vipEventType = "VipRenewed" + } else if activation.Action == "upgrade" { + vipEventType = "VipUpgraded" + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + { + EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"), + EventType: vipEventType, + TransactionID: transactionID, + CommandID: command.CommandID, + UserID: command.UserID, + AssetType: "VIP", + AvailableDelta: 0, + FrozenDelta: 0, + Payload: metadata, + CreatedAtMS: nowMs, + }, + vipActivatedEvent(transactionID, command.CommandID, command.UserID, ledger.VipGrantSourcePurchase, activation.Action, activation.PreviousLevel, activation.VIP, level.RewardResourceGroupID, activation.ResourceGrantID, activation.RewardItems, nowMs), + }); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + if err := tx.Commit(); err != nil { + return ledger.PurchaseVipReceipt{}, err + } + + return ledger.PurchaseVipReceipt{ + OrderID: orderID, + TransactionID: transactionID, + Vip: activation.VIP, + CoinSpent: level.PriceCoin, + CoinBalanceAfter: coinBalanceAfter, + RewardItems: activation.RewardItems, + }, nil +} + +func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vipActivationCommand) (vipActivationResult, error) { + previousLevel := int32(0) + baseExpiresAt := command.NowMS + startedAt := command.NowMS + if command.Current.Active { + previousLevel = command.Current.Level + baseExpiresAt = command.Current.ExpiresAtMS + startedAt = command.Current.StartedAtMS + if command.Current.Level > command.Level.Level { + return vipActivationResult{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed") + } + } + expiresAt := baseExpiresAt + command.Level.DurationMS + rewardItems, resourceGrantID, err := r.applyVipRewardGroup(ctx, tx, vipRewardGrantCommand{ + AppCode: command.AppCode, + CommandID: command.CommandID, + UserID: command.UserID, + Source: command.Source, + Reason: command.Reason, + OperatorUserID: command.OperatorUserID, + }, command.Level, expiresAt, command.NowMS) + if err != nil { + return vipActivationResult{}, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE user_vip_memberships + SET level = ?, name = ?, status = ?, started_at_ms = ?, expires_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND user_id = ?`, + command.Level.Level, command.Level.Name, ledger.VipStatusActive, startedAt, expiresAt, command.NowMS, command.AppCode, command.UserID, + ); err != nil { + return vipActivationResult{}, err + } + action := vipActivationAction(command.Source, previousLevel, command.Level.Level) + if _, err := tx.ExecContext(ctx, ` + INSERT INTO user_vip_history ( + app_code, user_id, action, previous_level, new_level, order_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + command.AppCode, command.UserID, action, previousLevel, command.Level.Level, command.RecordID, command.NowMS, + ); err != nil { + return vipActivationResult{}, err + } + return vipActivationResult{ + VIP: ledger.UserVip{ + UserID: command.UserID, + Level: command.Level.Level, + Name: command.Level.Name, + Active: true, + StartedAtMS: startedAt, + ExpiresAtMS: expiresAt, + UpdatedAtMS: command.NowMS, + }, + PreviousLevel: previousLevel, + Action: action, + ResourceGrantID: resourceGrantID, + RewardItems: rewardItems, + }, nil +} + +func vipActivationAction(source string, previousLevel int32, newLevel int32) string { + if source != ledger.VipGrantSourcePurchase { + return source + } + if previousLevel == newLevel { + return "renew" + } + if previousLevel > 0 && previousLevel < newLevel { + return "upgrade" + } + return "purchase" +} + +func (r *Repository) getVipLevelForUpdate(ctx context.Context, tx *sql.Tx, level int32) (ledger.VipLevel, error) { + item, err := r.scanVipLevel(tx.QueryRowContext(ctx, ` + SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, + required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms + FROM vip_levels + WHERE app_code = ? AND level = ? + FOR UPDATE`, + appcode.FromContext(ctx), level, + )) + if errors.Is(err, sql.ErrNoRows) { + return ledger.VipLevel{}, xerr.New(xerr.VIPLevelNotFound, "vip level not found") + } + return item, err +} + +func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, command vipRewardGrantCommand, level ledger.VipLevel, expiresAtMS int64, nowMs int64) ([]ledger.VipRewardItem, string, error) { + if level.RewardResourceGroupID <= 0 { + return nil, "", nil + } + group, err := r.getResourceGroup(ctx, tx, level.RewardResourceGroupID, true) + if err != nil { + return nil, "", err + } + if group.Status != resourcedomain.StatusActive { + return nil, "", xerr.New(xerr.Conflict, "vip reward group is disabled") + } + group.Items, err = r.listResourceGroupItemsTx(ctx, tx, level.RewardResourceGroupID, true) + if err != nil { + return nil, "", err + } + groupSnapshot, err := json.Marshal(group) + if err != nil { + return nil, "", err + } + rewardCommandID := "vip_reward:" + command.CommandID + grantID := resourceGrantID(command.AppCode, rewardCommandID) + source := ledger.NormalizeVipGrantSource(command.Source) + if source == "" { + source = ledger.VipGrantSourcePurchase + } + reason := strings.TrimSpace(command.Reason) + if reason == "" { + reason = source + } + operatorUserID := command.OperatorUserID + if operatorUserID <= 0 { + operatorUserID = command.UserID + } + if err := r.insertResourceGrant(ctx, tx, grantID, rewardCommandID, command.UserID, source, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", level.RewardResourceGroupID), stableHash(string(groupSnapshot)), string(groupSnapshot), reason, operatorUserID, nowMs); err != nil { + return nil, "", err + } + durationMS := expiresAtMS - nowMs + rewards := make([]ledger.VipRewardItem, 0, len(group.Items)) + for _, item := range group.Items { + if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset { + inserted, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item, nowMs) + if err != nil { + return nil, "", err + } + rewards = append(rewards, ledger.VipRewardItem{Quantity: inserted.Quantity, ExpiresAtMS: 0}) + continue + } + if err := validateActiveResourceGroupResource(item.Resource); err != nil { + return nil, "", err + } + inserted, err := r.applyGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item.Resource, item.Quantity, durationMS, nowMs) + if err != nil { + return nil, "", err + } + if inserted.EntitlementID != "" { + if err := r.alignEntitlementExpiresAt(ctx, tx, inserted.EntitlementID, expiresAtMS, nowMs); err != nil { + return nil, "", err + } + } + rewards = append(rewards, ledger.VipRewardItem{ + ResourceID: item.Resource.ResourceID, + ResourceCode: item.Resource.ResourceCode, + ResourceType: item.Resource.ResourceType, + Name: item.Resource.Name, + Quantity: inserted.Quantity, + ExpiresAtMS: expiresAtMS, + AssetURL: item.Resource.AssetURL, + PreviewURL: item.Resource.PreviewURL, + AnimationURL: item.Resource.AnimationURL, + }) + } + if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("ResourceGroupGranted", rewardCommandID, command.UserID, level.RewardResourceGroupID, map[string]any{"grant_id": grantID, "source": source}, nowMs), + }); err != nil { + return nil, "", err + } + return rewards, grantID, nil +} + +func vipActivatedEvent(transactionID string, commandID string, userID int64, source string, action string, previousLevel int32, vip ledger.UserVip, rewardGroupID int64, resourceGrantID string, rewardItems []ledger.VipRewardItem, nowMs int64) walletOutboxEvent { + payload := map[string]any{ + "event_type": "VipActivated", + "transaction_id": transactionID, + "command_id": commandID, + "user_id": userID, + "source": source, + "action": action, + "previous_level": previousLevel, + "level": vip.Level, + "vip_name": vip.Name, + "started_at_ms": vip.StartedAtMS, + "expires_at_ms": vip.ExpiresAtMS, + "reward_resource_group_id": rewardGroupID, + "resource_grant_id": resourceGrantID, + "reward_items": vipRewardItemsNoticePayload(rewardItems), + "created_at_ms": nowMs, + } + return walletOutboxEvent{ + EventID: eventID(transactionID, "VipActivated", userID, vipOutboxAsset), + EventType: "VipActivated", + TransactionID: transactionID, + CommandID: commandID, + UserID: userID, + AssetType: vipOutboxAsset, + AvailableDelta: 0, + FrozenDelta: 0, + Payload: payload, + CreatedAtMS: nowMs, + } +} + +func vipRewardItemsNoticePayload(items []ledger.VipRewardItem) []map[string]any { + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, map[string]any{ + "resource_id": item.ResourceID, + "resource_code": item.ResourceCode, + "resource_type": item.ResourceType, + "name": item.Name, + "quantity": item.Quantity, + "expires_at_ms": item.ExpiresAtMS, + "asset_url": item.AssetURL, + "preview_url": item.PreviewURL, + "animation_url": item.AnimationURL, + }) + } + return payload +} + +func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (ledger.PurchaseVipReceipt, error) { + var orderID string + var level int32 + var priceCoin int64 + var grantID string + err := tx.QueryRowContext(ctx, ` + SELECT order_id, target_level, price_coin, resource_grant_id + FROM vip_purchase_orders + WHERE app_code = ? AND wallet_transaction_id = ?`, + appcode.FromContext(ctx), transactionID, + ).Scan(&orderID, &level, &priceCoin, &grantID) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + vip, err := r.queryUserVip(ctx, tx, userID, time.Now().UnixMilli(), false) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, vip.ExpiresAtMS) + if err != nil { + return ledger.PurchaseVipReceipt{}, err + } + return ledger.PurchaseVipReceipt{ + OrderID: orderID, + TransactionID: transactionID, + Vip: vip, + CoinSpent: priceCoin, + CoinBalanceAfter: balance.AvailableAmount, + RewardItems: rewards, + }, nil +} diff --git a/services/wallet-service/internal/storage/mysql/vip_query_repository.go b/services/wallet-service/internal/storage/mysql/vip_query_repository.go new file mode 100644 index 00000000..2dc831ef --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/vip_query_repository.go @@ -0,0 +1,251 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + "time" +) + +// GetMyVip 返回用户当前 VIP 状态;过期只影响 active 投影,不直接修改历史事实。 +func (r *Repository) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) { + if r == nil || r.db == nil { + return ledger.UserVip{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + vip, err := r.queryUserVip(ctx, r.db, userID, time.Now().UnixMilli(), false) + if errors.Is(err, sql.ErrNoRows) { + return ledger.UserVip{UserID: userID}, nil + } + return vip, err +} + +// ListVipPackages 返回当前会员和全部 active VIP 包。 +func (r *Repository) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) { + if r == nil || r.db == nil { + return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + nowMs := time.Now().UnixMilli() + current, err := r.GetMyVip(ctx, userID) + if err != nil { + return ledger.UserVip{}, nil, err + } + totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, r.db, userID) + if err != nil { + return ledger.UserVip{}, nil, err + } + levels, err := r.listVipLevels(ctx, nowMs, current.Level, totalRechargeCoin) + return current, levels, err +} + +// ListAdminVipLevels 返回后台配置页所需的全部 VIP 等级。 +func (r *Repository) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + rows, err := r.db.QueryContext(ctx, ` + SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, + required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms + FROM vip_levels + WHERE app_code = ? + ORDER BY level ASC`, + appcode.FromContext(ctx), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + levels := make([]ledger.VipLevel, 0, 10) + for rows.Next() { + level, err := r.scanVipLevel(rows) + if err != nil { + return nil, err + } + if level.RewardResourceGroupID > 0 { + level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, time.Now().UnixMilli()+level.DurationMS) + if err != nil { + return nil, err + } + } + levels = append(levels, level) + } + return levels, rows.Err() +} + +func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLevel int32, totalRechargeCoin int64) ([]ledger.VipLevel, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, + required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms + FROM vip_levels + WHERE app_code = ? AND status = ? + ORDER BY sort_order ASC, level ASC`, + appcode.FromContext(ctx), ledger.VipStatusActive, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + levels := make([]ledger.VipLevel, 0) + for rows.Next() { + level, err := r.scanVipLevel(rows) + if err != nil { + return nil, err + } + level.UserRechargeCoinAmount = totalRechargeCoin + level.RechargeGateRequired = level.Level >= 6 + level.CanPurchase = currentLevel == 0 || level.Level >= currentLevel + if !level.CanPurchase { + level.PurchaseLockedReason = "current_vip_higher" + } + if level.CanPurchase && level.RechargeGateRequired && totalRechargeCoin < level.RequiredRechargeCoinAmount { + level.CanPurchase = false + level.PurchaseLockedReason = "recharge_required" + } + level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, nowMs+level.DurationMS) + if err != nil { + return nil, err + } + levels = append(levels, level) + } + return levels, rows.Err() +} + +func (r *Repository) scanVipLevel(scanner scanTarget) (ledger.VipLevel, error) { + var level ledger.VipLevel + err := scanner.Scan( + &level.Level, + &level.Name, + &level.Status, + &level.PriceCoin, + &level.DurationMS, + &level.RewardResourceGroupID, + &level.RequiredRechargeCoinAmount, + &level.SortOrder, + &level.CreatedAtMS, + &level.UpdatedAtMS, + ) + level.RechargeGateRequired = level.Level >= 6 + return level, err +} + +func (r *Repository) userRechargeCoinAmount(ctx context.Context, querier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +}, userID int64) (int64, error) { + var total int64 + err := querier.QueryRowContext(ctx, ` + SELECT total_coin_amount + FROM wallet_user_recharge_stats + WHERE app_code = ? AND user_id = ?`, + appcode.FromContext(ctx), userID, + ).Scan(&total) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return total, err +} + +func (r *Repository) ensureUserVipForUpdate(ctx context.Context, tx *sql.Tx, userID int64, nowMs int64) (ledger.UserVip, error) { + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO user_vip_memberships ( + app_code, user_id, level, name, status, started_at_ms, expires_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, 0, '', 'inactive', 0, 0, ?, ?)`, + appcode.FromContext(ctx), userID, nowMs, nowMs, + ); err != nil { + return ledger.UserVip{}, err + } + return r.queryUserVip(ctx, tx, userID, nowMs, true) +} + +func (r *Repository) queryUserVip(ctx context.Context, querier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +}, userID int64, nowMs int64, lock bool) (ledger.UserVip, error) { + query := ` + SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms + FROM user_vip_memberships m + LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level + WHERE m.app_code = ? AND m.user_id = ?` + if lock { + query += ` FOR UPDATE` + } + var vip ledger.UserVip + if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&vip.UserID, &vip.Level, &vip.Name, &vip.StartedAtMS, &vip.ExpiresAtMS, &vip.UpdatedAtMS); err != nil { + return ledger.UserVip{}, err + } + vip.Active = vip.Level > 0 && vip.ExpiresAtMS > nowMs + if !vip.Active { + vip.Level = 0 + vip.Name = "" + } + return vip, nil +} + +func (r *Repository) vipRewardItems(ctx context.Context, groupID int64, expiresAtMS int64) ([]ledger.VipRewardItem, error) { + if groupID <= 0 { + return nil, nil + } + items, err := r.listResourceGroupItemsWithQuery(ctx, r.db, groupID) + if err != nil { + return nil, err + } + rewards := make([]ledger.VipRewardItem, 0, len(items)) + for _, item := range items { + if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource { + continue + } + rewards = append(rewards, ledger.VipRewardItem{ + ResourceID: item.Resource.ResourceID, + ResourceCode: item.Resource.ResourceCode, + ResourceType: item.Resource.ResourceType, + Name: item.Resource.Name, + Quantity: item.Quantity, + ExpiresAtMS: expiresAtMS, + AssetURL: item.Resource.AssetURL, + PreviewURL: item.Resource.PreviewURL, + AnimationURL: item.Resource.AnimationURL, + }) + } + return rewards, nil +} + +func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID string, expiresAtMS int64) ([]ledger.VipRewardItem, error) { + if grantID == "" { + return nil, nil + } + rows, err := tx.QueryContext(ctx, ` + SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity, + COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, '') + FROM resource_grant_items i + JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id + WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ? + ORDER BY i.grant_item_id ASC`, + appcode.FromContext(ctx), grantID, resourcedomain.ResultEntitlement, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]ledger.VipRewardItem, 0) + for rows.Next() { + var item ledger.VipRewardItem + if err := rows.Scan( + &item.ResourceID, + &item.ResourceCode, + &item.ResourceType, + &item.Name, + &item.Quantity, + &item.AssetURL, + &item.PreviewURL, + &item.AnimationURL, + ); err != nil { + return nil, err + } + item.ExpiresAtMS = expiresAtMS + items = append(items, item) + } + return items, rows.Err() +} diff --git a/services/wallet-service/internal/storage/mysql/vip_types.go b/services/wallet-service/internal/storage/mysql/vip_types.go new file mode 100644 index 00000000..3f8e5c68 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/vip_types.go @@ -0,0 +1,36 @@ +package mysql + +import ( + "hyapp/services/wallet-service/internal/domain/ledger" +) + +type vipActivationCommand struct { + AppCode string + CommandID string + UserID int64 + Source string + Reason string + OperatorUserID int64 + TransactionID string + RecordID string + Level ledger.VipLevel + Current ledger.UserVip + NowMS int64 +} + +type vipActivationResult struct { + VIP ledger.UserVip + PreviousLevel int32 + Action string + ResourceGrantID string + RewardItems []ledger.VipRewardItem +} + +type vipRewardGrantCommand struct { + AppCode string + CommandID string + UserID int64 + Source string + Reason string + OperatorUserID int64 +} diff --git a/services/wallet-service/internal/storage/mysql/wallet_query_repository.go b/services/wallet-service/internal/storage/mysql/wallet_query_repository.go new file mode 100644 index 00000000..f57d9400 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/wallet_query_repository.go @@ -0,0 +1,190 @@ +package mysql + +import ( + "context" + "encoding/json" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" + "strings" +) + +// GetWalletOverview 返回钱包首页摘要;开关放在 wallet-service 内,避免 gateway 硬编码账务能力。 +func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) { + + balances, err := r.GetBalances(ctx, userID, []string{ + ledger.AssetCoin, + ledger.AssetHostSalaryUSD, + ledger.AssetAgencySalaryUSD, + ledger.AssetBDSalaryUSD, + ledger.AssetAdminSalaryUSD, + }) + if err != nil { + return ledger.WalletOverview{}, err + } + return ledger.WalletOverview{ + Balances: balances, + FeatureFlags: ledger.WalletFeatureFlags{ + RechargeEnabled: true, + DiamondExchangeEnabled: false, + }, + }, nil +} + +// GetWalletValueSummary 返回我的页钱包卡片需要的最小金币余额。 +func (r *Repository) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) { + balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin}) + if err != nil { + return ledger.WalletValueSummary{}, err + } + if len(balances) == 0 { + return ledger.WalletValueSummary{}, nil + } + return ledger.WalletValueSummary{ + CoinAmount: balances[0].AvailableAmount, + UpdatedAtMS: balances[0].UpdatedAtMs, + }, nil +} + +// GetUserGiftWall 读取送礼事务维护的收礼聚合表;它只表达已扣费成功的礼物事实。 +func (r *Repository) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) { + if r == nil || r.db == nil { + return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + appCode := appcode.FromContext(ctx) + rows, err := r.db.QueryContext(ctx, ` + SELECT gift_id, gift_name, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'), + gift_type_code, COALESCE(CAST(presentation_json AS CHAR), '{}'), gift_count, + total_value, total_coin_value, total_diamond_value, total_gift_point, total_heat_value, + charge_asset_type, last_price_version, first_received_at_ms, last_received_at_ms, sort_order + FROM user_gift_wall + WHERE app_code = ? AND user_id = ? + ORDER BY sort_order ASC, total_value DESC, last_received_at_ms DESC, gift_id ASC`, + appCode, userID, + ) + if err != nil { + return ledger.UserGiftWall{}, err + } + defer rows.Close() + + wall := ledger.UserGiftWall{Items: make([]ledger.GiftWallItem, 0)} + for rows.Next() { + var item ledger.GiftWallItem + if err := rows.Scan( + &item.GiftID, + &item.GiftName, + &item.ResourceID, + &item.ResourceSnapshotJSON, + &item.GiftTypeCode, + &item.PresentationJSON, + &item.GiftCount, + &item.TotalValue, + &item.TotalCoinValue, + &item.TotalDiamondValue, + &item.TotalGiftPoint, + &item.TotalHeatValue, + &item.ChargeAssetType, + &item.LastPriceVersion, + &item.FirstReceivedAtMS, + &item.LastReceivedAtMS, + &item.SortOrder, + ); err != nil { + return ledger.UserGiftWall{}, err + } + item.Resource = giftWallResourceFromJSON(item.ResourceSnapshotJSON) + wall.Items = append(wall.Items, item) + wall.GiftKindCount++ + wall.GiftTotalCount += item.GiftCount + wall.TotalValue += item.TotalValue + wall.TotalCoinValue += item.TotalCoinValue + wall.TotalDiamondValue += item.TotalDiamondValue + wall.TotalGiftPoint += item.TotalGiftPoint + wall.TotalHeatValue += item.TotalHeatValue + } + if err := rows.Err(); err != nil { + return ledger.UserGiftWall{}, err + } + return wall, nil +} + +func giftWallResourceFromJSON(raw string) ledger.GiftWallResource { + var resource ledger.GiftWallResource + if strings.TrimSpace(raw) == "" { + return resource + } + + _ = json.Unmarshal([]byte(raw), &resource) + return resource +} + +// GetDiamondExchangeConfig 返回后台配置的钻石兑换规则,未配置时返回空列表而不是 gateway 默认值。 +func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]ledger.DiamondExchangeRule, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + return []ledger.DiamondExchangeRule{}, nil +} + +// ListWalletTransactions 分页读取当前用户钱包分录,并带出交易业务类型。 +func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) { + if r == nil || r.db == nil { + return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + ctx = contextWithCommandApp(ctx, query.AppCode) + query.AppCode = appcode.FromContext(ctx) + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + where := `WHERE e.app_code = ? AND e.user_id = ?` + args := []any{query.AppCode, query.UserID} + if strings.TrimSpace(query.AssetType) != "" { + where += ` AND e.asset_type = ?` + args = append(args, query.AssetType) + } + + var total int64 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM wallet_entries e + `+where, + args..., + ).Scan(&total); err != nil { + return nil, 0, err + } + + rows, err := r.db.QueryContext(ctx, ` + SELECT e.entry_id, e.transaction_id, wt.biz_type, e.asset_type, + e.available_delta, e.frozen_delta, e.available_after, e.frozen_after, + e.counterparty_user_id, e.room_id, e.created_at_ms + FROM wallet_entries e + JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id + `+where+` + ORDER BY e.created_at_ms DESC, e.entry_id DESC + LIMIT ? OFFSET ?`, + append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]ledger.WalletTransaction, 0, query.PageSize) + for rows.Next() { + var item ledger.WalletTransaction + if err := rows.Scan( + &item.EntryID, + &item.TransactionID, + &item.BizType, + &item.AssetType, + &item.AvailableDelta, + &item.FrozenDelta, + &item.AvailableAfter, + &item.FrozenAfter, + &item.CounterpartyUserID, + &item.RoomID, + &item.CreatedAtMS, + ); err != nil { + return nil, 0, err + } + items = append(items, item) + } + return items, total, rows.Err() +} diff --git a/services/wallet-service/internal/transport/grpc/admin_ledger.go b/services/wallet-service/internal/transport/grpc/admin_ledger.go new file mode 100644 index 00000000..81fe01af --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/admin_ledger.go @@ -0,0 +1,39 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// AdminCreditAsset 处理后台手动调账,公网权限和审计入口由 gateway/admin 层控制。 +func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + balance, transactionID, err := s.svc.AdminCreditAsset(ctx, ledger.AdminCreditAssetCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + AssetType: req.GetAssetType(), + Amount: req.GetAmount(), + OperatorUserID: req.GetOperatorUserId(), + Reason: req.GetReason(), + EvidenceRef: req.GetEvidenceRef(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.AdminCreditAssetResponse{ + TransactionId: transactionID, + Balance: &walletv1.AssetBalance{ + AppCode: balance.AppCode, + AssetType: balance.AssetType, + AvailableAmount: balance.AvailableAmount, + FrozenAmount: balance.FrozenAmount, + Version: balance.Version, + }, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/balance.go b/services/wallet-service/internal/transport/grpc/balance.go new file mode 100644 index 00000000..5e4b8c59 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/balance.go @@ -0,0 +1,208 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// GetBalances 返回用户多资产余额投影。 +func (s *Server) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + balances, err := s.svc.GetBalances(ctx, req.GetUserId(), req.GetAssetTypes()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + response := &walletv1.GetBalancesResponse{Balances: make([]*walletv1.AssetBalance, 0, len(balances))} + for _, balance := range balances { + response.Balances = append(response.Balances, &walletv1.AssetBalance{ + AppCode: balance.AppCode, + AssetType: balance.AssetType, + AvailableAmount: balance.AvailableAmount, + FrozenAmount: balance.FrozenAmount, + Version: balance.Version, + }) + } + + return response, nil +} + +// GetWalletOverview 返回钱包首页摘要,供我的页和钱包二级页复用。 +func (s *Server) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + overview, err := s.svc.GetWalletOverview(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.GetWalletOverviewResponse{ + Balances: balancesToProto(overview.Balances), + FeatureFlags: walletFeatureFlagsToProto(overview.FeatureFlags), + }, nil +} + +// GetWalletValueSummary 返回我的页钱包卡片最小金币余额。 +func (s *Server) GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + summary, err := s.svc.GetWalletValueSummary(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{ + CoinAmount: summary.CoinAmount, + UpdatedAtMs: summary.UpdatedAtMS, + }}, nil +} + +// GetUserGiftWall 返回当前用户收到礼物的聚合墙;调用方负责限定 user_id 为当前登录用户。 +func (s *Server) GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + wall, err := s.svc.GetUserGiftWall(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.GetUserGiftWallResponse{ + Items: make([]*walletv1.GiftWallItem, 0, len(wall.Items)), + GiftKindCount: wall.GiftKindCount, + GiftTotalCount: wall.GiftTotalCount, + TotalValue: wall.TotalValue, + TotalCoinValue: wall.TotalCoinValue, + TotalDiamondValue: wall.TotalDiamondValue, + TotalGiftPoint: wall.TotalGiftPoint, + TotalHeatValue: wall.TotalHeatValue, + } + for _, item := range wall.Items { + resp.Items = append(resp.Items, giftWallItemToProto(item)) + } + return resp, nil +} + +// GetDiamondExchangeConfig 返回钻石兑换配置。 +func (s *Server) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + rules, err := s.svc.GetDiamondExchangeConfig(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.GetDiamondExchangeConfigResponse{Rules: make([]*walletv1.DiamondExchangeRule, 0, len(rules))} + for _, rule := range rules { + resp.Rules = append(resp.Rules, &walletv1.DiamondExchangeRule{ + ExchangeType: rule.ExchangeType, + FromAssetType: rule.FromAssetType, + ToAssetType: rule.ToAssetType, + FromAmount: rule.FromAmount, + ToAmount: rule.ToAmount, + Enabled: rule.Enabled, + }) + } + return resp, nil +} + +// ListWalletTransactions 返回当前用户钱包流水。 +func (s *Server) ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + items, total, err := s.svc.ListWalletTransactions(ctx, ledger.ListWalletTransactionsQuery{ + AppCode: req.GetAppCode(), + UserID: req.GetUserId(), + AssetType: req.GetAssetType(), + Page: req.GetPage(), + PageSize: req.GetPageSize(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListWalletTransactionsResponse{Transactions: make([]*walletv1.WalletTransaction, 0, len(items)), Total: total} + for _, item := range items { + resp.Transactions = append(resp.Transactions, &walletv1.WalletTransaction{ + EntryId: item.EntryID, + TransactionId: item.TransactionID, + BizType: item.BizType, + AssetType: item.AssetType, + AvailableDelta: item.AvailableDelta, + FrozenDelta: item.FrozenDelta, + AvailableAfter: item.AvailableAfter, + FrozenAfter: item.FrozenAfter, + CounterpartyUserId: item.CounterpartyUserID, + RoomId: item.RoomID, + CreatedAtMs: item.CreatedAtMS, + }) + } + return resp, nil +} + +func balancesToProto(balances []ledger.AssetBalance) []*walletv1.AssetBalance { + items := make([]*walletv1.AssetBalance, 0, len(balances)) + for _, balance := range balances { + items = append(items, balanceToProto(balance)) + } + return items +} + +func balanceToProto(balance ledger.AssetBalance) *walletv1.AssetBalance { + return &walletv1.AssetBalance{ + AppCode: balance.AppCode, + AssetType: balance.AssetType, + AvailableAmount: balance.AvailableAmount, + FrozenAmount: balance.FrozenAmount, + Version: balance.Version, + } +} + +func giftWallItemToProto(item ledger.GiftWallItem) *walletv1.GiftWallItem { + return &walletv1.GiftWallItem{ + GiftId: item.GiftID, + GiftName: item.GiftName, + ResourceId: item.ResourceID, + Resource: giftWallResourceToProto(item.Resource), + ResourceSnapshotJson: item.ResourceSnapshotJSON, + GiftTypeCode: item.GiftTypeCode, + PresentationJson: item.PresentationJSON, + GiftCount: item.GiftCount, + TotalValue: item.TotalValue, + TotalCoinValue: item.TotalCoinValue, + TotalDiamondValue: item.TotalDiamondValue, + TotalGiftPoint: item.TotalGiftPoint, + TotalHeatValue: item.TotalHeatValue, + ChargeAssetType: item.ChargeAssetType, + LastPriceVersion: item.LastPriceVersion, + FirstReceivedAtMs: item.FirstReceivedAtMS, + LastReceivedAtMs: item.LastReceivedAtMS, + SortOrder: item.SortOrder, + } +} + +func giftWallResourceToProto(resource ledger.GiftWallResource) *walletv1.Resource { + if resource.ResourceID == 0 { + return nil + } + return &walletv1.Resource{ + AppCode: resource.AppCode, + ResourceId: resource.ResourceID, + ResourceCode: resource.ResourceCode, + ResourceType: resource.ResourceType, + Name: resource.Name, + Status: resource.Status, + Grantable: resource.Grantable, + GrantStrategy: resource.GrantStrategy, + WalletAssetType: resource.WalletAssetType, + WalletAssetAmount: resource.WalletAssetAmount, + UsageScopes: resource.UsageScopes, + AssetUrl: resource.AssetURL, + PreviewUrl: resource.PreviewURL, + AnimationUrl: resource.AnimationURL, + MetadataJson: resource.MetadataJSON, + SortOrder: resource.SortOrder, + CreatedAtMs: resource.CreatedAtMS, + UpdatedAtMs: resource.UpdatedAtMS, + } +} + +func walletFeatureFlagsToProto(flags ledger.WalletFeatureFlags) *walletv1.WalletFeatureFlags { + return &walletv1.WalletFeatureFlags{ + RechargeEnabled: flags.RechargeEnabled, + DiamondExchangeEnabled: flags.DiamondExchangeEnabled, + } +} diff --git a/services/wallet-service/internal/transport/grpc/coin_seller.go b/services/wallet-service/internal/transport/grpc/coin_seller.go new file mode 100644 index 00000000..8900efc7 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/coin_seller.go @@ -0,0 +1,151 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// AdminCreditCoinSellerStock 处理后台币商进货或金币补偿,币商身份由 admin-server 先行校验。 +func (s *Server) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.AdminCreditCoinSellerStock(ctx, ledger.CoinSellerStockCreditCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + SellerUserID: req.GetSellerUserId(), + SellerCountryID: req.GetSellerCountryId(), + SellerRegionID: req.GetSellerRegionId(), + StockType: req.GetStockType(), + CoinAmount: req.GetCoinAmount(), + PaidCurrencyCode: req.GetPaidCurrencyCode(), + PaidAmountMicro: req.GetPaidAmountMicro(), + PaymentRef: req.GetPaymentRef(), + EvidenceRef: req.GetEvidenceRef(), + OperatorUserID: req.GetOperatorUserId(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.AdminCreditCoinSellerStockResponse{ + TransactionId: receipt.TransactionID, + SellerUserId: receipt.SellerUserID, + StockType: receipt.StockType, + CoinAmount: receipt.CoinAmount, + PaidCurrencyCode: receipt.PaidCurrencyCode, + PaidAmountMicro: receipt.PaidAmountMicro, + CountsAsSellerRecharge: receipt.CountsAsSellerRecharge, + BalanceAfter: receipt.BalanceAfter, + CreatedAtMs: receipt.CreatedAtMS, + }, nil +} + +// TransferCoinFromSeller 处理币商转玩家金币,调用方必须先完成币商身份校验。 +func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + SellerUserID: req.GetSellerUserId(), + TargetUserID: req.GetTargetUserId(), + TargetCountryID: req.GetTargetCountryId(), + SellerRegionID: req.GetSellerRegionId(), + TargetRegionID: req.GetTargetRegionId(), + Amount: req.GetAmount(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.TransferCoinFromSellerResponse{ + TransactionId: receipt.TransactionID, + SellerBalanceAfter: receipt.SellerBalanceAfter, + TargetBalanceAfter: receipt.TargetBalanceAfter, + Amount: receipt.Amount, + RechargeUsdMinor: receipt.RechargeUSDMinor, + RechargeCurrencyCode: receipt.RechargeCurrencyCode, + RechargePolicyId: receipt.RechargePolicyID, + RechargePolicyVersion: receipt.RechargePolicyVersion, + RechargePolicyCoinAmount: receipt.RechargePolicyCoinAmount, + RechargePolicyUsdMinorAmount: receipt.RechargePolicyUSDMinorUnit, + }, nil +} + +// ListCoinSellerSalaryExchangeRateTiers 返回工资转币商使用的区域比例区间,调用方用它做预计到账展示。 +func (s *Server) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + tiers, err := s.svc.ListCoinSellerSalaryExchangeRateTiers(ctx, req.GetAppCode(), req.GetRegionId(), req.GetIncludeDisabled()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + response := &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{Tiers: make([]*walletv1.CoinSellerSalaryExchangeRateTier, 0, len(tiers))} + for _, tier := range tiers { + response.Tiers = append(response.Tiers, &walletv1.CoinSellerSalaryExchangeRateTier{ + RegionId: tier.RegionID, + MinUsdMinor: tier.MinUSDMinor, + MaxUsdMinor: tier.MaxUSDMinor, + CoinPerUsd: tier.CoinPerUSD, + Status: tier.Status, + SortOrder: int32(tier.SortOrder), + UpdatedAtMs: tier.UpdatedAtMS, + }) + } + return response, nil +} + +// ExchangeSalaryToCoin 处理用户工资兑换普通金币,身份资产已经由 gateway 校验并注入。 +func (s *Server) ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.ExchangeSalaryToCoin(ctx, ledger.SalaryExchangeCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + SalaryAssetType: req.GetSalaryAssetType(), + SalaryUSDMinor: req.GetSalaryUsdMinor(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.ExchangeSalaryToCoinResponse{ + TransactionId: receipt.TransactionID, + SalaryBalanceAfter: receipt.SalaryBalanceAfter, + CoinBalanceAfter: receipt.CoinBalanceAfter, + SalaryUsdMinor: receipt.SalaryUSDMinor, + CoinAmount: receipt.CoinAmount, + CoinPerUsd: receipt.CoinPerUSD, + }, nil +} + +// TransferSalaryToCoinSeller 处理用户工资转同区域币商专用金币库存。 +func (s *Server) TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.TransferSalaryToCoinSeller(ctx, ledger.SalaryTransferToCoinSellerCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + SourceUserID: req.GetSourceUserId(), + SellerUserID: req.GetSellerUserId(), + SalaryAssetType: req.GetSalaryAssetType(), + SalaryUSDMinor: req.GetSalaryUsdMinor(), + RegionID: req.GetRegionId(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.TransferSalaryToCoinSellerResponse{ + TransactionId: receipt.TransactionID, + SourceSalaryBalanceAfter: receipt.SourceSalaryBalanceAfter, + SellerBalanceAfter: receipt.SellerBalanceAfter, + SalaryUsdMinor: receipt.SalaryUSDMinor, + CoinAmount: receipt.CoinAmount, + CoinPerUsd: receipt.CoinPerUSD, + RateMinUsdMinor: receipt.RateMinUSDMinor, + RateMaxUsdMinor: receipt.RateMaxUSDMinor, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/external_recharge.go b/services/wallet-service/internal/transport/grpc/external_recharge.go new file mode 100644 index 00000000..aa7f0ea8 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/external_recharge.go @@ -0,0 +1,242 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ListThirdPartyPaymentChannels 返回三方支付渠道、国家和方式树,后台用它直接渲染二级展开列表。 +func (s *Server) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + channels, err := s.svc.ListThirdPartyPaymentChannels(ctx, ledger.ListThirdPartyPaymentChannelsQuery{ + AppCode: req.GetAppCode(), + ProviderCode: req.GetProviderCode(), + Status: req.GetStatus(), + IncludeDisabledMethods: req.GetIncludeDisabledMethods(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListThirdPartyPaymentChannelsResponse{Channels: make([]*walletv1.ThirdPartyPaymentChannel, 0, len(channels))} + for _, channel := range channels { + resp.Channels = append(resp.Channels, thirdPartyPaymentChannelToProto(channel)) + } + return resp, nil +} + +// SetThirdPartyPaymentMethodStatus 开关某个国家下的具体支付方式;渠道本身不被连带关闭。 +func (s *Server) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + method, err := s.svc.SetThirdPartyPaymentMethodStatus(ctx, ledger.ThirdPartyPaymentMethodStatusCommand{ + AppCode: req.GetAppCode(), + MethodID: req.GetMethodId(), + Enabled: req.GetEnabled(), + OperatorUserID: req.GetOperatorUserId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.ThirdPartyPaymentMethodResponse{Method: thirdPartyPaymentMethodToProto(method)}, nil +} + +// UpdateThirdPartyPaymentRate 保存 USD 到国家本币汇率;H5 只展示已配置正汇率的国家方式。 +func (s *Server) UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + method, err := s.svc.UpdateThirdPartyPaymentRate(ctx, ledger.ThirdPartyPaymentRateCommand{ + AppCode: req.GetAppCode(), + MethodID: req.GetMethodId(), + USDToCurrencyRate: req.GetUsdToCurrencyRate(), + OperatorUserID: req.GetOperatorUserId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.ThirdPartyPaymentMethodResponse{Method: thirdPartyPaymentMethodToProto(method)}, nil +} + +// ListH5RechargeOptions 返回 H5 当前账号的商品和支付方式,普通用户和币商由 audience_type 分流。 +func (s *Server) ListH5RechargeOptions(ctx context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + options, err := s.svc.ListH5RechargeOptions(ctx, ledger.H5RechargeOptionsQuery{ + AppCode: req.GetAppCode(), + TargetUserID: req.GetTargetUserId(), + TargetRegionID: req.GetTargetRegionId(), + TargetCountryCode: req.GetTargetCountryCode(), + AudienceType: req.GetAudienceType(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.H5RechargeOptionsResponse{ + Products: make([]*walletv1.RechargeProduct, 0, len(options.Products)), + PaymentMethods: make([]*walletv1.ThirdPartyPaymentMethod, 0, len(options.PaymentMethods)), + UsdtTrc20Enabled: options.USDTTRC20Enabled, + UsdtTrc20Address: options.USDTTRC20Address, + } + for _, product := range options.Products { + resp.Products = append(resp.Products, rechargeProductToProto(product)) + } + for _, method := range options.PaymentMethods { + resp.PaymentMethods = append(resp.PaymentMethods, thirdPartyPaymentMethodToProto(method)) + } + return resp, nil +} + +// CreateH5RechargeOrder 创建外部充值订单;MiFaPay 只返回 pay_url,USDT 只返回共享收款地址。 +func (s *Server) CreateH5RechargeOrder(ctx context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + order, err := s.svc.CreateH5RechargeOrder(ctx, ledger.CreateExternalRechargeOrderCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + TargetRegionID: req.GetTargetRegionId(), + TargetCountryCode: req.GetTargetCountryCode(), + AudienceType: req.GetAudienceType(), + ProductID: req.GetProductId(), + ProviderCode: req.GetProviderCode(), + PaymentMethodID: req.GetPaymentMethodId(), + ReturnURL: req.GetReturnUrl(), + NotifyURL: req.GetNotifyUrl(), + ClientIP: req.GetClientIp(), + Language: req.GetLanguage(), + PayerName: req.GetPayerName(), + PayerAccount: req.GetPayerAccount(), + PayerEmail: req.GetPayerEmail(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil +} + +// SubmitH5RechargeTx 接收 USDT tx_hash;链上校验和幂等入账仍在 wallet-service 事务里完成。 +func (s *Server) SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + order, err := s.svc.SubmitH5RechargeTx(ctx, ledger.SubmitExternalRechargeTxCommand{ + AppCode: req.GetAppCode(), + OrderID: req.GetOrderId(), + TargetUserID: req.GetTargetUserId(), + TxHash: req.GetTxHash(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil +} + +// GetH5RechargeOrder 给 H5 轮询订单状态;target_user_id 用于避免无 token 场景越权看单。 +func (s *Server) GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + order, err := s.svc.GetH5RechargeOrder(ctx, req.GetAppCode(), req.GetOrderId(), req.GetTargetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil +} + +// HandleMifapayNotify 验签并处理 MiFaPay 回调;成功或重复成功由 HTTP 层按 response_text 返回大写 SUCCESS。 +func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + order, accepted, err := s.svc.HandleMifapayNotify(ctx, req.GetAppCode(), ledger.MifaPayNotification{ + MerAccount: req.GetMerAccount(), + Data: req.GetData(), + Sign: req.GetSign(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + responseText := "FAIL" + if accepted { + responseText = "SUCCESS" + } + return &walletv1.HandleMifapayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil +} + +// HandleV5PayNotify 验签并处理 V5Pay 回调;成功或重复成功由 HTTP 层按 response_text 返回小写 success。 +func (s *Server) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + order, accepted, err := s.svc.HandleV5PayNotify(ctx, req.GetAppCode(), ledger.V5PayNotification{ + Fields: req.GetFields(), + RawJSON: req.GetRawJson(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + responseText := "fail" + if accepted { + responseText = "success" + } + return &walletv1.HandleV5PayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil +} + +func thirdPartyPaymentChannelToProto(channel ledger.ThirdPartyPaymentChannel) *walletv1.ThirdPartyPaymentChannel { + resp := &walletv1.ThirdPartyPaymentChannel{ + AppCode: channel.AppCode, + ProviderCode: channel.ProviderCode, + ProviderName: channel.ProviderName, + Status: channel.Status, + SortOrder: channel.SortOrder, + Methods: make([]*walletv1.ThirdPartyPaymentMethod, 0, len(channel.Methods)), + } + for _, method := range channel.Methods { + resp.Methods = append(resp.Methods, thirdPartyPaymentMethodToProto(method)) + } + return resp +} + +func thirdPartyPaymentMethodToProto(method ledger.ThirdPartyPaymentMethod) *walletv1.ThirdPartyPaymentMethod { + return &walletv1.ThirdPartyPaymentMethod{ + MethodId: method.MethodID, + AppCode: method.AppCode, + ProviderCode: method.ProviderCode, + ProviderName: method.ProviderName, + CountryCode: method.CountryCode, + CountryName: method.CountryName, + CurrencyCode: method.CurrencyCode, + PayWay: method.PayWay, + PayType: method.PayType, + MethodName: method.MethodName, + LogoUrl: method.LogoURL, + Status: method.Status, + UsdToCurrencyRate: method.USDToCurrencyRate, + SortOrder: method.SortOrder, + CreatedAtMs: method.CreatedAtMS, + UpdatedAtMs: method.UpdatedAtMS, + } +} + +func externalRechargeOrderToProto(order ledger.ExternalRechargeOrder) *walletv1.ExternalRechargeOrder { + return &walletv1.ExternalRechargeOrder{ + OrderId: order.OrderID, + AppCode: order.AppCode, + TargetUserId: order.TargetUserID, + TargetRegionId: order.TargetRegionID, + TargetCountryCode: order.TargetCountryCode, + AudienceType: order.AudienceType, + ProductId: order.ProductID, + ProductName: order.ProductName, + CoinAmount: order.CoinAmount, + UsdMinorAmount: order.USDMinorAmount, + ProviderCode: order.ProviderCode, + PaymentMethodId: order.PaymentMethodID, + CountryCode: order.CountryCode, + CurrencyCode: order.CurrencyCode, + ProviderAmountMinor: order.ProviderAmountMinor, + PayWay: order.PayWay, + PayType: order.PayType, + PayUrl: order.PayURL, + ProviderOrderId: order.ProviderOrderID, + TxHash: order.TxHash, + ReceiveAddress: order.ReceiveAddress, + Status: order.Status, + FailureReason: order.FailureReason, + TransactionId: order.TransactionID, + CreatedAtMs: order.CreatedAtMS, + UpdatedAtMs: order.UpdatedAtMS, + IdempotentReplay: order.IdempotentReplay, + } +} diff --git a/services/wallet-service/internal/transport/grpc/game.go b/services/wallet-service/internal/transport/grpc/game.go new file mode 100644 index 00000000..2197b675 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/game.go @@ -0,0 +1,36 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ApplyGameCoinChange 处理 game-service 发起的游戏专用金币改账。 +func (s *Server) ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.ApplyGameCoinChange(ctx, ledger.GameCoinChangeCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + PlatformCode: req.GetPlatformCode(), + GameID: req.GetGameId(), + ProviderOrderID: req.GetProviderOrderId(), + ProviderRoundID: req.GetProviderRoundId(), + OpType: req.GetOpType(), + CoinAmount: req.GetCoinAmount(), + RoomID: req.GetRoomId(), + RequestHash: req.GetRequestHash(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.ApplyGameCoinChangeResponse{ + WalletTransactionId: receipt.TransactionID, + BalanceAfter: receipt.BalanceAfter, + IdempotentReplay: receipt.IdempotentReplay, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/gift.go b/services/wallet-service/internal/transport/grpc/gift.go new file mode 100644 index 00000000..8ac088b1 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/gift.go @@ -0,0 +1,127 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// DebitGift 只负责 protobuf 转换和错误映射,扣费规则在 service/repository。 +func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.DebitGift(ctx, ledger.DebitGiftCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + RoomID: req.GetRoomId(), + SenderUserID: req.GetSenderUserId(), + TargetUserID: req.GetTargetUserId(), + GiftID: req.GetGiftId(), + GiftCount: req.GetGiftCount(), + PriceVersion: req.GetPriceVersion(), + RegionID: req.GetRegionId(), + SenderRegionID: req.GetSenderRegionId(), + TargetIsHost: req.GetTargetIsHost(), + + TargetHostRegionID: req.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), + EntitlementID: req.GetEntitlementId(), + ChargeSource: req.GetChargeSource(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return debitGiftResponseFromReceipt(receipt), nil +} + +// BatchDebitGift 只负责批量目标 protobuf 转换;同事务扣费和幂等由 wallet service/repository 保证。 +func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + targets := make([]ledger.DebitGiftTargetCommand, 0, len(req.GetTargets())) + for _, target := range req.GetTargets() { + targets = append(targets, ledger.DebitGiftTargetCommand{ + CommandID: target.GetCommandId(), + TargetUserID: target.GetTargetUserId(), + TargetIsHost: target.GetTargetIsHost(), + TargetHostRegionID: target.GetTargetHostRegionId(), + TargetAgencyOwnerUserID: target.GetTargetAgencyOwnerUserId(), + }) + } + receipt, err := s.svc.BatchDebitGift(ctx, ledger.BatchDebitGiftCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + RoomID: req.GetRoomId(), + SenderUserID: req.GetSenderUserId(), + GiftID: req.GetGiftId(), + GiftCount: req.GetGiftCount(), + PriceVersion: req.GetPriceVersion(), + RegionID: req.GetRegionId(), + SenderRegionID: req.GetSenderRegionId(), + Targets: targets, + EntitlementID: req.GetEntitlementId(), + ChargeSource: req.GetChargeSource(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + response := &walletv1.BatchDebitGiftResponse{ + Aggregate: debitGiftResponseFromReceipt(receipt.Aggregate), + Receipts: make([]*walletv1.BatchDebitGiftReceipt, 0, len(receipt.Targets)), + } + for _, target := range receipt.Targets { + response.Receipts = append(response.Receipts, &walletv1.BatchDebitGiftReceipt{ + TargetUserId: target.TargetUserID, + CommandId: target.CommandID, + Billing: debitGiftResponseFromReceipt(target.Receipt), + }) + } + return response, nil +} + +// DebitRobotGift 扣机器人专用金币;该入口只供内部机器人房间编排链路调用。 +func (s *Server) DebitRobotGift(ctx context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.DebitRobotGift(ctx, ledger.DebitGiftCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + RoomID: req.GetRoomId(), + SenderUserID: req.GetSenderUserId(), + TargetUserID: req.GetTargetUserId(), + GiftID: req.GetGiftId(), + GiftCount: req.GetGiftCount(), + PriceVersion: req.GetPriceVersion(), + RegionID: req.GetRegionId(), + SenderRegionID: req.GetSenderRegionId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return debitGiftResponseFromReceipt(receipt), nil +} + +func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftResponse { + return &walletv1.DebitGiftResponse{ + BillingReceiptId: receipt.BillingReceiptID, + BalanceAfter: receipt.BalanceAfter, + TransactionId: receipt.TransactionID, + CoinSpent: receipt.CoinSpent, + ChargeAssetType: receipt.ChargeAssetType, + ChargeAmount: receipt.ChargeAmount, + GiftPointAdded: receipt.GiftPointAdded, + HeatValue: receipt.HeatValue, + GiftTypeCode: receipt.GiftTypeCode, + CpRelationType: receipt.CPRelationType, + GiftName: receipt.GiftName, + GiftIconUrl: receipt.GiftIconURL, + GiftAnimationUrl: receipt.GiftAnimationURL, + GiftEffectTypes: receipt.GiftEffectTypes, + PriceVersion: receipt.PriceVersion, + HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded, + HostPeriodCycleKey: receipt.HostPeriodCycleKey, + EntitlementId: receipt.EntitlementID, + ChargeSource: receipt.ChargeSource, + } +} diff --git a/services/wallet-service/internal/transport/grpc/host_salary.go b/services/wallet-service/internal/transport/grpc/host_salary.go new file mode 100644 index 00000000..826d8911 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/host_salary.go @@ -0,0 +1,78 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// GetActiveHostSalaryPolicy 读取当前生效的主播工资政策,供 gateway 按当前主播区域展示平台规则。 +func (s *Server) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + policy, found, err := s.svc.GetActiveHostSalaryPolicy(ctx, req.GetAppCode(), req.GetRegionId(), req.GetSettlementMode(), req.GetSettlementTriggerMode(), req.GetNowMs()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + response := &walletv1.GetActiveHostSalaryPolicyResponse{Found: found} + if found { + response.Policy = hostSalaryPolicyToProto(policy) + } + return response, nil +} + +// GetHostSalaryProgress 读取主播当前工资周期钻石累计,gateway 用它计算工资政策等级进度。 +func (s *Server) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + progress, err := s.svc.GetHostSalaryProgress(ctx, req.GetAppCode(), req.GetHostUserId(), req.GetCycleKey(), req.GetNowMs()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.GetHostSalaryProgressResponse{Progress: hostSalaryProgressToProto(progress)}, nil +} + +func hostSalaryProgressToProto(progress ledger.HostSalaryProgress) *walletv1.HostSalaryProgress { + return &walletv1.HostSalaryProgress{ + HostUserId: progress.HostUserID, + CycleKey: progress.CycleKey, + RegionId: progress.RegionID, + AgencyOwnerUserId: progress.AgencyOwnerUserID, + TotalDiamonds: progress.TotalDiamonds, + GiftDiamondTotal: progress.GiftDiamondTotal, + UpdatedAtMs: progress.UpdatedAtMS, + } +} + +func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalaryPolicy { + levels := make([]*walletv1.HostSalaryPolicyLevel, 0, len(policy.Levels)) + for _, level := range policy.Levels { + levels = append(levels, hostSalaryPolicyLevelToProto(level)) + } + return &walletv1.HostSalaryPolicy{ + PolicyId: policy.PolicyID, + Name: policy.Name, + RegionId: policy.RegionID, + Status: policy.Status, + SettlementMode: policy.SettlementMode, + SettlementTriggerMode: policy.SettlementTriggerMode, + GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio, + ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate, + EffectiveFromMs: policy.EffectiveFromMs, + EffectiveToMs: policy.EffectiveToMs, + Levels: levels, + } +} + +func hostSalaryPolicyLevelToProto(level ledger.HostSalaryPolicyLevel) *walletv1.HostSalaryPolicyLevel { + return &walletv1.HostSalaryPolicyLevel{ + LevelNo: int32(level.LevelNo), + RequiredDiamonds: level.RequiredDiamonds, + HostSalaryUsdMinor: level.HostSalaryUSDMinor, + HostCoinReward: level.HostCoinReward, + AgencySalaryUsdMinor: level.AgencySalaryUSDMinor, + Status: level.Status, + SortOrder: int32(level.SortOrder), + } +} diff --git a/services/wallet-service/internal/transport/grpc/recharge_product.go b/services/wallet-service/internal/transport/grpc/recharge_product.go new file mode 100644 index 00000000..1eb09529 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/recharge_product.go @@ -0,0 +1,157 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ListRechargeProducts 返回当前用户区域可用的充值渠道和档位。 +func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + products, channels, err := s.svc.ListRechargeProducts(ctx, req.GetUserId(), req.GetRegionId(), req.GetPlatform(), req.GetAudienceType()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListRechargeProductsResponse{Channels: channels, Products: make([]*walletv1.RechargeProduct, 0, len(products))} + for _, product := range products { + resp.Products = append(resp.Products, rechargeProductToProto(product)) + } + return resp, nil +} + +// ConfirmGooglePayment 校验 Google Play purchase token 并完成钱包入账。 +func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.ConfirmGooglePayment(ctx, ledger.GooglePaymentCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + RegionID: req.GetRegionId(), + ProductID: req.GetProductId(), + ProductCode: req.GetProductCode(), + PackageName: req.GetPackageName(), + PurchaseToken: req.GetPurchaseToken(), + OrderID: req.GetOrderId(), + PurchaseTimeMS: req.GetPurchaseTimeMs(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.ConfirmGooglePaymentResponse{ + PaymentOrderId: receipt.PaymentOrderID, + TransactionId: receipt.TransactionID, + Status: receipt.Status, + ProductId: receipt.ProductID, + ProductCode: receipt.ProductCode, + CoinAmount: receipt.CoinAmount, + Balance: balanceToProto(receipt.Balance), + IdempotentReplay: receipt.IdempotentReplay, + ConsumeState: receipt.ConsumeState, + }, nil +} + +// ListAdminRechargeProducts 返回后台内购配置列表。 +func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + products, total, err := s.svc.ListAdminRechargeProducts(ctx, ledger.ListRechargeProductsQuery{ + AppCode: req.GetAppCode(), + Status: req.GetStatus(), + Platform: req.GetPlatform(), + RegionID: req.GetRegionId(), + Keyword: req.GetKeyword(), + Page: req.GetPage(), + PageSize: req.GetPageSize(), + AudienceType: req.GetAudienceType(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListAdminRechargeProductsResponse{Products: make([]*walletv1.RechargeProduct, 0, len(products)), Total: total} + for _, product := range products { + resp.Products = append(resp.Products, rechargeProductToProto(product)) + } + return resp, nil +} + +// CreateRechargeProduct 创建后台内购商品配置。 +func (s *Server) CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + product, err := s.svc.CreateRechargeProduct(ctx, ledger.RechargeProductCommand{ + AppCode: req.GetAppCode(), + AmountMicro: req.GetAmountMicro(), + CoinAmount: req.GetCoinAmount(), + ProductName: req.GetProductName(), + Description: req.GetDescription(), + Platform: req.GetPlatform(), + RegionIDs: req.GetRegionIds(), + Enabled: req.GetEnabled(), + OperatorUserID: req.GetOperatorUserId(), + AudienceType: req.GetAudienceType(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.RechargeProductResponse{Product: rechargeProductToProto(product)}, nil +} + +// UpdateRechargeProduct 修改后台内购商品配置。 +func (s *Server) UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + product, err := s.svc.UpdateRechargeProduct(ctx, ledger.RechargeProductCommand{ + AppCode: req.GetAppCode(), + ProductID: req.GetProductId(), + AmountMicro: req.GetAmountMicro(), + CoinAmount: req.GetCoinAmount(), + ProductName: req.GetProductName(), + Description: req.GetDescription(), + Platform: req.GetPlatform(), + RegionIDs: req.GetRegionIds(), + Enabled: req.GetEnabled(), + OperatorUserID: req.GetOperatorUserId(), + AudienceType: req.GetAudienceType(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.RechargeProductResponse{Product: rechargeProductToProto(product)}, nil +} + +// DeleteRechargeProduct 删除后台内购商品配置。 +func (s *Server) DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + if err := s.svc.DeleteRechargeProduct(ctx, req.GetAppCode(), req.GetProductId()); err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.DeleteRechargeProductResponse{Deleted: true}, nil +} + +func rechargeProductToProto(product ledger.RechargeProduct) *walletv1.RechargeProduct { + return &walletv1.RechargeProduct{ + AppCode: product.AppCode, + AudienceType: product.AudienceType, + ProductId: product.ProductID, + ProductCode: product.ProductCode, + ProductName: product.ProductName, + Description: product.Description, + Platform: product.Platform, + Channel: product.Channel, + CurrencyCode: product.CurrencyCode, + AmountMicro: product.AmountMicro, + AmountMinor: product.AmountMinor, + CoinAmount: product.CoinAmount, + PolicyVersion: product.PolicyVersion, + Status: product.Status, + Enabled: product.Enabled, + SortOrder: product.SortOrder, + RegionIds: product.RegionIDs, + ResourceAssetType: product.ResourceAssetType, + CreatedByUserId: product.CreatedByUserID, + UpdatedByUserId: product.UpdatedByUserID, + CreatedAtMs: product.CreatedAtMS, + UpdatedAtMs: product.UpdatedAtMS, + } +} diff --git a/services/wallet-service/internal/transport/grpc/resource.go b/services/wallet-service/internal/transport/grpc/resource.go index 9f7275cb..1845ebbe 100644 --- a/services/wallet-service/internal/transport/grpc/resource.go +++ b/services/wallet-service/internal/transport/grpc/resource.go @@ -2,9 +2,7 @@ package grpc import ( "context" - walletv1 "hyapp.local/api/proto/wallet/v1" - "hyapp/pkg/appcode" "hyapp/pkg/xerr" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) @@ -420,388 +418,3 @@ func (s *Server) PurchaseResourceShopItem(ctx context.Context, req *walletv1.Pur } return resourceShopPurchaseReceiptToProto(receipt), nil } - -func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand { - return resourcedomain.ResourceCommand{ - AppCode: req.GetAppCode(), - ResourceCode: req.GetResourceCode(), - ResourceType: req.GetResourceType(), - Name: req.GetName(), - Status: req.GetStatus(), - Grantable: req.GetGrantable(), - GrantStrategy: req.GetGrantStrategy(), - WalletAssetType: req.GetWalletAssetType(), - WalletAssetAmount: req.GetWalletAssetAmount(), - PriceType: req.GetPriceType(), - CoinPrice: req.GetCoinPrice(), - GiftPointAmount: 0, - UsageScopes: req.GetUsageScopes(), - AssetURL: req.GetAssetUrl(), - PreviewURL: req.GetPreviewUrl(), - AnimationURL: req.GetAnimationUrl(), - MetadataJSON: req.GetMetadataJson(), - SortOrder: req.GetSortOrder(), - OperatorUserID: req.GetOperatorUserId(), - ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled), - } -} - -func resourceCommandFromUpdate(req *walletv1.UpdateResourceRequest) resourcedomain.ResourceCommand { - command := resourceCommandFromCreate(&walletv1.CreateResourceRequest{ - AppCode: req.GetAppCode(), - ResourceCode: req.GetResourceCode(), - ResourceType: req.GetResourceType(), - Name: req.GetName(), - Status: req.GetStatus(), - Grantable: req.GetGrantable(), - GrantStrategy: req.GetGrantStrategy(), - WalletAssetType: req.GetWalletAssetType(), - WalletAssetAmount: req.GetWalletAssetAmount(), - PriceType: req.GetPriceType(), - CoinPrice: req.GetCoinPrice(), - GiftPointAmount: 0, - UsageScopes: req.GetUsageScopes(), - AssetUrl: req.GetAssetUrl(), - PreviewUrl: req.GetPreviewUrl(), - AnimationUrl: req.GetAnimationUrl(), - MetadataJson: req.GetMetadataJson(), - SortOrder: req.GetSortOrder(), - OperatorUserId: req.GetOperatorUserId(), - ManagerGrantEnabled: req.ManagerGrantEnabled, - }) - command.ResourceID = req.GetResourceId() - return command -} - -func resourceGroupCommandFromCreate(req *walletv1.CreateResourceGroupRequest) resourcedomain.ResourceGroupCommand { - return resourcedomain.ResourceGroupCommand{ - AppCode: req.GetAppCode(), - GroupCode: req.GetGroupCode(), - Name: req.GetName(), - Status: req.GetStatus(), - Description: req.GetDescription(), - SortOrder: req.GetSortOrder(), - Items: groupItemInputs(req.GetItems()), - OperatorUserID: req.GetOperatorUserId(), - } -} - -func resourceGroupCommandFromUpdate(req *walletv1.UpdateResourceGroupRequest) resourcedomain.ResourceGroupCommand { - return resourcedomain.ResourceGroupCommand{ - AppCode: req.GetAppCode(), - GroupID: req.GetGroupId(), - GroupCode: req.GetGroupCode(), - Name: req.GetName(), - Status: req.GetStatus(), - Description: req.GetDescription(), - SortOrder: req.GetSortOrder(), - Items: groupItemInputs(req.GetItems()), - OperatorUserID: req.GetOperatorUserId(), - } -} - -func giftConfigCommandFromCreate(req *walletv1.CreateGiftConfigRequest) resourcedomain.GiftConfigCommand { - return resourcedomain.GiftConfigCommand{ - AppCode: req.GetAppCode(), - GiftID: req.GetGiftId(), - ResourceID: req.GetResourceId(), - Status: req.GetStatus(), - Name: req.GetName(), - SortOrder: req.GetSortOrder(), - PresentationJSON: req.GetPresentationJson(), - PriceVersion: req.GetPriceVersion(), - CoinPrice: req.GetCoinPrice(), - GiftPointAmount: 0, - HeatValue: req.GetHeatValue(), - EffectiveAtMS: req.GetEffectiveAtMs(), - GiftTypeCode: req.GetGiftTypeCode(), - CPRelationType: req.GetCpRelationType(), - ChargeAssetType: req.GetChargeAssetType(), - EffectiveFromMS: req.GetEffectiveFromMs(), - EffectiveToMS: req.GetEffectiveToMs(), - EffectTypes: req.GetEffectTypes(), - OperatorUserID: req.GetOperatorUserId(), - RegionIDs: req.GetRegionIds(), - } -} - -func giftConfigCommandFromUpdate(req *walletv1.UpdateGiftConfigRequest) resourcedomain.GiftConfigCommand { - return resourcedomain.GiftConfigCommand{ - AppCode: req.GetAppCode(), - GiftID: req.GetGiftId(), - ResourceID: req.GetResourceId(), - Status: req.GetStatus(), - Name: req.GetName(), - SortOrder: req.GetSortOrder(), - PresentationJSON: req.GetPresentationJson(), - PriceVersion: req.GetPriceVersion(), - CoinPrice: req.GetCoinPrice(), - GiftPointAmount: 0, - HeatValue: req.GetHeatValue(), - EffectiveAtMS: req.GetEffectiveAtMs(), - GiftTypeCode: req.GetGiftTypeCode(), - CPRelationType: req.GetCpRelationType(), - ChargeAssetType: req.GetChargeAssetType(), - EffectiveFromMS: req.GetEffectiveFromMs(), - EffectiveToMS: req.GetEffectiveToMs(), - EffectTypes: req.GetEffectTypes(), - OperatorUserID: req.GetOperatorUserId(), - RegionIDs: req.GetRegionIds(), - } -} - -func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.ResourceGroupItemInput { - out := make([]resourcedomain.ResourceGroupItemInput, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - out = append(out, resourcedomain.ResourceGroupItemInput{ - ItemType: item.GetItemType(), - ResourceID: item.GetResourceId(), - WalletAssetType: item.GetWalletAssetType(), - WalletAssetAmount: item.GetWalletAssetAmount(), - Quantity: item.GetQuantity(), - DurationMS: item.GetDurationMs(), - SortOrder: item.GetSortOrder(), - }) - } - return out -} - -func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourcedomain.ResourceShopItemInput { - out := make([]resourcedomain.ResourceShopItemInput, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - out = append(out, resourcedomain.ResourceShopItemInput{ - ShopItemID: item.GetShopItemId(), - ResourceID: item.GetResourceId(), - Status: item.GetStatus(), - DurationDays: item.GetDurationDays(), - EffectiveFromMS: item.GetEffectiveFromMs(), - EffectiveToMS: item.GetEffectiveToMs(), - SortOrder: item.GetSortOrder(), - }) - } - return out -} - -func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource { - return &walletv1.Resource{ - AppCode: appcode.Normalize(resource.AppCode), - ResourceId: resource.ResourceID, - ResourceCode: resource.ResourceCode, - ResourceType: resource.ResourceType, - Name: resource.Name, - Status: resource.Status, - Grantable: resource.Grantable, - GrantStrategy: resource.GrantStrategy, - WalletAssetType: resource.WalletAssetType, - WalletAssetAmount: resource.WalletAssetAmount, - PriceType: resource.PriceType, - CoinPrice: resource.CoinPrice, - GiftPointAmount: 0, - UsageScopes: resource.UsageScopes, - AssetUrl: resource.AssetURL, - PreviewUrl: resource.PreviewURL, - AnimationUrl: resource.AnimationURL, - MetadataJson: resource.MetadataJSON, - SortOrder: resource.SortOrder, - CreatedByUserId: resource.CreatedByUserID, - UpdatedByUserId: resource.UpdatedByUserID, - CreatedAtMs: resource.CreatedAtMS, - UpdatedAtMs: resource.UpdatedAtMS, - ManagerGrantEnabled: resource.ManagerGrantEnabled, - } -} - -func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.ResourceShopItem { - return &walletv1.ResourceShopItem{ - AppCode: appcode.Normalize(item.AppCode), - ShopItemId: item.ShopItemID, - ResourceId: item.ResourceID, - Resource: resourceToProto(item.Resource), - Status: item.Status, - DurationDays: item.DurationDays, - PriceType: item.PriceType, - CoinPrice: item.CoinPrice, - EffectiveFromMs: item.EffectiveFromMS, - EffectiveToMs: item.EffectiveToMS, - SortOrder: item.SortOrder, - CreatedByUserId: item.CreatedByUserID, - UpdatedByUserId: item.UpdatedByUserID, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - } -} - -func resourceShopPurchaseOrderToProto(item resourcedomain.ResourceShopPurchaseOrder) *walletv1.ResourceShopPurchaseOrder { - return &walletv1.ResourceShopPurchaseOrder{ - AppCode: appcode.Normalize(item.AppCode), - OrderId: item.OrderID, - CommandId: item.CommandID, - UserId: item.UserID, - ShopItemId: item.ShopItemID, - ResourceId: item.ResourceID, - Resource: resourceToProto(item.Resource), - DurationDays: item.DurationDays, - PriceCoin: item.PriceCoin, - Status: item.Status, - WalletTransactionId: item.WalletTransactionID, - ResourceGrantId: item.ResourceGrantID, - EntitlementId: item.EntitlementID, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - } -} - -func resourceShopPurchaseReceiptToProto(receipt resourcedomain.ResourceShopPurchaseReceipt) *walletv1.PurchaseResourceShopItemResponse { - return &walletv1.PurchaseResourceShopItemResponse{ - OrderId: receipt.OrderID, - TransactionId: receipt.TransactionID, - ResourceGrantId: receipt.GrantID, - ShopItem: resourceShopItemToProto(receipt.ShopItem), - Resource: userResourceToProto(receipt.Resource), - Balance: balanceToProto(receipt.Balance), - CoinSpent: receipt.CoinSpent, - } -} - -func managerGrantEnabledOrDefault(value *bool) bool { - if value == nil { - // 新建/编辑入口如果没显式携带开关,按产品规则默认关闭经理中心赠送。 - return false - } - return *value -} - -func resourceGroupToProto(group resourcedomain.ResourceGroup) *walletv1.ResourceGroup { - items := make([]*walletv1.ResourceGroupItem, 0, len(group.Items)) - for _, item := range group.Items { - items = append(items, &walletv1.ResourceGroupItem{ - GroupItemId: item.GroupItemID, - GroupId: item.GroupID, - ResourceId: item.ResourceID, - Resource: resourceToProto(item.Resource), - Quantity: item.Quantity, - DurationMs: item.DurationMS, - SortOrder: item.SortOrder, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - ItemType: item.ItemType, - WalletAssetType: item.WalletAssetType, - WalletAssetAmount: item.WalletAssetAmount, - }) - } - return &walletv1.ResourceGroup{ - AppCode: appcode.Normalize(group.AppCode), - GroupId: group.GroupID, - GroupCode: group.GroupCode, - Name: group.Name, - Status: group.Status, - Description: group.Description, - SortOrder: group.SortOrder, - Items: items, - CreatedByUserId: group.CreatedByUserID, - UpdatedByUserId: group.UpdatedByUserID, - CreatedAtMs: group.CreatedAtMS, - UpdatedAtMs: group.UpdatedAtMS, - } -} - -func giftConfigToProto(gift resourcedomain.GiftConfig) *walletv1.GiftConfig { - return &walletv1.GiftConfig{ - AppCode: appcode.Normalize(gift.AppCode), - GiftId: gift.GiftID, - ResourceId: gift.ResourceID, - Resource: resourceToProto(gift.Resource), - Status: gift.Status, - Name: gift.Name, - SortOrder: gift.SortOrder, - PresentationJson: gift.PresentationJSON, - PriceVersion: gift.PriceVersion, - ChargeAssetType: gift.ChargeAssetType, - CoinPrice: gift.CoinPrice, - GiftPointAmount: 0, - HeatValue: gift.HeatValue, - GiftTypeCode: gift.GiftTypeCode, - CpRelationType: gift.CPRelationType, - EffectiveFromMs: gift.EffectiveFromMS, - EffectiveToMs: gift.EffectiveToMS, - EffectTypes: gift.EffectTypes, - CreatedByUserId: gift.CreatedByUserID, - UpdatedByUserId: gift.UpdatedByUserID, - CreatedAtMs: gift.CreatedAtMS, - UpdatedAtMs: gift.UpdatedAtMS, - RegionIds: gift.RegionIDs, - } -} - -func giftTypeConfigToProto(item resourcedomain.GiftTypeConfig) *walletv1.GiftTypeConfig { - return &walletv1.GiftTypeConfig{ - AppCode: appcode.Normalize(item.AppCode), - TypeCode: item.TypeCode, - Name: item.Name, - TabKey: item.TabKey, - Status: item.Status, - SortOrder: item.SortOrder, - CreatedByUserId: item.CreatedByUserID, - UpdatedByUserId: item.UpdatedByUserID, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - } -} - -func userResourceToProto(item resourcedomain.UserResourceEntitlement) *walletv1.UserResourceEntitlement { - return &walletv1.UserResourceEntitlement{ - AppCode: appcode.Normalize(item.AppCode), - EntitlementId: item.EntitlementID, - UserId: item.UserID, - ResourceId: item.ResourceID, - Resource: resourceToProto(item.Resource), - Status: item.Status, - Quantity: item.Quantity, - RemainingQuantity: item.RemainingQuantity, - EffectiveAtMs: item.EffectiveAtMS, - ExpiresAtMs: item.ExpiresAtMS, - SourceGrantId: item.SourceGrantID, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - Equipped: item.Equipped, - } -} - -func resourceGrantToProto(grant resourcedomain.ResourceGrant) *walletv1.ResourceGrant { - items := make([]*walletv1.ResourceGrantItem, 0, len(grant.Items)) - for _, item := range grant.Items { - items = append(items, &walletv1.ResourceGrantItem{ - GrantItemId: item.GrantItemID, - GrantId: item.GrantID, - ResourceId: item.ResourceID, - ResourceSnapshotJson: item.ResourceSnapshotJSON, - Quantity: item.Quantity, - DurationMs: item.DurationMS, - ResultType: item.ResultType, - WalletTransactionId: item.WalletTransactionID, - EntitlementId: item.EntitlementID, - CreatedAtMs: item.CreatedAtMS, - }) - } - return &walletv1.ResourceGrant{ - AppCode: appcode.Normalize(grant.AppCode), - GrantId: grant.GrantID, - CommandId: grant.CommandID, - TargetUserId: grant.TargetUserID, - GrantSource: grant.GrantSource, - GrantSubjectType: grant.GrantSubjectType, - GrantSubjectId: grant.GrantSubjectID, - Status: grant.Status, - Reason: grant.Reason, - OperatorUserId: grant.OperatorUserID, - Items: items, - CreatedAtMs: grant.CreatedAtMS, - UpdatedAtMs: grant.UpdatedAtMS, - } -} diff --git a/services/wallet-service/internal/transport/grpc/resource_command_mapper.go b/services/wallet-service/internal/transport/grpc/resource_command_mapper.go new file mode 100644 index 00000000..c245653b --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/resource_command_mapper.go @@ -0,0 +1,173 @@ +package grpc + +import ( + walletv1 "hyapp.local/api/proto/wallet/v1" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" +) + +func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand { + return resourcedomain.ResourceCommand{ + AppCode: req.GetAppCode(), + ResourceCode: req.GetResourceCode(), + ResourceType: req.GetResourceType(), + Name: req.GetName(), + Status: req.GetStatus(), + Grantable: req.GetGrantable(), + GrantStrategy: req.GetGrantStrategy(), + WalletAssetType: req.GetWalletAssetType(), + WalletAssetAmount: req.GetWalletAssetAmount(), + PriceType: req.GetPriceType(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: 0, + UsageScopes: req.GetUsageScopes(), + AssetURL: req.GetAssetUrl(), + PreviewURL: req.GetPreviewUrl(), + AnimationURL: req.GetAnimationUrl(), + MetadataJSON: req.GetMetadataJson(), + SortOrder: req.GetSortOrder(), + OperatorUserID: req.GetOperatorUserId(), + ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled), + } +} + +func resourceCommandFromUpdate(req *walletv1.UpdateResourceRequest) resourcedomain.ResourceCommand { + command := resourceCommandFromCreate(&walletv1.CreateResourceRequest{ + AppCode: req.GetAppCode(), + ResourceCode: req.GetResourceCode(), + ResourceType: req.GetResourceType(), + Name: req.GetName(), + Status: req.GetStatus(), + Grantable: req.GetGrantable(), + GrantStrategy: req.GetGrantStrategy(), + WalletAssetType: req.GetWalletAssetType(), + WalletAssetAmount: req.GetWalletAssetAmount(), + PriceType: req.GetPriceType(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: 0, + UsageScopes: req.GetUsageScopes(), + AssetUrl: req.GetAssetUrl(), + PreviewUrl: req.GetPreviewUrl(), + AnimationUrl: req.GetAnimationUrl(), + MetadataJson: req.GetMetadataJson(), + SortOrder: req.GetSortOrder(), + OperatorUserId: req.GetOperatorUserId(), + ManagerGrantEnabled: req.ManagerGrantEnabled, + }) + command.ResourceID = req.GetResourceId() + return command +} + +func resourceGroupCommandFromCreate(req *walletv1.CreateResourceGroupRequest) resourcedomain.ResourceGroupCommand { + return resourcedomain.ResourceGroupCommand{ + AppCode: req.GetAppCode(), + GroupCode: req.GetGroupCode(), + Name: req.GetName(), + Status: req.GetStatus(), + Description: req.GetDescription(), + SortOrder: req.GetSortOrder(), + Items: groupItemInputs(req.GetItems()), + OperatorUserID: req.GetOperatorUserId(), + } +} + +func resourceGroupCommandFromUpdate(req *walletv1.UpdateResourceGroupRequest) resourcedomain.ResourceGroupCommand { + return resourcedomain.ResourceGroupCommand{ + AppCode: req.GetAppCode(), + GroupID: req.GetGroupId(), + GroupCode: req.GetGroupCode(), + Name: req.GetName(), + Status: req.GetStatus(), + Description: req.GetDescription(), + SortOrder: req.GetSortOrder(), + Items: groupItemInputs(req.GetItems()), + OperatorUserID: req.GetOperatorUserId(), + } +} + +func giftConfigCommandFromCreate(req *walletv1.CreateGiftConfigRequest) resourcedomain.GiftConfigCommand { + return resourcedomain.GiftConfigCommand{ + AppCode: req.GetAppCode(), + GiftID: req.GetGiftId(), + ResourceID: req.GetResourceId(), + Status: req.GetStatus(), + Name: req.GetName(), + SortOrder: req.GetSortOrder(), + PresentationJSON: req.GetPresentationJson(), + PriceVersion: req.GetPriceVersion(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: 0, + HeatValue: req.GetHeatValue(), + EffectiveAtMS: req.GetEffectiveAtMs(), + GiftTypeCode: req.GetGiftTypeCode(), + CPRelationType: req.GetCpRelationType(), + ChargeAssetType: req.GetChargeAssetType(), + EffectiveFromMS: req.GetEffectiveFromMs(), + EffectiveToMS: req.GetEffectiveToMs(), + EffectTypes: req.GetEffectTypes(), + OperatorUserID: req.GetOperatorUserId(), + RegionIDs: req.GetRegionIds(), + } +} + +func giftConfigCommandFromUpdate(req *walletv1.UpdateGiftConfigRequest) resourcedomain.GiftConfigCommand { + return resourcedomain.GiftConfigCommand{ + AppCode: req.GetAppCode(), + GiftID: req.GetGiftId(), + ResourceID: req.GetResourceId(), + Status: req.GetStatus(), + Name: req.GetName(), + SortOrder: req.GetSortOrder(), + PresentationJSON: req.GetPresentationJson(), + PriceVersion: req.GetPriceVersion(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: 0, + HeatValue: req.GetHeatValue(), + EffectiveAtMS: req.GetEffectiveAtMs(), + GiftTypeCode: req.GetGiftTypeCode(), + CPRelationType: req.GetCpRelationType(), + ChargeAssetType: req.GetChargeAssetType(), + EffectiveFromMS: req.GetEffectiveFromMs(), + EffectiveToMS: req.GetEffectiveToMs(), + EffectTypes: req.GetEffectTypes(), + OperatorUserID: req.GetOperatorUserId(), + RegionIDs: req.GetRegionIds(), + } +} + +func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.ResourceGroupItemInput { + out := make([]resourcedomain.ResourceGroupItemInput, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + out = append(out, resourcedomain.ResourceGroupItemInput{ + ItemType: item.GetItemType(), + ResourceID: item.GetResourceId(), + WalletAssetType: item.GetWalletAssetType(), + WalletAssetAmount: item.GetWalletAssetAmount(), + Quantity: item.GetQuantity(), + DurationMS: item.GetDurationMs(), + SortOrder: item.GetSortOrder(), + }) + } + return out +} + +func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourcedomain.ResourceShopItemInput { + out := make([]resourcedomain.ResourceShopItemInput, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + out = append(out, resourcedomain.ResourceShopItemInput{ + ShopItemID: item.GetShopItemId(), + ResourceID: item.GetResourceId(), + Status: item.GetStatus(), + DurationDays: item.GetDurationDays(), + EffectiveFromMS: item.GetEffectiveFromMs(), + EffectiveToMS: item.GetEffectiveToMs(), + SortOrder: item.GetSortOrder(), + }) + } + return out +} diff --git a/services/wallet-service/internal/transport/grpc/resource_mapper.go b/services/wallet-service/internal/transport/grpc/resource_mapper.go new file mode 100644 index 00000000..2786e797 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/resource_mapper.go @@ -0,0 +1,225 @@ +package grpc + +import ( + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" +) + +func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource { + return &walletv1.Resource{ + AppCode: appcode.Normalize(resource.AppCode), + ResourceId: resource.ResourceID, + ResourceCode: resource.ResourceCode, + ResourceType: resource.ResourceType, + Name: resource.Name, + Status: resource.Status, + Grantable: resource.Grantable, + GrantStrategy: resource.GrantStrategy, + WalletAssetType: resource.WalletAssetType, + WalletAssetAmount: resource.WalletAssetAmount, + PriceType: resource.PriceType, + CoinPrice: resource.CoinPrice, + GiftPointAmount: 0, + UsageScopes: resource.UsageScopes, + AssetUrl: resource.AssetURL, + PreviewUrl: resource.PreviewURL, + AnimationUrl: resource.AnimationURL, + MetadataJson: resource.MetadataJSON, + SortOrder: resource.SortOrder, + CreatedByUserId: resource.CreatedByUserID, + UpdatedByUserId: resource.UpdatedByUserID, + CreatedAtMs: resource.CreatedAtMS, + UpdatedAtMs: resource.UpdatedAtMS, + ManagerGrantEnabled: resource.ManagerGrantEnabled, + } +} + +func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.ResourceShopItem { + return &walletv1.ResourceShopItem{ + AppCode: appcode.Normalize(item.AppCode), + ShopItemId: item.ShopItemID, + ResourceId: item.ResourceID, + Resource: resourceToProto(item.Resource), + Status: item.Status, + DurationDays: item.DurationDays, + PriceType: item.PriceType, + CoinPrice: item.CoinPrice, + EffectiveFromMs: item.EffectiveFromMS, + EffectiveToMs: item.EffectiveToMS, + SortOrder: item.SortOrder, + CreatedByUserId: item.CreatedByUserID, + UpdatedByUserId: item.UpdatedByUserID, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + } +} + +func resourceShopPurchaseOrderToProto(item resourcedomain.ResourceShopPurchaseOrder) *walletv1.ResourceShopPurchaseOrder { + return &walletv1.ResourceShopPurchaseOrder{ + AppCode: appcode.Normalize(item.AppCode), + OrderId: item.OrderID, + CommandId: item.CommandID, + UserId: item.UserID, + ShopItemId: item.ShopItemID, + ResourceId: item.ResourceID, + Resource: resourceToProto(item.Resource), + DurationDays: item.DurationDays, + PriceCoin: item.PriceCoin, + Status: item.Status, + WalletTransactionId: item.WalletTransactionID, + ResourceGrantId: item.ResourceGrantID, + EntitlementId: item.EntitlementID, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + } +} + +func resourceShopPurchaseReceiptToProto(receipt resourcedomain.ResourceShopPurchaseReceipt) *walletv1.PurchaseResourceShopItemResponse { + return &walletv1.PurchaseResourceShopItemResponse{ + OrderId: receipt.OrderID, + TransactionId: receipt.TransactionID, + ResourceGrantId: receipt.GrantID, + ShopItem: resourceShopItemToProto(receipt.ShopItem), + Resource: userResourceToProto(receipt.Resource), + Balance: balanceToProto(receipt.Balance), + CoinSpent: receipt.CoinSpent, + } +} + +func managerGrantEnabledOrDefault(value *bool) bool { + if value == nil { + + return false + } + return *value +} + +func resourceGroupToProto(group resourcedomain.ResourceGroup) *walletv1.ResourceGroup { + items := make([]*walletv1.ResourceGroupItem, 0, len(group.Items)) + for _, item := range group.Items { + items = append(items, &walletv1.ResourceGroupItem{ + GroupItemId: item.GroupItemID, + GroupId: item.GroupID, + ResourceId: item.ResourceID, + Resource: resourceToProto(item.Resource), + Quantity: item.Quantity, + DurationMs: item.DurationMS, + SortOrder: item.SortOrder, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + ItemType: item.ItemType, + WalletAssetType: item.WalletAssetType, + WalletAssetAmount: item.WalletAssetAmount, + }) + } + return &walletv1.ResourceGroup{ + AppCode: appcode.Normalize(group.AppCode), + GroupId: group.GroupID, + GroupCode: group.GroupCode, + Name: group.Name, + Status: group.Status, + Description: group.Description, + SortOrder: group.SortOrder, + Items: items, + CreatedByUserId: group.CreatedByUserID, + UpdatedByUserId: group.UpdatedByUserID, + CreatedAtMs: group.CreatedAtMS, + UpdatedAtMs: group.UpdatedAtMS, + } +} + +func giftConfigToProto(gift resourcedomain.GiftConfig) *walletv1.GiftConfig { + return &walletv1.GiftConfig{ + AppCode: appcode.Normalize(gift.AppCode), + GiftId: gift.GiftID, + ResourceId: gift.ResourceID, + Resource: resourceToProto(gift.Resource), + Status: gift.Status, + Name: gift.Name, + SortOrder: gift.SortOrder, + PresentationJson: gift.PresentationJSON, + PriceVersion: gift.PriceVersion, + ChargeAssetType: gift.ChargeAssetType, + CoinPrice: gift.CoinPrice, + GiftPointAmount: 0, + HeatValue: gift.HeatValue, + GiftTypeCode: gift.GiftTypeCode, + CpRelationType: gift.CPRelationType, + EffectiveFromMs: gift.EffectiveFromMS, + EffectiveToMs: gift.EffectiveToMS, + EffectTypes: gift.EffectTypes, + CreatedByUserId: gift.CreatedByUserID, + UpdatedByUserId: gift.UpdatedByUserID, + CreatedAtMs: gift.CreatedAtMS, + UpdatedAtMs: gift.UpdatedAtMS, + RegionIds: gift.RegionIDs, + } +} + +func giftTypeConfigToProto(item resourcedomain.GiftTypeConfig) *walletv1.GiftTypeConfig { + return &walletv1.GiftTypeConfig{ + AppCode: appcode.Normalize(item.AppCode), + TypeCode: item.TypeCode, + Name: item.Name, + TabKey: item.TabKey, + Status: item.Status, + SortOrder: item.SortOrder, + CreatedByUserId: item.CreatedByUserID, + UpdatedByUserId: item.UpdatedByUserID, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + } +} + +func userResourceToProto(item resourcedomain.UserResourceEntitlement) *walletv1.UserResourceEntitlement { + return &walletv1.UserResourceEntitlement{ + AppCode: appcode.Normalize(item.AppCode), + EntitlementId: item.EntitlementID, + UserId: item.UserID, + ResourceId: item.ResourceID, + Resource: resourceToProto(item.Resource), + Status: item.Status, + Quantity: item.Quantity, + RemainingQuantity: item.RemainingQuantity, + EffectiveAtMs: item.EffectiveAtMS, + ExpiresAtMs: item.ExpiresAtMS, + SourceGrantId: item.SourceGrantID, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + Equipped: item.Equipped, + } +} + +func resourceGrantToProto(grant resourcedomain.ResourceGrant) *walletv1.ResourceGrant { + items := make([]*walletv1.ResourceGrantItem, 0, len(grant.Items)) + for _, item := range grant.Items { + items = append(items, &walletv1.ResourceGrantItem{ + GrantItemId: item.GrantItemID, + GrantId: item.GrantID, + ResourceId: item.ResourceID, + ResourceSnapshotJson: item.ResourceSnapshotJSON, + Quantity: item.Quantity, + DurationMs: item.DurationMS, + ResultType: item.ResultType, + WalletTransactionId: item.WalletTransactionID, + EntitlementId: item.EntitlementID, + CreatedAtMs: item.CreatedAtMS, + }) + } + return &walletv1.ResourceGrant{ + AppCode: appcode.Normalize(grant.AppCode), + GrantId: grant.GrantID, + CommandId: grant.CommandID, + TargetUserId: grant.TargetUserID, + GrantSource: grant.GrantSource, + GrantSubjectType: grant.GrantSubjectType, + GrantSubjectId: grant.GrantSubjectID, + Status: grant.Status, + Reason: grant.Reason, + OperatorUserId: grant.OperatorUserID, + Items: items, + CreatedAtMs: grant.CreatedAtMS, + UpdatedAtMs: grant.UpdatedAtMS, + } +} diff --git a/services/wallet-service/internal/transport/grpc/reward.go b/services/wallet-service/internal/transport/grpc/reward.go new file mode 100644 index 00000000..2fa41724 --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/reward.go @@ -0,0 +1,254 @@ +package grpc + +import ( + "context" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// DebitCPBreakupFee 扣除解除 CP/兄弟/姐妹关系费用;关系状态确认由 user-service 负责。 +func (s *Server) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.DebitCPBreakupFee(ctx, ledger.CPBreakupFeeCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + RelationshipID: req.GetRelationshipId(), + RelationType: req.GetRelationType(), + Amount: req.GetAmount(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.DebitCPBreakupFeeResponse{ + TransactionId: receipt.TransactionID, + CoinSpent: receipt.CoinSpent, + CoinBalanceAfter: receipt.CoinBalanceAfter, + Balance: balanceToProto(receipt.Balance), + }, nil +} + +// DebitWheelDraw 扣除转盘抽奖金币;中奖结果不在 wallet 内生成,避免钱包反向拥有活动规则。 +func (s *Server) DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.DebitWheelDraw(ctx, ledger.WheelDrawDebitCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + WheelID: req.GetWheelId(), + DrawCount: req.GetDrawCount(), + Amount: req.GetAmount(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.DebitWheelDrawResponse{ + TransactionId: receipt.TransactionID, + CoinSpent: receipt.CoinSpent, + CoinBalanceAfter: receipt.CoinBalanceAfter, + Balance: balanceToProto(receipt.Balance), + }, nil +} + +// CreditTaskReward 处理 activity-service 任务奖励金币入账。 +func (s *Server) CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest) (*walletv1.CreditTaskRewardResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditTaskReward(ctx, ledger.TaskRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + TaskType: req.GetTaskType(), + TaskID: req.GetTaskId(), + CycleKey: req.GetCycleKey(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditTaskRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} + +// CreditLuckyGiftReward 处理 activity-service 幸运礼物抽奖 outbox 的中奖金币入账。 +func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest) (*walletv1.CreditLuckyGiftRewardResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditLuckyGiftReward(ctx, ledger.LuckyGiftRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + DrawID: req.GetDrawId(), + RoomID: req.GetRoomId(), + VisibleRegionID: req.GetVisibleRegionId(), + CountryID: req.GetCountryId(), + GiftID: req.GetGiftId(), + PoolID: req.GetPoolId(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditLuckyGiftRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} + +// CreditWheelReward 处理 activity-service 转盘金币奖品 outbox 的入账。 +func (s *Server) CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest) (*walletv1.CreditWheelRewardResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditWheelReward(ctx, ledger.WheelRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + DrawID: req.GetDrawId(), + WheelID: req.GetWheelId(), + SelectedTierID: req.GetSelectedTierId(), + VisibleRegionID: req.GetVisibleRegionId(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditWheelRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} + +// CreditRoomTurnoverReward 处理 activity-service 每周房间流水奖励金币入账。 +func (s *Server) CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest) (*walletv1.CreditRoomTurnoverRewardResponse, error) { + + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditRoomTurnoverReward(ctx, ledger.RoomTurnoverRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + SettlementID: req.GetSettlementId(), + RoomID: req.GetRoomId(), + PeriodStartMS: req.GetPeriodStartMs(), + PeriodEndMS: req.GetPeriodEndMs(), + CoinSpent: req.GetCoinSpent(), + TierID: req.GetTierId(), + TierCode: req.GetTierCode(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditRoomTurnoverRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} + +// CreditInviteActivityReward 处理 activity-service 邀请活动奖励金币入账。 +func (s *Server) CreditInviteActivityReward(ctx context.Context, req *walletv1.CreditInviteActivityRewardRequest) (*walletv1.CreditInviteActivityRewardResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditInviteActivityReward(ctx, ledger.InviteActivityRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + ClaimID: req.GetClaimId(), + RewardType: req.GetRewardType(), + TierID: req.GetTierId(), + TierCode: req.GetTierCode(), + CycleKey: req.GetCycleKey(), + ReachedValue: req.GetReachedValue(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditInviteActivityRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} + +// CreditAgencyOpeningReward 处理 activity-service 代理开业流水档位奖励金币入账。 +func (s *Server) CreditAgencyOpeningReward(ctx context.Context, req *walletv1.CreditAgencyOpeningRewardRequest) (*walletv1.CreditAgencyOpeningRewardResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.CreditAgencyOpeningReward(ctx, ledger.AgencyOpeningRewardCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Amount: req.GetAmount(), + ApplicationID: req.GetApplicationId(), + CycleID: req.GetCycleId(), + AgencyID: req.GetAgencyId(), + RankNo: req.GetRankNo(), + ScoreCoins: req.GetScoreCoins(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + + return &walletv1.CreditAgencyOpeningRewardResponse{ + TransactionId: receipt.TransactionID, + Amount: receipt.Amount, + GrantedAtMs: receipt.GrantedAtMS, + Balance: &walletv1.AssetBalance{ + AppCode: receipt.Balance.AppCode, + AssetType: receipt.Balance.AssetType, + AvailableAmount: receipt.Balance.AvailableAmount, + FrozenAmount: receipt.Balance.FrozenAmount, + Version: receipt.Balance.Version, + }, + }, nil +} diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index cbe6f0c2..0a66e706 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -1,13 +1,7 @@ package grpc import ( - "context" - "time" - walletv1 "hyapp.local/api/proto/wallet/v1" - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" walletservice "hyapp/services/wallet-service/internal/service/wallet" ) @@ -22,1368 +16,3 @@ type Server struct { func NewServer(svc *walletservice.Service) *Server { return &Server{svc: svc} } - -// DebitGift 只负责 protobuf 转换和错误映射,扣费规则在 service/repository。 -func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.DebitGift(ctx, ledger.DebitGiftCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - RoomID: req.GetRoomId(), - SenderUserID: req.GetSenderUserId(), - TargetUserID: req.GetTargetUserId(), - GiftID: req.GetGiftId(), - GiftCount: req.GetGiftCount(), - PriceVersion: req.GetPriceVersion(), - RegionID: req.GetRegionId(), - SenderRegionID: req.GetSenderRegionId(), - TargetIsHost: req.GetTargetIsHost(), - // 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。 - TargetHostRegionID: req.GetTargetHostRegionId(), - TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), - EntitlementID: req.GetEntitlementId(), - ChargeSource: req.GetChargeSource(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return debitGiftResponseFromReceipt(receipt), nil -} - -// BatchDebitGift 只负责批量目标 protobuf 转换;同事务扣费和幂等由 wallet service/repository 保证。 -func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - targets := make([]ledger.DebitGiftTargetCommand, 0, len(req.GetTargets())) - for _, target := range req.GetTargets() { - targets = append(targets, ledger.DebitGiftTargetCommand{ - CommandID: target.GetCommandId(), - TargetUserID: target.GetTargetUserId(), - TargetIsHost: target.GetTargetIsHost(), - TargetHostRegionID: target.GetTargetHostRegionId(), - TargetAgencyOwnerUserID: target.GetTargetAgencyOwnerUserId(), - }) - } - receipt, err := s.svc.BatchDebitGift(ctx, ledger.BatchDebitGiftCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - RoomID: req.GetRoomId(), - SenderUserID: req.GetSenderUserId(), - GiftID: req.GetGiftId(), - GiftCount: req.GetGiftCount(), - PriceVersion: req.GetPriceVersion(), - RegionID: req.GetRegionId(), - SenderRegionID: req.GetSenderRegionId(), - Targets: targets, - EntitlementID: req.GetEntitlementId(), - ChargeSource: req.GetChargeSource(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - response := &walletv1.BatchDebitGiftResponse{ - Aggregate: debitGiftResponseFromReceipt(receipt.Aggregate), - Receipts: make([]*walletv1.BatchDebitGiftReceipt, 0, len(receipt.Targets)), - } - for _, target := range receipt.Targets { - response.Receipts = append(response.Receipts, &walletv1.BatchDebitGiftReceipt{ - TargetUserId: target.TargetUserID, - CommandId: target.CommandID, - Billing: debitGiftResponseFromReceipt(target.Receipt), - }) - } - return response, nil -} - -// DebitRobotGift 扣机器人专用金币;该入口只供内部机器人房间编排链路调用。 -func (s *Server) DebitRobotGift(ctx context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.DebitRobotGift(ctx, ledger.DebitGiftCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - RoomID: req.GetRoomId(), - SenderUserID: req.GetSenderUserId(), - TargetUserID: req.GetTargetUserId(), - GiftID: req.GetGiftId(), - GiftCount: req.GetGiftCount(), - PriceVersion: req.GetPriceVersion(), - RegionID: req.GetRegionId(), - SenderRegionID: req.GetSenderRegionId(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return debitGiftResponseFromReceipt(receipt), nil -} - -func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftResponse { - return &walletv1.DebitGiftResponse{ - BillingReceiptId: receipt.BillingReceiptID, - BalanceAfter: receipt.BalanceAfter, - TransactionId: receipt.TransactionID, - CoinSpent: receipt.CoinSpent, - ChargeAssetType: receipt.ChargeAssetType, - ChargeAmount: receipt.ChargeAmount, - GiftPointAdded: receipt.GiftPointAdded, - HeatValue: receipt.HeatValue, - GiftTypeCode: receipt.GiftTypeCode, - CpRelationType: receipt.CPRelationType, - GiftName: receipt.GiftName, - GiftIconUrl: receipt.GiftIconURL, - GiftAnimationUrl: receipt.GiftAnimationURL, - GiftEffectTypes: receipt.GiftEffectTypes, - PriceVersion: receipt.PriceVersion, - HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded, - HostPeriodCycleKey: receipt.HostPeriodCycleKey, - EntitlementId: receipt.EntitlementID, - ChargeSource: receipt.ChargeSource, - } -} - -// GetBalances 返回用户多资产余额投影。 -func (s *Server) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - balances, err := s.svc.GetBalances(ctx, req.GetUserId(), req.GetAssetTypes()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - response := &walletv1.GetBalancesResponse{Balances: make([]*walletv1.AssetBalance, 0, len(balances))} - for _, balance := range balances { - response.Balances = append(response.Balances, &walletv1.AssetBalance{ - AppCode: balance.AppCode, - AssetType: balance.AssetType, - AvailableAmount: balance.AvailableAmount, - FrozenAmount: balance.FrozenAmount, - Version: balance.Version, - }) - } - - return response, nil -} - -// GetActiveHostSalaryPolicy 读取当前生效的主播工资政策,供 gateway 按当前主播区域展示平台规则。 -func (s *Server) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - policy, found, err := s.svc.GetActiveHostSalaryPolicy(ctx, req.GetAppCode(), req.GetRegionId(), req.GetSettlementMode(), req.GetSettlementTriggerMode(), req.GetNowMs()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - response := &walletv1.GetActiveHostSalaryPolicyResponse{Found: found} - if found { - response.Policy = hostSalaryPolicyToProto(policy) - } - return response, nil -} - -// GetHostSalaryProgress 读取主播当前工资周期钻石累计,gateway 用它计算工资政策等级进度。 -func (s *Server) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - progress, err := s.svc.GetHostSalaryProgress(ctx, req.GetAppCode(), req.GetHostUserId(), req.GetCycleKey(), req.GetNowMs()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GetHostSalaryProgressResponse{Progress: hostSalaryProgressToProto(progress)}, nil -} - -func hostSalaryProgressToProto(progress ledger.HostSalaryProgress) *walletv1.HostSalaryProgress { - return &walletv1.HostSalaryProgress{ - HostUserId: progress.HostUserID, - CycleKey: progress.CycleKey, - RegionId: progress.RegionID, - AgencyOwnerUserId: progress.AgencyOwnerUserID, - TotalDiamonds: progress.TotalDiamonds, - GiftDiamondTotal: progress.GiftDiamondTotal, - UpdatedAtMs: progress.UpdatedAtMS, - } -} - -func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalaryPolicy { - levels := make([]*walletv1.HostSalaryPolicyLevel, 0, len(policy.Levels)) - for _, level := range policy.Levels { - levels = append(levels, hostSalaryPolicyLevelToProto(level)) - } - return &walletv1.HostSalaryPolicy{ - PolicyId: policy.PolicyID, - Name: policy.Name, - RegionId: policy.RegionID, - Status: policy.Status, - SettlementMode: policy.SettlementMode, - SettlementTriggerMode: policy.SettlementTriggerMode, - GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio, - ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate, - EffectiveFromMs: policy.EffectiveFromMs, - EffectiveToMs: policy.EffectiveToMs, - Levels: levels, - } -} - -func hostSalaryPolicyLevelToProto(level ledger.HostSalaryPolicyLevel) *walletv1.HostSalaryPolicyLevel { - return &walletv1.HostSalaryPolicyLevel{ - LevelNo: int32(level.LevelNo), - RequiredDiamonds: level.RequiredDiamonds, - HostSalaryUsdMinor: level.HostSalaryUSDMinor, - HostCoinReward: level.HostCoinReward, - AgencySalaryUsdMinor: level.AgencySalaryUSDMinor, - Status: level.Status, - SortOrder: int32(level.SortOrder), - } -} - -// GetWalletOverview 返回钱包首页摘要,供我的页和钱包二级页复用。 -func (s *Server) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - overview, err := s.svc.GetWalletOverview(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GetWalletOverviewResponse{ - Balances: balancesToProto(overview.Balances), - FeatureFlags: walletFeatureFlagsToProto(overview.FeatureFlags), - }, nil -} - -// GetWalletValueSummary 返回我的页钱包卡片最小金币余额。 -func (s *Server) GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - summary, err := s.svc.GetWalletValueSummary(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{ - CoinAmount: summary.CoinAmount, - UpdatedAtMs: summary.UpdatedAtMS, - }}, nil -} - -// GetUserGiftWall 返回当前用户收到礼物的聚合墙;调用方负责限定 user_id 为当前登录用户。 -func (s *Server) GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - wall, err := s.svc.GetUserGiftWall(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.GetUserGiftWallResponse{ - Items: make([]*walletv1.GiftWallItem, 0, len(wall.Items)), - GiftKindCount: wall.GiftKindCount, - GiftTotalCount: wall.GiftTotalCount, - TotalValue: wall.TotalValue, - TotalCoinValue: wall.TotalCoinValue, - TotalDiamondValue: wall.TotalDiamondValue, - TotalGiftPoint: wall.TotalGiftPoint, - TotalHeatValue: wall.TotalHeatValue, - } - for _, item := range wall.Items { - resp.Items = append(resp.Items, giftWallItemToProto(item)) - } - return resp, nil -} - -// ListRechargeProducts 返回当前用户区域可用的充值渠道和档位。 -func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - products, channels, err := s.svc.ListRechargeProducts(ctx, req.GetUserId(), req.GetRegionId(), req.GetPlatform(), req.GetAudienceType()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListRechargeProductsResponse{Channels: channels, Products: make([]*walletv1.RechargeProduct, 0, len(products))} - for _, product := range products { - resp.Products = append(resp.Products, rechargeProductToProto(product)) - } - return resp, nil -} - -// ConfirmGooglePayment 校验 Google Play purchase token 并完成钱包入账。 -func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.ConfirmGooglePayment(ctx, ledger.GooglePaymentCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - RegionID: req.GetRegionId(), - ProductID: req.GetProductId(), - ProductCode: req.GetProductCode(), - PackageName: req.GetPackageName(), - PurchaseToken: req.GetPurchaseToken(), - OrderID: req.GetOrderId(), - PurchaseTimeMS: req.GetPurchaseTimeMs(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.ConfirmGooglePaymentResponse{ - PaymentOrderId: receipt.PaymentOrderID, - TransactionId: receipt.TransactionID, - Status: receipt.Status, - ProductId: receipt.ProductID, - ProductCode: receipt.ProductCode, - CoinAmount: receipt.CoinAmount, - Balance: balanceToProto(receipt.Balance), - IdempotentReplay: receipt.IdempotentReplay, - ConsumeState: receipt.ConsumeState, - }, nil -} - -// ListAdminRechargeProducts 返回后台内购配置列表。 -func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - products, total, err := s.svc.ListAdminRechargeProducts(ctx, ledger.ListRechargeProductsQuery{ - AppCode: req.GetAppCode(), - Status: req.GetStatus(), - Platform: req.GetPlatform(), - RegionID: req.GetRegionId(), - Keyword: req.GetKeyword(), - Page: req.GetPage(), - PageSize: req.GetPageSize(), - AudienceType: req.GetAudienceType(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListAdminRechargeProductsResponse{Products: make([]*walletv1.RechargeProduct, 0, len(products)), Total: total} - for _, product := range products { - resp.Products = append(resp.Products, rechargeProductToProto(product)) - } - return resp, nil -} - -// CreateRechargeProduct 创建后台内购商品配置。 -func (s *Server) CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - product, err := s.svc.CreateRechargeProduct(ctx, ledger.RechargeProductCommand{ - AppCode: req.GetAppCode(), - AmountMicro: req.GetAmountMicro(), - CoinAmount: req.GetCoinAmount(), - ProductName: req.GetProductName(), - Description: req.GetDescription(), - Platform: req.GetPlatform(), - RegionIDs: req.GetRegionIds(), - Enabled: req.GetEnabled(), - OperatorUserID: req.GetOperatorUserId(), - AudienceType: req.GetAudienceType(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.RechargeProductResponse{Product: rechargeProductToProto(product)}, nil -} - -// UpdateRechargeProduct 修改后台内购商品配置。 -func (s *Server) UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - product, err := s.svc.UpdateRechargeProduct(ctx, ledger.RechargeProductCommand{ - AppCode: req.GetAppCode(), - ProductID: req.GetProductId(), - AmountMicro: req.GetAmountMicro(), - CoinAmount: req.GetCoinAmount(), - ProductName: req.GetProductName(), - Description: req.GetDescription(), - Platform: req.GetPlatform(), - RegionIDs: req.GetRegionIds(), - Enabled: req.GetEnabled(), - OperatorUserID: req.GetOperatorUserId(), - AudienceType: req.GetAudienceType(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.RechargeProductResponse{Product: rechargeProductToProto(product)}, nil -} - -// DeleteRechargeProduct 删除后台内购商品配置。 -func (s *Server) DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - if err := s.svc.DeleteRechargeProduct(ctx, req.GetAppCode(), req.GetProductId()); err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.DeleteRechargeProductResponse{Deleted: true}, nil -} - -// ListThirdPartyPaymentChannels 返回三方支付渠道、国家和方式树,后台用它直接渲染二级展开列表。 -func (s *Server) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - channels, err := s.svc.ListThirdPartyPaymentChannels(ctx, ledger.ListThirdPartyPaymentChannelsQuery{ - AppCode: req.GetAppCode(), - ProviderCode: req.GetProviderCode(), - Status: req.GetStatus(), - IncludeDisabledMethods: req.GetIncludeDisabledMethods(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListThirdPartyPaymentChannelsResponse{Channels: make([]*walletv1.ThirdPartyPaymentChannel, 0, len(channels))} - for _, channel := range channels { - resp.Channels = append(resp.Channels, thirdPartyPaymentChannelToProto(channel)) - } - return resp, nil -} - -// SetThirdPartyPaymentMethodStatus 开关某个国家下的具体支付方式;渠道本身不被连带关闭。 -func (s *Server) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - method, err := s.svc.SetThirdPartyPaymentMethodStatus(ctx, ledger.ThirdPartyPaymentMethodStatusCommand{ - AppCode: req.GetAppCode(), - MethodID: req.GetMethodId(), - Enabled: req.GetEnabled(), - OperatorUserID: req.GetOperatorUserId(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.ThirdPartyPaymentMethodResponse{Method: thirdPartyPaymentMethodToProto(method)}, nil -} - -// UpdateThirdPartyPaymentRate 保存 USD 到国家本币汇率;H5 只展示已配置正汇率的国家方式。 -func (s *Server) UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - method, err := s.svc.UpdateThirdPartyPaymentRate(ctx, ledger.ThirdPartyPaymentRateCommand{ - AppCode: req.GetAppCode(), - MethodID: req.GetMethodId(), - USDToCurrencyRate: req.GetUsdToCurrencyRate(), - OperatorUserID: req.GetOperatorUserId(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.ThirdPartyPaymentMethodResponse{Method: thirdPartyPaymentMethodToProto(method)}, nil -} - -// ListH5RechargeOptions 返回 H5 当前账号的商品和支付方式,普通用户和币商由 audience_type 分流。 -func (s *Server) ListH5RechargeOptions(ctx context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - options, err := s.svc.ListH5RechargeOptions(ctx, ledger.H5RechargeOptionsQuery{ - AppCode: req.GetAppCode(), - TargetUserID: req.GetTargetUserId(), - TargetRegionID: req.GetTargetRegionId(), - TargetCountryCode: req.GetTargetCountryCode(), - AudienceType: req.GetAudienceType(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.H5RechargeOptionsResponse{ - Products: make([]*walletv1.RechargeProduct, 0, len(options.Products)), - PaymentMethods: make([]*walletv1.ThirdPartyPaymentMethod, 0, len(options.PaymentMethods)), - UsdtTrc20Enabled: options.USDTTRC20Enabled, - UsdtTrc20Address: options.USDTTRC20Address, - } - for _, product := range options.Products { - resp.Products = append(resp.Products, rechargeProductToProto(product)) - } - for _, method := range options.PaymentMethods { - resp.PaymentMethods = append(resp.PaymentMethods, thirdPartyPaymentMethodToProto(method)) - } - return resp, nil -} - -// CreateH5RechargeOrder 创建外部充值订单;MiFaPay 只返回 pay_url,USDT 只返回共享收款地址。 -func (s *Server) CreateH5RechargeOrder(ctx context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - order, err := s.svc.CreateH5RechargeOrder(ctx, ledger.CreateExternalRechargeOrderCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - TargetRegionID: req.GetTargetRegionId(), - TargetCountryCode: req.GetTargetCountryCode(), - AudienceType: req.GetAudienceType(), - ProductID: req.GetProductId(), - ProviderCode: req.GetProviderCode(), - PaymentMethodID: req.GetPaymentMethodId(), - ReturnURL: req.GetReturnUrl(), - NotifyURL: req.GetNotifyUrl(), - ClientIP: req.GetClientIp(), - Language: req.GetLanguage(), - PayerName: req.GetPayerName(), - PayerAccount: req.GetPayerAccount(), - PayerEmail: req.GetPayerEmail(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil -} - -// SubmitH5RechargeTx 接收 USDT tx_hash;链上校验和幂等入账仍在 wallet-service 事务里完成。 -func (s *Server) SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - order, err := s.svc.SubmitH5RechargeTx(ctx, ledger.SubmitExternalRechargeTxCommand{ - AppCode: req.GetAppCode(), - OrderID: req.GetOrderId(), - TargetUserID: req.GetTargetUserId(), - TxHash: req.GetTxHash(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil -} - -// GetH5RechargeOrder 给 H5 轮询订单状态;target_user_id 用于避免无 token 场景越权看单。 -func (s *Server) GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - order, err := s.svc.GetH5RechargeOrder(ctx, req.GetAppCode(), req.GetOrderId(), req.GetTargetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil -} - -// HandleMifapayNotify 验签并处理 MiFaPay 回调;成功或重复成功由 HTTP 层按 response_text 返回大写 SUCCESS。 -func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - order, accepted, err := s.svc.HandleMifapayNotify(ctx, req.GetAppCode(), ledger.MifaPayNotification{ - MerAccount: req.GetMerAccount(), - Data: req.GetData(), - Sign: req.GetSign(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - responseText := "FAIL" - if accepted { - responseText = "SUCCESS" - } - return &walletv1.HandleMifapayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil -} - -// HandleV5PayNotify 验签并处理 V5Pay 回调;成功或重复成功由 HTTP 层按 response_text 返回小写 success。 -func (s *Server) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - order, accepted, err := s.svc.HandleV5PayNotify(ctx, req.GetAppCode(), ledger.V5PayNotification{ - Fields: req.GetFields(), - RawJSON: req.GetRawJson(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - responseText := "fail" - if accepted { - responseText = "success" - } - return &walletv1.HandleV5PayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil -} - -// GetDiamondExchangeConfig 返回钻石兑换配置。 -func (s *Server) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - rules, err := s.svc.GetDiamondExchangeConfig(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.GetDiamondExchangeConfigResponse{Rules: make([]*walletv1.DiamondExchangeRule, 0, len(rules))} - for _, rule := range rules { - resp.Rules = append(resp.Rules, &walletv1.DiamondExchangeRule{ - ExchangeType: rule.ExchangeType, - FromAssetType: rule.FromAssetType, - ToAssetType: rule.ToAssetType, - FromAmount: rule.FromAmount, - ToAmount: rule.ToAmount, - Enabled: rule.Enabled, - }) - } - return resp, nil -} - -func rechargeProductToProto(product ledger.RechargeProduct) *walletv1.RechargeProduct { - return &walletv1.RechargeProduct{ - AppCode: product.AppCode, - AudienceType: product.AudienceType, - ProductId: product.ProductID, - ProductCode: product.ProductCode, - ProductName: product.ProductName, - Description: product.Description, - Platform: product.Platform, - Channel: product.Channel, - CurrencyCode: product.CurrencyCode, - AmountMicro: product.AmountMicro, - AmountMinor: product.AmountMinor, - CoinAmount: product.CoinAmount, - PolicyVersion: product.PolicyVersion, - Status: product.Status, - Enabled: product.Enabled, - SortOrder: product.SortOrder, - RegionIds: product.RegionIDs, - ResourceAssetType: product.ResourceAssetType, - CreatedByUserId: product.CreatedByUserID, - UpdatedByUserId: product.UpdatedByUserID, - CreatedAtMs: product.CreatedAtMS, - UpdatedAtMs: product.UpdatedAtMS, - } -} - -func thirdPartyPaymentChannelToProto(channel ledger.ThirdPartyPaymentChannel) *walletv1.ThirdPartyPaymentChannel { - resp := &walletv1.ThirdPartyPaymentChannel{ - AppCode: channel.AppCode, - ProviderCode: channel.ProviderCode, - ProviderName: channel.ProviderName, - Status: channel.Status, - SortOrder: channel.SortOrder, - Methods: make([]*walletv1.ThirdPartyPaymentMethod, 0, len(channel.Methods)), - } - for _, method := range channel.Methods { - resp.Methods = append(resp.Methods, thirdPartyPaymentMethodToProto(method)) - } - return resp -} - -func thirdPartyPaymentMethodToProto(method ledger.ThirdPartyPaymentMethod) *walletv1.ThirdPartyPaymentMethod { - return &walletv1.ThirdPartyPaymentMethod{ - MethodId: method.MethodID, - AppCode: method.AppCode, - ProviderCode: method.ProviderCode, - ProviderName: method.ProviderName, - CountryCode: method.CountryCode, - CountryName: method.CountryName, - CurrencyCode: method.CurrencyCode, - PayWay: method.PayWay, - PayType: method.PayType, - MethodName: method.MethodName, - LogoUrl: method.LogoURL, - Status: method.Status, - UsdToCurrencyRate: method.USDToCurrencyRate, - SortOrder: method.SortOrder, - CreatedAtMs: method.CreatedAtMS, - UpdatedAtMs: method.UpdatedAtMS, - } -} - -func externalRechargeOrderToProto(order ledger.ExternalRechargeOrder) *walletv1.ExternalRechargeOrder { - return &walletv1.ExternalRechargeOrder{ - OrderId: order.OrderID, - AppCode: order.AppCode, - TargetUserId: order.TargetUserID, - TargetRegionId: order.TargetRegionID, - TargetCountryCode: order.TargetCountryCode, - AudienceType: order.AudienceType, - ProductId: order.ProductID, - ProductName: order.ProductName, - CoinAmount: order.CoinAmount, - UsdMinorAmount: order.USDMinorAmount, - ProviderCode: order.ProviderCode, - PaymentMethodId: order.PaymentMethodID, - CountryCode: order.CountryCode, - CurrencyCode: order.CurrencyCode, - ProviderAmountMinor: order.ProviderAmountMinor, - PayWay: order.PayWay, - PayType: order.PayType, - PayUrl: order.PayURL, - ProviderOrderId: order.ProviderOrderID, - TxHash: order.TxHash, - ReceiveAddress: order.ReceiveAddress, - Status: order.Status, - FailureReason: order.FailureReason, - TransactionId: order.TransactionID, - CreatedAtMs: order.CreatedAtMS, - UpdatedAtMs: order.UpdatedAtMS, - IdempotentReplay: order.IdempotentReplay, - } -} - -// ListWalletTransactions 返回当前用户钱包流水。 -func (s *Server) ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - items, total, err := s.svc.ListWalletTransactions(ctx, ledger.ListWalletTransactionsQuery{ - AppCode: req.GetAppCode(), - UserID: req.GetUserId(), - AssetType: req.GetAssetType(), - Page: req.GetPage(), - PageSize: req.GetPageSize(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListWalletTransactionsResponse{Transactions: make([]*walletv1.WalletTransaction, 0, len(items)), Total: total} - for _, item := range items { - resp.Transactions = append(resp.Transactions, &walletv1.WalletTransaction{ - EntryId: item.EntryID, - TransactionId: item.TransactionID, - BizType: item.BizType, - AssetType: item.AssetType, - AvailableDelta: item.AvailableDelta, - FrozenDelta: item.FrozenDelta, - AvailableAfter: item.AvailableAfter, - FrozenAfter: item.FrozenAfter, - CounterpartyUserId: item.CounterpartyUserID, - RoomId: item.RoomID, - CreatedAtMs: item.CreatedAtMS, - }) - } - return resp, nil -} - -// ListVipPackages 返回当前 VIP 和可购买包列表。 -func (s *Server) ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - current, levels, err := s.svc.ListVipPackages(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListVipPackagesResponse{CurrentVip: vipToProto(current), Packages: make([]*walletv1.VipLevel, 0, len(levels))} - for _, level := range levels { - resp.Packages = append(resp.Packages, vipLevelToProto(level)) - } - return resp, nil -} - -// GetMyVip 返回当前登录用户 VIP 状态。 -func (s *Server) GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - vip, err := s.svc.GetMyVip(ctx, req.GetUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GetMyVipResponse{Vip: vipToProto(vip)}, nil -} - -// PurchaseVip 购买、续期或升级 VIP。 -func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.PurchaseVip(ctx, ledger.PurchaseVipCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - Level: req.GetLevel(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.PurchaseVipResponse{ - OrderId: receipt.OrderID, - TransactionId: receipt.TransactionID, - Vip: vipToProto(receipt.Vip), - CoinSpent: receipt.CoinSpent, - CoinBalanceAfter: receipt.CoinBalanceAfter, - RewardItems: vipRewardItemsToProto(receipt.RewardItems), - }, nil -} - -// DebitCPBreakupFee 扣除解除 CP/兄弟/姐妹关系费用;关系状态确认由 user-service 负责。 -func (s *Server) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.DebitCPBreakupFee(ctx, ledger.CPBreakupFeeCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - RelationshipID: req.GetRelationshipId(), - RelationType: req.GetRelationType(), - Amount: req.GetAmount(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.DebitCPBreakupFeeResponse{ - TransactionId: receipt.TransactionID, - CoinSpent: receipt.CoinSpent, - CoinBalanceAfter: receipt.CoinBalanceAfter, - Balance: balanceToProto(receipt.Balance), - }, nil -} - -// DebitWheelDraw 扣除转盘抽奖金币;中奖结果不在 wallet 内生成,避免钱包反向拥有活动规则。 -func (s *Server) DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.DebitWheelDraw(ctx, ledger.WheelDrawDebitCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - WheelID: req.GetWheelId(), - DrawCount: req.GetDrawCount(), - Amount: req.GetAmount(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.DebitWheelDrawResponse{ - TransactionId: receipt.TransactionID, - CoinSpent: receipt.CoinSpent, - CoinBalanceAfter: receipt.CoinBalanceAfter, - Balance: balanceToProto(receipt.Balance), - }, nil -} - -// GrantVip 统一处理活动和后台赠送 VIP。 -func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.GrantVip(ctx, ledger.GrantVipCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Level: req.GetLevel(), - GrantSource: req.GetGrantSource(), - OperatorUserID: req.GetOperatorUserId(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GrantVipResponse{ - TransactionId: receipt.TransactionID, - Vip: vipToProto(receipt.Vip), - RewardItems: vipRewardItemsToProto(receipt.RewardItems), - ServerTimeMs: time.Now().UnixMilli(), - }, nil -} - -// ListAdminVipLevels 返回后台 VIP 配置。 -func (s *Server) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - levels, err := s.svc.ListAdminVipLevels(ctx) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.ListAdminVipLevelsResponse{Levels: make([]*walletv1.VipLevel, 0, len(levels)), ServerTimeMs: time.Now().UnixMilli()} - for _, level := range levels { - resp.Levels = append(resp.Levels, vipLevelToProto(level)) - } - return resp, nil -} - -// UpdateAdminVipLevels 保存后台 VIP 配置。 -func (s *Server) UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - commands := make([]ledger.AdminVipLevelCommand, 0, len(req.GetLevels())) - for _, item := range req.GetLevels() { - if item == nil { - continue - } - commands = append(commands, ledger.AdminVipLevelCommand{ - Level: item.GetLevel(), - Name: item.GetName(), - Status: item.GetStatus(), - PriceCoin: item.GetPriceCoin(), - DurationMS: item.GetDurationMs(), - RewardResourceGroupID: item.GetRewardResourceGroupId(), - RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(), - SortOrder: item.GetSortOrder(), - }) - } - levels, err := s.svc.UpdateAdminVipLevels(ctx, commands, req.GetOperatorUserId()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - resp := &walletv1.UpdateAdminVipLevelsResponse{Levels: make([]*walletv1.VipLevel, 0, len(levels)), ServerTimeMs: time.Now().UnixMilli()} - for _, level := range levels { - resp.Levels = append(resp.Levels, vipLevelToProto(level)) - } - return resp, nil -} - -// AdminCreditAsset 处理后台手动调账,公网权限和审计入口由 gateway/admin 层控制。 -func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - balance, transactionID, err := s.svc.AdminCreditAsset(ctx, ledger.AdminCreditAssetCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - AssetType: req.GetAssetType(), - Amount: req.GetAmount(), - OperatorUserID: req.GetOperatorUserId(), - Reason: req.GetReason(), - EvidenceRef: req.GetEvidenceRef(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.AdminCreditAssetResponse{ - TransactionId: transactionID, - Balance: &walletv1.AssetBalance{ - AppCode: balance.AppCode, - AssetType: balance.AssetType, - AvailableAmount: balance.AvailableAmount, - FrozenAmount: balance.FrozenAmount, - Version: balance.Version, - }, - }, nil -} - -func balancesToProto(balances []ledger.AssetBalance) []*walletv1.AssetBalance { - items := make([]*walletv1.AssetBalance, 0, len(balances)) - for _, balance := range balances { - items = append(items, balanceToProto(balance)) - } - return items -} - -func balanceToProto(balance ledger.AssetBalance) *walletv1.AssetBalance { - return &walletv1.AssetBalance{ - AppCode: balance.AppCode, - AssetType: balance.AssetType, - AvailableAmount: balance.AvailableAmount, - FrozenAmount: balance.FrozenAmount, - Version: balance.Version, - } -} - -func giftWallItemToProto(item ledger.GiftWallItem) *walletv1.GiftWallItem { - return &walletv1.GiftWallItem{ - GiftId: item.GiftID, - GiftName: item.GiftName, - ResourceId: item.ResourceID, - Resource: giftWallResourceToProto(item.Resource), - ResourceSnapshotJson: item.ResourceSnapshotJSON, - GiftTypeCode: item.GiftTypeCode, - PresentationJson: item.PresentationJSON, - GiftCount: item.GiftCount, - TotalValue: item.TotalValue, - TotalCoinValue: item.TotalCoinValue, - TotalDiamondValue: item.TotalDiamondValue, - TotalGiftPoint: item.TotalGiftPoint, - TotalHeatValue: item.TotalHeatValue, - ChargeAssetType: item.ChargeAssetType, - LastPriceVersion: item.LastPriceVersion, - FirstReceivedAtMs: item.FirstReceivedAtMS, - LastReceivedAtMs: item.LastReceivedAtMS, - SortOrder: item.SortOrder, - } -} - -func giftWallResourceToProto(resource ledger.GiftWallResource) *walletv1.Resource { - if resource.ResourceID == 0 { - return nil - } - return &walletv1.Resource{ - AppCode: resource.AppCode, - ResourceId: resource.ResourceID, - ResourceCode: resource.ResourceCode, - ResourceType: resource.ResourceType, - Name: resource.Name, - Status: resource.Status, - Grantable: resource.Grantable, - GrantStrategy: resource.GrantStrategy, - WalletAssetType: resource.WalletAssetType, - WalletAssetAmount: resource.WalletAssetAmount, - UsageScopes: resource.UsageScopes, - AssetUrl: resource.AssetURL, - PreviewUrl: resource.PreviewURL, - AnimationUrl: resource.AnimationURL, - MetadataJson: resource.MetadataJSON, - SortOrder: resource.SortOrder, - CreatedAtMs: resource.CreatedAtMS, - UpdatedAtMs: resource.UpdatedAtMS, - } -} - -func walletFeatureFlagsToProto(flags ledger.WalletFeatureFlags) *walletv1.WalletFeatureFlags { - return &walletv1.WalletFeatureFlags{ - RechargeEnabled: flags.RechargeEnabled, - DiamondExchangeEnabled: flags.DiamondExchangeEnabled, - } -} - -func vipToProto(vip ledger.UserVip) *walletv1.UserVip { - return &walletv1.UserVip{ - UserId: vip.UserID, - Level: vip.Level, - Name: vip.Name, - Active: vip.Active, - StartedAtMs: vip.StartedAtMS, - ExpiresAtMs: vip.ExpiresAtMS, - UpdatedAtMs: vip.UpdatedAtMS, - } -} - -func vipLevelToProto(level ledger.VipLevel) *walletv1.VipLevel { - return &walletv1.VipLevel{ - Level: level.Level, - Name: level.Name, - Status: level.Status, - PriceCoin: level.PriceCoin, - DurationMs: level.DurationMS, - RewardResourceGroupId: level.RewardResourceGroupID, - RewardItems: vipRewardItemsToProto(level.RewardItems), - CanPurchase: level.CanPurchase, - SortOrder: level.SortOrder, - RechargeGateRequired: level.RechargeGateRequired, - RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount, - UserRechargeCoinAmount: level.UserRechargeCoinAmount, - PurchaseLockedReason: level.PurchaseLockedReason, - CreatedAtMs: level.CreatedAtMS, - UpdatedAtMs: level.UpdatedAtMS, - } -} - -func vipRewardItemsToProto(items []ledger.VipRewardItem) []*walletv1.VipRewardItem { - resp := make([]*walletv1.VipRewardItem, 0, len(items)) - for _, item := range items { - resp = append(resp, &walletv1.VipRewardItem{ - ResourceId: item.ResourceID, - ResourceCode: item.ResourceCode, - ResourceType: item.ResourceType, - Name: item.Name, - Quantity: item.Quantity, - ExpiresAtMs: item.ExpiresAtMS, - AssetUrl: item.AssetURL, - PreviewUrl: item.PreviewURL, - AnimationUrl: item.AnimationURL, - }) - } - return resp -} - -// CreditTaskReward 处理 activity-service 任务奖励金币入账。 -func (s *Server) CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest) (*walletv1.CreditTaskRewardResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditTaskReward(ctx, ledger.TaskRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - TaskType: req.GetTaskType(), - TaskID: req.GetTaskId(), - CycleKey: req.GetCycleKey(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditTaskRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// CreditLuckyGiftReward 处理 activity-service 幸运礼物抽奖 outbox 的中奖金币入账。 -func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest) (*walletv1.CreditLuckyGiftRewardResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditLuckyGiftReward(ctx, ledger.LuckyGiftRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - DrawID: req.GetDrawId(), - RoomID: req.GetRoomId(), - VisibleRegionID: req.GetVisibleRegionId(), - CountryID: req.GetCountryId(), - GiftID: req.GetGiftId(), - PoolID: req.GetPoolId(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditLuckyGiftRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// CreditWheelReward 处理 activity-service 转盘金币奖品 outbox 的入账。 -func (s *Server) CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest) (*walletv1.CreditWheelRewardResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditWheelReward(ctx, ledger.WheelRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - DrawID: req.GetDrawId(), - WheelID: req.GetWheelId(), - SelectedTierID: req.GetSelectedTierId(), - VisibleRegionID: req.GetVisibleRegionId(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditWheelRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// CreditRoomTurnoverReward 处理 activity-service 每周房间流水奖励金币入账。 -func (s *Server) CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest) (*walletv1.CreditRoomTurnoverRewardResponse, error) { - // gRPC 层不解释活动规则,只把 activity-service 的结算命令转换为钱包领域命令。 - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditRoomTurnoverReward(ctx, ledger.RoomTurnoverRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - SettlementID: req.GetSettlementId(), - RoomID: req.GetRoomId(), - PeriodStartMS: req.GetPeriodStartMs(), - PeriodEndMS: req.GetPeriodEndMs(), - CoinSpent: req.GetCoinSpent(), - TierID: req.GetTierId(), - TierCode: req.GetTierCode(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditRoomTurnoverRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// CreditInviteActivityReward 处理 activity-service 邀请活动奖励金币入账。 -func (s *Server) CreditInviteActivityReward(ctx context.Context, req *walletv1.CreditInviteActivityRewardRequest) (*walletv1.CreditInviteActivityRewardResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditInviteActivityReward(ctx, ledger.InviteActivityRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - ClaimID: req.GetClaimId(), - RewardType: req.GetRewardType(), - TierID: req.GetTierId(), - TierCode: req.GetTierCode(), - CycleKey: req.GetCycleKey(), - ReachedValue: req.GetReachedValue(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditInviteActivityRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// CreditAgencyOpeningReward 处理 activity-service 代理开业流水档位奖励金币入账。 -func (s *Server) CreditAgencyOpeningReward(ctx context.Context, req *walletv1.CreditAgencyOpeningRewardRequest) (*walletv1.CreditAgencyOpeningRewardResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.CreditAgencyOpeningReward(ctx, ledger.AgencyOpeningRewardCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - TargetUserID: req.GetTargetUserId(), - Amount: req.GetAmount(), - ApplicationID: req.GetApplicationId(), - CycleID: req.GetCycleId(), - AgencyID: req.GetAgencyId(), - RankNo: req.GetRankNo(), - ScoreCoins: req.GetScoreCoins(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.CreditAgencyOpeningRewardResponse{ - TransactionId: receipt.TransactionID, - Amount: receipt.Amount, - GrantedAtMs: receipt.GrantedAtMS, - Balance: &walletv1.AssetBalance{ - AppCode: receipt.Balance.AppCode, - AssetType: receipt.Balance.AssetType, - AvailableAmount: receipt.Balance.AvailableAmount, - FrozenAmount: receipt.Balance.FrozenAmount, - Version: receipt.Balance.Version, - }, - }, nil -} - -// ApplyGameCoinChange 处理 game-service 发起的游戏专用金币改账。 -func (s *Server) ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.ApplyGameCoinChange(ctx, ledger.GameCoinChangeCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - PlatformCode: req.GetPlatformCode(), - GameID: req.GetGameId(), - ProviderOrderID: req.GetProviderOrderId(), - ProviderRoundID: req.GetProviderRoundId(), - OpType: req.GetOpType(), - CoinAmount: req.GetCoinAmount(), - RoomID: req.GetRoomId(), - RequestHash: req.GetRequestHash(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.ApplyGameCoinChangeResponse{ - WalletTransactionId: receipt.TransactionID, - BalanceAfter: receipt.BalanceAfter, - IdempotentReplay: receipt.IdempotentReplay, - }, nil -} - -// AdminCreditCoinSellerStock 处理后台币商进货或金币补偿,币商身份由 admin-server 先行校验。 -func (s *Server) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.AdminCreditCoinSellerStock(ctx, ledger.CoinSellerStockCreditCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - SellerUserID: req.GetSellerUserId(), - SellerCountryID: req.GetSellerCountryId(), - SellerRegionID: req.GetSellerRegionId(), - StockType: req.GetStockType(), - CoinAmount: req.GetCoinAmount(), - PaidCurrencyCode: req.GetPaidCurrencyCode(), - PaidAmountMicro: req.GetPaidAmountMicro(), - PaymentRef: req.GetPaymentRef(), - EvidenceRef: req.GetEvidenceRef(), - OperatorUserID: req.GetOperatorUserId(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.AdminCreditCoinSellerStockResponse{ - TransactionId: receipt.TransactionID, - SellerUserId: receipt.SellerUserID, - StockType: receipt.StockType, - CoinAmount: receipt.CoinAmount, - PaidCurrencyCode: receipt.PaidCurrencyCode, - PaidAmountMicro: receipt.PaidAmountMicro, - CountsAsSellerRecharge: receipt.CountsAsSellerRecharge, - BalanceAfter: receipt.BalanceAfter, - CreatedAtMs: receipt.CreatedAtMS, - }, nil -} - -// TransferCoinFromSeller 处理币商转玩家金币,调用方必须先完成币商身份校验。 -func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - SellerUserID: req.GetSellerUserId(), - TargetUserID: req.GetTargetUserId(), - TargetCountryID: req.GetTargetCountryId(), - SellerRegionID: req.GetSellerRegionId(), - TargetRegionID: req.GetTargetRegionId(), - Amount: req.GetAmount(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - - return &walletv1.TransferCoinFromSellerResponse{ - TransactionId: receipt.TransactionID, - SellerBalanceAfter: receipt.SellerBalanceAfter, - TargetBalanceAfter: receipt.TargetBalanceAfter, - Amount: receipt.Amount, - RechargeUsdMinor: receipt.RechargeUSDMinor, - RechargeCurrencyCode: receipt.RechargeCurrencyCode, - RechargePolicyId: receipt.RechargePolicyID, - RechargePolicyVersion: receipt.RechargePolicyVersion, - RechargePolicyCoinAmount: receipt.RechargePolicyCoinAmount, - RechargePolicyUsdMinorAmount: receipt.RechargePolicyUSDMinorUnit, - }, nil -} - -// ListCoinSellerSalaryExchangeRateTiers 返回工资转币商使用的区域比例区间,调用方用它做预计到账展示。 -func (s *Server) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - tiers, err := s.svc.ListCoinSellerSalaryExchangeRateTiers(ctx, req.GetAppCode(), req.GetRegionId(), req.GetIncludeDisabled()) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - response := &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{Tiers: make([]*walletv1.CoinSellerSalaryExchangeRateTier, 0, len(tiers))} - for _, tier := range tiers { - response.Tiers = append(response.Tiers, &walletv1.CoinSellerSalaryExchangeRateTier{ - RegionId: tier.RegionID, - MinUsdMinor: tier.MinUSDMinor, - MaxUsdMinor: tier.MaxUSDMinor, - CoinPerUsd: tier.CoinPerUSD, - Status: tier.Status, - SortOrder: int32(tier.SortOrder), - UpdatedAtMs: tier.UpdatedAtMS, - }) - } - return response, nil -} - -// ExchangeSalaryToCoin 处理用户工资兑换普通金币,身份资产已经由 gateway 校验并注入。 -func (s *Server) ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.ExchangeSalaryToCoin(ctx, ledger.SalaryExchangeCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - UserID: req.GetUserId(), - SalaryAssetType: req.GetSalaryAssetType(), - SalaryUSDMinor: req.GetSalaryUsdMinor(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.ExchangeSalaryToCoinResponse{ - TransactionId: receipt.TransactionID, - SalaryBalanceAfter: receipt.SalaryBalanceAfter, - CoinBalanceAfter: receipt.CoinBalanceAfter, - SalaryUsdMinor: receipt.SalaryUSDMinor, - CoinAmount: receipt.CoinAmount, - CoinPerUsd: receipt.CoinPerUSD, - }, nil -} - -// TransferSalaryToCoinSeller 处理用户工资转同区域币商专用金币库存。 -func (s *Server) TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - receipt, err := s.svc.TransferSalaryToCoinSeller(ctx, ledger.SalaryTransferToCoinSellerCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - SourceUserID: req.GetSourceUserId(), - SellerUserID: req.GetSellerUserId(), - SalaryAssetType: req.GetSalaryAssetType(), - SalaryUSDMinor: req.GetSalaryUsdMinor(), - RegionID: req.GetRegionId(), - Reason: req.GetReason(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.TransferSalaryToCoinSellerResponse{ - TransactionId: receipt.TransactionID, - SourceSalaryBalanceAfter: receipt.SourceSalaryBalanceAfter, - SellerBalanceAfter: receipt.SellerBalanceAfter, - SalaryUsdMinor: receipt.SalaryUSDMinor, - CoinAmount: receipt.CoinAmount, - CoinPerUsd: receipt.CoinPerUSD, - RateMinUsdMinor: receipt.RateMinUSDMinor, - RateMaxUsdMinor: receipt.RateMaxUSDMinor, - }, nil -} diff --git a/services/wallet-service/internal/transport/grpc/vip.go b/services/wallet-service/internal/transport/grpc/vip.go new file mode 100644 index 00000000..8b2d885e --- /dev/null +++ b/services/wallet-service/internal/transport/grpc/vip.go @@ -0,0 +1,174 @@ +package grpc + +import ( + "context" + "time" + + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// ListVipPackages 返回当前 VIP 和可购买包列表。 +func (s *Server) ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + current, levels, err := s.svc.ListVipPackages(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListVipPackagesResponse{CurrentVip: vipToProto(current), Packages: make([]*walletv1.VipLevel, 0, len(levels))} + for _, level := range levels { + resp.Packages = append(resp.Packages, vipLevelToProto(level)) + } + return resp, nil +} + +// GetMyVip 返回当前登录用户 VIP 状态。 +func (s *Server) GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + vip, err := s.svc.GetMyVip(ctx, req.GetUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.GetMyVipResponse{Vip: vipToProto(vip)}, nil +} + +// PurchaseVip 购买、续期或升级 VIP。 +func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.PurchaseVip(ctx, ledger.PurchaseVipCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + UserID: req.GetUserId(), + Level: req.GetLevel(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.PurchaseVipResponse{ + OrderId: receipt.OrderID, + TransactionId: receipt.TransactionID, + Vip: vipToProto(receipt.Vip), + CoinSpent: receipt.CoinSpent, + CoinBalanceAfter: receipt.CoinBalanceAfter, + RewardItems: vipRewardItemsToProto(receipt.RewardItems), + }, nil +} + +// GrantVip 统一处理活动和后台赠送 VIP。 +func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + receipt, err := s.svc.GrantVip(ctx, ledger.GrantVipCommand{ + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + TargetUserID: req.GetTargetUserId(), + Level: req.GetLevel(), + GrantSource: req.GetGrantSource(), + OperatorUserID: req.GetOperatorUserId(), + Reason: req.GetReason(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &walletv1.GrantVipResponse{ + TransactionId: receipt.TransactionID, + Vip: vipToProto(receipt.Vip), + RewardItems: vipRewardItemsToProto(receipt.RewardItems), + ServerTimeMs: time.Now().UnixMilli(), + }, nil +} + +// ListAdminVipLevels 返回后台 VIP 配置。 +func (s *Server) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + levels, err := s.svc.ListAdminVipLevels(ctx) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.ListAdminVipLevelsResponse{Levels: make([]*walletv1.VipLevel, 0, len(levels)), ServerTimeMs: time.Now().UnixMilli()} + for _, level := range levels { + resp.Levels = append(resp.Levels, vipLevelToProto(level)) + } + return resp, nil +} + +// UpdateAdminVipLevels 保存后台 VIP 配置。 +func (s *Server) UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error) { + ctx = appcode.WithContext(ctx, req.GetAppCode()) + commands := make([]ledger.AdminVipLevelCommand, 0, len(req.GetLevels())) + for _, item := range req.GetLevels() { + if item == nil { + continue + } + commands = append(commands, ledger.AdminVipLevelCommand{ + Level: item.GetLevel(), + Name: item.GetName(), + Status: item.GetStatus(), + PriceCoin: item.GetPriceCoin(), + DurationMS: item.GetDurationMs(), + RewardResourceGroupID: item.GetRewardResourceGroupId(), + RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(), + SortOrder: item.GetSortOrder(), + }) + } + levels, err := s.svc.UpdateAdminVipLevels(ctx, commands, req.GetOperatorUserId()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &walletv1.UpdateAdminVipLevelsResponse{Levels: make([]*walletv1.VipLevel, 0, len(levels)), ServerTimeMs: time.Now().UnixMilli()} + for _, level := range levels { + resp.Levels = append(resp.Levels, vipLevelToProto(level)) + } + return resp, nil +} + +func vipToProto(vip ledger.UserVip) *walletv1.UserVip { + return &walletv1.UserVip{ + UserId: vip.UserID, + Level: vip.Level, + Name: vip.Name, + Active: vip.Active, + StartedAtMs: vip.StartedAtMS, + ExpiresAtMs: vip.ExpiresAtMS, + UpdatedAtMs: vip.UpdatedAtMS, + } +} + +func vipLevelToProto(level ledger.VipLevel) *walletv1.VipLevel { + return &walletv1.VipLevel{ + Level: level.Level, + Name: level.Name, + Status: level.Status, + PriceCoin: level.PriceCoin, + DurationMs: level.DurationMS, + RewardResourceGroupId: level.RewardResourceGroupID, + RewardItems: vipRewardItemsToProto(level.RewardItems), + CanPurchase: level.CanPurchase, + SortOrder: level.SortOrder, + RechargeGateRequired: level.RechargeGateRequired, + RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount, + UserRechargeCoinAmount: level.UserRechargeCoinAmount, + PurchaseLockedReason: level.PurchaseLockedReason, + CreatedAtMs: level.CreatedAtMS, + UpdatedAtMs: level.UpdatedAtMS, + } +} + +func vipRewardItemsToProto(items []ledger.VipRewardItem) []*walletv1.VipRewardItem { + resp := make([]*walletv1.VipRewardItem, 0, len(items)) + for _, item := range items { + resp = append(resp, &walletv1.VipRewardItem{ + ResourceId: item.ResourceID, + ResourceCode: item.ResourceCode, + ResourceType: item.ResourceType, + Name: item.Name, + Quantity: item.Quantity, + ExpiresAtMs: item.ExpiresAtMS, + AssetUrl: item.AssetURL, + PreviewUrl: item.PreviewURL, + AnimationUrl: item.AnimationURL, + }) + } + return resp +}