代码结构梳理

This commit is contained in:
zhx 2026-06-23 11:53:00 +08:00
parent adde820027
commit c96777e405
146 changed files with 20557 additions and 19174 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}" <<EOF
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
brokerIP1 = ${broker_ip}
listenPort = 10911
EOF
printf '%s\n' "${OUTPUT_PATH}"

View File

@ -3,17 +3,18 @@ package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
serviceapp "hyapp/pkg/servicekit/app"
"hyapp/pkg/servicekit/grpcserver"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/services/activity-service/internal/config"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
cumulativerechargeservice "hyapp/services/activity-service/internal/service/cumulativerecharge"
@ -44,9 +45,7 @@ type App struct {
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
roomConn *grpc.ClientConn
workerCtx context.Context
workerStop context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -82,7 +81,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
server := grpcserver.New("activity-service")
services, err := buildServiceBundle(cfg, repository, clients)
if err != nil {
cleanup()
@ -101,7 +100,6 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
workerCtx, workerStop := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
@ -121,8 +119,7 @@ func New(cfg config.Config) (*App, error) {
userConn: clients.userConn,
walletConn: clients.walletConn,
roomConn: clients.roomConn,
workerCtx: workerCtx,
workerStop: workerStop,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -134,21 +131,20 @@ func (a *App) Run() error {
a.health.MarkStopped()
return err
}
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
a.workerWG.Go(func() {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workers != nil {
a.workers.Go(func(ctx context.Context) {
a.broadcast.RunWorker(ctx, broadcastservice.WorkerOptions{})
})
}
if a.luckyGiftWorkerEnabled && a.luckyGift != nil && a.workerCtx != nil {
a.workerWG.Go(func() {
a.luckyGift.RunWorker(a.workerCtx, a.luckyGiftWorkerOptions)
if a.luckyGiftWorkerEnabled && a.luckyGift != nil && a.workers != nil {
a.workers.Go(func(ctx context.Context) {
a.luckyGift.RunWorker(ctx, a.luckyGiftWorkerOptions)
})
}
err := a.server.Serve(a.listener)
if a.workerStop != nil {
a.workerStop()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
@ -162,12 +158,11 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerStop != nil {
a.workerStop()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
grpcserver.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.userConn != nil {
_ = a.userConn.Close()
@ -206,19 +201,9 @@ func (a *App) closeHealthHTTP() {
}
func (a *App) startMQ() error {
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
}
return nil
return servicemq.StartConsumers(a.mqConsumers)
}
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()))
}
}
servicemq.ShutdownConsumers(a.mqConsumers)
}

View File

@ -2,23 +2,22 @@ package app
import (
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/activity-service/internal/config"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
)
func newHealthServers(cfg config.Config, server *grpc.Server, repository *mysqlstorage.Repository) (*grpchealth.ServingChecker, *healthhttp.Server, error) {
// gRPC health 和 HTTP ready 共用同一个 checker避免一个入口显示 ready、另一个入口还在 draining。
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
return servicehealth.New(server, servicehealth.Config{
ServiceName: "activity-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 {
return nil, nil, err
}
return health, healthHTTP, nil
}

View File

@ -9,6 +9,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/activity-service/internal/config"
@ -348,9 +349,7 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsum
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
servicemq.ShutdownConsumers(consumers)
}
type userRegionChangedPayload struct {

View File

@ -10,15 +10,16 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/cron-service/internal/config"
"hyapp/services/cron-service/internal/integration"
"hyapp/services/cron-service/internal/scheduler"
@ -37,9 +38,7 @@ type App struct {
activityConn *grpc.ClientConn
gameConn *grpc.ClientConn
walletConn *grpc.ClientConn
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -90,13 +89,16 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service")))
health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
server := servicegrpc.New("cron-service")
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "cron-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 {
_ = walletConn.Close()
_ = gameConn.Close()
@ -107,7 +109,6 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
workerCtx, workerCancel := context.WithCancel(context.Background())
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
@ -143,8 +144,7 @@ func New(cfg config.Config) (*App, error) {
activityConn: activityConn,
gameConn: gameConn,
walletConn: walletConn,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -153,14 +153,11 @@ func (a *App) Run() error {
a.runHealthHTTP()
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
}()
if a.scheduler != nil {
a.workerWG.Go(func() {
a.scheduler.Run(a.workerCtx)
a.workers.Go(func(ctx context.Context) {
a.scheduler.Run(ctx)
})
}
@ -176,11 +173,8 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.workers.StopAndWait()
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.repository != nil {
_ = a.repository.Close()

View File

@ -10,14 +10,15 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/gamemq"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/services/game-service/internal/client"
"hyapp/services/game-service/internal/config"
diceservice "hyapp/services/game-service/internal/service/dice"
@ -103,7 +104,7 @@ func New(cfg config.Config) (*App, error) {
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
server := servicegrpc.New("game-service")
walletClient := client.NewWalletClient(walletConn)
userClient := client.NewUserClient(userConn)
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn))
@ -133,12 +134,15 @@ func New(cfg config.Config) (*App, error) {
gamev1.RegisterGameCallbackServiceServer(server, transport)
gamev1.RegisterGameAdminServiceServer(server, transport)
gamev1.RegisterGameCronServiceServer(server, transport)
health := grpchealth.NewServingChecker("game-service", grpchealth.Dependency{
Name: "mysql",
Check: repo.Ping,
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "game-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repo.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = robotConn.Close()
@ -175,7 +179,7 @@ func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.stopOutboxWorker()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.walletConn != nil {
_ = a.walletConn.Close()
@ -276,16 +280,11 @@ func (a *App) startMQ() error {
if a.outboxProducer == nil {
return nil
}
return a.outboxProducer.Start()
return servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer})
}
func (a *App) shutdownMQ() {
if a.outboxProducer == nil {
return
}
if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer})
}
func (a *App) stopOutboxWorker() {

View File

@ -15,6 +15,7 @@ import (
_ "github.com/go-sql-driver/mysql"
"google.golang.org/grpc"
"hyapp/pkg/grpcclient"
serviceapp "hyapp/pkg/servicekit/app"
"hyapp/pkg/tencentcos"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/appconfig"
@ -446,12 +447,8 @@ func (i gatewayTencentIMAccountImporter) ImportAccount(ctx context.Context, user
// Run 启动 HTTP 服务。
func (a *App) Run() error {
a.health.MarkHTTPServing()
err := a.server.Serve(a.listener)
err := serviceapp.ServeHTTP(a.server, a.listener)
a.health.MarkHTTPStopped()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
@ -460,14 +457,8 @@ func (a *App) Close() error {
var err error
a.closeOnce.Do(func() {
a.health.MarkDraining()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// HTTP Shutdown drains accepted requests before client connections close,
// so in-flight handlers can finish their internal gRPC calls.
if shutdownErr := a.server.Shutdown(shutdownCtx); shutdownErr != nil {
// 超过 drain 预算后强制关闭,避免发布脚本被异常长连接卡住。
err = errors.Join(err, shutdownErr, a.server.Close())
}
// HTTP drain 要先于内部 gRPC 连接关闭,保证已进入 handler 的编排请求能自然完成。
err = errors.Join(err, serviceapp.ShutdownHTTP(a.server, 15*time.Second))
if a.roomConn != nil {
err = errors.Join(err, a.roomConn.Close())
}

View File

@ -4,20 +4,21 @@ package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"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"
@ -44,9 +45,7 @@ type App struct {
cpNoticeService *cpnotice.Service
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
mqConsumers []*rocketmqx.Consumer
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -114,13 +113,16 @@ func New(cfg config.Config) (*App, error) {
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
Name: "mysql",
Check: store.Ping,
server := servicegrpc.New("notice-service")
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "notice-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: store.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = store.Close()
@ -201,7 +203,6 @@ func New(cfg config.Config) (*App, error) {
}
mqConsumers = append(mqConsumers, consumer)
}
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
@ -217,8 +218,7 @@ func New(cfg config.Config) (*App, error) {
cpNoticeService: cpSvc,
cpWorkerOptions: cpOptions,
mqConsumers: mqConsumers,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -231,21 +231,20 @@ func (a *App) Run() error {
return err
}
defer func() {
if a.workerCancel != nil {
a.workerCancel()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped()
}()
if a.walletWorkerEnabled && a.walletNoticeService != nil {
a.workerWG.Go(func() {
a.walletNoticeService.RunWalletBalanceWorker(a.workerCtx, a.walletWorkerOptions)
a.workers.Go(func(ctx context.Context) {
a.walletNoticeService.RunWalletBalanceWorker(ctx, a.walletWorkerOptions)
})
}
if a.roomWorkerEnabled && a.roomNoticeService != nil {
a.workerWG.Go(func() {
a.roomNoticeService.RunRoomKickWorker(a.workerCtx, a.roomWorkerOptions)
a.workers.Go(func(ctx context.Context) {
a.roomNoticeService.RunRoomKickWorker(ctx, a.roomWorkerOptions)
})
}
err := a.server.Serve(a.listener)
@ -259,12 +258,11 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.store != nil {
_ = a.store.Close()
@ -328,27 +326,15 @@ func cpNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) cpnotice.CPNotic
}
func (a *App) startMQ() error {
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
}
return nil
return servicemq.StartConsumers(a.mqConsumers)
}
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()))
}
}
servicemq.ShutdownConsumers(a.mqConsumers)
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
servicemq.ShutdownConsumers(consumers)
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {

View File

@ -9,12 +9,12 @@ import (
"time"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
robotv1 "hyapp.local/api/proto/robot/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/robot-service/internal/config"
"hyapp/services/robot-service/internal/service/gamerobot"
"hyapp/services/robot-service/internal/service/roomrobot"
@ -61,16 +61,19 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("robot-service")))
server := servicegrpc.New("robot-service")
transport := grpcserver.NewServer(gamerobot.New(repo), roomrobot.New(repo))
robotv1.RegisterGameRobotServiceServer(server, transport)
robotv1.RegisterRoomRobotServiceServer(server, transport)
health := grpchealth.NewServingChecker("robot-service", grpchealth.Dependency{
Name: "mysql",
Check: repo.Ping,
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "robot-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repo.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = repo.Close()
@ -94,7 +97,7 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.repo != nil {
_ = a.repo.Close()

View File

@ -11,16 +11,17 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"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/tencentrtc"
"hyapp/pkg/usermq"
@ -65,12 +66,8 @@ type App struct {
mqProducers []*rocketmqx.Producer
// mqConsumers 在 gRPC 服务启动前订阅,承接 room_outbox fanout 和 delayed open。
mqConsumers []*rocketmqx.Consumer
// workerCtx 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workerCtx context.Context
// workerCancel 停止 presence stale 和 outbox 补偿 worker。
workerCancel context.CancelFunc
// workerWG 等待后台 worker 当前事件或当前扫描批次安全退出。
workerWG sync.WaitGroup
// workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workers *serviceapp.BackgroundGroup
// closeOnce 防止信号退出和 Serve 错误同时触发重复关闭。
closeOnce sync.Once
}
@ -366,15 +363,14 @@ func New(cfg config.Config) (*App, error) {
mqConsumers = append(mqConsumers, userConsumer)
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
server := servicegrpc.New("room-service")
roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
// 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
roomv1.RegisterRoomCommandServiceServer(server, roomServer)
roomv1.RegisterRoomGuardServiceServer(server, roomServer)
roomv1.RegisterRoomQueryServiceServer(server, roomServer)
healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory)
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState)
healthHTTP, err := servicehealth.Register(server, healthState, cfg.HealthHTTPAddr, cfg.NodeID)
if err != nil {
closeClientConn(activityConn)
_ = walletConn.Close()
@ -393,7 +389,6 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close()
return nil, err
}
workerCtx, workerCancel := context.WithCancel(context.Background())
closeRobotDepsOnError = false
return &App{
@ -412,8 +407,7 @@ func New(cfg config.Config) (*App, error) {
healthHTTP: healthHTTP,
mqProducers: mqProducers,
mqConsumers: mqConsumers,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -431,36 +425,33 @@ func (a *App) Run() error {
return err
}
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
a.shutdownMQ()
}()
a.workerWG.Go(func() {
a.runNodeRegistryHeartbeat(a.workerCtx)
a.workers.Go(func(ctx context.Context) {
a.runNodeRegistryHeartbeat(ctx)
})
if a.cfg.PresenceStaleScanInterval > 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 {

View File

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

View File

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

View File

@ -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 '是否启用红包',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
package resource
func ValidStatus(value string) bool {
switch NormalizeStatus(value) {
case StatusActive, StatusDisabled:
return true
default:
return false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 APIservice 只依赖购买校验和消耗能力。
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)
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package mysql
const (
bizTypeVIPPurchase = "vip_purchase"
bizTypeVIPGrant = "vip_grant"
vipOutboxAsset = "VIP"
managerCenterVIPGrantDurationMS = 30 * 24 * 60 * 60 * 1000
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(&regionID); 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More