Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 634b5a7bd0 | |||
| 5ef3f2717e | |||
| 2c1ee78bc4 | |||
| 13bafd5847 | |||
| e8e3a3a72b | |||
| 58c346abec | |||
| abd8c068b6 | |||
| eac0137069 | |||
| cce147108c | |||
| 980e5adb90 | |||
| 07409eb602 | |||
| 117dc0d6a7 | |||
| c92495c5b9 | |||
| 92cf2921dd | |||
| ce3babcc33 | |||
| a46fb83054 | |||
| 9933d34bdd | |||
| d2710d356f | |||
| f0a5288e20 | |||
| 77fa0cadd2 |
@@ -43,6 +43,7 @@ logs/
|
|||||||
/test/
|
/test/
|
||||||
*_test.go
|
*_test.go
|
||||||
!tests/acceptance/*_test.go
|
!tests/acceptance/*_test.go
|
||||||
|
!internal/handler/subscribe_test.go
|
||||||
*_test_config.go
|
*_test_config.go
|
||||||
**/logtest/
|
**/logtest/
|
||||||
*_test.yaml
|
*_test.yaml
|
||||||
|
|||||||
+71
-47
@@ -113,53 +113,77 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
|||||||
proxies = append(
|
proxies = append(
|
||||||
proxies,
|
proxies,
|
||||||
Proxy{
|
Proxy{
|
||||||
Sort: item.Sort,
|
Sort: item.Sort,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Server: item.Address,
|
Server: item.Address,
|
||||||
Port: item.Port,
|
Port: item.Port,
|
||||||
Type: item.Protocol,
|
Type: item.Protocol,
|
||||||
Tags: strings.Split(item.Tags, ","),
|
Tags: strings.Split(item.Tags, ","),
|
||||||
Security: protocol.Security,
|
Security: protocol.Security,
|
||||||
SNI: protocol.SNI,
|
SNI: protocol.SNI,
|
||||||
AllowInsecure: protocol.AllowInsecure,
|
AllowInsecure: protocol.AllowInsecure,
|
||||||
Fingerprint: protocol.Fingerprint,
|
Fingerprint: protocol.Fingerprint,
|
||||||
RealityServerAddr: protocol.RealityServerAddr,
|
RealityServerAddr: protocol.RealityServerAddr,
|
||||||
RealityServerPort: protocol.RealityServerPort,
|
RealityServerPort: protocol.RealityServerPort,
|
||||||
RealityPrivateKey: protocol.RealityPrivateKey,
|
RealityPrivateKey: protocol.RealityPrivateKey,
|
||||||
RealityPublicKey: protocol.RealityPublicKey,
|
RealityPublicKey: protocol.RealityPublicKey,
|
||||||
RealityShortId: protocol.RealityShortId,
|
RealityShortId: protocol.RealityShortId,
|
||||||
Transport: protocol.Transport,
|
Transport: protocol.Transport,
|
||||||
Host: protocol.Host,
|
Host: protocol.Host,
|
||||||
Path: protocol.Path,
|
Path: protocol.Path,
|
||||||
ServiceName: protocol.ServiceName,
|
ServiceName: protocol.ServiceName,
|
||||||
Method: protocol.Cipher,
|
Method: protocol.Cipher,
|
||||||
ServerKey: protocol.ServerKey,
|
ServerKey: protocol.ServerKey,
|
||||||
Flow: protocol.Flow,
|
Flow: protocol.Flow,
|
||||||
HopPorts: protocol.HopPorts,
|
HopPorts: protocol.HopPorts,
|
||||||
HopInterval: protocol.HopInterval,
|
HopInterval: protocol.HopInterval,
|
||||||
ObfsPassword: protocol.ObfsPassword,
|
ObfsPassword: protocol.ObfsPassword,
|
||||||
UpMbps: protocol.UpMbps,
|
UpMbps: protocol.UpMbps,
|
||||||
DownMbps: protocol.DownMbps,
|
DownMbps: protocol.DownMbps,
|
||||||
DisableSNI: protocol.DisableSNI,
|
DisableSNI: protocol.DisableSNI,
|
||||||
ReduceRtt: protocol.ReduceRtt,
|
ReduceRtt: protocol.ReduceRtt,
|
||||||
UDPRelayMode: protocol.UDPRelayMode,
|
UDPRelayMode: protocol.UDPRelayMode,
|
||||||
CongestionController: protocol.CongestionController,
|
CongestionController: protocol.CongestionController,
|
||||||
PaddingScheme: protocol.PaddingScheme,
|
PaddingScheme: protocol.PaddingScheme,
|
||||||
Multiplex: protocol.Multiplex,
|
Multiplex: protocol.Multiplex,
|
||||||
XhttpMode: protocol.XhttpMode,
|
XhttpMode: protocol.XhttpMode,
|
||||||
XhttpExtra: protocol.XhttpExtra,
|
XhttpExtra: protocol.XhttpExtra,
|
||||||
Encryption: protocol.Encryption,
|
Encryption: protocol.Encryption,
|
||||||
EncryptionMode: protocol.EncryptionMode,
|
EncryptionMode: protocol.EncryptionMode,
|
||||||
EncryptionRtt: protocol.EncryptionRtt,
|
EncryptionRtt: protocol.EncryptionRtt,
|
||||||
EncryptionTicket: protocol.EncryptionTicket,
|
EncryptionTicket: protocol.EncryptionTicket,
|
||||||
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
||||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||||
EncryptionPassword: protocol.EncryptionPassword,
|
EncryptionPassword: protocol.EncryptionPassword,
|
||||||
Ratio: protocol.Ratio,
|
Ratio: protocol.Ratio,
|
||||||
CertMode: protocol.CertMode,
|
CertMode: protocol.CertMode,
|
||||||
CertDNSProvider: protocol.CertDNSProvider,
|
CertDNSProvider: protocol.CertDNSProvider,
|
||||||
CertDNSEnv: protocol.CertDNSEnv,
|
CertDNSEnv: protocol.CertDNSEnv,
|
||||||
|
SimnetPsk: protocol.SimnetPsk,
|
||||||
|
SimnetKeyID: protocol.SimnetKeyID,
|
||||||
|
SimnetTicketID: protocol.SimnetTicketID,
|
||||||
|
SimnetPath: protocol.SimnetPath,
|
||||||
|
SimnetCarrier: protocol.SimnetCarrier,
|
||||||
|
SimnetAfEnabled: protocol.SimnetAfEnabled,
|
||||||
|
SimnetAfPathMode: protocol.SimnetAfPathMode,
|
||||||
|
SimnetAfPathPrefix: protocol.SimnetAfPathPrefix,
|
||||||
|
SimnetAfPathSuffix: protocol.SimnetAfPathSuffix,
|
||||||
|
SimnetAfMagicMode: protocol.SimnetAfMagicMode,
|
||||||
|
SimnetAfResponseJitterMs: protocol.SimnetAfResponseJitterMs,
|
||||||
|
SimnetAfHandshakePolymorphism: protocol.SimnetAfHandshakePolymorphism,
|
||||||
|
SimnetAfSettingsJitter: protocol.SimnetAfSettingsJitter,
|
||||||
|
SimnetAfFakeHeaderInjection: protocol.SimnetAfFakeHeaderInjection,
|
||||||
|
SimnetFallbackEnabled: protocol.SimnetFallbackEnabled,
|
||||||
|
SimnetFallbackTargetScheme: protocol.SimnetFallbackTargetScheme,
|
||||||
|
SimnetFallbackTargetHost: protocol.SimnetFallbackTargetHost,
|
||||||
|
SimnetFallbackTargetPort: protocol.SimnetFallbackTargetPort,
|
||||||
|
SimnetFallbackHostHeader: protocol.SimnetFallbackHostHeader,
|
||||||
|
SimnetFallbackTLSSNI: protocol.SimnetFallbackTLSSNI,
|
||||||
|
SimnetClientMaxConcurrentStreams: protocol.SimnetClientMaxConcurrentStreams,
|
||||||
|
SimnetClientMaxStreamsPerSession: protocol.SimnetClientMaxStreamsPerSession,
|
||||||
|
SimnetClientSessionIdleTimeoutSecs: protocol.SimnetClientSessionIdleTimeoutSecs,
|
||||||
|
SimnetClientMaxUDPSessions: protocol.SimnetClientMaxUDPSessions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -81,10 +81,38 @@ type Proxy struct {
|
|||||||
CertMode string // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string // DNS provider for certificate
|
CertDNSProvider string // DNS provider for certificate
|
||||||
CertDNSEnv string // Environment for DNS provider
|
CertDNSEnv string // Environment for DNS provider
|
||||||
|
|
||||||
|
// Simnet Options (server-side config; per-user psk/key_id are derived at
|
||||||
|
// render time from UserInfo, never stored on the Proxy).
|
||||||
|
SimnetPsk string // server-side PSK (key_id=0), used for AF derivation
|
||||||
|
SimnetKeyID int // server key id (0)
|
||||||
|
SimnetTicketID string
|
||||||
|
SimnetPath string
|
||||||
|
SimnetCarrier string
|
||||||
|
SimnetAfEnabled bool
|
||||||
|
SimnetAfPathMode string
|
||||||
|
SimnetAfPathPrefix string
|
||||||
|
SimnetAfPathSuffix string
|
||||||
|
SimnetAfMagicMode string
|
||||||
|
SimnetAfResponseJitterMs int
|
||||||
|
SimnetAfHandshakePolymorphism bool
|
||||||
|
SimnetAfSettingsJitter bool
|
||||||
|
SimnetAfFakeHeaderInjection bool
|
||||||
|
SimnetFallbackEnabled bool
|
||||||
|
SimnetFallbackTargetScheme string
|
||||||
|
SimnetFallbackTargetHost string
|
||||||
|
SimnetFallbackTargetPort int
|
||||||
|
SimnetFallbackHostHeader string
|
||||||
|
SimnetFallbackTLSSNI string
|
||||||
|
SimnetClientMaxConcurrentStreams int
|
||||||
|
SimnetClientMaxStreamsPerSession int
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int
|
||||||
|
SimnetClientMaxUDPSessions int
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Password string
|
Password string
|
||||||
|
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
|
||||||
ExpiredAt time.Time
|
ExpiredAt time.Time
|
||||||
Download int64
|
Download int64
|
||||||
Upload int64
|
Upload int64
|
||||||
@@ -104,7 +132,10 @@ type Client struct {
|
|||||||
|
|
||||||
func (c *Client) Build() ([]byte, error) {
|
func (c *Client) Build() ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
|
funcMap := sprig.TxtFuncMap()
|
||||||
|
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
|
||||||
|
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
|
||||||
|
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/pkg/simnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildOmnxtSimnetConfigs is a subscription template function (registered in
|
||||||
|
// Client.Build) that produces the per-user OmnXT SimNet JSON config array.
|
||||||
|
//
|
||||||
|
// It mirrors the Pro reference (NPanel-backend
|
||||||
|
// internal/biz/public/subscription/template.go buildOmnxtSimnetConfigs):
|
||||||
|
// - per-user simnet_psk / simnet_key_id are DERIVED from the user's
|
||||||
|
// subscription (uuid + user_subscribe.id), never stored.
|
||||||
|
// - the server PSK (key_id=0) is passed through as simnet_server_psk so the
|
||||||
|
// client SDK can derive AF path/magic with the same key material.
|
||||||
|
//
|
||||||
|
// Template usage: {{ buildOmnxtSimnetConfigs .Proxies .UserInfo .Params | toPrettyJson }}
|
||||||
|
func buildOmnxtSimnetConfigs(proxies []map[string]interface{}, userInfo User, params map[string]string) []map[string]interface{} {
|
||||||
|
result := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
proxyMode := strings.TrimSpace(params["proxy_mode"])
|
||||||
|
if proxyMode == "" {
|
||||||
|
proxyMode = "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsServers := []string{"1.1.1.1"}
|
||||||
|
if raw := strings.TrimSpace(params["dns_servers"]); raw != "" {
|
||||||
|
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == ',' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
parsed := make([]string, 0, len(parts))
|
||||||
|
for _, item := range parts {
|
||||||
|
if item = strings.TrimSpace(item); item != "" {
|
||||||
|
parsed = append(parsed, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parsed) > 0 {
|
||||||
|
dnsServers = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user credentials derived from the subscription record (see pkg/simnet).
|
||||||
|
userKeyID := simnet.DeriveKeyID(userInfo.SubscribeID)
|
||||||
|
userPSK := simnet.DeriveUserPSK(userInfo.Password)
|
||||||
|
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if smString(proxy["Type"]) != "simnet" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
afEnabled := smBool(proxy["SimnetAfEnabled"])
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"tag": smString(proxy["Name"]),
|
||||||
|
"server_addr": smString(proxy["Server"]),
|
||||||
|
"server_port": smInt(proxy["Port"]),
|
||||||
|
"protocol": "simnet",
|
||||||
|
"sni": smString(proxy["SNI"]),
|
||||||
|
"allow_insecure": smBool(proxy["AllowInsecure"]),
|
||||||
|
"simnet_psk": userPSK,
|
||||||
|
"simnet_key_id": userKeyID,
|
||||||
|
// Server PSK is required for AF path/magic/content-type derivation.
|
||||||
|
"simnet_server_psk": smStringOrNil(proxy["SimnetPsk"]),
|
||||||
|
"simnet_server_key_id": smInt(proxy["SimnetKeyID"]),
|
||||||
|
"simnet_ticket_id": smStringOrNil(proxy["SimnetTicketID"]),
|
||||||
|
"simnet_path": smDefaultString(smString(proxy["SimnetPath"]), "/simnet/session"),
|
||||||
|
"simnet_carrier": smDefaultString(smString(proxy["SimnetCarrier"]), "h2"),
|
||||||
|
"simnet_af_enabled": afEnabled,
|
||||||
|
"simnet_client_max_concurrent_streams": smDefaultInt(smInt(proxy["SimnetClientMaxConcurrentStreams"]), 32),
|
||||||
|
"simnet_client_max_streams_per_session": smDefaultInt(smInt(proxy["SimnetClientMaxStreamsPerSession"]), 512),
|
||||||
|
"simnet_client_session_idle_timeout_secs": smDefaultInt(smInt(proxy["SimnetClientSessionIdleTimeoutSecs"]), 90),
|
||||||
|
"simnet_client_max_udp_sessions": smDefaultInt(smInt(proxy["SimnetClientMaxUDPSessions"]), 64),
|
||||||
|
"proxy_mode": proxyMode,
|
||||||
|
"dns_servers": dnsServers,
|
||||||
|
}
|
||||||
|
if afEnabled {
|
||||||
|
item["simnet_af_path_mode"] = smDefaultString(smString(proxy["SimnetAfPathMode"]), "api")
|
||||||
|
item["simnet_af_path_prefix"] = smStringOrNil(proxy["SimnetAfPathPrefix"])
|
||||||
|
item["simnet_af_path_suffix"] = smStringOrNil(proxy["SimnetAfPathSuffix"])
|
||||||
|
item["simnet_af_magic_mode"] = smDefaultString(smString(proxy["SimnetAfMagicMode"]), "derived")
|
||||||
|
item["simnet_af_response_jitter_ms"] = smDefaultInt(smInt(proxy["SimnetAfResponseJitterMs"]), 50)
|
||||||
|
item["simnet_af_handshake_polymorphism"] = smBool(proxy["SimnetAfHandshakePolymorphism"])
|
||||||
|
item["simnet_af_settings_jitter"] = smBool(proxy["SimnetAfSettingsJitter"])
|
||||||
|
item["simnet_af_fake_header_injection"] = smBool(proxy["SimnetAfFakeHeaderInjection"])
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func smString(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func smStringOrNil(v interface{}) interface{} {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func smBool(v interface{}) bool {
|
||||||
|
b, ok := v.(bool)
|
||||||
|
return ok && b
|
||||||
|
}
|
||||||
|
|
||||||
|
func smInt(v interface{}) int {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int8:
|
||||||
|
return int(n)
|
||||||
|
case int16:
|
||||||
|
return int(n)
|
||||||
|
case int32:
|
||||||
|
return int(n)
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case uint:
|
||||||
|
return int(n)
|
||||||
|
case uint8:
|
||||||
|
return int(n)
|
||||||
|
case uint16:
|
||||||
|
return int(n)
|
||||||
|
case uint32:
|
||||||
|
return int(n)
|
||||||
|
case uint64:
|
||||||
|
return int(n)
|
||||||
|
case float32:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func smDefaultString(s, def string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func smDefaultInt(i, def int) int {
|
||||||
|
if i == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOmnxtProtocolLinks wraps each simnet config into a base64 "simnet://"
|
||||||
|
// link, matching the Pro reference's final delivery format (migration 02140,
|
||||||
|
// template.go buildOmnxtProtocolLinks). Template usage:
|
||||||
|
//
|
||||||
|
// {{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}
|
||||||
|
// {{- end }}
|
||||||
|
func buildOmnxtProtocolLinks(proxies []map[string]interface{}, userInfo User, params map[string]string) []string {
|
||||||
|
configs := buildOmnxtSimnetConfigs(proxies, userInfo, params)
|
||||||
|
result := make([]string, 0, len(configs))
|
||||||
|
|
||||||
|
for _, item := range configs {
|
||||||
|
serverAddr := smString(item["server_addr"])
|
||||||
|
serverPort := smInt(item["server_port"])
|
||||||
|
tag := smString(item["tag"])
|
||||||
|
if serverAddr == "" || serverPort == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
afEnabled := smBool(item["simnet_af_enabled"])
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"protocol": "simnet",
|
||||||
|
"server_addr": serverAddr,
|
||||||
|
"server_port": serverPort,
|
||||||
|
"sni": smString(item["sni"]),
|
||||||
|
"simnet_psk": smString(item["simnet_psk"]),
|
||||||
|
"simnet_key_id": smInt(item["simnet_key_id"]),
|
||||||
|
"simnet_server_psk": item["simnet_server_psk"],
|
||||||
|
"simnet_server_key_id": smInt(item["simnet_server_key_id"]),
|
||||||
|
"simnet_ticket_id": item["simnet_ticket_id"],
|
||||||
|
"simnet_path": item["simnet_path"],
|
||||||
|
"simnet_carrier": smString(item["simnet_carrier"]),
|
||||||
|
"simnet_af_enabled": afEnabled,
|
||||||
|
"simnet_client_max_concurrent_streams": smInt(item["simnet_client_max_concurrent_streams"]),
|
||||||
|
"simnet_client_max_streams_per_session": smInt(item["simnet_client_max_streams_per_session"]),
|
||||||
|
"simnet_client_session_idle_timeout_secs": smInt(item["simnet_client_session_idle_timeout_secs"]),
|
||||||
|
"simnet_client_max_udp_sessions": smInt(item["simnet_client_max_udp_sessions"]),
|
||||||
|
"proxy_mode": item["proxy_mode"],
|
||||||
|
"dns_servers": item["dns_servers"],
|
||||||
|
}
|
||||||
|
if afEnabled {
|
||||||
|
payload["simnet_af_path_mode"] = smString(item["simnet_af_path_mode"])
|
||||||
|
payload["simnet_af_path_prefix"] = item["simnet_af_path_prefix"]
|
||||||
|
payload["simnet_af_path_suffix"] = item["simnet_af_path_suffix"]
|
||||||
|
payload["simnet_af_magic_mode"] = smString(item["simnet_af_magic_mode"])
|
||||||
|
payload["simnet_af_response_jitter_ms"] = smInt(item["simnet_af_response_jitter_ms"])
|
||||||
|
payload["simnet_af_handshake_polymorphism"] = smBool(item["simnet_af_handshake_polymorphism"])
|
||||||
|
payload["simnet_af_settings_jitter"] = smBool(item["simnet_af_settings_jitter"])
|
||||||
|
payload["simnet_af_fake_header_injection"] = smBool(item["simnet_af_fake_header_injection"])
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := encodeProtocolPayload(payload)
|
||||||
|
if encoded == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, "simnet://"+encoded+"#"+url.QueryEscape(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeProtocolPayload url-encodes a payload map and base64-encodes it,
|
||||||
|
// matching the reference encodeProtocolPayload.
|
||||||
|
func encodeProtocolPayload(payload map[string]interface{}) string {
|
||||||
|
values := url.Values{}
|
||||||
|
for key, value := range payload {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
continue
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
values.Set(key, v)
|
||||||
|
}
|
||||||
|
case bool:
|
||||||
|
if v {
|
||||||
|
values.Set(key, "1")
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
if v != 0 {
|
||||||
|
values.Set(key, strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
case int32:
|
||||||
|
if v != 0 {
|
||||||
|
values.Set(key, strconv.FormatInt(int64(v), 10))
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v != 0 {
|
||||||
|
values.Set(key, strconv.FormatInt(v, 10))
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
if len(v) > 0 {
|
||||||
|
values.Set(key, strings.Join(v, ","))
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
items := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s := smString(item); s != "" {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
values.Set(key, strings.Join(items, ","))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if s := smString(v); s != "" {
|
||||||
|
values.Set(key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "Lottery Admin API"
|
||||||
|
desc: "Admin-facing lottery endpoints for HIF-3 Stage 1"
|
||||||
|
author: "hifast"
|
||||||
|
version: "0.1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "../types.api"
|
||||||
|
|
||||||
|
@server (
|
||||||
|
prefix: v1/admin/lottery
|
||||||
|
group: admin/lottery
|
||||||
|
middleware: AuthMiddleware
|
||||||
|
)
|
||||||
|
service ppanel {
|
||||||
|
@doc "Create a new activity (status=draft)"
|
||||||
|
@handler CreateLotteryActivity
|
||||||
|
post /activities (CreateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||||
|
|
||||||
|
@doc "Update mutable activity fields"
|
||||||
|
@handler UpdateLotteryActivity
|
||||||
|
put /activities (UpdateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||||
|
|
||||||
|
@doc "List activities (paginated)"
|
||||||
|
@handler ListLotteryActivities
|
||||||
|
get /activities (ListAdminLotteryActivitiesRequest) returns (ListAdminLotteryActivitiesResponse)
|
||||||
|
|
||||||
|
@doc "Get one activity"
|
||||||
|
@handler GetLotteryActivity
|
||||||
|
get /activities/detail (AdminActivityIdRequest) returns (AdminLotteryActivity)
|
||||||
|
|
||||||
|
@doc "Publish (draft/paused → running)"
|
||||||
|
@handler PublishLotteryActivity
|
||||||
|
post /activities/publish (AdminActivityIdRequest)
|
||||||
|
|
||||||
|
@doc "Pause (running → paused)"
|
||||||
|
@handler PauseLotteryActivity
|
||||||
|
post /activities/pause (AdminActivityIdRequest)
|
||||||
|
|
||||||
|
@doc "Update eligibility/chance_sources (rule-caps enforced)"
|
||||||
|
@handler UpdateLotteryRules
|
||||||
|
put /activities/rules (UpdateAdminLotteryRulesRequest)
|
||||||
|
|
||||||
|
@doc "Delete activity (soft-delete; running must be paused first)"
|
||||||
|
@handler DeleteLotteryActivity
|
||||||
|
delete /activities/:id (AdminActivityIdRequest)
|
||||||
|
|
||||||
|
@doc "Create prize"
|
||||||
|
@handler CreateLotteryPrize
|
||||||
|
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||||
|
|
||||||
|
@doc "Update prize"
|
||||||
|
@handler UpdateLotteryPrize
|
||||||
|
put /prizes/:id (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||||
|
|
||||||
|
@doc "Delete prize"
|
||||||
|
@handler DeleteLotteryPrize
|
||||||
|
delete /prizes/:id (AdminPrizeIdRequest)
|
||||||
|
|
||||||
|
@doc "List prizes on an activity"
|
||||||
|
@handler ListLotteryPrizes
|
||||||
|
get /prizes (ListAdminLotteryPrizesRequest) returns (ListAdminLotteryPrizesResponse)
|
||||||
|
|
||||||
|
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
||||||
|
@handler GrantLotteryChance
|
||||||
|
post /chances/grant (GrantAdminLotteryChanceRequest)
|
||||||
|
|
||||||
|
// Stage 2 (HIF-4): 人工奖工单接口
|
||||||
|
@doc "List manual-claim work orders (filter by type/status/activity/user/time)"
|
||||||
|
@handler ListLotteryClaims
|
||||||
|
get /claims (ListAdminLotteryClaimsRequest) returns (ListAdminLotteryClaimsResponse)
|
||||||
|
|
||||||
|
@doc "Summary counts for claims workbench"
|
||||||
|
@handler LotteryClaimsSummary
|
||||||
|
get /claims/summary returns (AdminLotteryClaimsSummary)
|
||||||
|
|
||||||
|
@doc "Approve a claim (reviewing -> paying)"
|
||||||
|
@handler ApproveLotteryClaim
|
||||||
|
post /claims/approve (AdminApproveClaimRequest)
|
||||||
|
|
||||||
|
@doc "Reject a claim (reviewing/paying -> rejected; user may resubmit)"
|
||||||
|
@handler RejectLotteryClaim
|
||||||
|
post /claims/reject (AdminRejectClaimRequest)
|
||||||
|
|
||||||
|
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
|
||||||
|
@handler MarkPaidLotteryClaim
|
||||||
|
post /claims/mark-paid (AdminMarkPaidClaimRequest)
|
||||||
|
|
||||||
|
@doc "List lottery draws (grant records)"
|
||||||
|
@handler ListLotteryDraws
|
||||||
|
get /draws (ListAdminLotteryDrawsRequest) returns (ListAdminLotteryDrawsResponse)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "Lottery API"
|
||||||
|
desc: "User-facing lottery endpoints for HIF-3 Stage 1"
|
||||||
|
author: "hifast"
|
||||||
|
version: "0.1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "../types.api"
|
||||||
|
|
||||||
|
@server (
|
||||||
|
prefix: v1/lottery
|
||||||
|
group: public/lottery
|
||||||
|
middleware: AuthMiddleware,DeviceMiddleware
|
||||||
|
)
|
||||||
|
service ppanel {
|
||||||
|
@doc "Get lottery activity config + user status"
|
||||||
|
@handler QueryLotteryConfig
|
||||||
|
get /config (GetLotteryConfigRequest) returns (GetLotteryConfigResponse)
|
||||||
|
|
||||||
|
@doc "Draw once (nonce idempotent, rate limited 1/sec)"
|
||||||
|
@handler DrawLottery
|
||||||
|
post /draw (DrawLotteryRequest) returns (DrawLotteryResponse)
|
||||||
|
|
||||||
|
@doc "List my draws"
|
||||||
|
@handler QueryLotteryRecords
|
||||||
|
get /records (GetLotteryRecordsRequest) returns (GetLotteryRecordsResponse)
|
||||||
|
|
||||||
|
@doc "Claim a prize (Stage 1 returns 100010 not_claimable)"
|
||||||
|
@handler ClaimLotteryPrize
|
||||||
|
post /claim (ClaimLotteryPrizeRequest) returns (ClaimLotteryPrizeResponse)
|
||||||
|
}
|
||||||
@@ -1045,6 +1045,51 @@ type (
|
|||||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||||
|
SimnetPsk string `json:"simnet_psk,omitempty"`
|
||||||
|
SimnetKeyID int `json:"simnet_key_id,omitempty"`
|
||||||
|
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||||
|
SimnetPath string `json:"simnet_path,omitempty"`
|
||||||
|
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||||
|
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||||
|
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||||
|
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||||
|
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||||
|
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||||
|
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||||
|
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||||
|
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||||
|
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||||
|
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||||
|
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||||
|
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||||
|
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||||
|
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||||
|
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||||
|
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||||
|
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||||
|
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||||
|
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||||
|
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||||
|
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||||
|
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||||
|
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||||
|
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||||
|
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||||
|
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||||
|
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||||
|
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||||
|
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||||
|
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||||
|
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||||
|
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||||
|
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||||
|
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||||
|
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||||
}
|
}
|
||||||
// reset user subscribe token
|
// reset user subscribe token
|
||||||
ResetUserSubscribeTokenRequest {
|
ResetUserSubscribeTokenRequest {
|
||||||
|
|||||||
@@ -0,0 +1,813 @@
|
|||||||
|
# PPanel Server Simnet 协议接入实施计划
|
||||||
|
|
||||||
|
本文档用于指导在现有自维护后端 `/Users/Apple/code_vpn/vpn/ppanel-server` 中接入 `simnet` 协议。目标不是把 Pro 新版后端整体迁移进来,而是在保留旧系统架构、数据库主链路和现有节点管理模型的前提下,把 `simnet` 做到管理端可配置、OmnXT 节点可拉取、SlagClient 可订阅连接、用户授权和流量统计闭环。
|
||||||
|
|
||||||
|
参考实现来自新版 Pro 后端:`/Users/Apple/Downloads/NPanelPro-pro/NPanel-backend`。
|
||||||
|
|
||||||
|
## 1. 项目背景
|
||||||
|
|
||||||
|
当前旧后端已经有完整的 Server、Node、Subscribe、Traffic、Online User 等链路,协议配置主要保存在 Server 的 `protocols` JSON 字段里,Node 侧用 `protocol + port + address` 描述对外节点。新版 Pro 后端已经加入了 `simnet` 协议字段、管理端接口、节点兼容接口和订阅交付逻辑,但它的整体工程结构和旧仓库不同。
|
||||||
|
|
||||||
|
旧仓库是 Gin/goctl/Gorm 风格,核心入口包括:
|
||||||
|
|
||||||
|
- API 定义:`apis/admin/server.api`、`apis/node/node.api`、`apis/public/subscribe.api`、`apis/types.api`
|
||||||
|
- 生成类型:`internal/types/types.go`
|
||||||
|
- 管理端 Server 逻辑:`internal/logic/admin/server/*`
|
||||||
|
- 节点服务端配置拉取:`internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- 节点用户列表拉取:`internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- 公共订阅节点返回:`internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- 节点在线与流量上报:`internal/logic/server/pushOnlineUsersLogic.go`、`internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
|
||||||
|
新版 Pro 的关键参考入口包括:
|
||||||
|
|
||||||
|
- Simnet 管理端字段:`api/admin/server/v1/server.proto`
|
||||||
|
- OmnXT 节点兼容接口:`internal/server/http_compat_server.go`
|
||||||
|
- 公共订阅响应:`api/public/subscribe/v1/subscribe.proto`
|
||||||
|
- 公共订阅映射:`internal/service/public/subscribe/subscribe.go`
|
||||||
|
- UA/capability 过滤:`internal/biz/public/subscribe/subscribe.go`
|
||||||
|
- 节点交付数据:`internal/data/delivery_node.go`
|
||||||
|
- 协议模型和默认值:`internal/model/server/protocol.go`
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
1. 在旧后端中完整支持 `simnet` 协议的保存、查询、下发、订阅和统计。
|
||||||
|
2. 继续使用旧系统 Server 的 `protocols` JSON 保存协议配置,不强制拆表保存管理端协议配置。
|
||||||
|
3. 第一版支持当前实际需要的能力:H2、TLS/SNI、AF、HTTPS Fallback。
|
||||||
|
4. Reverse 字段先纳入模型和接口,默认关闭;不在第一版强制上线 Reverse 转发能力。
|
||||||
|
5. 管理端配置、OmnXT 服务端运行配置、SlagClient 客户端订阅配置使用不同 DTO,避免敏感字段误下发。
|
||||||
|
6. 使用 `type + port` 唯一定位一个 Server 内的协议实例,支持同一 Server 未来存在多个协议。
|
||||||
|
7. OmnXT 拉取配置必须校验 `secret_key`。
|
||||||
|
8. Server 级 PSK 不得下发给普通用户。
|
||||||
|
9. 优先设计每用户独立 Simnet Key ID/PSK,使用户隔离、封禁、重置和审计可控。
|
||||||
|
10. SlagClient 订阅响应兼容 `protocols` JSON 和顶层 `simnet_*` 字段。
|
||||||
|
|
||||||
|
### 非目标
|
||||||
|
|
||||||
|
1. 不整体替换旧后端为 Pro 新后端。
|
||||||
|
2. 不一次性迁移 Pro 的全部协议字段、路由系统、完整 delivery node 架构。
|
||||||
|
3. 不第一版实现 OmniFlow 或其他新协议。
|
||||||
|
4. 不改变现有套餐、订单、余额、邀请等业务主链路。
|
||||||
|
5. 不把生产服务器凭据、JWT、节点 SSH 密码写入代码或文档。
|
||||||
|
|
||||||
|
## 3. 总体技术策略
|
||||||
|
|
||||||
|
最科学的迁移方式是“协议纵向切入”,而不是“代码横向搬运”。也就是沿着 `simnet` 从管理端保存到节点运行,再到用户订阅、授权、流量统计的完整链路逐层补齐。
|
||||||
|
|
||||||
|
建议分三段落地:
|
||||||
|
|
||||||
|
1. Server 侧先闭环:管理端能保存 `simnet`,OmnXT 能用 `secret_key` 拉到运行配置。
|
||||||
|
2. User 侧再闭环:每个用户生成独立凭据,OmnXT 用户列表和 SlagClient 订阅使用同一套凭据。
|
||||||
|
3. 运维侧最后闭环:流量、在线、到期、限额、TLS/AF/Fallback、灰度和回滚全部验证。
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
|
||||||
|
- 旧架构优先:沿用 goctl API、`internal/types`、现有 logic/model 风格。
|
||||||
|
- DTO 分层:管理端 DTO 可以看到完整配置;节点 DTO 只给 OmnXT 运行需要;订阅 DTO 只给用户连接需要。
|
||||||
|
- 敏感字段隔离:Server PSK、证书 DNS 环境变量、节点密钥不得进入普通用户订阅响应。
|
||||||
|
- 渐进兼容:老协议、老客户端、老节点不受影响。
|
||||||
|
- 可回滚:每个阶段都能通过关闭 `simnet` 协议或恢复旧接口行为回滚。
|
||||||
|
|
||||||
|
## 4. Simnet 数据链路
|
||||||
|
|
||||||
|
完整链路如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Admin UI
|
||||||
|
-> POST /api/v1/admin/server/create or update
|
||||||
|
-> Server.protocols JSON contains type=simnet
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> GET /api/v1/server/config?server_id=...&protocol=simnet&secret_key=...
|
||||||
|
-> receives server runtime config, including server-side PSK and TLS/AF/Fallback settings
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> GET /api/v1/server/user/list?server_id=...&protocol=simnet&secret_key=...
|
||||||
|
-> receives active user authorization list and per-user simnet credentials
|
||||||
|
|
||||||
|
SlagClient
|
||||||
|
-> GET /api/v1/public/subscribe?token=... with capability headers
|
||||||
|
-> receives node address, port, TLS/SNI, path, AF/Fallback public fields and user credential
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> POST traffic / online user report
|
||||||
|
-> backend maps simnet user credential to user subscribe and records traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
`simnet` 的运行配置不能只靠 `server.protocols` 原样下发,因为同一份 JSON 同时包含管理端字段、Server 密钥字段和用户连接字段。必须在每个出口做字段筛选和转换。
|
||||||
|
|
||||||
|
## 5. 阶段 0:建立基线与确认契约
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
确认旧后端、OmnXT、SlagClient 对 `simnet` 的最小契约,先把边界钉牢,避免后续实现时字段名、鉴权方式或客户端解析格式反复改。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 从新版 Pro 提取 `simnet` 管理字段、服务端字段、订阅字段的差异表。
|
||||||
|
2. 用当前 OmnXT 安装脚本部署的版本抓取真实请求路径和请求参数。
|
||||||
|
3. 用 SlagClient 抓取订阅请求 header,确认 capability header 名称和版本值。
|
||||||
|
4. 确认 `secret_key` 当前在旧仓库 `internal/middleware/serverMiddleware.go` 或节点接口 handler 中的校验方式。
|
||||||
|
5. 确认 `server_id + protocol` 是否已经足够定位节点运行配置;如果端口也会重复,需要补充 `port` 查询参数。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
本阶段原则上不改业务代码,只新增测试夹具或临时验证脚本。可新增:
|
||||||
|
|
||||||
|
- `tests/simnet/fixtures/`
|
||||||
|
- `docs/simnet-contract.md`,如需要更细的契约文档
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 需要可运行的旧后端本地环境或测试库。
|
||||||
|
- 需要 OmnXT 当前版本真实请求样本。
|
||||||
|
- 需要 SlagClient 当前版本订阅响应解析规则。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 明确 OmnXT 配置接口路径、方法、请求参数和响应字段。
|
||||||
|
2. 明确 SlagClient 识别 `simnet` 的字段格式。
|
||||||
|
3. 明确 capability header 优先级:先 capability header,再 User-Agent 兜底。
|
||||||
|
4. 明确 `type + port` 是协议实例唯一键。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
本阶段不涉及生产行为,无需业务回滚。
|
||||||
|
|
||||||
|
## 6. 阶段 1:协议模型与参数校验
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让旧后端的 `Protocol` 类型可以完整表达第一版 `simnet` 配置,并在创建/更新 Server 时有默认值和校验。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `apis/types.api` 的 `Protocol` 结构加入 `simnet` 字段。
|
||||||
|
2. 重新生成 `internal/types/types.go`。
|
||||||
|
3. 在 `internal/model/node` 中的协议模型加入同名 JSON 字段,保证 Server 的 `protocols` JSON 能完整 marshal/unmarshal。
|
||||||
|
4. 新增 `simnet` 默认值函数,例如 `ApplySimnetDefaults`。
|
||||||
|
5. 新增 `simnet` 参数校验函数,例如 `ValidateSimnetProtocol`。
|
||||||
|
6. 校验 `type + port` 唯一,避免同一 Server 下出现两个 `simnet:443`。
|
||||||
|
7. 限制第一版允许值:`simnet_carrier=h2`、`security=tls|none`,生产建议默认 `tls`。
|
||||||
|
8. 校验 path 必须以 `/` 开头,fallback host 非空时端口必须在 1-65535。
|
||||||
|
9. 校验 `simnet_psk` 最小长度和字符集;自动生成时使用安全随机。
|
||||||
|
|
||||||
|
### 字段范围
|
||||||
|
|
||||||
|
核心字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_psk
|
||||||
|
simnet_key_id
|
||||||
|
simnet_ticket_id
|
||||||
|
simnet_path
|
||||||
|
simnet_carrier
|
||||||
|
```
|
||||||
|
|
||||||
|
TLS 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
security
|
||||||
|
sni
|
||||||
|
allow_insecure
|
||||||
|
cert_mode
|
||||||
|
cert_dns_provider
|
||||||
|
cert_dns_env
|
||||||
|
```
|
||||||
|
|
||||||
|
AF 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_af_enabled
|
||||||
|
simnet_af_path_mode
|
||||||
|
simnet_af_path_prefix
|
||||||
|
simnet_af_path_suffix
|
||||||
|
simnet_af_magic_mode
|
||||||
|
simnet_af_response_jitter_ms
|
||||||
|
simnet_af_handshake_polymorphism
|
||||||
|
simnet_af_settings_jitter
|
||||||
|
simnet_af_fake_header_injection
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_fallback_enabled
|
||||||
|
simnet_fallback_target_scheme
|
||||||
|
simnet_fallback_target_host
|
||||||
|
simnet_fallback_target_port
|
||||||
|
simnet_fallback_host_header
|
||||||
|
simnet_fallback_tls_sni
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_reverse_enabled
|
||||||
|
simnet_reverse_listen_addr
|
||||||
|
simnet_reverse_listen_port
|
||||||
|
simnet_reverse_target_host
|
||||||
|
simnet_reverse_target_port
|
||||||
|
```
|
||||||
|
|
||||||
|
### 默认值
|
||||||
|
|
||||||
|
建议默认值如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
port: 443
|
||||||
|
simnet_path: /simnet/session
|
||||||
|
simnet_carrier: h2
|
||||||
|
security: tls
|
||||||
|
allow_insecure: false
|
||||||
|
simnet_af_path_mode: api
|
||||||
|
simnet_af_magic_mode: derived
|
||||||
|
simnet_af_response_jitter_ms: 1
|
||||||
|
simnet_reverse_enabled: false
|
||||||
|
simnet_reverse_listen_addr: 127.0.0.1
|
||||||
|
simnet_fallback_enabled: true
|
||||||
|
simnet_fallback_target_scheme: https
|
||||||
|
simnet_fallback_target_port: 443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/types.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/model/node/*` 或实际定义 `node.Protocol` 的文件
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- 可新增 `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 0 的字段契约。
|
||||||
|
- goctl 代码生成命令可用。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 管理端提交 `type=simnet` 时,Server 可以保存完整 JSON。
|
||||||
|
2. 未传默认字段时自动补齐默认值。
|
||||||
|
3. 非法 path、非法 port、重复 `type + port` 会被拒绝。
|
||||||
|
4. 旧协议保存和返回不变。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭管理端提交 `simnet` 的入口校验;或恢复 `apis/types.api` 和生成类型,旧协议数据仍可继续工作。
|
||||||
|
|
||||||
|
## 7. 阶段 2:管理端 Server 接口
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让管理端 Server 创建、更新、查询能完整展示和编辑 `simnet`,并保持 Node 更新接口与 Server 协议配置一致。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 更新 `CreateServerRequest`、`UpdateServerRequest`、`FilterServerListResponse`、`GetServerProtocolsResponse` 中的协议字段。
|
||||||
|
2. 在 create/update Server 时对每个 protocol 先做 normalize,再落库。
|
||||||
|
3. 在 filter/list/detail 接口中返回规范化后的 `simnet` 字段。
|
||||||
|
4. 检查 `CreateNodeRequest`、`UpdateNodeRequest` 是否允许 `protocol=simnet`。
|
||||||
|
5. Node 端 `node_type=front` 的创建/更新要允许 `simnet`,并校验其 `port` 与 Server 里的 `simnet` 协议端口一致。
|
||||||
|
6. 如果管理端前端需要协议选项,`GetServerProtocols` 要返回 `simnet`,并带默认字段方便 UI 填充。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/admin/server.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||||
|
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||||
|
- `internal/logic/admin/server/createNodeLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateNodeLogic.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 协议模型已经可表达 `simnet`。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 管理端能创建一个 Server,包含 `simnet:443`。
|
||||||
|
2. 管理端能更新 `simnet_path`、`sni`、AF 和 fallback 字段。
|
||||||
|
3. 管理端节点列表显示 `HK simnet` 这类节点时,协议类型不丢失。
|
||||||
|
4. `GetServerProtocols` 返回的 `protocols` JSON 与数据库一致且字段完整。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
从管理端把 `simnet` 协议 disabled,保留数据但不对节点下发;或回滚 Server 相关 API 和 logic。
|
||||||
|
|
||||||
|
## 8. 阶段 3:OmnXT 服务端配置下发
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 节点通过旧后端节点 API 拉到可运行的 `simnet` 服务端配置。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 检查 `apis/node/node.api` 中 `GetServerConfigRequest` 是否有 `secret_key`、`server_id`、`protocol`。
|
||||||
|
2. 在 `GetServerConfigLogic` 中加入 `protocol=simnet` 分支。
|
||||||
|
3. 根据 `server_id + protocol + port` 找到启用的 `simnet` 协议配置。
|
||||||
|
4. 验证 `secret_key`,失败时返回明确错误,并记录来源 IP 和 server_id。
|
||||||
|
5. 构造 OmnXT 服务端运行 DTO,包含 Server 运行需要的 PSK、path、carrier、TLS、SNI、AF、fallback、reverse 默认关闭字段。
|
||||||
|
6. 不把管理端专用字段、无关协议字段原样塞给 OmnXT。
|
||||||
|
7. 缓存 key 要包含 `server_id + protocol + port`,避免同端口多协议污染缓存。
|
||||||
|
8. OmnXT 配置变更后要能通过更新 Server 或清理缓存生效。
|
||||||
|
|
||||||
|
### 服务端 DTO 建议
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocol": "simnet",
|
||||||
|
"port": 443,
|
||||||
|
"listen": ":443",
|
||||||
|
"simnet_psk": "server-side-secret",
|
||||||
|
"simnet_path": "/simnet/session",
|
||||||
|
"simnet_carrier": "h2",
|
||||||
|
"security": "tls",
|
||||||
|
"sni": "example.com",
|
||||||
|
"allow_insecure": false,
|
||||||
|
"simnet_af_enabled": true,
|
||||||
|
"simnet_fallback_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- `internal/logic/server/constant.go`
|
||||||
|
- `internal/middleware/serverMiddleware.go`
|
||||||
|
- 可新增 `internal/logic/server/simnet_config.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 和阶段 2。
|
||||||
|
- OmnXT 实际接口字段确认完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `secret_key` 正确时,OmnXT 能拉到 `simnet` 服务端配置。
|
||||||
|
2. `secret_key` 错误时,请求被拒绝。
|
||||||
|
3. 修改管理端 `simnet_path` 后,OmnXT 重启或刷新能拿到新 path。
|
||||||
|
4. Server PSK 只出现在 OmnXT 服务端配置中,不出现在普通用户订阅中。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭 `simnet.enable` 或回滚 `GetServerConfigLogic` 的 `simnet` 分支;旧协议节点不受影响。
|
||||||
|
|
||||||
|
## 9. 阶段 4:用户级 Simnet 凭据
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
为每个有效用户订阅生成独立 `simnet` 凭据,避免所有用户共享 Server PSK,支持单用户封禁、重置和流量归属。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 新增用户级凭据模型,建议按 `user_subscribe_id + server_id + protocol + port` 维度唯一。
|
||||||
|
2. 字段建议包括:`id`、`user_id`、`user_subscribe_id`、`server_id`、`protocol`、`port`、`key_id`、`psk`、`ticket_id`、`enabled`、`created_at`、`updated_at`、`rotated_at`。
|
||||||
|
3. 添加数据库 migration,并在初始化兼容逻辑中保证表存在。
|
||||||
|
4. 用户第一次订阅或节点第一次拉用户列表时懒生成凭据。
|
||||||
|
5. 支持管理员重置某个用户订阅 token 时同步重置 `simnet` 凭据,避免旧凭据继续可用。
|
||||||
|
6. 凭据生成使用加密安全随机;`key_id` 可用递增 id 或稳定 hash,但必须避免全局冲突。
|
||||||
|
7. 保留 `ticket_id` 字段,第一版可为空或由 OmnXT 需要时生成。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `internal/model/user/*` 或新增 `internal/model/simnet/*`
|
||||||
|
- `initialize/migrate/*`
|
||||||
|
- `initialize/schema_compat.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- 用户订阅 token 重置逻辑:`internal/logic/admin/user/resetUserSubscribeTokenHandler.go` 对应 logic
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 0 确认 OmnXT 和 SlagClient 需要的用户凭据格式。
|
||||||
|
- 阶段 1 的协议模型完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 同一用户同一节点多次订阅拿到稳定凭据。
|
||||||
|
2. 不同用户拿到不同凭据。
|
||||||
|
3. 重置用户订阅 token 后旧凭据失效,新凭据生效。
|
||||||
|
4. 凭据表有唯一约束,重复生成不会产生两条有效凭据。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
可以停止向 OmnXT 下发 `simnet` 用户授权,并禁用 `simnet` 节点。数据库表可保留,不影响旧协议。
|
||||||
|
|
||||||
|
## 10. 阶段 5:OmnXT 用户授权同步
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 拉取用户列表时获得 `simnet` 可认证用户,并且用户到期、限额、禁用、套餐节点组变化后同步生效。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `GetServerUserListLogic` 中加入 `simnet` 用户映射。
|
||||||
|
2. 沿用旧系统的有效用户筛选条件:订阅有效、未到期、流量未超限、用户未禁用、节点组有权限。
|
||||||
|
3. 对 `simnet` 用户返回 `user_id`、`subscribe_id`、`uuid`、`key_id`、`psk`、`ticket_id`、限速字段。
|
||||||
|
4. OmnXT 请求 `protocol=simnet` 时,只返回有 `simnet` 权限的用户。
|
||||||
|
5. 缓存 key 加入 `protocol + port`,用户订阅变更、流量变更、节点组变更时能失效。
|
||||||
|
6. 对 `hysteria2` 等旧兼容映射不做破坏;`normalizeServerUserListProtocol` 仅新增 `simnet` 透传。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- `internal/logic/server/constant.go`
|
||||||
|
- 用户订阅、节点组、流量相关 model/service
|
||||||
|
- 可新增 `internal/logic/server/simnet_user.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- 现有用户有效性判断需要梳理清楚。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. OmnXT 拉用户列表时能看到有效用户的 `simnet` 凭据。
|
||||||
|
2. 用户到期、禁用或流量超限后,从 OmnXT 用户列表消失。
|
||||||
|
3. 套餐节点组取消该节点后,从 OmnXT 用户列表消失。
|
||||||
|
4. 老协议用户列表响应不变。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
保留凭据表,但关闭 `GetServerUserListLogic` 的 `simnet` 分支或禁用节点。
|
||||||
|
|
||||||
|
## 11. 阶段 6:公共订阅与 SlagClient
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 SlagClient 冷启动、重启、重新订阅时都能拿到完整 `simnet` 节点,并正确构造连接。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `apis/public/subscribe.api` 的 `UserSubscribeNodeInfo` 加入用户连接需要的顶层 `simnet_*` 字段。
|
||||||
|
2. 保留 `protocols` JSON,确保 SlagClient 旧解析路径仍可读取。
|
||||||
|
3. 在 `QueryUserSubscribeNodeListLogic` 中解析 Server 的 `protocols` JSON,并把匹配 `node.protocol + node.port` 的 `simnet` 配置映射到订阅响应。
|
||||||
|
4. 订阅响应只下发用户级 `simnet_key_id`、用户级 `simnet_psk`、可公开 path/carrier/TLS/SNI/AF/Fallback 字段。
|
||||||
|
5. 不下发 Server 级 `simnet_psk`、DNS provider env、管理端密钥字段。
|
||||||
|
6. 新增 capability header 判断,例如 `X-Client-Capabilities: simnet` 或当前 SlagClient 实际 header。
|
||||||
|
7. 如果没有 capability header,则使用 User-Agent 作为兼容兜底;不应单纯依赖 UA。
|
||||||
|
8. 对不支持 `simnet` 的客户端隐藏 `simnet` 节点,避免客户端崩溃或展示不可用节点。
|
||||||
|
9. 如果 SlagClient 同时支持 `protocols` JSON 和顶层字段,优先让顶层字段完整,`protocols` 作为兼容冗余。
|
||||||
|
|
||||||
|
### 订阅 DTO 建议
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HK simnet",
|
||||||
|
"protocol": "simnet",
|
||||||
|
"port": 443,
|
||||||
|
"address": "node.example.com",
|
||||||
|
"sni": "net.example.com",
|
||||||
|
"simnet_key_id": 10001,
|
||||||
|
"simnet_psk": "user-side-secret",
|
||||||
|
"simnet_ticket_id": "",
|
||||||
|
"simnet_path": "/simnet/session",
|
||||||
|
"simnet_carrier": "h2",
|
||||||
|
"security": "tls",
|
||||||
|
"allow_insecure": false,
|
||||||
|
"simnet_af_enabled": true,
|
||||||
|
"simnet_af_path_mode": "api",
|
||||||
|
"simnet_af_magic_mode": "derived",
|
||||||
|
"simnet_fallback_enabled": true,
|
||||||
|
"simnet_fallback_target_scheme": "https",
|
||||||
|
"simnet_fallback_target_host": "www.example.com",
|
||||||
|
"simnet_fallback_target_port": 443
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/public/subscribe.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- `internal/logic/common/subscriptionTrace.go`,如有订阅 UA 或设备记录
|
||||||
|
- 可新增 `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- SlagClient capability header 契约确认。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. SlagClient 冷启动订阅后能看到 `simnet` 节点。
|
||||||
|
2. SlagClient 重启后仍能从订阅恢复连接配置。
|
||||||
|
3. 不支持 `simnet` 的客户端订阅不返回 `simnet` 节点。
|
||||||
|
4. 普通用户订阅响应不包含 Server 级 PSK。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
订阅侧隐藏 `simnet` 节点或关闭 capability 开关;旧协议订阅不受影响。
|
||||||
|
|
||||||
|
## 12. 阶段 7:流量和在线用户映射
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 上报的 `simnet` 在线用户和流量能正确归属到用户订阅,并触发旧系统现有的限额、日志、后台统计。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 确认 OmnXT 上报用户标识是 `uuid`、`key_id`、`user_id` 还是其他字段。
|
||||||
|
2. 如果 OmnXT 上报 `key_id`,后端通过用户级凭据表反查 `user_subscribe_id` 和 `user_id`。
|
||||||
|
3. 如果 OmnXT 上报 `uuid`,需要确认 `uuid` 与 `simnet` 凭据绑定关系,不允许跨用户伪造。
|
||||||
|
4. 在 `serverPushUserTrafficLogic` 中加入 `simnet` 标识解析。
|
||||||
|
5. 在 `pushOnlineUsersLogic` 中加入 `simnet` 在线用户映射。
|
||||||
|
6. 更新后台节点在线数统计,确保 `simnet:443` 与其他协议隔离。
|
||||||
|
7. 失败上报要记录协议、server_id、port、用户标识和错误原因,方便排查。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||||
|
- `internal/model/traffic/*`
|
||||||
|
- `internal/model/node/*`
|
||||||
|
- 凭据表 model
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- OmnXT 上报格式确认。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `simnet` 连接产生流量后,用户已用流量增加。
|
||||||
|
2. 节点后台能看到 `simnet` 在线人数。
|
||||||
|
3. 用户超限后 OmnXT 用户列表不再包含该用户。
|
||||||
|
4. 旧协议流量统计不受影响。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
禁用 `simnet` 流量上报分支或关闭 `simnet` 节点;旧协议统计不受影响。
|
||||||
|
|
||||||
|
## 13. 阶段 8:TLS、AF 与 Fallback
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把当前实际部署需要的 TLS/SNI、AF 和 HTTPS Fallback 做到可配置、可验证、可运维。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. TLS:支持 `security=tls`、`sni`、`allow_insecure=false`。
|
||||||
|
2. 证书模式:第一版支持 `cert_mode=http`;DNS provider 字段先保留,不在普通订阅下发。
|
||||||
|
3. AF:支持 `simnet_af_enabled`、`path_mode=api`、`magic_mode=derived`、`response_jitter_ms`。
|
||||||
|
4. Fallback:支持 fallback scheme、host、port、host header、TLS SNI。
|
||||||
|
5. Reverse:字段保存和下发给 OmnXT,但默认关闭;如果开启必须要求 target host/port 完整。
|
||||||
|
6. 添加配置快照日志,OmnXT 拉取时打印非敏感字段,便于确认线上配置是否生效。
|
||||||
|
7. 对真实节点做 `443` 端口监听、证书申请、fallback 站点访问验证。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
- `internal/logic/server/simnet_config.go`
|
||||||
|
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
- `etc/ppanel.yaml`,如需要新增全局开关
|
||||||
|
- 节点部署文档或运维脚本,视 OmnXT 实际需求决定
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 3 OmnXT 配置下发。
|
||||||
|
- 节点服务器域名、证书、端口和 fallback 目标准备完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. OmnXT 能在 `443` 启动 `simnet` H2 TLS。
|
||||||
|
2. SNI 与证书匹配。
|
||||||
|
3. AF 开启后 SlagClient 仍可连接。
|
||||||
|
4. Fallback 目标在非协议请求时可访问。
|
||||||
|
5. OmnXT 重启后配置仍然生效。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭 AF 或 fallback;必要时把 `simnet.enable=false`,保留旧协议节点承载用户。
|
||||||
|
|
||||||
|
## 14. 阶段 9:自动化测试
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
用测试保护 `simnet` 的关键契约,减少后续修改协议字段时再次出现“面板有配置、节点拿不到、客户端不识别”的问题。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 协议模型测试:默认值、校验、marshal/unmarshal。
|
||||||
|
2. 管理端测试:create/update Server 保存 `simnet` 字段完整。
|
||||||
|
3. 节点配置测试:`secret_key` 正确/错误、`simnet` DTO 字段筛选。
|
||||||
|
4. 用户凭据测试:生成稳定性、用户隔离、重置失效。
|
||||||
|
5. 订阅测试:capability header 支持时返回 `simnet`;不支持时隐藏。
|
||||||
|
6. 敏感字段测试:普通订阅中不得出现 Server PSK、DNS env。
|
||||||
|
7. 流量测试:OmnXT 上报 `key_id` 后可归属用户。
|
||||||
|
8. 回归测试:现有 vless、trojan、hysteria2、shadowsocks 订阅不变。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `tests/acceptance/*`
|
||||||
|
- `internal/logic/admin/server/*_test.go`
|
||||||
|
- `internal/logic/server/*_test.go`
|
||||||
|
- `internal/logic/public/subscribe/*_test.go`
|
||||||
|
- `internal/model/simnet/*_test.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 到阶段 7 基本实现完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `go test ./...` 通过,或项目当前可执行测试集全部通过。
|
||||||
|
2. 新增测试能覆盖 Server、OmnXT、SlagClient、Traffic 四条主链路。
|
||||||
|
3. 任意敏感字段泄露测试失败时,CI 阻断。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
测试本身不影响生产;如果某阶段实现回滚,相应测试应标记待实现或一并回滚。
|
||||||
|
|
||||||
|
## 15. 阶段 10:灰度发布与回滚
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把 `simnet` 以可控方式上线,先让一个节点和少量测试用户跑通,再扩大范围。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 增加全局或配置级开关:`simnet_enabled`。
|
||||||
|
2. 管理端先创建一个独立测试 Server 和一个 `simnet` front node。
|
||||||
|
3. 只给测试套餐或测试节点组分配该节点。
|
||||||
|
4. 部署 OmnXT,确认能拉配置、拉用户、启动监听。
|
||||||
|
5. 用测试用户订阅 SlagClient,验证冷启动、重启、切换网络、重拉订阅。
|
||||||
|
6. 观察在线用户、流量上报、错误日志、证书续期和 fallback 访问。
|
||||||
|
7. 稳定后把节点加入正式套餐节点组。
|
||||||
|
8. 保留旧协议节点作为回退路径,不把全部用户一次性切到 `simnet`。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `etc/ppanel.yaml`,如需要全局开关
|
||||||
|
- `internal/config/config.go`
|
||||||
|
- `internal/svc/serviceContext.go`
|
||||||
|
- 运维部署文档
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 到阶段 9 完成。
|
||||||
|
- 测试节点服务器、域名、证书、OmnXT 可用。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 测试用户能稳定连接 `simnet`。
|
||||||
|
2. SlagClient 重启后无需人工操作即可恢复。
|
||||||
|
3. OmnXT 重启后能自动拉配置和用户授权。
|
||||||
|
4. 管理端能看到在线和流量。
|
||||||
|
5. 关闭 `simnet` 后用户可回退到旧协议节点。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
1. 管理端将 `simnet` 协议 `enable=false`。
|
||||||
|
2. 从套餐节点组移除 `simnet` 节点。
|
||||||
|
3. OmnXT 停止 `simnet` inbound。
|
||||||
|
4. 回滚后端到上一版本。
|
||||||
|
5. 保留凭据表和字段,后续排查后可再次启用。
|
||||||
|
|
||||||
|
## 16. 文件改动范围
|
||||||
|
|
||||||
|
预计完整生产可用版本会影响 27-45 个业务/配置文件、12-20 个测试文件,新增约 3,000-6,000 行代码和测试。实际数量取决于 goctl 生成文件体积、现有 model 组织方式和 OmnXT/SlagClient 契约是否稳定。
|
||||||
|
|
||||||
|
### 必改范围
|
||||||
|
|
||||||
|
- `apis/types.api`
|
||||||
|
- `apis/admin/server.api`
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `apis/public/subscribe.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/model/node/*`
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||||
|
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||||
|
- `internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
|
||||||
|
### 可能新增范围
|
||||||
|
|
||||||
|
- `internal/model/simnet/*`
|
||||||
|
- `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
- `internal/logic/server/simnet_config.go`
|
||||||
|
- `internal/logic/server/simnet_user.go`
|
||||||
|
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
- `initialize/migrate/*simnet*`
|
||||||
|
- `tests/simnet/*`
|
||||||
|
- `docs/simnet-contract.md`
|
||||||
|
|
||||||
|
### 前端联动范围
|
||||||
|
|
||||||
|
如果管理端前端也要同步配置,需要在前端仓库补齐:
|
||||||
|
|
||||||
|
- Server 创建/编辑表单的 `simnet` 协议字段
|
||||||
|
- 协议默认值填充
|
||||||
|
- 字段校验提示
|
||||||
|
- Node 创建/更新时允许 `protocol=simnet`
|
||||||
|
- 隐藏 Server PSK 的展示或复制入口
|
||||||
|
|
||||||
|
## 17. 提交拆分
|
||||||
|
|
||||||
|
建议按以下提交拆分,方便 review 和回滚:
|
||||||
|
|
||||||
|
1. `simnet: add protocol model fields and validation`
|
||||||
|
2. `simnet: support admin server create/update/list`
|
||||||
|
3. `simnet: expose server runtime config for OmnXT`
|
||||||
|
4. `simnet: add per-user credentials`
|
||||||
|
5. `simnet: sync OmnXT user authorization`
|
||||||
|
6. `simnet: expose public subscribe fields for SlagClient`
|
||||||
|
7. `simnet: map traffic and online reports`
|
||||||
|
8. `simnet: add tls af fallback handling`
|
||||||
|
9. `simnet: add tests and rollout switch`
|
||||||
|
|
||||||
|
每个提交都应该能单独说明行为变化,并尽量避免把 goctl 生成文件和手写逻辑混在一个巨大提交里。如果生成文件不可避免较大,提交说明中要明确哪些是生成结果。
|
||||||
|
|
||||||
|
## 18. 验收标准
|
||||||
|
|
||||||
|
最终验收必须覆盖下面场景:
|
||||||
|
|
||||||
|
1. 管理端能创建 Server,协议为 `simnet`,端口 `443`,TLS/SNI、AF、Fallback 字段保存完整。
|
||||||
|
2. 管理端能创建或更新 Node,`protocol=simnet`,`address` 指向实际节点服务器。
|
||||||
|
3. OmnXT 使用正确 `secret_key` 能拉取 `simnet` 服务端运行配置。
|
||||||
|
4. OmnXT 使用错误 `secret_key` 被拒绝。
|
||||||
|
5. OmnXT 重启后自动恢复 `simnet` inbound。
|
||||||
|
6. 有效用户能通过 OmnXT 用户列表获得授权。
|
||||||
|
7. 不同用户的 `simnet_key_id` 或 `simnet_psk` 不相同。
|
||||||
|
8. 用户禁用、到期或流量超限后,OmnXT 用户列表移除该用户。
|
||||||
|
9. SlagClient 冷启动能通过订阅拿到 `simnet` 节点并连接。
|
||||||
|
10. SlagClient 重启后不丢失协议配置。
|
||||||
|
11. 不支持 `simnet` 的客户端订阅不会收到 `simnet` 节点。
|
||||||
|
12. 普通用户订阅响应不泄露 Server PSK、DNS provider env、节点 `secret_key`。
|
||||||
|
13. `simnet` 连接产生流量后,用户流量、节点流量、后台日志同步更新。
|
||||||
|
14. 关闭 `simnet` 后,旧协议订阅、节点运行和流量统计不受影响。
|
||||||
|
15. `go test ./...` 或项目当前有效测试集通过。
|
||||||
|
|
||||||
|
## 19. 风险清单
|
||||||
|
|
||||||
|
| 风险 | 影响 | 控制方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Server PSK 被下发给普通用户 | 所有用户共享密钥,泄露后整节点风险扩大 | DTO 分层,订阅敏感字段测试阻断 |
|
||||||
|
| OmnXT 和后端字段名不一致 | 节点启动失败或配置不生效 | 阶段 0 固化契约,用真实 OmnXT 请求回放测试 |
|
||||||
|
| SlagClient 只读顶层字段或只读 protocols JSON | 客户端拿到节点但无法连接 | 双格式兼容,顶层字段和 protocols 都保持可读 |
|
||||||
|
| 单用户凭据缺失 | 无法隔离用户,封禁和流量归属困难 | 阶段 4 必须先做凭据表,不走全员共享 PSK |
|
||||||
|
| capability 判断不准确 | 老客户端看到不可用节点 | capability header 优先,UA 只兜底,默认隐藏不支持客户端 |
|
||||||
|
| 缓存 key 未包含 port | 多协议或同协议多端口串配置 | cache key 包含 `server_id + protocol + port` |
|
||||||
|
| 流量上报标识不明确 | 用户流量无法入账或串账 | 与 OmnXT 明确上报 `key_id`,后端反查凭据表 |
|
||||||
|
| TLS/证书/fallback 运维失败 | 节点 443 无法正常服务 | 灰度节点先跑,保留旧协议回退 |
|
||||||
|
| goctl 生成覆盖手写改动 | 代码冲突或字段丢失 | 所有类型先改 api 文件,再生成;手写扩展放独立文件 |
|
||||||
|
|
||||||
|
## 20. 工期估算
|
||||||
|
|
||||||
|
在 OmnXT 和 SlagClient 契约清楚、测试环境可用的情况下:
|
||||||
|
|
||||||
|
- 阶段 0:0.5-1 天
|
||||||
|
- 阶段 1-2:1.5-2 天
|
||||||
|
- 阶段 3:1-1.5 天
|
||||||
|
- 阶段 4:1.5-2 天
|
||||||
|
- 阶段 5:1-1.5 天
|
||||||
|
- 阶段 6:1-1.5 天
|
||||||
|
- 阶段 7:1-2 天
|
||||||
|
- 阶段 8:1 天
|
||||||
|
- 阶段 9:2-3 天
|
||||||
|
- 阶段 10:1 天
|
||||||
|
|
||||||
|
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
|
||||||
|
|
||||||
|
## 21. 推荐执行顺序
|
||||||
|
|
||||||
|
第一周先完成最小闭环:
|
||||||
|
|
||||||
|
1. 阶段 0:确认契约。
|
||||||
|
2. 阶段 1:协议模型与校验。
|
||||||
|
3. 阶段 2:管理端保存和查询。
|
||||||
|
4. 阶段 3:OmnXT 配置下发。
|
||||||
|
|
||||||
|
第二周完成用户链路:
|
||||||
|
|
||||||
|
1. 阶段 4:用户级凭据。
|
||||||
|
2. 阶段 5:OmnXT 用户授权。
|
||||||
|
3. 阶段 6:SlagClient 订阅。
|
||||||
|
4. 阶段 7:流量和在线用户映射。
|
||||||
|
|
||||||
|
最后做生产化:
|
||||||
|
|
||||||
|
1. 阶段 8:TLS、AF、Fallback 运维验证。
|
||||||
|
2. 阶段 9:自动化测试补齐。
|
||||||
|
3. 阶段 10:灰度发布和回滚演练。
|
||||||
|
|
||||||
|
## 22. 当前结论
|
||||||
|
|
||||||
|
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
|
||||||
|
|
||||||
|
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
|
||||||
@@ -82,6 +82,7 @@ require (
|
|||||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||||
|
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
|
||||||
github.com/aliyun/credentials-go v1.3.10 // indirect
|
github.com/aliyun/credentials-go v1.3.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||||
@@ -145,6 +146,7 @@ require (
|
|||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
|
|||||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
||||||
@@ -395,6 +397,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 02156 抽奖活动 Stage 1 回滚
|
||||||
|
-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前
|
||||||
|
-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `lottery_eligibility_snapshot`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_prize_snapshot`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_draw`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_chance_grant`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_chance_balance`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_prize`;
|
||||||
|
DROP TABLE IF EXISTS `lottery_activity`;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- 02156 抽奖活动 Stage 1(后端核心闭环)
|
||||||
|
--
|
||||||
|
-- 新建 7 张表 + 全部索引 + 幂等约束。
|
||||||
|
-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检
|
||||||
|
-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。
|
||||||
|
--
|
||||||
|
-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删):
|
||||||
|
-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品
|
||||||
|
-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw
|
||||||
|
-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行
|
||||||
|
-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会)
|
||||||
|
-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1
|
||||||
|
-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_activity` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题',
|
||||||
|
`description` TEXT COMMENT '活动描述(Markdown)',
|
||||||
|
`start_at` DATETIME NOT NULL COMMENT '开始时间',
|
||||||
|
`end_at` DATETIME NOT NULL COMMENT '结束时间',
|
||||||
|
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended',
|
||||||
|
`grid_size` TINYINT NOT NULL DEFAULT 8 COMMENT '前端九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格;老 schema 是 9)',
|
||||||
|
`eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
|
||||||
|
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant)',
|
||||||
|
`unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_status_time` (`status`, `start_at`, `end_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_prize` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID',
|
||||||
|
`slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based)',
|
||||||
|
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none',
|
||||||
|
`name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称',
|
||||||
|
`icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL',
|
||||||
|
`config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3})',
|
||||||
|
`weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)',
|
||||||
|
`total_stock` BIGINT COMMENT '总库存(NULL 表示无限)',
|
||||||
|
`remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL)',
|
||||||
|
`is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)',
|
||||||
|
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`),
|
||||||
|
KEY `idx_activity_fallback` (`activity_id`, `is_fallback`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_chance_balance` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1)',
|
||||||
|
`total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)',
|
||||||
|
`total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_chance_grant` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant',
|
||||||
|
`source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}")',
|
||||||
|
`amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数',
|
||||||
|
`expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)',
|
||||||
|
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`),
|
||||||
|
KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_draw` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID)',
|
||||||
|
`prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL)',
|
||||||
|
`is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)',
|
||||||
|
`dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed',
|
||||||
|
`dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)',
|
||||||
|
`dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)',
|
||||||
|
`drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`),
|
||||||
|
KEY `idx_user_time` (`user_id`, `drawn_at`),
|
||||||
|
KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`),
|
||||||
|
KEY `idx_activity_prize` (`activity_id`, `prize_id`),
|
||||||
|
KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||||
|
`prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id)',
|
||||||
|
`slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)',
|
||||||
|
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)',
|
||||||
|
`name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)',
|
||||||
|
`config` JSON NOT NULL COMMENT '类型专属配置(快照)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_draw_id` (`draw_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)',
|
||||||
|
`unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required)',
|
||||||
|
`evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||||
|
KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 02157 抽奖发奖账本回滚
|
||||||
|
DROP TABLE IF EXISTS `lottery_grant_ledger`;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 02157 抽奖发奖账本(PR B)
|
||||||
|
--
|
||||||
|
-- 目的:以 external_ref 作为 DB 层唯一键,做每个 draw 的发奖幂等。
|
||||||
|
-- 各 PrizeHandler.Dispatch 内先 SELECT/INSERT lottery_grant_ledger,命中即幂等返回,
|
||||||
|
-- 未命中再调下游发放(UpdateSubscribe / UpdateCommission + WriteCommissionLog),
|
||||||
|
-- 全部在同一 tx 内完成 → 抽奖事务与发奖账本同生共死。
|
||||||
|
--
|
||||||
|
-- 关键唯一索引:external_ref。惯例值 = "lottery:{activity_id}:{draw_id}"。
|
||||||
|
-- handler_type:与 lottery_prize.type 一致(vpn_duration / commission / …),用于统计。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_grant_ledger` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`external_ref` VARCHAR(128) NOT NULL COMMENT '幂等键:lottery:{activity_id}:{draw_id}',
|
||||||
|
`handler_type` VARCHAR(32) NOT NULL COMMENT 'handler 类型,与 lottery_prize.type 对齐',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID(家庭组已归位到 owner)',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||||
|
`amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量(天/佣金金额,单位与 handler 一致)',
|
||||||
|
`payload` JSON COMMENT '发放后的关键结果快照(订阅 ID、佣金前后余额等)',
|
||||||
|
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发放完成时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_external_ref` (`external_ref`),
|
||||||
|
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||||
|
KEY `idx_draw_id` (`draw_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖发奖账本(幂等键 = external_ref)';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 02158 admin_action_log 回滚
|
||||||
|
DROP TABLE IF EXISTS `admin_action_log`;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
|
||||||
|
--
|
||||||
|
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
|
||||||
|
-- 与合规审查。actor_user_id 是操作者的 user.id;action 是操作动作
|
||||||
|
-- (lottery.activity.create / lottery.prize.update / lottery.rules.put / ...);
|
||||||
|
-- target_ids 是被操作对象的主键数组(JSON);request_hash 是请求 body 的 sha1
|
||||||
|
-- 摘要(对同一批次多次写入去重);ip/user_agent 从上下文取。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `admin_action_log` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`actor_user_id` BIGINT UNSIGNED NOT NULL COMMENT '操作者 user.id',
|
||||||
|
`action` VARCHAR(64) NOT NULL COMMENT '动作 code(点分层级)',
|
||||||
|
`target_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '被操作对象 ID 逗号分隔或 JSON 数组',
|
||||||
|
`request_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 body sha1 摘要',
|
||||||
|
`ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '操作者 IP',
|
||||||
|
`user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作者 UA',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_actor_time` (`actor_user_id`, `created_at`),
|
||||||
|
KEY `idx_action_time` (`action`, `created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台写操作审计流水';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 02159 抽奖活动 Stage 2 down migration
|
||||||
|
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
|
||||||
|
DROP TABLE IF EXISTS `lottery_claim`;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 02159 抽奖活动 Stage 2(人工奖领奖工单)
|
||||||
|
--
|
||||||
|
-- 新建 `lottery_claim` 表:承载 crypto / physical / manual_other 三类人工奖
|
||||||
|
-- 从"抽中"到"运营打款/发货"的完整工单状态机。
|
||||||
|
--
|
||||||
|
-- 幂等设计:`CREATE TABLE IF NOT EXISTS`;一个 draw_id 只能有一条 claim 行
|
||||||
|
-- (UNIQUE 约束保证 POST /draw 事务不会重复挂单,避免用户端重放时重复入队)。
|
||||||
|
--
|
||||||
|
-- 关键索引:
|
||||||
|
-- 1) UNIQUE (draw_id) — 抽奖记录 ↔ 领奖工单 一对一
|
||||||
|
-- 2) (activity_id, status) — 后台工单列表按活动 + 状态过滤
|
||||||
|
-- 3) (user_id, activity_id) — GET /records 按用户拉工单
|
||||||
|
-- 4) (status, expires_at) — 过期定时任务扫描
|
||||||
|
--
|
||||||
|
-- 状态机(详细见 doc/lottery-stage2 或 issue HIF-4):
|
||||||
|
-- pending_claim ─── 用户提交 ──→ reviewing
|
||||||
|
-- └── 超时 ──→ expired
|
||||||
|
-- reviewing ─── 运营 approve ──→ paying
|
||||||
|
-- └── 运营 reject ──→ rejected(用户可再次提交)
|
||||||
|
-- paying ─── 运营 mark-paid ──→ paid(终态)
|
||||||
|
-- └── 运营 reject ──→ rejected
|
||||||
|
-- rejected ─── 用户再提交 ──→ reviewing
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `lottery_claim` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||||
|
`prize_type` VARCHAR(32) NOT NULL COMMENT '奖品类型(crypto/physical/manual_other,冗余便于后台按类型过滤)',
|
||||||
|
`claim_data` JSON COMMENT '用户提交的领奖表单数据(结构随 prize_type 变化)',
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'pending_claim' COMMENT '状态:pending_claim / reviewing / paying / paid / rejected / expired',
|
||||||
|
`submitted_at` DATETIME DEFAULT NULL COMMENT '用户提交领奖信息时间(首次提交后写;重新提交会覆盖)',
|
||||||
|
`expires_at` DATETIME NOT NULL COMMENT '领奖窗口截止时间(默认 now+7d,可被奖品 config.claim_ttl_hours 覆盖)',
|
||||||
|
`reviewed_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '最近一次审核操作者 user.id',
|
||||||
|
`reviewed_at` DATETIME DEFAULT NULL COMMENT '最近一次审核时间',
|
||||||
|
`reject_reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '拒绝原因',
|
||||||
|
`tx_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链上交易哈希(crypto 打款)',
|
||||||
|
`delivery_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '快递单号 / 发货单据编号(physical 发货)',
|
||||||
|
`paid_at` DATETIME DEFAULT NULL COMMENT '运营标记打款/发货完成时间',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||||
|
KEY `idx_activity_status` (`activity_id`, `status`),
|
||||||
|
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||||
|
KEY `idx_status_expires` (`status`, `expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖人工奖领奖工单';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9
|
||||||
|
--
|
||||||
|
-- 与 up.sql 对称,只回退默认值,不动数据。
|
||||||
|
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 02160 抽奖 Stage 2 F8:lottery_activity.grid_size 默认值从 9 改成 8
|
||||||
|
--
|
||||||
|
-- 前端与产品对齐后确认布局 A:3×3 挖中心 → 中心是"点击抽奖"按钮(不是奖品格),
|
||||||
|
-- 其余 8 格挂奖品。因此 grid_size 的默认值应为 8,不再是 9。
|
||||||
|
--
|
||||||
|
-- 兼容性:
|
||||||
|
-- * up.sql 的 CREATE TABLE 已在 02156 里跑过,MySQL 的 CREATE TABLE IF NOT EXISTS
|
||||||
|
-- 不会改动既存表结构。所以老部署的 lottery_activity.grid_size 默认值仍是 9,
|
||||||
|
-- 必须用一条独立的 ALTER 迁移把默认值改过来。
|
||||||
|
-- * 已有数据(grid_size=9 的老活动)不动 —— ALTER DEFAULT 只影响新插入行且未提供
|
||||||
|
-- grid_size 的场景;Go 侧 admin/lottery.go 的兜底也已配套改成 8。
|
||||||
|
--
|
||||||
|
-- 幂等:ALTER COLUMN ... SET DEFAULT 在 MySQL 8.0+ 是幂等的(重复执行等值 SET
|
||||||
|
-- 不会报错),重跑安全。
|
||||||
|
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 8;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- OmnXT SimNet subscription application.
|
||||||
|
-- Delivers simnet nodes as base64 "simnet://" protocol links built by the
|
||||||
|
-- adapter template function buildOmnxtProtocolLinks (per-user psk/key_id derived
|
||||||
|
-- from the subscription; server PSK carried for AF derivation). Matched by
|
||||||
|
-- User-Agent containing "omnxt". Mirrors the Pro reference final format
|
||||||
|
-- (migrations 02138 + 02140).
|
||||||
|
INSERT IGNORE INTO `subscribe_application`
|
||||||
|
(`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`)
|
||||||
|
VALUES
|
||||||
|
(1001, 'OmnXT SimNet', '', 'OmnXT SimNet base64 subscription', '', 'OmnXT', 0, '{{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}\n{{- end }}', 'base64', '{}', NOW(3), NOW(3));
|
||||||
@@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:"
|
|||||||
|
|
||||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||||
|
|
||||||
|
// UserEnableKeyPrefix user enable state cache key prefix
|
||||||
|
const UserEnableKeyPrefix = "user:enable:"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Config struct {
|
|||||||
Currency Currency `yaml:"Currency"`
|
Currency Currency `yaml:"Currency"`
|
||||||
Trace trace.Config `yaml:"Trace"`
|
Trace trace.Config `yaml:"Trace"`
|
||||||
S3 S3Config `yaml:"S3"`
|
S3 S3Config `yaml:"S3"`
|
||||||
|
Lottery LotteryConfig `yaml:"Lottery"`
|
||||||
Administrator struct {
|
Administrator struct {
|
||||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||||
Password string `yaml:"Password" default:"password"`
|
Password string `yaml:"Password" default:"password"`
|
||||||
@@ -250,6 +251,13 @@ type InviteConfig struct {
|
|||||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LotteryConfig 是抽奖 Stage 1 的 feature flag。默认关闭,交 QA 前手动打开。
|
||||||
|
// 关闭时用户端 POST /draw 返回 4003 activity_ended(前端展示"活动已结束",
|
||||||
|
// 与"配置关闭"避免暴露内部状态);后台 CRUD 仍然可用,方便配置好活动再开。
|
||||||
|
type LotteryConfig struct {
|
||||||
|
Enable bool `yaml:"Enable" default:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
// KuttConfig Kutt 短链接服务配置
|
// KuttConfig Kutt 短链接服务配置
|
||||||
type KuttConfig struct {
|
type KuttConfig struct {
|
||||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// admin_claims_handler.go 提供 Stage 2 后台工单接口的 gin handler 层。
|
||||||
|
// 路径注册在 internal/handler/lottery_routes.go 里;handler 只负责参数绑定 +
|
||||||
|
// 委派到 internal/logic/admin/lottery/admin_claims.go。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListLotteryClaimsHandler GET /v1/admin/lottery/claims
|
||||||
|
func ListLotteryClaimsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ListAdminLotteryClaimsRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
l := adminlottery.NewListLotteryClaimsLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.ListLotteryClaims(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveLotteryClaimHandler POST /v1/admin/lottery/claims/approve
|
||||||
|
func ApproveLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminApproveClaimRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewApproveLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.ApproveLotteryClaim(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectLotteryClaimHandler POST /v1/admin/lottery/claims/reject
|
||||||
|
func RejectLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminRejectClaimRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewRejectLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.RejectLotteryClaim(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPaidLotteryClaimHandler POST /v1/admin/lottery/claims/mark-paid
|
||||||
|
func MarkPaidLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminMarkPaidClaimRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewMarkPaidLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.MarkPaidLotteryClaim(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryClaimsSummaryHandler GET /v1/admin/lottery/claims/summary
|
||||||
|
func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
l := adminlottery.NewLotteryClaimsSummaryLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.LotteryClaimsSummary()
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLotteryDrawsHandler GET /v1/admin/lottery/draws
|
||||||
|
func ListLotteryDrawsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ListAdminLotteryDrawsRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
l := adminlottery.NewListLotteryDrawsLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.ListLotteryDraws(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Package lottery contains gin handlers for the admin-side lottery endpoints.
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.CreateAdminLotteryActivityRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewCreateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.CreateLotteryActivity(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.UpdateAdminLotteryActivityRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewUpdateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.UpdateLotteryActivity(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListLotteryActivitiesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ListAdminLotteryActivitiesRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
l := adminlottery.NewListLotteryActivitiesLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.ListLotteryActivities(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminActivityIdRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewGetLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GetLotteryActivity(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PublishLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminActivityIdRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewPublishLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.PublishLotteryActivity(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PauseLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminActivityIdRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewPauseLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.PauseLotteryActivity(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLotteryRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.UpdateAdminLotteryRulesRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewUpdateLotteryRulesLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.UpdateLotteryRules(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.CreateAdminLotteryPrizeRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewCreateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.CreateLotteryPrize(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.UpdateAdminLotteryPrizeRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||||
|
req.Id = id
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewUpdateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.UpdateLotteryPrize(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminPrizeIdRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||||
|
req.Id = id
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewDeleteLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.DeleteLotteryPrize(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListLotteryPrizesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ListAdminLotteryPrizesRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewListLotteryPrizesLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.ListLotteryPrizes(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GrantAdminLotteryChanceRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewGrantLotteryChanceLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.GrantLotteryChance(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLotteryActivityHandler DELETE /v1/admin/lottery/activities/:id
|
||||||
|
func DeleteLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.AdminActivityIdRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||||
|
req.Id = id
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := adminlottery.NewDeleteLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||||
|
result.HttpResult(c, nil, l.DeleteLotteryActivity(&req))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
adminLottery "github.com/perfect-panel/server/internal/handler/admin/lottery"
|
||||||
|
publicLottery "github.com/perfect-panel/server/internal/handler/public/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/middleware"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerLotteryRoutes wires the Stage 1 lottery endpoints. Kept in its own
|
||||||
|
// file to avoid ballooning routes.go and to make the lottery surface easy to
|
||||||
|
// audit end-to-end. The path prefix "/v1/lottery" is under the user middleware
|
||||||
|
// stack (AuthMiddleware + DeviceMiddleware); "/v1/admin/lottery" uses the
|
||||||
|
// admin-detecting AuthMiddleware (path contains "admin" segment).
|
||||||
|
func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||||
|
userGroup := router.Group("/v1/lottery")
|
||||||
|
userGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
{
|
||||||
|
userGroup.GET("/config", publicLottery.QueryLotteryConfigHandler(serverCtx))
|
||||||
|
userGroup.POST("/draw", publicLottery.DrawLotteryHandler(serverCtx))
|
||||||
|
userGroup.GET("/records", publicLottery.QueryLotteryRecordsHandler(serverCtx))
|
||||||
|
userGroup.POST("/claim", publicLottery.ClaimLotteryPrizeHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
|
adminGroup := router.Group("/v1/admin/lottery")
|
||||||
|
adminGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.AdminMetaMiddleware())
|
||||||
|
{
|
||||||
|
adminGroup.POST("/activities", adminLottery.CreateLotteryActivityHandler(serverCtx))
|
||||||
|
adminGroup.PUT("/activities", adminLottery.UpdateLotteryActivityHandler(serverCtx))
|
||||||
|
adminGroup.GET("/activities", adminLottery.ListLotteryActivitiesHandler(serverCtx))
|
||||||
|
adminGroup.GET("/activities/detail", adminLottery.GetLotteryActivityHandler(serverCtx))
|
||||||
|
adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx))
|
||||||
|
adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx))
|
||||||
|
adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx))
|
||||||
|
adminGroup.DELETE("/activities/:id", adminLottery.DeleteLotteryActivityHandler(serverCtx))
|
||||||
|
|
||||||
|
adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
|
||||||
|
adminGroup.PUT("/prizes/:id", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||||
|
adminGroup.DELETE("/prizes/:id", adminLottery.DeleteLotteryPrizeHandler(serverCtx))
|
||||||
|
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
||||||
|
|
||||||
|
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
||||||
|
|
||||||
|
// Stage 2 (HIF-4): 人工奖工单接口
|
||||||
|
adminGroup.GET("/claims", adminLottery.ListLotteryClaimsHandler(serverCtx))
|
||||||
|
adminGroup.GET("/claims/summary", adminLottery.LotteryClaimsSummaryHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
|
||||||
|
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx))
|
||||||
|
|
||||||
|
// Stage 3: 抽奖记录(发放流水)
|
||||||
|
adminGroup.GET("/draws", adminLottery.ListLotteryDrawsHandler(serverCtx))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package lottery contains the user-facing lottery HTTP handlers. Each handler
|
||||||
|
// binds request params via gin, validates, delegates to the logic package,
|
||||||
|
// and renders through pkg/result to keep the API response envelope consistent.
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryLotteryConfigHandler serves GET /api/v1/lottery/config.
|
||||||
|
func QueryLotteryConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetLotteryConfigRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := lottery.NewQueryLotteryConfigLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.QueryLotteryConfig(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawLotteryHandler serves POST /api/v1/lottery/draw.
|
||||||
|
func DrawLotteryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.DrawLotteryRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := lottery.NewDrawLotteryLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.DrawLottery(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryLotteryRecordsHandler serves GET /api/v1/lottery/records.
|
||||||
|
func QueryLotteryRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetLotteryRecordsRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
l := lottery.NewQueryLotteryRecordsLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.QueryLotteryRecords(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimLotteryPrizeHandler serves POST /api/v1/lottery/claim. Stage 1
|
||||||
|
// always returns 4010 not_claimable.
|
||||||
|
func ClaimLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ClaimLotteryPrizeRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
if err := svcCtx.Validate(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := lottery.NewClaimLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.ClaimLotteryPrize(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|
||||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
|
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
|
||||||
resp, err := l.QueryUserSubscribeNodeList()
|
resp, err := l.QueryUserSubscribeNodeList()
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1221,4 +1221,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Get Server Protocol Config
|
// Get Server Protocol Config
|
||||||
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Lottery (Stage 1) --------------------------------------------------
|
||||||
|
registerLotteryRoutes(router, serverCtx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
||||||
resp, err := l.Handler(&req)
|
resp, err := l.Handler(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Internal Server")
|
result.HttpResult(c, nil, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("subscription-userinfo", resp.Header)
|
c.Header("subscription-userinfo", resp.Header)
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
"github.com/perfect-panel/server/internal/model/client"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscribeHandlerReturnsBusinessErrorForDisabledUser(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
redisServer, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("miniredis.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||||
|
defer func() {
|
||||||
|
_ = rdb.Close()
|
||||||
|
}()
|
||||||
|
if err := rdb.Set(context.Background(), logiccommon.UserEnableCacheKey(83696), "false", 0).Err(); err != nil {
|
||||||
|
t.Fatalf("seed user enable cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/subscribe", SubscribeHandler(&svc.ServiceContext{
|
||||||
|
Config: config.Config{
|
||||||
|
Subscribe: config.SubscribeConfig{
|
||||||
|
SubscribePath: "/api/subscribe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClientModel: subscribeClientModelStub{
|
||||||
|
list: []*client.SubscribeApplication{
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
UserAgent: "clashmeta",
|
||||||
|
IsDefault: true,
|
||||||
|
OutputFormat: "yaml",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Redis: rdb,
|
||||||
|
UserModel: subscribeUserModelStub{
|
||||||
|
subscribe: &user.Subscribe{Id: 35446, UserId: 83696, SubscribeId: 1, Token: "disabled-token"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/subscribe?token=disabled-token", nil)
|
||||||
|
req.Header.Set("User-Agent", "ClashMetaForAndroid/2.11.7.Meta")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected HTTP 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code uint32 `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Code != xerr.UserDisabled {
|
||||||
|
t.Fatalf("expected code %d, got %d (%s)", xerr.UserDisabled, resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscribeClientModelStub struct {
|
||||||
|
list []*client.SubscribeApplication
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) Insert(context.Context, *client.SubscribeApplication) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) FindOne(context.Context, int64) (*client.SubscribeApplication, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) Update(context.Context, *client.SubscribeApplication) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) Delete(context.Context, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) List(context.Context) ([]*client.SubscribeApplication, error) {
|
||||||
|
return s.list, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeClientModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscribeUserModelStub struct {
|
||||||
|
subscribe *user.Subscribe
|
||||||
|
subErr error
|
||||||
|
findOne *user.User
|
||||||
|
findOneErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) Insert(context.Context, *user.User, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOne(context.Context, int64) (*user.User, error) {
|
||||||
|
if s.findOneErr != nil {
|
||||||
|
return nil, s.findOneErr
|
||||||
|
}
|
||||||
|
return s.findOne, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) Update(context.Context, *user.User, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateCommission(context.Context, int64, int64, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) Delete(context.Context, int64, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryPageList(context.Context, int, int, *user.UserFilterParams) ([]*user.User, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneByReferCode(context.Context, string) (*user.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) BatchDeleteUser(context.Context, []int64, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) InsertSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneSubscribeByToken(context.Context, string) (*user.Subscribe, error) {
|
||||||
|
if s.subErr != nil {
|
||||||
|
return nil, s.subErr
|
||||||
|
}
|
||||||
|
return s.subscribe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindSingleModeAnchorSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneSubscribeByOrderId(context.Context, int64) (*user.Subscribe, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) DeleteSubscribe(context.Context, string, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) DeleteSubscribeById(context.Context, int64, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryUserSubscribe(context.Context, int64, ...int64) ([]*user.SubscribeDetails, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneSubscribeDetailsById(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneUserSubscribe(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindUsersSubscribeBySubscribeId(context.Context, int64) ([]*user.Subscribe, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateUserSubscribeWithTraffic(context.Context, int64, int64, int64, bool, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryResisterUserTotalByDate(context.Context, time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryResisterUserTotalByMonthly(context.Context, time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryResisterUserTotal(context.Context) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryAdminUsers(context.Context) ([]*user.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateUserCache(context.Context, *user.User) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateUserSubscribeCache(context.Context, *user.Subscribe) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryActiveSubscriptions(context.Context, ...int64) (map[int64]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindUserAuthMethods(context.Context, int64) ([]*user.AuthMethods, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) InsertUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) DeleteUserAuthMethods(context.Context, int64, string, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindUserAuthMethodByOpenID(context.Context, string, string) (*user.AuthMethods, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindUserAuthMethodByUserId(context.Context, string, int64) (*user.AuthMethods, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindUserAuthMethodByPlatform(context.Context, int64, string) (*user.AuthMethods, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneByEmail(context.Context, string) (*user.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneDevice(context.Context, int64) (*user.Device, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryDeviceList(context.Context, int64) ([]*user.Device, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryDeviceListByUserIds(context.Context, []int64) ([]*user.Device, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryDevicePageList(context.Context, int64, int64, int, int) ([]*user.Device, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) UpdateDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindOneDeviceByIdentifier(context.Context, string) (*user.Device, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) DeleteDevice(context.Context, int64, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) InsertDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) ClearSubscribeCache(context.Context, ...*user.Subscribe) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) ClearUserCache(context.Context, ...*user.User) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) ClearDeviceCache(context.Context, ...*user.Device) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryDailyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) QueryMonthlyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindActiveSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s subscribeUserModelStub) FindActiveSubscribesByUserIds(context.Context, []int64) (map[int64]*user.UserStatusInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||||||
|
//
|
||||||
|
// GET /v1/admin/lottery/claims — 分页列表
|
||||||
|
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||||||
|
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||||||
|
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||||||
|
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||||||
|
//
|
||||||
|
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||||||
|
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/audit"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||||||
|
const (
|
||||||
|
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||||||
|
ActionLotteryClaimReject = "lottery.claim.reject"
|
||||||
|
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- ListLotteryClaims ----------------------------------------------------
|
||||||
|
|
||||||
|
type ListLotteryClaimsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||||||
|
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||||||
|
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||||||
|
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{})
|
||||||
|
if t := strings.TrimSpace(req.Type); t != "" {
|
||||||
|
db = db.Where("prize_type = ?", t)
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(req.Status); s != "" {
|
||||||
|
db = db.Where("status = ?", s)
|
||||||
|
}
|
||||||
|
if req.ActivityId > 0 {
|
||||||
|
db = db.Where("activity_id = ?", req.ActivityId)
|
||||||
|
}
|
||||||
|
if req.UserId > 0 {
|
||||||
|
db = db.Where("user_id = ?", req.UserId)
|
||||||
|
}
|
||||||
|
if req.From > 0 {
|
||||||
|
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||||||
|
}
|
||||||
|
if req.To > 0 {
|
||||||
|
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Claim
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||||||
|
drawIds := make([]int64, 0, len(rows))
|
||||||
|
userIds := make([]int64, 0, len(rows))
|
||||||
|
for _, c := range rows {
|
||||||
|
drawIds = append(drawIds, c.DrawId)
|
||||||
|
userIds = append(userIds, c.UserId)
|
||||||
|
}
|
||||||
|
snaps, err := l.loadSnapshots(drawIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := l.loadUsers(userIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||||||
|
for _, c := range rows {
|
||||||
|
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
|
if len(drawIds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var snaps []modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||||
|
for _, s := range snaps {
|
||||||
|
out[s.DrawId] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||||||
|
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||||||
|
type row struct {
|
||||||
|
UserId int64
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user_auth_methods").
|
||||||
|
Select("user_id AS user_id, auth_identifier AS email").
|
||||||
|
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, exists := out[r.UserId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.UserId] = r.Email
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ApproveLotteryClaim --------------------------------------------------
|
||||||
|
|
||||||
|
type ApproveLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||||||
|
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||||||
|
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaying,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": "",
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimApprove,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RejectLotteryClaim ---------------------------------------------------
|
||||||
|
|
||||||
|
type RejectLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||||||
|
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||||||
|
// 剩余窗口内可再次提交。
|
||||||
|
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(req.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status IN ?", req.Id,
|
||||||
|
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusRejected,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": reason,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimReject,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||||||
|
|
||||||
|
type MarkPaidLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||||||
|
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPaidLotteryClaim paying → paid。
|
||||||
|
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||||||
|
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||||||
|
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
txHash := strings.TrimSpace(req.TxHash)
|
||||||
|
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||||||
|
now := time.Now()
|
||||||
|
paidAt := now
|
||||||
|
if req.PaidAt > 0 {
|
||||||
|
paidAt = time.Unix(req.PaidAt, 0)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先取当前 claim 用于类型强校验
|
||||||
|
var claim modelLottery.Claim
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAS 推进 status。
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaid,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"tx_hash": txHash,
|
||||||
|
"delivery_ref": deliveryRef,
|
||||||
|
"paid_at": paidAt,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||||||
|
if err := tx.Model(&modelLottery.Draw{}).
|
||||||
|
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"dispatch_state": modelLottery.DispatchStatePaid,
|
||||||
|
"dispatched_at": paidAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimMarkPaid,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||||||
|
// - crypto: tx_hash 必填
|
||||||
|
// - physical: delivery_ref 必填
|
||||||
|
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||||||
|
//
|
||||||
|
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||||||
|
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
if txHash == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
if deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
if txHash == "" && deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ClaimsSummary --------------------------------------------------------
|
||||||
|
|
||||||
|
type LotteryClaimsSummaryLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||||||
|
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||||||
|
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
PrizeType string
|
||||||
|
Status string
|
||||||
|
Cnt int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Select("prize_type, status, COUNT(*) AS cnt").
|
||||||
|
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Group("prize_type, status").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
summary := &types.AdminLotteryClaimsSummary{}
|
||||||
|
for _, r := range rows {
|
||||||
|
bucket := bucketByType(summary, r.PrizeType)
|
||||||
|
if bucket == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Status {
|
||||||
|
case modelLottery.ClaimStatusReviewing:
|
||||||
|
bucket.Reviewing = r.Cnt
|
||||||
|
case modelLottery.ClaimStatusPaying:
|
||||||
|
bucket.Paying = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||||||
|
Count(&summary.Overdue).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
return &s.Crypto
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
return &s.Physical
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
return &s.ManualOther
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||||||
|
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||||||
|
view := types.AdminLotteryClaim{
|
||||||
|
Id: c.Id,
|
||||||
|
DrawId: c.DrawId,
|
||||||
|
ActivityId: c.ActivityId,
|
||||||
|
Status: c.Status,
|
||||||
|
ExpiresAt: c.ExpiresAt.Unix(),
|
||||||
|
ReviewedBy: c.ReviewedBy,
|
||||||
|
RejectReason: c.RejectReason,
|
||||||
|
TxHash: c.TxHash,
|
||||||
|
DeliveryRef: c.DeliveryRef,
|
||||||
|
CreatedAt: c.CreatedAt.Unix(),
|
||||||
|
User: types.AdminLotteryClaimUser{
|
||||||
|
Id: c.UserId,
|
||||||
|
Email: email,
|
||||||
|
},
|
||||||
|
Prize: types.AdminLotteryClaimPrize{
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if snap.Type == "" {
|
||||||
|
// snapshot 未命中,用 claim.prize_type 兜底
|
||||||
|
view.Prize.Type = c.PrizeType
|
||||||
|
}
|
||||||
|
if c.ClaimData != "" {
|
||||||
|
view.ClaimData = json.RawMessage(c.ClaimData)
|
||||||
|
}
|
||||||
|
if c.SubmittedAt != nil {
|
||||||
|
view.SubmittedAt = c.SubmittedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.ReviewedAt != nil {
|
||||||
|
view.ReviewedAt = c.ReviewedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.PaidAt != nil {
|
||||||
|
view.PaidAt = c.PaidAt.Unix()
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||||
|
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||||
|
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||||
|
// - bucketByType 的类型 → 状态桶映射
|
||||||
|
// - claimToAdminView 的 nullable 字段渲染
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateMarkPaidByType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prizeType string
|
||||||
|
txHash string
|
||||||
|
deliveryRef string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||||
|
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||||
|
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||||
|
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||||
|
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||||
|
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||||
|
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||||
|
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||||
|
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||||
|
{"unknown type", "auto_hallucinated", "", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketByType(t *testing.T) {
|
||||||
|
s := &types.AdminLotteryClaimsSummary{}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||||
|
t.Fatal("crypto bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||||
|
t.Fatal("physical bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||||
|
t.Fatal("manual_other bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, "unknown") != nil {
|
||||||
|
t.Fatal("unknown type must return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||||
|
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 42,
|
||||||
|
DrawId: 1234,
|
||||||
|
UserId: 88,
|
||||||
|
ActivityId: 100,
|
||||||
|
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||||
|
Status: modelLottery.ClaimStatusPaid,
|
||||||
|
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||||
|
SubmittedAt: &submittedAt,
|
||||||
|
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||||
|
TxHash: "0xabcdef",
|
||||||
|
PaidAt: &paidAt,
|
||||||
|
}
|
||||||
|
snap := modelLottery.PrizeSnapshot{
|
||||||
|
Type: modelLottery.PrizeTypeCrypto,
|
||||||
|
Name: "1 BTC",
|
||||||
|
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, snap, "user@example.com")
|
||||||
|
|
||||||
|
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||||
|
t.Fatalf("view IDs wrong: %+v", view)
|
||||||
|
}
|
||||||
|
if view.User.Email != "user@example.com" {
|
||||||
|
t.Fatalf("Email = %q", view.User.Email)
|
||||||
|
}
|
||||||
|
if view.Status != modelLottery.ClaimStatusPaid {
|
||||||
|
t.Fatalf("Status = %q", view.Status)
|
||||||
|
}
|
||||||
|
if view.SubmittedAt != submittedAt.Unix() {
|
||||||
|
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||||
|
}
|
||||||
|
if view.PaidAt != paidAt.Unix() {
|
||||||
|
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||||
|
}
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||||
|
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
if len(view.ClaimData) == 0 {
|
||||||
|
t.Fatal("ClaimData must be included when non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 1,
|
||||||
|
DrawId: 2,
|
||||||
|
PrizeType: modelLottery.PrizeTypePhysical,
|
||||||
|
Status: modelLottery.ClaimStatusPendingClaim,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||||
|
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// admin_draws.go 实现后台抽奖记录(发放流水)接口:
|
||||||
|
//
|
||||||
|
// GET /v1/admin/lottery/draws — 分页列表(谁/何时/中了什么/发放状态与结果)
|
||||||
|
//
|
||||||
|
// 自动奖(vpn_duration/commission)的实际发放结果取自 lottery_grant_ledger.payload;
|
||||||
|
// 人工奖(crypto/physical/manual_other)的领奖进展在 /claims 里看。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListLotteryDrawsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryDrawsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryDrawsLogic {
|
||||||
|
return &ListLotteryDrawsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLotteryDraws 按 activity/user/win/dispatch_state/prize_type/时间窗过滤,
|
||||||
|
// 附带奖品快照 + 用户邮箱 + 发放账本结果,避免前端 N+1。
|
||||||
|
func (l *ListLotteryDrawsLogic) ListLotteryDraws(req *types.ListAdminLotteryDrawsRequest) (*types.ListAdminLotteryDrawsResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Draw{})
|
||||||
|
if req.ActivityId > 0 {
|
||||||
|
db = db.Where("activity_id = ?", req.ActivityId)
|
||||||
|
}
|
||||||
|
if req.UserId > 0 {
|
||||||
|
db = db.Where("user_id = ?", req.UserId)
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(req.Win) {
|
||||||
|
case "1":
|
||||||
|
db = db.Where("is_win = ?", true)
|
||||||
|
case "0":
|
||||||
|
db = db.Where("is_win = ?", false)
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(req.DispatchState); s != "" {
|
||||||
|
db = db.Where("dispatch_state = ?", s)
|
||||||
|
}
|
||||||
|
// prize_type 挂在快照表上,用 EXISTS 子查询过滤(避免 JOIN 影响分页去重)。
|
||||||
|
if pt := strings.TrimSpace(req.PrizeType); pt != "" {
|
||||||
|
db = db.Where("EXISTS (SELECT 1 FROM lottery_prize_snapshot s WHERE s.draw_id = lottery_draw.id AND s.type = ?)", pt)
|
||||||
|
}
|
||||||
|
if req.From > 0 {
|
||||||
|
db = db.Where("drawn_at >= ?", time.Unix(req.From, 0))
|
||||||
|
}
|
||||||
|
if req.To > 0 {
|
||||||
|
db = db.Where("drawn_at < ?", time.Unix(req.To, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Draw
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
drawIds := make([]int64, 0, len(rows))
|
||||||
|
userIds := make([]int64, 0, len(rows))
|
||||||
|
for _, d := range rows {
|
||||||
|
drawIds = append(drawIds, d.Id)
|
||||||
|
userIds = append(userIds, d.UserId)
|
||||||
|
}
|
||||||
|
snaps, err := l.loadSnapshots(drawIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ledgers, err := l.loadLedgers(drawIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := l.loadUsers(userIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &types.ListAdminLotteryDrawsResponse{Total: total, List: make([]types.AdminLotteryDraw, 0, len(rows))}
|
||||||
|
for _, d := range rows {
|
||||||
|
resp.List = append(resp.List, drawToAdminView(d, snaps[d.Id], ledgers[d.Id], users[d.UserId]))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryDrawsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
|
if len(drawIds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var snaps []modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||||
|
for _, s := range snaps {
|
||||||
|
out[s.DrawId] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryDrawsLogic) loadLedgers(drawIds []int64) (map[int64]modelLottery.GrantLedger, error) {
|
||||||
|
if len(drawIds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []modelLottery.GrantLedger
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.GrantLedger, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.DrawId] = r // draw ↔ ledger 一对一(一次抽奖至多一条发放)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryDrawsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
UserId int64
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user_auth_methods").
|
||||||
|
Select("user_id AS user_id, auth_identifier AS email").
|
||||||
|
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, ok := out[r.UserId]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.UserId] = r.Email
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawToAdminView 组装单条抽奖记录后台视图。
|
||||||
|
func drawToAdminView(d modelLottery.Draw, snap modelLottery.PrizeSnapshot, ledger modelLottery.GrantLedger, email string) types.AdminLotteryDraw {
|
||||||
|
view := types.AdminLotteryDraw{
|
||||||
|
DrawId: d.Id,
|
||||||
|
ActivityId: d.ActivityId,
|
||||||
|
IsWin: d.IsWin,
|
||||||
|
DispatchState: d.DispatchState,
|
||||||
|
DispatchError: d.DispatchError,
|
||||||
|
DrawnAt: d.DrawnAt.Unix(),
|
||||||
|
CreatedAt: d.CreatedAt.Unix(),
|
||||||
|
User: types.AdminLotteryDrawUser{
|
||||||
|
Id: d.UserId,
|
||||||
|
Email: email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if d.DispatchedAt != nil {
|
||||||
|
view.DispatchedAt = d.DispatchedAt.Unix()
|
||||||
|
}
|
||||||
|
if snap.DrawId != 0 || snap.Type != "" {
|
||||||
|
view.Prize = &types.AdminLotteryDrawPrize{
|
||||||
|
Slot: snap.Slot,
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ledger.Id != 0 {
|
||||||
|
view.GrantAmount = ledger.Amount
|
||||||
|
if ledger.Payload != "" {
|
||||||
|
var p struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ledger.Payload), &p); err == nil {
|
||||||
|
view.GrantMessage = p.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
// Package lottery contains the admin-facing lottery HTTP logic. Every write
|
||||||
|
// endpoint runs its user-visible mutation inside a tx that ALSO writes an
|
||||||
|
// admin_action_log row via audit.WriteAdminAction, so a rollback leaves no
|
||||||
|
// dangling audit entries. Rules PUT is gated by rulecaps.ValidateEligibilityJSON.
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/audit"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/lottery/rulecaps"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentAdminId retrieves the actor's user.id from ctx (populated by
|
||||||
|
// AuthMiddleware). Zero → treated as unauthorized. All admin endpoints below
|
||||||
|
// short-circuit if the caller is not admin.
|
||||||
|
func currentAdminId(ctx context.Context) int64 {
|
||||||
|
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||||
|
if !ok || u == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestMeta(ctx context.Context) (ip, ua string) {
|
||||||
|
// AdminMetaMiddleware populates these keys on the request context after
|
||||||
|
// AuthMiddleware runs. Absent middleware (unit tests, non-admin paths)
|
||||||
|
// → empty strings, which is the intended defensive default.
|
||||||
|
if v, ok := ctx.Value(constant.CtxKeyIP).(string); ok {
|
||||||
|
ip = v
|
||||||
|
}
|
||||||
|
if v, ok := ctx.Value(constant.CtxKeyUserAgent).(string); ok {
|
||||||
|
ua = v
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonOrDefault(raw json.RawMessage, def string) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreateLotteryActivity -------------------------------------------------
|
||||||
|
|
||||||
|
type CreateLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryActivityLogic {
|
||||||
|
return &CreateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||||
|
return nil, ruleCapsToXerr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := modelLottery.Activity{
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
Description: req.Description,
|
||||||
|
StartAt: time.Unix(req.StartAt, 0),
|
||||||
|
EndAt: time.Unix(req.EndAt, 0),
|
||||||
|
Status: modelLottery.ActivityStatusDraft,
|
||||||
|
GridSize: req.GridSize,
|
||||||
|
Eligibility: jsonOrDefault(req.Eligibility, "{}"),
|
||||||
|
ChanceSources: jsonOrDefault(req.ChanceSources, "[]"),
|
||||||
|
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
|
||||||
|
}
|
||||||
|
if activity.GridSize <= 0 {
|
||||||
|
// HIF-4 F8: 布局 A 3×3 挖中心 → 8 个奖品格。前端约定中心是"点击抽奖"按钮,
|
||||||
|
// 不渲染为奖品;后台仍允许挂 slot=4 但前端会忽略。
|
||||||
|
activity.GridSize = 8
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&activity).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryActivityCreate,
|
||||||
|
TargetIds: int64ToStr(activity.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return activityToAdminView(activity), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UpdateLotteryActivity -------------------------------------------------
|
||||||
|
|
||||||
|
type UpdateLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryActivityLogic {
|
||||||
|
return &UpdateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryActivityLogic) UpdateLotteryActivity(req *types.UpdateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
var updated modelLottery.Activity
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
fields := map[string]any{}
|
||||||
|
if req.Title != "" {
|
||||||
|
fields["title"] = req.Title
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
fields["description"] = req.Description
|
||||||
|
}
|
||||||
|
if req.StartAt > 0 {
|
||||||
|
fields["start_at"] = time.Unix(req.StartAt, 0)
|
||||||
|
}
|
||||||
|
if req.EndAt > 0 {
|
||||||
|
fields["end_at"] = time.Unix(req.EndAt, 0)
|
||||||
|
}
|
||||||
|
if req.GridSize > 0 {
|
||||||
|
fields["grid_size"] = req.GridSize
|
||||||
|
}
|
||||||
|
if req.UnmetAction != "" {
|
||||||
|
fields["unmet_action"] = req.UnmetAction
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryActivityUpdate,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return activityToAdminView(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ListLotteryActivities -------------------------------------------------
|
||||||
|
|
||||||
|
type ListLotteryActivitiesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryActivitiesLogic {
|
||||||
|
return &ListLotteryActivitiesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryActivitiesLogic) ListLotteryActivities(req *types.ListAdminLotteryActivitiesRequest) (*types.ListAdminLotteryActivitiesResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Activity{})
|
||||||
|
if req.Status != "" {
|
||||||
|
db = db.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
if req.Search != "" {
|
||||||
|
db = db.Where("title LIKE ?", "%"+req.Search+"%")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Activity
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
resp := &types.ListAdminLotteryActivitiesResponse{Total: total, List: make([]types.AdminLotteryActivity, 0, len(rows))}
|
||||||
|
for _, a := range rows {
|
||||||
|
resp.List = append(resp.List, *activityToAdminView(a))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GetLotteryActivity ---------------------------------------------------
|
||||||
|
|
||||||
|
type GetLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLotteryActivityLogic {
|
||||||
|
return &GetLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetLotteryActivityLogic) GetLotteryActivity(req *types.AdminActivityIdRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
var a modelLottery.Activity
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return activityToAdminView(a), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Publish / Pause -------------------------------------------------------
|
||||||
|
|
||||||
|
type toggleActivityStatusLogic struct {
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
action string
|
||||||
|
next string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *toggleActivityStatusLogic) run(id int64) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var a modelLottery.Activity
|
||||||
|
if err := tx.Where("id = ?", id).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", id).UpdateColumn("status", l.next).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: l.action,
|
||||||
|
TargetIds: int64ToStr(id),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublishLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLotteryActivityLogic {
|
||||||
|
return &PublishLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *PublishLotteryActivityLogic) PublishLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||||
|
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPublish, next: modelLottery.ActivityStatusRunning}
|
||||||
|
return t.run(req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PauseLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPauseLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PauseLotteryActivityLogic {
|
||||||
|
return &PauseLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||||
|
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPause, next: modelLottery.ActivityStatusPaused}
|
||||||
|
return t.run(req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DeleteLotteryActivity -------------------------------------------------
|
||||||
|
|
||||||
|
type DeleteLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeleteLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryActivityLogic {
|
||||||
|
return &DeleteLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLotteryActivity 软删活动 + 硬删其奖品(同事务)。运行中的活动禁止删除,
|
||||||
|
// 需先暂停,避免误删正在进行的抽奖。历史抽奖记录/快照保留(独立于奖品行)。
|
||||||
|
func (l *DeleteLotteryActivityLogic) DeleteLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var a modelLottery.Activity
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if a.Status == modelLottery.ActivityStatusRunning {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "运行中的活动请先暂停再删除")
|
||||||
|
}
|
||||||
|
// 软删活动(Activity 有 gorm.DeletedAt)。
|
||||||
|
if err := tx.Delete(&modelLottery.Activity{}, req.Id).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||||
|
}
|
||||||
|
// 硬删奖品(Prize 无软删字段),避免残留孤儿奖品。
|
||||||
|
if err := tx.Where("activity_id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryActivityDelete,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UpdateLotteryRules (with caps) ----------------------------------------
|
||||||
|
|
||||||
|
type UpdateLotteryRulesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryRulesLogic {
|
||||||
|
return &UpdateLotteryRulesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryRulesLogic) UpdateLotteryRules(req *types.UpdateAdminLotteryRulesRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||||
|
return ruleCapsToXerr(err)
|
||||||
|
}
|
||||||
|
// Pre-collect the field diff outside the tx so an empty update rejects
|
||||||
|
// without opening one (cheaper on the happy path + easier to test).
|
||||||
|
fields := map[string]any{}
|
||||||
|
if len(req.Eligibility) > 0 {
|
||||||
|
fields["eligibility"] = string(req.Eligibility)
|
||||||
|
}
|
||||||
|
if len(req.ChanceSources) > 0 {
|
||||||
|
fields["chance_sources"] = string(req.ChanceSources)
|
||||||
|
}
|
||||||
|
if req.UnmetAction != "" {
|
||||||
|
fields["unmet_action"] = req.UnmetAction
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields)
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryRulesPut,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleCapsToXerr(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooDeep):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooDeep, err.Error())
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooManyNodes):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooMany, err.Error())
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooLarge):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooLarge, err.Error())
|
||||||
|
default:
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreatePrize / UpdatePrize / DeletePrize / ListPrizes -----------------
|
||||||
|
|
||||||
|
type CreateLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryPrizeLogic {
|
||||||
|
return &CreateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CreateLotteryPrizeLogic) CreateLotteryPrize(req *types.CreateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
prize := modelLottery.Prize{
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
Slot: req.Slot,
|
||||||
|
Type: req.Type,
|
||||||
|
Name: req.Name,
|
||||||
|
IconURL: req.IconUrl,
|
||||||
|
Config: jsonOrDefault(req.Config, "{}"),
|
||||||
|
Weight: req.Weight,
|
||||||
|
IsFallback: req.IsFallback,
|
||||||
|
}
|
||||||
|
if req.TotalStock != nil {
|
||||||
|
prize.TotalStock.Int64 = *req.TotalStock
|
||||||
|
prize.TotalStock.Valid = true
|
||||||
|
prize.RemainingStock.Int64 = *req.TotalStock
|
||||||
|
prize.RemainingStock.Valid = true
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&prize).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeCreate,
|
||||||
|
TargetIds: int64ToStr(prize.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prizeToAdminView(prize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryPrizeLogic {
|
||||||
|
return &UpdateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
var updated modelLottery.Prize
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.DatabaseQueryError)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
fields := map[string]any{}
|
||||||
|
if req.Slot != nil {
|
||||||
|
fields["slot"] = *req.Slot
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
fields["type"] = req.Type
|
||||||
|
}
|
||||||
|
if req.Name != "" {
|
||||||
|
fields["name"] = req.Name
|
||||||
|
}
|
||||||
|
if req.IconUrl != "" {
|
||||||
|
fields["icon_url"] = req.IconUrl
|
||||||
|
}
|
||||||
|
if len(req.Config) > 0 {
|
||||||
|
fields["config"] = string(req.Config)
|
||||||
|
}
|
||||||
|
if req.Weight != nil {
|
||||||
|
fields["weight"] = *req.Weight
|
||||||
|
}
|
||||||
|
if req.TotalStock != nil {
|
||||||
|
fields["total_stock"] = *req.TotalStock
|
||||||
|
fields["remaining_stock"] = *req.TotalStock
|
||||||
|
}
|
||||||
|
if req.IsFallback != nil {
|
||||||
|
fields["is_fallback"] = *req.IsFallback
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
if err := tx.Model(&modelLottery.Prize{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeUpdate,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prizeToAdminView(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeleteLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryPrizeLogic {
|
||||||
|
return &DeleteLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DeleteLotteryPrizeLogic) DeleteLotteryPrize(req *types.AdminPrizeIdRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeDelete,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListLotteryPrizesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryPrizesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryPrizesLogic {
|
||||||
|
return &ListLotteryPrizesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryPrizesLogic) ListLotteryPrizes(req *types.ListAdminLotteryPrizesRequest) (*types.ListAdminLotteryPrizesResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Prize
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("activity_id = ?", req.ActivityId).Order("slot ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
resp := &types.ListAdminLotteryPrizesResponse{List: make([]types.AdminLotteryPrize, 0, len(rows))}
|
||||||
|
for _, p := range rows {
|
||||||
|
resp.List = append(resp.List, *prizeToAdminView(p))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GrantLotteryChance ----------------------------------------------------
|
||||||
|
|
||||||
|
type GrantLotteryChanceLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGrantLotteryChanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GrantLotteryChanceLogic {
|
||||||
|
return &GrantLotteryChanceLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GrantLotteryChanceLogic) GrantLotteryChance(req *types.GrantAdminLotteryChanceRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
// Prefix sourceRef with "manual:{actor}:" so admin-granted chances are
|
||||||
|
// distinguishable in ChanceGrant flow (audit trail + admin-scoped
|
||||||
|
// idempotency).
|
||||||
|
ref := "manual:" + int64ToStr(actor) + ":" + req.SourceRef
|
||||||
|
if err := l.svcCtx.LotteryChance.Grant(l.ctx, req.UserId, req.ActivityId, modelLottery.ChanceSourceManualGrant, ref, req.Amount); err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.LotteryInternalError), err.Error())
|
||||||
|
}
|
||||||
|
// Audit outside the ChanceService tx — the Grant is idempotent so a
|
||||||
|
// duplicated audit row is preferable to a lost one.
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryChancesGrant,
|
||||||
|
TargetIds: int64ToStr(req.UserId) + "," + int64ToStr(req.ActivityId),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func activityToAdminView(a modelLottery.Activity) *types.AdminLotteryActivity {
|
||||||
|
return &types.AdminLotteryActivity{
|
||||||
|
Id: a.Id,
|
||||||
|
Title: a.Title,
|
||||||
|
Description: a.Description,
|
||||||
|
StartAt: a.StartAt.Unix(),
|
||||||
|
EndAt: a.EndAt.Unix(),
|
||||||
|
Status: a.Status,
|
||||||
|
GridSize: a.GridSize,
|
||||||
|
Eligibility: json.RawMessage(defaultRawIfEmpty(a.Eligibility, "{}")),
|
||||||
|
ChanceSources: json.RawMessage(defaultRawIfEmpty(a.ChanceSources, "[]")),
|
||||||
|
UnmetAction: a.UnmetAction,
|
||||||
|
CreatedAt: a.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: a.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prizeToAdminView(p modelLottery.Prize) *types.AdminLotteryPrize {
|
||||||
|
view := &types.AdminLotteryPrize{
|
||||||
|
Id: p.Id,
|
||||||
|
ActivityId: p.ActivityId,
|
||||||
|
Slot: p.Slot,
|
||||||
|
Type: p.Type,
|
||||||
|
Name: p.Name,
|
||||||
|
IconUrl: p.IconURL,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(p.Config, "{}")),
|
||||||
|
Weight: p.Weight,
|
||||||
|
IsFallback: p.IsFallback,
|
||||||
|
CreatedAt: p.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: p.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
if p.TotalStock.Valid {
|
||||||
|
v := p.TotalStock.Int64
|
||||||
|
view.TotalStock = &v
|
||||||
|
}
|
||||||
|
if p.RemainingStock.Valid {
|
||||||
|
v := p.RemainingStock.Int64
|
||||||
|
view.RemainingStock = &v
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRawIfEmpty(s, fallback string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(s, fallback string) string {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64ToStr(v int64) string {
|
||||||
|
// small buffer avoids strconv import here.
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
if v < 0 {
|
||||||
|
neg = true
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAdminLotteryDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("actual sql does not contain expected: " + expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("gorm: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminCtx() context.Context {
|
||||||
|
return context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 7})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsOversizeEligibility(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// build oversized JSON
|
||||||
|
blob := strings.Repeat("a", 9000)
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{
|
||||||
|
Id: 1,
|
||||||
|
Eligibility: json.RawMessage(`{"op":"AND","payload":"` + blob + `"}`),
|
||||||
|
}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Fatalf("expected CodeError, got %v", err)
|
||||||
|
}
|
||||||
|
if ce.GetErrCode() != xerr.LotteryRuleTooLarge {
|
||||||
|
t.Fatalf("expected LotteryRuleTooLarge, got %d", ce.GetErrCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsDeepTree(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// build depth 9 tree
|
||||||
|
tree := map[string]any{"op": "OR", "children": []any{}}
|
||||||
|
cur := tree
|
||||||
|
for i := 1; i < 9; i++ {
|
||||||
|
next := map[string]any{"op": "OR", "children": []any{}}
|
||||||
|
cur["children"] = []any{next}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(tree)
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: raw}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.LotteryRuleTooDeep {
|
||||||
|
t.Fatalf("expected LotteryRuleTooDeep, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsAnonymousCaller(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
logic := NewUpdateLotteryRulesLogic(context.Background(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(&types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: json.RawMessage("{}")})
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.ErrorTokenInvalid {
|
||||||
|
t.Fatalf("expected token invalid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_ValidTreePersists(t *testing.T) {
|
||||||
|
db, mock, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("UPDATE `lottery_activity`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{
|
||||||
|
Id: 1,
|
||||||
|
Eligibility: json.RawMessage(`{"type":"has_subscription"}`),
|
||||||
|
ChanceSources: json.RawMessage(`[{"source":"daily_signin","amount":1}]`),
|
||||||
|
}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.UpdateLotteryRules(req); err != nil {
|
||||||
|
t.Fatalf("UpdateLotteryRules: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_MissingBothFieldsRejects(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{Id: 1}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.InvalidParams {
|
||||||
|
t.Fatalf("expected InvalidParams, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRequestMeta_EmptyWhenCtxUnset asserts requestMeta returns empty strings
|
||||||
|
// when neither typed context key is populated (unit tests, non-admin paths).
|
||||||
|
func TestRequestMeta_EmptyWhenCtxUnset(t *testing.T) {
|
||||||
|
ip, ua := requestMeta(context.Background())
|
||||||
|
if ip != "" || ua != "" {
|
||||||
|
t.Fatalf("expected empty, got ip=%q ua=%q", ip, ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestMeta_ReadsTypedKeys asserts requestMeta picks up the values
|
||||||
|
// AdminMetaMiddleware pins onto ctx via the typed CtxKey constants.
|
||||||
|
func TestRequestMeta_ReadsTypedKeys(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyIP, "10.99.99.7")
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, "qa-audit-probe")
|
||||||
|
|
||||||
|
ip, ua := requestMeta(ctx)
|
||||||
|
if ip != "10.99.99.7" {
|
||||||
|
t.Fatalf("ip = %q, want %q", ip, "10.99.99.7")
|
||||||
|
}
|
||||||
|
if ua != "qa-audit-probe" {
|
||||||
|
t.Fatalf("ua = %q, want %q", ua, "qa-audit-probe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestMeta_IgnoresBareStringKeys guards against the F2 root cause:
|
||||||
|
// pre-fix, the writer used bare-string keys "ip" / "user_agent" which never
|
||||||
|
// collided with anyone's typed reader — so audit rows always saw empty
|
||||||
|
// strings. The test proves the reader now IGNORES bare-string writes: only
|
||||||
|
// the typed CtxKey path counts.
|
||||||
|
func TestRequestMeta_IgnoresBareStringKeys(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||||
|
ctx = context.WithValue(ctx, "ip", "should-be-ignored")
|
||||||
|
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||||
|
ctx = context.WithValue(ctx, "user_agent", "should-be-ignored")
|
||||||
|
|
||||||
|
ip, ua := requestMeta(ctx)
|
||||||
|
if ip != "" || ua != "" {
|
||||||
|
t.Fatalf("bare-string keys must be ignored; got ip=%q ua=%q", ip, ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ func (l *CreateServerLogic) CreateServer(req *types.CreateServerRequest) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||||
|
protocol.NormalizeSimnet()
|
||||||
protocols = append(protocols, protocol)
|
protocols = append(protocols, protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ func (l *FilterServerListLogic) FilterServerList(req *types.FilterServerListRequ
|
|||||||
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Normalize simnet defaults on the response (safe: dst is a fresh slice).
|
||||||
|
for i := range dst {
|
||||||
|
dst[i].NormalizeSimnet()
|
||||||
|
}
|
||||||
tool.DeepCopy(&protocols, dst)
|
tool.DeepCopy(&protocols, dst)
|
||||||
server.Protocols = protocols
|
server.Protocols = protocols
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func (l *GetServerProtocolsLogic) GetServerProtocols(req *types.GetServerProtoco
|
|||||||
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
// Normalize simnet defaults on the response so the admin UI always sees a
|
||||||
|
// consistent config even for legacy/hand-inserted rows. dst is a fresh slice
|
||||||
|
// (not shared with the DB), so mutating it here is safe.
|
||||||
|
for i := range dst {
|
||||||
|
dst[i].NormalizeSimnet()
|
||||||
|
}
|
||||||
tool.DeepCopy(&protocols, dst)
|
tool.DeepCopy(&protocols, dst)
|
||||||
|
|
||||||
return &types.GetServerProtocolsResponse{
|
return &types.GetServerProtocolsResponse{
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||||
|
protocol.NormalizeSimnet()
|
||||||
protocols = append(protocols, protocol)
|
protocols = append(protocols, protocol)
|
||||||
}
|
}
|
||||||
err = data.MarshalProtocols(protocols)
|
err = data.MarshalProtocols(protocols)
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -50,84 +46,11 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
|
|||||||
|
|
||||||
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set)
|
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set)
|
||||||
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
|
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
|
||||||
sessionSet := make(map[string]struct{})
|
if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil {
|
||||||
|
|
||||||
userIDText := strconv.FormatInt(userId, 10)
|
|
||||||
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result()
|
|
||||||
if scanErr != nil {
|
|
||||||
l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for _, sessionKey := range keys {
|
|
||||||
value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result()
|
|
||||||
if getErr != nil || value != userIDText {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
|
|
||||||
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sessionSet[sessionID] = struct{}{}
|
|
||||||
}
|
|
||||||
cursor = nextCursor
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceKeySet := make(map[string]struct{})
|
|
||||||
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
|
|
||||||
cursor = 0
|
|
||||||
for {
|
|
||||||
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result()
|
|
||||||
if scanErr != nil {
|
|
||||||
l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for _, deviceKey := range keys {
|
|
||||||
sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result()
|
|
||||||
if getErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := sessionSet[sessionID]; exists {
|
|
||||||
deviceKeySet[deviceKey] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = nextCursor
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sessionSet) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
|
||||||
pipe := l.svcCtx.Redis.TxPipeline()
|
|
||||||
for sessionID := range sessionSet {
|
|
||||||
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
|
|
||||||
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
|
|
||||||
pipe.ZRem(l.ctx, sessionsKey, sessionID)
|
|
||||||
}
|
|
||||||
pipe.Del(l.ctx, sessionsKey)
|
|
||||||
|
|
||||||
for deviceKey := range deviceKeySet {
|
|
||||||
pipe.Del(l.ctx, deviceKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pipe.Exec(l.ctx); err != nil {
|
|
||||||
l.Errorw("清理会话缓存失败",
|
l.Errorw("清理会话缓存失败",
|
||||||
logger.Field("user_id", userId),
|
logger.Field("user_id", userId),
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Infow("[KickOffline] 管理员踢设备-清除所有Session",
|
|
||||||
logger.Field("user_id", userId),
|
|
||||||
logger.Field("count", len(sessionSet)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clearAllSessions(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||||
|
userIDText := strconv.FormatInt(userID, 10)
|
||||||
|
sessionSet := make(map[string]struct{})
|
||||||
|
|
||||||
|
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
|
||||||
|
var cursor uint64
|
||||||
|
for {
|
||||||
|
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, pattern, 200).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, sessionKey := range keys {
|
||||||
|
value, err := svcCtx.Redis.Get(ctx, sessionKey).Result()
|
||||||
|
if err != nil || value != userIDText {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
|
||||||
|
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionSet[sessionID] = struct{}{}
|
||||||
|
}
|
||||||
|
cursor = nextCursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessionSet) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceKeySet := make(map[string]struct{})
|
||||||
|
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
|
||||||
|
cursor = 0
|
||||||
|
for {
|
||||||
|
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, devicePattern, 200).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, deviceKey := range keys {
|
||||||
|
sessionID, err := svcCtx.Redis.Get(ctx, deviceKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := sessionSet[sessionID]; exists {
|
||||||
|
deviceKeySet[deviceKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = nextCursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userID)
|
||||||
|
pipe := svcCtx.Redis.TxPipeline()
|
||||||
|
for sessionID := range sessionSet {
|
||||||
|
pipe.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
|
||||||
|
pipe.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
|
||||||
|
pipe.ZRem(ctx, sessionsKey, sessionID)
|
||||||
|
}
|
||||||
|
pipe.Del(ctx, sessionsKey)
|
||||||
|
for deviceKey := range deviceKeySet {
|
||||||
|
pipe.Del(ctx, deviceKey)
|
||||||
|
}
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClearAllSessions(t *testing.T) {
|
||||||
|
redisServer, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("miniredis.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||||
|
defer rdb.Close()
|
||||||
|
|
||||||
|
svcCtx := &svc.ServiceContext{Redis: rdb}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := int64(42)
|
||||||
|
sessionID := "session-a"
|
||||||
|
otherSessionID := "session-b"
|
||||||
|
userSessionKey := config.SessionIdKey + ":" + sessionID
|
||||||
|
userDetailKey := config.SessionIdKey + ":detail:" + sessionID
|
||||||
|
otherSessionKey := config.SessionIdKey + ":" + otherSessionID
|
||||||
|
userSessionsZSet := config.UserSessionsKeyPrefix + "42"
|
||||||
|
deviceKey := config.DeviceCacheKeyKey + ":device-1"
|
||||||
|
unrelatedDeviceKey := config.DeviceCacheKeyKey + ":device-2"
|
||||||
|
|
||||||
|
setString(t, redisServer, userSessionKey, "42")
|
||||||
|
setString(t, redisServer, userDetailKey, "detail")
|
||||||
|
setString(t, redisServer, otherSessionKey, "99")
|
||||||
|
setString(t, redisServer, deviceKey, sessionID)
|
||||||
|
setString(t, redisServer, unrelatedDeviceKey, otherSessionID)
|
||||||
|
if _, err := redisServer.ZAdd(userSessionsZSet, 1, sessionID); err != nil {
|
||||||
|
t.Fatalf("seed session zset: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := clearAllSessions(ctx, svcCtx, userID); err != nil {
|
||||||
|
t.Fatalf("clearAllSessions() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMissing(t, redisServer, userSessionKey)
|
||||||
|
assertMissing(t, redisServer, userDetailKey)
|
||||||
|
assertMissing(t, redisServer, deviceKey)
|
||||||
|
assertMissing(t, redisServer, userSessionsZSet)
|
||||||
|
|
||||||
|
if !redisServer.Exists(otherSessionKey) {
|
||||||
|
t.Fatalf("unrelated session %q should remain", otherSessionKey)
|
||||||
|
}
|
||||||
|
if !redisServer.Exists(unrelatedDeviceKey) {
|
||||||
|
t.Fatalf("unrelated device mapping %q should remain", unrelatedDeviceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setString(t *testing.T, server *miniredis.Miniredis, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := server.Set(key, value); err != nil {
|
||||||
|
t.Fatalf("set %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMissing(t *testing.T, server *miniredis.Miniredis, key string) {
|
||||||
|
t.Helper()
|
||||||
|
if server.Exists(key) {
|
||||||
|
t.Fatalf("expected key %q to be removed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
|
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size")
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size")
|
||||||
}
|
}
|
||||||
|
if req.Enable != nil && !*req.Enable {
|
||||||
|
if userInfo.IsAdmin != nil && *userInfo.IsAdmin {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "admin user cannot be disabled")
|
||||||
|
}
|
||||||
|
if userInfo.Id == 2 {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "demo user cannot be disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||||
if req.Balance != nil && userInfo.Balance != *req.Balance {
|
if req.Balance != nil && userInfo.Balance != *req.Balance {
|
||||||
@@ -176,6 +184,19 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
}
|
}
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update User Error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update User Error")
|
||||||
}
|
}
|
||||||
|
if req.Enable != nil {
|
||||||
|
if cacheErr := logicCommon.InvalidateUserEnableCache(l.ctx, l.svcCtx, userInfo.Id); cacheErr != nil {
|
||||||
|
l.Errorw("[UpdateUserBasicInfoLogic] clear enable cache failed", logger.Field("err", cacheErr.Error()), logger.Field("userId", req.UserId))
|
||||||
|
}
|
||||||
|
if !*req.Enable {
|
||||||
|
if sessionErr := clearAllSessions(l.ctx, l.svcCtx, userInfo.Id); sessionErr != nil {
|
||||||
|
l.Errorw("[UpdateUserBasicInfoLogic] clear sessions failed", logger.Field("err", sessionErr.Error()), logger.Field("userId", req.UserId))
|
||||||
|
}
|
||||||
|
for _, device := range userInfo.UserDevices {
|
||||||
|
l.svcCtx.DeviceManager.KickDevice(userInfo.Id, device.Identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Package audit records administrative write actions to the admin_action_log
|
||||||
|
// table so security/compliance can trace who did what across lottery admin
|
||||||
|
// endpoints. Every admin CRUD in PR C calls WriteAdminAction inside its own
|
||||||
|
// transaction; the caller is expected to have already validated permissions.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action code convention: dot-separated, prefix by domain (e.g.
|
||||||
|
// "lottery.activity.create", "lottery.prize.delete"). Keep them short and
|
||||||
|
// stable so downstream analytics can pivot without maintaining a translation
|
||||||
|
// table.
|
||||||
|
const (
|
||||||
|
ActionLotteryActivityCreate = "lottery.activity.create"
|
||||||
|
ActionLotteryActivityUpdate = "lottery.activity.update"
|
||||||
|
ActionLotteryActivityDelete = "lottery.activity.delete"
|
||||||
|
ActionLotteryActivityPublish = "lottery.activity.publish"
|
||||||
|
ActionLotteryActivityPause = "lottery.activity.pause"
|
||||||
|
ActionLotteryPrizeCreate = "lottery.prize.create"
|
||||||
|
ActionLotteryPrizeUpdate = "lottery.prize.update"
|
||||||
|
ActionLotteryPrizeDelete = "lottery.prize.delete"
|
||||||
|
ActionLotteryRulesPut = "lottery.activity.rules.put"
|
||||||
|
ActionLotteryChancesGrant = "lottery.chances.grant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminActionLog is the GORM entity for admin_action_log.
|
||||||
|
type AdminActionLog struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
ActorUserId int64 `gorm:"type:bigint unsigned;not null;comment:操作者 user.id"`
|
||||||
|
Action string `gorm:"type:varchar(64);not null;comment:动作 code"`
|
||||||
|
TargetIds string `gorm:"type:varchar(255);not null;default:'';comment:被操作对象 ID"`
|
||||||
|
RequestHash string `gorm:"type:varchar(64);not null;default:'';comment:请求摘要"`
|
||||||
|
IP string `gorm:"type:varchar(45);not null;default:'';comment:操作者 IP"`
|
||||||
|
UserAgent string `gorm:"type:varchar(255);not null;default:'';comment:操作者 UA"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:操作时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName pins the entity to the migration table name.
|
||||||
|
func (AdminActionLog) TableName() string { return "admin_action_log" }
|
||||||
|
|
||||||
|
// Entry is the pre-hashed convenience input to WriteAdminAction. Callers
|
||||||
|
// build one with actor + action + payload fields; the writer computes the
|
||||||
|
// request hash and inserts inside tx.
|
||||||
|
type Entry struct {
|
||||||
|
ActorUserId int64
|
||||||
|
Action string
|
||||||
|
// TargetIds is stringified list of primary keys touched by this action.
|
||||||
|
// Free-form: comma-separated ints, JSON array, etc.
|
||||||
|
TargetIds string
|
||||||
|
// RequestBody is hashed to produce request_hash. Pass nil if not applicable.
|
||||||
|
RequestBody []byte
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAdminAction inserts an admin_action_log row inside the caller's tx.
|
||||||
|
// The row lives-or-dies with the caller's transaction: a rollback drops the
|
||||||
|
// audit trail, which is the intended coupling — we don't want to record
|
||||||
|
// actions that never happened.
|
||||||
|
func WriteAdminAction(ctx context.Context, tx *gorm.DB, e Entry) error {
|
||||||
|
if tx == nil {
|
||||||
|
return fmt.Errorf("audit: WriteAdminAction requires a transaction handle")
|
||||||
|
}
|
||||||
|
if e.ActorUserId == 0 || e.Action == "" {
|
||||||
|
return fmt.Errorf("audit: WriteAdminAction requires ActorUserId and Action")
|
||||||
|
}
|
||||||
|
row := AdminActionLog{
|
||||||
|
ActorUserId: e.ActorUserId,
|
||||||
|
Action: strings.TrimSpace(e.Action),
|
||||||
|
TargetIds: e.TargetIds,
|
||||||
|
RequestHash: hashBody(e.RequestBody),
|
||||||
|
IP: e.IP,
|
||||||
|
UserAgent: truncate(e.UserAgent, 255),
|
||||||
|
}
|
||||||
|
return tx.WithContext(ctx).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashBody(body []byte) string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(body)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAdminAction_RequiresTx(t *testing.T) {
|
||||||
|
err := WriteAdminAction(context.Background(), nil, Entry{ActorUserId: 1, Action: "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on nil tx")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAdminAction_RequiresActorAndAction(t *testing.T) {
|
||||||
|
db, _, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := WriteAdminAction(context.Background(), db, Entry{Action: "x"}); err == nil {
|
||||||
|
t.Fatal("expected error when ActorUserId=0")
|
||||||
|
}
|
||||||
|
if err := WriteAdminAction(context.Background(), db, Entry{ActorUserId: 1}); err == nil {
|
||||||
|
t.Fatal("expected error when Action empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAdminAction_InsertsRow(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
|
||||||
|
err := WriteAdminAction(context.Background(), db, Entry{
|
||||||
|
ActorUserId: 42,
|
||||||
|
Action: ActionLotteryActivityCreate,
|
||||||
|
TargetIds: "[1,2,3]",
|
||||||
|
RequestBody: []byte(`{"title":"test"}`),
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
UserAgent: "curl/7.85",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteAdminAction: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashBody(t *testing.T) {
|
||||||
|
if got := hashBody(nil); got != "" {
|
||||||
|
t.Fatalf("nil body should hash to empty, got %q", got)
|
||||||
|
}
|
||||||
|
if got := hashBody([]byte("")); got != "" {
|
||||||
|
t.Fatalf("empty body should hash to empty, got %q", got)
|
||||||
|
}
|
||||||
|
if got := hashBody([]byte("abc")); len(got) != 40 {
|
||||||
|
t.Fatalf("expected 40-char sha1 hex, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
if got := truncate("hello", 10); got != "hello" {
|
||||||
|
t.Fatalf("short strings pass through, got %q", got)
|
||||||
|
}
|
||||||
|
if got := truncate("hello world", 5); got != "hello" {
|
||||||
|
t.Fatalf("expected truncation to 5, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAdminAction_DBError(t *testing.T) {
|
||||||
|
db, mock, cleanup := newTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||||
|
WillReturnError(errors.New("db down"))
|
||||||
|
|
||||||
|
err := WriteAdminAction(context.Background(), db, Entry{
|
||||||
|
ActorUserId: 1,
|
||||||
|
Action: "test",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error propagation from DB")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,6 +96,9 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
|||||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||||
}
|
}
|
||||||
|
if logiccommon.IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// Bind device to user if identifier is provided
|
// Bind device to user if identifier is provided
|
||||||
if req.Identifier != "" {
|
if req.Identifier != "" {
|
||||||
@@ -130,6 +135,7 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
|||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,6 +116,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if logicCommon.IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// Record login status
|
// Record login status
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -188,6 +193,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||||
|
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/auth"
|
"github.com/perfect-panel/server/internal/model/auth"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +76,9 @@ func (l *OAuthLoginGetTokenLogic) OAuthLoginGetToken(req *types.OAuthLoginGetTok
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if logiccommon.IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
|
||||||
token, err := l.generateToken(userInfo, requestID)
|
token, err := l.generateToken(userInfo, requestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -628,6 +633,7 @@ func (l *OAuthLoginGetTokenLogic) generateToken(userInfo *user.User, requestID s
|
|||||||
)
|
)
|
||||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err)
|
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err)
|
||||||
}
|
}
|
||||||
|
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||||
|
|
||||||
l.Infow("jwt token generated successfully",
|
l.Infow("jwt token generated successfully",
|
||||||
logger.Field("request_id", requestID),
|
logger.Field("request_id", requestID),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
if common.IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
// Record login status
|
// Record login status
|
||||||
defer func(svcCtx *svc.ServiceContext) {
|
defer func(svcCtx *svc.ServiceContext) {
|
||||||
if userInfo.Id != 0 {
|
if userInfo.Id != 0 {
|
||||||
@@ -165,6 +169,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
|||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/pkg/captcha"
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -89,6 +91,9 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
|||||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||||
}
|
}
|
||||||
|
if logicCommon.IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// Update last login time
|
// Update last login time
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -135,6 +140,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
|||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
} else if err == nil && !u.DeletedAt.Valid {
|
} else if err == nil && !u.DeletedAt.Valid {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user email exist: %v", req.Email)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user email exist: %v", req.Email)
|
||||||
} else if err == nil && u.DeletedAt.Valid {
|
} else if err == nil && u.DeletedAt.Valid {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
|
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const userEnableCacheTTL = 30 * time.Second
|
||||||
|
|
||||||
|
func UserEnableCacheKey(userID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d", config.UserEnableKeyPrefix, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsUserDisabled(userInfo *modeluser.User) bool {
|
||||||
|
return userInfo != nil && userInfo.Enable != nil && !*userInfo.Enable
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveEnabledUser(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user id: %d", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := UserEnableCacheKey(userID)
|
||||||
|
cached, err := svcCtx.Redis.Get(ctx, cacheKey).Result()
|
||||||
|
if err == nil {
|
||||||
|
if cached == strconv.FormatBool(false) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
return svcCtx.UserModel.FindOne(ctx, userID)
|
||||||
|
}
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
logger.WithContext(ctx).Errorw("get user enable cache failed, fallback to db",
|
||||||
|
logger.Field("user_id", userID),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
return loadEnabledUserFromDB(ctx, svcCtx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := svcCtx.UserModel.FindOne(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheErr := CacheUserEnabled(ctx, svcCtx, userID, !IsUserDisabled(userInfo)); cacheErr != nil {
|
||||||
|
logger.WithContext(ctx).Errorw("cache user enable state failed",
|
||||||
|
logger.Field("user_id", userID),
|
||||||
|
logger.Field("error", cacheErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if IsUserDisabled(userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheUserEnabled(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, enabled bool) error {
|
||||||
|
return svcCtx.Redis.Set(ctx, UserEnableCacheKey(userID), strconv.FormatBool(enabled), userEnableCacheTTL).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateUserEnableCache(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||||
|
return svcCtx.Redis.Del(ctx, UserEnableCacheKey(userID)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnabledUserFromDB(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||||
|
var userInfo modeluser.User
|
||||||
|
if err := svcCtx.DB.WithContext(ctx).
|
||||||
|
Model(&modeluser.User{}).
|
||||||
|
Unscoped().
|
||||||
|
Where("`id` = ?", userID).
|
||||||
|
Preload("UserDevices").
|
||||||
|
Preload("AuthMethods").
|
||||||
|
First(&userInfo).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if IsUserDisabled(&userInfo) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||||
|
}
|
||||||
|
return &userInfo, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsUserDisabled(t *testing.T) {
|
||||||
|
trueValue := true
|
||||||
|
falseValue := false
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
user *user.User
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil user treated as enabled",
|
||||||
|
user: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil enable treated as enabled",
|
||||||
|
user: &user.User{Id: 1},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled user",
|
||||||
|
user: &user.User{Id: 2, Enable: &trueValue},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled user",
|
||||||
|
user: &user.User{Id: 3, Enable: &falseValue},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsUserDisabled(tc.user); got != tc.want {
|
||||||
|
t.Fatalf("IsUserDisabled() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEnabledUser(t *testing.T) {
|
||||||
|
t.Run("cache hit false returns disabled error without db query", func(t *testing.T) {
|
||||||
|
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := svcCtx.Redis.Set(ctx, UserEnableCacheKey(9), "false", 0).Err(); err != nil {
|
||||||
|
t.Fatalf("seed redis: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ResolveEnabledUser(ctx, svcCtx, 9)
|
||||||
|
assertCodeError(t, err, xerr.UserDisabled)
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unexpected db query: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cache miss loads enabled user and backfills cache", func(t *testing.T) {
|
||||||
|
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
expectFindOne(mock, 11, true)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
userInfo, err := ResolveEnabledUser(ctx, svcCtx, 11)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if userInfo.Id != 11 {
|
||||||
|
t.Fatalf("ResolveEnabledUser() user id = %d, want 11", userInfo.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := svcCtx.Redis.Get(ctx, UserEnableCacheKey(11)).Result()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read backfilled cache: %v", err)
|
||||||
|
}
|
||||||
|
if cached != "true" {
|
||||||
|
t.Fatalf("backfilled cache = %q, want %q", cached, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("db expectations: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cache miss loads disabled user and caches false", func(t *testing.T) {
|
||||||
|
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||||
|
defer redisServer.Close()
|
||||||
|
|
||||||
|
expectFindOne(mock, 13, false)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := ResolveEnabledUser(ctx, svcCtx, 13)
|
||||||
|
assertCodeError(t, err, xerr.UserDisabled)
|
||||||
|
|
||||||
|
cached, cacheErr := svcCtx.Redis.Get(ctx, UserEnableCacheKey(13)).Result()
|
||||||
|
if cacheErr != nil {
|
||||||
|
t.Fatalf("read disabled cache: %v", cacheErr)
|
||||||
|
}
|
||||||
|
if cached != "false" {
|
||||||
|
t.Fatalf("disabled cache = %q, want %q", cached, "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("db expectations: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("redis failure falls back to db", func(t *testing.T) {
|
||||||
|
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||||
|
expectFindOne(mock, 17, true)
|
||||||
|
redisServer.Close()
|
||||||
|
|
||||||
|
userInfo, err := ResolveEnabledUser(context.Background(), svcCtx, 17)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if userInfo.Id != 17 {
|
||||||
|
t.Fatalf("ResolveEnabledUser() user id = %d, want 17", userInfo.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("db expectations: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnableTestServiceContext(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, *miniredis.Miniredis) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
|
}
|
||||||
|
mock.MatchExpectationsInOrder(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(mysql.New(mysql.Config{
|
||||||
|
Conn: sqlDB,
|
||||||
|
SkipInitializeWithVersion: true,
|
||||||
|
}), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{SingularTable: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gorm.Open() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
redisServer, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("miniredis.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = rdb.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &svc.ServiceContext{
|
||||||
|
DB: gdb,
|
||||||
|
Redis: rdb,
|
||||||
|
UserModel: user.NewModel(gdb, rdb),
|
||||||
|
}, mock, redisServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectFindOne(mock sqlmock.Sqlmock, userID int64, enabled bool) {
|
||||||
|
rows := sqlmock.NewRows([]string{
|
||||||
|
"id",
|
||||||
|
"password",
|
||||||
|
"algo",
|
||||||
|
"salt",
|
||||||
|
"avatar",
|
||||||
|
"balance",
|
||||||
|
"refer_code",
|
||||||
|
"referer_id",
|
||||||
|
"commission",
|
||||||
|
"referral_percentage",
|
||||||
|
"only_first_purchase",
|
||||||
|
"gift_amount",
|
||||||
|
"enable",
|
||||||
|
"is_admin",
|
||||||
|
"enable_balance_notify",
|
||||||
|
"enable_login_notify",
|
||||||
|
"enable_subscribe_notify",
|
||||||
|
"enable_trade_notify",
|
||||||
|
"rules",
|
||||||
|
"member_status",
|
||||||
|
"remark",
|
||||||
|
"last_login_time",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
}).AddRow(
|
||||||
|
userID,
|
||||||
|
"pwd",
|
||||||
|
"default",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
int64(0),
|
||||||
|
"",
|
||||||
|
int64(0),
|
||||||
|
int64(0),
|
||||||
|
uint8(0),
|
||||||
|
true,
|
||||||
|
int64(0),
|
||||||
|
enabled,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user` WHERE `id` = ? ORDER BY `user`.`id` LIMIT ?")).
|
||||||
|
WithArgs(userID, 1).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_device` WHERE `user_device`.`user_id` = ?")).
|
||||||
|
WithArgs(userID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "identifier"}))
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_auth_methods` WHERE `user_auth_methods`.`user_id` = ?")).
|
||||||
|
WithArgs(userID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "auth_type", "auth_identifier", "verified"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCodeError(t *testing.T, err error, wantCode uint32) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeErr *xerr.CodeError
|
||||||
|
if !errors.As(err, &codeErr) {
|
||||||
|
t.Fatalf("error %T does not contain xerr.CodeError: %v", err, err)
|
||||||
|
}
|
||||||
|
if codeErr.GetErrCode() != wantCode {
|
||||||
|
t.Fatalf("error code = %d, want %d", codeErr.GetErrCode(), wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
// Package draw implements POST /api/v1/lottery/draw: the transactional lottery
|
||||||
|
// draw flow. The service composes the ChanceService, RuleEvaluator,
|
||||||
|
// WeightedPicker, PrizeHandler.Registry and LedgerService primitives from the
|
||||||
|
// model layer into one atomic sequence.
|
||||||
|
//
|
||||||
|
// Ordering matters — the flow is:
|
||||||
|
// 1. feature-flag gate (config.Lottery.Enable)
|
||||||
|
// 2. Redis rate limit (per-user 1/sec)
|
||||||
|
// 3. Load activity + validate window/status
|
||||||
|
// 4. Build RuleContext (pre-tx reads)
|
||||||
|
// 5. Evaluate eligibility (pure compute)
|
||||||
|
// 6. Load prize pool snapshot (pre-tx read)
|
||||||
|
// 7. Open tx →
|
||||||
|
// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce)
|
||||||
|
// — hit returns the recorded draw
|
||||||
|
// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement)
|
||||||
|
// 7c. WeightedPicker.Pick
|
||||||
|
// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0
|
||||||
|
// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot
|
||||||
|
// 7f. Auto-handler Dispatch (in tx)
|
||||||
|
// 7g. Update draw.dispatch_state
|
||||||
|
// → commit
|
||||||
|
//
|
||||||
|
// Everything past step 5 uses the caller's transaction; post-commit cache
|
||||||
|
// invalidation is the handler layer's job (a future enhancement — the
|
||||||
|
// underlying UserModel already invalidates its own cache on UpdateSubscribe).
|
||||||
|
package draw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/pkg/limit"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is the input to Draw.
|
||||||
|
type Request struct {
|
||||||
|
UserId int64
|
||||||
|
ActivityId int64
|
||||||
|
ClientNonce string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what Draw returns to the caller (user handler).
|
||||||
|
type Result struct {
|
||||||
|
DrawId int64
|
||||||
|
IsWin bool
|
||||||
|
Prize *PrizeSummary
|
||||||
|
ChancesRemaining int64
|
||||||
|
Claim ClaimSummary
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrizeSummary is the awarded-prize view rendered for the user.
|
||||||
|
type PrizeSummary struct {
|
||||||
|
Slot int
|
||||||
|
Id int64
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Config json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimSummary describes whether the user needs to take a further action.
|
||||||
|
type ClaimSummary struct {
|
||||||
|
Required bool
|
||||||
|
AutoClaimed bool
|
||||||
|
Message string
|
||||||
|
// ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。
|
||||||
|
ExpiresAt int64
|
||||||
|
// ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema
|
||||||
|
// (nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。
|
||||||
|
ClaimFormSchema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service orchestrates the transactional draw flow. Deps are struct-injected
|
||||||
|
// so tests can substitute fakes and production wiring lives in ServiceContext.
|
||||||
|
type Service struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps groups the collaborators. Nil-safe checks live in Draw itself, not here.
|
||||||
|
type Deps struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Enabled bool
|
||||||
|
RateLimiter RateLimiter
|
||||||
|
Chance lottery.ChanceService
|
||||||
|
Evaluator lottery.RuleEvaluator
|
||||||
|
Picker lottery.WeightedPicker
|
||||||
|
Registry lottery.Registry
|
||||||
|
ContextBuilder RuleContextBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter admits at most 1 draw per second per user. Extracted to an
|
||||||
|
// interface so tests can supply an always-admit fake without pulling Redis.
|
||||||
|
type RateLimiter interface {
|
||||||
|
Allow(ctx context.Context, userId int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleContextBuilder loads the per-user snapshot needed by the rule
|
||||||
|
// evaluator. Extracted so tests can inject deterministic contexts.
|
||||||
|
type RuleContextBuilder interface {
|
||||||
|
Build(ctx context.Context, userId int64) (lottery.RuleContext, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService returns a Draw service ready to serve requests.
|
||||||
|
func NewService(d Deps) *Service { return &Service{deps: d} }
|
||||||
|
|
||||||
|
// Draw runs the full lottery draw flow. Errors are xerr codes suitable for
|
||||||
|
// direct return by the HTTP handler; internal errors are wrapped as
|
||||||
|
// LotteryInternalError.
|
||||||
|
func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
if err := s.validateRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.deps.Enabled {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
if err := s.applyRateLimit(ctx, req.UserId); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := s.loadRunningActivity(ctx, req.ActivityId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-tx reads: user context + prize pool snapshot. Cheap and out of the
|
||||||
|
// hot-lock window; the tx step re-checks stock atomically.
|
||||||
|
rc, err := s.buildRuleContext(ctx, req.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := parseEligibilityTree(activity.Eligibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapInternal(err)
|
||||||
|
}
|
||||||
|
passed, unmet, err := s.deps.Evaluator.Evaluate(ctx, tree, rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapInternal(err)
|
||||||
|
}
|
||||||
|
if !passed {
|
||||||
|
// The rejection itself is not an error to the caller — but we still
|
||||||
|
// record an eligibility snapshot for support/audit before returning
|
||||||
|
// the 4001. Snapshot write intentionally uses its own tx: the draw
|
||||||
|
// itself never got issued, so there is no draw_id to correlate; we
|
||||||
|
// omit the snapshot in that case.
|
||||||
|
_ = unmet // unmet is available to the handler via error metadata if needed
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryNotEligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
prizes, err := s.loadPrizes(ctx, req.ActivityId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapInternal(err)
|
||||||
|
}
|
||||||
|
if len(prizes) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *Result
|
||||||
|
txErr := s.deps.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// (a) Nonce dedupe — race-safe idempotency check.
|
||||||
|
if existing, existsErr := s.findExistingDraw(ctx, tx, req); existsErr != nil {
|
||||||
|
return existsErr
|
||||||
|
} else if existing != nil {
|
||||||
|
result, existsErr = s.buildResultFromExistingDraw(ctx, tx, existing)
|
||||||
|
return existsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Consume chance atomically. ErrNoChances → 4002.
|
||||||
|
remaining, consumeErr := s.deps.Chance.Consume(ctx, tx, req.UserId, req.ActivityId)
|
||||||
|
if errors.Is(consumeErr, lottery.ErrNoChances) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryNoChances)
|
||||||
|
}
|
||||||
|
if consumeErr != nil {
|
||||||
|
return wrapInternal(consumeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) Pick a prize.
|
||||||
|
idx, pickErr := s.deps.Picker.Pick(prizes)
|
||||||
|
if pickErr != nil {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
picked := prizes[idx]
|
||||||
|
|
||||||
|
// (d) Limited-stock decrement.
|
||||||
|
final, stockErr := s.decrementStockOrFallback(ctx, tx, picked, prizes)
|
||||||
|
if stockErr != nil {
|
||||||
|
return wrapInternal(stockErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (e) Insert draw + snapshots.
|
||||||
|
draw, insertErr := s.insertDraw(ctx, tx, req, final)
|
||||||
|
if insertErr != nil {
|
||||||
|
return wrapInternal(insertErr)
|
||||||
|
}
|
||||||
|
if snapErr := s.insertSnapshots(ctx, tx, draw, final, passed); snapErr != nil {
|
||||||
|
return wrapInternal(snapErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (f) Dispatch prize (auto handler) or create pending claim (manual handler).
|
||||||
|
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final)
|
||||||
|
if dispatchErr != nil {
|
||||||
|
return dispatchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// (g) Update draw.dispatch_state to reflect handler outcome.
|
||||||
|
if updateErr := s.finalizeDrawState(ctx, tx, draw, dispatch); updateErr != nil {
|
||||||
|
return wrapInternal(updateErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = buildResult(draw, final, dispatch, claimInfo, remaining)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
if _, ok := txErr.(*xerr.CodeError); ok {
|
||||||
|
return nil, txErr
|
||||||
|
}
|
||||||
|
return nil, wrapInternal(txErr)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- individual steps ------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Service) validateRequest(req Request) error {
|
||||||
|
if req.UserId <= 0 || req.ActivityId <= 0 || req.ClientNonce == "" {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
if len(req.ClientNonce) > 64 {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applyRateLimit(ctx context.Context, userId int64) error {
|
||||||
|
if s.deps.RateLimiter == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.deps.RateLimiter.Allow(ctx, userId); err != nil {
|
||||||
|
if errors.Is(err, ErrRateLimited) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryRateLimited)
|
||||||
|
}
|
||||||
|
return wrapInternal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadRunningActivity(ctx context.Context, activityId int64) (*lottery.Activity, error) {
|
||||||
|
var activity lottery.Activity
|
||||||
|
now := time.Now()
|
||||||
|
err := s.deps.DB.WithContext(ctx).
|
||||||
|
Where("id = ?", activityId).
|
||||||
|
First(&activity).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return nil, wrapInternal(err)
|
||||||
|
}
|
||||||
|
if activity.Status != lottery.ActivityStatusRunning {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
if activity.StartAt.After(now) || activity.EndAt.Before(now) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return &activity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildRuleContext(ctx context.Context, userId int64) (lottery.RuleContext, error) {
|
||||||
|
if s.deps.ContextBuilder == nil {
|
||||||
|
return lottery.RuleContext{UserId: userId, Now: time.Now().Unix()}, nil
|
||||||
|
}
|
||||||
|
return s.deps.ContextBuilder.Build(ctx, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadPrizes(ctx context.Context, activityId int64) ([]lottery.Prize, error) {
|
||||||
|
var prizes []lottery.Prize
|
||||||
|
err := s.deps.DB.WithContext(ctx).
|
||||||
|
Where("activity_id = ?", activityId).
|
||||||
|
Order("slot ASC").
|
||||||
|
Find(&prizes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prizes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) findExistingDraw(ctx context.Context, tx *gorm.DB, req Request) (*lottery.Draw, error) {
|
||||||
|
var existing lottery.Draw
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND client_nonce = ?", req.UserId, req.ClientNonce).
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrementStockOrFallback runs the limited-stock optimistic lock. If the
|
||||||
|
// chosen prize is unlimited, returns it as-is. If limited and stock survives
|
||||||
|
// → returns it. If limited and sold-out → walks the pool for the first
|
||||||
|
// `is_fallback=true` prize or falls back to a "none" (thanks-for-playing)
|
||||||
|
// synthetic prize.
|
||||||
|
func (s *Service) decrementStockOrFallback(ctx context.Context, tx *gorm.DB, picked lottery.Prize, pool []lottery.Prize) (lottery.Prize, error) {
|
||||||
|
if !picked.RemainingStock.Valid {
|
||||||
|
return picked, nil
|
||||||
|
}
|
||||||
|
res := tx.WithContext(ctx).
|
||||||
|
Model(&lottery.Prize{}).
|
||||||
|
Where("id = ? AND remaining_stock > 0", picked.Id).
|
||||||
|
UpdateColumn("remaining_stock", gorm.Expr("`remaining_stock` - 1"))
|
||||||
|
if res.Error != nil {
|
||||||
|
return lottery.Prize{}, res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
return picked, nil
|
||||||
|
}
|
||||||
|
// Sold out → fallback selection.
|
||||||
|
for _, p := range pool {
|
||||||
|
if p.IsFallback {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No fallback declared → synthesize a "谢谢参与" from the last non-fallback
|
||||||
|
// entry (any type=none in the pool wins); if pool has no none, we
|
||||||
|
// synthesize an ephemeral prize record. Note: this prize is NOT persisted
|
||||||
|
// as a separate row — it just satisfies the return contract.
|
||||||
|
for _, p := range pool {
|
||||||
|
if p.Type == lottery.PrizeTypeNone {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lottery.Prize{
|
||||||
|
ActivityId: picked.ActivityId,
|
||||||
|
Slot: picked.Slot,
|
||||||
|
Type: lottery.PrizeTypeNone,
|
||||||
|
Name: "谢谢参与",
|
||||||
|
Config: "{}",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertDraw(ctx context.Context, tx *gorm.DB, req Request, prize lottery.Prize) (*lottery.Draw, error) {
|
||||||
|
draw := lottery.Draw{
|
||||||
|
UserId: req.UserId,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
ClientNonce: req.ClientNonce,
|
||||||
|
IsWin: prize.Type != lottery.PrizeTypeNone,
|
||||||
|
DispatchState: lottery.DispatchStateNone,
|
||||||
|
DrawnAt: time.Now(),
|
||||||
|
}
|
||||||
|
if prize.Id > 0 {
|
||||||
|
draw.PrizeId = sql.NullInt64{Int64: prize.Id, Valid: true}
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(&draw).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &draw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, prize lottery.Prize, passedEligibility bool) error {
|
||||||
|
ps := lottery.PrizeSnapshot{
|
||||||
|
DrawId: draw.Id,
|
||||||
|
PrizeId: prize.Id,
|
||||||
|
Slot: prize.Slot,
|
||||||
|
Type: prize.Type,
|
||||||
|
Name: prize.Name,
|
||||||
|
Config: prize.Config,
|
||||||
|
}
|
||||||
|
if ps.Config == "" {
|
||||||
|
ps.Config = "{}"
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(&ps).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
es := lottery.EligibilitySnapshot{
|
||||||
|
DrawId: draw.Id,
|
||||||
|
UserId: draw.UserId,
|
||||||
|
ActivityId: draw.ActivityId,
|
||||||
|
Passed: passedEligibility,
|
||||||
|
// UnmetReasons 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||||
|
// Stage 1 只有 passed=true 进 insertSnapshots,语义上"没有未过项",
|
||||||
|
// 用 "[]" 与 PrizeSnapshot.Config 的 "{}" 守卫对称。
|
||||||
|
// Stage 2 若开始持久化 passed=false 的失败评估,再改成真正的 marshal。
|
||||||
|
UnmetReasons: "[]",
|
||||||
|
// 显式 time.Now():GORM 遇 zero time 有时会传 '0000-00-00 00:00:00',
|
||||||
|
// 触 sql_mode STRICT。不依赖 DB DEFAULT CURRENT_TIMESTAMP。
|
||||||
|
EvaluatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
return tx.WithContext(ctx).Create(&es).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingClaimInfo carries the manual-claim details the draw service produced
|
||||||
|
// this turn. Zero-value = draw did not create a claim (auto prize or none).
|
||||||
|
type pendingClaimInfo struct {
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ClaimFormSchema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch
|
||||||
|
// (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path).
|
||||||
|
//
|
||||||
|
// - draw.IsWin == false → thanks-for-playing, auto_claimed.
|
||||||
|
// - handler.IsAuto()==true → call Dispatch inside caller's tx.
|
||||||
|
// - handler.IsAuto()==false → insert lottery_claim (pending_claim) and
|
||||||
|
// return ClaimFormSchema + ExpiresAt so the
|
||||||
|
// caller can render the response.
|
||||||
|
func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) {
|
||||||
|
if !draw.IsWin {
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||||
|
}
|
||||||
|
if s.deps.Registry == nil {
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
|
||||||
|
}
|
||||||
|
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prizeHandler.IsAuto() {
|
||||||
|
dispatchReq := lottery.DispatchRequest{
|
||||||
|
UserId: req.UserId,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
DrawId: draw.Id,
|
||||||
|
Prize: prize,
|
||||||
|
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
|
||||||
|
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
|
||||||
|
}
|
||||||
|
result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq)
|
||||||
|
return result, pendingClaimInfo{}, dispatchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual-claim path (Stage 2). Insert a pending_claim row inside the same
|
||||||
|
// draw tx so a rollback also erases the claim.
|
||||||
|
expiresAt := s.computeClaimExpiry(prize)
|
||||||
|
claim := lottery.Claim{
|
||||||
|
DrawId: draw.Id,
|
||||||
|
UserId: req.UserId,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
PrizeType: prize.Type,
|
||||||
|
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
|
||||||
|
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
|
||||||
|
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
|
||||||
|
// GrantLedger.Payload 的守卫风格一致。
|
||||||
|
ClaimData: "{}",
|
||||||
|
Status: lottery.ClaimStatusPendingClaim,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil {
|
||||||
|
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := prizeHandler.ClaimSchema()
|
||||||
|
// crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。
|
||||||
|
if prize.Type == lottery.PrizeTypeCrypto {
|
||||||
|
schema = handler.BuildCryptoClaimSchema(prize.Config)
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{
|
||||||
|
State: lottery.DispatchStatePendingClaim,
|
||||||
|
Message: "请凭此截图直接联系人工客服兑换奖励。",
|
||||||
|
}, pendingClaimInfo{
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ClaimFormSchema: schema,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正
|
||||||
|
// 则回落到 lottery.DefaultClaimTTLHours (7 天)。
|
||||||
|
func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time {
|
||||||
|
hours := lottery.DefaultClaimTTLHours
|
||||||
|
if prize.Config != "" {
|
||||||
|
var cfg struct {
|
||||||
|
ClaimTTLHours int `json:"claim_ttl_hours"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 {
|
||||||
|
hours = cfg.ClaimTTLHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Now().Add(time.Duration(hours) * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{
|
||||||
|
"dispatch_state": dispatch.State,
|
||||||
|
}
|
||||||
|
if dispatch.State == lottery.DispatchStateAutoClaimed || dispatch.State == lottery.DispatchStatePaid {
|
||||||
|
updates["dispatched_at"] = now
|
||||||
|
}
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Model(&lottery.Draw{}).
|
||||||
|
Where("id = ?", draw.Id).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, existing *lottery.Draw) (*Result, error) {
|
||||||
|
var snap lottery.PrizeSnapshot
|
||||||
|
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&snap).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remaining, _ := s.deps.Chance.Query(ctx, existing.UserId, existing.ActivityId)
|
||||||
|
var prize *PrizeSummary
|
||||||
|
if existing.IsWin {
|
||||||
|
prize = &PrizeSummary{
|
||||||
|
Slot: snap.Slot,
|
||||||
|
Id: snap.PrizeId,
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(snap.Config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claim := ClaimSummary{
|
||||||
|
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
||||||
|
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
||||||
|
}
|
||||||
|
// 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次
|
||||||
|
// 收到的响应比首次少字段。
|
||||||
|
if claim.Required {
|
||||||
|
var claimRow lottery.Claim
|
||||||
|
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error
|
||||||
|
if err == nil {
|
||||||
|
claim.ExpiresAt = claimRow.ExpiresAt.Unix()
|
||||||
|
if s.deps.Registry != nil {
|
||||||
|
if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok {
|
||||||
|
if claimRow.PrizeType == lottery.PrizeTypeCrypto {
|
||||||
|
claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config)
|
||||||
|
} else {
|
||||||
|
claim.ClaimFormSchema = h.ClaimSchema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Result{
|
||||||
|
DrawId: existing.Id,
|
||||||
|
IsWin: existing.IsWin,
|
||||||
|
Prize: prize,
|
||||||
|
ChancesRemaining: remaining,
|
||||||
|
Claim: claim,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result {
|
||||||
|
res := &Result{
|
||||||
|
DrawId: draw.Id,
|
||||||
|
IsWin: draw.IsWin,
|
||||||
|
ChancesRemaining: remaining,
|
||||||
|
Message: dispatch.Message,
|
||||||
|
Claim: ClaimSummary{
|
||||||
|
Required: dispatch.State == lottery.DispatchStatePendingClaim,
|
||||||
|
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
|
||||||
|
Message: dispatch.Message,
|
||||||
|
ClaimFormSchema: claim.ClaimFormSchema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !claim.ExpiresAt.IsZero() {
|
||||||
|
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
|
if draw.IsWin {
|
||||||
|
res.Prize = &PrizeSummary{
|
||||||
|
Slot: prize.Slot,
|
||||||
|
Id: prize.Id,
|
||||||
|
Type: prize.Type,
|
||||||
|
Name: prize.Name,
|
||||||
|
Config: json.RawMessage(prize.Config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEligibilityTree tolerates empty/null activity.Eligibility as "no gate".
|
||||||
|
func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) {
|
||||||
|
trimmed := ""
|
||||||
|
for _, r := range raw {
|
||||||
|
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
|
||||||
|
trimmed += string(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var tree lottery.EligibilityRule
|
||||||
|
if err := json.Unmarshal([]byte(raw), &tree); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse eligibility: %w", err)
|
||||||
|
}
|
||||||
|
return &tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。
|
||||||
|
//
|
||||||
|
// HIF-4 F10:msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文
|
||||||
|
// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致
|
||||||
|
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种
|
||||||
|
// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。
|
||||||
|
func wrapInternal(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Preserve already-coded errors (their msg 是设计过的对外文案,不动).
|
||||||
|
if _, ok := err.(*xerr.CodeError); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 内部细节走日志,供运维/后端排查;err.Error() 不外传。
|
||||||
|
logger.WithContext(context.Background()).Error("[lottery draw internal error]",
|
||||||
|
logger.Field("error", err.Error()))
|
||||||
|
return xerr.NewErrCode(xerr.LotteryInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rate limiter production wiring ----------------------------------------
|
||||||
|
|
||||||
|
// ErrRateLimited is returned by RateLimiter.Allow when the caller exceeded
|
||||||
|
// the configured quota.
|
||||||
|
var ErrRateLimited = errors.New("draw: rate limited")
|
||||||
|
|
||||||
|
// RedisRateLimiter is the production RateLimiter backed by pkg/limit's
|
||||||
|
// Redis-Lua fixed-window (1 hit per 1 second per user), matching the
|
||||||
|
// existing sendEmailCodeLogic pattern.
|
||||||
|
type RedisRateLimiter struct {
|
||||||
|
limiter *limit.PeriodLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisRateLimiter builds a per-user 1-req/1-sec limiter. keyPrefix is
|
||||||
|
// expected to end with ':' so the composed key is human-readable.
|
||||||
|
func NewRedisRateLimiter(limiter *limit.PeriodLimit) *RedisRateLimiter {
|
||||||
|
return &RedisRateLimiter{limiter: limiter}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow admits or rejects the caller.
|
||||||
|
func (r *RedisRateLimiter) Allow(ctx context.Context, userId int64) error {
|
||||||
|
if r == nil || r.limiter == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, err := r.limiter.TakeCtx(ctx, strconv.FormatInt(userId, 10))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state == limit.Allowed || state == limit.HitQuota {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrRateLimited
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommissionHandler 发放"抽奖佣金"。写 user.commission 增量 + system_logs
|
||||||
|
// (Type=Commission, CommissionType=339 Lottery) —— 用新增的 CommissionTypeLottery
|
||||||
|
// 常量与 Purchase/Renewal 区分,账目侧对账更清晰。
|
||||||
|
//
|
||||||
|
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||||
|
// Reserve 命中即幂等,未命中才走真实发放。commission 直接发给中奖者本人,不做
|
||||||
|
// 家庭组归位(family owner 不代收成员的抽奖佣金)。
|
||||||
|
type CommissionHandler struct {
|
||||||
|
deps CommissionDeps
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommissionDeps 是 CommissionHandler 需要的最小依赖集。抽出到接口方便测试。
|
||||||
|
type CommissionDeps struct {
|
||||||
|
Ledger lottery.LedgerService
|
||||||
|
// UpdateCommission 对齐 UserModel.UpdateCommission 签名。
|
||||||
|
UpdateCommission func(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
|
||||||
|
// WriteCommissionLog 对齐 common.WriteCommissionLog 签名。
|
||||||
|
WriteCommissionLog func(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommissionHandler 构造真实的 commission handler。
|
||||||
|
func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
|
||||||
|
return &CommissionHandler{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
||||||
|
func (*CommissionHandler) IsAuto() bool { return true }
|
||||||
|
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
||||||
|
func (*CommissionHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|
||||||
|
type commissionConfig struct {
|
||||||
|
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
||||||
|
AmountCents int64 `json:"amount_cents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commissionPayload struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
LogType uint16 `json:"log_type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch 在 caller 的事务内为中奖人发放佣金。
|
||||||
|
func (h *CommissionHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return lottery.DispatchResult{}, errors.New("commission handler requires a transaction")
|
||||||
|
}
|
||||||
|
if req.IdempotencyKey == "" {
|
||||||
|
return lottery.DispatchResult{}, errors.New("commission handler requires DispatchRequest.IdempotencyKey")
|
||||||
|
}
|
||||||
|
if h.deps.UpdateCommission == nil || h.deps.WriteCommissionLog == nil {
|
||||||
|
return lottery.DispatchResult{}, errors.New("commission handler deps not fully wired")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg commissionConfig
|
||||||
|
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("decode commission config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.AmountCents <= 0 {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("commission config amount_cents must be > 0, got %d", cfg.AmountCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 佣金"发给中奖者本人"(不走家庭组归位)。
|
||||||
|
targetUserID := req.UserId
|
||||||
|
|
||||||
|
entry := lottery.GrantLedger{
|
||||||
|
ExternalRef: req.IdempotencyKey,
|
||||||
|
HandlerType: lottery.PrizeTypeCommission,
|
||||||
|
UserId: targetUserID,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
DrawId: req.DrawId,
|
||||||
|
Amount: cfg.AmountCents,
|
||||||
|
}
|
||||||
|
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||||
|
if err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||||
|
}
|
||||||
|
if alreadyExisted {
|
||||||
|
var payload commissionPayload
|
||||||
|
if row.Payload != "" {
|
||||||
|
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||||
|
}
|
||||||
|
if payload.Message == "" {
|
||||||
|
payload.Message = "佣金已到账"
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未存在 → 真实发放。UpdateCommission 用 gorm.Expr 原子累加,避免丢更新。
|
||||||
|
if err := h.deps.UpdateCommission(ctx, targetUserID, cfg.AmountCents, tx); err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("update commission for user %d: %w", targetUserID, err)
|
||||||
|
}
|
||||||
|
// 传 external_ref 到 WriteCommissionLog 的 orderNo 位("lottery:*"),与业务 order 命名域天然区分。
|
||||||
|
if err := h.deps.WriteCommissionLog(tx, targetUserID, logmodel.CommissionTypeLottery, cfg.AmountCents, req.IdempotencyKey); err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("write commission log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := commissionPayload{
|
||||||
|
Amount: cfg.AmountCents,
|
||||||
|
LogType: logmodel.CommissionTypeLottery,
|
||||||
|
OrderNo: req.IdempotencyKey,
|
||||||
|
Message: fmt.Sprintf("佣金已到账 %d", cfg.AmountCents),
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("marshal ledger payload: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&lottery.GrantLedger{}).
|
||||||
|
Where("id = ?", row.Id).
|
||||||
|
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("update ledger payload: %w", err)
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteCommissionLog 是 internal/logic/common.WriteCommissionLog 的镜像。
|
||||||
|
// 抽到 handler 包避免 internal/svc → internal/logic/common 的 import cycle
|
||||||
|
// (internal/logic/common 里有别的文件反向 import 了 svc)。函数体保持一致,
|
||||||
|
// 未来若 common 侧调整了签名或者行为要同步到这里。
|
||||||
|
func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||||
|
logInfo := logmodel.Commission{
|
||||||
|
Type: logType,
|
||||||
|
Amount: amount,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
content, err := logInfo.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(logmodel.SystemLog{}).Create(&logmodel.SystemLog{
|
||||||
|
Type: logmodel.TypeCommission.Uint8(),
|
||||||
|
Date: time.Now().Format(time.DateOnly),
|
||||||
|
ObjectID: objectID,
|
||||||
|
Content: string(content),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommission_RequiresIdempotencyKey(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewCommissionHandler(CommissionDeps{
|
||||||
|
Ledger: &fakeLedger{},
|
||||||
|
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT call UpdateCommission without idempotency key")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||||
|
})
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||||
|
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommission_RequiresTx(t *testing.T) {
|
||||||
|
h := NewCommissionHandler(CommissionDeps{Ledger: &fakeLedger{}})
|
||||||
|
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||||
|
t.Fatalf("expected tx error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommission_IdempotentHitDoesNotWrite(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{
|
||||||
|
Payload: `{"message":"佣金已到账 300"}`,
|
||||||
|
}, true, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewCommissionHandler(CommissionDeps{
|
||||||
|
Ledger: ledger,
|
||||||
|
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT UpdateCommission on idempotent hit")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error {
|
||||||
|
t.Fatal("must NOT WriteCommissionLog on idempotent hit")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if res.Message != "佣金已到账 300" {
|
||||||
|
t.Fatalf("expected replay message, got %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommission_FirstTimeWritesCommissionAndLog(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 11}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
updateCommissionCalled = false
|
||||||
|
writeLogCalled = false
|
||||||
|
writeLogType uint16
|
||||||
|
writeLogAmount int64
|
||||||
|
writeLogOrderNo string
|
||||||
|
)
|
||||||
|
h := NewCommissionHandler(CommissionDeps{
|
||||||
|
Ledger: ledger,
|
||||||
|
UpdateCommission: func(_ context.Context, uid, amount int64, _ ...*gorm.DB) error {
|
||||||
|
updateCommissionCalled = true
|
||||||
|
if uid != 42 || amount != 300 {
|
||||||
|
t.Fatalf("UpdateCommission got (uid=%d, amount=%d)", uid, amount)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WriteCommissionLog: func(_ *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||||
|
writeLogCalled = true
|
||||||
|
writeLogType = logType
|
||||||
|
writeLogAmount = amount
|
||||||
|
writeLogOrderNo = orderNo
|
||||||
|
if objectID != 42 {
|
||||||
|
t.Fatalf("WriteCommissionLog objectID=%d, want 42", objectID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmockAnyResult())
|
||||||
|
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if !updateCommissionCalled || !writeLogCalled {
|
||||||
|
t.Fatalf("expected both UpdateCommission and WriteCommissionLog to be called (uc=%v wl=%v)", updateCommissionCalled, writeLogCalled)
|
||||||
|
}
|
||||||
|
if writeLogType != logmodel.CommissionTypeLottery {
|
||||||
|
t.Fatalf("expected CommissionTypeLottery(%d), got %d", logmodel.CommissionTypeLottery, writeLogType)
|
||||||
|
}
|
||||||
|
if writeLogAmount != 300 {
|
||||||
|
t.Fatalf("expected amount 300, got %d", writeLogAmount)
|
||||||
|
}
|
||||||
|
if writeLogOrderNo != "lottery:100:200" {
|
||||||
|
t.Fatalf("expected orderNo to reuse ExternalRef, got %q", writeLogOrderNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommission_BadConfigRejected(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewCommissionHandler(CommissionDeps{
|
||||||
|
Ledger: &fakeLedger{},
|
||||||
|
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error { return nil },
|
||||||
|
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||||
|
})
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config string
|
||||||
|
}{
|
||||||
|
{name: "invalid json", config: `{bad`},
|
||||||
|
{name: "zero amount", config: `{"amount_cents":0}`},
|
||||||
|
{name: "negative amount", config: `{"amount_cents":-1}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
Prize: lottery.Prize{Config: tt.config},
|
||||||
|
IdempotencyKey: "k",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommission_MissingDepsFailsFast(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewCommissionHandler(CommissionDeps{
|
||||||
|
Ledger: &fakeLedger{},
|
||||||
|
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||||
|
})
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
IdempotencyKey: "k",
|
||||||
|
Prize: lottery.Prize{Config: `{"amount_cents":1}`},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when deps missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Package handler crypto/physical/manual_other 是 Stage 2 引入的三类"人工奖"
|
||||||
|
// PrizeHandler。特点:IsAuto()=false,抽奖事务不调用 Dispatch,而是由 draw
|
||||||
|
// 服务事务内插入 lottery_claim (pending_claim)。用户随后 POST /claim 提交
|
||||||
|
// 领奖表单;运营在后台 approve → mark-paid。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 通用错误 -------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrClaimDataEmpty 表示用户没有提交任何领奖 body。
|
||||||
|
ErrClaimDataEmpty = errors.New("lottery: claim data is empty")
|
||||||
|
// ErrClaimDataMalformed 表示 body 不是合法 JSON 或缺关键字段。
|
||||||
|
ErrClaimDataMalformed = errors.New("lottery: claim data is malformed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// notSupportedDispatch 返回 ErrDispatchNotSupported,供三个人工奖 handler 共享。
|
||||||
|
// 抽奖服务在 handler.IsAuto()==false 时会短路,不会真的调用 Dispatch;这个
|
||||||
|
// 实现只是防御性的:万一未来某处直接调用了 Dispatch,能立刻在日志里看到问题。
|
||||||
|
func notSupportedDispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return lottery.DispatchResult{}, lottery.ErrDispatchNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeClaimJSON 是三个人工 handler 通用的 body 解码路径:空 body 直接返回
|
||||||
|
// ErrClaimDataEmpty;解码失败返回 ErrClaimDataMalformed(wrap 原因)。
|
||||||
|
func decodeClaimJSON(raw []byte, out any) error {
|
||||||
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
|
||||||
|
return ErrClaimDataEmpty
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- crypto handler ------------------------------------------------------
|
||||||
|
|
||||||
|
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
|
||||||
|
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
|
||||||
|
type CryptoHandler struct{}
|
||||||
|
|
||||||
|
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
|
||||||
|
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
|
||||||
|
|
||||||
|
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
|
||||||
|
func (*CryptoHandler) IsAuto() bool { return false }
|
||||||
|
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
|
||||||
|
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
|
||||||
|
// 这里的常量是空 enum 的"模板";ClaimSchema() 返回不带具体 networks 的通用
|
||||||
|
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
|
||||||
|
// 生成带 enum 的最终 schema 附到 draw response 上。
|
||||||
|
var cryptoClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["network","address"],
|
||||||
|
"properties": {
|
||||||
|
"network": {"type":"string","title":"打款网络"},
|
||||||
|
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
|
||||||
|
|
||||||
|
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema:
|
||||||
|
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
|
||||||
|
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
|
||||||
|
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
|
||||||
|
var cfg cryptoConfig
|
||||||
|
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||||
|
return cryptoClaimSchemaJSON
|
||||||
|
}
|
||||||
|
if len(cfg.Networks) == 0 {
|
||||||
|
return cryptoClaimSchemaJSON
|
||||||
|
}
|
||||||
|
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
|
||||||
|
for i, n := range cfg.Networks {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(n)
|
||||||
|
b.Write(encoded)
|
||||||
|
}
|
||||||
|
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
|
||||||
|
return json.RawMessage(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoConfig 是 lottery_prize.config 的解码目标。
|
||||||
|
type cryptoConfig struct {
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoClaimInput struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
|
||||||
|
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
|
||||||
|
// mark-paid 前人肉复核。
|
||||||
|
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
|
||||||
|
|
||||||
|
// ValidateClaim 校验用户提交的 { network, address }:
|
||||||
|
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
|
||||||
|
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
|
||||||
|
// 污染 ValidateClaim 的签名)
|
||||||
|
// - address 必须匹配基础字符集与长度
|
||||||
|
func (*CryptoHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input cryptoClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Network) == "" {
|
||||||
|
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
|
||||||
|
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
|
||||||
|
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
|
||||||
|
// 由 POST /claim 逻辑层负责调用。
|
||||||
|
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
|
||||||
|
var input cryptoClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cfg cryptoConfig
|
||||||
|
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||||
|
return fmt.Errorf("decode crypto config: %w", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Networks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
network := strings.TrimSpace(input.Network)
|
||||||
|
for _, allowed := range cfg.Networks {
|
||||||
|
if allowed == network {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- physical handler ----------------------------------------------------
|
||||||
|
|
||||||
|
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
|
||||||
|
type PhysicalHandler struct{}
|
||||||
|
|
||||||
|
// NewPhysicalHandler 构造 physical handler。
|
||||||
|
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
|
||||||
|
|
||||||
|
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
|
||||||
|
func (*PhysicalHandler) IsAuto() bool { return false }
|
||||||
|
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var physicalClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name","phone","province","city","district","detail"],
|
||||||
|
"properties": {
|
||||||
|
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
|
||||||
|
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
|
||||||
|
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
|
||||||
|
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
|
||||||
|
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
|
||||||
|
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
|
||||||
|
|
||||||
|
type physicalClaimInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Province string `json:"province"`
|
||||||
|
City string `json:"city"`
|
||||||
|
District string `json:"district"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
|
||||||
|
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
|
||||||
|
|
||||||
|
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input physicalClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Name) == "" {
|
||||||
|
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
|
||||||
|
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Province) == "" ||
|
||||||
|
strings.TrimSpace(input.City) == "" ||
|
||||||
|
strings.TrimSpace(input.District) == "" ||
|
||||||
|
strings.TrimSpace(input.Detail) == "" {
|
||||||
|
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- manual_other handler ------------------------------------------------
|
||||||
|
|
||||||
|
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
|
||||||
|
type ManualOtherHandler struct{}
|
||||||
|
|
||||||
|
// NewManualOtherHandler 构造 manual_other handler。
|
||||||
|
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
|
||||||
|
func (*ManualOtherHandler) IsAuto() bool { return false }
|
||||||
|
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
return notSupportedDispatch(ctx, tx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualOtherClaimSchemaJSON = json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["contact_type","contact_value"],
|
||||||
|
"properties": {
|
||||||
|
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
|
||||||
|
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
|
||||||
|
"remark": {"type":"string","title":"备注","maxLength":512}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
|
||||||
|
|
||||||
|
type manualOtherClaimInput struct {
|
||||||
|
ContactType string `json:"contact_type"`
|
||||||
|
ContactValue string `json:"contact_value"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// manualOtherContactTypes 是 contact_type 允许的枚举。
|
||||||
|
var manualOtherContactTypes = map[string]struct{}{
|
||||||
|
"phone": {},
|
||||||
|
"email": {},
|
||||||
|
"tg": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
|
||||||
|
var input manualOtherClaimInput
|
||||||
|
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ct := strings.TrimSpace(input.ContactType)
|
||||||
|
if _, ok := manualOtherContactTypes[ct]; !ok {
|
||||||
|
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.ContactValue) == "" {
|
||||||
|
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
if len(input.Remark) > 512 {
|
||||||
|
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
||||||
|
// - Type / IsAuto / Dispatch 契约
|
||||||
|
// - ClaimSchema 返回合法 JSON
|
||||||
|
// - ValidateClaim 正确/错误样本表驱动
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Crypto ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCryptoHandler_Contract(t *testing.T) {
|
||||||
|
h := NewCryptoHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypeCrypto {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false for manual claim handler")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil for manual handler")
|
||||||
|
}
|
||||||
|
var schema map[string]any
|
||||||
|
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
||||||
|
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewCryptoHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
errIsErr error
|
||||||
|
}{
|
||||||
|
{"empty", "", true, ErrClaimDataEmpty},
|
||||||
|
{"whitespace", " ", true, ErrClaimDataEmpty},
|
||||||
|
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
||||||
|
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
||||||
|
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
||||||
|
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
||||||
|
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCryptoNetwork(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
prizeConfig string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
||||||
|
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
||||||
|
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
||||||
|
{"empty body", "", `{"networks":["BTC"]}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
||||||
|
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
||||||
|
s := string(schema)
|
||||||
|
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
||||||
|
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
||||||
|
}
|
||||||
|
// 合法 JSON
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(schema, &out); err != nil {
|
||||||
|
t.Fatalf("built schema not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
||||||
|
// invalid JSON → fallback to generic schema without enum
|
||||||
|
schema := BuildCryptoClaimSchema(`not-json`)
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(schema, &out); err != nil {
|
||||||
|
t.Fatalf("fallback schema not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Physical -------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestPhysicalHandler_Contract(t *testing.T) {
|
||||||
|
h := NewPhysicalHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypePhysical {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false")
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewPhysicalHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty", ``, true},
|
||||||
|
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||||
|
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||||
|
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
||||||
|
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
||||||
|
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ManualOther ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestManualOtherHandler_Contract(t *testing.T) {
|
||||||
|
h := NewManualOtherHandler()
|
||||||
|
if h.Type() != lottery.PrizeTypeManualOther {
|
||||||
|
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
||||||
|
}
|
||||||
|
if h.IsAuto() {
|
||||||
|
t.Fatal("IsAuto must be false")
|
||||||
|
}
|
||||||
|
if h.ClaimSchema() == nil {
|
||||||
|
t.Fatal("ClaimSchema must not be nil")
|
||||||
|
}
|
||||||
|
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||||
|
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
||||||
|
h := NewManualOtherHandler()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty", ``, true},
|
||||||
|
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
||||||
|
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
||||||
|
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
||||||
|
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
||||||
|
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
||||||
|
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := h.ValidateClaim([]byte(tc.body))
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态机常量约束 --------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIsClaimStatusResubmittable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
status string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{lottery.ClaimStatusPendingClaim, true},
|
||||||
|
{lottery.ClaimStatusRejected, true},
|
||||||
|
{lottery.ClaimStatusReviewing, false},
|
||||||
|
{lottery.ClaimStatusPaying, false},
|
||||||
|
{lottery.ClaimStatusPaid, false},
|
||||||
|
{lottery.ClaimStatusExpired, false},
|
||||||
|
{"", false},
|
||||||
|
{"unknown", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
||||||
|
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
prizeType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{lottery.PrizeTypeCrypto, true},
|
||||||
|
{lottery.PrizeTypePhysical, true},
|
||||||
|
{lottery.PrizeTypeManualOther, true},
|
||||||
|
{lottery.PrizeTypeVPNDuration, false},
|
||||||
|
{lottery.PrizeTypeCommission, false},
|
||||||
|
{lottery.PrizeTypeNone, false},
|
||||||
|
{"unknown", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
||||||
|
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqlmockAnyResult 是 sqlmock.NewResult 的简写,语义与它一致(0 影响行)。
|
||||||
|
func sqlmockAnyResult() driver.Result {
|
||||||
|
return sqlmock.NewResult(0, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// Package handler contains real PrizeHandler implementations for lottery prize
|
||||||
|
// dispatch. Handlers live in the logic layer because they depend on UserModel,
|
||||||
|
// NodeModel, and commonLogic — importing those from the pure-model
|
||||||
|
// internal/model/lottery package would flip the layering.
|
||||||
|
//
|
||||||
|
// All Dispatch entry points are called inside the draw service's transaction
|
||||||
|
// and must remain tx-only: no cache invalidation, no goroutine fan-out. The
|
||||||
|
// draw service is responsible for post-commit side effects (node cache clear,
|
||||||
|
// user group recalculation) once the enclosing transaction commits.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VPNDurationHandler 发放"N 天订阅时长"。
|
||||||
|
//
|
||||||
|
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||||
|
// Reserve 命中即幂等,返回 payload 里之前记录的 message;未命中才走真实发放。
|
||||||
|
//
|
||||||
|
// 家庭组:走 ResolveEffectiveUser 归位到 owner;若 owner 无活跃订阅,日志
|
||||||
|
// "skipped" 并返回 auto_claimed(与 grantGiftDays 的行为一致,避免中奖后无处发
|
||||||
|
// 的场景导致抽奖事务回滚吞事件)。
|
||||||
|
type VPNDurationHandler struct {
|
||||||
|
deps VPNDurationDeps
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPNDurationDeps 是 VPNDurationHandler 需要的依赖。用 struct 显式收拢,避免
|
||||||
|
// 直接依赖庞大的 ServiceContext;测试时可注入实现了同接口的 mock。
|
||||||
|
type VPNDurationDeps struct {
|
||||||
|
UserModel usermodel.Model
|
||||||
|
Ledger lottery.LedgerService
|
||||||
|
DB *gorm.DB
|
||||||
|
// ResolveEffectiveUser 用于家庭组归位。为 nil 时不做归位(等价于身份函数)。
|
||||||
|
// 生产接线用 DefaultResolveEffectiveUser(DB) 包出闭包。
|
||||||
|
ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现。语义与
|
||||||
|
// internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner),
|
||||||
|
// 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common
|
||||||
|
// 的 import cycle(common 包里有别的文件反向 import 了 svc)。
|
||||||
|
func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) {
|
||||||
|
return func(ctx context.Context, userID int64) (int64, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
var row struct {
|
||||||
|
OwnerUserID int64 `gorm:"column:owner_user_id"`
|
||||||
|
}
|
||||||
|
q := db.WithContext(ctx).
|
||||||
|
Table("user_family_member").
|
||||||
|
Select("user_family.owner_user_id AS owner_user_id").
|
||||||
|
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL").
|
||||||
|
Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive).
|
||||||
|
Order("user_family_member.role").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&row)
|
||||||
|
if q.Error != nil {
|
||||||
|
return 0, q.Error
|
||||||
|
}
|
||||||
|
if q.RowsAffected == 0 || row.OwnerUserID <= 0 {
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
return row.OwnerUserID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVPNDurationHandler 构造真实的 vpn_duration handler。
|
||||||
|
func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
|
||||||
|
return &VPNDurationHandler{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。
|
||||||
|
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
|
||||||
|
func (*VPNDurationHandler) IsAuto() bool { return true }
|
||||||
|
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
|
||||||
|
func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
|
|
||||||
|
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
|
||||||
|
type vpnDurationConfig struct {
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
// SubscribeId 指定“无活跃订阅时新建订阅”所用的套餐计划 ID。
|
||||||
|
// 0 表示不新建:延续历史行为(无活跃订阅则记录 skipped 不发放)。
|
||||||
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
|
||||||
|
// message;同时便于对账(哪条 user_subscribe 被延长、延长了多少天)。
|
||||||
|
type vpnDurationPayload struct {
|
||||||
|
EffectiveUserID int64 `json:"effective_user_id"`
|
||||||
|
SubscribeID int64 `json:"subscribe_id"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// vpnDurationUserMessage 是免费时长中奖后返回给用户的提示文案(N=中奖天数,动态)。
|
||||||
|
// 内部对账用的详细结果仍写在 ledger.payload.message(如"已加 N 天到订阅")。
|
||||||
|
func vpnDurationUserMessage(days int) string {
|
||||||
|
return fmt.Sprintf("稍后您的 %d 天免费时长将会自动添加至您的账户。如果超过24小时未添加成功,请联系人工客服处理。", days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch 在 caller 的事务内发放订阅时长。
|
||||||
|
func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires a transaction")
|
||||||
|
}
|
||||||
|
if req.IdempotencyKey == "" {
|
||||||
|
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires DispatchRequest.IdempotencyKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg vpnDurationConfig
|
||||||
|
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("decode vpn_duration config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.DurationDays <= 0 {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("vpn_duration config duration_days must be > 0, got %d", cfg.DurationDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 家庭组归位:注入的 ResolveEffectiveUser 决定是否穿透到 owner。
|
||||||
|
effectiveUserID := req.UserId
|
||||||
|
if h.deps.ResolveEffectiveUser != nil {
|
||||||
|
if eid, err := h.deps.ResolveEffectiveUser(ctx, req.UserId); err == nil && eid > 0 {
|
||||||
|
effectiveUserID = eid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := lottery.GrantLedger{
|
||||||
|
ExternalRef: req.IdempotencyKey,
|
||||||
|
HandlerType: lottery.PrizeTypeVPNDuration,
|
||||||
|
UserId: effectiveUserID,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
DrawId: req.DrawId,
|
||||||
|
Amount: int64(cfg.DurationDays),
|
||||||
|
}
|
||||||
|
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||||
|
if err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||||
|
}
|
||||||
|
if alreadyExisted {
|
||||||
|
// 幂等命中:直接返回之前记录的 payload.message。
|
||||||
|
var payload vpnDurationPayload
|
||||||
|
if row.Payload != "" {
|
||||||
|
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||||
|
}
|
||||||
|
if payload.Message == "" {
|
||||||
|
payload.Message = "已加到订阅"
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未存在 → 真实发放。查用户的活跃订阅。
|
||||||
|
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
|
||||||
|
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||||
|
// 无活跃订阅:
|
||||||
|
// - 若奖品配置了 subscribe_id,则按该套餐新建一条订阅并发放时长;
|
||||||
|
// - 否则延续旧行为:记录 skipped 但不失败(避免抽奖事务因无处发放而回滚)。
|
||||||
|
if cfg.SubscribeId > 0 {
|
||||||
|
newSub, createErr := h.createSubscription(ctx, tx, effectiveUserID, req.DrawId, cfg)
|
||||||
|
if createErr != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("auto-create subscribe for user %d: %w", effectiveUserID, createErr)
|
||||||
|
}
|
||||||
|
payload := vpnDurationPayload{
|
||||||
|
EffectiveUserID: effectiveUserID,
|
||||||
|
SubscribeID: newSub.Id,
|
||||||
|
Days: cfg.DurationDays,
|
||||||
|
Message: fmt.Sprintf("已新建订阅并加 %d 天", cfg.DurationDays),
|
||||||
|
}
|
||||||
|
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||||
|
return lottery.DispatchResult{}, err
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||||
|
}
|
||||||
|
payload := vpnDurationPayload{
|
||||||
|
EffectiveUserID: effectiveUserID,
|
||||||
|
Days: cfg.DurationDays,
|
||||||
|
Message: "跳过:用户无活跃订阅",
|
||||||
|
}
|
||||||
|
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||||
|
return lottery.DispatchResult{}, err
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||||
|
}
|
||||||
|
if findErr != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("find active subscribe for user %d: %w", effectiveUserID, findErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算新 ExpireTime。样板见 activateOrderLogic.go:1336-1342:
|
||||||
|
// a) NoLimit 永久(time.UnixMilli(0))→ 不延长
|
||||||
|
// b) 已过期 → 从 now 起加
|
||||||
|
// c) 未过期 → 从 ExpireTime 起加
|
||||||
|
now := time.Now()
|
||||||
|
if !activeSub.ExpireTime.Equal(time.UnixMilli(0)) {
|
||||||
|
if activeSub.ExpireTime.Before(now) {
|
||||||
|
activeSub.ExpireTime = now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||||
|
} else {
|
||||||
|
activeSub.ExpireTime = activeSub.ExpireTime.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeSub.Status = 1
|
||||||
|
activeSub.FinishedAt = nil
|
||||||
|
|
||||||
|
if err := h.deps.UserModel.UpdateSubscribe(ctx, activeSub, tx); err != nil {
|
||||||
|
return lottery.DispatchResult{}, fmt.Errorf("update subscribe %d: %w", activeSub.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := vpnDurationPayload{
|
||||||
|
EffectiveUserID: effectiveUserID,
|
||||||
|
SubscribeID: activeSub.Id,
|
||||||
|
Days: cfg.DurationDays,
|
||||||
|
Message: fmt.Sprintf("已加 %d 天到订阅", cfg.DurationDays),
|
||||||
|
}
|
||||||
|
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||||
|
return lottery.DispatchResult{}, err
|
||||||
|
}
|
||||||
|
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
|
||||||
|
// 最新 token 非空的历史订阅(样板 activateOrderLogic.go:1371-1393)。
|
||||||
|
func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||||
|
activeSub, err := h.deps.UserModel.FindActiveSubscribe(ctx, userID)
|
||||||
|
if err == nil {
|
||||||
|
return activeSub, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.deps.DB == nil {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
var fallback usermodel.Subscribe
|
||||||
|
fallbackErr := h.deps.DB.WithContext(ctx).
|
||||||
|
Model(&usermodel.Subscribe{}).
|
||||||
|
Where("user_id = ? AND token != ''", userID).
|
||||||
|
Where("status IN ?", []int64{0, 1, 2, 3}).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&fallback).Error
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return nil, fallbackErr
|
||||||
|
}
|
||||||
|
return &fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSubscription 在无活跃订阅时,按奖品配置的 subscribe_id 套餐为用户新建一条
|
||||||
|
// 订阅,时长为 cfg.DurationDays 天。套餐属性(流量、节点组)继承自计划,token/uuid
|
||||||
|
// 现场生成。整个操作在 caller 的事务内完成,随抽奖事务一起提交/回滚。
|
||||||
|
func (h *VPNDurationHandler) createSubscription(ctx context.Context, tx *gorm.DB, userID, drawID int64, cfg vpnDurationConfig) (*usermodel.Subscribe, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, errors.New("createSubscription requires a transaction")
|
||||||
|
}
|
||||||
|
var plan subscribemodel.Subscribe
|
||||||
|
if err := tx.WithContext(ctx).Where("id = ?", cfg.SubscribeId).First(&plan).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("subscribe plan %d not found", cfg.SubscribeId)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
// token 需全局唯一:用 lottery draw 维度做种子,避免与订单 token 冲突。
|
||||||
|
tokenSeed := fmt.Sprintf("lottery:%d:%d:%d", cfg.SubscribeId, userID, drawID)
|
||||||
|
newSub := &usermodel.Subscribe{
|
||||||
|
UserId: userID,
|
||||||
|
OrderId: 0,
|
||||||
|
SubscribeId: plan.Id,
|
||||||
|
NodeGroupId: plan.NodeGroupId,
|
||||||
|
StartTime: now,
|
||||||
|
ExpireTime: now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour),
|
||||||
|
Traffic: plan.Traffic,
|
||||||
|
Token: uuidx.SubscribeToken(tokenSeed),
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(newSub).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return newSub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal ledger payload: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Model(&lottery.GrantLedger{}).
|
||||||
|
Where("id = ?", ledgerID).
|
||||||
|
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||||
|
return fmt.Errorf("update ledger payload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newHandlerTestDB 建一个 sqlmock 支撑的 gorm.DB,子测试直接把它当 tx 传给 handler。
|
||||||
|
func newHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("actual sql does not contain expected: " + expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{
|
||||||
|
SkipDefaultTransaction: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeLedger 让 handler 单测不依赖真实 SQL,只验证控制流。
|
||||||
|
type fakeLedger struct {
|
||||||
|
reserveFn func(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLedger) Reserve(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return f.reserveFn(ctx, tx, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUserModel 满足 usermodel.Model 里 handler 用到的两个方法。
|
||||||
|
type fakeUserModel struct {
|
||||||
|
usermodel.Model
|
||||||
|
findActive func(ctx context.Context, userID int64) (*usermodel.Subscribe, error)
|
||||||
|
updateSubscribe func(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserModel) FindActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||||
|
return f.findActive(ctx, userID)
|
||||||
|
}
|
||||||
|
func (f *fakeUserModel) UpdateSubscribe(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error {
|
||||||
|
return f.updateSubscribe(ctx, sub, tx...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityResolver 单测里的家庭组归位:始终返回自身。
|
||||||
|
func identityResolver(_ context.Context, userID int64) (int64, error) { return userID, nil }
|
||||||
|
|
||||||
|
func TestVPNDuration_RequiresIdempotencyKey(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: &fakeLedger{},
|
||||||
|
DB: db,
|
||||||
|
ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||||
|
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_RequiresTx(t *testing.T) {
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{Ledger: &fakeLedger{}})
|
||||||
|
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||||
|
t.Fatalf("expected tx error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stored := lottery.GrantLedger{
|
||||||
|
Id: 9,
|
||||||
|
ExternalRef: "lottery:100:200",
|
||||||
|
Payload: `{"message":"已加 3 天到订阅"}`,
|
||||||
|
}
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &stored, true, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||||
|
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger,
|
||||||
|
UserModel: fake,
|
||||||
|
DB: db,
|
||||||
|
ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if res.State != lottery.DispatchStateAutoClaimed {
|
||||||
|
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||||
|
t.Fatalf("expected stored message, got %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
},
|
||||||
|
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT UpdateSubscribe when no active sub")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback query returns no rows either
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if res.State != lottery.DispatchStateAutoClaimed {
|
||||||
|
t.Fatalf("expected auto_claimed even on skip, got %q", res.State)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||||
|
t.Fatalf("expected skip message, got %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
future := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||||
|
activeSub := &usermodel.Subscribe{
|
||||||
|
Id: 77,
|
||||||
|
UserId: 42,
|
||||||
|
ExpireTime: future,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5, ExternalRef: "lottery:100:200"}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalled := false
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||||
|
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||||
|
updateCalled = true
|
||||||
|
expected := future.Add(3 * 24 * time.Hour)
|
||||||
|
if !sub.ExpireTime.Equal(expected) {
|
||||||
|
t.Fatalf("expire time not stacked: got %s want %s", sub.ExpireTime, expected)
|
||||||
|
}
|
||||||
|
if sub.Status != 1 {
|
||||||
|
t.Fatalf("expected Status=1 after grant, got %d", sub.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if !updateCalled {
|
||||||
|
t.Fatalf("expected UpdateSubscribe to be called")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||||
|
t.Fatalf("unexpected message: %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_ExpiredSubscribeRestartsFromNow(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
past := time.Now().Add(-24 * time.Hour)
|
||||||
|
activeSub := &usermodel.Subscribe{
|
||||||
|
Id: 77,
|
||||||
|
ExpireTime: past,
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||||
|
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||||
|
delta := time.Until(sub.ExpireTime)
|
||||||
|
if delta < 3*24*time.Hour-5*time.Second || delta > 3*24*time.Hour+5*time.Second {
|
||||||
|
t.Fatalf("expected ~3 days from now, got %v", delta)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||||
|
IdempotencyKey: "k",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_NoLimitNotExtended(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
noLimit := time.UnixMilli(0)
|
||||||
|
activeSub := &usermodel.Subscribe{
|
||||||
|
Id: 77,
|
||||||
|
ExpireTime: noLimit,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||||
|
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||||
|
if !sub.ExpireTime.Equal(noLimit) {
|
||||||
|
t.Fatalf("no-limit ExpireTime must not be extended, got %v", sub.ExpireTime)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||||
|
IdempotencyKey: "k",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVPNDuration_BadConfigRejected(t *testing.T) {
|
||||||
|
db, _, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: &fakeLedger{},
|
||||||
|
DB: db,
|
||||||
|
ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config string
|
||||||
|
}{
|
||||||
|
{name: "invalid json", config: `{bad`},
|
||||||
|
{name: "zero days", config: `{"duration_days":0}`},
|
||||||
|
{name: "negative days", config: `{"duration_days":-1}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
Prize: lottery.Prize{Config: tt.config},
|
||||||
|
IdempotencyKey: "k",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVPNDuration_NoActiveSubscribeCreatesSubscription 覆盖“无活跃订阅 + 奖品配置了
|
||||||
|
// subscribe_id”时按该套餐新建订阅并发放时长的路径(问题2 的修复)。
|
||||||
|
func TestVPNDuration_NoActiveSubscribeCreatesSubscription(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
},
|
||||||
|
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT UpdateSubscribe when creating a new subscription")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) findActiveSubscribe 的 DB 回退查询 → 无行
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
// 2) 加载 subscribe 套餐计划
|
||||||
|
mock.ExpectQuery("FROM `subscribe`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
|
||||||
|
AddRow(int64(7), int64(1024), int64(3)))
|
||||||
|
// 3) 新建 user_subscribe
|
||||||
|
mock.ExpectExec("INSERT INTO `user_subscribe`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(555, 1))
|
||||||
|
// 4) 回写 ledger payload
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":7}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if res.State != lottery.DispatchStateAutoClaimed {
|
||||||
|
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||||
|
t.Fatalf("unexpected message: %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVPNDuration_NoActiveSubscribeNoPlanStillSkips 确认未配置 subscribe_id 时,
|
||||||
|
// 仍沿用旧的“跳过发放”行为(不新建订阅),保持向后兼容。
|
||||||
|
func TestVPNDuration_NoActiveSubscribeNoPlanStillSkips(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
},
|
||||||
|
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||||
|
t.Fatal("must NOT touch subscription when no plan configured")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":5}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||||
|
t.Fatalf("expected skip message, got %q", res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVPNDuration_NoActiveSubscribePlanNotFound 确认配置的 subscribe_id 不存在时,
|
||||||
|
// Dispatch 返回错误(让抽奖事务回滚),而不是静默成功。
|
||||||
|
func TestVPNDuration_NoActiveSubscribePlanNotFound(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHandlerTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ledger := &fakeLedger{
|
||||||
|
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||||
|
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake := &fakeUserModel{
|
||||||
|
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
},
|
||||||
|
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { return nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
mock.ExpectQuery("FROM `subscribe`").
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
|
||||||
|
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||||
|
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||||
|
})
|
||||||
|
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":999}`},
|
||||||
|
IdempotencyKey: "lottery:100:200",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when configured plan is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("expected 'not found' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Package hook contains lottery-side outbound integrations — hooks other flows
|
||||||
|
// (order activation, sign-in, etc.) call after they succeed to feed events into
|
||||||
|
// the lottery system.
|
||||||
|
//
|
||||||
|
// All hooks are fire-and-forget by contract: they run in their own goroutine so
|
||||||
|
// caller latency and error handling are unaffected. Hook failures are logged
|
||||||
|
// and dropped — an invite that fails to earn a lottery chance never blocks the
|
||||||
|
// order it was piggy-backing on.
|
||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InviteHook is fired by order/renewal activation when an invited user
|
||||||
|
// completes a payment. It grants lottery chances to the referer across every
|
||||||
|
// currently running activity that declares an "invite_success" chance source.
|
||||||
|
type InviteHook interface {
|
||||||
|
// OnConversion queues a background grant for referer. Returns immediately.
|
||||||
|
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
|
||||||
|
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
|
||||||
|
// value before the lottery system is wired in. Its OnConversion returns
|
||||||
|
// immediately without side effects — no goroutine, no log spam.
|
||||||
|
func NoopInviteHook() InviteHook { return noopInviteHook{} }
|
||||||
|
|
||||||
|
type noopInviteHook struct{}
|
||||||
|
|
||||||
|
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
|
||||||
|
|
||||||
|
// defaultInviteHook is the production implementation. It queries running
|
||||||
|
// activities on every call rather than caching them — the query is cheap
|
||||||
|
// (small table, indexed by status+time), and skipping the cache avoids stale
|
||||||
|
// reads when an activity is paused or its chance_sources are re-configured.
|
||||||
|
type defaultInviteHook struct {
|
||||||
|
db *gorm.DB
|
||||||
|
chance lottery.ChanceService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInviteHook builds the production invite hook.
|
||||||
|
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
|
||||||
|
if db == nil || chance == nil {
|
||||||
|
return NoopInviteHook()
|
||||||
|
}
|
||||||
|
return &defaultInviteHook{db: db, chance: chance}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConversion spawns a fire-and-forget goroutine that walks all running
|
||||||
|
// activities and calls ChanceService.Grant for each one that declares an
|
||||||
|
// invite_success source.
|
||||||
|
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
|
||||||
|
if refererUserID <= 0 || orderNo == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go h.run(refererUserID, orderNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
|
||||||
|
// Fresh context so the caller cancelling their goroutine does not abort us.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
activities, err := h.loadRunningActivities(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("referer_user_id", refererUserID),
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range activities {
|
||||||
|
activity := &activities[i]
|
||||||
|
grants := parseInviteGrantsFromSources(activity.ChanceSources)
|
||||||
|
for _, amount := range grants {
|
||||||
|
if amount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
|
||||||
|
// Prefix orderNo with "order:" so audit trails can tell business-order
|
||||||
|
// derived refs apart from other source families (manual_grant uses
|
||||||
|
// "manual:*", daily_signin uses "signin:*"). DB uniqueness is already
|
||||||
|
// bucketed by source, but the prefix makes log/analytics readable.
|
||||||
|
ref := "order:" + orderNo
|
||||||
|
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, amount); err != nil {
|
||||||
|
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("referer_user_id", refererUserID),
|
||||||
|
logger.Field("activity_id", activity.Id),
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
|
||||||
|
now := time.Now()
|
||||||
|
var activities []lottery.Activity
|
||||||
|
if err := h.db.WithContext(ctx).
|
||||||
|
Model(&lottery.Activity{}).
|
||||||
|
Where("status = ?", lottery.ActivityStatusRunning).
|
||||||
|
Where("start_at <= ? AND end_at >= ?", now, now).
|
||||||
|
Find(&activities).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
|
||||||
|
// activity and returns the per-conversion grant amount for each invite_success
|
||||||
|
// source (an activity may declare multiple, e.g. with different params by
|
||||||
|
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
|
||||||
|
func parseInviteGrantsFromSources(raw string) []int {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sources []lottery.ChanceSource
|
||||||
|
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
||||||
|
// Malformed configs skip silently — the activity is misconfigured, not
|
||||||
|
// a hook fault. Admin CRUD (PR C) will surface it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]int, 0, len(sources))
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
|
||||||
|
out = append(out, s.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHookTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("actual sql does not contain expected: " + expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
type chanceCall struct {
|
||||||
|
userId, activityId int64
|
||||||
|
source, sourceRef string
|
||||||
|
amount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeChanceService struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []chanceCall
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount})
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil }
|
||||||
|
|
||||||
|
func (f *fakeChanceService) recorded() []chanceCall {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]chanceCall, len(f.calls))
|
||||||
|
copy(out, f.calls)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopInviteHook_IsInert(t *testing.T) {
|
||||||
|
NoopInviteHook().OnConversion(context.Background(), 1, "ord")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) {
|
||||||
|
chance := &fakeChanceService{}
|
||||||
|
h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0
|
||||||
|
h.OnConversion(context.Background(), 0, "ord")
|
||||||
|
// No goroutine means no calls; give scheduler a beat and confirm empty.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if len(chance.recorded()) != 0 {
|
||||||
|
t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHookTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Activity 100: single invite_success source, amount=1
|
||||||
|
// Activity 200: two sources, only invite_success (amount=2) counts
|
||||||
|
// Activity 300: has invite_success amount=0 → skipped
|
||||||
|
mock.ExpectQuery("FROM `lottery_activity`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||||
|
AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||||
|
AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||||
|
AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||||
|
|
||||||
|
chance := &fakeChanceService{}
|
||||||
|
h := NewInviteHook(db, chance)
|
||||||
|
h.OnConversion(context.Background(), 42, "order-xyz")
|
||||||
|
|
||||||
|
// give the goroutine time to complete
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for len(chance.recorded()) < 2 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for grants; got %+v", chance.recorded())
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := chance.recorded()
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls)
|
||||||
|
}
|
||||||
|
byActivity := map[int64]int{}
|
||||||
|
for _, c := range calls {
|
||||||
|
if c.source != lottery.ChanceSourceInviteSuccess {
|
||||||
|
t.Fatalf("unexpected source: %+v", c)
|
||||||
|
}
|
||||||
|
if c.sourceRef != "order:order-xyz" {
|
||||||
|
t.Fatalf("expected orderNo prefixed as source_ref, got %q", c.sourceRef)
|
||||||
|
}
|
||||||
|
if c.userId != 42 {
|
||||||
|
t.Fatalf("expected referer=42, got %d", c.userId)
|
||||||
|
}
|
||||||
|
byActivity[c.activityId] = c.amount
|
||||||
|
}
|
||||||
|
if byActivity[100] != 1 || byActivity[200] != 2 {
|
||||||
|
t.Fatalf("wrong amounts: %+v", byActivity)
|
||||||
|
}
|
||||||
|
if _, exists := byActivity[300]; exists {
|
||||||
|
t.Fatalf("activity 300 has invite_success amount=0 and must be skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHookTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `lottery_activity`").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||||
|
AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||||
|
AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||||
|
|
||||||
|
chance := &fakeChanceService{}
|
||||||
|
h := NewInviteHook(db, chance)
|
||||||
|
h.OnConversion(context.Background(), 42, "order-1")
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for len(chance.recorded()) < 1 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out; got %+v", chance.recorded())
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the well-formed activity should have been granted; malformed skipped silently.
|
||||||
|
calls := chance.recorded()
|
||||||
|
if len(calls) != 1 || calls[0].activityId != 100 {
|
||||||
|
t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) {
|
||||||
|
db, mock, cleanup := newHookTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `lottery_activity`").
|
||||||
|
WillReturnError(errors.New("db down"))
|
||||||
|
|
||||||
|
chance := &fakeChanceService{}
|
||||||
|
h := NewInviteHook(db, chance)
|
||||||
|
h.OnConversion(context.Background(), 42, "ord")
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if len(chance.recorded()) != 0 {
|
||||||
|
t.Fatalf("expected no grants when query fails, got %+v", chance.recorded())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) {
|
||||||
|
got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`)
|
||||||
|
if len(got) != 1 || got[0] != 5 {
|
||||||
|
t.Fatalf("expected [5], got %v", got)
|
||||||
|
}
|
||||||
|
if got := parseInviteGrantsFromSources(""); got != nil {
|
||||||
|
t.Fatalf("empty string should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
if got := parseInviteGrantsFromSources(`{bad`); got != nil {
|
||||||
|
t.Fatalf("bad json should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package rulecaps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 抽奖门槛规则树的固定上限。恶意 admin 或误配可以让 PUT rules 的 JSON 递归
|
||||||
|
// 爆炸,评估时爆栈;这些常量给"合理配置"预留了充足空间,同时挡住 blob。
|
||||||
|
const (
|
||||||
|
// MaxDepth 是嵌套 AND/OR 允许的最大深度(根算 1 层)。
|
||||||
|
MaxDepth = 8
|
||||||
|
// MaxNodes 是整树里叶子 + 聚合节点总数上限。
|
||||||
|
MaxNodes = 64
|
||||||
|
// MaxBytes 是原始 JSON 字节数上限(8KB)。
|
||||||
|
MaxBytes = 8 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrRuleTreeTooDeep 表示 AND/OR 嵌套超过 MaxDepth。
|
||||||
|
var ErrRuleTreeTooDeep = errors.New("rule tree exceeds max depth")
|
||||||
|
|
||||||
|
// ErrRuleTreeTooManyNodes 表示节点总数超过 MaxNodes。
|
||||||
|
var ErrRuleTreeTooManyNodes = errors.New("rule tree exceeds max node count")
|
||||||
|
|
||||||
|
// ErrRuleTreeTooLarge 表示 JSON payload 超过 MaxBytes。
|
||||||
|
var ErrRuleTreeTooLarge = errors.New("rule tree JSON exceeds max byte size")
|
||||||
|
|
||||||
|
// ValidateEligibilityJSON 是 PUT /activities/{id}/rules 收到 eligibility JSON
|
||||||
|
// 时的准入闸门。三个上限任一超限 → 返回带上下文的错误,caller 直接 400。
|
||||||
|
// 空 JSON、"{}"、`null` 都视为合法(表示"无门槛")。
|
||||||
|
func ValidateEligibilityJSON(raw []byte) error {
|
||||||
|
if len(raw) > MaxBytes {
|
||||||
|
return fmt.Errorf("%w: %d bytes > %d limit", ErrRuleTreeTooLarge, len(raw), MaxBytes)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许 null / "{}" 表示无门槛。
|
||||||
|
trimmed := trimJSONWhitespace(raw)
|
||||||
|
if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == "{}" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tree lottery.EligibilityRule
|
||||||
|
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||||
|
return fmt.Errorf("invalid eligibility JSON: %w", err)
|
||||||
|
}
|
||||||
|
return validateRule(&tree, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRule 递归检查一棵规则树;depth 是当前节点所在层(根 = 1)。
|
||||||
|
// 用共享计数器(返回值)而不是外部 counter 是为了让递归签名保持无副作用。
|
||||||
|
func validateRule(node *lottery.EligibilityRule, depth int) error {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if depth > MaxDepth {
|
||||||
|
return fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||||
|
}
|
||||||
|
count, err := countAndValidate(node, depth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > MaxNodes {
|
||||||
|
return fmt.Errorf("%w: got %d nodes, max %d", ErrRuleTreeTooManyNodes, count, MaxNodes)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countAndValidate 深度优先遍历,一次递归同时统计节点数并做深度检查。
|
||||||
|
// 返回 count 是子树总节点数(含当前节点);err 表明遍历中已经超限。
|
||||||
|
func countAndValidate(node *lottery.EligibilityRule, depth int) (int, error) {
|
||||||
|
if node == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if depth > MaxDepth {
|
||||||
|
return 0, fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||||
|
}
|
||||||
|
total := 1
|
||||||
|
for _, child := range node.Children {
|
||||||
|
sub, err := countAndValidate(child, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total += sub
|
||||||
|
// 提前退出:命中节点数上限就不要继续 walk 剩余分支。
|
||||||
|
if total > MaxNodes {
|
||||||
|
return 0, fmt.Errorf("%w: got at least %d nodes, max %d", ErrRuleTreeTooManyNodes, total, MaxNodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimJSONWhitespace 剥掉前后 JSON 空白,用于识别"实质空"的 payload。
|
||||||
|
func trimJSONWhitespace(raw []byte) []byte {
|
||||||
|
i, j := 0, len(raw)
|
||||||
|
for i < j && isJSONWhitespace(raw[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for j > i && isJSONWhitespace(raw[j-1]) {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
return raw[i:j]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSONWhitespace(b byte) bool {
|
||||||
|
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package rulecaps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_AcceptsEmpty(t *testing.T) {
|
||||||
|
cases := [][]byte{
|
||||||
|
nil,
|
||||||
|
[]byte(""),
|
||||||
|
[]byte("{}"),
|
||||||
|
[]byte("null"),
|
||||||
|
[]byte(" \n \t "),
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := ValidateEligibilityJSON(c); err != nil {
|
||||||
|
t.Fatalf("expected accept for %q, got %v", string(c), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_RejectsOversizePayload(t *testing.T) {
|
||||||
|
blob := make([]byte, MaxBytes+1)
|
||||||
|
for i := range blob {
|
||||||
|
blob[i] = 'a'
|
||||||
|
}
|
||||||
|
err := ValidateEligibilityJSON(blob)
|
||||||
|
if !errors.Is(err, ErrRuleTreeTooLarge) {
|
||||||
|
t.Fatalf("expected ErrRuleTreeTooLarge, got %v", err)
|
||||||
|
}
|
||||||
|
// user-facing message should name the limit
|
||||||
|
if !strings.Contains(err.Error(), "8192") {
|
||||||
|
t.Fatalf("expected message to mention 8192 byte limit, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_RejectsBadJSON(t *testing.T) {
|
||||||
|
err := ValidateEligibilityJSON([]byte(`{bad`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeepTree 构造 depth 层单链嵌套(每层一个 OR 聚合)。root 为第 1 层。
|
||||||
|
func buildDeepTree(depth int) *lottery.EligibilityRule {
|
||||||
|
root := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||||
|
current := root
|
||||||
|
for i := 2; i < depth; i++ {
|
||||||
|
next := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||||
|
current.Children = []*lottery.EligibilityRule{next}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_RejectsDepthOverLimit(t *testing.T) {
|
||||||
|
tree := buildDeepTree(MaxDepth + 1) // depth 9 with defaults
|
||||||
|
raw, err := json.Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
err = ValidateEligibilityJSON(raw)
|
||||||
|
if !errors.Is(err, ErrRuleTreeTooDeep) {
|
||||||
|
t.Fatalf("expected ErrRuleTreeTooDeep, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_AcceptsMaxDepth(t *testing.T) {
|
||||||
|
tree := buildDeepTree(MaxDepth)
|
||||||
|
raw, _ := json.Marshal(tree)
|
||||||
|
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||||
|
t.Fatalf("depth=MaxDepth must be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWideTree 构造根节点 + N 个叶子,总节点数 = 1 + N。
|
||||||
|
func buildWideTree(leaves int) *lottery.EligibilityRule {
|
||||||
|
root := &lottery.EligibilityRule{Op: "AND"}
|
||||||
|
for i := 0; i < leaves; i++ {
|
||||||
|
root.Children = append(root.Children, &lottery.EligibilityRule{Type: "has_subscription"})
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_RejectsNodeCountOverLimit(t *testing.T) {
|
||||||
|
// 65 nodes total = 1 root + 64 leaves > MaxNodes
|
||||||
|
tree := buildWideTree(MaxNodes)
|
||||||
|
raw, _ := json.Marshal(tree)
|
||||||
|
err := ValidateEligibilityJSON(raw)
|
||||||
|
if !errors.Is(err, ErrRuleTreeTooManyNodes) {
|
||||||
|
t.Fatalf("expected ErrRuleTreeTooManyNodes, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_AcceptsAtNodeLimit(t *testing.T) {
|
||||||
|
// 64 nodes = 1 root + 63 leaves
|
||||||
|
tree := buildWideTree(MaxNodes - 1)
|
||||||
|
raw, _ := json.Marshal(tree)
|
||||||
|
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||||
|
t.Fatalf("nodes=MaxNodes must be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEligibilityJSON_AcceptsRealisticTree(t *testing.T) {
|
||||||
|
// Typical activity: (has_subscription AND invite_count>=3) OR user_tag in {vip}
|
||||||
|
raw := []byte(`{
|
||||||
|
"op": "OR",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"op": "AND",
|
||||||
|
"children": [
|
||||||
|
{"type": "has_subscription", "params": {"min_days_remaining": 7}},
|
||||||
|
{"type": "invite_count", "params": {"min": 3}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"type": "user_tag", "params": {"tags": ["vip"]}}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||||
|
t.Fatalf("realistic tree should be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
// Package lottery implements the user-side lottery HTTP endpoints:
|
||||||
|
//
|
||||||
|
// GET /api/v1/lottery/config
|
||||||
|
// POST /api/v1/lottery/draw
|
||||||
|
// GET /api/v1/lottery/records
|
||||||
|
// POST /api/v1/lottery/claim (Stage 2: submit manual claim data)
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||||
|
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。
|
||||||
|
// 匿名 / 未登录返回 0;handler 侧应当由 AuthMiddleware 已经拦截。
|
||||||
|
func currentUserId(ctx context.Context) int64 {
|
||||||
|
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||||
|
if !ok || u == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /config ------------------------------------------------------------
|
||||||
|
|
||||||
|
// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。
|
||||||
|
type QueryLotteryConfigLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic {
|
||||||
|
return &QueryLotteryConfigLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) {
|
||||||
|
userId := currentUserId(l.ctx)
|
||||||
|
if userId == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
activity, err := l.loadActivity(req.ActivityId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prizes, err := l.loadPrizes(req.ActivityId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId)
|
||||||
|
|
||||||
|
resp := &types.GetLotteryConfigResponse{
|
||||||
|
Activity: types.LotteryActivityConfig{
|
||||||
|
Id: activity.Id,
|
||||||
|
Title: activity.Title,
|
||||||
|
Description: activity.Description,
|
||||||
|
StartAt: activity.StartAt.Unix(),
|
||||||
|
EndAt: activity.EndAt.Unix(),
|
||||||
|
Status: activity.Status,
|
||||||
|
GridSize: activity.GridSize,
|
||||||
|
},
|
||||||
|
User: types.LotteryUserStatus{
|
||||||
|
ChancesRemaining: remaining,
|
||||||
|
// Eligible / UnmetReasons 需要 RuleContextBuilder;PR C 里 draw 路径
|
||||||
|
// 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到
|
||||||
|
// 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。
|
||||||
|
Eligible: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes))
|
||||||
|
for _, p := range prizes {
|
||||||
|
soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0
|
||||||
|
resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{
|
||||||
|
Slot: p.Slot,
|
||||||
|
Id: p.Id,
|
||||||
|
Type: p.Type,
|
||||||
|
Name: p.Name,
|
||||||
|
IconUrl: p.IconURL,
|
||||||
|
Config: json.RawMessage(defaultIfEmpty(p.Config)),
|
||||||
|
SoldOut: soldOut,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) {
|
||||||
|
var activity modelLottery.Activity
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if activity.Status == modelLottery.ActivityStatusEnded {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return &activity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) {
|
||||||
|
var prizes []modelLottery.Prize
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Where("activity_id = ?", activityId).
|
||||||
|
Order("slot ASC").
|
||||||
|
Find(&prizes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return prizes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(s string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POST /draw -------------------------------------------------------------
|
||||||
|
|
||||||
|
// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。
|
||||||
|
type DrawLotteryLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic {
|
||||||
|
return &DrawLotteryLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) {
|
||||||
|
userId := currentUserId(l.ctx)
|
||||||
|
if userId == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if l.svcCtx.LotteryDrawService == nil {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{
|
||||||
|
UserId: userId,
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
ClientNonce: req.ClientNonce,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := &types.DrawLotteryResponse{
|
||||||
|
DrawId: result.DrawId,
|
||||||
|
IsWin: result.IsWin,
|
||||||
|
ChancesRemaining: result.ChancesRemaining,
|
||||||
|
Claim: types.LotteryClaimStatus{
|
||||||
|
Required: result.Claim.Required,
|
||||||
|
AutoClaimed: result.Claim.AutoClaimed,
|
||||||
|
Message: result.Claim.Message,
|
||||||
|
ExpiresAt: result.Claim.ExpiresAt,
|
||||||
|
ClaimFormSchema: result.Claim.ClaimFormSchema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if result.Prize != nil {
|
||||||
|
resp.Prize = &types.DrawnPrize{
|
||||||
|
Slot: result.Prize.Slot,
|
||||||
|
Id: result.Prize.Id,
|
||||||
|
Type: result.Prize.Type,
|
||||||
|
Name: result.Prize.Name,
|
||||||
|
Config: result.Prize.Config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /records ----------------------------------------------------------
|
||||||
|
|
||||||
|
// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。
|
||||||
|
type QueryLotteryRecordsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic {
|
||||||
|
return &QueryLotteryRecordsLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) {
|
||||||
|
userId := currentUserId(l.ctx)
|
||||||
|
if userId == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Draw{}).
|
||||||
|
Where("user_id = ?", userId)
|
||||||
|
if req.ActivityId > 0 {
|
||||||
|
db = db.Where("activity_id = ?", req.ActivityId)
|
||||||
|
}
|
||||||
|
if state := recordStatusFilter(req.Status); state != "" {
|
||||||
|
switch state {
|
||||||
|
case "unclaimed":
|
||||||
|
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim)
|
||||||
|
case "paid":
|
||||||
|
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid)
|
||||||
|
case "expired":
|
||||||
|
db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var draws []modelLottery.Draw
|
||||||
|
if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
snapshots, err := l.loadPrizeSnapshots(draws)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, err := l.loadClaims(draws)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
|
||||||
|
for _, d := range draws {
|
||||||
|
record := types.LotteryRecord{
|
||||||
|
DrawId: d.Id,
|
||||||
|
ActivityId: d.ActivityId,
|
||||||
|
IsWin: d.IsWin,
|
||||||
|
DispatchState: d.DispatchState,
|
||||||
|
DrawnAt: d.DrawnAt.Unix(),
|
||||||
|
}
|
||||||
|
snap, hasSnap := snapshots[d.Id]
|
||||||
|
if hasSnap {
|
||||||
|
record.Prize = &types.DrawnPrize{
|
||||||
|
Slot: snap.Slot,
|
||||||
|
Id: snap.PrizeId,
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claim, ok := claims[d.Id]; ok {
|
||||||
|
record.Claim = l.buildRecordClaim(claim, snap, hasSnap)
|
||||||
|
}
|
||||||
|
resp.List = append(resp.List, record)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClaims 批量拉这一页里所有 draw 关联的 lottery_claim。人工奖 draw 一定有一行,
|
||||||
|
// 自动奖 draw / 谢谢参与不会有;缺失的 draw_id 直接不在 map 里,调用侧只做存在性判断。
|
||||||
|
func (l *QueryLotteryRecordsLogic) loadClaims(draws []modelLottery.Draw) (map[int64]modelLottery.Claim, error) {
|
||||||
|
if len(draws) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(draws))
|
||||||
|
for _, d := range draws {
|
||||||
|
ids = append(ids, d.Id)
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Claim
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.Claim, len(rows))
|
||||||
|
for _, c := range rows {
|
||||||
|
out[c.DrawId] = c
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRecordClaim 把 lottery_claim 组装成 GET /records 里的 Claim 字段。
|
||||||
|
// 状态允许再提交(pending_claim / rejected)时附带 ClaimFormSchema,
|
||||||
|
// 否则不再下发(避免前端误以为还能再填)。
|
||||||
|
func (l *QueryLotteryRecordsLogic) buildRecordClaim(claim modelLottery.Claim, snap modelLottery.PrizeSnapshot, hasSnap bool) *types.LotteryRecordClaim {
|
||||||
|
view := &types.LotteryRecordClaim{
|
||||||
|
Status: claim.Status,
|
||||||
|
ExpiresAt: claim.ExpiresAt.Unix(),
|
||||||
|
TxHash: claim.TxHash,
|
||||||
|
DeliveryRef: claim.DeliveryRef,
|
||||||
|
RejectReason: claim.RejectReason,
|
||||||
|
}
|
||||||
|
if claim.ClaimData != "" {
|
||||||
|
view.ClaimData = json.RawMessage(claim.ClaimData)
|
||||||
|
}
|
||||||
|
if claim.SubmittedAt != nil {
|
||||||
|
view.SubmittedAt = claim.SubmittedAt.Unix()
|
||||||
|
}
|
||||||
|
if claim.PaidAt != nil {
|
||||||
|
view.PaidAt = claim.PaidAt.Unix()
|
||||||
|
}
|
||||||
|
// 只在允许再提交状态下下发 schema。
|
||||||
|
if modelLottery.IsClaimStatusResubmittable(claim.Status) && l.svcCtx.LotteryRegistry != nil {
|
||||||
|
if h, ok := l.svcCtx.LotteryRegistry.Get(claim.PrizeType); ok {
|
||||||
|
if claim.PrizeType == modelLottery.PrizeTypeCrypto && hasSnap {
|
||||||
|
view.ClaimFormSchema = lotteryhandler.BuildCryptoClaimSchema(snap.Config)
|
||||||
|
} else {
|
||||||
|
view.ClaimFormSchema = h.ClaimSchema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
|
if len(draws) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(draws))
|
||||||
|
for _, d := range draws {
|
||||||
|
ids = append(ids, d.Id)
|
||||||
|
}
|
||||||
|
var snaps []modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&snaps).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||||
|
for _, s := range snaps {
|
||||||
|
out[s.DrawId] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordStatusFilter(s string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "", "all":
|
||||||
|
return ""
|
||||||
|
case "unclaimed", "paid", "expired":
|
||||||
|
return strings.ToLower(s)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POST /claim ----------------------------------------------------------
|
||||||
|
|
||||||
|
// ClaimLotteryPrizeLogic 是 Stage 2 人工奖领奖入口。
|
||||||
|
//
|
||||||
|
// 调用契约(错误码见 pkg/xerr):
|
||||||
|
//
|
||||||
|
// 4007 draw_not_found — 传入的 draw_id 不存在
|
||||||
|
// 4008 not_your_draw — draw 属于其他用户
|
||||||
|
// 4010 not_claimable — 该 draw 未中奖 / 自动奖 / 找不到 pending_claim
|
||||||
|
// 4009 claim_expired — pending_claim.expires_at 已过期
|
||||||
|
// 4005 already_submitted — 当前状态 (reviewing/paying/paid/expired) 禁止再提交
|
||||||
|
// 4006 invalid_claim_data — handler.ValidateClaim 校验失败
|
||||||
|
type ClaimLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic {
|
||||||
|
return &ClaimLotteryPrizeLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。
|
||||||
|
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||||
|
userId := currentUserId(l.ctx)
|
||||||
|
if userId == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if req.DrawId <= 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
claimData := selectClaimData(req)
|
||||||
|
|
||||||
|
// 1. 定位 draw + 归属校验(提前失败,避免暴露内部资源)
|
||||||
|
var draw modelLottery.Draw
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.DrawId).First(&draw).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryDrawNotFound)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if draw.UserId != userId {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryNotYourDraw)
|
||||||
|
}
|
||||||
|
if !draw.IsWin || draw.DispatchState != modelLottery.DispatchStatePendingClaim {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 抽奖时刻快照(用于 crypto network 二次校验)
|
||||||
|
var snap modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id = ?", draw.Id).First(&snap).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if !modelLottery.IsPrizeTypeManualClaim(snap.Type) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||||
|
}
|
||||||
|
handler, ok := l.svcCtx.LotteryRegistry.Get(snap.Type)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. handler 校验 body
|
||||||
|
if err := handler.ValidateClaim(claimData); err != nil {
|
||||||
|
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||||
|
}
|
||||||
|
if snap.Type == modelLottery.PrizeTypeCrypto {
|
||||||
|
if err := lotteryhandler.ValidateCryptoNetwork(claimData, snap.Config); err != nil {
|
||||||
|
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 事务内更新 claim(乐观锁:status IN (pending_claim, rejected) AND expires_at > now)
|
||||||
|
now := time.Now()
|
||||||
|
var response *types.ClaimLotteryPrizeResponse
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var claim modelLottery.Claim
|
||||||
|
if err := tx.Where("draw_id = ?", draw.Id).First(&claim).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if claim.ExpiresAt.Before(now) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimExpired)
|
||||||
|
}
|
||||||
|
if !modelLottery.IsClaimStatusResubmittable(claim.Status) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAS 更新:命中 status 白名单 + expires_at 未过期时才走。RowsAffected==0
|
||||||
|
// 视为并发拦截(另一个请求已经把状态推进了),报 4005 即可。
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status IN ? AND expires_at > ?",
|
||||||
|
claim.Id,
|
||||||
|
[]string{modelLottery.ClaimStatusPendingClaim, modelLottery.ClaimStatusRejected},
|
||||||
|
now).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"claim_data": string(claimData),
|
||||||
|
"status": modelLottery.ClaimStatusReviewing,
|
||||||
|
"submitted_at": now,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||||
|
}
|
||||||
|
response = &types.ClaimLotteryPrizeResponse{
|
||||||
|
Status: modelLottery.ClaimStatusReviewing,
|
||||||
|
SubmittedAt: now.Unix(),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectClaimData 兼容 ClaimData(首选)与 Input(历史字段名)。
|
||||||
|
func selectClaimData(req *types.ClaimLotteryPrizeRequest) []byte {
|
||||||
|
if len(req.ClaimData) > 0 {
|
||||||
|
return req.ClaimData
|
||||||
|
}
|
||||||
|
return req.Input
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// lottery_stage2_test.go — Stage 2 相关的纯函数单测。
|
||||||
|
// 用户 API 主流程(POST /claim)走 DB 事务 + auth middleware,集成在 QA 脚本里跑;
|
||||||
|
// 这里只补 handler / helper 层的纯逻辑分支。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectClaimData_PreferClaimDataOverInput(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
req types.ClaimLotteryPrizeRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"claim_data set", types.ClaimLotteryPrizeRequest{ClaimData: []byte(`{"a":1}`), Input: []byte(`{"b":2}`)}, `{"a":1}`},
|
||||||
|
{"only input", types.ClaimLotteryPrizeRequest{Input: []byte(`{"b":2}`)}, `{"b":2}`},
|
||||||
|
{"neither", types.ClaimLotteryPrizeRequest{}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := string(selectClaimData(&tc.req))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("selectClaimData = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordStatusFilter_KnownStates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"all", ""},
|
||||||
|
{"unclaimed", "unclaimed"},
|
||||||
|
{"paid", "paid"},
|
||||||
|
{"expired", "expired"},
|
||||||
|
{"unknown", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := recordStatusFilter(tc.in); got != tc.want {
|
||||||
|
t.Errorf("recordStatusFilter(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/simnet"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -20,16 +21,18 @@ import (
|
|||||||
|
|
||||||
type QueryUserSubscribeNodeListLogic struct {
|
type QueryUserSubscribeNodeListLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user subscribe node info
|
// Get user subscribe node info
|
||||||
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic {
|
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext, userAgent string) *QueryUserSubscribeNodeListLogic {
|
||||||
return &QueryUserSubscribeNodeListLogic{
|
return &QueryUserSubscribeNodeListLogic{
|
||||||
Logger: logger.WithContext(ctx),
|
Logger: logger.WithContext(ctx),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svcCtx: svcCtx,
|
svcCtx: svcCtx,
|
||||||
|
userAgent: userAgent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,12 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
|||||||
if server == nil {
|
if server == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Hide experimental protocols (simnet) — and their server material in
|
||||||
|
// the raw protocols JSON — from non first-party clients. Mirrors the
|
||||||
|
// Pro reference FilterExperimentalNodesForClient.
|
||||||
|
if simnet.IsExperimentalProtocol(n.Protocol) && !simnet.ClientSupportsExperimental(l.userAgent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
userSubscribeNode := &types.UserSubscribeNodeInfo{
|
userSubscribeNode := &types.UserSubscribeNodeInfo{
|
||||||
Id: n.Id,
|
Id: n.Id,
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
|
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
|
||||||
// TODO: remove in future versions
|
// TODO: remove in future versions
|
||||||
Hysteria2 = "hysteria2"
|
Hysteria2 = "hysteria2"
|
||||||
|
Simnet = "simnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
|
|||||||
@@ -234,6 +234,22 @@ func (l *GetServerConfigLogic) compatible(config node.Protocol) map[string]inter
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Simnet:
|
||||||
|
// Simnet ships its whole normalized protocol as the node runtime config
|
||||||
|
// (server PSK key_id=0, path, carrier, TLS, AF, fallback, reverse, resource
|
||||||
|
// limits), matching the Pro reference (compat_legacy.go simnet case).
|
||||||
|
config.NormalizeSimnet()
|
||||||
|
// Config snapshot log — non-sensitive fields only (never log the PSK).
|
||||||
|
l.Infow("[GetServerConfig] simnet runtime config",
|
||||||
|
logger.Field("port", config.Port),
|
||||||
|
logger.Field("path", config.SimnetPath),
|
||||||
|
logger.Field("carrier", config.SimnetCarrier),
|
||||||
|
logger.Field("security", config.Security),
|
||||||
|
logger.Field("af_enabled", config.SimnetAfEnabled),
|
||||||
|
logger.Field("fallback_enabled", config.SimnetFallbackEnabled),
|
||||||
|
)
|
||||||
|
result = config
|
||||||
|
|
||||||
}
|
}
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
s, _ := json.Marshal(result)
|
s, _ := json.Marshal(result)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func TestNormalizeServerUserListProtocol(t *testing.T) {
|
|||||||
{"tuic unchanged", "tuic", "tuic"},
|
{"tuic unchanged", "tuic", "tuic"},
|
||||||
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
|
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
|
||||||
{"anytls unchanged", "anytls", "anytls"},
|
{"anytls unchanged", "anytls", "anytls"},
|
||||||
|
{"simnet unchanged", "simnet", "simnet"},
|
||||||
{"empty unchanged", "", ""},
|
{"empty unchanged", "", ""},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package subscribe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
|
"github.com/perfect-panel/server/pkg/simnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// filterExperimentalNodesForClient removes experimental-protocol nodes (simnet)
|
||||||
|
// unless the client UA is a first-party client. Prevents generic clients from
|
||||||
|
// rendering unusable simnet entries and from receiving simnet server material.
|
||||||
|
// Keyword logic is shared via pkg/simnet (mirrors the Pro reference).
|
||||||
|
func filterExperimentalNodesForClient(servers []*node.Node, userAgent string) []*node.Node {
|
||||||
|
if simnet.ClientSupportsExperimental(userAgent) {
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
filtered := make([]*node.Node, 0, len(servers))
|
||||||
|
for _, n := range servers {
|
||||||
|
if n == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if simnet.IsExperimentalProtocol(n.Protocol) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, n)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/adapter"
|
"github.com/perfect-panel/server/adapter"
|
||||||
|
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/client"
|
"github.com/perfect-panel/server/internal/model/client"
|
||||||
"github.com/perfect-panel/server/internal/model/group"
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
@@ -79,6 +80,10 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
|||||||
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token))
|
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if _, err := logiccommon.ResolveEnabledUser(l.ctx.Request.Context(), l.svc, userSubscribe.UserId); err != nil {
|
||||||
|
l.Errorw("[SubscribeLogic] User disabled", logger.Field("error", err.Error()), logger.Field("userId", userSubscribe.UserId))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var subscribeStatus = false
|
var subscribeStatus = false
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -96,6 +101,11 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Experimental protocols (simnet) are only delivered to their own clients/SDK
|
||||||
|
// (UA hits omnxt/slag/slaglab). Hide them from every other client so a
|
||||||
|
// generic template never renders a broken/unusable node. Mirrors the Pro
|
||||||
|
// reference FilterExperimentalNodesForClient.
|
||||||
|
servers = filterExperimentalNodesForClient(servers, userAgent)
|
||||||
a := adapter.NewAdapter(
|
a := adapter.NewAdapter(
|
||||||
targetApp.SubscribeTemplate,
|
targetApp.SubscribeTemplate,
|
||||||
adapter.WithServers(servers),
|
adapter.WithServers(servers),
|
||||||
@@ -104,6 +114,7 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
|||||||
adapter.WithOutputFormat(targetApp.OutputFormat),
|
adapter.WithOutputFormat(targetApp.OutputFormat),
|
||||||
adapter.WithUserInfo(adapter.User{
|
adapter.WithUserInfo(adapter.User{
|
||||||
Password: userSubscribe.UUID,
|
Password: userSubscribe.UUID,
|
||||||
|
SubscribeID: userSubscribe.Id,
|
||||||
ExpiredAt: userSubscribe.ExpireTime,
|
ExpiredAt: userSubscribe.ExpireTime,
|
||||||
Download: userSubscribe.Download,
|
Download: userSubscribe.Download,
|
||||||
Upload: userSubscribe.Upload,
|
Upload: userSubscribe.Upload,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminMetaMiddleware pins the request's client IP and User-Agent onto the
|
||||||
|
// context under constant.CtxKeyIP / constant.CtxKeyUserAgent so downstream
|
||||||
|
// audit writers (admin_action_log) can capture them without threading the
|
||||||
|
// gin.Context through every logic layer.
|
||||||
|
//
|
||||||
|
// This middleware is a no-op for auth: it does NOT gate access; wire it
|
||||||
|
// after AuthMiddleware so ctx.Value(CtxKeyUser) is already populated by the
|
||||||
|
// time an admin logic writes audit rows.
|
||||||
|
func AdminMetaMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyIP, c.ClientIP())
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, c.Request.UserAgent())
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminMetaMiddleware_PopulatesCtx asserts the middleware pins ClientIP
|
||||||
|
// and User-Agent onto the request context under the typed constant keys, so
|
||||||
|
// downstream audit writers can pick them up without gin.Context threading.
|
||||||
|
func TestAdminMetaMiddleware_PopulatesCtx(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var (
|
||||||
|
gotIP string
|
||||||
|
gotUA string
|
||||||
|
)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(AdminMetaMiddleware())
|
||||||
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
gotIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||||
|
gotUA, _ = ctx.Value(constant.CtxKeyUserAgent).(string)
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.RemoteAddr = "10.99.99.7:54321"
|
||||||
|
req.Header.Set("User-Agent", "qa-audit-probe")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if gotUA != "qa-audit-probe" {
|
||||||
|
t.Fatalf("user_agent = %q, want %q", gotUA, "qa-audit-probe")
|
||||||
|
}
|
||||||
|
// Gin resolves ClientIP() from RemoteAddr when no forwarded headers are
|
||||||
|
// trusted. It strips the port, so we assert the exact host we set.
|
||||||
|
if gotIP != "10.99.99.7" {
|
||||||
|
t.Fatalf("ip = %q, want %q", gotIP, "10.99.99.7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminMetaMiddleware_UsesTypedKey guards against the regression that
|
||||||
|
// motivated PR D: reader and writer must share the typed CtxKey, not a bare
|
||||||
|
// string. A ctx.Value("ip") lookup (bare string) MUST miss even though the
|
||||||
|
// typed CtxKey "ip" is present.
|
||||||
|
func TestAdminMetaMiddleware_UsesTypedKey(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var (
|
||||||
|
typedIP string
|
||||||
|
bareStrIP any
|
||||||
|
)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(AdminMetaMiddleware())
|
||||||
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
typedIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||||
|
bareStrIP = ctx.Value("ip") // bare string key — must MISS
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.RemoteAddr = "10.1.2.3:80"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if typedIP == "" {
|
||||||
|
t.Fatalf("typed CtxKeyIP lookup must succeed")
|
||||||
|
}
|
||||||
|
if bareStrIP != nil {
|
||||||
|
t.Fatalf("bare-string \"ip\" lookup MUST miss (got %v); F2 regression risk", bareStrIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -87,10 +88,10 @@ func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string,
|
|||||||
|
|
||||||
svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second)
|
svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second)
|
||||||
|
|
||||||
userInfo, err := svc.UserModel.FindOne(c, userId)
|
userInfo, err := logiccommon.ResolveEnabledUser(c, svc, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId))
|
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId))
|
||||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error"))
|
result.HttpResult(c, nil, err)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ func PanDomainMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
l := subscribe.NewSubscribeLogic(c, svc)
|
l := subscribe.NewSubscribeLogic(c, svc)
|
||||||
resp, err := l.Handler(&request)
|
resp, err := l.Handler(&request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
result.HttpResult(c, nil, err)
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("subscription-userinfo", resp.Header)
|
c.Header("subscription-userinfo", resp.Header)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const (
|
|||||||
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
||||||
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
||||||
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
|
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
|
||||||
|
CommissionTypeLottery uint16 = 339 // 抽奖奖励(PR B: 与 Purchase/Renewal 区分,便于对账)
|
||||||
GiftTypeIncrease uint16 = 341 // Increase
|
GiftTypeIncrease uint16 = 341 // Increase
|
||||||
GiftTypeReduce uint16 = 342 // Reduce
|
GiftTypeReduce uint16 = 342 // Reduce
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chanceService 是 ChanceService 的默认实现。
|
||||||
|
type chanceService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。
|
||||||
|
func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} }
|
||||||
|
|
||||||
|
// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。
|
||||||
|
// 幂等策略:
|
||||||
|
// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突
|
||||||
|
// 2. 冲突视为"已发过",直接返回 nil 不重复发放
|
||||||
|
// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining
|
||||||
|
//
|
||||||
|
// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账,
|
||||||
|
// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。
|
||||||
|
func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
grant := ChanceGrant{
|
||||||
|
UserId: userId,
|
||||||
|
ActivityId: activityId,
|
||||||
|
Source: source,
|
||||||
|
SourceRef: sourceRef,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
// OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。
|
||||||
|
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
// 幂等命中:已经发过,balance 不动。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未冲突 → 累加余额(upsert balance 行)。
|
||||||
|
balance := ChanceBalance{
|
||||||
|
UserId: userId,
|
||||||
|
ActivityId: activityId,
|
||||||
|
Remaining: int64(amount),
|
||||||
|
TotalEarned: int64(amount),
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount),
|
||||||
|
"total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount),
|
||||||
|
}),
|
||||||
|
}).Create(&balance).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。
|
||||||
|
// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。
|
||||||
|
func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return 0, errors.New("Consume requires a transaction handle")
|
||||||
|
}
|
||||||
|
var balance ChanceBalance
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||||
|
First(&balance).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, ErrNoChances
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if balance.Remaining <= 0 {
|
||||||
|
return 0, ErrNoChances
|
||||||
|
}
|
||||||
|
// 累加 spent,扣减 remaining,一条 SQL 完成。
|
||||||
|
updateErr := tx.WithContext(ctx).
|
||||||
|
Model(&ChanceBalance{}).
|
||||||
|
Where("id = ? AND remaining > 0", balance.Id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"remaining": gorm.Expr("`remaining` - 1"),
|
||||||
|
"total_spent": gorm.Expr("`total_spent` + 1"),
|
||||||
|
}).Error
|
||||||
|
if updateErr != nil {
|
||||||
|
return 0, updateErr
|
||||||
|
}
|
||||||
|
return balance.Remaining - 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。
|
||||||
|
func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) {
|
||||||
|
var balance ChanceBalance
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||||
|
First(&balance).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if balance.Remaining < 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return balance.Remaining, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil {
|
||||||
|
t.Fatalf("Grant(amount=0) unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("no queries expected, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
// INSERT lottery_chance_grant,未冲突返回 1 行
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
// UPSERT lottery_chance_balance
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_chance_balance`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||||
|
t.Fatalf("Grant: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
// INSERT lottery_chance_grant,UNIQUE 冲突 → 0 行影响
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
// balance 不应被触发
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||||
|
t.Fatalf("Grant: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Consume_LocksAndDecrements(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||||
|
WithArgs(int64(42), int64(100), 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||||
|
AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1)))
|
||||||
|
mock.ExpectExec("UPDATE `lottery_chance_balance`").
|
||||||
|
WithArgs(sqlmock.AnyArg(), int64(9)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
var remaining int64
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var e error
|
||||||
|
remaining, e = svc.Consume(context.Background(), tx, 42, 100)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Consume: %v", err)
|
||||||
|
}
|
||||||
|
if remaining != 1 {
|
||||||
|
t.Fatalf("expected remaining=1, got %d", remaining)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||||
|
WithArgs(int64(42), int64(100), 1).
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrNoChances) {
|
||||||
|
t.Fatalf("expected ErrNoChances, got %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||||
|
WithArgs(int64(42), int64(100), 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||||
|
AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3)))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrNoChances) {
|
||||||
|
t.Fatalf("expected ErrNoChances when remaining=0, got %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Consume_RequiresTx(t *testing.T) {
|
||||||
|
db, _, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil {
|
||||||
|
t.Fatalf("expected error when tx is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||||
|
WithArgs(int64(42), int64(100), 1).
|
||||||
|
WillReturnError(gorm.ErrRecordNotFound)
|
||||||
|
|
||||||
|
svc := NewChanceService(db)
|
||||||
|
got, err := svc.Query(context.Background(), 42, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query: %v", err)
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
t.Fatalf("expected 0 when no row, got %d", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GrantLedger 是发奖账本一行。UNIQUE(external_ref) 是幂等键的载体:
|
||||||
|
// 每次 PrizeHandler.Dispatch 用 DispatchRequest.IdempotencyKey 作 external_ref,
|
||||||
|
// INSERT 冲突即"已发过",直接返回持久化的原结果。
|
||||||
|
type GrantLedger struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
ExternalRef string `gorm:"type:varchar(128);not null;uniqueIndex:uk_external_ref;comment:幂等键"`
|
||||||
|
HandlerType string `gorm:"type:varchar(32);not null;comment:handler 类型"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:发放对象用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
DrawId int64 `gorm:"type:bigint unsigned;not null;comment:抽奖记录 ID"`
|
||||||
|
Amount int64 `gorm:"type:bigint;not null;default:0;comment:发放数量"`
|
||||||
|
Payload string `gorm:"type:json;comment:发放后的关键结果快照"`
|
||||||
|
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:发放完成时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 对齐 02157 migration。
|
||||||
|
func (GrantLedger) TableName() string { return "lottery_grant_ledger" }
|
||||||
|
|
||||||
|
// LedgerService 处理发奖账本的幂等 upsert。所有 handler 的第一步都是它。
|
||||||
|
type LedgerService interface {
|
||||||
|
// Reserve 尝试为 external_ref 抢占一行账本。
|
||||||
|
// - 未冲突 → 返回新建行,caller 继续调用下游业务;提交事务时账本一起落。
|
||||||
|
// - 冲突 → 返回已存在的账本行,caller 视为幂等命中直接返回。
|
||||||
|
// 传入 tx 必须是 caller 的事务句柄,保证账本行随抽奖事务一起提交。
|
||||||
|
Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (row *GrantLedger, alreadyExisted bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ledgerService struct{}
|
||||||
|
|
||||||
|
// NewLedgerService 返回默认账本服务。
|
||||||
|
func NewLedgerService() LedgerService { return &ledgerService{} }
|
||||||
|
|
||||||
|
// Reserve 用 INSERT ... ON CONFLICT DO NOTHING 抢占 external_ref。
|
||||||
|
// 未命中时再走一次 SELECT 拿到实际持久化的行(不管是新插的还是旧的),
|
||||||
|
// 目的是让 caller 拿到统一的 GrantLedger 结构,方便回写 draw 状态。
|
||||||
|
func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (*GrantLedger, bool, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, false, errors.New("Reserve requires a transaction handle")
|
||||||
|
}
|
||||||
|
if entry.ExternalRef == "" {
|
||||||
|
return nil, false, errors.New("Reserve requires a non-empty ExternalRef")
|
||||||
|
}
|
||||||
|
// Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||||
|
// handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶
|
||||||
|
// 段的空 payload 用 "{}" 兜底,与 PrizeSnapshot.Config、
|
||||||
|
// EligibilitySnapshot.UnmetReasons 的守卫对称。
|
||||||
|
if entry.Payload == "" {
|
||||||
|
entry.Payload = "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
insertRes := tx.WithContext(ctx).
|
||||||
|
Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&entry)
|
||||||
|
if insertRes.Error != nil {
|
||||||
|
return nil, false, insertRes.Error
|
||||||
|
}
|
||||||
|
alreadyExisted := insertRes.RowsAffected == 0
|
||||||
|
|
||||||
|
// 读回持久化的行,避免依赖 gorm 的 AutoIncrement 回填在冲突分支不确定的行为。
|
||||||
|
var stored GrantLedger
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Where("external_ref = ?", entry.ExternalRef).
|
||||||
|
First(&stored).Error; err != nil {
|
||||||
|
return nil, alreadyExisted, err
|
||||||
|
}
|
||||||
|
return &stored, alreadyExisted, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewLedgerService()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||||
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||||
|
WithArgs("lottery:100:200", 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||||
|
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||||
|
ExternalRef: "lottery:100:200",
|
||||||
|
HandlerType: "vpn_duration",
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Amount: 3,
|
||||||
|
})
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if existed {
|
||||||
|
t.Fatalf("expected not existed")
|
||||||
|
}
|
||||||
|
if row.Id != 7 {
|
||||||
|
t.Fatalf("expected reloaded id=7, got %d", row.Id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tx: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewLedgerService()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
|
||||||
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||||
|
WithArgs("lottery:100:200", 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||||
|
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||||
|
ExternalRef: "lottery:100:200",
|
||||||
|
HandlerType: "vpn_duration",
|
||||||
|
})
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if !existed {
|
||||||
|
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
|
||||||
|
}
|
||||||
|
if row.Id != 9 {
|
||||||
|
t.Fatalf("expected stored id=9, got %d", row.Id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tx: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
|
||||||
|
svc := NewLedgerService()
|
||||||
|
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
|
||||||
|
t.Fatalf("expected error when tx is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
|
||||||
|
db, _, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewLedgerService()
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on empty ExternalRef")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReserve_EmptyPayloadDefaultsToEmptyJSONObject is the F6 regression guard.
|
||||||
|
//
|
||||||
|
// Before PR F, Reserve created lottery_grant_ledger rows with the caller's
|
||||||
|
// empty entry.Payload written verbatim ("") into the `payload` JSON column —
|
||||||
|
// MySQL error 3140 rejects empty strings on JSON columns, so every real
|
||||||
|
// draw's ledger INSERT died. sqlmock does no JSON validation so the earlier
|
||||||
|
// tests were silent about it.
|
||||||
|
//
|
||||||
|
// Guard the exact Go-layer value we send by asserting the INSERT arg for
|
||||||
|
// `payload` is "{}" (never ""). This is the same pattern as PR E's
|
||||||
|
// EligibilitySnapshot.UnmetReasons guard.
|
||||||
|
func TestReserve_EmptyPayloadDefaultsToEmptyJSONObject(t *testing.T) {
|
||||||
|
db, mock, cleanup := newLotteryTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// GORM omits granted_at from the INSERT column list because it has
|
||||||
|
// `<-:create;default:CURRENT_TIMESTAMP` — 7 args, not 8. Column order:
|
||||||
|
// external_ref, handler_type, user_id, activity_id, draw_id, amount, payload.
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||||
|
WithArgs(
|
||||||
|
sqlmock.AnyArg(), // external_ref
|
||||||
|
sqlmock.AnyArg(), // handler_type
|
||||||
|
sqlmock.AnyArg(), // user_id
|
||||||
|
sqlmock.AnyArg(), // activity_id
|
||||||
|
sqlmock.AnyArg(), // draw_id
|
||||||
|
sqlmock.AnyArg(), // amount
|
||||||
|
payloadNotEmptyString{t}, // MUST be "{}", never ""
|
||||||
|
).
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||||
|
WithArgs("lottery:100:200", 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref"}).AddRow(int64(1), "lottery:100:200"))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
svc := NewLedgerService()
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Intentionally leave Payload empty — the guard must default it.
|
||||||
|
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||||
|
ExternalRef: "lottery:100:200",
|
||||||
|
HandlerType: "vpn_duration",
|
||||||
|
UserId: 42,
|
||||||
|
ActivityId: 100,
|
||||||
|
DrawId: 200,
|
||||||
|
Amount: 3,
|
||||||
|
})
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reserve: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadNotEmptyString is a per-arg matcher: the value MUST be a non-empty
|
||||||
|
// string; specifically "{}" per the PR F guard. Empty string is the exact F6
|
||||||
|
// regression symptom (MySQL error 3140).
|
||||||
|
type payloadNotEmptyString struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (m payloadNotEmptyString) Match(v driver.Value) bool {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
m.t.Fatalf("F6 guard: expected string for Payload, got %T (%v)", v, v)
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
m.t.Fatalf("F6 regression: Payload must not be empty string (MySQL error 3140)")
|
||||||
|
}
|
||||||
|
if s != "{}" {
|
||||||
|
m.t.Fatalf("F6 guard: expected Payload==%q, got %q", "{}", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 状态与类型常量 ---------------------------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActivityStatusDraft = "draft"
|
||||||
|
ActivityStatusRunning = "running"
|
||||||
|
ActivityStatusPaused = "paused"
|
||||||
|
ActivityStatusEnded = "ended"
|
||||||
|
|
||||||
|
UnmetActionBlock = "block"
|
||||||
|
UnmetActionShowReason = "show_reason"
|
||||||
|
|
||||||
|
// PrizeType* 是发奖 handler 注册表的键。
|
||||||
|
// Stage 1 已实装:vpn_duration / commission / none。
|
||||||
|
// Stage 2 新增人工奖:crypto / physical / manual_other。
|
||||||
|
PrizeTypeVPNDuration = "vpn_duration"
|
||||||
|
PrizeTypeCommission = "commission"
|
||||||
|
PrizeTypeNone = "none"
|
||||||
|
// Stage 2 人工奖类型(HIF-4)。
|
||||||
|
PrizeTypeCrypto = "crypto"
|
||||||
|
PrizeTypePhysical = "physical"
|
||||||
|
PrizeTypeManualOther = "manual_other"
|
||||||
|
// Stage 3 预留。
|
||||||
|
PrizeTypeBalance = "balance"
|
||||||
|
PrizeTypeGiftAmount = "gift_amount"
|
||||||
|
PrizeTypeCoupon = "coupon"
|
||||||
|
PrizeTypePoints = "points"
|
||||||
|
|
||||||
|
// ChanceSource* 是次数入账触发源。
|
||||||
|
ChanceSourceDailySignin = "daily_signin"
|
||||||
|
ChanceSourceNewSubscription = "new_subscription"
|
||||||
|
ChanceSourceInviteSuccess = "invite_success"
|
||||||
|
ChanceSourceManualGrant = "manual_grant"
|
||||||
|
|
||||||
|
// DispatchState* 是 lottery_draw.dispatch_state 的取值。
|
||||||
|
DispatchStateNone = "none"
|
||||||
|
DispatchStateAutoClaimed = "auto_claimed"
|
||||||
|
DispatchStatePendingClaim = "pending_claim"
|
||||||
|
DispatchStatePaid = "paid"
|
||||||
|
DispatchStateExpired = "expired"
|
||||||
|
DispatchStateFailed = "failed"
|
||||||
|
|
||||||
|
// ClaimStatus* 是 lottery_claim.status 的取值(Stage 2)。
|
||||||
|
// pending_claim: 已入库,等用户填领奖信息。
|
||||||
|
// reviewing: 用户已提交,等运营审核。
|
||||||
|
// paying: 运营 approve,等运营线下打款/发货 + mark-paid。
|
||||||
|
// paid: 运营已录入 tx_hash / delivery_ref。终态。
|
||||||
|
// rejected: 运营 reject(可为 reviewing → rejected 或 paying → rejected);用户可再次提交。
|
||||||
|
// expired: pending_claim 超时未提交(业务规则:过期不补次数)。终态。
|
||||||
|
ClaimStatusPendingClaim = "pending_claim"
|
||||||
|
ClaimStatusReviewing = "reviewing"
|
||||||
|
ClaimStatusPaying = "paying"
|
||||||
|
ClaimStatusPaid = "paid"
|
||||||
|
ClaimStatusRejected = "rejected"
|
||||||
|
ClaimStatusExpired = "expired"
|
||||||
|
|
||||||
|
// DefaultClaimTTLHours 是 Stage 2 spec 里"默认 7 天"的实际编码:
|
||||||
|
// 每个奖品可通过 config.claim_ttl_hours 覆盖单个奖品的过期窗口。
|
||||||
|
DefaultClaimTTLHours = 24 * 7
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 实体 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
Title string `gorm:"type:varchar(128);not null;default:'';comment:活动标题"`
|
||||||
|
Description string `gorm:"type:text;comment:活动描述"`
|
||||||
|
StartAt time.Time `gorm:"not null;comment:开始时间"`
|
||||||
|
EndAt time.Time `gorm:"not null;comment:结束时间"`
|
||||||
|
Status string `gorm:"type:varchar(16);not null;default:'draft';comment:状态"`
|
||||||
|
GridSize int `gorm:"type:tinyint;not null;default:8;comment:九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格)"`
|
||||||
|
Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"`
|
||||||
|
ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"`
|
||||||
|
UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Activity) TableName() string { return "lottery_activity" }
|
||||||
|
|
||||||
|
type Prize struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:所属活动"`
|
||||||
|
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
|
||||||
|
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
|
||||||
|
Name string `gorm:"type:varchar(128);not null;default:'';comment:名称"`
|
||||||
|
IconURL string `gorm:"type:varchar(512);not null;default:'';comment:图标 URL"`
|
||||||
|
Config string `gorm:"type:json;not null;comment:类型专属配置"`
|
||||||
|
Weight int `gorm:"type:int;not null;default:0;comment:加权随机权重"`
|
||||||
|
TotalStock sql.NullInt64 `gorm:"type:bigint;comment:总库存(NULL=无限)"`
|
||||||
|
RemainingStock sql.NullInt64 `gorm:"type:bigint;comment:剩余库存(NULL=无限)"`
|
||||||
|
IsFallback bool `gorm:"type:tinyint(1);not null;default:0;comment:是否为保底奖"`
|
||||||
|
Version int64 `gorm:"type:bigint;not null;default:0;comment:乐观锁"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Prize) TableName() string { return "lottery_prize" }
|
||||||
|
|
||||||
|
type ChanceBalance struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
Remaining int64 `gorm:"type:bigint;not null;default:0;comment:剩余次数"`
|
||||||
|
TotalEarned int64 `gorm:"type:bigint;not null;default:0;comment:累计入账"`
|
||||||
|
TotalSpent int64 `gorm:"type:bigint;not null;default:0;comment:累计消耗"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChanceBalance) TableName() string { return "lottery_chance_balance" }
|
||||||
|
|
||||||
|
type ChanceGrant struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
Source string `gorm:"type:varchar(32);not null;comment:触发源"`
|
||||||
|
SourceRef string `gorm:"type:varchar(128);not null;comment:外部业务幂等键"`
|
||||||
|
Amount int `gorm:"type:int;not null;default:0;comment:本次发放次数"`
|
||||||
|
ExpiresAt *time.Time `gorm:"default:null;comment:到期时间"`
|
||||||
|
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:入账时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChanceGrant) TableName() string { return "lottery_chance_grant" }
|
||||||
|
|
||||||
|
type Draw struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
ClientNonce string `gorm:"type:varchar(64);not null;comment:前端幂等键"`
|
||||||
|
PrizeId sql.NullInt64 `gorm:"type:bigint unsigned;comment:中奖奖品 ID(谢谢参与=NULL)"`
|
||||||
|
IsWin bool `gorm:"type:tinyint(1);not null;default:0;comment:是否中奖"`
|
||||||
|
DispatchState string `gorm:"type:varchar(16);not null;default:'none';comment:发放状态"`
|
||||||
|
DispatchError string `gorm:"type:text;comment:发放失败原因"`
|
||||||
|
DispatchedAt *time.Time `gorm:"default:null;comment:发放完成时间"`
|
||||||
|
DrawnAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:抽奖时间"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Draw) TableName() string { return "lottery_draw" }
|
||||||
|
|
||||||
|
type PrizeSnapshot struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||||
|
PrizeId int64 `gorm:"type:bigint unsigned;not null;comment:奖品 ID"`
|
||||||
|
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
|
||||||
|
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
|
||||||
|
Name string `gorm:"type:varchar(128);not null;comment:奖品名称"`
|
||||||
|
Config string `gorm:"type:json;not null;comment:类型专属配置"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PrizeSnapshot) TableName() string { return "lottery_prize_snapshot" }
|
||||||
|
|
||||||
|
type EligibilitySnapshot struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
Passed bool `gorm:"type:tinyint(1);not null;default:0;comment:是否通过门槛"`
|
||||||
|
UnmetReasons string `gorm:"type:json;comment:未通过项"`
|
||||||
|
EvaluatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:评估时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
|
||||||
|
|
||||||
|
// Claim 是 Stage 2 的人工奖领奖工单。lottery_draw ↔ lottery_claim 一对一
|
||||||
|
// (由 lottery_claim.draw_id UNIQUE 保证)。
|
||||||
|
//
|
||||||
|
// 生命周期:抽奖事务命中人工类奖品 → 同事务插入一行 status=pending_claim;
|
||||||
|
// 用户 POST /claim → 转 reviewing;运营 approve → paying → mark-paid → paid。
|
||||||
|
// 详细状态机见 02159_lottery_claim.up.sql 的注释。
|
||||||
|
type Claim struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||||
|
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||||
|
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||||
|
PrizeType string `gorm:"type:varchar(32);not null;comment:奖品类型(冗余便于后台过滤)"`
|
||||||
|
ClaimData string `gorm:"type:json;comment:用户提交的领奖表单(结构随 prize_type 变化)"`
|
||||||
|
Status string `gorm:"type:varchar(32);not null;default:'pending_claim';comment:状态"`
|
||||||
|
SubmittedAt *time.Time `gorm:"default:null;comment:用户提交领奖信息时间"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;comment:领奖窗口截止时间"`
|
||||||
|
ReviewedBy int64 `gorm:"type:bigint unsigned;default:0;comment:最近一次审核操作者"`
|
||||||
|
ReviewedAt *time.Time `gorm:"default:null;comment:最近一次审核时间"`
|
||||||
|
RejectReason string `gorm:"type:varchar(512);not null;default:'';comment:拒绝原因"`
|
||||||
|
TxHash string `gorm:"type:varchar(128);not null;default:'';comment:链上交易哈希"`
|
||||||
|
DeliveryRef string `gorm:"type:varchar(128);not null;default:'';comment:快递单号 / 发货单据编号"`
|
||||||
|
PaidAt *time.Time `gorm:"default:null;comment:运营标记打款/发货完成时间"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 对齐 02159 migration。
|
||||||
|
func (Claim) TableName() string { return "lottery_claim" }
|
||||||
|
|
||||||
|
// IsClaimStatusResubmittable 判断当前 claim 状态是否允许用户再次提交领奖数据。
|
||||||
|
// pending_claim(还没提交过)与 rejected(被运营拒绝后允许重填)算入。
|
||||||
|
// 其他状态(reviewing / paying / paid / expired)都禁止再提交。
|
||||||
|
func IsClaimStatusResubmittable(status string) bool {
|
||||||
|
return status == ClaimStatusPendingClaim || status == ClaimStatusRejected
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrizeTypeManualClaim 判断某奖品类型是否属于"人工领奖"类别,
|
||||||
|
// 即 draw 时需要挂 lottery_claim 而不是自动发放。
|
||||||
|
func IsPrizeTypeManualClaim(prizeType string) bool {
|
||||||
|
switch prizeType {
|
||||||
|
case PrizeTypeCrypto, PrizeTypePhysical, PrizeTypeManualOther:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON 结构体辅助 -------------------------------------------------------
|
||||||
|
|
||||||
|
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
|
||||||
|
// 每个节点要么是"叶子"(Type 非空),要么是"聚合"(Op 为 AND/OR + Children)。
|
||||||
|
type EligibilityRule struct {
|
||||||
|
Op string `json:"op,omitempty"`
|
||||||
|
Children []*EligibilityRule `json:"children,omitempty"`
|
||||||
|
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChanceSource 描述次数来源的一条配置,持久化在 lottery_activity.chance_sources。
|
||||||
|
type ChanceSource struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
DailyLimit int `json:"daily_limit,omitempty"`
|
||||||
|
Unlimited bool `json:"unlimited,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmetReason 是门槛评估结果,作为 GET /config 的 unmet_reasons 元素返回。
|
||||||
|
type UnmetReason struct {
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Current int64 `json:"current,omitempty"`
|
||||||
|
Required int64 `json:"required,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。
|
||||||
|
type defaultRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
handlers map[string]PrizeHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
|
||||||
|
// 实际业务 handler(vpn_duration / commission)在 internal/logic/lottery/handler 里,
|
||||||
|
// 由 initialize 阶段注入(避免 model 层反向依赖 logic 层)。
|
||||||
|
func NewRegistry() *defaultRegistry {
|
||||||
|
return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 挂接一个 handler;重复注册会覆盖(初始化阶段可控,不额外拦截)。
|
||||||
|
func (r *defaultRegistry) Register(h PrizeHandler) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.handlers[h.Type()] = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *defaultRegistry) Get(prizeType string) (PrizeHandler, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
h, ok := r.handlers[prizeType]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) {
|
||||||
|
if h, ok := r.Get(prizeType); ok {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
return nil, ErrHandlerNotRegistered
|
||||||
|
}
|
||||||
|
|
||||||
|
// noopHandler 是"谢谢参与",永远归 model 层:不依赖任何业务,最小占位。
|
||||||
|
type noopHandler struct{}
|
||||||
|
|
||||||
|
// NewNoopHandler 返回 PrizeTypeNone 的 handler。
|
||||||
|
func NewNoopHandler() PrizeHandler { return &noopHandler{} }
|
||||||
|
|
||||||
|
func (noopHandler) Type() string { return PrizeTypeNone }
|
||||||
|
func (noopHandler) IsAuto() bool { return true }
|
||||||
|
func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
|
||||||
|
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
||||||
|
}
|
||||||
|
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
|
||||||
|
func (noopHandler) ClaimSchema() json.RawMessage { return nil }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistry_GetMissing(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, ok := r.Get("nope"); ok {
|
||||||
|
t.Fatalf("empty registry should not return handler")
|
||||||
|
}
|
||||||
|
if _, err := r.MustGet("nope"); !errors.Is(err, ErrHandlerNotRegistered) {
|
||||||
|
t.Fatalf("MustGet on missing type should return ErrHandlerNotRegistered, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register(NewNoopHandler())
|
||||||
|
|
||||||
|
h, err := r.MustGet(PrizeTypeNone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MustGet(%q): %v", PrizeTypeNone, err)
|
||||||
|
}
|
||||||
|
if h.Type() != PrizeTypeNone {
|
||||||
|
t.Fatalf("Type mismatch: want %q got %q", PrizeTypeNone, h.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) {
|
||||||
|
h := NewNoopHandler()
|
||||||
|
if !h.IsAuto() {
|
||||||
|
t.Fatal("noop must be auto")
|
||||||
|
}
|
||||||
|
res, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("noop dispatch err: %v", err)
|
||||||
|
}
|
||||||
|
if res.State != DispatchStateAutoClaimed {
|
||||||
|
t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 门槛规则类型常量。前端展示时可依据这些 key 组装本地化提示。
|
||||||
|
const (
|
||||||
|
RuleTypeHasSubscription = "has_subscription" // 需要有活跃订阅(可带 min_days_remaining)
|
||||||
|
RuleTypeSubscriptionType = "subscription_type" // 订阅套餐必须在 plan_ids 中
|
||||||
|
RuleTypeInviteCount = "invite_count" // 邀请人数 ≥ min(可带 window_days,由 ContextBuilder 预算)
|
||||||
|
RuleTypeTotalRecharge = "total_recharge" // 累计充值 ≥ min_usdt
|
||||||
|
RuleTypeRegisterDays = "register_days" // 注册天数 ≥ min
|
||||||
|
RuleTypeUserTag = "user_tag" // 用户标签命中 tags[] 中任一
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpAND = "AND"
|
||||||
|
OpOR = "OR"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultEvaluator 是 RuleEvaluator 的开箱实现。
|
||||||
|
type defaultEvaluator struct{}
|
||||||
|
|
||||||
|
// NewRuleEvaluator 返回默认门槛评估器。
|
||||||
|
func NewRuleEvaluator() RuleEvaluator { return &defaultEvaluator{} }
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) Evaluate(_ context.Context, tree *EligibilityRule, rc RuleContext) (bool, []UnmetReason, error) {
|
||||||
|
if tree == nil {
|
||||||
|
return true, nil, nil
|
||||||
|
}
|
||||||
|
unmet := make([]UnmetReason, 0, 4)
|
||||||
|
passed, err := e.evalNode(tree, rc, &unmet)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
return passed, unmet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalNode(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
if node == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// 聚合节点
|
||||||
|
if node.Op != "" {
|
||||||
|
return e.evalGroup(node, rc, unmet)
|
||||||
|
}
|
||||||
|
// 叶子节点
|
||||||
|
if node.Type == "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return e.evalLeaf(node, rc, unmet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalGroup(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
op := strings.ToUpper(node.Op)
|
||||||
|
if len(node.Children) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case OpAND:
|
||||||
|
allPassed := true
|
||||||
|
for _, child := range node.Children {
|
||||||
|
ok, err := e.evalNode(child, rc, unmet)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
allPassed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allPassed, nil
|
||||||
|
case OpOR:
|
||||||
|
// OR 只收集内部未通过项到一个临时篮子;若整体通过则不冒泡出去。
|
||||||
|
anyPassed := false
|
||||||
|
local := make([]UnmetReason, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
ok, err := e.evalNode(child, rc, &local)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
anyPassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyPassed {
|
||||||
|
*unmet = append(*unmet, local...)
|
||||||
|
}
|
||||||
|
return anyPassed, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown group op %q", node.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalLeaf(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
switch node.Type {
|
||||||
|
case RuleTypeHasSubscription:
|
||||||
|
return e.evalHasSubscription(node, rc, unmet)
|
||||||
|
case RuleTypeSubscriptionType:
|
||||||
|
return e.evalSubscriptionType(node, rc, unmet)
|
||||||
|
case RuleTypeInviteCount:
|
||||||
|
return e.evalInviteCount(node, rc, unmet)
|
||||||
|
case RuleTypeTotalRecharge:
|
||||||
|
return e.evalTotalRecharge(node, rc, unmet)
|
||||||
|
case RuleTypeRegisterDays:
|
||||||
|
return e.evalRegisterDays(node, rc, unmet)
|
||||||
|
case RuleTypeUserTag:
|
||||||
|
return e.evalUserTag(node, rc, unmet)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown leaf rule type %q", node.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 单条规则 -------------------------------------------------------------
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalHasSubscription(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
MinDaysRemaining int64 `json:"min_days_remaining"`
|
||||||
|
}
|
||||||
|
if len(node.Params) > 0 {
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("has_subscription params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rc.HasActiveSubscription {
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeHasSubscription,
|
||||||
|
Hint: "需要有活跃订阅",
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if params.MinDaysRemaining > 0 {
|
||||||
|
got := rc.SubscriptionExpiresIn / 86400
|
||||||
|
if got < params.MinDaysRemaining {
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeHasSubscription,
|
||||||
|
Hint: fmt.Sprintf("订阅剩余天数不足,还差 %d 天", params.MinDaysRemaining-got),
|
||||||
|
Current: got,
|
||||||
|
Required: params.MinDaysRemaining,
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalSubscriptionType(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
PlanIds []int64 `json:"plan_ids"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("subscription_type params: %w", err)
|
||||||
|
}
|
||||||
|
want := make(map[int64]struct{}, len(params.PlanIds))
|
||||||
|
for _, id := range params.PlanIds {
|
||||||
|
want[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range rc.SubscriptionPlanIds {
|
||||||
|
if _, ok := want[id]; ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeSubscriptionType,
|
||||||
|
Hint: "订阅类型不符合活动要求",
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalInviteCount(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
Min int64 `json:"min"`
|
||||||
|
WindowDays int64 `json:"window_days,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("invite_count params: %w", err)
|
||||||
|
}
|
||||||
|
if rc.InviteCount >= params.Min {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeInviteCount,
|
||||||
|
Hint: fmt.Sprintf("还需邀请 %d 人(%d/%d)", params.Min-rc.InviteCount, rc.InviteCount, params.Min),
|
||||||
|
Current: rc.InviteCount,
|
||||||
|
Required: params.Min,
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalTotalRecharge(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
MinUSDT int64 `json:"min_usdt"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("total_recharge params: %w", err)
|
||||||
|
}
|
||||||
|
if rc.TotalRechargeUSDT >= params.MinUSDT {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeTotalRecharge,
|
||||||
|
Hint: fmt.Sprintf("累计充值不足,还差 %d USDT", params.MinUSDT-rc.TotalRechargeUSDT),
|
||||||
|
Current: rc.TotalRechargeUSDT,
|
||||||
|
Required: params.MinUSDT,
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalRegisterDays(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
Min int64 `json:"min"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("register_days params: %w", err)
|
||||||
|
}
|
||||||
|
if rc.RegisterDays >= params.Min {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeRegisterDays,
|
||||||
|
Hint: fmt.Sprintf("注册天数不足,还差 %d 天", params.Min-rc.RegisterDays),
|
||||||
|
Current: rc.RegisterDays,
|
||||||
|
Required: params.Min,
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *defaultEvaluator) evalUserTag(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||||
|
var params struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||||
|
return false, fmt.Errorf("user_tag params: %w", err)
|
||||||
|
}
|
||||||
|
want := make(map[string]struct{}, len(params.Tags))
|
||||||
|
for _, t := range params.Tags {
|
||||||
|
want[t] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, t := range rc.UserTags {
|
||||||
|
if _, ok := want[t]; ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*unmet = append(*unmet, UnmetReason{
|
||||||
|
Rule: RuleTypeUserTag,
|
||||||
|
Hint: "用户标签不符合活动要求",
|
||||||
|
})
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustParams(t *testing.T, v any) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal params: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_HasSubscription(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Type: RuleTypeHasSubscription,
|
||||||
|
Params: mustParams(t, map[string]int64{"min_days_remaining": 7}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没订阅
|
||||||
|
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval err: %v", err)
|
||||||
|
}
|
||||||
|
if ok || len(unmet) != 1 || unmet[0].Rule != RuleTypeHasSubscription {
|
||||||
|
t.Fatalf("expected fail on no sub, got ok=%v unmet=%+v", ok, unmet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有订阅但剩余不足
|
||||||
|
ok, unmet, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
HasActiveSubscription: true,
|
||||||
|
SubscriptionExpiresIn: 3 * 86400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval err: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected fail on 3 days < 7 required, got pass")
|
||||||
|
}
|
||||||
|
if len(unmet) != 1 || unmet[0].Required != 7 || unmet[0].Current != 3 {
|
||||||
|
t.Fatalf("unmet mismatch: %+v", unmet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有订阅剩余足够
|
||||||
|
ok, _, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
HasActiveSubscription: true,
|
||||||
|
SubscriptionExpiresIn: 30 * 86400,
|
||||||
|
})
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("expected pass on 30 days, got ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_InviteCount(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Type: RuleTypeInviteCount,
|
||||||
|
Params: mustParams(t, map[string]int64{"min": 3}),
|
||||||
|
}
|
||||||
|
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 1})
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Fatalf("expected fail, got ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if unmet[0].Current != 1 || unmet[0].Required != 3 {
|
||||||
|
t.Fatalf("expected current=1 required=3, got %+v", unmet[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 3})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected pass with InviteCount=3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_ANDShort(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Op: OpAND,
|
||||||
|
Children: []*EligibilityRule{
|
||||||
|
{Type: RuleTypeHasSubscription},
|
||||||
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
HasActiveSubscription: true,
|
||||||
|
InviteCount: 1,
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected AND to fail")
|
||||||
|
}
|
||||||
|
// AND 需要收集全部未通过项(这里只有 1 条)
|
||||||
|
if len(unmet) != 1 || unmet[0].Rule != RuleTypeInviteCount {
|
||||||
|
t.Fatalf("expected 1 unmet (invite_count), got %+v", unmet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_ORPassIgnoresChildUnmet(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Op: OpOR,
|
||||||
|
Children: []*EligibilityRule{
|
||||||
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 100})},
|
||||||
|
{Type: RuleTypeHasSubscription}, // 会通过
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
HasActiveSubscription: true,
|
||||||
|
InviteCount: 0,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected OR to pass because one child passes")
|
||||||
|
}
|
||||||
|
if len(unmet) != 0 {
|
||||||
|
t.Fatalf("OR pass should hide child failures, got %+v", unmet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_ORFailBubblesAllChildren(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Op: OpOR,
|
||||||
|
Children: []*EligibilityRule{
|
||||||
|
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
||||||
|
{Type: RuleTypeHasSubscription},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
InviteCount: 1,
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected OR to fail")
|
||||||
|
}
|
||||||
|
if len(unmet) != 2 {
|
||||||
|
t.Fatalf("expected 2 unmet reasons on OR fail, got %+v", unmet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_SubscriptionType(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Type: RuleTypeSubscriptionType,
|
||||||
|
Params: mustParams(t, map[string][]int64{"plan_ids": {10, 20}}),
|
||||||
|
}
|
||||||
|
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
SubscriptionPlanIds: []int64{20},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected pass when user plan matches")
|
||||||
|
}
|
||||||
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||||
|
SubscriptionPlanIds: []int64{99},
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected fail when user plan not in whitelist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_UserTag(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{
|
||||||
|
Type: RuleTypeUserTag,
|
||||||
|
Params: mustParams(t, map[string][]string{"tags": {"vip", "beta"}}),
|
||||||
|
}
|
||||||
|
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"beta"}})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected pass when any tag matches")
|
||||||
|
}
|
||||||
|
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"foo"}})
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected fail when no tags match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_NilTreeAlwaysPasses(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
ok, unmet, err := ev.Evaluate(context.Background(), nil, RuleContext{})
|
||||||
|
if err != nil || !ok || len(unmet) != 0 {
|
||||||
|
t.Fatalf("nil tree should always pass; got ok=%v unmet=%+v err=%v", ok, unmet, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluator_UnknownRuleType(t *testing.T) {
|
||||||
|
ev := NewRuleEvaluator()
|
||||||
|
tree := &EligibilityRule{Type: "no_such_rule"}
|
||||||
|
_, _, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on unknown rule type")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、
|
||||||
|
// 加权随机选奖、发奖 handler 抽象。
|
||||||
|
//
|
||||||
|
// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、
|
||||||
|
// 邀请钩子)留到架构师 review 骨架 PR 之后再补。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 门槛规则引擎 ----------------------------------------------------------
|
||||||
|
|
||||||
|
// RuleContext 是评估门槛规则时可见的用户上下文。构造时应做一次批量
|
||||||
|
// 读,避免每条子规则各自查库。
|
||||||
|
type RuleContext struct {
|
||||||
|
UserId int64
|
||||||
|
Now int64 // Unix seconds
|
||||||
|
|
||||||
|
// 下列字段由 RuleContextBuilder 填充。规则实现只读,不写。
|
||||||
|
HasActiveSubscription bool
|
||||||
|
SubscriptionExpiresIn int64 // seconds until expiry; 0 if no active sub
|
||||||
|
SubscriptionPlanIds []int64
|
||||||
|
InviteCount int64 // 若规则限定 window_days,则调用方需自行按窗口预计算
|
||||||
|
TotalRechargeUSDT int64 // 已充值总额,单位与业务侧一致
|
||||||
|
RegisterDays int64
|
||||||
|
UserTags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleEvaluator 评估一棵门槛规则树,返回是否通过 + 未通过项。
|
||||||
|
// 实现是纯计算,不做任何 DB 写。
|
||||||
|
type RuleEvaluator interface {
|
||||||
|
Evaluate(ctx context.Context, tree *EligibilityRule, rc RuleContext) (passed bool, unmet []UnmetReason, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 加权随机选奖 ----------------------------------------------------------
|
||||||
|
|
||||||
|
// WeightedPicker 从奖池中按 weight 加权抽一次。实现应使用注入的 rand 源,
|
||||||
|
// 便于测试;weight 为 0 的奖品视作不参与随机(可用于挂出但不发放)。
|
||||||
|
type WeightedPicker interface {
|
||||||
|
// Pick 从 candidates 中返回一个索引 i;若累计权重为 0 返回 ErrEmptyPool。
|
||||||
|
// 调用方在事务外先做快照,事务内再对返回的 candidates[i] 做库存乐观扣减。
|
||||||
|
Pick(candidates []Prize) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrEmptyPool 表示奖池累计权重为 0,无法完成一次随机。
|
||||||
|
var ErrEmptyPool = errors.New("lottery: empty prize pool")
|
||||||
|
|
||||||
|
// ---- 次数入账 / 消耗 -------------------------------------------------------
|
||||||
|
|
||||||
|
// ChanceService 处理"用户抽奖次数账户",为触发源提供幂等入账、为抽奖流程提供
|
||||||
|
// 事务内原子扣减。
|
||||||
|
type ChanceService interface {
|
||||||
|
// Grant 记录一次次数入账;幂等键 = (activityId, source, sourceRef)。
|
||||||
|
// 已存在的 sourceRef 视为幂等命中,返回 nil 不重复发放。
|
||||||
|
Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error
|
||||||
|
|
||||||
|
// Consume 在事务内扣减一次次数(SELECT ... FOR UPDATE 锁 chance_balance),
|
||||||
|
// 返回扣减后剩余次数。剩余为 0 时返回 ErrNoChances。
|
||||||
|
Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (remaining int64, err error)
|
||||||
|
|
||||||
|
// Query 返回用户当前剩余次数(非事务,用于 GET /config 展示)。
|
||||||
|
Query(ctx context.Context, userId, activityId int64) (remaining int64, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoChances 表示用户在该活动下已无剩余抽奖次数。
|
||||||
|
var ErrNoChances = errors.New("lottery: no chances remaining")
|
||||||
|
|
||||||
|
// ---- 发奖 Handler ---------------------------------------------------------
|
||||||
|
|
||||||
|
// DispatchRequest 是 PrizeHandler.Dispatch 的输入。事务由调用方开启并传入,
|
||||||
|
// handler 在同 tx 内完成外部账本写入 + 业务侧发放。
|
||||||
|
type DispatchRequest struct {
|
||||||
|
UserId int64
|
||||||
|
ActivityId int64
|
||||||
|
DrawId int64
|
||||||
|
Prize Prize // 当前奖品(含 Config JSON)
|
||||||
|
Snapshot PrizeSnapshot // 抽奖时刻快照
|
||||||
|
// IdempotencyKey 是 lottery_grant_ledger.external_ref 的最终值。
|
||||||
|
// 调用方(抽奖服务)负责生成,惯例 = fmt.Sprintf("lottery:%d:%d", ActivityId, DrawId)。
|
||||||
|
// 每次 Dispatch 用同一 IdempotencyKey 重试 → handler 命中 ledger UNIQUE 返回原结果。
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchResult 是发奖结果。
|
||||||
|
type DispatchResult struct {
|
||||||
|
// State 会写入 lottery_draw.dispatch_state。
|
||||||
|
State string
|
||||||
|
// Message 供前端展示("已加到订阅"/"佣金到账 3 USDT" 等)。
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
|
||||||
|
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
|
||||||
|
// IsAuto=true 的三种,Stage 2 补齐 crypto/physical/manual_other)。
|
||||||
|
//
|
||||||
|
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write,
|
||||||
|
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
|
||||||
|
type PrizeHandler interface {
|
||||||
|
Type() string
|
||||||
|
IsAuto() bool
|
||||||
|
// Dispatch 在调用方的事务内执行;返回结果或错误。
|
||||||
|
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
|
||||||
|
//
|
||||||
|
// 对 IsAuto()=false 的人工奖 handler,Dispatch 不会被抽奖服务调用;
|
||||||
|
// 实现返回 ErrDispatchNotSupported 即可。
|
||||||
|
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
|
||||||
|
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);auto handler
|
||||||
|
// 直接返回 nil 即可(默认 noopHandler / vpn_duration / commission 都不用)。
|
||||||
|
ValidateClaim(raw []byte) error
|
||||||
|
// ClaimSchema 返回该奖品的领奖表单 JSON Schema(Stage 2 才用)。
|
||||||
|
// - auto handler 返回 nil(前端拿到 nil / null 就知道不用弹表单)。
|
||||||
|
// - 人工奖 handler 返回一段合法 JSON Schema,前端据此动态渲染表单。
|
||||||
|
ClaimSchema() json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrDispatchNotSupported 是 IsAuto()=false handler 的 Dispatch 占位错误:
|
||||||
|
// 抽奖服务命中人工奖时不应调用 Dispatch,理论上永远不会返回给用户,仅供
|
||||||
|
// 单测断言与防御性编程使用。
|
||||||
|
var ErrDispatchNotSupported = errors.New("lottery: dispatch not supported for manual claim handler")
|
||||||
|
|
||||||
|
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
|
||||||
|
// 若不慎命中真实 handler 会立即失败,避免误发。
|
||||||
|
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
|
||||||
|
|
||||||
|
// Registry 是 type → PrizeHandler 的路由表。抽奖服务只依赖此接口,
|
||||||
|
// 具体 handler 由 initialize 阶段注入。
|
||||||
|
type Registry interface {
|
||||||
|
Get(prizeType string) (PrizeHandler, bool)
|
||||||
|
// MustGet 在类型未注册时返回 ErrHandlerNotRegistered。
|
||||||
|
MustGet(prizeType string) (PrizeHandler, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrHandlerNotRegistered 表示奖品类型没有对应 handler。
|
||||||
|
var ErrHandlerNotRegistered = errors.New("lottery: prize handler not registered")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightedPicker 是 WeightedPicker 的默认实现。使用累计权重 O(log n) 二分选取。
|
||||||
|
// 注入的 rand.Source 允许测试固定种子。
|
||||||
|
type weightedPicker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rng *rand.Rand
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWeightedPicker 返回默认加权选取器。传 seed=0 使用当前时间纳秒。
|
||||||
|
func NewWeightedPicker(seed int64) WeightedPicker {
|
||||||
|
if seed == 0 {
|
||||||
|
seed = time.Now().UnixNano()
|
||||||
|
}
|
||||||
|
return &weightedPicker{
|
||||||
|
rng: rand.New(rand.NewSource(seed)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick 从 candidates 中返回一个索引。以下奖品不参与随机:
|
||||||
|
// - 权重 <= 0
|
||||||
|
// - is_fallback=true 的保底奖(只在限量奖售罄时兜底发放,绝不能被随机抽中,
|
||||||
|
// 否则真实奖品会被“谢谢参与”类保底项挤占)
|
||||||
|
//
|
||||||
|
// 累计权重为 0(无任何可抽奖品)返回 ErrEmptyPool。
|
||||||
|
//
|
||||||
|
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
|
||||||
|
// 不需要预分配,并对小池(<20 项)足够快。
|
||||||
|
func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return 0, ErrEmptyPool
|
||||||
|
}
|
||||||
|
// eligible 判定:非保底 && 权重为正,才计入随机池。
|
||||||
|
eligible := func(c Prize) bool { return !c.IsFallback && c.Weight > 0 }
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
for _, c := range candidates {
|
||||||
|
if eligible(c) {
|
||||||
|
total += int64(c.Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return 0, ErrEmptyPool
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
roll := p.rng.Int63n(total)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var cum int64
|
||||||
|
for i, c := range candidates {
|
||||||
|
if !eligible(c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cum += int64(c.Weight)
|
||||||
|
if roll < cum {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 走到这里说明浮点/累加异常,回退到最后一个参与随机的项。
|
||||||
|
for i := len(candidates) - 1; i >= 0; i-- {
|
||||||
|
if eligible(candidates[i]) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ErrEmptyPool
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeightedPicker_EmptyPool(t *testing.T) {
|
||||||
|
p := NewWeightedPicker(42)
|
||||||
|
if _, err := p.Pick(nil); err != ErrEmptyPool {
|
||||||
|
t.Fatalf("expected ErrEmptyPool for nil pool, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.Pick([]Prize{{Weight: 0}, {Weight: 0}}); err != ErrEmptyPool {
|
||||||
|
t.Fatalf("expected ErrEmptyPool when all weights are 0, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedPicker_SkipsZeroWeight(t *testing.T) {
|
||||||
|
p := NewWeightedPicker(1)
|
||||||
|
pool := []Prize{
|
||||||
|
{Id: 1, Weight: 0}, // 不参与
|
||||||
|
{Id: 2, Weight: 100}, // 独占权重
|
||||||
|
{Id: 3, Weight: 0}, // 不参与
|
||||||
|
}
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
idx, err := p.Pick(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick err: %v", err)
|
||||||
|
}
|
||||||
|
if idx != 1 {
|
||||||
|
t.Fatalf("expected idx 1 (only positive weight), got %d", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
|
||||||
|
p := NewWeightedPicker(2026)
|
||||||
|
pool := []Prize{
|
||||||
|
{Id: 10, Weight: 10}, // 10/60 ≈ 16.67%
|
||||||
|
{Id: 20, Weight: 20}, // 20/60 ≈ 33.33%
|
||||||
|
{Id: 30, Weight: 30}, // 30/60 = 50%
|
||||||
|
}
|
||||||
|
const trials = 60000
|
||||||
|
counts := make(map[int]int)
|
||||||
|
for i := 0; i < trials; i++ {
|
||||||
|
idx, err := p.Pick(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick err: %v", err)
|
||||||
|
}
|
||||||
|
counts[idx]++
|
||||||
|
}
|
||||||
|
// 允许 ±2% 偏差
|
||||||
|
expect := []float64{10.0 / 60, 20.0 / 60, 30.0 / 60}
|
||||||
|
for i, e := range expect {
|
||||||
|
got := float64(counts[i]) / float64(trials)
|
||||||
|
if got < e-0.02 || got > e+0.02 {
|
||||||
|
t.Fatalf("prize %d: expected %.4f (±0.02), got %.4f", i, e, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedPicker_ExcludesFallback(t *testing.T) {
|
||||||
|
p := NewWeightedPicker(3)
|
||||||
|
pool := []Prize{
|
||||||
|
{Id: 1, Weight: 100, IsFallback: true}, // 保底奖:即便权重很高也绝不被随机抽中
|
||||||
|
{Id: 2, Weight: 5, Type: PrizeTypeVPNDuration}, // 唯一可抽真实奖品
|
||||||
|
}
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
idx, err := p.Pick(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick err: %v", err)
|
||||||
|
}
|
||||||
|
if idx != 1 {
|
||||||
|
t.Fatalf("fallback prize must never be picked; expected idx 1, got %d", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedPicker_AllFallbackYieldsEmptyPool(t *testing.T) {
|
||||||
|
p := NewWeightedPicker(9)
|
||||||
|
pool := []Prize{
|
||||||
|
{Id: 1, Weight: 10, IsFallback: true},
|
||||||
|
{Id: 2, Weight: 20, IsFallback: true},
|
||||||
|
}
|
||||||
|
if _, err := p.Pick(pool); err != ErrEmptyPool {
|
||||||
|
t.Fatalf("expected ErrEmptyPool when only fallback prizes exist, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
|
||||||
|
pool := []Prize{
|
||||||
|
{Id: 1, Weight: 1},
|
||||||
|
{Id: 2, Weight: 1},
|
||||||
|
{Id: 3, Weight: 1},
|
||||||
|
}
|
||||||
|
a := NewWeightedPicker(7)
|
||||||
|
b := NewWeightedPicker(7)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
ai, _ := a.Pick(pool)
|
||||||
|
bi, _ := b.Pick(pool)
|
||||||
|
if ai != bi {
|
||||||
|
t.Fatalf("iter %d: seed 7 diverged: a=%d b=%d", i, ai, bi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ var AllProtocols = []string{
|
|||||||
"tuic",
|
"tuic",
|
||||||
"hysteria",
|
"hysteria",
|
||||||
"hysteria2",
|
"hysteria2",
|
||||||
|
"simnet",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
|
|||||||
"tuic",
|
"tuic",
|
||||||
"hysteria",
|
"hysteria",
|
||||||
"hysteria2",
|
"hysteria2",
|
||||||
|
"simnet",
|
||||||
}
|
}
|
||||||
set := make(map[string]struct{}, len(AllProtocols))
|
set := make(map[string]struct{}, len(AllProtocols))
|
||||||
for _, p := range AllProtocols {
|
for _, p := range AllProtocols {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package node
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -71,17 +73,20 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalProtocols Marshal server protocols to json
|
// MarshalProtocols Marshal server protocols to json.
|
||||||
|
// Uniqueness is scoped to type+port (a protocol entry is one listener instance),
|
||||||
|
// which allows the same protocol on multiple ports, e.g. simnet:443 and simnet:8443.
|
||||||
func (m *Server) MarshalProtocols(list []Protocol) error {
|
func (m *Server) MarshalProtocols(list []Protocol) error {
|
||||||
var validate = make(map[string]bool)
|
var validate = make(map[string]bool)
|
||||||
for _, protocol := range list {
|
for _, protocol := range list {
|
||||||
if protocol.Type == "" {
|
if strings.TrimSpace(protocol.Type) == "" {
|
||||||
return errors.New("protocol type is required")
|
return errors.New("protocol type is required")
|
||||||
}
|
}
|
||||||
if _, exists := validate[protocol.Type]; exists {
|
key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port)
|
||||||
return errors.New("duplicate protocol type: " + protocol.Type)
|
if _, exists := validate[key]; exists {
|
||||||
|
return errors.New("duplicate protocol type+port: " + key)
|
||||||
}
|
}
|
||||||
validate[protocol.Type] = true
|
validate[key] = true
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(list)
|
data, err := json.Marshal(list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,6 +158,192 @@ type Protocol struct {
|
|||||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||||
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
|
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
|
||||||
|
|
||||||
|
// Simnet protocol fields. Field set and json tags mirror the Pro reference
|
||||||
|
// (NPanel-backend internal/model/server/protocol.go) so OmnXT/SlagClient
|
||||||
|
// see an identical wire contract.
|
||||||
|
SimnetPsk string `json:"simnet_psk,omitempty"` // server-side PSK (key_id=0), never sent to normal users
|
||||||
|
SimnetKeyID int `json:"simnet_key_id,omitempty"` // server key id (0)
|
||||||
|
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||||
|
SimnetPath string `json:"simnet_path,omitempty"`
|
||||||
|
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||||
|
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||||
|
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||||
|
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||||
|
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||||
|
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||||
|
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||||
|
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||||
|
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||||
|
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||||
|
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||||
|
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||||
|
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||||
|
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||||
|
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||||
|
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||||
|
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||||
|
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||||
|
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||||
|
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||||
|
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||||
|
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||||
|
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||||
|
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||||
|
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||||
|
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||||
|
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||||
|
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||||
|
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||||
|
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||||
|
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||||
|
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||||
|
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||||
|
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||||
|
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||||
|
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSimnetInboundMaxStreamsPerSession int = 128
|
||||||
|
defaultSimnetInboundMaxUDPStreamsPerSession int = 64
|
||||||
|
defaultSimnetInboundMaxHandlerTasksPerSession int = 128
|
||||||
|
defaultSimnetStreamEventChannelCapacity int = 256
|
||||||
|
defaultSimnetStreamDataChannelCapacity int = 128
|
||||||
|
defaultSimnetTargetDialTimeoutMs int = 12_000
|
||||||
|
defaultSimnetTargetMaxConcurrentDials int = 256
|
||||||
|
defaultSimnetSessionWindow int = 4 * 1024 * 1024
|
||||||
|
defaultSimnetMaxConcurrentStreams int = 100
|
||||||
|
defaultSimnetInitialWindowSize int = 65_535
|
||||||
|
defaultSimnetMaxFrameSize int = 16_384
|
||||||
|
defaultSimnetClientMaxConcurrentStreams int = 32
|
||||||
|
defaultSimnetClientMaxStreamsPerSession int = 512
|
||||||
|
defaultSimnetClientSessionIdleTimeoutSecs int = 90
|
||||||
|
defaultSimnetClientMaxUDPSessions int = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSimnet applies simnet defaults and clears mutually-exclusive fields.
|
||||||
|
// Ported from the Pro reference (NPanel-backend protocol.go NormalizeSimnet) so
|
||||||
|
// the runtime config matches what OmnXT expects.
|
||||||
|
func (m *Protocol) NormalizeSimnet() {
|
||||||
|
if m == nil || m.Type != "simnet" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Port == 0 {
|
||||||
|
m.Port = 443
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.SimnetPath) == "" {
|
||||||
|
m.SimnetPath = "/simnet/session"
|
||||||
|
}
|
||||||
|
// Carrier: only h2 is supported in v1; coerce empty/grpc to h2 (matches the
|
||||||
|
// Pro reference normalizeSimnetProtocol).
|
||||||
|
if c := strings.TrimSpace(m.SimnetCarrier); c == "" || c == "grpc" {
|
||||||
|
m.SimnetCarrier = "h2"
|
||||||
|
}
|
||||||
|
m.applySimnetResourceDefaults()
|
||||||
|
if !m.SimnetFallbackEnabled || strings.TrimSpace(m.SimnetFallbackTargetHost) == "" {
|
||||||
|
m.SimnetFallbackEnabled = false
|
||||||
|
m.SimnetFallbackTargetScheme = ""
|
||||||
|
m.SimnetFallbackTargetHost = ""
|
||||||
|
m.SimnetFallbackTargetPort = 0
|
||||||
|
m.SimnetFallbackHostHeader = ""
|
||||||
|
m.SimnetFallbackTLSSNI = ""
|
||||||
|
} else {
|
||||||
|
m.SimnetFallbackTargetHost = strings.TrimSpace(m.SimnetFallbackTargetHost)
|
||||||
|
m.SimnetFallbackHostHeader = strings.TrimSpace(m.SimnetFallbackHostHeader)
|
||||||
|
m.SimnetFallbackTLSSNI = strings.TrimSpace(m.SimnetFallbackTLSSNI)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme)) {
|
||||||
|
case "http", "https":
|
||||||
|
m.SimnetFallbackTargetScheme = strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme))
|
||||||
|
default:
|
||||||
|
m.SimnetFallbackTargetScheme = "https"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.SimnetAfEnabled {
|
||||||
|
m.SimnetAfPathMode = ""
|
||||||
|
m.SimnetAfMagicMode = ""
|
||||||
|
m.SimnetAfPathPrefix = ""
|
||||||
|
m.SimnetAfPathSuffix = ""
|
||||||
|
m.SimnetAfResponseJitterMs = 0
|
||||||
|
m.SimnetAfHandshakePolymorphism = false
|
||||||
|
m.SimnetAfSettingsJitter = false
|
||||||
|
m.SimnetAfFakeHeaderInjection = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.SimnetAfPathMode == "" {
|
||||||
|
m.SimnetAfPathMode = "api"
|
||||||
|
}
|
||||||
|
if m.SimnetAfMagicMode == "" {
|
||||||
|
m.SimnetAfMagicMode = "derived"
|
||||||
|
}
|
||||||
|
if m.SimnetAfResponseJitterMs == 0 {
|
||||||
|
m.SimnetAfResponseJitterMs = 50
|
||||||
|
}
|
||||||
|
if !m.SimnetAfHandshakePolymorphism {
|
||||||
|
m.SimnetAfHandshakePolymorphism = true
|
||||||
|
}
|
||||||
|
if !m.SimnetAfSettingsJitter {
|
||||||
|
m.SimnetAfSettingsJitter = true
|
||||||
|
}
|
||||||
|
if !m.SimnetAfFakeHeaderInjection {
|
||||||
|
m.SimnetAfFakeHeaderInjection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Protocol) applySimnetResourceDefaults() {
|
||||||
|
if m.SimnetInboundMaxStreamsPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxStreamsPerSession = defaultSimnetInboundMaxStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetInboundMaxUDPStreamsPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxUDPStreamsPerSession = defaultSimnetInboundMaxUDPStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetInboundMaxHandlerTasksPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxHandlerTasksPerSession = defaultSimnetInboundMaxHandlerTasksPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetStreamEventChannelCapacity <= 0 {
|
||||||
|
m.SimnetStreamEventChannelCapacity = defaultSimnetStreamEventChannelCapacity
|
||||||
|
}
|
||||||
|
if m.SimnetStreamDataChannelCapacity <= 0 {
|
||||||
|
m.SimnetStreamDataChannelCapacity = defaultSimnetStreamDataChannelCapacity
|
||||||
|
}
|
||||||
|
if m.SimnetTargetDialTimeoutMs <= 0 {
|
||||||
|
m.SimnetTargetDialTimeoutMs = defaultSimnetTargetDialTimeoutMs
|
||||||
|
}
|
||||||
|
if m.SimnetTargetMaxConcurrentDials <= 0 {
|
||||||
|
m.SimnetTargetMaxConcurrentDials = defaultSimnetTargetMaxConcurrentDials
|
||||||
|
}
|
||||||
|
if m.SimnetSendWindow <= 0 {
|
||||||
|
m.SimnetSendWindow = defaultSimnetSessionWindow
|
||||||
|
}
|
||||||
|
if m.SimnetRecvWindow <= 0 {
|
||||||
|
m.SimnetRecvWindow = defaultSimnetSessionWindow
|
||||||
|
}
|
||||||
|
if m.SimnetMaxConcurrentStreams <= 0 {
|
||||||
|
m.SimnetMaxConcurrentStreams = defaultSimnetMaxConcurrentStreams
|
||||||
|
}
|
||||||
|
if m.SimnetInitialWindowSize <= 0 {
|
||||||
|
m.SimnetInitialWindowSize = defaultSimnetInitialWindowSize
|
||||||
|
}
|
||||||
|
if m.SimnetMaxFrameSize <= 0 {
|
||||||
|
m.SimnetMaxFrameSize = defaultSimnetMaxFrameSize
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxConcurrentStreams <= 0 {
|
||||||
|
m.SimnetClientMaxConcurrentStreams = defaultSimnetClientMaxConcurrentStreams
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxStreamsPerSession <= 0 {
|
||||||
|
m.SimnetClientMaxStreamsPerSession = defaultSimnetClientMaxStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetClientSessionIdleTimeoutSecs <= 0 {
|
||||||
|
m.SimnetClientSessionIdleTimeoutSecs = defaultSimnetClientSessionIdleTimeoutSecs
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxUDPSessions <= 0 {
|
||||||
|
m.SimnetClientMaxUDPSessions = defaultSimnetClientMaxUDPSessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal protocol to json
|
// Marshal protocol to json
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/storage"
|
"github.com/perfect-panel/server/pkg/storage"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
lotterydraw "github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||||
|
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||||
|
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
|
||||||
"github.com/perfect-panel/server/internal/model/ads"
|
"github.com/perfect-panel/server/internal/model/ads"
|
||||||
"github.com/perfect-panel/server/internal/model/announcement"
|
"github.com/perfect-panel/server/internal/model/announcement"
|
||||||
"github.com/perfect-panel/server/internal/model/auth"
|
"github.com/perfect-panel/server/internal/model/auth"
|
||||||
@@ -19,6 +22,7 @@ import (
|
|||||||
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
|
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
||||||
|
"github.com/perfect-panel/server/internal/model/lottery"
|
||||||
"github.com/perfect-panel/server/internal/model/order"
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
"github.com/perfect-panel/server/internal/model/payment"
|
"github.com/perfect-panel/server/internal/model/payment"
|
||||||
"github.com/perfect-panel/server/internal/model/promo"
|
"github.com/perfect-panel/server/internal/model/promo"
|
||||||
@@ -71,6 +75,13 @@ type ServiceContext struct {
|
|||||||
AnnouncementModel announcement.Model
|
AnnouncementModel announcement.Model
|
||||||
IAPAppleTransactionModel iapapple.Model
|
IAPAppleTransactionModel iapapple.Model
|
||||||
|
|
||||||
|
// Lottery (Stage 1)
|
||||||
|
LotteryChance lottery.ChanceService
|
||||||
|
LotteryLedger lottery.LedgerService
|
||||||
|
LotteryInviteHook lotteryhook.InviteHook
|
||||||
|
LotteryRegistry lottery.Registry
|
||||||
|
LotteryDrawService *lotterydraw.Service
|
||||||
|
|
||||||
Restart func() error
|
Restart func() error
|
||||||
TelegramBot *tgbotapi.BotAPI
|
TelegramBot *tgbotapi.BotAPI
|
||||||
NodeMultiplierManager *nodeMultiplier.Manager
|
NodeMultiplierManager *nodeMultiplier.Manager
|
||||||
@@ -148,6 +159,47 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||||||
}
|
}
|
||||||
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
||||||
srv.DeviceManager = NewDeviceManager(srv)
|
srv.DeviceManager = NewDeviceManager(srv)
|
||||||
|
// Lottery Stage 1: wire the always-safe pieces (ChanceService, LedgerService,
|
||||||
|
// InviteHook). PrizeHandler registry is created in the draw service (PR C).
|
||||||
|
srv.LotteryChance = lottery.NewChanceService(db)
|
||||||
|
srv.LotteryLedger = lottery.NewLedgerService()
|
||||||
|
srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance)
|
||||||
|
|
||||||
|
// PR C: PrizeHandler registry + Draw service wiring. Handlers are hooked in
|
||||||
|
// order: noop → vpn_duration → commission. Later registrations override.
|
||||||
|
registry := lottery.NewRegistry()
|
||||||
|
registry.Register(lottery.NewNoopHandler())
|
||||||
|
registry.Register(lotteryhandler.NewVPNDurationHandler(lotteryhandler.VPNDurationDeps{
|
||||||
|
UserModel: srv.UserModel,
|
||||||
|
Ledger: srv.LotteryLedger,
|
||||||
|
DB: db,
|
||||||
|
ResolveEffectiveUser: lotteryhandler.DefaultResolveEffectiveUser(db),
|
||||||
|
}))
|
||||||
|
registry.Register(lotteryhandler.NewCommissionHandler(lotteryhandler.CommissionDeps{
|
||||||
|
Ledger: srv.LotteryLedger,
|
||||||
|
UpdateCommission: srv.UserModel.UpdateCommission,
|
||||||
|
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
||||||
|
}))
|
||||||
|
// Stage 2 人工奖 handler:无外部依赖,直接注册。抽奖服务在 IsAuto()=false
|
||||||
|
// 时不会调用 Dispatch,而是由 draw 事务内挂 lottery_claim (pending_claim),
|
||||||
|
// 交给运营在后台审核 + 线下打款/发货。
|
||||||
|
registry.Register(lotteryhandler.NewCryptoHandler())
|
||||||
|
registry.Register(lotteryhandler.NewPhysicalHandler())
|
||||||
|
registry.Register(lotteryhandler.NewManualOtherHandler())
|
||||||
|
srv.LotteryRegistry = registry
|
||||||
|
|
||||||
|
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
||||||
|
srv.LotteryDrawService = lotterydraw.NewService(lotterydraw.Deps{
|
||||||
|
DB: db,
|
||||||
|
Enabled: c.Lottery.Enable,
|
||||||
|
RateLimiter: lotterydraw.NewRedisRateLimiter(drawLimiter),
|
||||||
|
Chance: srv.LotteryChance,
|
||||||
|
Evaluator: lottery.NewRuleEvaluator(),
|
||||||
|
Picker: lottery.NewWeightedPicker(0),
|
||||||
|
Registry: registry,
|
||||||
|
// ContextBuilder 留 nil:PR C 阶段无活动配置门槛不评估用户上下文;后续
|
||||||
|
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
|
||||||
|
})
|
||||||
if c.S3.Enable {
|
if c.S3.Enable {
|
||||||
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// Types for lottery Stage 1 user & admin APIs. Kept in a separate file so
|
||||||
|
// future `goctl` regenerations of types.go do not clobber them (same pattern
|
||||||
|
// as internal/types/subscribe.go).
|
||||||
|
//
|
||||||
|
// Field names mirror the shape declared in HIF-3 (Stage 1 spec) and the
|
||||||
|
// architect's PR C brief. Do NOT rename without updating the .api files and
|
||||||
|
// notifying frontend.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ---- User API ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetLotteryConfigRequest is the query for GET /api/v1/lottery/config.
|
||||||
|
type GetLotteryConfigRequest struct {
|
||||||
|
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryActivityConfig is the activity snapshot returned to the user.
|
||||||
|
type LotteryActivityConfig struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
StartAt int64 `json:"start_at"`
|
||||||
|
EndAt int64 `json:"end_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GridSize int `json:"grid_size"`
|
||||||
|
Prizes []LotteryPrizeConfig `json:"prizes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryPrizeConfig is the slot-facing view of a prize (no weight / stock
|
||||||
|
// exposed — those are admin-only).
|
||||||
|
type LotteryPrizeConfig struct {
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IconUrl string `json:"icon_url"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
SoldOut bool `json:"sold_out"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryUserStatus reports whether the user can currently draw.
|
||||||
|
type LotteryUserStatus struct {
|
||||||
|
Eligible bool `json:"eligible"`
|
||||||
|
ChancesRemaining int64 `json:"chances_remaining"`
|
||||||
|
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
|
||||||
|
type LotteryUnmetReason struct {
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Current int64 `json:"current,omitempty"`
|
||||||
|
Required int64 `json:"required,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLotteryConfigResponse is the envelope for GET /config.
|
||||||
|
type GetLotteryConfigResponse struct {
|
||||||
|
Activity LotteryActivityConfig `json:"activity"`
|
||||||
|
User LotteryUserStatus `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawLotteryRequest is the body for POST /api/v1/lottery/draw.
|
||||||
|
type DrawLotteryRequest struct {
|
||||||
|
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||||
|
ClientNonce string `json:"client_nonce" validate:"required,max=64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawnPrize is a slim prize view returned on draw success.
|
||||||
|
type DrawnPrize struct {
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryClaimStatus tells the frontend whether more action is needed.
|
||||||
|
type LotteryClaimStatus struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
AutoClaimed bool `json:"auto_claimed"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
// ExpiresAt 是领奖窗口截止时间(Unix 秒;0 表示不适用,例如自动奖)。
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
// ClaimFormSchema 是人工奖的领奖表单 JSON Schema(前端据此动态渲染)。
|
||||||
|
// nil 表示不适用(自动奖 / 谢谢参与)。
|
||||||
|
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawLotteryResponse is what /draw returns.
|
||||||
|
type DrawLotteryResponse struct {
|
||||||
|
DrawId int64 `json:"draw_id"`
|
||||||
|
IsWin bool `json:"is_win"`
|
||||||
|
Prize *DrawnPrize `json:"prize"`
|
||||||
|
Claim LotteryClaimStatus `json:"claim"`
|
||||||
|
ChancesRemaining int64 `json:"chances_remaining"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLotteryRecordsRequest paginates over the user's draws.
|
||||||
|
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20);tag 里不设
|
||||||
|
// default 以避免 staticcheck 与 Gin binding 的语义冲突。
|
||||||
|
type GetLotteryRecordsRequest struct {
|
||||||
|
ActivityId int64 `form:"activity_id"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryRecord is one row in the records list.
|
||||||
|
type LotteryRecord struct {
|
||||||
|
DrawId int64 `json:"draw_id"`
|
||||||
|
ActivityId int64 `json:"activity_id"`
|
||||||
|
IsWin bool `json:"is_win"`
|
||||||
|
Prize *DrawnPrize `json:"prize"`
|
||||||
|
DispatchState string `json:"dispatch_state"`
|
||||||
|
DrawnAt int64 `json:"drawn_at"`
|
||||||
|
// Claim 是人工奖的工单详情;自动奖 / 未中奖时为 nil。Stage 2 新增。
|
||||||
|
Claim *LotteryRecordClaim `json:"claim,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryRecordClaim 是 GET /records 里"人工奖工单"的用户视图。
|
||||||
|
// 状态:pending_claim / reviewing / paying / paid / rejected / expired。
|
||||||
|
// - 用户在 pending_claim 或 rejected 时可再次提交(前端读 ClaimFormSchema 渲染)。
|
||||||
|
// - paying / paid / expired 时前端只展示状态与打款/发货结果。
|
||||||
|
type LotteryRecordClaim struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||||
|
RejectReason string `json:"reject_reason,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
// ClaimFormSchema 仅当 status 允许再提交(pending_claim / rejected)时下发。
|
||||||
|
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLotteryRecordsResponse is the paginated payload.
|
||||||
|
type GetLotteryRecordsResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
List []LotteryRecord `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimLotteryPrizeRequest is Stage 2: submit claim data for a manual prize.
|
||||||
|
type ClaimLotteryPrizeRequest struct {
|
||||||
|
DrawId int64 `json:"draw_id" validate:"required"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data"`
|
||||||
|
// Input 是 Stage 1 骨架里预留的旧字段名,为了不破坏前端契约保留。
|
||||||
|
// Deprecated: Prefer ClaimData for new callers.
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimLotteryPrizeResponse mirrors Stage 2 shape.
|
||||||
|
type ClaimLotteryPrizeResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin API --------------------------------------------------------------
|
||||||
|
|
||||||
|
// AdminLotteryActivity is the full admin view of an activity.
|
||||||
|
type AdminLotteryActivity struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
StartAt int64 `json:"start_at"`
|
||||||
|
EndAt int64 `json:"end_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GridSize int `json:"grid_size"`
|
||||||
|
Eligibility json.RawMessage `json:"eligibility"`
|
||||||
|
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||||
|
UnmetAction string `json:"unmet_action"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdminLotteryActivityRequest creates a new activity in "draft" status.
|
||||||
|
type CreateAdminLotteryActivityRequest struct {
|
||||||
|
Title string `json:"title" validate:"required,max=128"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
StartAt int64 `json:"start_at" validate:"required"`
|
||||||
|
EndAt int64 `json:"end_at" validate:"required"`
|
||||||
|
// GridSize:0 由 logic 层兜底为 9(不能在 json tag 里写 default=… ——
|
||||||
|
// staticcheck SA5008 会拒;encoding/json 也不认这个选项)。
|
||||||
|
GridSize int `json:"grid_size"`
|
||||||
|
Eligibility json.RawMessage `json:"eligibility"`
|
||||||
|
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||||
|
// UnmetAction:空字符串由 logic 层兜底为 "block"。
|
||||||
|
UnmetAction string `json:"unmet_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAdminLotteryActivityRequest updates mutable fields.
|
||||||
|
type UpdateAdminLotteryActivityRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
StartAt int64 `json:"start_at,omitempty"`
|
||||||
|
EndAt int64 `json:"end_at,omitempty"`
|
||||||
|
GridSize int `json:"grid_size,omitempty"`
|
||||||
|
UnmetAction string `json:"unmet_action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryActivitiesRequest paginates admin listings.
|
||||||
|
// Page/Size 默认 by logic 层。
|
||||||
|
type ListAdminLotteryActivitiesRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
Status string `form:"status,omitempty"`
|
||||||
|
Search string `form:"search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryActivitiesResponse pages.
|
||||||
|
type ListAdminLotteryActivitiesResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
List []AdminLotteryActivity `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminActivityIdRequest is used by GET /detail, publish, pause, delete.
|
||||||
|
type AdminActivityIdRequest struct {
|
||||||
|
Id int64 `form:"id" json:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAdminLotteryRulesRequest overwrites eligibility / chance_sources /
|
||||||
|
// unmet_action in a single call. Validated against rule caps before persist.
|
||||||
|
type UpdateAdminLotteryRulesRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
Eligibility json.RawMessage `json:"eligibility"`
|
||||||
|
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||||
|
UnmetAction string `json:"unmet_action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryPrize is the admin view of a prize (includes weight + stock).
|
||||||
|
type AdminLotteryPrize struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
ActivityId int64 `json:"activity_id"`
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IconUrl string `json:"icon_url"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
TotalStock *int64 `json:"total_stock"`
|
||||||
|
RemainingStock *int64 `json:"remaining_stock"`
|
||||||
|
IsFallback bool `json:"is_fallback"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdminLotteryPrizeRequest is nested under /activities/{id}/prizes.
|
||||||
|
type CreateAdminLotteryPrizeRequest struct {
|
||||||
|
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Type string `json:"type" validate:"required,max=32"`
|
||||||
|
Name string `json:"name" validate:"required,max=128"`
|
||||||
|
IconUrl string `json:"icon_url"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||||
|
IsFallback bool `json:"is_fallback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAdminLotteryPrizeRequest updates mutable prize fields. Id comes from
|
||||||
|
// the URL path (PUT /prizes/:id), not the body.
|
||||||
|
type UpdateAdminLotteryPrizeRequest struct {
|
||||||
|
Id int64 `path:"id" json:"-" validate:"required"`
|
||||||
|
Slot *int `json:"slot,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IconUrl string `json:"icon_url,omitempty"`
|
||||||
|
Config json.RawMessage `json:"config,omitempty"`
|
||||||
|
Weight *int `json:"weight,omitempty"`
|
||||||
|
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||||
|
IsFallback *bool `json:"is_fallback,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryPrizesRequest lists prizes for an activity.
|
||||||
|
type ListAdminLotteryPrizesRequest struct {
|
||||||
|
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryPrizesResponse pages.
|
||||||
|
type ListAdminLotteryPrizesResponse struct {
|
||||||
|
List []AdminLotteryPrize `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPrizeIdRequest is used by DELETE /prizes/:id. Id comes from the URL path.
|
||||||
|
type AdminPrizeIdRequest struct {
|
||||||
|
Id int64 `path:"id" form:"id" json:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
|
||||||
|
// activity. sourceRef doubles as idempotency key.
|
||||||
|
type GrantAdminLotteryChanceRequest struct {
|
||||||
|
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||||
|
UserId int64 `json:"user_id" validate:"required"`
|
||||||
|
Amount int `json:"amount" validate:"required,min=1"`
|
||||||
|
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stage 2 Admin Claims --------------------------------------------------
|
||||||
|
|
||||||
|
// ListAdminLotteryClaimsRequest lists claims filtered by type/status/activity.
|
||||||
|
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20)。
|
||||||
|
type ListAdminLotteryClaimsRequest struct {
|
||||||
|
Type string `form:"type,omitempty"`
|
||||||
|
Status string `form:"status,omitempty"`
|
||||||
|
ActivityId int64 `form:"activity_id,omitempty"`
|
||||||
|
UserId int64 `form:"user_id,omitempty"`
|
||||||
|
From int64 `form:"from,omitempty"` // Unix 秒
|
||||||
|
To int64 `form:"to,omitempty"` // Unix 秒
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaim is the admin-facing view of one claim row.
|
||||||
|
type AdminLotteryClaim struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
DrawId int64 `json:"draw_id"`
|
||||||
|
ActivityId int64 `json:"activity_id"`
|
||||||
|
User AdminLotteryClaimUser `json:"user"`
|
||||||
|
Prize AdminLotteryClaimPrize `json:"prize"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||||
|
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
ReviewedBy int64 `json:"reviewed_by,omitempty"`
|
||||||
|
ReviewedAt int64 `json:"reviewed_at,omitempty"`
|
||||||
|
RejectReason string `json:"reject_reason,omitempty"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimUser 是 claim 列表里附带的用户简况。
|
||||||
|
type AdminLotteryClaimUser struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimPrize 是 claim 列表里附带的奖品简况(含 config,供审核判断)。
|
||||||
|
type AdminLotteryClaimPrize struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryClaimsResponse pages.
|
||||||
|
type ListAdminLotteryClaimsResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Claims []AdminLotteryClaim `json:"claims"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApproveClaimRequest 转 reviewing → paying。
|
||||||
|
type AdminApproveClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectClaimRequest 转 reviewing/paying → rejected,reason 必填。
|
||||||
|
type AdminRejectClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
Reason string `json:"reason" validate:"required,max=512"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMarkPaidClaimRequest 转 paying → paid。
|
||||||
|
// tx_hash / delivery_ref 至少填一个;crypto 必填 tx_hash,physical 必填
|
||||||
|
// delivery_ref,manual_other 至少填一个(业务层做类型检查)。
|
||||||
|
// paid_at 可选,缺省时用服务端 now。
|
||||||
|
type AdminMarkPaidClaimRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
TxHash string `json:"tx_hash,omitempty" validate:"max=128"`
|
||||||
|
DeliveryRef string `json:"delivery_ref,omitempty" validate:"max=128"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"` // Unix 秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimsSummary 是 /claims/summary 的响应。overdue = 待用户填的
|
||||||
|
// pending_claim 中已过期的条数(不重叠 status=expired)。
|
||||||
|
type AdminLotteryClaimsSummary struct {
|
||||||
|
Crypto AdminLotteryClaimsStatusCount `json:"crypto"`
|
||||||
|
Physical AdminLotteryClaimsStatusCount `json:"physical"`
|
||||||
|
ManualOther AdminLotteryClaimsStatusCount `json:"manual_other"`
|
||||||
|
Overdue int64 `json:"overdue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryClaimsStatusCount 是各类型的关键状态计数。
|
||||||
|
type AdminLotteryClaimsStatusCount struct {
|
||||||
|
Reviewing int64 `json:"reviewing"`
|
||||||
|
Paying int64 `json:"paying"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stage 3 后台抽奖记录(发放流水)--------------------------------------
|
||||||
|
|
||||||
|
// ListAdminLotteryDrawsRequest 列出抽奖记录,支持活动/用户/中奖/发放状态/奖品类型/时间窗过滤。
|
||||||
|
type ListAdminLotteryDrawsRequest struct {
|
||||||
|
ActivityId int64 `form:"activity_id,omitempty"`
|
||||||
|
UserId int64 `form:"user_id,omitempty"`
|
||||||
|
PrizeType string `form:"prize_type,omitempty"`
|
||||||
|
DispatchState string `form:"dispatch_state,omitempty"`
|
||||||
|
Win string `form:"win,omitempty"` // "1"=只看中奖, "0"=只看未中奖, ""=全部
|
||||||
|
From int64 `form:"from,omitempty"`
|
||||||
|
To int64 `form:"to,omitempty"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryDrawUser 抽奖记录里附带的用户简况。
|
||||||
|
type AdminLotteryDrawUser struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryDrawPrize 抽奖记录里的奖品快照(抽奖时刻冻结)。
|
||||||
|
type AdminLotteryDrawPrize struct {
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLotteryDraw 是后台抽奖记录视图:谁、何时、中了什么、发放状态与结果。
|
||||||
|
type AdminLotteryDraw struct {
|
||||||
|
DrawId int64 `json:"draw_id"`
|
||||||
|
ActivityId int64 `json:"activity_id"`
|
||||||
|
User AdminLotteryDrawUser `json:"user"`
|
||||||
|
IsWin bool `json:"is_win"`
|
||||||
|
Prize *AdminLotteryDrawPrize `json:"prize,omitempty"`
|
||||||
|
DispatchState string `json:"dispatch_state"`
|
||||||
|
DispatchError string `json:"dispatch_error,omitempty"`
|
||||||
|
// GrantAmount / GrantMessage 来自 lottery_grant_ledger(自动奖发放结果):
|
||||||
|
// vpn_duration=天数,commission=分;message 如"已新建订阅并加 30 天"。
|
||||||
|
GrantAmount int64 `json:"grant_amount,omitempty"`
|
||||||
|
GrantMessage string `json:"grant_message,omitempty"`
|
||||||
|
DrawnAt int64 `json:"drawn_at"`
|
||||||
|
DispatchedAt int64 `json:"dispatched_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminLotteryDrawsResponse 分页。
|
||||||
|
type ListAdminLotteryDrawsResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
List []AdminLotteryDraw `json:"list"`
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user