Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 634b5a7bd0 | |||
| 5ef3f2717e | |||
| 2c1ee78bc4 | |||
| 13bafd5847 | |||
| e8e3a3a72b | |||
| 58c346abec | |||
| abd8c068b6 | |||
| eac0137069 | |||
| cce147108c | |||
| 980e5adb90 | |||
| 07409eb602 | |||
| 117dc0d6a7 | |||
| c92495c5b9 | |||
| 92cf2921dd | |||
| ce3babcc33 | |||
| a46fb83054 | |||
| 9933d34bdd | |||
| d2710d356f | |||
| f0a5288e20 | |||
| 77fa0cadd2 | |||
| 3d1a31a19f | |||
| 08434cfa32 | |||
| be09a115ec | |||
| 077dba3d98 | |||
| 39bd36b2f8 | |||
| cfb253d96f | |||
| f11097ab83 | |||
| 3e6318dcdf | |||
| c39bfd39dd |
@@ -21,10 +21,10 @@ concurrency:
|
||||
|
||||
env:
|
||||
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
ACCEPTANCE_REPORT_DIR: acceptance-artifacts
|
||||
ACCEPTANCE_REPORT_PATH: acceptance-artifacts/acceptance-report.json
|
||||
ACCEPTANCE_TEST_JSON: acceptance-artifacts/go-test.json
|
||||
ACCEPTANCE_FAILURE_LOG: acceptance-artifacts/failure.log
|
||||
ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts
|
||||
ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json
|
||||
ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json
|
||||
ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log
|
||||
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
|
||||
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
@@ -75,13 +75,24 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing=()
|
||||
required=(
|
||||
if [ -z "${STAGING_BASE_URL:-}" ]; then
|
||||
echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置错误"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL` 不能为空。"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_optional=()
|
||||
optional=(
|
||||
ACCEPTANCE_ADMIN_EMAIL
|
||||
ACCEPTANCE_ADMIN_PASSWORD
|
||||
ACCEPTANCE_USER_EMAIL
|
||||
ACCEPTANCE_USER_PASSWORD
|
||||
STAGING_BASE_URL
|
||||
STAGING_DB_HOST
|
||||
STAGING_DB_USER
|
||||
STAGING_DB_PASSWORD
|
||||
@@ -90,24 +101,35 @@ jobs:
|
||||
STAGING_REDIS_PASSWORD
|
||||
)
|
||||
|
||||
for key in "${required[@]}"; do
|
||||
for key in "${optional[@]}"; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
missing+=("$key")
|
||||
missing_optional+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
printf '缺少必需 GitHub Actions secrets/vars:\n' | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||
printf -- '- %s\n' "${missing[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置缺失"
|
||||
echo "状态:部分配置缺失"
|
||||
echo
|
||||
echo "缺少以下 secrets/vars:"
|
||||
printf -- '- `%s`\n' "${missing[@]}"
|
||||
echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过:"
|
||||
printf -- '- `%s`\n' "${missing_optional[@]}"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置检查通过"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 下载依赖模块
|
||||
|
||||
@@ -43,6 +43,7 @@ logs/
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
!internal/handler/subscribe_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
|
||||
+71
-47
@@ -113,53 +113,77 @@ 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,
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+32
-1
@@ -81,10 +81,38 @@ 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
|
||||
@@ -104,7 +132,10 @@ type Client struct {
|
||||
|
||||
func (c *Client) Build() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
|
||||
funcMap := sprig.TxtFuncMap()
|
||||
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
|
||||
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
|
||||
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/simnet"
|
||||
)
|
||||
|
||||
// buildOmnxtSimnetConfigs is a subscription template function (registered in
|
||||
// Client.Build) that produces the per-user OmnXT SimNet JSON config array.
|
||||
//
|
||||
// It mirrors the Pro reference (NPanel-backend
|
||||
// internal/biz/public/subscription/template.go buildOmnxtSimnetConfigs):
|
||||
// - per-user simnet_psk / simnet_key_id are DERIVED from the user's
|
||||
// subscription (uuid + user_subscribe.id), never stored.
|
||||
// - the server PSK (key_id=0) is passed through as simnet_server_psk so the
|
||||
// client SDK can derive AF path/magic with the same key material.
|
||||
//
|
||||
// Template usage: {{ buildOmnxtSimnetConfigs .Proxies .UserInfo .Params | toPrettyJson }}
|
||||
func buildOmnxtSimnetConfigs(proxies []map[string]interface{}, userInfo User, params map[string]string) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, 0)
|
||||
|
||||
proxyMode := strings.TrimSpace(params["proxy_mode"])
|
||||
if proxyMode == "" {
|
||||
proxyMode = "global"
|
||||
}
|
||||
|
||||
dnsServers := []string{"1.1.1.1"}
|
||||
if raw := strings.TrimSpace(params["dns_servers"]); raw != "" {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r'
|
||||
})
|
||||
parsed := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
parsed = append(parsed, item)
|
||||
}
|
||||
}
|
||||
if len(parsed) > 0 {
|
||||
dnsServers = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user credentials derived from the subscription record (see pkg/simnet).
|
||||
userKeyID := simnet.DeriveKeyID(userInfo.SubscribeID)
|
||||
userPSK := simnet.DeriveUserPSK(userInfo.Password)
|
||||
|
||||
for _, proxy := range proxies {
|
||||
if smString(proxy["Type"]) != "simnet" {
|
||||
continue
|
||||
}
|
||||
|
||||
afEnabled := smBool(proxy["SimnetAfEnabled"])
|
||||
item := map[string]interface{}{
|
||||
"tag": smString(proxy["Name"]),
|
||||
"server_addr": smString(proxy["Server"]),
|
||||
"server_port": smInt(proxy["Port"]),
|
||||
"protocol": "simnet",
|
||||
"sni": smString(proxy["SNI"]),
|
||||
"allow_insecure": smBool(proxy["AllowInsecure"]),
|
||||
"simnet_psk": userPSK,
|
||||
"simnet_key_id": userKeyID,
|
||||
// Server PSK is required for AF path/magic/content-type derivation.
|
||||
"simnet_server_psk": smStringOrNil(proxy["SimnetPsk"]),
|
||||
"simnet_server_key_id": smInt(proxy["SimnetKeyID"]),
|
||||
"simnet_ticket_id": smStringOrNil(proxy["SimnetTicketID"]),
|
||||
"simnet_path": smDefaultString(smString(proxy["SimnetPath"]), "/simnet/session"),
|
||||
"simnet_carrier": smDefaultString(smString(proxy["SimnetCarrier"]), "h2"),
|
||||
"simnet_af_enabled": afEnabled,
|
||||
"simnet_client_max_concurrent_streams": smDefaultInt(smInt(proxy["SimnetClientMaxConcurrentStreams"]), 32),
|
||||
"simnet_client_max_streams_per_session": smDefaultInt(smInt(proxy["SimnetClientMaxStreamsPerSession"]), 512),
|
||||
"simnet_client_session_idle_timeout_secs": smDefaultInt(smInt(proxy["SimnetClientSessionIdleTimeoutSecs"]), 90),
|
||||
"simnet_client_max_udp_sessions": smDefaultInt(smInt(proxy["SimnetClientMaxUDPSessions"]), 64),
|
||||
"proxy_mode": proxyMode,
|
||||
"dns_servers": dnsServers,
|
||||
}
|
||||
if afEnabled {
|
||||
item["simnet_af_path_mode"] = smDefaultString(smString(proxy["SimnetAfPathMode"]), "api")
|
||||
item["simnet_af_path_prefix"] = smStringOrNil(proxy["SimnetAfPathPrefix"])
|
||||
item["simnet_af_path_suffix"] = smStringOrNil(proxy["SimnetAfPathSuffix"])
|
||||
item["simnet_af_magic_mode"] = smDefaultString(smString(proxy["SimnetAfMagicMode"]), "derived")
|
||||
item["simnet_af_response_jitter_ms"] = smDefaultInt(smInt(proxy["SimnetAfResponseJitterMs"]), 50)
|
||||
item["simnet_af_handshake_polymorphism"] = smBool(proxy["SimnetAfHandshakePolymorphism"])
|
||||
item["simnet_af_settings_jitter"] = smBool(proxy["SimnetAfSettingsJitter"])
|
||||
item["simnet_af_fake_header_injection"] = smBool(proxy["SimnetAfFakeHeaderInjection"])
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func smString(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func smStringOrNil(v interface{}) interface{} {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smBool(v interface{}) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
func smInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int8:
|
||||
return int(n)
|
||||
case int16:
|
||||
return int(n)
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case uint:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
case uint64:
|
||||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func smDefaultString(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func smDefaultInt(i, def int) int {
|
||||
if i == 0 {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// buildOmnxtProtocolLinks wraps each simnet config into a base64 "simnet://"
|
||||
// link, matching the Pro reference's final delivery format (migration 02140,
|
||||
// template.go buildOmnxtProtocolLinks). Template usage:
|
||||
//
|
||||
// {{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}
|
||||
// {{- end }}
|
||||
func buildOmnxtProtocolLinks(proxies []map[string]interface{}, userInfo User, params map[string]string) []string {
|
||||
configs := buildOmnxtSimnetConfigs(proxies, userInfo, params)
|
||||
result := make([]string, 0, len(configs))
|
||||
|
||||
for _, item := range configs {
|
||||
serverAddr := smString(item["server_addr"])
|
||||
serverPort := smInt(item["server_port"])
|
||||
tag := smString(item["tag"])
|
||||
if serverAddr == "" || serverPort == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
afEnabled := smBool(item["simnet_af_enabled"])
|
||||
payload := map[string]interface{}{
|
||||
"protocol": "simnet",
|
||||
"server_addr": serverAddr,
|
||||
"server_port": serverPort,
|
||||
"sni": smString(item["sni"]),
|
||||
"simnet_psk": smString(item["simnet_psk"]),
|
||||
"simnet_key_id": smInt(item["simnet_key_id"]),
|
||||
"simnet_server_psk": item["simnet_server_psk"],
|
||||
"simnet_server_key_id": smInt(item["simnet_server_key_id"]),
|
||||
"simnet_ticket_id": item["simnet_ticket_id"],
|
||||
"simnet_path": item["simnet_path"],
|
||||
"simnet_carrier": smString(item["simnet_carrier"]),
|
||||
"simnet_af_enabled": afEnabled,
|
||||
"simnet_client_max_concurrent_streams": smInt(item["simnet_client_max_concurrent_streams"]),
|
||||
"simnet_client_max_streams_per_session": smInt(item["simnet_client_max_streams_per_session"]),
|
||||
"simnet_client_session_idle_timeout_secs": smInt(item["simnet_client_session_idle_timeout_secs"]),
|
||||
"simnet_client_max_udp_sessions": smInt(item["simnet_client_max_udp_sessions"]),
|
||||
"proxy_mode": item["proxy_mode"],
|
||||
"dns_servers": item["dns_servers"],
|
||||
}
|
||||
if afEnabled {
|
||||
payload["simnet_af_path_mode"] = smString(item["simnet_af_path_mode"])
|
||||
payload["simnet_af_path_prefix"] = item["simnet_af_path_prefix"]
|
||||
payload["simnet_af_path_suffix"] = item["simnet_af_path_suffix"]
|
||||
payload["simnet_af_magic_mode"] = smString(item["simnet_af_magic_mode"])
|
||||
payload["simnet_af_response_jitter_ms"] = smInt(item["simnet_af_response_jitter_ms"])
|
||||
payload["simnet_af_handshake_polymorphism"] = smBool(item["simnet_af_handshake_polymorphism"])
|
||||
payload["simnet_af_settings_jitter"] = smBool(item["simnet_af_settings_jitter"])
|
||||
payload["simnet_af_fake_header_injection"] = smBool(item["simnet_af_fake_header_injection"])
|
||||
}
|
||||
|
||||
encoded := encodeProtocolPayload(payload)
|
||||
if encoded == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, "simnet://"+encoded+"#"+url.QueryEscape(tag))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeProtocolPayload url-encodes a payload map and base64-encodes it,
|
||||
// matching the reference encodeProtocolPayload.
|
||||
func encodeProtocolPayload(payload map[string]interface{}) string {
|
||||
values := url.Values{}
|
||||
for key, value := range payload {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
continue
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
values.Set(key, v)
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
values.Set(key, "1")
|
||||
}
|
||||
case int:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.Itoa(v))
|
||||
}
|
||||
case int32:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.FormatInt(int64(v), 10))
|
||||
}
|
||||
case int64:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.FormatInt(v, 10))
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
values.Set(key, strings.Join(v, ","))
|
||||
}
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s := smString(item); s != "" {
|
||||
items = append(items, s)
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
values.Set(key, strings.Join(items, ","))
|
||||
}
|
||||
default:
|
||||
if s := smString(v); s != "" {
|
||||
values.Set(key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery Admin API"
|
||||
desc: "Admin-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/lottery
|
||||
group: admin/lottery
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create a new activity (status=draft)"
|
||||
@handler CreateLotteryActivity
|
||||
post /activities (CreateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Update mutable activity fields"
|
||||
@handler UpdateLotteryActivity
|
||||
put /activities (UpdateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "List activities (paginated)"
|
||||
@handler ListLotteryActivities
|
||||
get /activities (ListAdminLotteryActivitiesRequest) returns (ListAdminLotteryActivitiesResponse)
|
||||
|
||||
@doc "Get one activity"
|
||||
@handler GetLotteryActivity
|
||||
get /activities/detail (AdminActivityIdRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Publish (draft/paused → running)"
|
||||
@handler PublishLotteryActivity
|
||||
post /activities/publish (AdminActivityIdRequest)
|
||||
|
||||
@doc "Pause (running → paused)"
|
||||
@handler PauseLotteryActivity
|
||||
post /activities/pause (AdminActivityIdRequest)
|
||||
|
||||
@doc "Update eligibility/chance_sources (rule-caps enforced)"
|
||||
@handler UpdateLotteryRules
|
||||
put /activities/rules (UpdateAdminLotteryRulesRequest)
|
||||
|
||||
@doc "Delete activity (soft-delete; running must be paused first)"
|
||||
@handler DeleteLotteryActivity
|
||||
delete /activities/:id (AdminActivityIdRequest)
|
||||
|
||||
@doc "Create prize"
|
||||
@handler CreateLotteryPrize
|
||||
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Update prize"
|
||||
@handler UpdateLotteryPrize
|
||||
put /prizes/:id (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Delete prize"
|
||||
@handler DeleteLotteryPrize
|
||||
delete /prizes/:id (AdminPrizeIdRequest)
|
||||
|
||||
@doc "List prizes on an activity"
|
||||
@handler ListLotteryPrizes
|
||||
get /prizes (ListAdminLotteryPrizesRequest) returns (ListAdminLotteryPrizesResponse)
|
||||
|
||||
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
||||
@handler GrantLotteryChance
|
||||
post /chances/grant (GrantAdminLotteryChanceRequest)
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
@doc "List manual-claim work orders (filter by type/status/activity/user/time)"
|
||||
@handler ListLotteryClaims
|
||||
get /claims (ListAdminLotteryClaimsRequest) returns (ListAdminLotteryClaimsResponse)
|
||||
|
||||
@doc "Summary counts for claims workbench"
|
||||
@handler LotteryClaimsSummary
|
||||
get /claims/summary returns (AdminLotteryClaimsSummary)
|
||||
|
||||
@doc "Approve a claim (reviewing -> paying)"
|
||||
@handler ApproveLotteryClaim
|
||||
post /claims/approve (AdminApproveClaimRequest)
|
||||
|
||||
@doc "Reject a claim (reviewing/paying -> rejected; user may resubmit)"
|
||||
@handler RejectLotteryClaim
|
||||
post /claims/reject (AdminRejectClaimRequest)
|
||||
|
||||
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
|
||||
@handler MarkPaidLotteryClaim
|
||||
post /claims/mark-paid (AdminMarkPaidClaimRequest)
|
||||
|
||||
@doc "List lottery draws (grant records)"
|
||||
@handler ListLotteryDraws
|
||||
get /draws (ListAdminLotteryDrawsRequest) returns (ListAdminLotteryDrawsResponse)
|
||||
}
|
||||
@@ -64,6 +64,8 @@ type (
|
||||
ServerUser {
|
||||
Id int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
// SpeedLimit 单位为 Mbps,0 表示不限速。
|
||||
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery API"
|
||||
desc: "User-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/lottery
|
||||
group: public/lottery
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Get lottery activity config + user status"
|
||||
@handler QueryLotteryConfig
|
||||
get /config (GetLotteryConfigRequest) returns (GetLotteryConfigResponse)
|
||||
|
||||
@doc "Draw once (nonce idempotent, rate limited 1/sec)"
|
||||
@handler DrawLottery
|
||||
post /draw (DrawLotteryRequest) returns (DrawLotteryResponse)
|
||||
|
||||
@doc "List my draws"
|
||||
@handler QueryLotteryRecords
|
||||
get /records (GetLotteryRecordsRequest) returns (GetLotteryRecordsResponse)
|
||||
|
||||
@doc "Claim a prize (Stage 1 returns 100010 not_claimable)"
|
||||
@handler ClaimLotteryPrize
|
||||
post /claim (ClaimLotteryPrizeRequest) returns (ClaimLotteryPrizeResponse)
|
||||
}
|
||||
+34
-3
@@ -137,9 +137,35 @@ type (
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
WithdrawalLogSummary {
|
||||
CommissionBalance int64 `json:"commission_balance"`
|
||||
LockedByPending int64 `json:"locked_by_pending"`
|
||||
AvailableToWithdraw int64 `json:"available_to_withdraw"`
|
||||
TotalHistoricalAmount int64 `json:"total_historical_amount"`
|
||||
TotalRefundedAmount int64 `json:"total_refunded_amount"`
|
||||
TotalIncomeAmount int64 `json:"total_income_amount"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
|
||||
}
|
||||
QueryCommissionReturnLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
CommissionReturnLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
EventType uint16 `json:"event_type"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
QueryCommissionReturnLogResponse {
|
||||
List []CommissionReturnLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
GetDeviceOnlineStatsResponse {
|
||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||
@@ -384,10 +410,14 @@ service ppanel {
|
||||
@handler CancelWithdrawal
|
||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log"
|
||||
@doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
|
||||
@doc "Query Commission Return Log"
|
||||
@handler QueryCommissionReturnLog
|
||||
get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse)
|
||||
|
||||
@doc "Device Online Statistics"
|
||||
@handler DeviceOnlineStatistics
|
||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||
@@ -447,3 +477,4 @@ service ppanel {
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
@@ -1045,6 +1045,51 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
# PPanel Server Simnet 协议接入实施计划
|
||||
|
||||
本文档用于指导在现有自维护后端 `/Users/Apple/code_vpn/vpn/ppanel-server` 中接入 `simnet` 协议。目标不是把 Pro 新版后端整体迁移进来,而是在保留旧系统架构、数据库主链路和现有节点管理模型的前提下,把 `simnet` 做到管理端可配置、OmnXT 节点可拉取、SlagClient 可订阅连接、用户授权和流量统计闭环。
|
||||
|
||||
参考实现来自新版 Pro 后端:`/Users/Apple/Downloads/NPanelPro-pro/NPanel-backend`。
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
当前旧后端已经有完整的 Server、Node、Subscribe、Traffic、Online User 等链路,协议配置主要保存在 Server 的 `protocols` JSON 字段里,Node 侧用 `protocol + port + address` 描述对外节点。新版 Pro 后端已经加入了 `simnet` 协议字段、管理端接口、节点兼容接口和订阅交付逻辑,但它的整体工程结构和旧仓库不同。
|
||||
|
||||
旧仓库是 Gin/goctl/Gorm 风格,核心入口包括:
|
||||
|
||||
- API 定义:`apis/admin/server.api`、`apis/node/node.api`、`apis/public/subscribe.api`、`apis/types.api`
|
||||
- 生成类型:`internal/types/types.go`
|
||||
- 管理端 Server 逻辑:`internal/logic/admin/server/*`
|
||||
- 节点服务端配置拉取:`internal/logic/server/getServerConfigLogic.go`
|
||||
- 节点用户列表拉取:`internal/logic/server/getServerUserListLogic.go`
|
||||
- 公共订阅节点返回:`internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- 节点在线与流量上报:`internal/logic/server/pushOnlineUsersLogic.go`、`internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
|
||||
新版 Pro 的关键参考入口包括:
|
||||
|
||||
- Simnet 管理端字段:`api/admin/server/v1/server.proto`
|
||||
- OmnXT 节点兼容接口:`internal/server/http_compat_server.go`
|
||||
- 公共订阅响应:`api/public/subscribe/v1/subscribe.proto`
|
||||
- 公共订阅映射:`internal/service/public/subscribe/subscribe.go`
|
||||
- UA/capability 过滤:`internal/biz/public/subscribe/subscribe.go`
|
||||
- 节点交付数据:`internal/data/delivery_node.go`
|
||||
- 协议模型和默认值:`internal/model/server/protocol.go`
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
1. 在旧后端中完整支持 `simnet` 协议的保存、查询、下发、订阅和统计。
|
||||
2. 继续使用旧系统 Server 的 `protocols` JSON 保存协议配置,不强制拆表保存管理端协议配置。
|
||||
3. 第一版支持当前实际需要的能力:H2、TLS/SNI、AF、HTTPS Fallback。
|
||||
4. Reverse 字段先纳入模型和接口,默认关闭;不在第一版强制上线 Reverse 转发能力。
|
||||
5. 管理端配置、OmnXT 服务端运行配置、SlagClient 客户端订阅配置使用不同 DTO,避免敏感字段误下发。
|
||||
6. 使用 `type + port` 唯一定位一个 Server 内的协议实例,支持同一 Server 未来存在多个协议。
|
||||
7. OmnXT 拉取配置必须校验 `secret_key`。
|
||||
8. Server 级 PSK 不得下发给普通用户。
|
||||
9. 优先设计每用户独立 Simnet Key ID/PSK,使用户隔离、封禁、重置和审计可控。
|
||||
10. SlagClient 订阅响应兼容 `protocols` JSON 和顶层 `simnet_*` 字段。
|
||||
|
||||
### 非目标
|
||||
|
||||
1. 不整体替换旧后端为 Pro 新后端。
|
||||
2. 不一次性迁移 Pro 的全部协议字段、路由系统、完整 delivery node 架构。
|
||||
3. 不第一版实现 OmniFlow 或其他新协议。
|
||||
4. 不改变现有套餐、订单、余额、邀请等业务主链路。
|
||||
5. 不把生产服务器凭据、JWT、节点 SSH 密码写入代码或文档。
|
||||
|
||||
## 3. 总体技术策略
|
||||
|
||||
最科学的迁移方式是“协议纵向切入”,而不是“代码横向搬运”。也就是沿着 `simnet` 从管理端保存到节点运行,再到用户订阅、授权、流量统计的完整链路逐层补齐。
|
||||
|
||||
建议分三段落地:
|
||||
|
||||
1. Server 侧先闭环:管理端能保存 `simnet`,OmnXT 能用 `secret_key` 拉到运行配置。
|
||||
2. User 侧再闭环:每个用户生成独立凭据,OmnXT 用户列表和 SlagClient 订阅使用同一套凭据。
|
||||
3. 运维侧最后闭环:流量、在线、到期、限额、TLS/AF/Fallback、灰度和回滚全部验证。
|
||||
|
||||
核心原则:
|
||||
|
||||
- 旧架构优先:沿用 goctl API、`internal/types`、现有 logic/model 风格。
|
||||
- DTO 分层:管理端 DTO 可以看到完整配置;节点 DTO 只给 OmnXT 运行需要;订阅 DTO 只给用户连接需要。
|
||||
- 敏感字段隔离:Server PSK、证书 DNS 环境变量、节点密钥不得进入普通用户订阅响应。
|
||||
- 渐进兼容:老协议、老客户端、老节点不受影响。
|
||||
- 可回滚:每个阶段都能通过关闭 `simnet` 协议或恢复旧接口行为回滚。
|
||||
|
||||
## 4. Simnet 数据链路
|
||||
|
||||
完整链路如下:
|
||||
|
||||
```text
|
||||
Admin UI
|
||||
-> POST /api/v1/admin/server/create or update
|
||||
-> Server.protocols JSON contains type=simnet
|
||||
|
||||
OmnXT Node
|
||||
-> GET /api/v1/server/config?server_id=...&protocol=simnet&secret_key=...
|
||||
-> receives server runtime config, including server-side PSK and TLS/AF/Fallback settings
|
||||
|
||||
OmnXT Node
|
||||
-> GET /api/v1/server/user/list?server_id=...&protocol=simnet&secret_key=...
|
||||
-> receives active user authorization list and per-user simnet credentials
|
||||
|
||||
SlagClient
|
||||
-> GET /api/v1/public/subscribe?token=... with capability headers
|
||||
-> receives node address, port, TLS/SNI, path, AF/Fallback public fields and user credential
|
||||
|
||||
OmnXT Node
|
||||
-> POST traffic / online user report
|
||||
-> backend maps simnet user credential to user subscribe and records traffic
|
||||
```
|
||||
|
||||
`simnet` 的运行配置不能只靠 `server.protocols` 原样下发,因为同一份 JSON 同时包含管理端字段、Server 密钥字段和用户连接字段。必须在每个出口做字段筛选和转换。
|
||||
|
||||
## 5. 阶段 0:建立基线与确认契约
|
||||
|
||||
### 目标
|
||||
|
||||
确认旧后端、OmnXT、SlagClient 对 `simnet` 的最小契约,先把边界钉牢,避免后续实现时字段名、鉴权方式或客户端解析格式反复改。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 从新版 Pro 提取 `simnet` 管理字段、服务端字段、订阅字段的差异表。
|
||||
2. 用当前 OmnXT 安装脚本部署的版本抓取真实请求路径和请求参数。
|
||||
3. 用 SlagClient 抓取订阅请求 header,确认 capability header 名称和版本值。
|
||||
4. 确认 `secret_key` 当前在旧仓库 `internal/middleware/serverMiddleware.go` 或节点接口 handler 中的校验方式。
|
||||
5. 确认 `server_id + protocol` 是否已经足够定位节点运行配置;如果端口也会重复,需要补充 `port` 查询参数。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
本阶段原则上不改业务代码,只新增测试夹具或临时验证脚本。可新增:
|
||||
|
||||
- `tests/simnet/fixtures/`
|
||||
- `docs/simnet-contract.md`,如需要更细的契约文档
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 需要可运行的旧后端本地环境或测试库。
|
||||
- 需要 OmnXT 当前版本真实请求样本。
|
||||
- 需要 SlagClient 当前版本订阅响应解析规则。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 明确 OmnXT 配置接口路径、方法、请求参数和响应字段。
|
||||
2. 明确 SlagClient 识别 `simnet` 的字段格式。
|
||||
3. 明确 capability header 优先级:先 capability header,再 User-Agent 兜底。
|
||||
4. 明确 `type + port` 是协议实例唯一键。
|
||||
|
||||
### 回滚点
|
||||
|
||||
本阶段不涉及生产行为,无需业务回滚。
|
||||
|
||||
## 6. 阶段 1:协议模型与参数校验
|
||||
|
||||
### 目标
|
||||
|
||||
让旧后端的 `Protocol` 类型可以完整表达第一版 `simnet` 配置,并在创建/更新 Server 时有默认值和校验。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `apis/types.api` 的 `Protocol` 结构加入 `simnet` 字段。
|
||||
2. 重新生成 `internal/types/types.go`。
|
||||
3. 在 `internal/model/node` 中的协议模型加入同名 JSON 字段,保证 Server 的 `protocols` JSON 能完整 marshal/unmarshal。
|
||||
4. 新增 `simnet` 默认值函数,例如 `ApplySimnetDefaults`。
|
||||
5. 新增 `simnet` 参数校验函数,例如 `ValidateSimnetProtocol`。
|
||||
6. 校验 `type + port` 唯一,避免同一 Server 下出现两个 `simnet:443`。
|
||||
7. 限制第一版允许值:`simnet_carrier=h2`、`security=tls|none`,生产建议默认 `tls`。
|
||||
8. 校验 path 必须以 `/` 开头,fallback host 非空时端口必须在 1-65535。
|
||||
9. 校验 `simnet_psk` 最小长度和字符集;自动生成时使用安全随机。
|
||||
|
||||
### 字段范围
|
||||
|
||||
核心字段:
|
||||
|
||||
```text
|
||||
simnet_psk
|
||||
simnet_key_id
|
||||
simnet_ticket_id
|
||||
simnet_path
|
||||
simnet_carrier
|
||||
```
|
||||
|
||||
TLS 字段:
|
||||
|
||||
```text
|
||||
security
|
||||
sni
|
||||
allow_insecure
|
||||
cert_mode
|
||||
cert_dns_provider
|
||||
cert_dns_env
|
||||
```
|
||||
|
||||
AF 字段:
|
||||
|
||||
```text
|
||||
simnet_af_enabled
|
||||
simnet_af_path_mode
|
||||
simnet_af_path_prefix
|
||||
simnet_af_path_suffix
|
||||
simnet_af_magic_mode
|
||||
simnet_af_response_jitter_ms
|
||||
simnet_af_handshake_polymorphism
|
||||
simnet_af_settings_jitter
|
||||
simnet_af_fake_header_injection
|
||||
```
|
||||
|
||||
Fallback 字段:
|
||||
|
||||
```text
|
||||
simnet_fallback_enabled
|
||||
simnet_fallback_target_scheme
|
||||
simnet_fallback_target_host
|
||||
simnet_fallback_target_port
|
||||
simnet_fallback_host_header
|
||||
simnet_fallback_tls_sni
|
||||
```
|
||||
|
||||
Reverse 字段:
|
||||
|
||||
```text
|
||||
simnet_reverse_enabled
|
||||
simnet_reverse_listen_addr
|
||||
simnet_reverse_listen_port
|
||||
simnet_reverse_target_host
|
||||
simnet_reverse_target_port
|
||||
```
|
||||
|
||||
### 默认值
|
||||
|
||||
建议默认值如下:
|
||||
|
||||
```text
|
||||
port: 443
|
||||
simnet_path: /simnet/session
|
||||
simnet_carrier: h2
|
||||
security: tls
|
||||
allow_insecure: false
|
||||
simnet_af_path_mode: api
|
||||
simnet_af_magic_mode: derived
|
||||
simnet_af_response_jitter_ms: 1
|
||||
simnet_reverse_enabled: false
|
||||
simnet_reverse_listen_addr: 127.0.0.1
|
||||
simnet_fallback_enabled: true
|
||||
simnet_fallback_target_scheme: https
|
||||
simnet_fallback_target_port: 443
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/types.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/model/node/*` 或实际定义 `node.Protocol` 的文件
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- 可新增 `internal/logic/admin/server/protocol_simnet.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 0 的字段契约。
|
||||
- goctl 代码生成命令可用。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 管理端提交 `type=simnet` 时,Server 可以保存完整 JSON。
|
||||
2. 未传默认字段时自动补齐默认值。
|
||||
3. 非法 path、非法 port、重复 `type + port` 会被拒绝。
|
||||
4. 旧协议保存和返回不变。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭管理端提交 `simnet` 的入口校验;或恢复 `apis/types.api` 和生成类型,旧协议数据仍可继续工作。
|
||||
|
||||
## 7. 阶段 2:管理端 Server 接口
|
||||
|
||||
### 目标
|
||||
|
||||
让管理端 Server 创建、更新、查询能完整展示和编辑 `simnet`,并保持 Node 更新接口与 Server 协议配置一致。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 更新 `CreateServerRequest`、`UpdateServerRequest`、`FilterServerListResponse`、`GetServerProtocolsResponse` 中的协议字段。
|
||||
2. 在 create/update Server 时对每个 protocol 先做 normalize,再落库。
|
||||
3. 在 filter/list/detail 接口中返回规范化后的 `simnet` 字段。
|
||||
4. 检查 `CreateNodeRequest`、`UpdateNodeRequest` 是否允许 `protocol=simnet`。
|
||||
5. Node 端 `node_type=front` 的创建/更新要允许 `simnet`,并校验其 `port` 与 Server 里的 `simnet` 协议端口一致。
|
||||
6. 如果管理端前端需要协议选项,`GetServerProtocols` 要返回 `simnet`,并带默认字段方便 UI 填充。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/admin/server.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||
- `internal/logic/admin/server/createNodeLogic.go`
|
||||
- `internal/logic/admin/server/updateNodeLogic.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 协议模型已经可表达 `simnet`。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 管理端能创建一个 Server,包含 `simnet:443`。
|
||||
2. 管理端能更新 `simnet_path`、`sni`、AF 和 fallback 字段。
|
||||
3. 管理端节点列表显示 `HK simnet` 这类节点时,协议类型不丢失。
|
||||
4. `GetServerProtocols` 返回的 `protocols` JSON 与数据库一致且字段完整。
|
||||
|
||||
### 回滚点
|
||||
|
||||
从管理端把 `simnet` 协议 disabled,保留数据但不对节点下发;或回滚 Server 相关 API 和 logic。
|
||||
|
||||
## 8. 阶段 3:OmnXT 服务端配置下发
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 节点通过旧后端节点 API 拉到可运行的 `simnet` 服务端配置。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 检查 `apis/node/node.api` 中 `GetServerConfigRequest` 是否有 `secret_key`、`server_id`、`protocol`。
|
||||
2. 在 `GetServerConfigLogic` 中加入 `protocol=simnet` 分支。
|
||||
3. 根据 `server_id + protocol + port` 找到启用的 `simnet` 协议配置。
|
||||
4. 验证 `secret_key`,失败时返回明确错误,并记录来源 IP 和 server_id。
|
||||
5. 构造 OmnXT 服务端运行 DTO,包含 Server 运行需要的 PSK、path、carrier、TLS、SNI、AF、fallback、reverse 默认关闭字段。
|
||||
6. 不把管理端专用字段、无关协议字段原样塞给 OmnXT。
|
||||
7. 缓存 key 要包含 `server_id + protocol + port`,避免同端口多协议污染缓存。
|
||||
8. OmnXT 配置变更后要能通过更新 Server 或清理缓存生效。
|
||||
|
||||
### 服务端 DTO 建议
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "simnet",
|
||||
"port": 443,
|
||||
"listen": ":443",
|
||||
"simnet_psk": "server-side-secret",
|
||||
"simnet_path": "/simnet/session",
|
||||
"simnet_carrier": "h2",
|
||||
"security": "tls",
|
||||
"sni": "example.com",
|
||||
"allow_insecure": false,
|
||||
"simnet_af_enabled": true,
|
||||
"simnet_fallback_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/getServerConfigLogic.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- `internal/middleware/serverMiddleware.go`
|
||||
- 可新增 `internal/logic/server/simnet_config.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 和阶段 2。
|
||||
- OmnXT 实际接口字段确认完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `secret_key` 正确时,OmnXT 能拉到 `simnet` 服务端配置。
|
||||
2. `secret_key` 错误时,请求被拒绝。
|
||||
3. 修改管理端 `simnet_path` 后,OmnXT 重启或刷新能拿到新 path。
|
||||
4. Server PSK 只出现在 OmnXT 服务端配置中,不出现在普通用户订阅中。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭 `simnet.enable` 或回滚 `GetServerConfigLogic` 的 `simnet` 分支;旧协议节点不受影响。
|
||||
|
||||
## 9. 阶段 4:用户级 Simnet 凭据
|
||||
|
||||
### 目标
|
||||
|
||||
为每个有效用户订阅生成独立 `simnet` 凭据,避免所有用户共享 Server PSK,支持单用户封禁、重置和流量归属。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 新增用户级凭据模型,建议按 `user_subscribe_id + server_id + protocol + port` 维度唯一。
|
||||
2. 字段建议包括:`id`、`user_id`、`user_subscribe_id`、`server_id`、`protocol`、`port`、`key_id`、`psk`、`ticket_id`、`enabled`、`created_at`、`updated_at`、`rotated_at`。
|
||||
3. 添加数据库 migration,并在初始化兼容逻辑中保证表存在。
|
||||
4. 用户第一次订阅或节点第一次拉用户列表时懒生成凭据。
|
||||
5. 支持管理员重置某个用户订阅 token 时同步重置 `simnet` 凭据,避免旧凭据继续可用。
|
||||
6. 凭据生成使用加密安全随机;`key_id` 可用递增 id 或稳定 hash,但必须避免全局冲突。
|
||||
7. 保留 `ticket_id` 字段,第一版可为空或由 OmnXT 需要时生成。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `internal/model/user/*` 或新增 `internal/model/simnet/*`
|
||||
- `initialize/migrate/*`
|
||||
- `initialize/schema_compat.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- 用户订阅 token 重置逻辑:`internal/logic/admin/user/resetUserSubscribeTokenHandler.go` 对应 logic
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 0 确认 OmnXT 和 SlagClient 需要的用户凭据格式。
|
||||
- 阶段 1 的协议模型完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 同一用户同一节点多次订阅拿到稳定凭据。
|
||||
2. 不同用户拿到不同凭据。
|
||||
3. 重置用户订阅 token 后旧凭据失效,新凭据生效。
|
||||
4. 凭据表有唯一约束,重复生成不会产生两条有效凭据。
|
||||
|
||||
### 回滚点
|
||||
|
||||
可以停止向 OmnXT 下发 `simnet` 用户授权,并禁用 `simnet` 节点。数据库表可保留,不影响旧协议。
|
||||
|
||||
## 10. 阶段 5:OmnXT 用户授权同步
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 拉取用户列表时获得 `simnet` 可认证用户,并且用户到期、限额、禁用、套餐节点组变化后同步生效。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `GetServerUserListLogic` 中加入 `simnet` 用户映射。
|
||||
2. 沿用旧系统的有效用户筛选条件:订阅有效、未到期、流量未超限、用户未禁用、节点组有权限。
|
||||
3. 对 `simnet` 用户返回 `user_id`、`subscribe_id`、`uuid`、`key_id`、`psk`、`ticket_id`、限速字段。
|
||||
4. OmnXT 请求 `protocol=simnet` 时,只返回有 `simnet` 权限的用户。
|
||||
5. 缓存 key 加入 `protocol + port`,用户订阅变更、流量变更、节点组变更时能失效。
|
||||
6. 对 `hysteria2` 等旧兼容映射不做破坏;`normalizeServerUserListProtocol` 仅新增 `simnet` 透传。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- 用户订阅、节点组、流量相关 model/service
|
||||
- 可新增 `internal/logic/server/simnet_user.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- 现有用户有效性判断需要梳理清楚。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. OmnXT 拉用户列表时能看到有效用户的 `simnet` 凭据。
|
||||
2. 用户到期、禁用或流量超限后,从 OmnXT 用户列表消失。
|
||||
3. 套餐节点组取消该节点后,从 OmnXT 用户列表消失。
|
||||
4. 老协议用户列表响应不变。
|
||||
|
||||
### 回滚点
|
||||
|
||||
保留凭据表,但关闭 `GetServerUserListLogic` 的 `simnet` 分支或禁用节点。
|
||||
|
||||
## 11. 阶段 6:公共订阅与 SlagClient
|
||||
|
||||
### 目标
|
||||
|
||||
让 SlagClient 冷启动、重启、重新订阅时都能拿到完整 `simnet` 节点,并正确构造连接。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `apis/public/subscribe.api` 的 `UserSubscribeNodeInfo` 加入用户连接需要的顶层 `simnet_*` 字段。
|
||||
2. 保留 `protocols` JSON,确保 SlagClient 旧解析路径仍可读取。
|
||||
3. 在 `QueryUserSubscribeNodeListLogic` 中解析 Server 的 `protocols` JSON,并把匹配 `node.protocol + node.port` 的 `simnet` 配置映射到订阅响应。
|
||||
4. 订阅响应只下发用户级 `simnet_key_id`、用户级 `simnet_psk`、可公开 path/carrier/TLS/SNI/AF/Fallback 字段。
|
||||
5. 不下发 Server 级 `simnet_psk`、DNS provider env、管理端密钥字段。
|
||||
6. 新增 capability header 判断,例如 `X-Client-Capabilities: simnet` 或当前 SlagClient 实际 header。
|
||||
7. 如果没有 capability header,则使用 User-Agent 作为兼容兜底;不应单纯依赖 UA。
|
||||
8. 对不支持 `simnet` 的客户端隐藏 `simnet` 节点,避免客户端崩溃或展示不可用节点。
|
||||
9. 如果 SlagClient 同时支持 `protocols` JSON 和顶层字段,优先让顶层字段完整,`protocols` 作为兼容冗余。
|
||||
|
||||
### 订阅 DTO 建议
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "HK simnet",
|
||||
"protocol": "simnet",
|
||||
"port": 443,
|
||||
"address": "node.example.com",
|
||||
"sni": "net.example.com",
|
||||
"simnet_key_id": 10001,
|
||||
"simnet_psk": "user-side-secret",
|
||||
"simnet_ticket_id": "",
|
||||
"simnet_path": "/simnet/session",
|
||||
"simnet_carrier": "h2",
|
||||
"security": "tls",
|
||||
"allow_insecure": false,
|
||||
"simnet_af_enabled": true,
|
||||
"simnet_af_path_mode": "api",
|
||||
"simnet_af_magic_mode": "derived",
|
||||
"simnet_fallback_enabled": true,
|
||||
"simnet_fallback_target_scheme": "https",
|
||||
"simnet_fallback_target_host": "www.example.com",
|
||||
"simnet_fallback_target_port": 443
|
||||
}
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/public/subscribe.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/common/subscriptionTrace.go`,如有订阅 UA 或设备记录
|
||||
- 可新增 `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- SlagClient capability header 契约确认。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. SlagClient 冷启动订阅后能看到 `simnet` 节点。
|
||||
2. SlagClient 重启后仍能从订阅恢复连接配置。
|
||||
3. 不支持 `simnet` 的客户端订阅不返回 `simnet` 节点。
|
||||
4. 普通用户订阅响应不包含 Server 级 PSK。
|
||||
|
||||
### 回滚点
|
||||
|
||||
订阅侧隐藏 `simnet` 节点或关闭 capability 开关;旧协议订阅不受影响。
|
||||
|
||||
## 12. 阶段 7:流量和在线用户映射
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 上报的 `simnet` 在线用户和流量能正确归属到用户订阅,并触发旧系统现有的限额、日志、后台统计。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 确认 OmnXT 上报用户标识是 `uuid`、`key_id`、`user_id` 还是其他字段。
|
||||
2. 如果 OmnXT 上报 `key_id`,后端通过用户级凭据表反查 `user_subscribe_id` 和 `user_id`。
|
||||
3. 如果 OmnXT 上报 `uuid`,需要确认 `uuid` 与 `simnet` 凭据绑定关系,不允许跨用户伪造。
|
||||
4. 在 `serverPushUserTrafficLogic` 中加入 `simnet` 标识解析。
|
||||
5. 在 `pushOnlineUsersLogic` 中加入 `simnet` 在线用户映射。
|
||||
6. 更新后台节点在线数统计,确保 `simnet:443` 与其他协议隔离。
|
||||
7. 失败上报要记录协议、server_id、port、用户标识和错误原因,方便排查。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||
- `internal/model/traffic/*`
|
||||
- `internal/model/node/*`
|
||||
- 凭据表 model
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- OmnXT 上报格式确认。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `simnet` 连接产生流量后,用户已用流量增加。
|
||||
2. 节点后台能看到 `simnet` 在线人数。
|
||||
3. 用户超限后 OmnXT 用户列表不再包含该用户。
|
||||
4. 旧协议流量统计不受影响。
|
||||
|
||||
### 回滚点
|
||||
|
||||
禁用 `simnet` 流量上报分支或关闭 `simnet` 节点;旧协议统计不受影响。
|
||||
|
||||
## 13. 阶段 8:TLS、AF 与 Fallback
|
||||
|
||||
### 目标
|
||||
|
||||
把当前实际部署需要的 TLS/SNI、AF 和 HTTPS Fallback 做到可配置、可验证、可运维。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. TLS:支持 `security=tls`、`sni`、`allow_insecure=false`。
|
||||
2. 证书模式:第一版支持 `cert_mode=http`;DNS provider 字段先保留,不在普通订阅下发。
|
||||
3. AF:支持 `simnet_af_enabled`、`path_mode=api`、`magic_mode=derived`、`response_jitter_ms`。
|
||||
4. Fallback:支持 fallback scheme、host、port、host header、TLS SNI。
|
||||
5. Reverse:字段保存和下发给 OmnXT,但默认关闭;如果开启必须要求 target host/port 完整。
|
||||
6. 添加配置快照日志,OmnXT 拉取时打印非敏感字段,便于确认线上配置是否生效。
|
||||
7. 对真实节点做 `443` 端口监听、证书申请、fallback 站点访问验证。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `internal/logic/admin/server/protocol_simnet.go`
|
||||
- `internal/logic/server/simnet_config.go`
|
||||
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
- `etc/ppanel.yaml`,如需要新增全局开关
|
||||
- 节点部署文档或运维脚本,视 OmnXT 实际需求决定
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 3 OmnXT 配置下发。
|
||||
- 节点服务器域名、证书、端口和 fallback 目标准备完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. OmnXT 能在 `443` 启动 `simnet` H2 TLS。
|
||||
2. SNI 与证书匹配。
|
||||
3. AF 开启后 SlagClient 仍可连接。
|
||||
4. Fallback 目标在非协议请求时可访问。
|
||||
5. OmnXT 重启后配置仍然生效。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭 AF 或 fallback;必要时把 `simnet.enable=false`,保留旧协议节点承载用户。
|
||||
|
||||
## 14. 阶段 9:自动化测试
|
||||
|
||||
### 目标
|
||||
|
||||
用测试保护 `simnet` 的关键契约,减少后续修改协议字段时再次出现“面板有配置、节点拿不到、客户端不识别”的问题。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 协议模型测试:默认值、校验、marshal/unmarshal。
|
||||
2. 管理端测试:create/update Server 保存 `simnet` 字段完整。
|
||||
3. 节点配置测试:`secret_key` 正确/错误、`simnet` DTO 字段筛选。
|
||||
4. 用户凭据测试:生成稳定性、用户隔离、重置失效。
|
||||
5. 订阅测试:capability header 支持时返回 `simnet`;不支持时隐藏。
|
||||
6. 敏感字段测试:普通订阅中不得出现 Server PSK、DNS env。
|
||||
7. 流量测试:OmnXT 上报 `key_id` 后可归属用户。
|
||||
8. 回归测试:现有 vless、trojan、hysteria2、shadowsocks 订阅不变。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `tests/acceptance/*`
|
||||
- `internal/logic/admin/server/*_test.go`
|
||||
- `internal/logic/server/*_test.go`
|
||||
- `internal/logic/public/subscribe/*_test.go`
|
||||
- `internal/model/simnet/*_test.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 到阶段 7 基本实现完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `go test ./...` 通过,或项目当前可执行测试集全部通过。
|
||||
2. 新增测试能覆盖 Server、OmnXT、SlagClient、Traffic 四条主链路。
|
||||
3. 任意敏感字段泄露测试失败时,CI 阻断。
|
||||
|
||||
### 回滚点
|
||||
|
||||
测试本身不影响生产;如果某阶段实现回滚,相应测试应标记待实现或一并回滚。
|
||||
|
||||
## 15. 阶段 10:灰度发布与回滚
|
||||
|
||||
### 目标
|
||||
|
||||
把 `simnet` 以可控方式上线,先让一个节点和少量测试用户跑通,再扩大范围。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 增加全局或配置级开关:`simnet_enabled`。
|
||||
2. 管理端先创建一个独立测试 Server 和一个 `simnet` front node。
|
||||
3. 只给测试套餐或测试节点组分配该节点。
|
||||
4. 部署 OmnXT,确认能拉配置、拉用户、启动监听。
|
||||
5. 用测试用户订阅 SlagClient,验证冷启动、重启、切换网络、重拉订阅。
|
||||
6. 观察在线用户、流量上报、错误日志、证书续期和 fallback 访问。
|
||||
7. 稳定后把节点加入正式套餐节点组。
|
||||
8. 保留旧协议节点作为回退路径,不把全部用户一次性切到 `simnet`。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `etc/ppanel.yaml`,如需要全局开关
|
||||
- `internal/config/config.go`
|
||||
- `internal/svc/serviceContext.go`
|
||||
- 运维部署文档
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 到阶段 9 完成。
|
||||
- 测试节点服务器、域名、证书、OmnXT 可用。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 测试用户能稳定连接 `simnet`。
|
||||
2. SlagClient 重启后无需人工操作即可恢复。
|
||||
3. OmnXT 重启后能自动拉配置和用户授权。
|
||||
4. 管理端能看到在线和流量。
|
||||
5. 关闭 `simnet` 后用户可回退到旧协议节点。
|
||||
|
||||
### 回滚点
|
||||
|
||||
1. 管理端将 `simnet` 协议 `enable=false`。
|
||||
2. 从套餐节点组移除 `simnet` 节点。
|
||||
3. OmnXT 停止 `simnet` inbound。
|
||||
4. 回滚后端到上一版本。
|
||||
5. 保留凭据表和字段,后续排查后可再次启用。
|
||||
|
||||
## 16. 文件改动范围
|
||||
|
||||
预计完整生产可用版本会影响 27-45 个业务/配置文件、12-20 个测试文件,新增约 3,000-6,000 行代码和测试。实际数量取决于 goctl 生成文件体积、现有 model 组织方式和 OmnXT/SlagClient 契约是否稳定。
|
||||
|
||||
### 必改范围
|
||||
|
||||
- `apis/types.api`
|
||||
- `apis/admin/server.api`
|
||||
- `apis/node/node.api`
|
||||
- `apis/public/subscribe.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/model/node/*`
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||
- `internal/logic/server/getServerConfigLogic.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
|
||||
### 可能新增范围
|
||||
|
||||
- `internal/model/simnet/*`
|
||||
- `internal/logic/admin/server/protocol_simnet.go`
|
||||
- `internal/logic/server/simnet_config.go`
|
||||
- `internal/logic/server/simnet_user.go`
|
||||
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
- `initialize/migrate/*simnet*`
|
||||
- `tests/simnet/*`
|
||||
- `docs/simnet-contract.md`
|
||||
|
||||
### 前端联动范围
|
||||
|
||||
如果管理端前端也要同步配置,需要在前端仓库补齐:
|
||||
|
||||
- Server 创建/编辑表单的 `simnet` 协议字段
|
||||
- 协议默认值填充
|
||||
- 字段校验提示
|
||||
- Node 创建/更新时允许 `protocol=simnet`
|
||||
- 隐藏 Server PSK 的展示或复制入口
|
||||
|
||||
## 17. 提交拆分
|
||||
|
||||
建议按以下提交拆分,方便 review 和回滚:
|
||||
|
||||
1. `simnet: add protocol model fields and validation`
|
||||
2. `simnet: support admin server create/update/list`
|
||||
3. `simnet: expose server runtime config for OmnXT`
|
||||
4. `simnet: add per-user credentials`
|
||||
5. `simnet: sync OmnXT user authorization`
|
||||
6. `simnet: expose public subscribe fields for SlagClient`
|
||||
7. `simnet: map traffic and online reports`
|
||||
8. `simnet: add tls af fallback handling`
|
||||
9. `simnet: add tests and rollout switch`
|
||||
|
||||
每个提交都应该能单独说明行为变化,并尽量避免把 goctl 生成文件和手写逻辑混在一个巨大提交里。如果生成文件不可避免较大,提交说明中要明确哪些是生成结果。
|
||||
|
||||
## 18. 验收标准
|
||||
|
||||
最终验收必须覆盖下面场景:
|
||||
|
||||
1. 管理端能创建 Server,协议为 `simnet`,端口 `443`,TLS/SNI、AF、Fallback 字段保存完整。
|
||||
2. 管理端能创建或更新 Node,`protocol=simnet`,`address` 指向实际节点服务器。
|
||||
3. OmnXT 使用正确 `secret_key` 能拉取 `simnet` 服务端运行配置。
|
||||
4. OmnXT 使用错误 `secret_key` 被拒绝。
|
||||
5. OmnXT 重启后自动恢复 `simnet` inbound。
|
||||
6. 有效用户能通过 OmnXT 用户列表获得授权。
|
||||
7. 不同用户的 `simnet_key_id` 或 `simnet_psk` 不相同。
|
||||
8. 用户禁用、到期或流量超限后,OmnXT 用户列表移除该用户。
|
||||
9. SlagClient 冷启动能通过订阅拿到 `simnet` 节点并连接。
|
||||
10. SlagClient 重启后不丢失协议配置。
|
||||
11. 不支持 `simnet` 的客户端订阅不会收到 `simnet` 节点。
|
||||
12. 普通用户订阅响应不泄露 Server PSK、DNS provider env、节点 `secret_key`。
|
||||
13. `simnet` 连接产生流量后,用户流量、节点流量、后台日志同步更新。
|
||||
14. 关闭 `simnet` 后,旧协议订阅、节点运行和流量统计不受影响。
|
||||
15. `go test ./...` 或项目当前有效测试集通过。
|
||||
|
||||
## 19. 风险清单
|
||||
|
||||
| 风险 | 影响 | 控制方式 |
|
||||
| --- | --- | --- |
|
||||
| Server PSK 被下发给普通用户 | 所有用户共享密钥,泄露后整节点风险扩大 | DTO 分层,订阅敏感字段测试阻断 |
|
||||
| OmnXT 和后端字段名不一致 | 节点启动失败或配置不生效 | 阶段 0 固化契约,用真实 OmnXT 请求回放测试 |
|
||||
| SlagClient 只读顶层字段或只读 protocols JSON | 客户端拿到节点但无法连接 | 双格式兼容,顶层字段和 protocols 都保持可读 |
|
||||
| 单用户凭据缺失 | 无法隔离用户,封禁和流量归属困难 | 阶段 4 必须先做凭据表,不走全员共享 PSK |
|
||||
| capability 判断不准确 | 老客户端看到不可用节点 | capability header 优先,UA 只兜底,默认隐藏不支持客户端 |
|
||||
| 缓存 key 未包含 port | 多协议或同协议多端口串配置 | cache key 包含 `server_id + protocol + port` |
|
||||
| 流量上报标识不明确 | 用户流量无法入账或串账 | 与 OmnXT 明确上报 `key_id`,后端反查凭据表 |
|
||||
| TLS/证书/fallback 运维失败 | 节点 443 无法正常服务 | 灰度节点先跑,保留旧协议回退 |
|
||||
| goctl 生成覆盖手写改动 | 代码冲突或字段丢失 | 所有类型先改 api 文件,再生成;手写扩展放独立文件 |
|
||||
|
||||
## 20. 工期估算
|
||||
|
||||
在 OmnXT 和 SlagClient 契约清楚、测试环境可用的情况下:
|
||||
|
||||
- 阶段 0:0.5-1 天
|
||||
- 阶段 1-2:1.5-2 天
|
||||
- 阶段 3:1-1.5 天
|
||||
- 阶段 4:1.5-2 天
|
||||
- 阶段 5:1-1.5 天
|
||||
- 阶段 6:1-1.5 天
|
||||
- 阶段 7:1-2 天
|
||||
- 阶段 8:1 天
|
||||
- 阶段 9:2-3 天
|
||||
- 阶段 10:1 天
|
||||
|
||||
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
|
||||
|
||||
## 21. 推荐执行顺序
|
||||
|
||||
第一周先完成最小闭环:
|
||||
|
||||
1. 阶段 0:确认契约。
|
||||
2. 阶段 1:协议模型与校验。
|
||||
3. 阶段 2:管理端保存和查询。
|
||||
4. 阶段 3:OmnXT 配置下发。
|
||||
|
||||
第二周完成用户链路:
|
||||
|
||||
1. 阶段 4:用户级凭据。
|
||||
2. 阶段 5:OmnXT 用户授权。
|
||||
3. 阶段 6:SlagClient 订阅。
|
||||
4. 阶段 7:流量和在线用户映射。
|
||||
|
||||
最后做生产化:
|
||||
|
||||
1. 阶段 8:TLS、AF、Fallback 运维验证。
|
||||
2. 阶段 9:自动化测试补齐。
|
||||
3. 阶段 10:灰度发布和回滚演练。
|
||||
|
||||
## 22. 当前结论
|
||||
|
||||
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
|
||||
|
||||
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
|
||||
@@ -82,6 +82,7 @@ require (
|
||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/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
|
||||
@@ -145,6 +146,7 @@ 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
|
||||
|
||||
@@ -54,6 +54,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.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=
|
||||
@@ -395,6 +397,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.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=
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 02156 抽奖活动 Stage 1 回滚
|
||||
-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前
|
||||
-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。
|
||||
|
||||
DROP TABLE IF EXISTS `lottery_eligibility_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_prize_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_draw`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_grant`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_balance`;
|
||||
DROP TABLE IF EXISTS `lottery_prize`;
|
||||
DROP TABLE IF EXISTS `lottery_activity`;
|
||||
@@ -0,0 +1,125 @@
|
||||
-- 02156 抽奖活动 Stage 1(后端核心闭环)
|
||||
--
|
||||
-- 新建 7 张表 + 全部索引 + 幂等约束。
|
||||
-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检
|
||||
-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。
|
||||
--
|
||||
-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删):
|
||||
-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品
|
||||
-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw
|
||||
-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行
|
||||
-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会)
|
||||
-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1
|
||||
-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_activity` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题',
|
||||
`description` TEXT COMMENT '活动描述(Markdown)',
|
||||
`start_at` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`end_at` DATETIME NOT NULL COMMENT '结束时间',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended',
|
||||
`grid_size` TINYINT NOT NULL DEFAULT 8 COMMENT '前端九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格;老 schema 是 9)',
|
||||
`eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
|
||||
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant)',
|
||||
`unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status_time` (`status`, `start_at`, `end_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none',
|
||||
`name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称',
|
||||
`icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3})',
|
||||
`weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)',
|
||||
`total_stock` BIGINT COMMENT '总库存(NULL 表示无限)',
|
||||
`remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL)',
|
||||
`is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)',
|
||||
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`),
|
||||
KEY `idx_activity_fallback` (`activity_id`, `is_fallback`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_balance` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1)',
|
||||
`total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)',
|
||||
`total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_grant` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant',
|
||||
`source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}")',
|
||||
`amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`),
|
||||
KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_draw` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID)',
|
||||
`prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL)',
|
||||
`is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)',
|
||||
`dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed',
|
||||
`dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)',
|
||||
`dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)',
|
||||
`drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`),
|
||||
KEY `idx_user_time` (`user_id`, `drawn_at`),
|
||||
KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`),
|
||||
KEY `idx_activity_prize` (`activity_id`, `prize_id`),
|
||||
KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id)',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(快照)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)',
|
||||
`unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required)',
|
||||
`evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02157 抽奖发奖账本回滚
|
||||
DROP TABLE IF EXISTS `lottery_grant_ledger`;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 02157 抽奖发奖账本(PR B)
|
||||
--
|
||||
-- 目的:以 external_ref 作为 DB 层唯一键,做每个 draw 的发奖幂等。
|
||||
-- 各 PrizeHandler.Dispatch 内先 SELECT/INSERT lottery_grant_ledger,命中即幂等返回,
|
||||
-- 未命中再调下游发放(UpdateSubscribe / UpdateCommission + WriteCommissionLog),
|
||||
-- 全部在同一 tx 内完成 → 抽奖事务与发奖账本同生共死。
|
||||
--
|
||||
-- 关键唯一索引:external_ref。惯例值 = "lottery:{activity_id}:{draw_id}"。
|
||||
-- handler_type:与 lottery_prize.type 一致(vpn_duration / commission / …),用于统计。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_grant_ledger` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`external_ref` VARCHAR(128) NOT NULL COMMENT '幂等键:lottery:{activity_id}:{draw_id}',
|
||||
`handler_type` VARCHAR(32) NOT NULL COMMENT 'handler 类型,与 lottery_prize.type 对齐',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID(家庭组已归位到 owner)',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量(天/佣金金额,单位与 handler 一致)',
|
||||
`payload` JSON COMMENT '发放后的关键结果快照(订阅 ID、佣金前后余额等)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发放完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_external_ref` (`external_ref`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖发奖账本(幂等键 = external_ref)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02158 admin_action_log 回滚
|
||||
DROP TABLE IF EXISTS `admin_action_log`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
|
||||
--
|
||||
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
|
||||
-- 与合规审查。actor_user_id 是操作者的 user.id;action 是操作动作
|
||||
-- (lottery.activity.create / lottery.prize.update / lottery.rules.put / ...);
|
||||
-- target_ids 是被操作对象的主键数组(JSON);request_hash 是请求 body 的 sha1
|
||||
-- 摘要(对同一批次多次写入去重);ip/user_agent 从上下文取。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin_action_log` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` BIGINT UNSIGNED NOT NULL COMMENT '操作者 user.id',
|
||||
`action` VARCHAR(64) NOT NULL COMMENT '动作 code(点分层级)',
|
||||
`target_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '被操作对象 ID 逗号分隔或 JSON 数组',
|
||||
`request_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 body sha1 摘要',
|
||||
`ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '操作者 IP',
|
||||
`user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作者 UA',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_actor_time` (`actor_user_id`, `created_at`),
|
||||
KEY `idx_action_time` (`action`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台写操作审计流水';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 02159 抽奖活动 Stage 2 down migration
|
||||
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
|
||||
DROP TABLE IF EXISTS `lottery_claim`;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 02159 抽奖活动 Stage 2(人工奖领奖工单)
|
||||
--
|
||||
-- 新建 `lottery_claim` 表:承载 crypto / physical / manual_other 三类人工奖
|
||||
-- 从"抽中"到"运营打款/发货"的完整工单状态机。
|
||||
--
|
||||
-- 幂等设计:`CREATE TABLE IF NOT EXISTS`;一个 draw_id 只能有一条 claim 行
|
||||
-- (UNIQUE 约束保证 POST /draw 事务不会重复挂单,避免用户端重放时重复入队)。
|
||||
--
|
||||
-- 关键索引:
|
||||
-- 1) UNIQUE (draw_id) — 抽奖记录 ↔ 领奖工单 一对一
|
||||
-- 2) (activity_id, status) — 后台工单列表按活动 + 状态过滤
|
||||
-- 3) (user_id, activity_id) — GET /records 按用户拉工单
|
||||
-- 4) (status, expires_at) — 过期定时任务扫描
|
||||
--
|
||||
-- 状态机(详细见 doc/lottery-stage2 或 issue HIF-4):
|
||||
-- pending_claim ─── 用户提交 ──→ reviewing
|
||||
-- └── 超时 ──→ expired
|
||||
-- reviewing ─── 运营 approve ──→ paying
|
||||
-- └── 运营 reject ──→ rejected(用户可再次提交)
|
||||
-- paying ─── 运营 mark-paid ──→ paid(终态)
|
||||
-- └── 运营 reject ──→ rejected
|
||||
-- rejected ─── 用户再提交 ──→ reviewing
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_claim` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`prize_type` VARCHAR(32) NOT NULL COMMENT '奖品类型(crypto/physical/manual_other,冗余便于后台按类型过滤)',
|
||||
`claim_data` JSON COMMENT '用户提交的领奖表单数据(结构随 prize_type 变化)',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending_claim' COMMENT '状态:pending_claim / reviewing / paying / paid / rejected / expired',
|
||||
`submitted_at` DATETIME DEFAULT NULL COMMENT '用户提交领奖信息时间(首次提交后写;重新提交会覆盖)',
|
||||
`expires_at` DATETIME NOT NULL COMMENT '领奖窗口截止时间(默认 now+7d,可被奖品 config.claim_ttl_hours 覆盖)',
|
||||
`reviewed_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '最近一次审核操作者 user.id',
|
||||
`reviewed_at` DATETIME DEFAULT NULL COMMENT '最近一次审核时间',
|
||||
`reject_reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '拒绝原因',
|
||||
`tx_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链上交易哈希(crypto 打款)',
|
||||
`delivery_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '快递单号 / 发货单据编号(physical 发货)',
|
||||
`paid_at` DATETIME DEFAULT NULL COMMENT '运营标记打款/发货完成时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_activity_status` (`activity_id`, `status`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_status_expires` (`status`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖人工奖领奖工单';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9
|
||||
--
|
||||
-- 与 up.sql 对称,只回退默认值,不动数据。
|
||||
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 02160 抽奖 Stage 2 F8:lottery_activity.grid_size 默认值从 9 改成 8
|
||||
--
|
||||
-- 前端与产品对齐后确认布局 A:3×3 挖中心 → 中心是"点击抽奖"按钮(不是奖品格),
|
||||
-- 其余 8 格挂奖品。因此 grid_size 的默认值应为 8,不再是 9。
|
||||
--
|
||||
-- 兼容性:
|
||||
-- * up.sql 的 CREATE TABLE 已在 02156 里跑过,MySQL 的 CREATE TABLE IF NOT EXISTS
|
||||
-- 不会改动既存表结构。所以老部署的 lottery_activity.grid_size 默认值仍是 9,
|
||||
-- 必须用一条独立的 ALTER 迁移把默认值改过来。
|
||||
-- * 已有数据(grid_size=9 的老活动)不动 —— ALTER DEFAULT 只影响新插入行且未提供
|
||||
-- grid_size 的场景;Go 侧 admin/lottery.go 的兜底也已配套改成 8。
|
||||
--
|
||||
-- 幂等:ALTER COLUMN ... SET DEFAULT 在 MySQL 8.0+ 是幂等的(重复执行等值 SET
|
||||
-- 不会报错),重跑安全。
|
||||
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 8;
|
||||
@@ -0,0 +1 @@
|
||||
DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- OmnXT SimNet subscription application.
|
||||
-- Delivers simnet nodes as base64 "simnet://" protocol links built by the
|
||||
-- adapter template function buildOmnxtProtocolLinks (per-user psk/key_id derived
|
||||
-- from the subscription; server PSK carried for AF derivation). Matched by
|
||||
-- User-Agent containing "omnxt". Mirrors the Pro reference final format
|
||||
-- (migrations 02138 + 02140).
|
||||
INSERT IGNORE INTO `subscribe_application`
|
||||
(`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1001, 'OmnXT SimNet', '', 'OmnXT SimNet base64 subscription', '', 'OmnXT', 0, '{{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}\n{{- end }}', 'base64', '{}', NOW(3), NOW(3));
|
||||
@@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:"
|
||||
|
||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
|
||||
// UserEnableKeyPrefix user enable state cache key prefix
|
||||
const UserEnableKeyPrefix = "user:enable:"
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
Currency Currency `yaml:"Currency"`
|
||||
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"`
|
||||
@@ -250,6 +251,13 @@ 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 短链接
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// admin_claims_handler.go 提供 Stage 2 后台工单接口的 gin handler 层。
|
||||
// 路径注册在 internal/handler/lottery_routes.go 里;handler 只负责参数绑定 +
|
||||
// 委派到 internal/logic/admin/lottery/admin_claims.go。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// ListLotteryClaimsHandler GET /v1/admin/lottery/claims
|
||||
func ListLotteryClaimsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryClaimsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryClaimsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryClaims(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApproveLotteryClaimHandler POST /v1/admin/lottery/claims/approve
|
||||
func ApproveLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminApproveClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewApproveLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.ApproveLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// RejectLotteryClaimHandler POST /v1/admin/lottery/claims/reject
|
||||
func RejectLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminRejectClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewRejectLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.RejectLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// MarkPaidLotteryClaimHandler POST /v1/admin/lottery/claims/mark-paid
|
||||
func MarkPaidLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminMarkPaidClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewMarkPaidLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.MarkPaidLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// LotteryClaimsSummaryHandler GET /v1/admin/lottery/claims/summary
|
||||
func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
l := adminlottery.NewLotteryClaimsSummaryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.LotteryClaimsSummary()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListLotteryDrawsHandler GET /v1/admin/lottery/draws
|
||||
func ListLotteryDrawsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryDrawsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryDrawsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryDraws(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package lottery contains gin handlers for the admin-side lottery endpoints.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func CreateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryActivitiesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryActivitiesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryActivitiesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryActivities(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGetLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func PublishLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPublishLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PublishLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func PauseLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPauseLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PauseLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryRulesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryRulesLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.UpdateLotteryRules(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func CreateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminPrizeIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewDeleteLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.DeleteLotteryPrize(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryPrizesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryPrizesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewListLotteryPrizesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryPrizes(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GrantAdminLotteryChanceRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGrantLotteryChanceLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.GrantLotteryChance(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteLotteryActivityHandler DELETE /v1/admin/lottery/activities/:id
|
||||
func DeleteLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewDeleteLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.DeleteLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminLottery "github.com/perfect-panel/server/internal/handler/admin/lottery"
|
||||
publicLottery "github.com/perfect-panel/server/internal/handler/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/middleware"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
// registerLotteryRoutes wires the Stage 1 lottery endpoints. Kept in its own
|
||||
// file to avoid ballooning routes.go and to make the lottery surface easy to
|
||||
// audit end-to-end. The path prefix "/v1/lottery" is under the user middleware
|
||||
// stack (AuthMiddleware + DeviceMiddleware); "/v1/admin/lottery" uses the
|
||||
// admin-detecting AuthMiddleware (path contains "admin" segment).
|
||||
func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
userGroup := router.Group("/v1/lottery")
|
||||
userGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
{
|
||||
userGroup.GET("/config", publicLottery.QueryLotteryConfigHandler(serverCtx))
|
||||
userGroup.POST("/draw", publicLottery.DrawLotteryHandler(serverCtx))
|
||||
userGroup.GET("/records", publicLottery.QueryLotteryRecordsHandler(serverCtx))
|
||||
userGroup.POST("/claim", publicLottery.ClaimLotteryPrizeHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminGroup := router.Group("/v1/admin/lottery")
|
||||
adminGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.AdminMetaMiddleware())
|
||||
{
|
||||
adminGroup.POST("/activities", adminLottery.CreateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities", adminLottery.UpdateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.GET("/activities", adminLottery.ListLotteryActivitiesHandler(serverCtx))
|
||||
adminGroup.GET("/activities/detail", adminLottery.GetLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx))
|
||||
adminGroup.DELETE("/activities/:id", adminLottery.DeleteLotteryActivityHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.PUT("/prizes/:id", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.DELETE("/prizes/:id", adminLottery.DeleteLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
adminGroup.GET("/claims", adminLottery.ListLotteryClaimsHandler(serverCtx))
|
||||
adminGroup.GET("/claims/summary", adminLottery.LotteryClaimsSummaryHandler(serverCtx))
|
||||
adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx))
|
||||
|
||||
// Stage 3: 抽奖记录(发放流水)
|
||||
adminGroup.GET("/draws", adminLottery.ListLotteryDrawsHandler(serverCtx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package lottery contains the user-facing lottery HTTP handlers. Each handler
|
||||
// binds request params via gin, validates, delegates to the logic package,
|
||||
// and renders through pkg/result to keep the API response envelope consistent.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// QueryLotteryConfigHandler serves GET /api/v1/lottery/config.
|
||||
func QueryLotteryConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryConfigRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewQueryLotteryConfigLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryConfig(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DrawLotteryHandler serves POST /api/v1/lottery/draw.
|
||||
func DrawLotteryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DrawLotteryRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewDrawLotteryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.DrawLottery(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// QueryLotteryRecordsHandler serves GET /api/v1/lottery/records.
|
||||
func QueryLotteryRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryRecordsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := lottery.NewQueryLotteryRecordsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryRecords(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeHandler serves POST /api/v1/lottery/claim. Stage 1
|
||||
// always returns 4010 not_claimable.
|
||||
func ClaimLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ClaimLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewClaimLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ClaimLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
|
||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
|
||||
resp, err := l.QueryUserSubscribeNodeList()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query Commission Return Log
|
||||
func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryCommissionReturnLogRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryCommissionReturnLog(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("commission_return_log response: %s", body)
|
||||
if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("withdrawal_log commission_refund response: %s", body)
|
||||
if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
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 injectTestUser(userID int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
|
||||
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
|
||||
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
|
||||
if len(eventTypes) > 0 {
|
||||
clauses := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
clauses = append(clauses, "`content` LIKE ?")
|
||||
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
|
||||
}
|
||||
query += " AND (" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
mock.ExpectQuery(query).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query Withdrawal Log
|
||||
// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)
|
||||
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryWithdrawalLogListRequest
|
||||
|
||||
@@ -1180,6 +1180,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Verify Email
|
||||
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
|
||||
|
||||
// Query Commission Return Log
|
||||
publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx))
|
||||
|
||||
// Query Withdrawal Log
|
||||
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
||||
}
|
||||
@@ -1218,4 +1221,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -84,7 +85,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
||||
resp, err := l.Handler(&req)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Internal Server")
|
||||
result.HttpResult(c, nil, err)
|
||||
return
|
||||
}
|
||||
c.Header("subscription-userinfo", resp.Header)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/client"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSubscribeHandlerReturnsBusinessErrorForDisabledUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
defer redisServer.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
defer func() {
|
||||
_ = rdb.Close()
|
||||
}()
|
||||
if err := rdb.Set(context.Background(), logiccommon.UserEnableCacheKey(83696), "false", 0).Err(); err != nil {
|
||||
t.Fatalf("seed user enable cache: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/subscribe", SubscribeHandler(&svc.ServiceContext{
|
||||
Config: config.Config{
|
||||
Subscribe: config.SubscribeConfig{
|
||||
SubscribePath: "/api/subscribe",
|
||||
},
|
||||
},
|
||||
ClientModel: subscribeClientModelStub{
|
||||
list: []*client.SubscribeApplication{
|
||||
{
|
||||
Id: 1,
|
||||
UserAgent: "clashmeta",
|
||||
IsDefault: true,
|
||||
OutputFormat: "yaml",
|
||||
},
|
||||
},
|
||||
},
|
||||
Redis: rdb,
|
||||
UserModel: subscribeUserModelStub{
|
||||
subscribe: &user.Subscribe{Id: 35446, UserId: 83696, SubscribeId: 1, Token: "disabled-token"},
|
||||
},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subscribe?token=disabled-token", nil)
|
||||
req.Header.Set("User-Agent", "ClashMetaForAndroid/2.11.7.Meta")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Code != xerr.UserDisabled {
|
||||
t.Fatalf("expected code %d, got %d (%s)", xerr.UserDisabled, resp.Code, resp.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
type subscribeClientModelStub struct {
|
||||
list []*client.SubscribeApplication
|
||||
err error
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Insert(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) FindOne(context.Context, int64) (*client.SubscribeApplication, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Update(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Delete(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) List(context.Context) ([]*client.SubscribeApplication, error) {
|
||||
return s.list, s.err
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type subscribeUserModelStub struct {
|
||||
subscribe *user.Subscribe
|
||||
subErr error
|
||||
findOne *user.User
|
||||
findOneErr error
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Insert(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOne(context.Context, int64) (*user.User, error) {
|
||||
if s.findOneErr != nil {
|
||||
return nil, s.findOneErr
|
||||
}
|
||||
return s.findOne, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Update(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateCommission(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Delete(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryPageList(context.Context, int, int, *user.UserFilterParams) ([]*user.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByReferCode(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) BatchDeleteUser(context.Context, []int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByToken(context.Context, string) (*user.Subscribe, error) {
|
||||
if s.subErr != nil {
|
||||
return nil, s.subErr
|
||||
}
|
||||
return s.subscribe, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindSingleModeAnchorSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByOrderId(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribe(context.Context, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribeById(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryUserSubscribe(context.Context, int64, ...int64) ([]*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeDetailsById(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneUserSubscribe(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUsersSubscribeBySubscribeId(context.Context, int64) ([]*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeWithTraffic(context.Context, int64, int64, int64, bool, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByDate(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByMonthly(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotal(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryAdminUsers(context.Context) ([]*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserCache(context.Context, *user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeCache(context.Context, *user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryActiveSubscriptions(context.Context, ...int64) (map[int64]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethods(context.Context, int64) ([]*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteUserAuthMethods(context.Context, int64, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByOpenID(context.Context, string, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByUserId(context.Context, string, int64) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByPlatform(context.Context, int64, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByEmail(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDevice(context.Context, int64) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceList(context.Context, int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceListByUserIds(context.Context, []int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDevicePageList(context.Context, int64, int64, int, int) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDeviceByIdentifier(context.Context, string) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteDevice(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearSubscribeCache(context.Context, ...*user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearUserCache(context.Context, ...*user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearDeviceCache(context.Context, ...*user.Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDailyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryMonthlyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribesByUserIds(context.Context, []int64) (map[int64]*user.UserStatusInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||||
//
|
||||
// GET /v1/admin/lottery/claims — 分页列表
|
||||
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||||
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||||
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||||
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||||
//
|
||||
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||||
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/audit"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||||
const (
|
||||
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||||
ActionLotteryClaimReject = "lottery.claim.reject"
|
||||
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||||
)
|
||||
|
||||
// ---- ListLotteryClaims ----------------------------------------------------
|
||||
|
||||
type ListLotteryClaimsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||||
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||||
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||||
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{})
|
||||
if t := strings.TrimSpace(req.Type); t != "" {
|
||||
db = db.Where("prize_type = ?", t)
|
||||
}
|
||||
if s := strings.TrimSpace(req.Status); s != "" {
|
||||
db = db.Where("status = ?", s)
|
||||
}
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if req.UserId > 0 {
|
||||
db = db.Where("user_id = ?", req.UserId)
|
||||
}
|
||||
if req.From > 0 {
|
||||
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||||
}
|
||||
if req.To > 0 {
|
||||
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var rows []modelLottery.Claim
|
||||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
|
||||
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||||
drawIds := make([]int64, 0, len(rows))
|
||||
userIds := make([]int64, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
drawIds = append(drawIds, c.DrawId)
|
||||
userIds = append(userIds, c.UserId)
|
||||
}
|
||||
snaps, err := l.loadSnapshots(drawIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := l.loadUsers(userIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||||
for _, c := range rows {
|
||||
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(drawIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||||
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||||
type row struct {
|
||||
UserId int64
|
||||
Email string
|
||||
}
|
||||
var rows []row
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user_auth_methods").
|
||||
Select("user_id AS user_id, auth_identifier AS email").
|
||||
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||
Order("created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if _, exists := out[r.UserId]; exists {
|
||||
continue
|
||||
}
|
||||
out[r.UserId] = r.Email
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- ApproveLotteryClaim --------------------------------------------------
|
||||
|
||||
type ApproveLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||||
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||||
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusPaying,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"reject_reason": "",
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimApprove,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- RejectLotteryClaim ---------------------------------------------------
|
||||
|
||||
type RejectLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||||
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||||
// 剩余窗口内可再次提交。
|
||||
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status IN ?", req.Id,
|
||||
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusRejected,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"reject_reason": reason,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimReject,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||||
|
||||
type MarkPaidLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||||
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// MarkPaidLotteryClaim paying → paid。
|
||||
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||||
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||||
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
txHash := strings.TrimSpace(req.TxHash)
|
||||
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||||
now := time.Now()
|
||||
paidAt := now
|
||||
if req.PaidAt > 0 {
|
||||
paidAt = time.Unix(req.PaidAt, 0)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先取当前 claim 用于类型强校验
|
||||
var claim modelLottery.Claim
|
||||
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// CAS 推进 status。
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusPaid,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"tx_hash": txHash,
|
||||
"delivery_ref": deliveryRef,
|
||||
"paid_at": paidAt,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||||
if err := tx.Model(&modelLottery.Draw{}).
|
||||
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||||
Updates(map[string]any{
|
||||
"dispatch_state": modelLottery.DispatchStatePaid,
|
||||
"dispatched_at": paidAt,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimMarkPaid,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||||
// - crypto: tx_hash 必填
|
||||
// - physical: delivery_ref 必填
|
||||
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||||
//
|
||||
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||||
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||||
switch prizeType {
|
||||
case modelLottery.PrizeTypeCrypto:
|
||||
if txHash == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||||
}
|
||||
case modelLottery.PrizeTypePhysical:
|
||||
if deliveryRef == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||||
}
|
||||
case modelLottery.PrizeTypeManualOther:
|
||||
if txHash == "" && deliveryRef == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||||
}
|
||||
default:
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- ClaimsSummary --------------------------------------------------------
|
||||
|
||||
type LotteryClaimsSummaryLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||||
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||||
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
type row struct {
|
||||
PrizeType string
|
||||
Status string
|
||||
Cnt int64
|
||||
}
|
||||
var rows []row
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Select("prize_type, status, COUNT(*) AS cnt").
|
||||
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||
Group("prize_type, status").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
summary := &types.AdminLotteryClaimsSummary{}
|
||||
for _, r := range rows {
|
||||
bucket := bucketByType(summary, r.PrizeType)
|
||||
if bucket == nil {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case modelLottery.ClaimStatusReviewing:
|
||||
bucket.Reviewing = r.Cnt
|
||||
case modelLottery.ClaimStatusPaying:
|
||||
bucket.Paying = r.Cnt
|
||||
}
|
||||
}
|
||||
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||||
Count(&summary.Overdue).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||||
switch prizeType {
|
||||
case modelLottery.PrizeTypeCrypto:
|
||||
return &s.Crypto
|
||||
case modelLottery.PrizeTypePhysical:
|
||||
return &s.Physical
|
||||
case modelLottery.PrizeTypeManualOther:
|
||||
return &s.ManualOther
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||||
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||||
view := types.AdminLotteryClaim{
|
||||
Id: c.Id,
|
||||
DrawId: c.DrawId,
|
||||
ActivityId: c.ActivityId,
|
||||
Status: c.Status,
|
||||
ExpiresAt: c.ExpiresAt.Unix(),
|
||||
ReviewedBy: c.ReviewedBy,
|
||||
RejectReason: c.RejectReason,
|
||||
TxHash: c.TxHash,
|
||||
DeliveryRef: c.DeliveryRef,
|
||||
CreatedAt: c.CreatedAt.Unix(),
|
||||
User: types.AdminLotteryClaimUser{
|
||||
Id: c.UserId,
|
||||
Email: email,
|
||||
},
|
||||
Prize: types.AdminLotteryClaimPrize{
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||
},
|
||||
}
|
||||
if snap.Type == "" {
|
||||
// snapshot 未命中,用 claim.prize_type 兜底
|
||||
view.Prize.Type = c.PrizeType
|
||||
}
|
||||
if c.ClaimData != "" {
|
||||
view.ClaimData = json.RawMessage(c.ClaimData)
|
||||
}
|
||||
if c.SubmittedAt != nil {
|
||||
view.SubmittedAt = c.SubmittedAt.Unix()
|
||||
}
|
||||
if c.ReviewedAt != nil {
|
||||
view.ReviewedAt = c.ReviewedAt.Unix()
|
||||
}
|
||||
if c.PaidAt != nil {
|
||||
view.PaidAt = c.PaidAt.Unix()
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||
// - bucketByType 的类型 → 状态桶映射
|
||||
// - claimToAdminView 的 nullable 字段渲染
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestValidateMarkPaidByType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prizeType string
|
||||
txHash string
|
||||
deliveryRef string
|
||||
wantErr bool
|
||||
}{
|
||||
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||
{"unknown type", "auto_hallucinated", "", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketByType(t *testing.T) {
|
||||
s := &types.AdminLotteryClaimsSummary{}
|
||||
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||
t.Fatal("crypto bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||
t.Fatal("physical bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||
t.Fatal("manual_other bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, "unknown") != nil {
|
||||
t.Fatal("unknown type must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
claim := modelLottery.Claim{
|
||||
Id: 42,
|
||||
DrawId: 1234,
|
||||
UserId: 88,
|
||||
ActivityId: 100,
|
||||
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||
Status: modelLottery.ClaimStatusPaid,
|
||||
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||
SubmittedAt: &submittedAt,
|
||||
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||
TxHash: "0xabcdef",
|
||||
PaidAt: &paidAt,
|
||||
}
|
||||
snap := modelLottery.PrizeSnapshot{
|
||||
Type: modelLottery.PrizeTypeCrypto,
|
||||
Name: "1 BTC",
|
||||
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||
}
|
||||
view := claimToAdminView(claim, snap, "user@example.com")
|
||||
|
||||
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||
t.Fatalf("view IDs wrong: %+v", view)
|
||||
}
|
||||
if view.User.Email != "user@example.com" {
|
||||
t.Fatalf("Email = %q", view.User.Email)
|
||||
}
|
||||
if view.Status != modelLottery.ClaimStatusPaid {
|
||||
t.Fatalf("Status = %q", view.Status)
|
||||
}
|
||||
if view.SubmittedAt != submittedAt.Unix() {
|
||||
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||
}
|
||||
if view.PaidAt != paidAt.Unix() {
|
||||
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||
}
|
||||
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||
}
|
||||
if len(view.ClaimData) == 0 {
|
||||
t.Fatal("ClaimData must be included when non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||
claim := modelLottery.Claim{
|
||||
Id: 1,
|
||||
DrawId: 2,
|
||||
PrizeType: modelLottery.PrizeTypePhysical,
|
||||
Status: modelLottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// admin_draws.go 实现后台抽奖记录(发放流水)接口:
|
||||
//
|
||||
// GET /v1/admin/lottery/draws — 分页列表(谁/何时/中了什么/发放状态与结果)
|
||||
//
|
||||
// 自动奖(vpn_duration/commission)的实际发放结果取自 lottery_grant_ledger.payload;
|
||||
// 人工奖(crypto/physical/manual_other)的领奖进展在 /claims 里看。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ListLotteryDrawsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryDrawsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryDrawsLogic {
|
||||
return &ListLotteryDrawsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// ListLotteryDraws 按 activity/user/win/dispatch_state/prize_type/时间窗过滤,
|
||||
// 附带奖品快照 + 用户邮箱 + 发放账本结果,避免前端 N+1。
|
||||
func (l *ListLotteryDrawsLogic) ListLotteryDraws(req *types.ListAdminLotteryDrawsRequest) (*types.ListAdminLotteryDrawsResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Draw{})
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if req.UserId > 0 {
|
||||
db = db.Where("user_id = ?", req.UserId)
|
||||
}
|
||||
switch strings.TrimSpace(req.Win) {
|
||||
case "1":
|
||||
db = db.Where("is_win = ?", true)
|
||||
case "0":
|
||||
db = db.Where("is_win = ?", false)
|
||||
}
|
||||
if s := strings.TrimSpace(req.DispatchState); s != "" {
|
||||
db = db.Where("dispatch_state = ?", s)
|
||||
}
|
||||
// prize_type 挂在快照表上,用 EXISTS 子查询过滤(避免 JOIN 影响分页去重)。
|
||||
if pt := strings.TrimSpace(req.PrizeType); pt != "" {
|
||||
db = db.Where("EXISTS (SELECT 1 FROM lottery_prize_snapshot s WHERE s.draw_id = lottery_draw.id AND s.type = ?)", pt)
|
||||
}
|
||||
if req.From > 0 {
|
||||
db = db.Where("drawn_at >= ?", time.Unix(req.From, 0))
|
||||
}
|
||||
if req.To > 0 {
|
||||
db = db.Where("drawn_at < ?", time.Unix(req.To, 0))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var rows []modelLottery.Draw
|
||||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
|
||||
drawIds := make([]int64, 0, len(rows))
|
||||
userIds := make([]int64, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
drawIds = append(drawIds, d.Id)
|
||||
userIds = append(userIds, d.UserId)
|
||||
}
|
||||
snaps, err := l.loadSnapshots(drawIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledgers, err := l.loadLedgers(drawIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := l.loadUsers(userIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &types.ListAdminLotteryDrawsResponse{Total: total, List: make([]types.AdminLotteryDraw, 0, len(rows))}
|
||||
for _, d := range rows {
|
||||
resp.List = append(resp.List, drawToAdminView(d, snaps[d.Id], ledgers[d.Id], users[d.UserId]))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *ListLotteryDrawsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(drawIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *ListLotteryDrawsLogic) loadLedgers(drawIds []int64) (map[int64]modelLottery.GrantLedger, error) {
|
||||
if len(drawIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []modelLottery.GrantLedger
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.GrantLedger, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.DrawId] = r // draw ↔ ledger 一对一(一次抽奖至多一条发放)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *ListLotteryDrawsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type row struct {
|
||||
UserId int64
|
||||
Email string
|
||||
}
|
||||
var rows []row
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user_auth_methods").
|
||||
Select("user_id AS user_id, auth_identifier AS email").
|
||||
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||
Order("created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if _, ok := out[r.UserId]; ok {
|
||||
continue
|
||||
}
|
||||
out[r.UserId] = r.Email
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// drawToAdminView 组装单条抽奖记录后台视图。
|
||||
func drawToAdminView(d modelLottery.Draw, snap modelLottery.PrizeSnapshot, ledger modelLottery.GrantLedger, email string) types.AdminLotteryDraw {
|
||||
view := types.AdminLotteryDraw{
|
||||
DrawId: d.Id,
|
||||
ActivityId: d.ActivityId,
|
||||
IsWin: d.IsWin,
|
||||
DispatchState: d.DispatchState,
|
||||
DispatchError: d.DispatchError,
|
||||
DrawnAt: d.DrawnAt.Unix(),
|
||||
CreatedAt: d.CreatedAt.Unix(),
|
||||
User: types.AdminLotteryDrawUser{
|
||||
Id: d.UserId,
|
||||
Email: email,
|
||||
},
|
||||
}
|
||||
if d.DispatchedAt != nil {
|
||||
view.DispatchedAt = d.DispatchedAt.Unix()
|
||||
}
|
||||
if snap.DrawId != 0 || snap.Type != "" {
|
||||
view.Prize = &types.AdminLotteryDrawPrize{
|
||||
Slot: snap.Slot,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||
}
|
||||
}
|
||||
if ledger.Id != 0 {
|
||||
view.GrantAmount = ledger.Amount
|
||||
if ledger.Payload != "" {
|
||||
var p struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ledger.Payload), &p); err == nil {
|
||||
view.GrantMessage = p.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
// Package lottery contains the admin-facing lottery HTTP logic. Every write
|
||||
// endpoint runs its user-visible mutation inside a tx that ALSO writes an
|
||||
// admin_action_log row via audit.WriteAdminAction, so a rollback leaves no
|
||||
// dangling audit entries. Rules PUT is gated by rulecaps.ValidateEligibilityJSON.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/audit"
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/rulecaps"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// currentAdminId retrieves the actor's user.id from ctx (populated by
|
||||
// AuthMiddleware). Zero → treated as unauthorized. All admin endpoints below
|
||||
// short-circuit if the caller is not admin.
|
||||
func currentAdminId(ctx context.Context) int64 {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||
if !ok || u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.Id
|
||||
}
|
||||
|
||||
func requestMeta(ctx context.Context) (ip, ua string) {
|
||||
// AdminMetaMiddleware populates these keys on the request context after
|
||||
// AuthMiddleware runs. Absent middleware (unit tests, non-admin paths)
|
||||
// → empty strings, which is the intended defensive default.
|
||||
if v, ok := ctx.Value(constant.CtxKeyIP).(string); ok {
|
||||
ip = v
|
||||
}
|
||||
if v, ok := ctx.Value(constant.CtxKeyUserAgent).(string); ok {
|
||||
ua = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func jsonOrDefault(raw json.RawMessage, def string) string {
|
||||
if len(raw) == 0 {
|
||||
return def
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ---- CreateLotteryActivity -------------------------------------------------
|
||||
|
||||
type CreateLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewCreateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryActivityLogic {
|
||||
return &CreateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||
return nil, ruleCapsToXerr(err)
|
||||
}
|
||||
|
||||
activity := modelLottery.Activity{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: req.Description,
|
||||
StartAt: time.Unix(req.StartAt, 0),
|
||||
EndAt: time.Unix(req.EndAt, 0),
|
||||
Status: modelLottery.ActivityStatusDraft,
|
||||
GridSize: req.GridSize,
|
||||
Eligibility: jsonOrDefault(req.Eligibility, "{}"),
|
||||
ChanceSources: jsonOrDefault(req.ChanceSources, "[]"),
|
||||
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
|
||||
}
|
||||
if activity.GridSize <= 0 {
|
||||
// HIF-4 F8: 布局 A 3×3 挖中心 → 8 个奖品格。前端约定中心是"点击抽奖"按钮,
|
||||
// 不渲染为奖品;后台仍允许挂 slot=4 但前端会忽略。
|
||||
activity.GridSize = 8
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&activity).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryActivityCreate,
|
||||
TargetIds: int64ToStr(activity.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activityToAdminView(activity), nil
|
||||
}
|
||||
|
||||
// ---- UpdateLotteryActivity -------------------------------------------------
|
||||
|
||||
type UpdateLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryActivityLogic {
|
||||
return &UpdateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryActivityLogic) UpdateLotteryActivity(req *types.UpdateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var updated modelLottery.Activity
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Title != "" {
|
||||
fields["title"] = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
fields["description"] = req.Description
|
||||
}
|
||||
if req.StartAt > 0 {
|
||||
fields["start_at"] = time.Unix(req.StartAt, 0)
|
||||
}
|
||||
if req.EndAt > 0 {
|
||||
fields["end_at"] = time.Unix(req.EndAt, 0)
|
||||
}
|
||||
if req.GridSize > 0 {
|
||||
fields["grid_size"] = req.GridSize
|
||||
}
|
||||
if req.UnmetAction != "" {
|
||||
fields["unmet_action"] = req.UnmetAction
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryActivityUpdate,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activityToAdminView(updated), nil
|
||||
}
|
||||
|
||||
// ---- ListLotteryActivities -------------------------------------------------
|
||||
|
||||
type ListLotteryActivitiesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryActivitiesLogic {
|
||||
return &ListLotteryActivitiesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *ListLotteryActivitiesLogic) ListLotteryActivities(req *types.ListAdminLotteryActivitiesRequest) (*types.ListAdminLotteryActivitiesResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Activity{})
|
||||
if req.Status != "" {
|
||||
db = db.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.Search != "" {
|
||||
db = db.Where("title LIKE ?", "%"+req.Search+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var rows []modelLottery.Activity
|
||||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
resp := &types.ListAdminLotteryActivitiesResponse{Total: total, List: make([]types.AdminLotteryActivity, 0, len(rows))}
|
||||
for _, a := range rows {
|
||||
resp.List = append(resp.List, *activityToAdminView(a))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GetLotteryActivity ---------------------------------------------------
|
||||
|
||||
type GetLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLotteryActivityLogic {
|
||||
return &GetLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *GetLotteryActivityLogic) GetLotteryActivity(req *types.AdminActivityIdRequest) (*types.AdminLotteryActivity, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
var a modelLottery.Activity
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return activityToAdminView(a), nil
|
||||
}
|
||||
|
||||
// ---- Publish / Pause -------------------------------------------------------
|
||||
|
||||
type toggleActivityStatusLogic struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
action string
|
||||
next string
|
||||
}
|
||||
|
||||
func (l *toggleActivityStatusLogic) run(id int64) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var a modelLottery.Activity
|
||||
if err := tx.Where("id = ?", id).First(&a).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", id).UpdateColumn("status", l.next).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: l.action,
|
||||
TargetIds: int64ToStr(id),
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type PublishLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewPublishLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLotteryActivityLogic {
|
||||
return &PublishLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *PublishLotteryActivityLogic) PublishLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPublish, next: modelLottery.ActivityStatusRunning}
|
||||
return t.run(req.Id)
|
||||
}
|
||||
|
||||
type PauseLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewPauseLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PauseLotteryActivityLogic {
|
||||
return &PauseLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPause, next: modelLottery.ActivityStatusPaused}
|
||||
return t.run(req.Id)
|
||||
}
|
||||
|
||||
// ---- DeleteLotteryActivity -------------------------------------------------
|
||||
|
||||
type DeleteLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDeleteLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryActivityLogic {
|
||||
return &DeleteLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// DeleteLotteryActivity 软删活动 + 硬删其奖品(同事务)。运行中的活动禁止删除,
|
||||
// 需先暂停,避免误删正在进行的抽奖。历史抽奖记录/快照保留(独立于奖品行)。
|
||||
func (l *DeleteLotteryActivityLogic) DeleteLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var a modelLottery.Activity
|
||||
if err := tx.Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if a.Status == modelLottery.ActivityStatusRunning {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "运行中的活动请先暂停再删除")
|
||||
}
|
||||
// 软删活动(Activity 有 gorm.DeletedAt)。
|
||||
if err := tx.Delete(&modelLottery.Activity{}, req.Id).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
}
|
||||
// 硬删奖品(Prize 无软删字段),避免残留孤儿奖品。
|
||||
if err := tx.Where("activity_id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryActivityDelete,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- UpdateLotteryRules (with caps) ----------------------------------------
|
||||
|
||||
type UpdateLotteryRulesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryRulesLogic {
|
||||
return &UpdateLotteryRulesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryRulesLogic) UpdateLotteryRules(req *types.UpdateAdminLotteryRulesRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||
return ruleCapsToXerr(err)
|
||||
}
|
||||
// Pre-collect the field diff outside the tx so an empty update rejects
|
||||
// without opening one (cheaper on the happy path + easier to test).
|
||||
fields := map[string]any{}
|
||||
if len(req.Eligibility) > 0 {
|
||||
fields["eligibility"] = string(req.Eligibility)
|
||||
}
|
||||
if len(req.ChanceSources) > 0 {
|
||||
fields["chance_sources"] = string(req.ChanceSources)
|
||||
}
|
||||
if req.UnmetAction != "" {
|
||||
fields["unmet_action"] = req.UnmetAction
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryRulesPut,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func ruleCapsToXerr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooDeep):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooDeep, err.Error())
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooManyNodes):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooMany, err.Error())
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooLarge):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooLarge, err.Error())
|
||||
default:
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreatePrize / UpdatePrize / DeletePrize / ListPrizes -----------------
|
||||
|
||||
type CreateLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewCreateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryPrizeLogic {
|
||||
return &CreateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *CreateLotteryPrizeLogic) CreateLotteryPrize(req *types.CreateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
prize := modelLottery.Prize{
|
||||
ActivityId: req.ActivityId,
|
||||
Slot: req.Slot,
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
IconURL: req.IconUrl,
|
||||
Config: jsonOrDefault(req.Config, "{}"),
|
||||
Weight: req.Weight,
|
||||
IsFallback: req.IsFallback,
|
||||
}
|
||||
if req.TotalStock != nil {
|
||||
prize.TotalStock.Int64 = *req.TotalStock
|
||||
prize.TotalStock.Valid = true
|
||||
prize.RemainingStock.Int64 = *req.TotalStock
|
||||
prize.RemainingStock.Valid = true
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&prize).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeCreate,
|
||||
TargetIds: int64ToStr(prize.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizeToAdminView(prize), nil
|
||||
}
|
||||
|
||||
type UpdateLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryPrizeLogic {
|
||||
return &UpdateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var updated modelLottery.Prize
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.DatabaseQueryError)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Slot != nil {
|
||||
fields["slot"] = *req.Slot
|
||||
}
|
||||
if req.Type != "" {
|
||||
fields["type"] = req.Type
|
||||
}
|
||||
if req.Name != "" {
|
||||
fields["name"] = req.Name
|
||||
}
|
||||
if req.IconUrl != "" {
|
||||
fields["icon_url"] = req.IconUrl
|
||||
}
|
||||
if len(req.Config) > 0 {
|
||||
fields["config"] = string(req.Config)
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
}
|
||||
if req.TotalStock != nil {
|
||||
fields["total_stock"] = *req.TotalStock
|
||||
fields["remaining_stock"] = *req.TotalStock
|
||||
}
|
||||
if req.IsFallback != nil {
|
||||
fields["is_fallback"] = *req.IsFallback
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := tx.Model(&modelLottery.Prize{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeUpdate,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizeToAdminView(updated), nil
|
||||
}
|
||||
|
||||
type DeleteLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDeleteLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryPrizeLogic {
|
||||
return &DeleteLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *DeleteLotteryPrizeLogic) DeleteLotteryPrize(req *types.AdminPrizeIdRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeDelete,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type ListLotteryPrizesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryPrizesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryPrizesLogic {
|
||||
return &ListLotteryPrizesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *ListLotteryPrizesLogic) ListLotteryPrizes(req *types.ListAdminLotteryPrizesRequest) (*types.ListAdminLotteryPrizesResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
var rows []modelLottery.Prize
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("activity_id = ?", req.ActivityId).Order("slot ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
resp := &types.ListAdminLotteryPrizesResponse{List: make([]types.AdminLotteryPrize, 0, len(rows))}
|
||||
for _, p := range rows {
|
||||
resp.List = append(resp.List, *prizeToAdminView(p))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GrantLotteryChance ----------------------------------------------------
|
||||
|
||||
type GrantLotteryChanceLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGrantLotteryChanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GrantLotteryChanceLogic {
|
||||
return &GrantLotteryChanceLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *GrantLotteryChanceLogic) GrantLotteryChance(req *types.GrantAdminLotteryChanceRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
// Prefix sourceRef with "manual:{actor}:" so admin-granted chances are
|
||||
// distinguishable in ChanceGrant flow (audit trail + admin-scoped
|
||||
// idempotency).
|
||||
ref := "manual:" + int64ToStr(actor) + ":" + req.SourceRef
|
||||
if err := l.svcCtx.LotteryChance.Grant(l.ctx, req.UserId, req.ActivityId, modelLottery.ChanceSourceManualGrant, ref, req.Amount); err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.LotteryInternalError), err.Error())
|
||||
}
|
||||
// Audit outside the ChanceService tx — the Grant is idempotent so a
|
||||
// duplicated audit row is preferable to a lost one.
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryChancesGrant,
|
||||
TargetIds: int64ToStr(req.UserId) + "," + int64ToStr(req.ActivityId),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func activityToAdminView(a modelLottery.Activity) *types.AdminLotteryActivity {
|
||||
return &types.AdminLotteryActivity{
|
||||
Id: a.Id,
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
StartAt: a.StartAt.Unix(),
|
||||
EndAt: a.EndAt.Unix(),
|
||||
Status: a.Status,
|
||||
GridSize: a.GridSize,
|
||||
Eligibility: json.RawMessage(defaultRawIfEmpty(a.Eligibility, "{}")),
|
||||
ChanceSources: json.RawMessage(defaultRawIfEmpty(a.ChanceSources, "[]")),
|
||||
UnmetAction: a.UnmetAction,
|
||||
CreatedAt: a.CreatedAt.Unix(),
|
||||
UpdatedAt: a.UpdatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func prizeToAdminView(p modelLottery.Prize) *types.AdminLotteryPrize {
|
||||
view := &types.AdminLotteryPrize{
|
||||
Id: p.Id,
|
||||
ActivityId: p.ActivityId,
|
||||
Slot: p.Slot,
|
||||
Type: p.Type,
|
||||
Name: p.Name,
|
||||
IconUrl: p.IconURL,
|
||||
Config: json.RawMessage(defaultRawIfEmpty(p.Config, "{}")),
|
||||
Weight: p.Weight,
|
||||
IsFallback: p.IsFallback,
|
||||
CreatedAt: p.CreatedAt.Unix(),
|
||||
UpdatedAt: p.UpdatedAt.Unix(),
|
||||
}
|
||||
if p.TotalStock.Valid {
|
||||
v := p.TotalStock.Int64
|
||||
view.TotalStock = &v
|
||||
}
|
||||
if p.RemainingStock.Valid {
|
||||
v := p.RemainingStock.Int64
|
||||
view.RemainingStock = &v
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func defaultRawIfEmpty(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultString(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func int64ToStr(v int64) string {
|
||||
// small buffer avoids strconv import here.
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if v < 0 {
|
||||
neg = true
|
||||
v = -v
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newAdminLotteryDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func adminCtx() context.Context {
|
||||
return context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 7})
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsOversizeEligibility(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// build oversized JSON
|
||||
blob := strings.Repeat("a", 9000)
|
||||
req := &types.UpdateAdminLotteryRulesRequest{
|
||||
Id: 1,
|
||||
Eligibility: json.RawMessage(`{"op":"AND","payload":"` + blob + `"}`),
|
||||
}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("expected CodeError, got %v", err)
|
||||
}
|
||||
if ce.GetErrCode() != xerr.LotteryRuleTooLarge {
|
||||
t.Fatalf("expected LotteryRuleTooLarge, got %d", ce.GetErrCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsDeepTree(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// build depth 9 tree
|
||||
tree := map[string]any{"op": "OR", "children": []any{}}
|
||||
cur := tree
|
||||
for i := 1; i < 9; i++ {
|
||||
next := map[string]any{"op": "OR", "children": []any{}}
|
||||
cur["children"] = []any{next}
|
||||
cur = next
|
||||
}
|
||||
raw, _ := json.Marshal(tree)
|
||||
req := &types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: raw}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.LotteryRuleTooDeep {
|
||||
t.Fatalf("expected LotteryRuleTooDeep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsAnonymousCaller(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
logic := NewUpdateLotteryRulesLogic(context.Background(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(&types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: json.RawMessage("{}")})
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.ErrorTokenInvalid {
|
||||
t.Fatalf("expected token invalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_ValidTreePersists(t *testing.T) {
|
||||
db, mock, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `lottery_activity`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
req := &types.UpdateAdminLotteryRulesRequest{
|
||||
Id: 1,
|
||||
Eligibility: json.RawMessage(`{"type":"has_subscription"}`),
|
||||
ChanceSources: json.RawMessage(`[{"source":"daily_signin","amount":1}]`),
|
||||
}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
if err := logic.UpdateLotteryRules(req); err != nil {
|
||||
t.Fatalf("UpdateLotteryRules: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_MissingBothFieldsRejects(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
req := &types.UpdateAdminLotteryRulesRequest{Id: 1}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.InvalidParams {
|
||||
t.Fatalf("expected InvalidParams, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// TestRequestMeta_EmptyWhenCtxUnset asserts requestMeta returns empty strings
|
||||
// when neither typed context key is populated (unit tests, non-admin paths).
|
||||
func TestRequestMeta_EmptyWhenCtxUnset(t *testing.T) {
|
||||
ip, ua := requestMeta(context.Background())
|
||||
if ip != "" || ua != "" {
|
||||
t.Fatalf("expected empty, got ip=%q ua=%q", ip, ua)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestMeta_ReadsTypedKeys asserts requestMeta picks up the values
|
||||
// AdminMetaMiddleware pins onto ctx via the typed CtxKey constants.
|
||||
func TestRequestMeta_ReadsTypedKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyIP, "10.99.99.7")
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, "qa-audit-probe")
|
||||
|
||||
ip, ua := requestMeta(ctx)
|
||||
if ip != "10.99.99.7" {
|
||||
t.Fatalf("ip = %q, want %q", ip, "10.99.99.7")
|
||||
}
|
||||
if ua != "qa-audit-probe" {
|
||||
t.Fatalf("ua = %q, want %q", ua, "qa-audit-probe")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestMeta_IgnoresBareStringKeys guards against the F2 root cause:
|
||||
// pre-fix, the writer used bare-string keys "ip" / "user_agent" which never
|
||||
// collided with anyone's typed reader — so audit rows always saw empty
|
||||
// strings. The test proves the reader now IGNORES bare-string writes: only
|
||||
// the typed CtxKey path counts.
|
||||
func TestRequestMeta_IgnoresBareStringKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||
ctx = context.WithValue(ctx, "ip", "should-be-ignored")
|
||||
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||
ctx = context.WithValue(ctx, "user_agent", "should-be-ignored")
|
||||
|
||||
ip, ua := requestMeta(ctx)
|
||||
if ip != "" || ua != "" {
|
||||
t.Fatalf("bare-string keys must be ignored; got ip=%q ua=%q", ip, ua)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
queue "github.com/perfect-panel/server/queue/types"
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusClaimed = 6
|
||||
)
|
||||
|
||||
type UpdateOrderStatusLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
@@ -31,8 +35,8 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error {
|
||||
if req.Status == orderStatusRefunded {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "refund status must use refund order endpoint")
|
||||
if req.Status == orderStatusClaimed || req.Status == orderStatusRefunded {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "claimed/refund statuses are reserved for internal refund and activation flows")
|
||||
}
|
||||
|
||||
info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id)
|
||||
|
||||
@@ -17,11 +17,13 @@ func TestUpdateOrderStatus_RejectsRefundStatus(t *testing.T) {
|
||||
svcCtx: &svc.ServiceContext{},
|
||||
}
|
||||
|
||||
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
|
||||
Id: 1001,
|
||||
Status: orderStatusRefunded,
|
||||
})
|
||||
if !isErrCode(err, xerr.OrderStatusError) {
|
||||
t.Fatalf("UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err)
|
||||
for _, status := range []uint8{orderStatusClaimed, orderStatusRefunded} {
|
||||
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
|
||||
Id: 1001,
|
||||
Status: status,
|
||||
})
|
||||
if !isErrCode(err, xerr.OrderStatusError) {
|
||||
t.Fatalf("status %d: UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", status, errCodeOf(err), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ func (l *CreateServerLogic) CreateServer(req *types.CreateServerRequest) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||
protocol.NormalizeSimnet()
|
||||
protocols = append(protocols, protocol)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ 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,6 +41,12 @@ 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,6 +100,8 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||
protocol.NormalizeSimnet()
|
||||
protocols = append(protocols, protocol)
|
||||
}
|
||||
err = data.MarshalProtocols(protocols)
|
||||
|
||||
@@ -2,11 +2,7 @@ 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"
|
||||
@@ -50,84 +46,11 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
|
||||
|
||||
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set)
|
||||
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
|
||||
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 {
|
||||
if err := clearAllSessions(l.ctx, l.svcCtx, userId); 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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
func clearAllSessions(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||
userIDText := strconv.FormatInt(userID, 10)
|
||||
sessionSet := make(map[string]struct{})
|
||||
|
||||
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sessionKey := range keys {
|
||||
value, err := svcCtx.Redis.Get(ctx, sessionKey).Result()
|
||||
if err != nil || value != userIDText {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
|
||||
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
|
||||
continue
|
||||
}
|
||||
sessionSet[sessionID] = struct{}{}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sessionSet) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deviceKeySet := make(map[string]struct{})
|
||||
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
|
||||
cursor = 0
|
||||
for {
|
||||
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, devicePattern, 200).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, deviceKey := range keys {
|
||||
sessionID, err := svcCtx.Redis.Get(ctx, deviceKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := sessionSet[sessionID]; exists {
|
||||
deviceKeySet[deviceKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userID)
|
||||
pipe := svcCtx.Redis.TxPipeline()
|
||||
for sessionID := range sessionSet {
|
||||
pipe.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
|
||||
pipe.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
|
||||
pipe.ZRem(ctx, sessionsKey, sessionID)
|
||||
}
|
||||
pipe.Del(ctx, sessionsKey)
|
||||
for deviceKey := range deviceKeySet {
|
||||
pipe.Del(ctx, deviceKey)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestClearAllSessions(t *testing.T) {
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
defer redisServer.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
defer rdb.Close()
|
||||
|
||||
svcCtx := &svc.ServiceContext{Redis: rdb}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := int64(42)
|
||||
sessionID := "session-a"
|
||||
otherSessionID := "session-b"
|
||||
userSessionKey := config.SessionIdKey + ":" + sessionID
|
||||
userDetailKey := config.SessionIdKey + ":detail:" + sessionID
|
||||
otherSessionKey := config.SessionIdKey + ":" + otherSessionID
|
||||
userSessionsZSet := config.UserSessionsKeyPrefix + "42"
|
||||
deviceKey := config.DeviceCacheKeyKey + ":device-1"
|
||||
unrelatedDeviceKey := config.DeviceCacheKeyKey + ":device-2"
|
||||
|
||||
setString(t, redisServer, userSessionKey, "42")
|
||||
setString(t, redisServer, userDetailKey, "detail")
|
||||
setString(t, redisServer, otherSessionKey, "99")
|
||||
setString(t, redisServer, deviceKey, sessionID)
|
||||
setString(t, redisServer, unrelatedDeviceKey, otherSessionID)
|
||||
if _, err := redisServer.ZAdd(userSessionsZSet, 1, sessionID); err != nil {
|
||||
t.Fatalf("seed session zset: %v", err)
|
||||
}
|
||||
|
||||
if err := clearAllSessions(ctx, svcCtx, userID); err != nil {
|
||||
t.Fatalf("clearAllSessions() error = %v", err)
|
||||
}
|
||||
|
||||
assertMissing(t, redisServer, userSessionKey)
|
||||
assertMissing(t, redisServer, userDetailKey)
|
||||
assertMissing(t, redisServer, deviceKey)
|
||||
assertMissing(t, redisServer, userSessionsZSet)
|
||||
|
||||
if !redisServer.Exists(otherSessionKey) {
|
||||
t.Fatalf("unrelated session %q should remain", otherSessionKey)
|
||||
}
|
||||
if !redisServer.Exists(unrelatedDeviceKey) {
|
||||
t.Fatalf("unrelated device mapping %q should remain", unrelatedDeviceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func setString(t *testing.T, server *miniredis.Miniredis, key, value string) {
|
||||
t.Helper()
|
||||
if err := server.Set(key, value); err != nil {
|
||||
t.Fatalf("set %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMissing(t *testing.T, server *miniredis.Miniredis, key string) {
|
||||
t.Helper()
|
||||
if server.Exists(key) {
|
||||
t.Fatalf("expected key %q to be removed", key)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
|
||||
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 {
|
||||
@@ -110,6 +118,16 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||
}
|
||||
change := *req.Commission - userInfo.Commission
|
||||
if change < 0 {
|
||||
// 禁止直接扣减佣金:扣减必须走 approveWithdrawal 写 type=334 日志,
|
||||
// 否则 user.commission 和 system_logs 会再次失衡,破坏账目闭环。
|
||||
l.Logger.Errorw("blocked direct commission deduction via admin update",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("change", change),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess),
|
||||
"commission deduction must go through withdrawal approval, not direct edit")
|
||||
}
|
||||
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -166,6 +184,19 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package audit records administrative write actions to the admin_action_log
|
||||
// table so security/compliance can trace who did what across lottery admin
|
||||
// endpoints. Every admin CRUD in PR C calls WriteAdminAction inside its own
|
||||
// transaction; the caller is expected to have already validated permissions.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Action code convention: dot-separated, prefix by domain (e.g.
|
||||
// "lottery.activity.create", "lottery.prize.delete"). Keep them short and
|
||||
// stable so downstream analytics can pivot without maintaining a translation
|
||||
// table.
|
||||
const (
|
||||
ActionLotteryActivityCreate = "lottery.activity.create"
|
||||
ActionLotteryActivityUpdate = "lottery.activity.update"
|
||||
ActionLotteryActivityDelete = "lottery.activity.delete"
|
||||
ActionLotteryActivityPublish = "lottery.activity.publish"
|
||||
ActionLotteryActivityPause = "lottery.activity.pause"
|
||||
ActionLotteryPrizeCreate = "lottery.prize.create"
|
||||
ActionLotteryPrizeUpdate = "lottery.prize.update"
|
||||
ActionLotteryPrizeDelete = "lottery.prize.delete"
|
||||
ActionLotteryRulesPut = "lottery.activity.rules.put"
|
||||
ActionLotteryChancesGrant = "lottery.chances.grant"
|
||||
)
|
||||
|
||||
// AdminActionLog is the GORM entity for admin_action_log.
|
||||
type AdminActionLog struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ActorUserId int64 `gorm:"type:bigint unsigned;not null;comment:操作者 user.id"`
|
||||
Action string `gorm:"type:varchar(64);not null;comment:动作 code"`
|
||||
TargetIds string `gorm:"type:varchar(255);not null;default:'';comment:被操作对象 ID"`
|
||||
RequestHash string `gorm:"type:varchar(64);not null;default:'';comment:请求摘要"`
|
||||
IP string `gorm:"type:varchar(45);not null;default:'';comment:操作者 IP"`
|
||||
UserAgent string `gorm:"type:varchar(255);not null;default:'';comment:操作者 UA"`
|
||||
CreatedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:操作时间"`
|
||||
}
|
||||
|
||||
// TableName pins the entity to the migration table name.
|
||||
func (AdminActionLog) TableName() string { return "admin_action_log" }
|
||||
|
||||
// Entry is the pre-hashed convenience input to WriteAdminAction. Callers
|
||||
// build one with actor + action + payload fields; the writer computes the
|
||||
// request hash and inserts inside tx.
|
||||
type Entry struct {
|
||||
ActorUserId int64
|
||||
Action string
|
||||
// TargetIds is stringified list of primary keys touched by this action.
|
||||
// Free-form: comma-separated ints, JSON array, etc.
|
||||
TargetIds string
|
||||
// RequestBody is hashed to produce request_hash. Pass nil if not applicable.
|
||||
RequestBody []byte
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// WriteAdminAction inserts an admin_action_log row inside the caller's tx.
|
||||
// The row lives-or-dies with the caller's transaction: a rollback drops the
|
||||
// audit trail, which is the intended coupling — we don't want to record
|
||||
// actions that never happened.
|
||||
func WriteAdminAction(ctx context.Context, tx *gorm.DB, e Entry) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("audit: WriteAdminAction requires a transaction handle")
|
||||
}
|
||||
if e.ActorUserId == 0 || e.Action == "" {
|
||||
return fmt.Errorf("audit: WriteAdminAction requires ActorUserId and Action")
|
||||
}
|
||||
row := AdminActionLog{
|
||||
ActorUserId: e.ActorUserId,
|
||||
Action: strings.TrimSpace(e.Action),
|
||||
TargetIds: e.TargetIds,
|
||||
RequestHash: hashBody(e.RequestBody),
|
||||
IP: e.IP,
|
||||
UserAgent: truncate(e.UserAgent, 255),
|
||||
}
|
||||
return tx.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
func hashBody(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_RequiresTx(t *testing.T) {
|
||||
err := WriteAdminAction(context.Background(), nil, Entry{ActorUserId: 1, Action: "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on nil tx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_RequiresActorAndAction(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := WriteAdminAction(context.Background(), db, Entry{Action: "x"}); err == nil {
|
||||
t.Fatal("expected error when ActorUserId=0")
|
||||
}
|
||||
if err := WriteAdminAction(context.Background(), db, Entry{ActorUserId: 1}); err == nil {
|
||||
t.Fatal("expected error when Action empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_InsertsRow(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
err := WriteAdminAction(context.Background(), db, Entry{
|
||||
ActorUserId: 42,
|
||||
Action: ActionLotteryActivityCreate,
|
||||
TargetIds: "[1,2,3]",
|
||||
RequestBody: []byte(`{"title":"test"}`),
|
||||
IP: "127.0.0.1",
|
||||
UserAgent: "curl/7.85",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteAdminAction: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashBody(t *testing.T) {
|
||||
if got := hashBody(nil); got != "" {
|
||||
t.Fatalf("nil body should hash to empty, got %q", got)
|
||||
}
|
||||
if got := hashBody([]byte("")); got != "" {
|
||||
t.Fatalf("empty body should hash to empty, got %q", got)
|
||||
}
|
||||
if got := hashBody([]byte("abc")); len(got) != 40 {
|
||||
t.Fatalf("expected 40-char sha1 hex, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("hello", 10); got != "hello" {
|
||||
t.Fatalf("short strings pass through, got %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "hello" {
|
||||
t.Fatalf("expected truncation to 5, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_DBError(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
err := WriteAdminAction(context.Background(), db, Entry{
|
||||
ActorUserId: 1,
|
||||
Action: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error propagation from DB")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/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"
|
||||
@@ -19,6 +20,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -94,6 +96,9 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||
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 != "" {
|
||||
@@ -130,6 +135,7 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
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,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -18,6 +19,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -114,6 +116,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
if logicCommon.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
|
||||
// Record login status
|
||||
defer func() {
|
||||
@@ -188,6 +193,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
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,6 +9,7 @@ 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"
|
||||
@@ -23,6 +24,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -74,6 +76,9 @@ 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 {
|
||||
@@ -628,6 +633,7 @@ func (l *OAuthLoginGetTokenLogic) generateToken(userInfo *user.User, requestID s
|
||||
)
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err)
|
||||
}
|
||||
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,6 +21,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||
}
|
||||
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 {
|
||||
@@ -165,6 +169,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -17,6 +18,7 @@ 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"
|
||||
@@ -89,6 +91,9 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||
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()
|
||||
@@ -135,6 +140,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
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,
|
||||
|
||||
@@ -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.UserDisabled), "user email deleted: %v", req.Email)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||
}
|
||||
|
||||
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const userEnableCacheTTL = 30 * time.Second
|
||||
|
||||
func UserEnableCacheKey(userID int64) string {
|
||||
return fmt.Sprintf("%s%d", config.UserEnableKeyPrefix, userID)
|
||||
}
|
||||
|
||||
func IsUserDisabled(userInfo *modeluser.User) bool {
|
||||
return userInfo != nil && userInfo.Enable != nil && !*userInfo.Enable
|
||||
}
|
||||
|
||||
func ResolveEnabledUser(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user id: %d", userID)
|
||||
}
|
||||
|
||||
cacheKey := UserEnableCacheKey(userID)
|
||||
cached, err := svcCtx.Redis.Get(ctx, cacheKey).Result()
|
||||
if err == nil {
|
||||
if cached == strconv.FormatBool(false) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return svcCtx.UserModel.FindOne(ctx, userID)
|
||||
}
|
||||
if err != nil && err != redis.Nil {
|
||||
logger.WithContext(ctx).Errorw("get user enable cache failed, fallback to db",
|
||||
logger.Field("user_id", userID),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return loadEnabledUserFromDB(ctx, svcCtx, userID)
|
||||
}
|
||||
|
||||
userInfo, err := svcCtx.UserModel.FindOne(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheErr := CacheUserEnabled(ctx, svcCtx, userID, !IsUserDisabled(userInfo)); cacheErr != nil {
|
||||
logger.WithContext(ctx).Errorw("cache user enable state failed",
|
||||
logger.Field("user_id", userID),
|
||||
logger.Field("error", cacheErr.Error()),
|
||||
)
|
||||
}
|
||||
if IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func CacheUserEnabled(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, enabled bool) error {
|
||||
return svcCtx.Redis.Set(ctx, UserEnableCacheKey(userID), strconv.FormatBool(enabled), userEnableCacheTTL).Err()
|
||||
}
|
||||
|
||||
func InvalidateUserEnableCache(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||
return svcCtx.Redis.Del(ctx, UserEnableCacheKey(userID)).Err()
|
||||
}
|
||||
|
||||
func loadEnabledUserFromDB(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||
var userInfo modeluser.User
|
||||
if err := svcCtx.DB.WithContext(ctx).
|
||||
Model(&modeluser.User{}).
|
||||
Unscoped().
|
||||
Where("`id` = ?", userID).
|
||||
Preload("UserDevices").
|
||||
Preload("AuthMethods").
|
||||
First(&userInfo).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsUserDisabled(&userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return &userInfo, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestIsUserDisabled(t *testing.T) {
|
||||
trueValue := true
|
||||
falseValue := false
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *user.User
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil user treated as enabled",
|
||||
user: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil enable treated as enabled",
|
||||
user: &user.User{Id: 1},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled user",
|
||||
user: &user.User{Id: 2, Enable: &trueValue},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "disabled user",
|
||||
user: &user.User{Id: 3, Enable: &falseValue},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsUserDisabled(tc.user); got != tc.want {
|
||||
t.Fatalf("IsUserDisabled() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEnabledUser(t *testing.T) {
|
||||
t.Run("cache hit false returns disabled error without db query", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := svcCtx.Redis.Set(ctx, UserEnableCacheKey(9), "false", 0).Err(); err != nil {
|
||||
t.Fatalf("seed redis: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEnabledUser(ctx, svcCtx, 9)
|
||||
assertCodeError(t, err, xerr.UserDisabled)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unexpected db query: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache miss loads enabled user and backfills cache", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
expectFindOne(mock, 11, true)
|
||||
|
||||
ctx := context.Background()
|
||||
userInfo, err := ResolveEnabledUser(ctx, svcCtx, 11)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||
}
|
||||
if userInfo.Id != 11 {
|
||||
t.Fatalf("ResolveEnabledUser() user id = %d, want 11", userInfo.Id)
|
||||
}
|
||||
|
||||
cached, err := svcCtx.Redis.Get(ctx, UserEnableCacheKey(11)).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("read backfilled cache: %v", err)
|
||||
}
|
||||
if cached != "true" {
|
||||
t.Fatalf("backfilled cache = %q, want %q", cached, "true")
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache miss loads disabled user and caches false", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
expectFindOne(mock, 13, false)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := ResolveEnabledUser(ctx, svcCtx, 13)
|
||||
assertCodeError(t, err, xerr.UserDisabled)
|
||||
|
||||
cached, cacheErr := svcCtx.Redis.Get(ctx, UserEnableCacheKey(13)).Result()
|
||||
if cacheErr != nil {
|
||||
t.Fatalf("read disabled cache: %v", cacheErr)
|
||||
}
|
||||
if cached != "false" {
|
||||
t.Fatalf("disabled cache = %q, want %q", cached, "false")
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redis failure falls back to db", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
expectFindOne(mock, 17, true)
|
||||
redisServer.Close()
|
||||
|
||||
userInfo, err := ResolveEnabledUser(context.Background(), svcCtx, 17)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||
}
|
||||
if userInfo.Id != 17 {
|
||||
t.Fatalf("ResolveEnabledUser() user id = %d, want 17", userInfo.Id)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newEnableTestServiceContext(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
gdb, err := gorm.Open(mysql.New(mysql.Config{
|
||||
Conn: sqlDB,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("gorm.Open() error = %v", err)
|
||||
}
|
||||
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = rdb.Close()
|
||||
})
|
||||
|
||||
return &svc.ServiceContext{
|
||||
DB: gdb,
|
||||
Redis: rdb,
|
||||
UserModel: user.NewModel(gdb, rdb),
|
||||
}, mock, redisServer
|
||||
}
|
||||
|
||||
func expectFindOne(mock sqlmock.Sqlmock, userID int64, enabled bool) {
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id",
|
||||
"password",
|
||||
"algo",
|
||||
"salt",
|
||||
"avatar",
|
||||
"balance",
|
||||
"refer_code",
|
||||
"referer_id",
|
||||
"commission",
|
||||
"referral_percentage",
|
||||
"only_first_purchase",
|
||||
"gift_amount",
|
||||
"enable",
|
||||
"is_admin",
|
||||
"enable_balance_notify",
|
||||
"enable_login_notify",
|
||||
"enable_subscribe_notify",
|
||||
"enable_trade_notify",
|
||||
"rules",
|
||||
"member_status",
|
||||
"remark",
|
||||
"last_login_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
}).AddRow(
|
||||
userID,
|
||||
"pwd",
|
||||
"default",
|
||||
"",
|
||||
"",
|
||||
int64(0),
|
||||
"",
|
||||
int64(0),
|
||||
int64(0),
|
||||
uint8(0),
|
||||
true,
|
||||
int64(0),
|
||||
enabled,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user` WHERE `id` = ? ORDER BY `user`.`id` LIMIT ?")).
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(rows)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_device` WHERE `user_device`.`user_id` = ?")).
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "identifier"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_auth_methods` WHERE `user_auth_methods`.`user_id` = ?")).
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "auth_type", "auth_identifier", "verified"}))
|
||||
}
|
||||
|
||||
func assertCodeError(t *testing.T, err error, wantCode uint32) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var codeErr *xerr.CodeError
|
||||
if !errors.As(err, &codeErr) {
|
||||
t.Fatalf("error %T does not contain xerr.CodeError: %v", err, err)
|
||||
}
|
||||
if codeErr.GetErrCode() != wantCode {
|
||||
t.Fatalf("error code = %d, want %d", codeErr.GetErrCode(), wantCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
// Package draw implements POST /api/v1/lottery/draw: the transactional lottery
|
||||
// draw flow. The service composes the ChanceService, RuleEvaluator,
|
||||
// WeightedPicker, PrizeHandler.Registry and LedgerService primitives from the
|
||||
// model layer into one atomic sequence.
|
||||
//
|
||||
// Ordering matters — the flow is:
|
||||
// 1. feature-flag gate (config.Lottery.Enable)
|
||||
// 2. Redis rate limit (per-user 1/sec)
|
||||
// 3. Load activity + validate window/status
|
||||
// 4. Build RuleContext (pre-tx reads)
|
||||
// 5. Evaluate eligibility (pure compute)
|
||||
// 6. Load prize pool snapshot (pre-tx read)
|
||||
// 7. Open tx →
|
||||
// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce)
|
||||
// — hit returns the recorded draw
|
||||
// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement)
|
||||
// 7c. WeightedPicker.Pick
|
||||
// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0
|
||||
// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot
|
||||
// 7f. Auto-handler Dispatch (in tx)
|
||||
// 7g. Update draw.dispatch_state
|
||||
// → commit
|
||||
//
|
||||
// Everything past step 5 uses the caller's transaction; post-commit cache
|
||||
// invalidation is the handler layer's job (a future enhancement — the
|
||||
// underlying UserModel already invalidates its own cache on UpdateSubscribe).
|
||||
package draw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/limit"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Request is the input to Draw.
|
||||
type Request struct {
|
||||
UserId int64
|
||||
ActivityId int64
|
||||
ClientNonce string
|
||||
}
|
||||
|
||||
// Result is what Draw returns to the caller (user handler).
|
||||
type Result struct {
|
||||
DrawId int64
|
||||
IsWin bool
|
||||
Prize *PrizeSummary
|
||||
ChancesRemaining int64
|
||||
Claim ClaimSummary
|
||||
Message string
|
||||
}
|
||||
|
||||
// PrizeSummary is the awarded-prize view rendered for the user.
|
||||
type PrizeSummary struct {
|
||||
Slot int
|
||||
Id int64
|
||||
Type string
|
||||
Name string
|
||||
Config json.RawMessage
|
||||
}
|
||||
|
||||
// ClaimSummary describes whether the user needs to take a further action.
|
||||
type ClaimSummary struct {
|
||||
Required bool
|
||||
AutoClaimed bool
|
||||
Message string
|
||||
// ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。
|
||||
ExpiresAt int64
|
||||
// ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema
|
||||
// (nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。
|
||||
ClaimFormSchema json.RawMessage
|
||||
}
|
||||
|
||||
// Service orchestrates the transactional draw flow. Deps are struct-injected
|
||||
// so tests can substitute fakes and production wiring lives in ServiceContext.
|
||||
type Service struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
// Deps groups the collaborators. Nil-safe checks live in Draw itself, not here.
|
||||
type Deps struct {
|
||||
DB *gorm.DB
|
||||
Enabled bool
|
||||
RateLimiter RateLimiter
|
||||
Chance lottery.ChanceService
|
||||
Evaluator lottery.RuleEvaluator
|
||||
Picker lottery.WeightedPicker
|
||||
Registry lottery.Registry
|
||||
ContextBuilder RuleContextBuilder
|
||||
}
|
||||
|
||||
// RateLimiter admits at most 1 draw per second per user. Extracted to an
|
||||
// interface so tests can supply an always-admit fake without pulling Redis.
|
||||
type RateLimiter interface {
|
||||
Allow(ctx context.Context, userId int64) error
|
||||
}
|
||||
|
||||
// RuleContextBuilder loads the per-user snapshot needed by the rule
|
||||
// evaluator. Extracted so tests can inject deterministic contexts.
|
||||
type RuleContextBuilder interface {
|
||||
Build(ctx context.Context, userId int64) (lottery.RuleContext, error)
|
||||
}
|
||||
|
||||
// NewService returns a Draw service ready to serve requests.
|
||||
func NewService(d Deps) *Service { return &Service{deps: d} }
|
||||
|
||||
// Draw runs the full lottery draw flow. Errors are xerr codes suitable for
|
||||
// direct return by the HTTP handler; internal errors are wrapped as
|
||||
// LotteryInternalError.
|
||||
func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
||||
if err := s.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.deps.Enabled {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
if err := s.applyRateLimit(ctx, req.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity, err := s.loadRunningActivity(ctx, req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-tx reads: user context + prize pool snapshot. Cheap and out of the
|
||||
// hot-lock window; the tx step re-checks stock atomically.
|
||||
rc, err := s.buildRuleContext(ctx, req.UserId)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
|
||||
tree, err := parseEligibilityTree(activity.Eligibility)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
passed, unmet, err := s.deps.Evaluator.Evaluate(ctx, tree, rc)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if !passed {
|
||||
// The rejection itself is not an error to the caller — but we still
|
||||
// record an eligibility snapshot for support/audit before returning
|
||||
// the 4001. Snapshot write intentionally uses its own tx: the draw
|
||||
// itself never got issued, so there is no draw_id to correlate; we
|
||||
// omit the snapshot in that case.
|
||||
_ = unmet // unmet is available to the handler via error metadata if needed
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotEligible)
|
||||
}
|
||||
|
||||
prizes, err := s.loadPrizes(ctx, req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if len(prizes) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
|
||||
var result *Result
|
||||
txErr := s.deps.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// (a) Nonce dedupe — race-safe idempotency check.
|
||||
if existing, existsErr := s.findExistingDraw(ctx, tx, req); existsErr != nil {
|
||||
return existsErr
|
||||
} else if existing != nil {
|
||||
result, existsErr = s.buildResultFromExistingDraw(ctx, tx, existing)
|
||||
return existsErr
|
||||
}
|
||||
|
||||
// (b) Consume chance atomically. ErrNoChances → 4002.
|
||||
remaining, consumeErr := s.deps.Chance.Consume(ctx, tx, req.UserId, req.ActivityId)
|
||||
if errors.Is(consumeErr, lottery.ErrNoChances) {
|
||||
return xerr.NewErrCode(xerr.LotteryNoChances)
|
||||
}
|
||||
if consumeErr != nil {
|
||||
return wrapInternal(consumeErr)
|
||||
}
|
||||
|
||||
// (c) Pick a prize.
|
||||
idx, pickErr := s.deps.Picker.Pick(prizes)
|
||||
if pickErr != nil {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
picked := prizes[idx]
|
||||
|
||||
// (d) Limited-stock decrement.
|
||||
final, stockErr := s.decrementStockOrFallback(ctx, tx, picked, prizes)
|
||||
if stockErr != nil {
|
||||
return wrapInternal(stockErr)
|
||||
}
|
||||
|
||||
// (e) Insert draw + snapshots.
|
||||
draw, insertErr := s.insertDraw(ctx, tx, req, final)
|
||||
if insertErr != nil {
|
||||
return wrapInternal(insertErr)
|
||||
}
|
||||
if snapErr := s.insertSnapshots(ctx, tx, draw, final, passed); snapErr != nil {
|
||||
return wrapInternal(snapErr)
|
||||
}
|
||||
|
||||
// (f) Dispatch prize (auto handler) or create pending claim (manual handler).
|
||||
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final)
|
||||
if dispatchErr != nil {
|
||||
return dispatchErr
|
||||
}
|
||||
|
||||
// (g) Update draw.dispatch_state to reflect handler outcome.
|
||||
if updateErr := s.finalizeDrawState(ctx, tx, draw, dispatch); updateErr != nil {
|
||||
return wrapInternal(updateErr)
|
||||
}
|
||||
|
||||
result = buildResult(draw, final, dispatch, claimInfo, remaining)
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
if _, ok := txErr.(*xerr.CodeError); ok {
|
||||
return nil, txErr
|
||||
}
|
||||
return nil, wrapInternal(txErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- individual steps ------------------------------------------------------
|
||||
|
||||
func (s *Service) validateRequest(req Request) error {
|
||||
if req.UserId <= 0 || req.ActivityId <= 0 || req.ClientNonce == "" {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
if len(req.ClientNonce) > 64 {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyRateLimit(ctx context.Context, userId int64) error {
|
||||
if s.deps.RateLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.deps.RateLimiter.Allow(ctx, userId); err != nil {
|
||||
if errors.Is(err, ErrRateLimited) {
|
||||
return xerr.NewErrCode(xerr.LotteryRateLimited)
|
||||
}
|
||||
return wrapInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRunningActivity(ctx context.Context, activityId int64) (*lottery.Activity, error) {
|
||||
var activity lottery.Activity
|
||||
now := time.Now()
|
||||
err := s.deps.DB.WithContext(ctx).
|
||||
Where("id = ?", activityId).
|
||||
First(&activity).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if activity.Status != lottery.ActivityStatusRunning {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
if activity.StartAt.After(now) || activity.EndAt.Before(now) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildRuleContext(ctx context.Context, userId int64) (lottery.RuleContext, error) {
|
||||
if s.deps.ContextBuilder == nil {
|
||||
return lottery.RuleContext{UserId: userId, Now: time.Now().Unix()}, nil
|
||||
}
|
||||
return s.deps.ContextBuilder.Build(ctx, userId)
|
||||
}
|
||||
|
||||
func (s *Service) loadPrizes(ctx context.Context, activityId int64) ([]lottery.Prize, error) {
|
||||
var prizes []lottery.Prize
|
||||
err := s.deps.DB.WithContext(ctx).
|
||||
Where("activity_id = ?", activityId).
|
||||
Order("slot ASC").
|
||||
Find(&prizes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
func (s *Service) findExistingDraw(ctx context.Context, tx *gorm.DB, req Request) (*lottery.Draw, error) {
|
||||
var existing lottery.Draw
|
||||
err := tx.WithContext(ctx).
|
||||
Where("user_id = ? AND client_nonce = ?", req.UserId, req.ClientNonce).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// decrementStockOrFallback runs the limited-stock optimistic lock. If the
|
||||
// chosen prize is unlimited, returns it as-is. If limited and stock survives
|
||||
// → returns it. If limited and sold-out → walks the pool for the first
|
||||
// `is_fallback=true` prize or falls back to a "none" (thanks-for-playing)
|
||||
// synthetic prize.
|
||||
func (s *Service) decrementStockOrFallback(ctx context.Context, tx *gorm.DB, picked lottery.Prize, pool []lottery.Prize) (lottery.Prize, error) {
|
||||
if !picked.RemainingStock.Valid {
|
||||
return picked, nil
|
||||
}
|
||||
res := tx.WithContext(ctx).
|
||||
Model(&lottery.Prize{}).
|
||||
Where("id = ? AND remaining_stock > 0", picked.Id).
|
||||
UpdateColumn("remaining_stock", gorm.Expr("`remaining_stock` - 1"))
|
||||
if res.Error != nil {
|
||||
return lottery.Prize{}, res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
return picked, nil
|
||||
}
|
||||
// Sold out → fallback selection.
|
||||
for _, p := range pool {
|
||||
if p.IsFallback {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// No fallback declared → synthesize a "谢谢参与" from the last non-fallback
|
||||
// entry (any type=none in the pool wins); if pool has no none, we
|
||||
// synthesize an ephemeral prize record. Note: this prize is NOT persisted
|
||||
// as a separate row — it just satisfies the return contract.
|
||||
for _, p := range pool {
|
||||
if p.Type == lottery.PrizeTypeNone {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return lottery.Prize{
|
||||
ActivityId: picked.ActivityId,
|
||||
Slot: picked.Slot,
|
||||
Type: lottery.PrizeTypeNone,
|
||||
Name: "谢谢参与",
|
||||
Config: "{}",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertDraw(ctx context.Context, tx *gorm.DB, req Request, prize lottery.Prize) (*lottery.Draw, error) {
|
||||
draw := lottery.Draw{
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
ClientNonce: req.ClientNonce,
|
||||
IsWin: prize.Type != lottery.PrizeTypeNone,
|
||||
DispatchState: lottery.DispatchStateNone,
|
||||
DrawnAt: time.Now(),
|
||||
}
|
||||
if prize.Id > 0 {
|
||||
draw.PrizeId = sql.NullInt64{Int64: prize.Id, Valid: true}
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&draw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &draw, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, prize lottery.Prize, passedEligibility bool) error {
|
||||
ps := lottery.PrizeSnapshot{
|
||||
DrawId: draw.Id,
|
||||
PrizeId: prize.Id,
|
||||
Slot: prize.Slot,
|
||||
Type: prize.Type,
|
||||
Name: prize.Name,
|
||||
Config: prize.Config,
|
||||
}
|
||||
if ps.Config == "" {
|
||||
ps.Config = "{}"
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&ps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
es := lottery.EligibilitySnapshot{
|
||||
DrawId: draw.Id,
|
||||
UserId: draw.UserId,
|
||||
ActivityId: draw.ActivityId,
|
||||
Passed: passedEligibility,
|
||||
// UnmetReasons 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||
// Stage 1 只有 passed=true 进 insertSnapshots,语义上"没有未过项",
|
||||
// 用 "[]" 与 PrizeSnapshot.Config 的 "{}" 守卫对称。
|
||||
// Stage 2 若开始持久化 passed=false 的失败评估,再改成真正的 marshal。
|
||||
UnmetReasons: "[]",
|
||||
// 显式 time.Now():GORM 遇 zero time 有时会传 '0000-00-00 00:00:00',
|
||||
// 触 sql_mode STRICT。不依赖 DB DEFAULT CURRENT_TIMESTAMP。
|
||||
EvaluatedAt: time.Now(),
|
||||
}
|
||||
return tx.WithContext(ctx).Create(&es).Error
|
||||
}
|
||||
|
||||
// pendingClaimInfo carries the manual-claim details the draw service produced
|
||||
// this turn. Zero-value = draw did not create a claim (auto prize or none).
|
||||
type pendingClaimInfo struct {
|
||||
ExpiresAt time.Time
|
||||
ClaimFormSchema json.RawMessage
|
||||
}
|
||||
|
||||
// dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch
|
||||
// (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path).
|
||||
//
|
||||
// - draw.IsWin == false → thanks-for-playing, auto_claimed.
|
||||
// - handler.IsAuto()==true → call Dispatch inside caller's tx.
|
||||
// - handler.IsAuto()==false → insert lottery_claim (pending_claim) and
|
||||
// return ClaimFormSchema + ExpiresAt so the
|
||||
// caller can render the response.
|
||||
func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) {
|
||||
if !draw.IsWin {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
if s.deps.Registry == nil {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
|
||||
if err != nil {
|
||||
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||
}
|
||||
|
||||
if prizeHandler.IsAuto() {
|
||||
dispatchReq := lottery.DispatchRequest{
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: draw.Id,
|
||||
Prize: prize,
|
||||
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
|
||||
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
|
||||
}
|
||||
result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq)
|
||||
return result, pendingClaimInfo{}, dispatchErr
|
||||
}
|
||||
|
||||
// Manual-claim path (Stage 2). Insert a pending_claim row inside the same
|
||||
// draw tx so a rollback also erases the claim.
|
||||
expiresAt := s.computeClaimExpiry(prize)
|
||||
claim := lottery.Claim{
|
||||
DrawId: draw.Id,
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
PrizeType: prize.Type,
|
||||
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
|
||||
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
|
||||
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
|
||||
// GrantLedger.Payload 的守卫风格一致。
|
||||
ClaimData: "{}",
|
||||
Status: lottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil {
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err))
|
||||
}
|
||||
|
||||
schema := prizeHandler.ClaimSchema()
|
||||
// crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。
|
||||
if prize.Type == lottery.PrizeTypeCrypto {
|
||||
schema = handler.BuildCryptoClaimSchema(prize.Config)
|
||||
}
|
||||
return lottery.DispatchResult{
|
||||
State: lottery.DispatchStatePendingClaim,
|
||||
Message: "请凭此截图直接联系人工客服兑换奖励。",
|
||||
}, pendingClaimInfo{
|
||||
ExpiresAt: expiresAt,
|
||||
ClaimFormSchema: schema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正
|
||||
// 则回落到 lottery.DefaultClaimTTLHours (7 天)。
|
||||
func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time {
|
||||
hours := lottery.DefaultClaimTTLHours
|
||||
if prize.Config != "" {
|
||||
var cfg struct {
|
||||
ClaimTTLHours int `json:"claim_ttl_hours"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 {
|
||||
hours = cfg.ClaimTTLHours
|
||||
}
|
||||
}
|
||||
return time.Now().Add(time.Duration(hours) * time.Hour)
|
||||
}
|
||||
|
||||
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"dispatch_state": dispatch.State,
|
||||
}
|
||||
if dispatch.State == lottery.DispatchStateAutoClaimed || dispatch.State == lottery.DispatchStatePaid {
|
||||
updates["dispatched_at"] = now
|
||||
}
|
||||
return tx.WithContext(ctx).
|
||||
Model(&lottery.Draw{}).
|
||||
Where("id = ?", draw.Id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, existing *lottery.Draw) (*Result, error) {
|
||||
var snap lottery.PrizeSnapshot
|
||||
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&snap).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
remaining, _ := s.deps.Chance.Query(ctx, existing.UserId, existing.ActivityId)
|
||||
var prize *PrizeSummary
|
||||
if existing.IsWin {
|
||||
prize = &PrizeSummary{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(snap.Config),
|
||||
}
|
||||
}
|
||||
claim := ClaimSummary{
|
||||
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
||||
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
||||
}
|
||||
// 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次
|
||||
// 收到的响应比首次少字段。
|
||||
if claim.Required {
|
||||
var claimRow lottery.Claim
|
||||
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error
|
||||
if err == nil {
|
||||
claim.ExpiresAt = claimRow.ExpiresAt.Unix()
|
||||
if s.deps.Registry != nil {
|
||||
if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok {
|
||||
if claimRow.PrizeType == lottery.PrizeTypeCrypto {
|
||||
claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config)
|
||||
} else {
|
||||
claim.ClaimFormSchema = h.ClaimSchema()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Result{
|
||||
DrawId: existing.Id,
|
||||
IsWin: existing.IsWin,
|
||||
Prize: prize,
|
||||
ChancesRemaining: remaining,
|
||||
Claim: claim,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result {
|
||||
res := &Result{
|
||||
DrawId: draw.Id,
|
||||
IsWin: draw.IsWin,
|
||||
ChancesRemaining: remaining,
|
||||
Message: dispatch.Message,
|
||||
Claim: ClaimSummary{
|
||||
Required: dispatch.State == lottery.DispatchStatePendingClaim,
|
||||
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
|
||||
Message: dispatch.Message,
|
||||
ClaimFormSchema: claim.ClaimFormSchema,
|
||||
},
|
||||
}
|
||||
if !claim.ExpiresAt.IsZero() {
|
||||
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
|
||||
}
|
||||
if draw.IsWin {
|
||||
res.Prize = &PrizeSummary{
|
||||
Slot: prize.Slot,
|
||||
Id: prize.Id,
|
||||
Type: prize.Type,
|
||||
Name: prize.Name,
|
||||
Config: json.RawMessage(prize.Config),
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// parseEligibilityTree tolerates empty/null activity.Eligibility as "no gate".
|
||||
func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) {
|
||||
trimmed := ""
|
||||
for _, r := range raw {
|
||||
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
|
||||
trimmed += string(r)
|
||||
}
|
||||
}
|
||||
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||
return nil, nil
|
||||
}
|
||||
var tree lottery.EligibilityRule
|
||||
if err := json.Unmarshal([]byte(raw), &tree); err != nil {
|
||||
return nil, fmt.Errorf("parse eligibility: %w", err)
|
||||
}
|
||||
return &tree, nil
|
||||
}
|
||||
|
||||
// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。
|
||||
//
|
||||
// HIF-4 F10:msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文
|
||||
// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致
|
||||
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种
|
||||
// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。
|
||||
func wrapInternal(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Preserve already-coded errors (their msg 是设计过的对外文案,不动).
|
||||
if _, ok := err.(*xerr.CodeError); ok {
|
||||
return err
|
||||
}
|
||||
// 内部细节走日志,供运维/后端排查;err.Error() 不外传。
|
||||
logger.WithContext(context.Background()).Error("[lottery draw internal error]",
|
||||
logger.Field("error", err.Error()))
|
||||
return xerr.NewErrCode(xerr.LotteryInternalError)
|
||||
}
|
||||
|
||||
// ---- Rate limiter production wiring ----------------------------------------
|
||||
|
||||
// ErrRateLimited is returned by RateLimiter.Allow when the caller exceeded
|
||||
// the configured quota.
|
||||
var ErrRateLimited = errors.New("draw: rate limited")
|
||||
|
||||
// RedisRateLimiter is the production RateLimiter backed by pkg/limit's
|
||||
// Redis-Lua fixed-window (1 hit per 1 second per user), matching the
|
||||
// existing sendEmailCodeLogic pattern.
|
||||
type RedisRateLimiter struct {
|
||||
limiter *limit.PeriodLimit
|
||||
}
|
||||
|
||||
// NewRedisRateLimiter builds a per-user 1-req/1-sec limiter. keyPrefix is
|
||||
// expected to end with ':' so the composed key is human-readable.
|
||||
func NewRedisRateLimiter(limiter *limit.PeriodLimit) *RedisRateLimiter {
|
||||
return &RedisRateLimiter{limiter: limiter}
|
||||
}
|
||||
|
||||
// Allow admits or rejects the caller.
|
||||
func (r *RedisRateLimiter) Allow(ctx context.Context, userId int64) error {
|
||||
if r == nil || r.limiter == nil {
|
||||
return nil
|
||||
}
|
||||
state, err := r.limiter.TakeCtx(ctx, strconv.FormatInt(userId, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == limit.Allowed || state == limit.HitQuota {
|
||||
return nil
|
||||
}
|
||||
return ErrRateLimited
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommissionHandler 发放"抽奖佣金"。写 user.commission 增量 + system_logs
|
||||
// (Type=Commission, CommissionType=339 Lottery) —— 用新增的 CommissionTypeLottery
|
||||
// 常量与 Purchase/Renewal 区分,账目侧对账更清晰。
|
||||
//
|
||||
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||
// Reserve 命中即幂等,未命中才走真实发放。commission 直接发给中奖者本人,不做
|
||||
// 家庭组归位(family owner 不代收成员的抽奖佣金)。
|
||||
type CommissionHandler struct {
|
||||
deps CommissionDeps
|
||||
}
|
||||
|
||||
// CommissionDeps 是 CommissionHandler 需要的最小依赖集。抽出到接口方便测试。
|
||||
type CommissionDeps struct {
|
||||
Ledger lottery.LedgerService
|
||||
// UpdateCommission 对齐 UserModel.UpdateCommission 签名。
|
||||
UpdateCommission func(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
|
||||
// WriteCommissionLog 对齐 common.WriteCommissionLog 签名。
|
||||
WriteCommissionLog func(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error
|
||||
}
|
||||
|
||||
// NewCommissionHandler 构造真实的 commission handler。
|
||||
func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
|
||||
return &CommissionHandler{deps: deps}
|
||||
}
|
||||
|
||||
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
||||
func (*CommissionHandler) IsAuto() bool { return true }
|
||||
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
||||
func (*CommissionHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
|
||||
type commissionConfig struct {
|
||||
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
||||
AmountCents int64 `json:"amount_cents"`
|
||||
}
|
||||
|
||||
type commissionPayload struct {
|
||||
Amount int64 `json:"amount"`
|
||||
LogType uint16 `json:"log_type"`
|
||||
Message string `json:"message"`
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
// Dispatch 在 caller 的事务内为中奖人发放佣金。
|
||||
func (h *CommissionHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
if tx == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires a transaction")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires DispatchRequest.IdempotencyKey")
|
||||
}
|
||||
if h.deps.UpdateCommission == nil || h.deps.WriteCommissionLog == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler deps not fully wired")
|
||||
}
|
||||
|
||||
var cfg commissionConfig
|
||||
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("decode commission config: %w", err)
|
||||
}
|
||||
if cfg.AmountCents <= 0 {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("commission config amount_cents must be > 0, got %d", cfg.AmountCents)
|
||||
}
|
||||
|
||||
// 佣金"发给中奖者本人"(不走家庭组归位)。
|
||||
targetUserID := req.UserId
|
||||
|
||||
entry := lottery.GrantLedger{
|
||||
ExternalRef: req.IdempotencyKey,
|
||||
HandlerType: lottery.PrizeTypeCommission,
|
||||
UserId: targetUserID,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: req.DrawId,
|
||||
Amount: cfg.AmountCents,
|
||||
}
|
||||
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||
}
|
||||
if alreadyExisted {
|
||||
var payload commissionPayload
|
||||
if row.Payload != "" {
|
||||
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||
}
|
||||
if payload.Message == "" {
|
||||
payload.Message = "佣金已到账"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。UpdateCommission 用 gorm.Expr 原子累加,避免丢更新。
|
||||
if err := h.deps.UpdateCommission(ctx, targetUserID, cfg.AmountCents, tx); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update commission for user %d: %w", targetUserID, err)
|
||||
}
|
||||
// 传 external_ref 到 WriteCommissionLog 的 orderNo 位("lottery:*"),与业务 order 命名域天然区分。
|
||||
if err := h.deps.WriteCommissionLog(tx, targetUserID, logmodel.CommissionTypeLottery, cfg.AmountCents, req.IdempotencyKey); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("write commission log: %w", err)
|
||||
}
|
||||
|
||||
payload := commissionPayload{
|
||||
Amount: cfg.AmountCents,
|
||||
LogType: logmodel.CommissionTypeLottery,
|
||||
OrderNo: req.IdempotencyKey,
|
||||
Message: fmt.Sprintf("佣金已到账 %d", cfg.AmountCents),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("marshal ledger payload: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&lottery.GrantLedger{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update ledger payload: %w", err)
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WriteCommissionLog 是 internal/logic/common.WriteCommissionLog 的镜像。
|
||||
// 抽到 handler 包避免 internal/svc → internal/logic/common 的 import cycle
|
||||
// (internal/logic/common 里有别的文件反向 import 了 svc)。函数体保持一致,
|
||||
// 未来若 common 侧调整了签名或者行为要同步到这里。
|
||||
func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||
logInfo := logmodel.Commission{
|
||||
Type: logType,
|
||||
Amount: amount,
|
||||
OrderNo: orderNo,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, err := logInfo.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(logmodel.SystemLog{}).Create(&logmodel.SystemLog{
|
||||
Type: logmodel.TypeCommission.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: objectID,
|
||||
Content: string(content),
|
||||
CreatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCommission_RequiresIdempotencyKey(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT call UpdateCommission without idempotency key")
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_RequiresTx(t *testing.T) {
|
||||
h := NewCommissionHandler(CommissionDeps{Ledger: &fakeLedger{}})
|
||||
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||
t.Fatalf("expected tx error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_IdempotentHitDoesNotWrite(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{
|
||||
Payload: `{"message":"佣金已到账 300"}`,
|
||||
}, true, nil
|
||||
},
|
||||
}
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: ledger,
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT UpdateCommission on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error {
|
||||
t.Fatal("must NOT WriteCommissionLog on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.Message != "佣金已到账 300" {
|
||||
t.Fatalf("expected replay message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_FirstTimeWritesCommissionAndLog(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 11}, false, nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
updateCommissionCalled = false
|
||||
writeLogCalled = false
|
||||
writeLogType uint16
|
||||
writeLogAmount int64
|
||||
writeLogOrderNo string
|
||||
)
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: ledger,
|
||||
UpdateCommission: func(_ context.Context, uid, amount int64, _ ...*gorm.DB) error {
|
||||
updateCommissionCalled = true
|
||||
if uid != 42 || amount != 300 {
|
||||
t.Fatalf("UpdateCommission got (uid=%d, amount=%d)", uid, amount)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(_ *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||
writeLogCalled = true
|
||||
writeLogType = logType
|
||||
writeLogAmount = amount
|
||||
writeLogOrderNo = orderNo
|
||||
if objectID != 42 {
|
||||
t.Fatalf("WriteCommissionLog objectID=%d, want 42", objectID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmockAnyResult())
|
||||
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if !updateCommissionCalled || !writeLogCalled {
|
||||
t.Fatalf("expected both UpdateCommission and WriteCommissionLog to be called (uc=%v wl=%v)", updateCommissionCalled, writeLogCalled)
|
||||
}
|
||||
if writeLogType != logmodel.CommissionTypeLottery {
|
||||
t.Fatalf("expected CommissionTypeLottery(%d), got %d", logmodel.CommissionTypeLottery, writeLogType)
|
||||
}
|
||||
if writeLogAmount != 300 {
|
||||
t.Fatalf("expected amount 300, got %d", writeLogAmount)
|
||||
}
|
||||
if writeLogOrderNo != "lottery:100:200" {
|
||||
t.Fatalf("expected orderNo to reuse ExternalRef, got %q", writeLogOrderNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_BadConfigRejected(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error { return nil },
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{name: "invalid json", config: `{bad`},
|
||||
{name: "zero amount", config: `{"amount_cents":0}`},
|
||||
{name: "negative amount", config: `{"amount_cents":-1}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: tt.config},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_MissingDepsFailsFast(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
IdempotencyKey: "k",
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":1}`},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when deps missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package handler crypto/physical/manual_other 是 Stage 2 引入的三类"人工奖"
|
||||
// PrizeHandler。特点:IsAuto()=false,抽奖事务不调用 Dispatch,而是由 draw
|
||||
// 服务事务内插入 lottery_claim (pending_claim)。用户随后 POST /claim 提交
|
||||
// 领奖表单;运营在后台 approve → mark-paid。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- 通用错误 -------------------------------------------------------------
|
||||
|
||||
var (
|
||||
// ErrClaimDataEmpty 表示用户没有提交任何领奖 body。
|
||||
ErrClaimDataEmpty = errors.New("lottery: claim data is empty")
|
||||
// ErrClaimDataMalformed 表示 body 不是合法 JSON 或缺关键字段。
|
||||
ErrClaimDataMalformed = errors.New("lottery: claim data is malformed")
|
||||
)
|
||||
|
||||
// notSupportedDispatch 返回 ErrDispatchNotSupported,供三个人工奖 handler 共享。
|
||||
// 抽奖服务在 handler.IsAuto()==false 时会短路,不会真的调用 Dispatch;这个
|
||||
// 实现只是防御性的:万一未来某处直接调用了 Dispatch,能立刻在日志里看到问题。
|
||||
func notSupportedDispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return lottery.DispatchResult{}, lottery.ErrDispatchNotSupported
|
||||
}
|
||||
|
||||
// decodeClaimJSON 是三个人工 handler 通用的 body 解码路径:空 body 直接返回
|
||||
// ErrClaimDataEmpty;解码失败返回 ErrClaimDataMalformed(wrap 原因)。
|
||||
func decodeClaimJSON(raw []byte, out any) error {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
|
||||
return ErrClaimDataEmpty
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- crypto handler ------------------------------------------------------
|
||||
|
||||
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
|
||||
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
|
||||
type CryptoHandler struct{}
|
||||
|
||||
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
|
||||
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
|
||||
|
||||
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
|
||||
func (*CryptoHandler) IsAuto() bool { return false }
|
||||
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
|
||||
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
|
||||
// 这里的常量是空 enum 的"模板";ClaimSchema() 返回不带具体 networks 的通用
|
||||
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
|
||||
// 生成带 enum 的最终 schema 附到 draw response 上。
|
||||
var cryptoClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["network","address"],
|
||||
"properties": {
|
||||
"network": {"type":"string","title":"打款网络"},
|
||||
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
|
||||
|
||||
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema:
|
||||
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
|
||||
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
|
||||
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
|
||||
for i, n := range cfg.Networks {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
encoded, _ := json.Marshal(n)
|
||||
b.Write(encoded)
|
||||
}
|
||||
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
|
||||
return json.RawMessage(b.String())
|
||||
}
|
||||
|
||||
// cryptoConfig 是 lottery_prize.config 的解码目标。
|
||||
type cryptoConfig struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type cryptoClaimInput struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
|
||||
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
|
||||
// mark-paid 前人肉复核。
|
||||
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
|
||||
|
||||
// ValidateClaim 校验用户提交的 { network, address }:
|
||||
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
|
||||
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
|
||||
// 污染 ValidateClaim 的签名)
|
||||
// - address 必须匹配基础字符集与长度
|
||||
func (*CryptoHandler) ValidateClaim(raw []byte) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Network) == "" {
|
||||
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
|
||||
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
|
||||
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
|
||||
// 由 POST /claim 逻辑层负责调用。
|
||||
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return fmt.Errorf("decode crypto config: %w", err)
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return nil
|
||||
}
|
||||
network := strings.TrimSpace(input.Network)
|
||||
for _, allowed := range cfg.Networks {
|
||||
if allowed == network {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
|
||||
}
|
||||
|
||||
// ---- physical handler ----------------------------------------------------
|
||||
|
||||
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
|
||||
type PhysicalHandler struct{}
|
||||
|
||||
// NewPhysicalHandler 构造 physical handler。
|
||||
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
|
||||
|
||||
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
|
||||
func (*PhysicalHandler) IsAuto() bool { return false }
|
||||
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var physicalClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["name","phone","province","city","district","detail"],
|
||||
"properties": {
|
||||
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
|
||||
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
|
||||
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
|
||||
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
|
||||
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
|
||||
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
|
||||
|
||||
type physicalClaimInput struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
|
||||
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
|
||||
|
||||
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
|
||||
var input physicalClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
|
||||
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.Province) == "" ||
|
||||
strings.TrimSpace(input.City) == "" ||
|
||||
strings.TrimSpace(input.District) == "" ||
|
||||
strings.TrimSpace(input.Detail) == "" {
|
||||
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- manual_other handler ------------------------------------------------
|
||||
|
||||
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
|
||||
type ManualOtherHandler struct{}
|
||||
|
||||
// NewManualOtherHandler 构造 manual_other handler。
|
||||
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
|
||||
|
||||
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
|
||||
func (*ManualOtherHandler) IsAuto() bool { return false }
|
||||
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var manualOtherClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["contact_type","contact_value"],
|
||||
"properties": {
|
||||
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
|
||||
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
|
||||
"remark": {"type":"string","title":"备注","maxLength":512}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
|
||||
|
||||
type manualOtherClaimInput struct {
|
||||
ContactType string `json:"contact_type"`
|
||||
ContactValue string `json:"contact_value"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// manualOtherContactTypes 是 contact_type 允许的枚举。
|
||||
var manualOtherContactTypes = map[string]struct{}{
|
||||
"phone": {},
|
||||
"email": {},
|
||||
"tg": {},
|
||||
}
|
||||
|
||||
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
|
||||
var input manualOtherClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
ct := strings.TrimSpace(input.ContactType)
|
||||
if _, ok := manualOtherContactTypes[ct]; !ok {
|
||||
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.ContactValue) == "" {
|
||||
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if len(input.Remark) > 512 {
|
||||
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
||||
// - Type / IsAuto / Dispatch 契约
|
||||
// - ClaimSchema 返回合法 JSON
|
||||
// - ValidateClaim 正确/错误样本表驱动
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
// ---- Crypto ---------------------------------------------------------------
|
||||
|
||||
func TestCryptoHandler_Contract(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
if h.Type() != lottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false for manual claim handler")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil for manual handler")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
||||
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
errIsErr error
|
||||
}{
|
||||
{"empty", "", true, ErrClaimDataEmpty},
|
||||
{"whitespace", " ", true, ErrClaimDataEmpty},
|
||||
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
||||
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
||||
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
||||
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
||||
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
||||
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCryptoNetwork(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
prizeConfig string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
||||
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
||||
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
||||
{"empty body", "", `{"networks":["BTC"]}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
||||
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
||||
s := string(schema)
|
||||
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
||||
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
||||
}
|
||||
// 合法 JSON
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("built schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
||||
// invalid JSON → fallback to generic schema without enum
|
||||
schema := BuildCryptoClaimSchema(`not-json`)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("fallback schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Physical -------------------------------------------------------------
|
||||
|
||||
func TestPhysicalHandler_Contract(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
if h.Type() != lottery.PrizeTypePhysical {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
||||
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
||||
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ManualOther ---------------------------------------------------------
|
||||
|
||||
func TestManualOtherHandler_Contract(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
if h.Type() != lottery.PrizeTypeManualOther {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
||||
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
||||
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
||||
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
||||
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
||||
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 状态机常量约束 --------------------------------------------------------
|
||||
|
||||
func TestIsClaimStatusResubmittable(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{lottery.ClaimStatusPendingClaim, true},
|
||||
{lottery.ClaimStatusRejected, true},
|
||||
{lottery.ClaimStatusReviewing, false},
|
||||
{lottery.ClaimStatusPaying, false},
|
||||
{lottery.ClaimStatusPaid, false},
|
||||
{lottery.ClaimStatusExpired, false},
|
||||
{"", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
||||
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
||||
cases := []struct {
|
||||
prizeType string
|
||||
want bool
|
||||
}{
|
||||
{lottery.PrizeTypeCrypto, true},
|
||||
{lottery.PrizeTypePhysical, true},
|
||||
{lottery.PrizeTypeManualOther, true},
|
||||
{lottery.PrizeTypeVPNDuration, false},
|
||||
{lottery.PrizeTypeCommission, false},
|
||||
{lottery.PrizeTypeNone, false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
||||
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
// sqlmockAnyResult 是 sqlmock.NewResult 的简写,语义与它一致(0 影响行)。
|
||||
func sqlmockAnyResult() driver.Result {
|
||||
return sqlmock.NewResult(0, 1)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package handler contains real PrizeHandler implementations for lottery prize
|
||||
// dispatch. Handlers live in the logic layer because they depend on UserModel,
|
||||
// NodeModel, and commonLogic — importing those from the pure-model
|
||||
// internal/model/lottery package would flip the layering.
|
||||
//
|
||||
// All Dispatch entry points are called inside the draw service's transaction
|
||||
// and must remain tx-only: no cache invalidation, no goroutine fan-out. The
|
||||
// draw service is responsible for post-commit side effects (node cache clear,
|
||||
// user group recalculation) once the enclosing transaction commits.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VPNDurationHandler 发放"N 天订阅时长"。
|
||||
//
|
||||
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||
// Reserve 命中即幂等,返回 payload 里之前记录的 message;未命中才走真实发放。
|
||||
//
|
||||
// 家庭组:走 ResolveEffectiveUser 归位到 owner;若 owner 无活跃订阅,日志
|
||||
// "skipped" 并返回 auto_claimed(与 grantGiftDays 的行为一致,避免中奖后无处发
|
||||
// 的场景导致抽奖事务回滚吞事件)。
|
||||
type VPNDurationHandler struct {
|
||||
deps VPNDurationDeps
|
||||
}
|
||||
|
||||
// VPNDurationDeps 是 VPNDurationHandler 需要的依赖。用 struct 显式收拢,避免
|
||||
// 直接依赖庞大的 ServiceContext;测试时可注入实现了同接口的 mock。
|
||||
type VPNDurationDeps struct {
|
||||
UserModel usermodel.Model
|
||||
Ledger lottery.LedgerService
|
||||
DB *gorm.DB
|
||||
// ResolveEffectiveUser 用于家庭组归位。为 nil 时不做归位(等价于身份函数)。
|
||||
// 生产接线用 DefaultResolveEffectiveUser(DB) 包出闭包。
|
||||
ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现。语义与
|
||||
// internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner),
|
||||
// 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common
|
||||
// 的 import cycle(common 包里有别的文件反向 import 了 svc)。
|
||||
func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) {
|
||||
return func(ctx context.Context, userID int64) (int64, error) {
|
||||
if userID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
var row struct {
|
||||
OwnerUserID int64 `gorm:"column:owner_user_id"`
|
||||
}
|
||||
q := db.WithContext(ctx).
|
||||
Table("user_family_member").
|
||||
Select("user_family.owner_user_id AS owner_user_id").
|
||||
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL").
|
||||
Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive).
|
||||
Order("user_family_member.role").
|
||||
Limit(1).
|
||||
Scan(&row)
|
||||
if q.Error != nil {
|
||||
return 0, q.Error
|
||||
}
|
||||
if q.RowsAffected == 0 || row.OwnerUserID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
return row.OwnerUserID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewVPNDurationHandler 构造真实的 vpn_duration handler。
|
||||
func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
|
||||
return &VPNDurationHandler{deps: deps}
|
||||
}
|
||||
|
||||
// Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。
|
||||
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
|
||||
func (*VPNDurationHandler) IsAuto() bool { return true }
|
||||
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
|
||||
func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
|
||||
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
|
||||
type vpnDurationConfig struct {
|
||||
DurationDays int `json:"duration_days"`
|
||||
// SubscribeId 指定“无活跃订阅时新建订阅”所用的套餐计划 ID。
|
||||
// 0 表示不新建:延续历史行为(无活跃订阅则记录 skipped 不发放)。
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
}
|
||||
|
||||
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
|
||||
// message;同时便于对账(哪条 user_subscribe 被延长、延长了多少天)。
|
||||
type vpnDurationPayload struct {
|
||||
EffectiveUserID int64 `json:"effective_user_id"`
|
||||
SubscribeID int64 `json:"subscribe_id"`
|
||||
Days int `json:"days"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// vpnDurationUserMessage 是免费时长中奖后返回给用户的提示文案(N=中奖天数,动态)。
|
||||
// 内部对账用的详细结果仍写在 ledger.payload.message(如"已加 N 天到订阅")。
|
||||
func vpnDurationUserMessage(days int) string {
|
||||
return fmt.Sprintf("稍后您的 %d 天免费时长将会自动添加至您的账户。如果超过24小时未添加成功,请联系人工客服处理。", days)
|
||||
}
|
||||
|
||||
// Dispatch 在 caller 的事务内发放订阅时长。
|
||||
func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
if tx == nil {
|
||||
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires a transaction")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires DispatchRequest.IdempotencyKey")
|
||||
}
|
||||
|
||||
var cfg vpnDurationConfig
|
||||
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("decode vpn_duration config: %w", err)
|
||||
}
|
||||
if cfg.DurationDays <= 0 {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("vpn_duration config duration_days must be > 0, got %d", cfg.DurationDays)
|
||||
}
|
||||
|
||||
// 家庭组归位:注入的 ResolveEffectiveUser 决定是否穿透到 owner。
|
||||
effectiveUserID := req.UserId
|
||||
if h.deps.ResolveEffectiveUser != nil {
|
||||
if eid, err := h.deps.ResolveEffectiveUser(ctx, req.UserId); err == nil && eid > 0 {
|
||||
effectiveUserID = eid
|
||||
}
|
||||
}
|
||||
|
||||
entry := lottery.GrantLedger{
|
||||
ExternalRef: req.IdempotencyKey,
|
||||
HandlerType: lottery.PrizeTypeVPNDuration,
|
||||
UserId: effectiveUserID,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: req.DrawId,
|
||||
Amount: int64(cfg.DurationDays),
|
||||
}
|
||||
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||
}
|
||||
if alreadyExisted {
|
||||
// 幂等命中:直接返回之前记录的 payload.message。
|
||||
var payload vpnDurationPayload
|
||||
if row.Payload != "" {
|
||||
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||
}
|
||||
if payload.Message == "" {
|
||||
payload.Message = "已加到订阅"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。查用户的活跃订阅。
|
||||
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
|
||||
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
// 无活跃订阅:
|
||||
// - 若奖品配置了 subscribe_id,则按该套餐新建一条订阅并发放时长;
|
||||
// - 否则延续旧行为:记录 skipped 但不失败(避免抽奖事务因无处发放而回滚)。
|
||||
if cfg.SubscribeId > 0 {
|
||||
newSub, createErr := h.createSubscription(ctx, tx, effectiveUserID, req.DrawId, cfg)
|
||||
if createErr != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("auto-create subscribe for user %d: %w", effectiveUserID, createErr)
|
||||
}
|
||||
payload := vpnDurationPayload{
|
||||
EffectiveUserID: effectiveUserID,
|
||||
SubscribeID: newSub.Id,
|
||||
Days: cfg.DurationDays,
|
||||
Message: fmt.Sprintf("已新建订阅并加 %d 天", cfg.DurationDays),
|
||||
}
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
payload := vpnDurationPayload{
|
||||
EffectiveUserID: effectiveUserID,
|
||||
Days: cfg.DurationDays,
|
||||
Message: "跳过:用户无活跃订阅",
|
||||
}
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
if findErr != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("find active subscribe for user %d: %w", effectiveUserID, findErr)
|
||||
}
|
||||
|
||||
// 计算新 ExpireTime。样板见 activateOrderLogic.go:1336-1342:
|
||||
// a) NoLimit 永久(time.UnixMilli(0))→ 不延长
|
||||
// b) 已过期 → 从 now 起加
|
||||
// c) 未过期 → 从 ExpireTime 起加
|
||||
now := time.Now()
|
||||
if !activeSub.ExpireTime.Equal(time.UnixMilli(0)) {
|
||||
if activeSub.ExpireTime.Before(now) {
|
||||
activeSub.ExpireTime = now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||
} else {
|
||||
activeSub.ExpireTime = activeSub.ExpireTime.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
activeSub.Status = 1
|
||||
activeSub.FinishedAt = nil
|
||||
|
||||
if err := h.deps.UserModel.UpdateSubscribe(ctx, activeSub, tx); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update subscribe %d: %w", activeSub.Id, err)
|
||||
}
|
||||
|
||||
payload := vpnDurationPayload{
|
||||
EffectiveUserID: effectiveUserID,
|
||||
SubscribeID: activeSub.Id,
|
||||
Days: cfg.DurationDays,
|
||||
Message: fmt.Sprintf("已加 %d 天到订阅", cfg.DurationDays),
|
||||
}
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
|
||||
// findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
|
||||
// 最新 token 非空的历史订阅(样板 activateOrderLogic.go:1371-1393)。
|
||||
func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||
activeSub, err := h.deps.UserModel.FindActiveSubscribe(ctx, userID)
|
||||
if err == nil {
|
||||
return activeSub, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if h.deps.DB == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var fallback usermodel.Subscribe
|
||||
fallbackErr := h.deps.DB.WithContext(ctx).
|
||||
Model(&usermodel.Subscribe{}).
|
||||
Where("user_id = ? AND token != ''", userID).
|
||||
Where("status IN ?", []int64{0, 1, 2, 3}).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&fallback).Error
|
||||
if fallbackErr != nil {
|
||||
return nil, fallbackErr
|
||||
}
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
// createSubscription 在无活跃订阅时,按奖品配置的 subscribe_id 套餐为用户新建一条
|
||||
// 订阅,时长为 cfg.DurationDays 天。套餐属性(流量、节点组)继承自计划,token/uuid
|
||||
// 现场生成。整个操作在 caller 的事务内完成,随抽奖事务一起提交/回滚。
|
||||
func (h *VPNDurationHandler) createSubscription(ctx context.Context, tx *gorm.DB, userID, drawID int64, cfg vpnDurationConfig) (*usermodel.Subscribe, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("createSubscription requires a transaction")
|
||||
}
|
||||
var plan subscribemodel.Subscribe
|
||||
if err := tx.WithContext(ctx).Where("id = ?", cfg.SubscribeId).First(&plan).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("subscribe plan %d not found", cfg.SubscribeId)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
// token 需全局唯一:用 lottery draw 维度做种子,避免与订单 token 冲突。
|
||||
tokenSeed := fmt.Sprintf("lottery:%d:%d:%d", cfg.SubscribeId, userID, drawID)
|
||||
newSub := &usermodel.Subscribe{
|
||||
UserId: userID,
|
||||
OrderId: 0,
|
||||
SubscribeId: plan.Id,
|
||||
NodeGroupId: plan.NodeGroupId,
|
||||
StartTime: now,
|
||||
ExpireTime: now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour),
|
||||
Traffic: plan.Traffic,
|
||||
Token: uuidx.SubscribeToken(tokenSeed),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(newSub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSub, nil
|
||||
}
|
||||
|
||||
func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ledger payload: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&lottery.GrantLedger{}).
|
||||
Where("id = ?", ledgerID).
|
||||
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||
return fmt.Errorf("update ledger payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newHandlerTestDB 建一个 sqlmock 支撑的 gorm.DB,子测试直接把它当 tx 传给 handler。
|
||||
func newHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
// fakeLedger 让 handler 单测不依赖真实 SQL,只验证控制流。
|
||||
type fakeLedger struct {
|
||||
reserveFn func(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeLedger) Reserve(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return f.reserveFn(ctx, tx, entry)
|
||||
}
|
||||
|
||||
// fakeUserModel 满足 usermodel.Model 里 handler 用到的两个方法。
|
||||
type fakeUserModel struct {
|
||||
usermodel.Model
|
||||
findActive func(ctx context.Context, userID int64) (*usermodel.Subscribe, error)
|
||||
updateSubscribe func(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error
|
||||
}
|
||||
|
||||
func (f *fakeUserModel) FindActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||
return f.findActive(ctx, userID)
|
||||
}
|
||||
func (f *fakeUserModel) UpdateSubscribe(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error {
|
||||
return f.updateSubscribe(ctx, sub, tx...)
|
||||
}
|
||||
|
||||
// identityResolver 单测里的家庭组归位:始终返回自身。
|
||||
func identityResolver(_ context.Context, userID int64) (int64, error) { return userID, nil }
|
||||
|
||||
func TestVPNDuration_RequiresIdempotencyKey(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_RequiresTx(t *testing.T) {
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{Ledger: &fakeLedger{}})
|
||||
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||
t.Fatalf("expected tx error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
stored := lottery.GrantLedger{
|
||||
Id: 9,
|
||||
ExternalRef: "lottery:100:200",
|
||||
Payload: `{"message":"已加 3 天到订阅"}`,
|
||||
}
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &stored, true, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||
return nil, nil
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger,
|
||||
UserModel: fake,
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected stored message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT UpdateSubscribe when no active sub")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// fallback query returns no rows either
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed even on skip, got %q", res.State)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected skip message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
future := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
UserId: 42,
|
||||
ExpireTime: future,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: "lottery:100:200"}, false, nil
|
||||
},
|
||||
}
|
||||
|
||||
updateCalled := false
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
updateCalled = true
|
||||
expected := future.Add(3 * 24 * time.Hour)
|
||||
if !sub.ExpireTime.Equal(expected) {
|
||||
t.Fatalf("expire time not stacked: got %s want %s", sub.ExpireTime, expected)
|
||||
}
|
||||
if sub.Status != 1 {
|
||||
t.Fatalf("expected Status=1 after grant, got %d", sub.Status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if !updateCalled {
|
||||
t.Fatalf("expected UpdateSubscribe to be called")
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("unexpected message: %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_ExpiredSubscribeRestartsFromNow(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
ExpireTime: past,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
delta := time.Until(sub.ExpireTime)
|
||||
if delta < 3*24*time.Hour-5*time.Second || delta > 3*24*time.Hour+5*time.Second {
|
||||
t.Fatalf("expected ~3 days from now, got %v", delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
}
|
||||
|
||||
func TestVPNDuration_NoLimitNotExtended(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
noLimit := time.UnixMilli(0)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
ExpireTime: noLimit,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
if !sub.ExpireTime.Equal(noLimit) {
|
||||
t.Fatalf("no-limit ExpireTime must not be extended, got %v", sub.ExpireTime)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
}
|
||||
|
||||
func TestVPNDuration_BadConfigRejected(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{name: "invalid json", config: `{bad`},
|
||||
{name: "zero days", config: `{"duration_days":0}`},
|
||||
{name: "negative days", config: `{"duration_days":-1}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: tt.config},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
_ = json.Unmarshal
|
||||
}
|
||||
|
||||
// TestVPNDuration_NoActiveSubscribeCreatesSubscription 覆盖“无活跃订阅 + 奖品配置了
|
||||
// subscribe_id”时按该套餐新建订阅并发放时长的路径(问题2 的修复)。
|
||||
func TestVPNDuration_NoActiveSubscribeCreatesSubscription(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT UpdateSubscribe when creating a new subscription")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// 1) findActiveSubscribe 的 DB 回退查询 → 无行
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
// 2) 加载 subscribe 套餐计划
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
|
||||
AddRow(int64(7), int64(1024), int64(3)))
|
||||
// 3) 新建 user_subscribe
|
||||
mock.ExpectExec("INSERT INTO `user_subscribe`").
|
||||
WillReturnResult(sqlmock.NewResult(555, 1))
|
||||
// 4) 回写 ledger payload
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":7}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("unexpected message: %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVPNDuration_NoActiveSubscribeNoPlanStillSkips 确认未配置 subscribe_id 时,
|
||||
// 仍沿用旧的“跳过发放”行为(不新建订阅),保持向后兼容。
|
||||
func TestVPNDuration_NoActiveSubscribeNoPlanStillSkips(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT touch subscription when no plan configured")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":5}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected skip message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVPNDuration_NoActiveSubscribePlanNotFound 确认配置的 subscribe_id 不存在时,
|
||||
// Dispatch 返回错误(让抽奖事务回滚),而不是静默成功。
|
||||
func TestVPNDuration_NoActiveSubscribePlanNotFound(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { return nil },
|
||||
}
|
||||
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":999}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when configured plan is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected 'not found' error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package hook contains lottery-side outbound integrations — hooks other flows
|
||||
// (order activation, sign-in, etc.) call after they succeed to feed events into
|
||||
// the lottery system.
|
||||
//
|
||||
// All hooks are fire-and-forget by contract: they run in their own goroutine so
|
||||
// caller latency and error handling are unaffected. Hook failures are logged
|
||||
// and dropped — an invite that fails to earn a lottery chance never blocks the
|
||||
// order it was piggy-backing on.
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InviteHook is fired by order/renewal activation when an invited user
|
||||
// completes a payment. It grants lottery chances to the referer across every
|
||||
// currently running activity that declares an "invite_success" chance source.
|
||||
type InviteHook interface {
|
||||
// OnConversion queues a background grant for referer. Returns immediately.
|
||||
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
|
||||
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
|
||||
}
|
||||
|
||||
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
|
||||
// value before the lottery system is wired in. Its OnConversion returns
|
||||
// immediately without side effects — no goroutine, no log spam.
|
||||
func NoopInviteHook() InviteHook { return noopInviteHook{} }
|
||||
|
||||
type noopInviteHook struct{}
|
||||
|
||||
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
|
||||
|
||||
// defaultInviteHook is the production implementation. It queries running
|
||||
// activities on every call rather than caching them — the query is cheap
|
||||
// (small table, indexed by status+time), and skipping the cache avoids stale
|
||||
// reads when an activity is paused or its chance_sources are re-configured.
|
||||
type defaultInviteHook struct {
|
||||
db *gorm.DB
|
||||
chance lottery.ChanceService
|
||||
}
|
||||
|
||||
// NewInviteHook builds the production invite hook.
|
||||
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
|
||||
if db == nil || chance == nil {
|
||||
return NoopInviteHook()
|
||||
}
|
||||
return &defaultInviteHook{db: db, chance: chance}
|
||||
}
|
||||
|
||||
// OnConversion spawns a fire-and-forget goroutine that walks all running
|
||||
// activities and calls ChanceService.Grant for each one that declares an
|
||||
// invite_success source.
|
||||
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
|
||||
if refererUserID <= 0 || orderNo == "" {
|
||||
return
|
||||
}
|
||||
go h.run(refererUserID, orderNo)
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
|
||||
// Fresh context so the caller cancelling their goroutine does not abort us.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
activities, err := h.loadRunningActivities(ctx)
|
||||
if err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range activities {
|
||||
activity := &activities[i]
|
||||
grants := parseInviteGrantsFromSources(activity.ChanceSources)
|
||||
for _, amount := range grants {
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
|
||||
// Prefix orderNo with "order:" so audit trails can tell business-order
|
||||
// derived refs apart from other source families (manual_grant uses
|
||||
// "manual:*", daily_signin uses "signin:*"). DB uniqueness is already
|
||||
// bucketed by source, but the prefix makes log/analytics readable.
|
||||
ref := "order:" + orderNo
|
||||
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, amount); err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("activity_id", activity.Id),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
|
||||
now := time.Now()
|
||||
var activities []lottery.Activity
|
||||
if err := h.db.WithContext(ctx).
|
||||
Model(&lottery.Activity{}).
|
||||
Where("status = ?", lottery.ActivityStatusRunning).
|
||||
Where("start_at <= ? AND end_at >= ?", now, now).
|
||||
Find(&activities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
|
||||
// activity and returns the per-conversion grant amount for each invite_success
|
||||
// source (an activity may declare multiple, e.g. with different params by
|
||||
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
|
||||
func parseInviteGrantsFromSources(raw string) []int {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var sources []lottery.ChanceSource
|
||||
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
||||
// Malformed configs skip silently — the activity is misconfigured, not
|
||||
// a hook fault. Admin CRUD (PR C) will surface it.
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
|
||||
out = append(out, s.Amount)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHookTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
type chanceCall struct {
|
||||
userId, activityId int64
|
||||
source, sourceRef string
|
||||
amount int
|
||||
}
|
||||
|
||||
type fakeChanceService struct {
|
||||
mu sync.Mutex
|
||||
calls []chanceCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount})
|
||||
return f.err
|
||||
}
|
||||
func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil }
|
||||
|
||||
func (f *fakeChanceService) recorded() []chanceCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]chanceCall, len(f.calls))
|
||||
copy(out, f.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoopInviteHook_IsInert(t *testing.T) {
|
||||
NoopInviteHook().OnConversion(context.Background(), 1, "ord")
|
||||
}
|
||||
|
||||
func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) {
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0
|
||||
h.OnConversion(context.Background(), 0, "ord")
|
||||
// No goroutine means no calls; give scheduler a beat and confirm empty.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Activity 100: single invite_success source, amount=1
|
||||
// Activity 200: two sources, only invite_success (amount=2) counts
|
||||
// Activity 300: has invite_success amount=0 → skipped
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-xyz")
|
||||
|
||||
// give the goroutine time to complete
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for grants; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls)
|
||||
}
|
||||
byActivity := map[int64]int{}
|
||||
for _, c := range calls {
|
||||
if c.source != lottery.ChanceSourceInviteSuccess {
|
||||
t.Fatalf("unexpected source: %+v", c)
|
||||
}
|
||||
if c.sourceRef != "order:order-xyz" {
|
||||
t.Fatalf("expected orderNo prefixed as source_ref, got %q", c.sourceRef)
|
||||
}
|
||||
if c.userId != 42 {
|
||||
t.Fatalf("expected referer=42, got %d", c.userId)
|
||||
}
|
||||
byActivity[c.activityId] = c.amount
|
||||
}
|
||||
if byActivity[100] != 1 || byActivity[200] != 2 {
|
||||
t.Fatalf("wrong amounts: %+v", byActivity)
|
||||
}
|
||||
if _, exists := byActivity[300]; exists {
|
||||
t.Fatalf("activity 300 has invite_success amount=0 and must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-1")
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 1 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Only the well-formed activity should have been granted; malformed skipped silently.
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 1 || calls[0].activityId != 100 {
|
||||
t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "ord")
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no grants when query fails, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) {
|
||||
got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`)
|
||||
if len(got) != 1 || got[0] != 5 {
|
||||
t.Fatalf("expected [5], got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(""); got != nil {
|
||||
t.Fatalf("empty string should return nil, got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(`{bad`); got != nil {
|
||||
t.Fatalf("bad json should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package rulecaps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
// 抽奖门槛规则树的固定上限。恶意 admin 或误配可以让 PUT rules 的 JSON 递归
|
||||
// 爆炸,评估时爆栈;这些常量给"合理配置"预留了充足空间,同时挡住 blob。
|
||||
const (
|
||||
// MaxDepth 是嵌套 AND/OR 允许的最大深度(根算 1 层)。
|
||||
MaxDepth = 8
|
||||
// MaxNodes 是整树里叶子 + 聚合节点总数上限。
|
||||
MaxNodes = 64
|
||||
// MaxBytes 是原始 JSON 字节数上限(8KB)。
|
||||
MaxBytes = 8 * 1024
|
||||
)
|
||||
|
||||
// ErrRuleTreeTooDeep 表示 AND/OR 嵌套超过 MaxDepth。
|
||||
var ErrRuleTreeTooDeep = errors.New("rule tree exceeds max depth")
|
||||
|
||||
// ErrRuleTreeTooManyNodes 表示节点总数超过 MaxNodes。
|
||||
var ErrRuleTreeTooManyNodes = errors.New("rule tree exceeds max node count")
|
||||
|
||||
// ErrRuleTreeTooLarge 表示 JSON payload 超过 MaxBytes。
|
||||
var ErrRuleTreeTooLarge = errors.New("rule tree JSON exceeds max byte size")
|
||||
|
||||
// ValidateEligibilityJSON 是 PUT /activities/{id}/rules 收到 eligibility JSON
|
||||
// 时的准入闸门。三个上限任一超限 → 返回带上下文的错误,caller 直接 400。
|
||||
// 空 JSON、"{}"、`null` 都视为合法(表示"无门槛")。
|
||||
func ValidateEligibilityJSON(raw []byte) error {
|
||||
if len(raw) > MaxBytes {
|
||||
return fmt.Errorf("%w: %d bytes > %d limit", ErrRuleTreeTooLarge, len(raw), MaxBytes)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 允许 null / "{}" 表示无门槛。
|
||||
trimmed := trimJSONWhitespace(raw)
|
||||
if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tree lottery.EligibilityRule
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return fmt.Errorf("invalid eligibility JSON: %w", err)
|
||||
}
|
||||
return validateRule(&tree, 1)
|
||||
}
|
||||
|
||||
// validateRule 递归检查一棵规则树;depth 是当前节点所在层(根 = 1)。
|
||||
// 用共享计数器(返回值)而不是外部 counter 是为了让递归签名保持无副作用。
|
||||
func validateRule(node *lottery.EligibilityRule, depth int) error {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if depth > MaxDepth {
|
||||
return fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||
}
|
||||
count, err := countAndValidate(node, depth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > MaxNodes {
|
||||
return fmt.Errorf("%w: got %d nodes, max %d", ErrRuleTreeTooManyNodes, count, MaxNodes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countAndValidate 深度优先遍历,一次递归同时统计节点数并做深度检查。
|
||||
// 返回 count 是子树总节点数(含当前节点);err 表明遍历中已经超限。
|
||||
func countAndValidate(node *lottery.EligibilityRule, depth int) (int, error) {
|
||||
if node == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if depth > MaxDepth {
|
||||
return 0, fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||
}
|
||||
total := 1
|
||||
for _, child := range node.Children {
|
||||
sub, err := countAndValidate(child, depth+1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += sub
|
||||
// 提前退出:命中节点数上限就不要继续 walk 剩余分支。
|
||||
if total > MaxNodes {
|
||||
return 0, fmt.Errorf("%w: got at least %d nodes, max %d", ErrRuleTreeTooManyNodes, total, MaxNodes)
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// trimJSONWhitespace 剥掉前后 JSON 空白,用于识别"实质空"的 payload。
|
||||
func trimJSONWhitespace(raw []byte) []byte {
|
||||
i, j := 0, len(raw)
|
||||
for i < j && isJSONWhitespace(raw[i]) {
|
||||
i++
|
||||
}
|
||||
for j > i && isJSONWhitespace(raw[j-1]) {
|
||||
j--
|
||||
}
|
||||
return raw[i:j]
|
||||
}
|
||||
|
||||
func isJSONWhitespace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package rulecaps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsEmpty(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
[]byte(""),
|
||||
[]byte("{}"),
|
||||
[]byte("null"),
|
||||
[]byte(" \n \t "),
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := ValidateEligibilityJSON(c); err != nil {
|
||||
t.Fatalf("expected accept for %q, got %v", string(c), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsOversizePayload(t *testing.T) {
|
||||
blob := make([]byte, MaxBytes+1)
|
||||
for i := range blob {
|
||||
blob[i] = 'a'
|
||||
}
|
||||
err := ValidateEligibilityJSON(blob)
|
||||
if !errors.Is(err, ErrRuleTreeTooLarge) {
|
||||
t.Fatalf("expected ErrRuleTreeTooLarge, got %v", err)
|
||||
}
|
||||
// user-facing message should name the limit
|
||||
if !strings.Contains(err.Error(), "8192") {
|
||||
t.Fatalf("expected message to mention 8192 byte limit, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsBadJSON(t *testing.T) {
|
||||
err := ValidateEligibilityJSON([]byte(`{bad`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeepTree 构造 depth 层单链嵌套(每层一个 OR 聚合)。root 为第 1 层。
|
||||
func buildDeepTree(depth int) *lottery.EligibilityRule {
|
||||
root := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||
current := root
|
||||
for i := 2; i < depth; i++ {
|
||||
next := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||
current.Children = []*lottery.EligibilityRule{next}
|
||||
current = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsDepthOverLimit(t *testing.T) {
|
||||
tree := buildDeepTree(MaxDepth + 1) // depth 9 with defaults
|
||||
raw, err := json.Marshal(tree)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
err = ValidateEligibilityJSON(raw)
|
||||
if !errors.Is(err, ErrRuleTreeTooDeep) {
|
||||
t.Fatalf("expected ErrRuleTreeTooDeep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsMaxDepth(t *testing.T) {
|
||||
tree := buildDeepTree(MaxDepth)
|
||||
raw, _ := json.Marshal(tree)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("depth=MaxDepth must be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildWideTree 构造根节点 + N 个叶子,总节点数 = 1 + N。
|
||||
func buildWideTree(leaves int) *lottery.EligibilityRule {
|
||||
root := &lottery.EligibilityRule{Op: "AND"}
|
||||
for i := 0; i < leaves; i++ {
|
||||
root.Children = append(root.Children, &lottery.EligibilityRule{Type: "has_subscription"})
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsNodeCountOverLimit(t *testing.T) {
|
||||
// 65 nodes total = 1 root + 64 leaves > MaxNodes
|
||||
tree := buildWideTree(MaxNodes)
|
||||
raw, _ := json.Marshal(tree)
|
||||
err := ValidateEligibilityJSON(raw)
|
||||
if !errors.Is(err, ErrRuleTreeTooManyNodes) {
|
||||
t.Fatalf("expected ErrRuleTreeTooManyNodes, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsAtNodeLimit(t *testing.T) {
|
||||
// 64 nodes = 1 root + 63 leaves
|
||||
tree := buildWideTree(MaxNodes - 1)
|
||||
raw, _ := json.Marshal(tree)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("nodes=MaxNodes must be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsRealisticTree(t *testing.T) {
|
||||
// Typical activity: (has_subscription AND invite_count>=3) OR user_tag in {vip}
|
||||
raw := []byte(`{
|
||||
"op": "OR",
|
||||
"children": [
|
||||
{
|
||||
"op": "AND",
|
||||
"children": [
|
||||
{"type": "has_subscription", "params": {"min_days_remaining": 7}},
|
||||
{"type": "invite_count", "params": {"min": 3}}
|
||||
]
|
||||
},
|
||||
{"type": "user_tag", "params": {"tags": ["vip"]}}
|
||||
]
|
||||
}`)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("realistic tree should be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// Package lottery implements the user-side lottery HTTP endpoints:
|
||||
//
|
||||
// GET /api/v1/lottery/config
|
||||
// POST /api/v1/lottery/draw
|
||||
// GET /api/v1/lottery/records
|
||||
// POST /api/v1/lottery/claim (Stage 2: submit manual claim data)
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。
|
||||
// 匿名 / 未登录返回 0;handler 侧应当由 AuthMiddleware 已经拦截。
|
||||
func currentUserId(ctx context.Context) int64 {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||
if !ok || u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.Id
|
||||
}
|
||||
|
||||
// ---- GET /config ------------------------------------------------------------
|
||||
|
||||
// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。
|
||||
type QueryLotteryConfigLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic {
|
||||
return &QueryLotteryConfigLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
activity, err := l.loadActivity(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes, err := l.loadPrizes(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId)
|
||||
|
||||
resp := &types.GetLotteryConfigResponse{
|
||||
Activity: types.LotteryActivityConfig{
|
||||
Id: activity.Id,
|
||||
Title: activity.Title,
|
||||
Description: activity.Description,
|
||||
StartAt: activity.StartAt.Unix(),
|
||||
EndAt: activity.EndAt.Unix(),
|
||||
Status: activity.Status,
|
||||
GridSize: activity.GridSize,
|
||||
},
|
||||
User: types.LotteryUserStatus{
|
||||
ChancesRemaining: remaining,
|
||||
// Eligible / UnmetReasons 需要 RuleContextBuilder;PR C 里 draw 路径
|
||||
// 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到
|
||||
// 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。
|
||||
Eligible: true,
|
||||
},
|
||||
}
|
||||
resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes))
|
||||
for _, p := range prizes {
|
||||
soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0
|
||||
resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{
|
||||
Slot: p.Slot,
|
||||
Id: p.Id,
|
||||
Type: p.Type,
|
||||
Name: p.Name,
|
||||
IconUrl: p.IconURL,
|
||||
Config: json.RawMessage(defaultIfEmpty(p.Config)),
|
||||
SoldOut: soldOut,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) {
|
||||
var activity modelLottery.Activity
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if activity.Status == modelLottery.ActivityStatusEnded {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) {
|
||||
var prizes []modelLottery.Prize
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Where("activity_id = ?", activityId).
|
||||
Order("slot ASC").
|
||||
Find(&prizes).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "{}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- POST /draw -------------------------------------------------------------
|
||||
|
||||
// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。
|
||||
type DrawLotteryLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic {
|
||||
return &DrawLotteryLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if l.svcCtx.LotteryDrawService == nil {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{
|
||||
UserId: userId,
|
||||
ActivityId: req.ActivityId,
|
||||
ClientNonce: req.ClientNonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.DrawLotteryResponse{
|
||||
DrawId: result.DrawId,
|
||||
IsWin: result.IsWin,
|
||||
ChancesRemaining: result.ChancesRemaining,
|
||||
Claim: types.LotteryClaimStatus{
|
||||
Required: result.Claim.Required,
|
||||
AutoClaimed: result.Claim.AutoClaimed,
|
||||
Message: result.Claim.Message,
|
||||
ExpiresAt: result.Claim.ExpiresAt,
|
||||
ClaimFormSchema: result.Claim.ClaimFormSchema,
|
||||
},
|
||||
}
|
||||
if result.Prize != nil {
|
||||
resp.Prize = &types.DrawnPrize{
|
||||
Slot: result.Prize.Slot,
|
||||
Id: result.Prize.Id,
|
||||
Type: result.Prize.Type,
|
||||
Name: result.Prize.Name,
|
||||
Config: result.Prize.Config,
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GET /records ----------------------------------------------------------
|
||||
|
||||
// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。
|
||||
type QueryLotteryRecordsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic {
|
||||
return &QueryLotteryRecordsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Draw{}).
|
||||
Where("user_id = ?", userId)
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if state := recordStatusFilter(req.Status); state != "" {
|
||||
switch state {
|
||||
case "unclaimed":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim)
|
||||
case "paid":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid)
|
||||
case "expired":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var draws []modelLottery.Draw
|
||||
if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
snapshots, err := l.loadPrizeSnapshots(draws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := l.loadClaims(draws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
|
||||
for _, d := range draws {
|
||||
record := types.LotteryRecord{
|
||||
DrawId: d.Id,
|
||||
ActivityId: d.ActivityId,
|
||||
IsWin: d.IsWin,
|
||||
DispatchState: d.DispatchState,
|
||||
DrawnAt: d.DrawnAt.Unix(),
|
||||
}
|
||||
snap, hasSnap := snapshots[d.Id]
|
||||
if hasSnap {
|
||||
record.Prize = &types.DrawnPrize{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
|
||||
}
|
||||
}
|
||||
if claim, ok := claims[d.Id]; ok {
|
||||
record.Claim = l.buildRecordClaim(claim, snap, hasSnap)
|
||||
}
|
||||
resp.List = append(resp.List, record)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// loadClaims 批量拉这一页里所有 draw 关联的 lottery_claim。人工奖 draw 一定有一行,
|
||||
// 自动奖 draw / 谢谢参与不会有;缺失的 draw_id 直接不在 map 里,调用侧只做存在性判断。
|
||||
func (l *QueryLotteryRecordsLogic) loadClaims(draws []modelLottery.Draw) (map[int64]modelLottery.Claim, error) {
|
||||
if len(draws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(draws))
|
||||
for _, d := range draws {
|
||||
ids = append(ids, d.Id)
|
||||
}
|
||||
var rows []modelLottery.Claim
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.Claim, len(rows))
|
||||
for _, c := range rows {
|
||||
out[c.DrawId] = c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildRecordClaim 把 lottery_claim 组装成 GET /records 里的 Claim 字段。
|
||||
// 状态允许再提交(pending_claim / rejected)时附带 ClaimFormSchema,
|
||||
// 否则不再下发(避免前端误以为还能再填)。
|
||||
func (l *QueryLotteryRecordsLogic) buildRecordClaim(claim modelLottery.Claim, snap modelLottery.PrizeSnapshot, hasSnap bool) *types.LotteryRecordClaim {
|
||||
view := &types.LotteryRecordClaim{
|
||||
Status: claim.Status,
|
||||
ExpiresAt: claim.ExpiresAt.Unix(),
|
||||
TxHash: claim.TxHash,
|
||||
DeliveryRef: claim.DeliveryRef,
|
||||
RejectReason: claim.RejectReason,
|
||||
}
|
||||
if claim.ClaimData != "" {
|
||||
view.ClaimData = json.RawMessage(claim.ClaimData)
|
||||
}
|
||||
if claim.SubmittedAt != nil {
|
||||
view.SubmittedAt = claim.SubmittedAt.Unix()
|
||||
}
|
||||
if claim.PaidAt != nil {
|
||||
view.PaidAt = claim.PaidAt.Unix()
|
||||
}
|
||||
// 只在允许再提交状态下下发 schema。
|
||||
if modelLottery.IsClaimStatusResubmittable(claim.Status) && l.svcCtx.LotteryRegistry != nil {
|
||||
if h, ok := l.svcCtx.LotteryRegistry.Get(claim.PrizeType); ok {
|
||||
if claim.PrizeType == modelLottery.PrizeTypeCrypto && hasSnap {
|
||||
view.ClaimFormSchema = lotteryhandler.BuildCryptoClaimSchema(snap.Config)
|
||||
} else {
|
||||
view.ClaimFormSchema = h.ClaimSchema()
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(draws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(draws))
|
||||
for _, d := range draws {
|
||||
ids = append(ids, d.Id)
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recordStatusFilter(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "all":
|
||||
return ""
|
||||
case "unclaimed", "paid", "expired":
|
||||
return strings.ToLower(s)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- POST /claim ----------------------------------------------------------
|
||||
|
||||
// ClaimLotteryPrizeLogic 是 Stage 2 人工奖领奖入口。
|
||||
//
|
||||
// 调用契约(错误码见 pkg/xerr):
|
||||
//
|
||||
// 4007 draw_not_found — 传入的 draw_id 不存在
|
||||
// 4008 not_your_draw — draw 属于其他用户
|
||||
// 4010 not_claimable — 该 draw 未中奖 / 自动奖 / 找不到 pending_claim
|
||||
// 4009 claim_expired — pending_claim.expires_at 已过期
|
||||
// 4005 already_submitted — 当前状态 (reviewing/paying/paid/expired) 禁止再提交
|
||||
// 4006 invalid_claim_data — handler.ValidateClaim 校验失败
|
||||
type ClaimLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic {
|
||||
return &ClaimLotteryPrizeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。
|
||||
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if req.DrawId <= 0 {
|
||||
return nil, xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
claimData := selectClaimData(req)
|
||||
|
||||
// 1. 定位 draw + 归属校验(提前失败,避免暴露内部资源)
|
||||
var draw modelLottery.Draw
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.DrawId).First(&draw).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryDrawNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if draw.UserId != userId {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotYourDraw)
|
||||
}
|
||||
if !draw.IsWin || draw.DispatchState != modelLottery.DispatchStatePendingClaim {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
|
||||
// 2. 抽奖时刻快照(用于 crypto network 二次校验)
|
||||
var snap modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id = ?", draw.Id).First(&snap).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if !modelLottery.IsPrizeTypeManualClaim(snap.Type) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
handler, ok := l.svcCtx.LotteryRegistry.Get(snap.Type)
|
||||
if !ok {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryInternalError)
|
||||
}
|
||||
|
||||
// 3. handler 校验 body
|
||||
if err := handler.ValidateClaim(claimData); err != nil {
|
||||
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||
}
|
||||
if snap.Type == modelLottery.PrizeTypeCrypto {
|
||||
if err := lotteryhandler.ValidateCryptoNetwork(claimData, snap.Config); err != nil {
|
||||
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 事务内更新 claim(乐观锁:status IN (pending_claim, rejected) AND expires_at > now)
|
||||
now := time.Now()
|
||||
var response *types.ClaimLotteryPrizeResponse
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var claim modelLottery.Claim
|
||||
if err := tx.Where("draw_id = ?", draw.Id).First(&claim).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if claim.ExpiresAt.Before(now) {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimExpired)
|
||||
}
|
||||
if !modelLottery.IsClaimStatusResubmittable(claim.Status) {
|
||||
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||
}
|
||||
|
||||
// CAS 更新:命中 status 白名单 + expires_at 未过期时才走。RowsAffected==0
|
||||
// 视为并发拦截(另一个请求已经把状态推进了),报 4005 即可。
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status IN ? AND expires_at > ?",
|
||||
claim.Id,
|
||||
[]string{modelLottery.ClaimStatusPendingClaim, modelLottery.ClaimStatusRejected},
|
||||
now).
|
||||
Updates(map[string]any{
|
||||
"claim_data": string(claimData),
|
||||
"status": modelLottery.ClaimStatusReviewing,
|
||||
"submitted_at": now,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||
}
|
||||
response = &types.ClaimLotteryPrizeResponse{
|
||||
Status: modelLottery.ClaimStatusReviewing,
|
||||
SubmittedAt: now.Unix(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// selectClaimData 兼容 ClaimData(首选)与 Input(历史字段名)。
|
||||
func selectClaimData(req *types.ClaimLotteryPrizeRequest) []byte {
|
||||
if len(req.ClaimData) > 0 {
|
||||
return req.ClaimData
|
||||
}
|
||||
return req.Input
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// lottery_stage2_test.go — Stage 2 相关的纯函数单测。
|
||||
// 用户 API 主流程(POST /claim)走 DB 事务 + auth middleware,集成在 QA 脚本里跑;
|
||||
// 这里只补 handler / helper 层的纯逻辑分支。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestSelectClaimData_PreferClaimDataOverInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req types.ClaimLotteryPrizeRequest
|
||||
want string
|
||||
}{
|
||||
{"claim_data set", types.ClaimLotteryPrizeRequest{ClaimData: []byte(`{"a":1}`), Input: []byte(`{"b":2}`)}, `{"a":1}`},
|
||||
{"only input", types.ClaimLotteryPrizeRequest{Input: []byte(`{"b":2}`)}, `{"b":2}`},
|
||||
{"neither", types.ClaimLotteryPrizeRequest{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := string(selectClaimData(&tc.req))
|
||||
if got != tc.want {
|
||||
t.Fatalf("selectClaimData = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStatusFilter_KnownStates(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"all", ""},
|
||||
{"unclaimed", "unclaimed"},
|
||||
{"paid", "paid"},
|
||||
{"expired", "expired"},
|
||||
{"unknown", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := recordStatusFilter(tc.in); got != tc.want {
|
||||
t.Errorf("recordStatusFilter(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/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"
|
||||
@@ -20,16 +21,18 @@ import (
|
||||
|
||||
type QueryUserSubscribeNodeListLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// Get user subscribe node info
|
||||
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic {
|
||||
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext, userAgent string) *QueryUserSubscribeNodeListLogic {
|
||||
return &QueryUserSubscribeNodeListLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
userAgent: userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +166,12 @@ 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,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"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"
|
||||
)
|
||||
|
||||
type commissionReturnLogRecord struct {
|
||||
LogID int64
|
||||
UserID int64
|
||||
Amount int64
|
||||
EventType uint16
|
||||
Content string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type QueryCommissionReturnLogLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewQueryCommissionReturnLogLogic Query Commission Return Log
|
||||
func NewQueryCommissionReturnLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryCommissionReturnLogLogic {
|
||||
return &QueryCommissionReturnLogLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) QueryCommissionReturnLog(req *types.QueryCommissionReturnLogRequest) (*types.QueryCommissionReturnLogResponse, error) {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Error("current user is not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
list, total, err := l.queryCommissionReturnLogRecords(u.Id, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respList := make([]types.CommissionReturnLog, 0, len(list))
|
||||
for _, item := range list {
|
||||
respList = append(respList, types.CommissionReturnLog{
|
||||
Id: item.LogID,
|
||||
UserId: item.UserID,
|
||||
Amount: item.Amount,
|
||||
EventType: item.EventType,
|
||||
Content: item.Content,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &types.QueryCommissionReturnLogResponse{
|
||||
List: respList,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePagination(page, size int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 10
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecords(userID int64, page, size int) ([]commissionReturnLogRecord, int64, error) {
|
||||
return l.queryCommissionReturnLogRecordsByEventTypes(userID, page, size,
|
||||
log.CommissionTypeRefund,
|
||||
log.CommissionTypeWithdrawReject,
|
||||
log.CommissionTypeWithdrawCancel,
|
||||
)
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTypes(userID int64, page, size int, eventTypes ...uint16) ([]commissionReturnLogRecord, int64, error) {
|
||||
if len(eventTypes) == 0 {
|
||||
return []commissionReturnLogRecord{}, 0, nil
|
||||
}
|
||||
|
||||
likeClauses := make([]string, 0, len(eventTypes))
|
||||
args := make([]interface{}, 0, len(eventTypes)+2)
|
||||
args = append(args, log.TypeCommission.Uint8(), userID)
|
||||
for _, eventType := range eventTypes {
|
||||
likeClauses = append(likeClauses, "`content` LIKE ?")
|
||||
args = append(args, commissionReturnLogEventTypePattern(eventType))
|
||||
}
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&log.SystemLog{}).
|
||||
Where("`type` = ? AND object_id = ? AND ("+strings.Join(likeClauses, " OR ")+")", args...)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission return logs failed: %v", err)
|
||||
}
|
||||
|
||||
var rows []log.SystemLog
|
||||
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission return logs failed: %v", err)
|
||||
}
|
||||
|
||||
list := make([]commissionReturnLogRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var content log.Commission
|
||||
if err := content.Unmarshal([]byte(row.Content)); err != nil {
|
||||
l.Errorw("unmarshal commission return log content failed",
|
||||
logger.Field("log_id", row.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if !isCommissionReturnEventType(content.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值
|
||||
var timestamp int64
|
||||
if content.Timestamp > 0 {
|
||||
timestamp = content.Timestamp / 1000
|
||||
} else {
|
||||
timestamp = row.CreatedAt.Unix()
|
||||
}
|
||||
list = append(list, commissionReturnLogRecord{
|
||||
LogID: row.Id,
|
||||
UserID: row.ObjectID,
|
||||
Amount: content.Amount,
|
||||
EventType: content.Type,
|
||||
Content: commissionReturnLogContentText(content.Type, content.OrderNo),
|
||||
CreatedAt: timestamp,
|
||||
UpdatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func commissionReturnLogEventTypePattern(eventType uint16) string {
|
||||
return "%\"type\":" + strconv.FormatUint(uint64(eventType), 10) + "%"
|
||||
}
|
||||
|
||||
func isCommissionReturnEventType(eventType uint16) bool {
|
||||
switch eventType {
|
||||
case log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// commissionReturnLogContentText 把佣金回退事件的原始 JSON 转成前端友好文字。
|
||||
// 333 订单退款回佣 - 附订单号便于追溯;337/338 提现相关 - 跟订单无关。
|
||||
func commissionReturnLogContentText(eventType uint16, orderNo string) string {
|
||||
switch eventType {
|
||||
case log.CommissionTypeRefund:
|
||||
if strings.TrimSpace(orderNo) != "" {
|
||||
return "订单退款回佣(订单号 " + orderNo + ")"
|
||||
}
|
||||
return "订单退款回佣"
|
||||
case log.CommissionTypeWithdrawReject:
|
||||
return "提现申请被驳回,佣金已退回"
|
||||
case log.CommissionTypeWithdrawCancel:
|
||||
return "已取消提现,佣金已退回"
|
||||
default:
|
||||
return "佣金调整"
|
||||
}
|
||||
}
|
||||
@@ -40,14 +40,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
size := req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 10
|
||||
}
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
|
||||
switch req.BizType {
|
||||
case "", withdrawalLogBizTypeWithdrawal:
|
||||
@@ -85,58 +78,89 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
|
||||
Method: row.Method,
|
||||
Account: row.Account,
|
||||
QrCodeUrl: row.QrCodeUrl,
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||
CreatedAt: row.CreatedAt.Unix(),
|
||||
UpdatedAt: row.UpdatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
summary, err := l.buildSummary(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.QueryWithdrawalLogListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
List: list,
|
||||
Total: total,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&log.SystemLog{}).
|
||||
Where("`type` = ? AND object_id = ? AND `content` LIKE ?", log.TypeCommission.Uint8(), userID, "%\"type\":333%")
|
||||
// buildSummary 聚合用户佣金账目快照,供前端展示闭环对账
|
||||
//
|
||||
// commission_balance = user.commission(当前余额,由 system_logs 累计而来)
|
||||
// locked_by_pending = 待审批提现占用
|
||||
// available_to_withdraw = balance - locked
|
||||
// total_historical_amount = 已通过提现累计(status=1)
|
||||
// total_refunded_amount = 佣金回扣累计(type=333/337/338)
|
||||
// total_income_amount = 佣金收入累计(type=331/332)
|
||||
func (l *QueryWithdrawalLogLogic) buildSummary(userID int64) (*types.WithdrawalLogSummary, error) {
|
||||
db := l.svcCtx.DB.WithContext(l.ctx)
|
||||
var summary types.WithdrawalLogSummary
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission refund logs failed: %v", err)
|
||||
if err := db.Model(&user.User{}).Where("id = ?", userID).
|
||||
Select("COALESCE(commission, 0)").Scan(&summary.CommissionBalance).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load commission balance failed: %v", err)
|
||||
}
|
||||
|
||||
var rows []log.SystemLog
|
||||
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission refund logs failed: %v", err)
|
||||
if err := db.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&summary.LockedByPending).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum pending withdrawals failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusApproved).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&summary.TotalHistoricalAmount).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum approved withdrawals failed: %v", err)
|
||||
}
|
||||
|
||||
row := db.Raw(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?)
|
||||
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS income,
|
||||
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?,?)
|
||||
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS refund
|
||||
FROM system_logs
|
||||
WHERE type = ? AND object_id = ?`,
|
||||
log.CommissionTypePurchase, log.CommissionTypeRenewal,
|
||||
log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel,
|
||||
log.TypeCommission.Uint8(), userID,
|
||||
).Row()
|
||||
if err := row.Scan(&summary.TotalIncomeAmount, &summary.TotalRefundedAmount); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "aggregate commission logs failed: %v", err)
|
||||
}
|
||||
|
||||
summary.AvailableToWithdraw = summary.CommissionBalance - summary.LockedByPending
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
|
||||
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]types.WithdrawalLog, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var content log.Commission
|
||||
if err := content.Unmarshal([]byte(row.Content)); err != nil {
|
||||
l.Errorw("unmarshal commission refund log content failed",
|
||||
logger.Field("log_id", row.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if content.Type != log.CommissionTypeRefund {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp := content.Timestamp
|
||||
if timestamp == 0 {
|
||||
timestamp = row.CreatedAt.UnixMilli()
|
||||
}
|
||||
list = append(list, types.WithdrawalLog{
|
||||
Id: row.Id,
|
||||
Id: row.LogID,
|
||||
BizType: withdrawalLogBizTypeCommissionRefund,
|
||||
UserId: row.ObjectID,
|
||||
Amount: content.Amount,
|
||||
UserId: row.UserID,
|
||||
Amount: row.Amount,
|
||||
Content: row.Content,
|
||||
CreatedAt: timestamp,
|
||||
UpdatedAt: timestamp,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -38,6 +40,23 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
|
||||
}).AddRow(
|
||||
int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt,
|
||||
))
|
||||
// buildSummary 触发的 4 个聚合查询
|
||||
mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`").
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000)))
|
||||
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||
WithArgs(userID, usermodel.WithdrawalStatusPending).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000)))
|
||||
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||
WithArgs(userID, usermodel.WithdrawalStatusApproved).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
|
||||
mock.ExpectQuery("FROM system_logs").
|
||||
WithArgs(
|
||||
logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal,
|
||||
logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel,
|
||||
logmodel.TypeCommission.Uint8(), userID,
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0)))
|
||||
|
||||
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
||||
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||
@@ -55,29 +74,64 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
|
||||
if got.BizType != withdrawalLogBizTypeWithdrawal {
|
||||
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal)
|
||||
}
|
||||
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.UnixMilli() || got.UpdatedAt != updatedAt.UnixMilli() {
|
||||
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() {
|
||||
t.Fatalf("withdrawal item = %+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
if resp.Summary == nil {
|
||||
t.Fatalf("expected summary, got nil")
|
||||
}
|
||||
if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 ||
|
||||
resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 {
|
||||
t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
|
||||
func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
content := `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000000123}`
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ? ORDER BY id DESC LIMIT ?").
|
||||
expectCommissionReturnQueries(mock, userID, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 3 || len(resp.List) != 3 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp)
|
||||
}
|
||||
|
||||
eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType}
|
||||
wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund}
|
||||
if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) {
|
||||
t.Fatalf("event types = %v, want %v", eventTypes, wantTypes)
|
||||
}
|
||||
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryWithdrawalLog_WithCommissionRefundBizTypeOnlyIncludes333(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 1, logmodel.CommissionTypeRefund)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, content, createdAt))
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
|
||||
|
||||
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
||||
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||
@@ -91,19 +145,72 @@ func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
|
||||
if resp.Total != 1 || len(resp.List) != 1 {
|
||||
t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp)
|
||||
}
|
||||
got := resp.List[0]
|
||||
if got.BizType != withdrawalLogBizTypeCommissionRefund {
|
||||
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeCommissionRefund)
|
||||
for _, item := range resp.List {
|
||||
if item.BizType != withdrawalLogBizTypeCommissionRefund {
|
||||
t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund)
|
||||
}
|
||||
if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" {
|
||||
t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item)
|
||||
}
|
||||
}
|
||||
if got.Id != 2001 || got.UserId != userID || got.Amount != 2500 || got.Content != content || got.CreatedAt != 1700000000123 || got.UpdatedAt != 1700000000123 {
|
||||
t.Fatalf("commission refund item = %+v", got)
|
||||
if resp.List[0].Id != 2001 {
|
||||
t.Fatalf("commission refund item id = %d, want 2001", resp.List[0].Id)
|
||||
}
|
||||
if got.Status != 0 || got.Method != 0 || got.Account != "" || got.QrCodeUrl != "" {
|
||||
t.Fatalf("commission refund withdrawal-only fields = %+v, want zero values", got)
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 2, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt))
|
||||
|
||||
var buf bytes.Buffer
|
||||
restoreLogger := captureTestLogs(&buf)
|
||||
defer restoreLogger()
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
if resp.Total != 2 || len(resp.List) != 1 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "unmarshal commission return log content failed") {
|
||||
t.Fatalf("expected warn log, got %q", buf.String())
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 0, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}))
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 0 || len(resp.List) != 0 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
|
||||
@@ -119,9 +226,7 @@ func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
|
||||
if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) {
|
||||
t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
@@ -150,7 +255,7 @@ func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func(
|
||||
|
||||
func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic {
|
||||
t.Helper()
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
ctx := newTestQueryCtx(userID)
|
||||
return &QueryWithdrawalLogLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
@@ -160,6 +265,47 @@ func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *Qu
|
||||
}
|
||||
}
|
||||
|
||||
func newTestQueryCtx(userID int64) context.Context {
|
||||
return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
}
|
||||
|
||||
func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
|
||||
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
|
||||
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
|
||||
if len(eventTypes) > 0 {
|
||||
clauses := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
clauses = append(clauses, "`content` LIKE ?")
|
||||
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
|
||||
}
|
||||
query += " AND (" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
mock.ExpectQuery(query).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
|
||||
}
|
||||
|
||||
func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func captureTestLogs(buf *bytes.Buffer) func() {
|
||||
prevWriter := logger.Reset()
|
||||
prevLevel := logger.InfoLevel
|
||||
logger.SetLevel(logger.DebugLevel)
|
||||
logger.SetWriter(logger.NewWriter(buf))
|
||||
return func() {
|
||||
logger.Reset()
|
||||
logger.SetLevel(prevLevel)
|
||||
if prevWriter != nil {
|
||||
logger.SetWriter(prevWriter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryWithdrawalLogErrCodeOf(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
|
||||
// TODO: remove in future versions
|
||||
Hysteria2 = "hysteria2"
|
||||
Simnet = "simnet"
|
||||
)
|
||||
|
||||
type SecurityConfig struct {
|
||||
|
||||
@@ -234,6 +234,22 @@ func (l *GetServerConfigLogic) compatible(config node.Protocol) map[string]inter
|
||||
},
|
||||
}
|
||||
|
||||
case Simnet:
|
||||
// Simnet ships its whole normalized protocol as the node runtime config
|
||||
// (server PSK key_id=0, path, carrier, TLS, AF, fallback, reverse, resource
|
||||
// limits), matching the Pro reference (compat_legacy.go simnet case).
|
||||
config.NormalizeSimnet()
|
||||
// Config snapshot log — non-sensitive fields only (never log the PSK).
|
||||
l.Infow("[GetServerConfig] simnet runtime config",
|
||||
logger.Field("port", config.Port),
|
||||
logger.Field("path", config.SimnetPath),
|
||||
logger.Field("carrier", config.SimnetCarrier),
|
||||
logger.Field("security", config.Security),
|
||||
logger.Field("af_enabled", config.SimnetAfEnabled),
|
||||
logger.Field("fallback_enabled", config.SimnetFallbackEnabled),
|
||||
)
|
||||
result = config
|
||||
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
s, _ := json.Marshal(result)
|
||||
|
||||
@@ -258,13 +258,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
}
|
||||
|
||||
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
|
||||
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户。
|
||||
// 用户级 speed_limit (user_subscribe.speed_limit) 与过期节点组 speed_limit
|
||||
// 取更严格的一个 — 0 视为"无限制",正值优先于 0。
|
||||
if len(nodeGroupIds) > 0 {
|
||||
expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds)
|
||||
for i := range expiredUsers {
|
||||
if expiredSpeedLimit > 0 {
|
||||
expiredUsers[i].SpeedLimit = expiredSpeedLimit
|
||||
}
|
||||
expiredUsers[i].SpeedLimit = mergeSpeedLimit(expiredUsers[i].SpeedLimit, expiredSpeedLimit)
|
||||
}
|
||||
users = append(users, expiredUsers...)
|
||||
}
|
||||
@@ -369,14 +369,34 @@ func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]
|
||||
}
|
||||
seen[userSub.Id] = true
|
||||
users = append(users, types.ServerUser{
|
||||
Id: userSub.Id,
|
||||
UUID: userSub.UUID,
|
||||
Id: userSub.Id,
|
||||
UUID: userSub.UUID,
|
||||
SpeedLimit: userSub.SpeedLimit,
|
||||
})
|
||||
}
|
||||
|
||||
return users, int64(expiredGroup.SpeedLimit)
|
||||
}
|
||||
|
||||
// mergeSpeedLimit 返回两个速度限制(Mbps)中更严格的一个。
|
||||
// 0 视为"无限制",因此会被任意正值覆盖;都为 0 时返回 0。
|
||||
// 用于用户级 speed_limit 与节点组级 speed_limit 的合并:
|
||||
// - both 0 → 0 (不限速)
|
||||
// - 仅一个 > 0 → 取该值
|
||||
// - both > 0 → 取较小者(更严格)
|
||||
func mergeSpeedLimit(a, b int64) int64 {
|
||||
if a <= 0 {
|
||||
return b
|
||||
}
|
||||
if b <= 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool {
|
||||
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||
|
||||
@@ -38,6 +38,7 @@ 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 {
|
||||
@@ -338,3 +339,32 @@ func httptestNewRequest() *http.Request {
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
|
||||
return req
|
||||
}
|
||||
|
||||
// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则:
|
||||
// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。
|
||||
// 这是 user-dimension 限速在过期节点组分支下能生效的关键。
|
||||
func TestMergeSpeedLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a int64
|
||||
b int64
|
||||
want int64
|
||||
}{
|
||||
{"both zero stays zero", 0, 0, 0},
|
||||
{"a positive b zero takes a", 30, 0, 30},
|
||||
{"a zero b positive takes b", 0, 50, 50},
|
||||
{"a negative treated as zero takes b", -1, 50, 50},
|
||||
{"b negative treated as zero takes a", 50, -1, 50},
|
||||
{"both positive takes smaller (a<b)", 20, 50, 20},
|
||||
{"both positive takes smaller (b<a)", 80, 30, 30},
|
||||
{"equal positive returns same value", 25, 25, 25},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mergeSpeedLimit(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("mergeSpeedLimit(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package subscribe
|
||||
|
||||
import (
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/pkg/simnet"
|
||||
)
|
||||
|
||||
// filterExperimentalNodesForClient removes experimental-protocol nodes (simnet)
|
||||
// unless the client UA is a first-party client. Prevents generic clients from
|
||||
// rendering unusable simnet entries and from receiving simnet server material.
|
||||
// Keyword logic is shared via pkg/simnet (mirrors the Pro reference).
|
||||
func filterExperimentalNodesForClient(servers []*node.Node, userAgent string) []*node.Node {
|
||||
if simnet.ClientSupportsExperimental(userAgent) {
|
||||
return servers
|
||||
}
|
||||
filtered := make([]*node.Node, 0, len(servers))
|
||||
for _, n := range servers {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if simnet.IsExperimentalProtocol(n.Protocol) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -79,6 +80,10 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
||||
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token))
|
||||
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() {
|
||||
@@ -96,6 +101,11 @@ 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),
|
||||
@@ -104,6 +114,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// AdminMetaMiddleware pins the request's client IP and User-Agent onto the
|
||||
// context under constant.CtxKeyIP / constant.CtxKeyUserAgent so downstream
|
||||
// audit writers (admin_action_log) can capture them without threading the
|
||||
// gin.Context through every logic layer.
|
||||
//
|
||||
// This middleware is a no-op for auth: it does NOT gate access; wire it
|
||||
// after AuthMiddleware so ctx.Value(CtxKeyUser) is already populated by the
|
||||
// time an admin logic writes audit rows.
|
||||
func AdminMetaMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyIP, c.ClientIP())
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, c.Request.UserAgent())
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// TestAdminMetaMiddleware_PopulatesCtx asserts the middleware pins ClientIP
|
||||
// and User-Agent onto the request context under the typed constant keys, so
|
||||
// downstream audit writers can pick them up without gin.Context threading.
|
||||
func TestAdminMetaMiddleware_PopulatesCtx(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var (
|
||||
gotIP string
|
||||
gotUA string
|
||||
)
|
||||
r := gin.New()
|
||||
r.Use(AdminMetaMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
gotIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||
gotUA, _ = ctx.Value(constant.CtxKeyUserAgent).(string)
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.RemoteAddr = "10.99.99.7:54321"
|
||||
req.Header.Set("User-Agent", "qa-audit-probe")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if gotUA != "qa-audit-probe" {
|
||||
t.Fatalf("user_agent = %q, want %q", gotUA, "qa-audit-probe")
|
||||
}
|
||||
// Gin resolves ClientIP() from RemoteAddr when no forwarded headers are
|
||||
// trusted. It strips the port, so we assert the exact host we set.
|
||||
if gotIP != "10.99.99.7" {
|
||||
t.Fatalf("ip = %q, want %q", gotIP, "10.99.99.7")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMetaMiddleware_UsesTypedKey guards against the regression that
|
||||
// motivated PR D: reader and writer must share the typed CtxKey, not a bare
|
||||
// string. A ctx.Value("ip") lookup (bare string) MUST miss even though the
|
||||
// typed CtxKey "ip" is present.
|
||||
func TestAdminMetaMiddleware_UsesTypedKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var (
|
||||
typedIP string
|
||||
bareStrIP any
|
||||
)
|
||||
r := gin.New()
|
||||
r.Use(AdminMetaMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
typedIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||
bareStrIP = ctx.Value("ip") // bare string key — must MISS
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.RemoteAddr = "10.1.2.3:80"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if typedIP == "" {
|
||||
t.Fatalf("typed CtxKeyIP lookup must succeed")
|
||||
}
|
||||
if bareStrIP != nil {
|
||||
t.Fatalf("bare-string \"ip\" lookup MUST miss (got %v); F2 regression risk", bareStrIP)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -87,10 +88,10 @@ func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string,
|
||||
|
||||
svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second)
|
||||
|
||||
userInfo, err := svc.UserModel.FindOne(c, userId)
|
||||
userInfo, err := logiccommon.ResolveEnabledUser(c, svc, userId)
|
||||
if err != nil {
|
||||
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"))
|
||||
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId))
|
||||
result.HttpResult(c, nil, err)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -66,6 +67,8 @@ 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)
|
||||
|
||||
@@ -16,7 +16,10 @@ type NodeGroup struct {
|
||||
IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
|
||||
ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
|
||||
MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
|
||||
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"`
|
||||
// SpeedLimit: 过期节点组对其内用户施加的速度上限。
|
||||
// 实际下发节点的字段为 ServerUser.SpeedLimit (Mbps),二者直接透传,无单位换算。
|
||||
// 注:早期 schema 注释写作 KB/s 系笔误,真实语义与 user_subscribe.speed_limit / subscribe.speed_limit 一致,均为 Mbps。
|
||||
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (Mbps); 0 means no limit"`
|
||||
MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"`
|
||||
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
|
||||
@@ -52,6 +52,7 @@ 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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// chanceService 是 ChanceService 的默认实现。
|
||||
type chanceService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。
|
||||
func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} }
|
||||
|
||||
// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。
|
||||
// 幂等策略:
|
||||
// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突
|
||||
// 2. 冲突视为"已发过",直接返回 nil 不重复发放
|
||||
// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining
|
||||
//
|
||||
// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账,
|
||||
// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。
|
||||
func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
grant := ChanceGrant{
|
||||
UserId: userId,
|
||||
ActivityId: activityId,
|
||||
Source: source,
|
||||
SourceRef: sourceRef,
|
||||
Amount: amount,
|
||||
}
|
||||
// OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 幂等命中:已经发过,balance 不动。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未冲突 → 累加余额(upsert balance 行)。
|
||||
balance := ChanceBalance{
|
||||
UserId: userId,
|
||||
ActivityId: activityId,
|
||||
Remaining: int64(amount),
|
||||
TotalEarned: int64(amount),
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount),
|
||||
"total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount),
|
||||
}),
|
||||
}).Create(&balance).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。
|
||||
// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。
|
||||
func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) {
|
||||
if tx == nil {
|
||||
return 0, errors.New("Consume requires a transaction handle")
|
||||
}
|
||||
var balance ChanceBalance
|
||||
err := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||
First(&balance).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, ErrNoChances
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if balance.Remaining <= 0 {
|
||||
return 0, ErrNoChances
|
||||
}
|
||||
// 累加 spent,扣减 remaining,一条 SQL 完成。
|
||||
updateErr := tx.WithContext(ctx).
|
||||
Model(&ChanceBalance{}).
|
||||
Where("id = ? AND remaining > 0", balance.Id).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": gorm.Expr("`remaining` - 1"),
|
||||
"total_spent": gorm.Expr("`total_spent` + 1"),
|
||||
}).Error
|
||||
if updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
return balance.Remaining - 1, nil
|
||||
}
|
||||
|
||||
// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。
|
||||
func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) {
|
||||
var balance ChanceBalance
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||
First(&balance).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if balance.Remaining < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return balance.Remaining, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil {
|
||||
t.Fatalf("Grant(amount=0) unexpected err: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("no queries expected, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// INSERT lottery_chance_grant,未冲突返回 1 行
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// UPSERT lottery_chance_balance
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_balance`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||
t.Fatalf("Grant: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// INSERT lottery_chance_grant,UNIQUE 冲突 → 0 行影响
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
// balance 不应被触发
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||
t.Fatalf("Grant: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_LocksAndDecrements(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||
AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1)))
|
||||
mock.ExpectExec("UPDATE `lottery_chance_balance`").
|
||||
WithArgs(sqlmock.AnyArg(), int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
var remaining int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
remaining, e = svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Consume: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Fatalf("expected remaining=1, got %d", remaining)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectRollback()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if !errors.Is(err, ErrNoChances) {
|
||||
t.Fatalf("expected ErrNoChances, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||
AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3)))
|
||||
mock.ExpectRollback()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if !errors.Is(err, ErrNoChances) {
|
||||
t.Fatalf("expected ErrNoChances when remaining=0, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_RequiresTx(t *testing.T) {
|
||||
db, _, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil {
|
||||
t.Fatalf("expected error when tx is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
|
||||
svc := NewChanceService(db)
|
||||
got, err := svc.Query(context.Background(), 42, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("expected 0 when no row, got %d", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GrantLedger 是发奖账本一行。UNIQUE(external_ref) 是幂等键的载体:
|
||||
// 每次 PrizeHandler.Dispatch 用 DispatchRequest.IdempotencyKey 作 external_ref,
|
||||
// INSERT 冲突即"已发过",直接返回持久化的原结果。
|
||||
type GrantLedger struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ExternalRef string `gorm:"type:varchar(128);not null;uniqueIndex:uk_external_ref;comment:幂等键"`
|
||||
HandlerType string `gorm:"type:varchar(32);not null;comment:handler 类型"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:发放对象用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;comment:抽奖记录 ID"`
|
||||
Amount int64 `gorm:"type:bigint;not null;default:0;comment:发放数量"`
|
||||
Payload string `gorm:"type:json;comment:发放后的关键结果快照"`
|
||||
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:发放完成时间"`
|
||||
}
|
||||
|
||||
// TableName 对齐 02157 migration。
|
||||
func (GrantLedger) TableName() string { return "lottery_grant_ledger" }
|
||||
|
||||
// LedgerService 处理发奖账本的幂等 upsert。所有 handler 的第一步都是它。
|
||||
type LedgerService interface {
|
||||
// Reserve 尝试为 external_ref 抢占一行账本。
|
||||
// - 未冲突 → 返回新建行,caller 继续调用下游业务;提交事务时账本一起落。
|
||||
// - 冲突 → 返回已存在的账本行,caller 视为幂等命中直接返回。
|
||||
// 传入 tx 必须是 caller 的事务句柄,保证账本行随抽奖事务一起提交。
|
||||
Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (row *GrantLedger, alreadyExisted bool, err error)
|
||||
}
|
||||
|
||||
type ledgerService struct{}
|
||||
|
||||
// NewLedgerService 返回默认账本服务。
|
||||
func NewLedgerService() LedgerService { return &ledgerService{} }
|
||||
|
||||
// Reserve 用 INSERT ... ON CONFLICT DO NOTHING 抢占 external_ref。
|
||||
// 未命中时再走一次 SELECT 拿到实际持久化的行(不管是新插的还是旧的),
|
||||
// 目的是让 caller 拿到统一的 GrantLedger 结构,方便回写 draw 状态。
|
||||
func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (*GrantLedger, bool, error) {
|
||||
if tx == nil {
|
||||
return nil, false, errors.New("Reserve requires a transaction handle")
|
||||
}
|
||||
if entry.ExternalRef == "" {
|
||||
return nil, false, errors.New("Reserve requires a non-empty ExternalRef")
|
||||
}
|
||||
// Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||
// handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶
|
||||
// 段的空 payload 用 "{}" 兜底,与 PrizeSnapshot.Config、
|
||||
// EligibilitySnapshot.UnmetReasons 的守卫对称。
|
||||
if entry.Payload == "" {
|
||||
entry.Payload = "{}"
|
||||
}
|
||||
|
||||
insertRes := tx.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&entry)
|
||||
if insertRes.Error != nil {
|
||||
return nil, false, insertRes.Error
|
||||
}
|
||||
alreadyExisted := insertRes.RowsAffected == 0
|
||||
|
||||
// 读回持久化的行,避免依赖 gorm 的 AutoIncrement 回填在冲突分支不确定的行为。
|
||||
var stored GrantLedger
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("external_ref = ?", entry.ExternalRef).
|
||||
First(&stored).Error; err != nil {
|
||||
return nil, alreadyExisted, err
|
||||
}
|
||||
return &stored, alreadyExisted, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Amount: 3,
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if existed {
|
||||
t.Fatalf("expected not existed")
|
||||
}
|
||||
if row.Id != 7 {
|
||||
t.Fatalf("expected reloaded id=7, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !existed {
|
||||
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
|
||||
}
|
||||
if row.Id != 9 {
|
||||
t.Fatalf("expected stored id=9, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
|
||||
svc := NewLedgerService()
|
||||
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
|
||||
t.Fatalf("expected error when tx is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
|
||||
db, _, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
|
||||
return e
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on empty ExternalRef")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserve_EmptyPayloadDefaultsToEmptyJSONObject is the F6 regression guard.
|
||||
//
|
||||
// Before PR F, Reserve created lottery_grant_ledger rows with the caller's
|
||||
// empty entry.Payload written verbatim ("") into the `payload` JSON column —
|
||||
// MySQL error 3140 rejects empty strings on JSON columns, so every real
|
||||
// draw's ledger INSERT died. sqlmock does no JSON validation so the earlier
|
||||
// tests were silent about it.
|
||||
//
|
||||
// Guard the exact Go-layer value we send by asserting the INSERT arg for
|
||||
// `payload` is "{}" (never ""). This is the same pattern as PR E's
|
||||
// EligibilitySnapshot.UnmetReasons guard.
|
||||
func TestReserve_EmptyPayloadDefaultsToEmptyJSONObject(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// GORM omits granted_at from the INSERT column list because it has
|
||||
// `<-:create;default:CURRENT_TIMESTAMP` — 7 args, not 8. Column order:
|
||||
// external_ref, handler_type, user_id, activity_id, draw_id, amount, payload.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // external_ref
|
||||
sqlmock.AnyArg(), // handler_type
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // amount
|
||||
payloadNotEmptyString{t}, // MUST be "{}", never ""
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref"}).AddRow(int64(1), "lottery:100:200"))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewLedgerService()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Intentionally leave Payload empty — the guard must default it.
|
||||
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Amount: 3,
|
||||
})
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// payloadNotEmptyString is a per-arg matcher: the value MUST be a non-empty
|
||||
// string; specifically "{}" per the PR F guard. Empty string is the exact F6
|
||||
// regression symptom (MySQL error 3140).
|
||||
type payloadNotEmptyString struct{ t *testing.T }
|
||||
|
||||
func (m payloadNotEmptyString) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
m.t.Fatalf("F6 guard: expected string for Payload, got %T (%v)", v, v)
|
||||
}
|
||||
if s == "" {
|
||||
m.t.Fatalf("F6 regression: Payload must not be empty string (MySQL error 3140)")
|
||||
}
|
||||
if s != "{}" {
|
||||
m.t.Fatalf("F6 guard: expected Payload==%q, got %q", "{}", s)
|
||||
}
|
||||
return true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user