代码结构梳理
This commit is contained in:
parent
adde820027
commit
c96777e405
3
Makefile
3
Makefile
@ -48,7 +48,8 @@ build:
|
||||
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
|
||||
run:
|
||||
./scripts/resolve-compose-container-conflicts.sh
|
||||
docker compose up -d mysql redis
|
||||
./scripts/prepare-local-rocketmq-config.sh
|
||||
ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker
|
||||
./scripts/apply-local-mysql-initdb.sh
|
||||
./scripts/apply-local-rocketmq-topics.sh
|
||||
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
61
pkg/servicekit/app/background.go
Normal file
61
pkg/servicekit/app/background.go
Normal 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()
|
||||
}
|
||||
22
pkg/servicekit/app/background_test.go
Normal file
22
pkg/servicekit/app/background_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
37
pkg/servicekit/app/http.go
Normal file
37
pkg/servicekit/app/http.go
Normal 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
|
||||
}
|
||||
78
pkg/servicekit/config/config.go
Normal file
78
pkg/servicekit/config/config.go
Normal 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
|
||||
}
|
||||
53
pkg/servicekit/config/config_test.go
Normal file
53
pkg/servicekit/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
pkg/servicekit/grpcserver/grpcserver.go
Normal file
28
pkg/servicekit/grpcserver/grpcserver.go
Normal 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)
|
||||
}
|
||||
56
pkg/servicekit/health/health.go
Normal file
56
pkg/servicekit/health/health.go
Normal 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
|
||||
}
|
||||
48
pkg/servicekit/health/health_test.go
Normal file
48
pkg/servicekit/health/health_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
66
pkg/servicekit/mq/rocketmq.go
Normal file
66
pkg/servicekit/mq/rocketmq.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
48
scripts/prepare-local-rocketmq-config.sh
Executable file
48
scripts/prepare-local-rocketmq-config.sh
Executable 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}"
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 '是否启用红包',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
28
services/wallet-service/internal/app/background_workers.go
Normal file
28
services/wallet-service/internal/app/background_workers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
services/wallet-service/internal/app/clients.go
Normal file
20
services/wallet-service/internal/app/clients.go
Normal 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) != ""
|
||||
}
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
28
services/wallet-service/internal/app/health.go
Normal file
28
services/wallet-service/internal/app/health.go
Normal 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)
|
||||
}
|
||||
89
services/wallet-service/internal/app/mq.go
Normal file
89
services/wallet-service/internal/app/mq.go
Normal 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)
|
||||
}
|
||||
148
services/wallet-service/internal/app/outbox_worker.go
Normal file
148
services/wallet-service/internal/app/outbox_worker.go
Normal 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()
|
||||
}
|
||||
42
services/wallet-service/internal/app/red_packet_worker.go
Normal file
42
services/wallet-service/internal/app/red_packet_worker.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
138
services/wallet-service/internal/domain/ledger/coin_seller.go
Normal file
138
services/wallet-service/internal/domain/ledger/coin_seller.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
132
services/wallet-service/internal/domain/ledger/constants.go
Normal file
132
services/wallet-service/internal/domain/ledger/constants.go
Normal 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
|
||||
)
|
||||
42
services/wallet-service/internal/domain/ledger/game.go
Normal file
42
services/wallet-service/internal/domain/ledger/game.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
119
services/wallet-service/internal/domain/ledger/gift.go
Normal file
119
services/wallet-service/internal/domain/ledger/gift.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
325
services/wallet-service/internal/domain/ledger/recharge.go
Normal file
325
services/wallet-service/internal/domain/ledger/recharge.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
146
services/wallet-service/internal/domain/ledger/red_packet.go
Normal file
146
services/wallet-service/internal/domain/ledger/red_packet.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
170
services/wallet-service/internal/domain/ledger/reward.go
Normal file
170
services/wallet-service/internal/domain/ledger/reward.go
Normal 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
|
||||
}
|
||||
111
services/wallet-service/internal/domain/ledger/vip.go
Normal file
111
services/wallet-service/internal/domain/ledger/vip.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
195
services/wallet-service/internal/domain/ledger/wallet_query.go
Normal file
195
services/wallet-service/internal/domain/ledger/wallet_query.go
Normal 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
|
||||
}
|
||||
}
|
||||
30
services/wallet-service/internal/domain/resource/badge.go
Normal file
30
services/wallet-service/internal/domain/resource/badge.go
Normal 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"`
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
168
services/wallet-service/internal/domain/resource/gift_config.go
Normal file
168
services/wallet-service/internal/domain/resource/gift_config.go
Normal 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
|
||||
}
|
||||
}
|
||||
60
services/wallet-service/internal/domain/resource/grant.go
Normal file
60
services/wallet-service/internal/domain/resource/grant.go
Normal 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
|
||||
}
|
||||
92
services/wallet-service/internal/domain/resource/group.go
Normal file
92
services/wallet-service/internal/domain/resource/group.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
134
services/wallet-service/internal/domain/resource/shop.go
Normal file
134
services/wallet-service/internal/domain/resource/shop.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package resource
|
||||
|
||||
func ValidStatus(value string) bool {
|
||||
switch NormalizeStatus(value) {
|
||||
case StatusActive, StatusDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
98
services/wallet-service/internal/service/wallet/balance.go
Normal file
98
services/wallet-service/internal/service/wallet/balance.go
Normal 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)
|
||||
}
|
||||
151
services/wallet-service/internal/service/wallet/coin_seller.go
Normal file
151
services/wallet-service/internal/service/wallet/coin_seller.go
Normal 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)
|
||||
}
|
||||
36
services/wallet-service/internal/service/wallet/compat.go
Normal file
36
services/wallet-service/internal/service/wallet/compat.go
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
39
services/wallet-service/internal/service/wallet/game.go
Normal file
39
services/wallet-service/internal/service/wallet/game.go
Normal 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)
|
||||
}
|
||||
31
services/wallet-service/internal/service/wallet/gift.go
Normal file
31
services/wallet-service/internal/service/wallet/gift.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
164
services/wallet-service/internal/service/wallet/ports/clients.go
Normal file
164
services/wallet-service/internal/service/wallet/ports/clients.go
Normal 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 API,service 只依赖购买校验和消耗能力。
|
||||
type GooglePlayClient interface {
|
||||
GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error)
|
||||
ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error
|
||||
}
|
||||
|
||||
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
|
||||
type MifaPayCreateOrderRequest struct {
|
||||
OrderID string
|
||||
TargetUserID int64
|
||||
ProductName string
|
||||
CoinAmount int64
|
||||
AmountMinor int64
|
||||
CurrencyCode string
|
||||
CountryCode string
|
||||
PayWay string
|
||||
PayType string
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
ClientIP string
|
||||
Language string
|
||||
ProviderAmountMinor int64
|
||||
PayerName string
|
||||
PayerAccount string
|
||||
PayerEmail string
|
||||
}
|
||||
|
||||
type MifaPayCreateOrderResponse struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
PayURL string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type MifaPayQueryOrderRequest struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
OrderCreatedAtMS int64
|
||||
}
|
||||
|
||||
type MifaPayQueryOrderResponse struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
Status string
|
||||
Amount string
|
||||
Currency string
|
||||
PayType string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type MifaPayNotifyData struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
OrderStatus string
|
||||
Amount string
|
||||
Currency string
|
||||
PayType string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。
|
||||
type V5PayCreateOrderRequest struct {
|
||||
OrderID string
|
||||
TargetUserID int64
|
||||
ProductName string
|
||||
CoinAmount int64
|
||||
AmountMinor int64
|
||||
CurrencyCode string
|
||||
CountryCode string
|
||||
ProductType string
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
Language string
|
||||
ProviderAmountMinor int64
|
||||
PayerName string
|
||||
PayerAccount string
|
||||
PayerEmail string
|
||||
}
|
||||
|
||||
type V5PayCreateOrderResponse struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
PayURL string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type V5PayQueryOrderRequest struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
CountryCode string
|
||||
CurrencyCode string
|
||||
}
|
||||
|
||||
type V5PayQueryOrderResponse struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
Status string
|
||||
Amount string
|
||||
Currency string
|
||||
ProductType string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type V5PayNotifyData struct {
|
||||
OrderID string
|
||||
ProviderOrderID string
|
||||
OrderStatus string
|
||||
Amount string
|
||||
Currency string
|
||||
ProductType string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
type V5PayProductTypesRequest struct {
|
||||
CountryCode string
|
||||
CurrencyCode string
|
||||
}
|
||||
|
||||
type V5PayProductType struct {
|
||||
ProductType string
|
||||
ProductName string
|
||||
Currency string
|
||||
SupportCashierMode int
|
||||
}
|
||||
|
||||
type V5PayProductTypesResponse struct {
|
||||
PayinList []V5PayProductType
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节,service 只处理订单状态。
|
||||
type MifaPayClient interface {
|
||||
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
|
||||
QueryOrder(ctx context.Context, req MifaPayQueryOrderRequest) (MifaPayQueryOrderResponse, error)
|
||||
ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error)
|
||||
}
|
||||
|
||||
// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节,service 只处理订单状态。
|
||||
type V5PayClient interface {
|
||||
CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error)
|
||||
QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error)
|
||||
ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error)
|
||||
ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error)
|
||||
}
|
||||
|
||||
// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。
|
||||
type TronUSDTClient interface {
|
||||
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
146
services/wallet-service/internal/service/wallet/projection.go
Normal file
146
services/wallet-service/internal/service/wallet/projection.go
Normal 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
|
||||
}
|
||||
}
|
||||
243
services/wallet-service/internal/service/wallet/recharge.go
Normal file
243
services/wallet-service/internal/service/wallet/recharge.go
Normal 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
|
||||
}
|
||||
194
services/wallet-service/internal/service/wallet/reward.go
Normal file
194
services/wallet-service/internal/service/wallet/reward.go
Normal 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
@ -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)
|
||||
}
|
||||
109
services/wallet-service/internal/service/wallet/vip.go
Normal file
109
services/wallet-service/internal/service/wallet/vip.go
Normal 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)
|
||||
}
|
||||
180
services/wallet-service/internal/storage/mysql/account.go
Normal file
180
services/wallet-service/internal/storage/mysql/account.go
Normal 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
|
||||
}
|
||||
105
services/wallet-service/internal/storage/mysql/admin_ledger.go
Normal file
105
services/wallet-service/internal/storage/mysql/admin_ledger.go
Normal 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,
|
||||
))
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package mysql
|
||||
|
||||
const (
|
||||
bizTypeVIPPurchase = "vip_purchase"
|
||||
bizTypeVIPGrant = "vip_grant"
|
||||
vipOutboxAsset = "VIP"
|
||||
managerCenterVIPGrantDurationMS = 30 * 24 * 60 * 60 * 1000
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
}
|
||||
38
services/wallet-service/internal/storage/mysql/constants.go
Normal file
38
services/wallet-service/internal/storage/mysql/constants.go
Normal 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"
|
||||
)
|
||||
59
services/wallet-service/internal/storage/mysql/entry.go
Normal file
59
services/wallet-service/internal/storage/mysql/entry.go
Normal 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
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
112
services/wallet-service/internal/storage/mysql/game_ledger.go
Normal file
112
services/wallet-service/internal/storage/mysql/game_ledger.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,274 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, query.AppCode)
|
||||
query.AppCode = appcode.FromContext(ctx)
|
||||
query = normalizeGiftConfigListQuery(query)
|
||||
where, args := giftConfigWhereSQL(query)
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM gift_configs gc
|
||||
JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
|
||||
`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+`
|
||||
ORDER BY gc.sort_order ASC, gc.created_at_ms DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanGiftConfig(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
|
||||
if priceErr == nil {
|
||||
item.PriceVersion = price.PriceVersion
|
||||
item.ChargeAssetType = price.ChargeAssetType
|
||||
item.CoinPrice = price.CoinPrice
|
||||
item.GiftPointAmount = 0
|
||||
item.HeatValue = price.CoinPrice
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) {
|
||||
row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID)
|
||||
gift, err := scanGiftConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
|
||||
}
|
||||
if err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
|
||||
if priceErr == nil {
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
|
||||
gift.GiftPointAmount = 0
|
||||
|
||||
gift.HeatValue = price.CoinPrice
|
||||
}
|
||||
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
|
||||
if err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, giftID string) (resourcedomain.GiftConfig, error) {
|
||||
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ? FOR UPDATE`, appcode.FromContext(ctx), giftID)
|
||||
gift, err := scanGiftConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
|
||||
}
|
||||
if err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
|
||||
price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli())
|
||||
if priceErr == nil {
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
gift.GiftPointAmount = 0
|
||||
gift.HeatValue = price.CoinPrice
|
||||
}
|
||||
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
|
||||
if err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, regionID := range regionIDs {
|
||||
if regionID < 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "gift region is invalid")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO gift_config_regions (
|
||||
app_code, gift_id, region_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
|
||||
rows, err := querier.QueryContext(ctx, `
|
||||
SELECT region_id
|
||||
FROM gift_config_regions
|
||||
WHERE app_code = ? AND gift_id = ?
|
||||
ORDER BY region_id ASC`,
|
||||
appcode.FromContext(ctx), giftID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
regionIDs := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var regionID int64
|
||||
if err := rows.Scan(®ionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
regionIDs = append(regionIDs, regionID)
|
||||
}
|
||||
return regionIDs, rows.Err()
|
||||
}
|
||||
|
||||
func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
||||
var gift resourcedomain.GiftConfig
|
||||
var resource resourcedomain.Resource
|
||||
var scopesJSON string
|
||||
var effectTypesJSON string
|
||||
if err := scanner.Scan(
|
||||
&gift.AppCode,
|
||||
&gift.GiftID,
|
||||
&gift.ResourceID,
|
||||
&gift.Status,
|
||||
&gift.Name,
|
||||
&gift.SortOrder,
|
||||
&gift.PresentationJSON,
|
||||
&gift.GiftTypeCode,
|
||||
&gift.EffectiveFromMS,
|
||||
&gift.EffectiveToMS,
|
||||
&effectTypesJSON,
|
||||
&gift.CreatedByUserID,
|
||||
&gift.UpdatedByUserID,
|
||||
&gift.CreatedAtMS,
|
||||
&gift.UpdatedAtMS,
|
||||
&resource.AppCode,
|
||||
&resource.ResourceID,
|
||||
&resource.ResourceCode,
|
||||
&resource.ResourceType,
|
||||
&resource.Name,
|
||||
&resource.Status,
|
||||
&resource.Grantable,
|
||||
&resource.ManagerGrantEnabled,
|
||||
&resource.GrantStrategy,
|
||||
&resource.WalletAssetType,
|
||||
&resource.WalletAssetAmount,
|
||||
&resource.PriceType,
|
||||
&resource.CoinPrice,
|
||||
&resource.GiftPointAmount,
|
||||
&scopesJSON,
|
||||
&resource.AssetURL,
|
||||
&resource.PreviewURL,
|
||||
&resource.AnimationURL,
|
||||
&resource.MetadataJSON,
|
||||
&resource.SortOrder,
|
||||
&resource.CreatedByUserID,
|
||||
&resource.UpdatedByUserID,
|
||||
&resource.CreatedAtMS,
|
||||
&resource.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return resourcedomain.GiftConfig{}, err
|
||||
}
|
||||
resource.UsageScopes = parseStringArray(scopesJSON)
|
||||
gift.Resource = resource
|
||||
gift.EffectTypes = parseStringArray(effectTypesJSON)
|
||||
|
||||
gift.CPRelationType = cpRelationTypeFromPresentationJSON(gift.PresentationJSON)
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
func giftConfigSelectSQL() string {
|
||||
return `
|
||||
SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
|
||||
COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
|
||||
COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
|
||||
COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'),
|
||||
gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
|
||||
` + resourceColumnsWithAlias("r") + `
|
||||
FROM gift_configs gc
|
||||
JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
|
||||
`
|
||||
}
|
||||
|
||||
func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) {
|
||||
where := `WHERE gc.app_code = ?`
|
||||
args := []any{query.AppCode}
|
||||
if query.ActiveOnly {
|
||||
where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
|
||||
AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
|
||||
AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)`
|
||||
nowMs := time.Now().UnixMilli()
|
||||
args = append(args, nowMs, nowMs)
|
||||
} else if query.Status != "" {
|
||||
where += ` AND gc.status = ?`
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)`
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
if query.GiftTypeCode != "" {
|
||||
where += ` AND gc.gift_type_code = ?`
|
||||
args = append(args, query.GiftTypeCode)
|
||||
}
|
||||
if query.FilterRegion {
|
||||
where += ` AND EXISTS (
|
||||
SELECT 1
|
||||
FROM gift_config_regions gcr
|
||||
WHERE gcr.app_code = gc.app_code
|
||||
AND gcr.gift_id = gc.gift_id
|
||||
AND (gcr.region_id = 0 OR gcr.region_id = ?)
|
||||
)`
|
||||
args = append(args, query.RegionID)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery {
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
|
||||
if strings.TrimSpace(query.GiftTypeCode) != "" {
|
||||
query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode)
|
||||
}
|
||||
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
|
||||
return query
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
))
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
279
services/wallet-service/internal/storage/mysql/gift_pricing.go
Normal file
279
services/wallet-service/internal/storage/mysql/gift_pricing.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
150
services/wallet-service/internal/storage/mysql/host_diamond.go
Normal file
150
services/wallet-service/internal/storage/mysql/host_diamond.go
Normal 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")
|
||||
}
|
||||
270
services/wallet-service/internal/storage/mysql/metadata.go
Normal file
270
services/wallet-service/internal/storage/mysql/metadata.go
Normal 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
Loading…
x
Reference in New Issue
Block a user