Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 a615724580 fix: align revenue statistics with order type
持续集成 / 构建/Vet/测试 (pull_request) Has been cancelled
持续集成 / golangci-lint (pull_request) Has been cancelled
测试环境部署 / 构建镜像并部署到测试环境 (push) Has been cancelled
2026-06-23 09:12:42 -07:00
116 changed files with 198 additions and 12972 deletions
-1
View File
@@ -43,7 +43,6 @@ logs/
/test/
*_test.go
!tests/acceptance/*_test.go
!internal/handler/subscribe_test.go
*_test_config.go
**/logtest/
*_test.yaml
+47 -71
View File
@@ -113,77 +113,53 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
proxies = append(
proxies,
Proxy{
Sort: item.Sort,
Name: item.Name,
Server: item.Address,
Port: item.Port,
Type: item.Protocol,
Tags: strings.Split(item.Tags, ","),
Security: protocol.Security,
SNI: protocol.SNI,
AllowInsecure: protocol.AllowInsecure,
Fingerprint: protocol.Fingerprint,
RealityServerAddr: protocol.RealityServerAddr,
RealityServerPort: protocol.RealityServerPort,
RealityPrivateKey: protocol.RealityPrivateKey,
RealityPublicKey: protocol.RealityPublicKey,
RealityShortId: protocol.RealityShortId,
Transport: protocol.Transport,
Host: protocol.Host,
Path: protocol.Path,
ServiceName: protocol.ServiceName,
Method: protocol.Cipher,
ServerKey: protocol.ServerKey,
Flow: protocol.Flow,
HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController,
PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode,
XhttpExtra: protocol.XhttpExtra,
Encryption: protocol.Encryption,
EncryptionMode: protocol.EncryptionMode,
EncryptionRtt: protocol.EncryptionRtt,
EncryptionTicket: protocol.EncryptionTicket,
EncryptionServerPadding: protocol.EncryptionServerPadding,
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio,
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
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,
Sort: item.Sort,
Name: item.Name,
Server: item.Address,
Port: item.Port,
Type: item.Protocol,
Tags: strings.Split(item.Tags, ","),
Security: protocol.Security,
SNI: protocol.SNI,
AllowInsecure: protocol.AllowInsecure,
Fingerprint: protocol.Fingerprint,
RealityServerAddr: protocol.RealityServerAddr,
RealityServerPort: protocol.RealityServerPort,
RealityPrivateKey: protocol.RealityPrivateKey,
RealityPublicKey: protocol.RealityPublicKey,
RealityShortId: protocol.RealityShortId,
Transport: protocol.Transport,
Host: protocol.Host,
Path: protocol.Path,
ServiceName: protocol.ServiceName,
Method: protocol.Cipher,
ServerKey: protocol.ServerKey,
Flow: protocol.Flow,
HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController,
PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode,
XhttpExtra: protocol.XhttpExtra,
Encryption: protocol.Encryption,
EncryptionMode: protocol.EncryptionMode,
EncryptionRtt: protocol.EncryptionRtt,
EncryptionTicket: protocol.EncryptionTicket,
EncryptionServerPadding: protocol.EncryptionServerPadding,
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio,
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv,
},
)
}
+1 -32
View File
@@ -81,38 +81,10 @@ type Proxy struct {
CertMode string // Certificate mode, `none``http``dns``self`
CertDNSProvider string // DNS provider for certificate
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 {
Password string
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
ExpiredAt time.Time
Download int64
Upload int64
@@ -132,10 +104,7 @@ type Client struct {
func (c *Client) Build() ([]byte, error) {
var buf bytes.Buffer
funcMap := sprig.TxtFuncMap()
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
if err != nil {
return nil, err
}
-273
View File
@@ -1,273 +0,0 @@
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()))
}
-94
View File
@@ -1,94 +0,0 @@
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)
}
-33
View File
@@ -1,33 +0,0 @@
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)
}
-45
View File
@@ -1045,51 +1045,6 @@ type (
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self`
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
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
ResetUserSubscribeTokenRequest {
-813
View File
@@ -1,813 +0,0 @@
# 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. 阶段 3OmnXT 服务端配置下发
### 目标
让 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. 阶段 5OmnXT 用户授权同步
### 目标
让 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. 阶段 8TLS、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 契约清楚、测试环境可用的情况下:
- 阶段 00.5-1 天
- 阶段 1-21.5-2 天
- 阶段 31-1.5 天
- 阶段 41.5-2 天
- 阶段 51-1.5 天
- 阶段 61-1.5 天
- 阶段 71-2 天
- 阶段 81 天
- 阶段 92-3 天
- 阶段 101 天
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
## 21. 推荐执行顺序
第一周先完成最小闭环:
1. 阶段 0:确认契约。
2. 阶段 1:协议模型与校验。
3. 阶段 2:管理端保存和查询。
4. 阶段 3OmnXT 配置下发。
第二周完成用户链路:
1. 阶段 4:用户级凭据。
2. 阶段 5OmnXT 用户授权。
3. 阶段 6SlagClient 订阅。
4. 阶段 7:流量和在线用户映射。
最后做生产化:
1. 阶段 8TLS、AF、Fallback 运维验证。
2. 阶段 9:自动化测试补齐。
3. 阶段 10:灰度发布和回滚演练。
## 22. 当前结论
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
-2
View File
@@ -82,7 +82,6 @@ require (
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-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/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
@@ -146,7 +145,6 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/metric v1.29.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
-4
View File
@@ -54,8 +54,6 @@ 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.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
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.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
@@ -397,8 +395,6 @@ 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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
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/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -1,11 +0,0 @@
-- 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`;
@@ -1,125 +0,0 @@
-- 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='抽奖时刻的门槛评估快照(对账/申诉用)';
@@ -1,2 +0,0 @@
-- 02157 抽奖发奖账本回滚
DROP TABLE IF EXISTS `lottery_grant_ledger`;
@@ -1,25 +0,0 @@
-- 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';
@@ -1,2 +0,0 @@
-- 02158 admin_action_log 回滚
DROP TABLE IF EXISTS `admin_action_log`;
@@ -1,21 +0,0 @@
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
--
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
-- 与合规审查。actor_user_id 是操作者的 user.idaction 是操作动作
-- 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='后台写操作审计流水';
@@ -1,3 +0,0 @@
-- 02159 抽奖活动 Stage 2 down migration
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
DROP TABLE IF EXISTS `lottery_claim`;
@@ -1,47 +0,0 @@
-- 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='抽奖人工奖领奖工单';
@@ -1,4 +0,0 @@
-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9
--
-- 与 up.sql 对称,只回退默认值,不动数据。
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9;
@@ -1,15 +0,0 @@
-- 02160 抽奖 Stage 2 F8lottery_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;
@@ -1 +0,0 @@
DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet';
@@ -1,10 +0,0 @@
-- 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));
-3
View File
@@ -70,6 +70,3 @@ const RegisterIpKeyPrefix = "register:ip:"
// UserSessionsKeyPrefix per-user sessions zset key prefix
const UserSessionsKeyPrefix = "auth:user_sessions:"
// UserEnableKeyPrefix user enable state cache key prefix
const UserEnableKeyPrefix = "user:enable:"
-8
View File
@@ -39,7 +39,6 @@ type Config struct {
Currency Currency `yaml:"Currency"`
Trace trace.Config `yaml:"Trace"`
S3 S3Config `yaml:"S3"`
Lottery LotteryConfig `yaml:"Lottery"`
Administrator struct {
Email string `yaml:"Email" default:"admin@ppanel.dev"`
Password string `yaml:"Password" default:"password"`
@@ -251,13 +250,6 @@ type InviteConfig struct {
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 短链接服务配置
type KuttConfig struct {
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
@@ -1,85 +0,0 @@
// 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)
}
}
@@ -1,194 +0,0 @@
// 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))
}
}
-55
View File
@@ -1,55 +0,0 @@
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))
}
}
@@ -1,69 +0,0 @@
// 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) {
return func(c *gin.Context) {
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryUserSubscribeNodeList()
result.HttpResult(c, resp, err)
}
-3
View File
@@ -1221,7 +1221,4 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Server Protocol Config
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
}
// ---- Lottery (Stage 1) --------------------------------------------------
registerLotteryRoutes(router, serverCtx)
}
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"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/result"
"github.com/perfect-panel/server/pkg/tool"
)
@@ -85,7 +84,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
l := subscribe.NewSubscribeLogic(c, svcCtx)
resp, err := l.Handler(&req)
if err != nil {
result.HttpResult(c, nil, err)
c.String(http.StatusInternalServerError, "Internal Server")
return
}
c.Header("subscription-userinfo", resp.Header)
-332
View File
@@ -1,332 +0,0 @@
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
}
@@ -1,471 +0,0 @@
// 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 → rejectedexpires_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
}
}
// overduepending_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
}
@@ -1,122 +0,0 @@
// 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)
}
}
-204
View File
@@ -1,204 +0,0 @@
// 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
}
-742
View File
@@ -1,742 +0,0 @@
// 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:])
}
@@ -1,134 +0,0 @@
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)
}
}
@@ -1,51 +0,0 @@
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,8 +83,6 @@ func (l *CreateServerLogic) CreateServer(req *types.CreateServerRequest) error {
}
}
}
// Simnet: apply defaults / normalize (no-op for other protocols)
protocol.NormalizeSimnet()
protocols = append(protocols, protocol)
}
@@ -54,10 +54,6 @@ func (l *FilterServerListLogic) FilterServerList(req *types.FilterServerListRequ
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
continue
}
// Normalize simnet defaults on the response (safe: dst is a fresh slice).
for i := range dst {
dst[i].NormalizeSimnet()
}
tool.DeepCopy(&protocols, dst)
server.Protocols = protocols
@@ -41,12 +41,6 @@ func (l *GetServerProtocolsLogic) GetServerProtocols(req *types.GetServerProtoco
l.Errorf("[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)
return &types.GetServerProtocolsResponse{
@@ -100,8 +100,6 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error {
}
}
}
// Simnet: apply defaults / normalize (no-op for other protocols)
protocol.NormalizeSimnet()
protocols = append(protocols, protocol)
}
err = data.MarshalProtocols(protocols)
@@ -2,7 +2,11 @@ package user
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
@@ -46,11 +50,84 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil {
sessionSet := make(map[string]struct{})
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("清理会话缓存失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
return
}
l.Infow("[KickOffline] 管理员踢设备-清除所有Session",
logger.Field("user_id", userId),
logger.Field("count", len(sessionSet)),
)
}
-81
View File
@@ -1,81 +0,0 @@
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
}
-74
View File
@@ -1,74 +0,0 @@
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,14 +44,6 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
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 {
if req.Balance != nil && userInfo.Balance != *req.Balance {
@@ -184,19 +176,6 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
}
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
}
-100
View File
@@ -1,100 +0,0 @@
// 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]
}
@@ -1,111 +0,0 @@
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,7 +7,6 @@ import (
"github.com/perfect-panel/server/internal/config"
"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/user"
"github.com/perfect-panel/server/internal/svc"
@@ -20,7 +19,6 @@ import (
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -96,9 +94,6 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.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
if req.Identifier != "" {
@@ -135,7 +130,6 @@ 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 {
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
return &types.LoginResponse{
Token: token,
-6
View File
@@ -7,7 +7,6 @@ import (
"time"
"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/user"
"github.com/perfect-panel/server/internal/svc"
@@ -19,7 +18,6 @@ import (
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -116,9 +114,6 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
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
defer func() {
@@ -193,7 +188,6 @@ 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 {
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
return &types.LoginResponse{
@@ -9,7 +9,6 @@ import (
"github.com/perfect-panel/server/internal/config"
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/log"
"github.com/perfect-panel/server/internal/model/user"
@@ -24,7 +23,6 @@ import (
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -76,9 +74,6 @@ func (l *OAuthLoginGetTokenLogic) OAuthLoginGetToken(req *types.OAuthLoginGetTok
if err != nil {
return nil, err
}
if logiccommon.IsUserDisabled(userInfo) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
}
token, err := l.generateToken(userInfo, requestID)
if err != nil {
@@ -633,7 +628,6 @@ func (l *OAuthLoginGetTokenLogic) generateToken(userInfo *user.User, requestID s
)
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",
logger.Field("request_id", requestID),
@@ -21,7 +21,6 @@ import (
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -65,9 +64,6 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
}
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
defer func(svcCtx *svc.ServiceContext) {
if userInfo.Id != 0 {
@@ -169,7 +165,6 @@ 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 {
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
return &types.LoginResponse{
Token: token,
-6
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"time"
logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/captcha"
"github.com/perfect-panel/server/pkg/constant"
@@ -18,7 +17,6 @@ import (
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"github.com/perfect-panel/server/internal/svc"
@@ -91,9 +89,6 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.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
now := time.Now()
@@ -140,7 +135,6 @@ 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 {
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
return &types.LoginResponse{
Token: token,
+1 -1
View File
@@ -101,7 +101,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
} else if err == nil && !u.DeletedAt.Valid {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user email exist: %v", req.Email)
} else if err == nil && u.DeletedAt.Valid {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email)
}
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
-89
View File
@@ -1,89 +0,0 @@
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
}
-266
View File
@@ -1,266 +0,0 @@
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)
}
}
-657
View File
@@ -1,657 +0,0 @@
// 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 F10msg 字段只带通用文案("抽奖服务暂时不可用"),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
@@ -1,128 +0,0 @@
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
}
@@ -1,32 +0,0 @@
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
}
@@ -1,185 +0,0 @@
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")
}
}
@@ -1,280 +0,0 @@
// 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;解码失败返回 ErrClaimDataMalformedwrap 原因)。
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
}
@@ -1,252 +0,0 @@
// 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)
}
}
}
@@ -1,12 +0,0 @@
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)
}
@@ -1,305 +0,0 @@
// 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 cyclecommon 包里有别的文件反向 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
}
@@ -1,486 +0,0 @@
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)
}
}
-140
View File
@@ -1,140 +0,0 @@
// 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
}
-191
View File
@@ -1,191 +0,0 @@
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)
}
}
-112
View File
@@ -1,112 +0,0 @@
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'
}
@@ -1,127 +0,0 @@
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)
}
}
-499
View File
@@ -1,499 +0,0 @@
// 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 上下文。
// 匿名 / 未登录返回 0handler 侧应当由 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 需要 RuleContextBuilderPR 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
}
@@ -1,49 +0,0 @@
// 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,7 +13,6 @@ import (
"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/simnet"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
@@ -21,18 +20,16 @@ import (
type QueryUserSubscribeNodeListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
userAgent string
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get user subscribe node info
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext, userAgent string) *QueryUserSubscribeNodeListLogic {
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic {
return &QueryUserSubscribeNodeListLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
userAgent: userAgent,
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
@@ -166,12 +163,6 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
if server == nil {
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{
Id: n.Id,
Name: n.Name,
-1
View File
@@ -12,7 +12,6 @@ const (
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
// TODO: remove in future versions
Hysteria2 = "hysteria2"
Simnet = "simnet"
)
type SecurityConfig struct {
@@ -234,22 +234,6 @@ 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{}
s, _ := json.Marshal(result)
@@ -38,7 +38,6 @@ func TestNormalizeServerUserListProtocol(t *testing.T) {
{"tuic unchanged", "tuic", "tuic"},
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
{"anytls unchanged", "anytls", "anytls"},
{"simnet unchanged", "simnet", "simnet"},
{"empty unchanged", "", ""},
}
for _, c := range cases {
-27
View File
@@ -1,27 +0,0 @@
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,7 +7,6 @@ import (
"time"
"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/group"
"github.com/perfect-panel/server/internal/model/log"
@@ -80,10 +79,6 @@ 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))
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
defer func() {
@@ -101,11 +96,6 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
if err != nil {
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(
targetApp.SubscribeTemplate,
adapter.WithServers(servers),
@@ -114,7 +104,6 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
adapter.WithOutputFormat(targetApp.OutputFormat),
adapter.WithUserInfo(adapter.User{
Password: userSubscribe.UUID,
SubscribeID: userSubscribe.Id,
ExpiredAt: userSubscribe.ExpireTime,
Download: userSubscribe.Download,
Upload: userSubscribe.Upload,
@@ -1,26 +0,0 @@
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()
}
}
@@ -1,81 +0,0 @@
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)
}
}
+3 -4
View File
@@ -6,7 +6,6 @@ import (
"strings"
"time"
logiccommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
@@ -88,10 +87,10 @@ func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string,
svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second)
userInfo, err := logiccommon.ResolveEnabledUser(c, svc, userId)
userInfo, err := svc.UserModel.FindOne(c, userId)
if err != nil {
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId))
result.HttpResult(c, nil, err)
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId))
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error"))
c.Abort()
return false
}
@@ -9,7 +9,6 @@ import (
"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/result"
"github.com/perfect-panel/server/pkg/tool"
)
@@ -67,8 +66,6 @@ func PanDomainMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
l := subscribe.NewSubscribeLogic(c, svc)
resp, err := l.Handler(&request)
if err != nil {
result.HttpResult(c, nil, err)
c.Abort()
return
}
c.Header("subscription-userinfo", resp.Header)
-1
View File
@@ -52,7 +52,6 @@ const (
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
CommissionTypeLottery uint16 = 339 // 抽奖奖励(PR B: 与 Purchase/Renewal 区分,便于对账)
GiftTypeIncrease uint16 = 341 // Increase
GiftTypeReduce uint16 = 342 // Reduce
)
-116
View File
@@ -1,116 +0,0 @@
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
}
@@ -1,197 +0,0 @@
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_grantUNIQUE 冲突 → 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)
}
}
-78
View File
@@ -1,78 +0,0 @@
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 覆盖真实 payloadReserve 阶
// 段的空 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
}
-188
View File
@@ -1,188 +0,0 @@
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
}
-251
View File
@@ -1,251 +0,0 @@
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"`
}
-57
View File
@@ -1,57 +0,0 @@
package lottery
import (
"context"
"encoding/json"
"sync"
"gorm.io/gorm"
)
// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。
type defaultRegistry struct {
mu sync.RWMutex
handlers map[string]PrizeHandler
}
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
// 实际业务 handlervpn_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 }
-46
View File
@@ -1,46 +0,0 @@
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)
}
}
-252
View File
@@ -1,252 +0,0 @@
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
}
@@ -1,193 +0,0 @@
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")
}
}
-139
View File
@@ -1,139 +0,0 @@
// 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 的人工奖 handlerDispatch 不会被抽奖服务调用;
// 实现返回 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 SchemaStage 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")
-73
View File
@@ -1,73 +0,0 @@
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
}
@@ -1,104 +0,0 @@
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)
}
}
}
-1
View File
@@ -38,7 +38,6 @@ var AllProtocols = []string{
"tuic",
"hysteria",
"hysteria2",
"simnet",
}
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
-1
View File
@@ -63,7 +63,6 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
"tuic",
"hysteria",
"hysteria2",
"simnet",
}
set := make(map[string]struct{}, len(AllProtocols))
for _, p := range AllProtocols {
+5 -196
View File
@@ -2,8 +2,6 @@ package node
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/pkg/logger"
@@ -73,20 +71,17 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error {
return nil
}
// 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.
// MarshalProtocols Marshal server protocols to json
func (m *Server) MarshalProtocols(list []Protocol) error {
var validate = make(map[string]bool)
for _, protocol := range list {
if strings.TrimSpace(protocol.Type) == "" {
if protocol.Type == "" {
return errors.New("protocol type is required")
}
key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port)
if _, exists := validate[key]; exists {
return errors.New("duplicate protocol type+port: " + key)
if _, exists := validate[protocol.Type]; exists {
return errors.New("duplicate protocol type: " + protocol.Type)
}
validate[key] = true
validate[protocol.Type] = true
}
data, err := json.Marshal(list)
if err != nil {
@@ -158,192 +153,6 @@ type Protocol struct {
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self`
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
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
-52
View File
@@ -11,9 +11,6 @@ import (
"github.com/perfect-panel/server/pkg/storage"
"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/announcement"
"github.com/perfect-panel/server/internal/model/auth"
@@ -22,7 +19,6 @@ import (
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/log"
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/payment"
"github.com/perfect-panel/server/internal/model/promo"
@@ -75,13 +71,6 @@ type ServiceContext struct {
AnnouncementModel announcement.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
TelegramBot *tgbotapi.BotAPI
NodeMultiplierManager *nodeMultiplier.Manager
@@ -159,47 +148,6 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
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 留 nilPR C 阶段无活动配置门槛不评估用户上下文;后续
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
})
if c.S3.Enable {
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
if err != nil {
-444
View File
@@ -1,444 +0,0 @@
// 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 → 1Size<=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"`
// GridSize0 由 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 → 1Size<=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 → rejectedreason 必填。
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_hashphysical 必填
// delivery_refmanual_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