Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 634b5a7bd0 | |||
| 5ef3f2717e | |||
| 2c1ee78bc4 | |||
| 13bafd5847 | |||
| e8e3a3a72b | |||
| 58c346abec | |||
| abd8c068b6 | |||
| eac0137069 | |||
| cce147108c | |||
| 980e5adb90 |
+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()))
|
||||
}
|
||||
+10
-2
@@ -43,17 +43,21 @@ service ppanel {
|
||||
@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 (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
put /prizes/:id (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Delete prize"
|
||||
@handler DeleteLotteryPrize
|
||||
delete /prizes (AdminPrizeIdRequest)
|
||||
delete /prizes/:id (AdminPrizeIdRequest)
|
||||
|
||||
@doc "List prizes on an activity"
|
||||
@handler ListLotteryPrizes
|
||||
@@ -83,4 +87,8 @@ service ppanel {
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -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` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS `lottery_activity` (
|
||||
`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 9 COMMENT '前端九宫格数量(3/6/8/9/12)',
|
||||
`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',
|
||||
|
||||
@@ -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));
|
||||
@@ -72,3 +72,14 @@ func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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"
|
||||
@@ -118,6 +120,9 @@ 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
|
||||
@@ -132,6 +137,9 @@ 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
|
||||
@@ -167,3 +175,20 @@ func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,11 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
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", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.DELETE("/prizes", adminLottery.DeleteLotteryPrizeHandler(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))
|
||||
@@ -47,5 +48,8 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -87,7 +87,9 @@ func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdmi
|
||||
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
|
||||
}
|
||||
if activity.GridSize <= 0 {
|
||||
activity.GridSize = 9
|
||||
// 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 {
|
||||
@@ -316,6 +318,55 @@ func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivit
|
||||
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 {
|
||||
@@ -467,6 +518,9 @@ func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotte
|
||||
if req.Slot != nil {
|
||||
fields["slot"] = *req.Slot
|
||||
}
|
||||
if req.Type != "" {
|
||||
fields["type"] = req.Type
|
||||
}
|
||||
if req.Name != "" {
|
||||
fields["name"] = req.Name
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -421,12 +422,12 @@ func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req R
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
if s.deps.Registry == nil {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, 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{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||
}
|
||||
@@ -452,8 +453,13 @@ func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req R
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
PrizeType: prize.Type,
|
||||
Status: lottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: expiresAt,
|
||||
// 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))
|
||||
@@ -466,7 +472,7 @@ func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req R
|
||||
}
|
||||
return lottery.DispatchResult{
|
||||
State: lottery.DispatchStatePendingClaim,
|
||||
Message: "等待填写领奖信息",
|
||||
Message: "请凭此截图直接联系人工客服兑换奖励。",
|
||||
}, pendingClaimInfo{
|
||||
ExpiresAt: expiresAt,
|
||||
ClaimFormSchema: schema,
|
||||
@@ -596,15 +602,24 @@ func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) {
|
||||
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.
|
||||
// Preserve already-coded errors (their msg 是设计过的对外文案,不动).
|
||||
if _, ok := err.(*xerr.CodeError); ok {
|
||||
return err
|
||||
}
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryInternalError, err.Error())
|
||||
// 内部细节走日志,供运维/后端排查;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 ----------------------------------------
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
handler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
@@ -514,6 +516,52 @@ func errAsCode(err error) (uint32, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// TestWrapInternal_ScrubsErrorDetailsFromMsg is the HIF-4 F10 regression guard.
|
||||
//
|
||||
// wrapInternal 之前把 err.Error() 直接塞 xerr.CodeError.Msg,导致对外响应
|
||||
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."}
|
||||
// 把 DB 结构/表名/内部包路径全部泄露给 app 端。F10 修法:细节走日志,msg 只
|
||||
// 带通用文案。此测试锁死:"wrapInternal(任意非 CodeError 的原生错误) 返回的
|
||||
// CodeError.Msg 不能等于原始 err.Error()"。
|
||||
func TestWrapInternal_ScrubsErrorDetailsFromMsg(t *testing.T) {
|
||||
sensitive := errors.New("insert lottery_claim for draw 15: Error 3140 (22032): Invalid JSON text: The document is empty")
|
||||
wrapped := wrapInternal(sensitive)
|
||||
if wrapped == nil {
|
||||
t.Fatal("wrapInternal returned nil for non-nil error")
|
||||
}
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(wrapped, &ce) {
|
||||
t.Fatalf("expected *xerr.CodeError, got %T", wrapped)
|
||||
}
|
||||
if ce.GetErrCode() != xerr.LotteryInternalError {
|
||||
t.Fatalf("expected code=%d, got %d", xerr.LotteryInternalError, ce.GetErrCode())
|
||||
}
|
||||
if strings.Contains(ce.GetErrMsg(), "lottery_claim") ||
|
||||
strings.Contains(ce.GetErrMsg(), "3140") ||
|
||||
strings.Contains(ce.GetErrMsg(), "Invalid JSON") {
|
||||
t.Fatalf("F10 regression: internal error detail leaked to msg: %q", ce.GetErrMsg())
|
||||
}
|
||||
// 反过来断言:msg 应该是标准文案(xerr.MapErrMsg 查表得到)
|
||||
if ce.GetErrMsg() != xerr.MapErrMsg(xerr.LotteryInternalError) {
|
||||
t.Fatalf("expected generic msg %q, got %q", xerr.MapErrMsg(xerr.LotteryInternalError), ce.GetErrMsg())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapInternal_PreservesCodeErrors 副断言:已经是 xerr.CodeError 的错误
|
||||
// 不能被 wrap 掉(它们的 msg 是设计过的对外文案,比如 4001/4002/4009)。
|
||||
func TestWrapInternal_PreservesCodeErrors(t *testing.T) {
|
||||
coded := xerr.NewErrCode(xerr.LotteryNoChances)
|
||||
wrapped := wrapInternal(coded)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(wrapped, &ce) {
|
||||
t.Fatalf("expected *xerr.CodeError, got %T", wrapped)
|
||||
}
|
||||
if ce.GetErrCode() != xerr.LotteryNoChances {
|
||||
t.Fatalf("F10 side-effect: coded error was rewrapped; got code %d instead of %d",
|
||||
ce.GetErrCode(), xerr.LotteryNoChances)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard
|
||||
// (kept from PR E — must survive Stage 2 rebase).
|
||||
//
|
||||
@@ -522,9 +570,9 @@ func errAsCode(err error) (uint32, bool) {
|
||||
// so every real /draw request 100% failed the tx commit even though sqlmock
|
||||
// (which does no JSON validation) was happy. This test snapshots the exact
|
||||
// INSERT arg values and asserts:
|
||||
// 1. UnmetReasons must never be "" (it should be "[]")
|
||||
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
|
||||
// '0000-00-00 00:00:00' on DATETIME NOT NULL)
|
||||
// 1. UnmetReasons must never be "" (it should be "[]")
|
||||
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
|
||||
// '0000-00-00 00:00:00' on DATETIME NOT NULL)
|
||||
//
|
||||
// sqlmock cannot catch the JSON validity itself — only real MySQL can — but
|
||||
// it can catch the two upstream bugs that let bad values through the Go
|
||||
@@ -763,3 +811,271 @@ func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) {
|
||||
t.Fatalf("expected default 7-day TTL, got diff=%ds", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraw_ManualClaimClaimDataIsValidJSON is the F7 regression guard
|
||||
// (HIF-11): the Stage 2 manual-claim path inserts a lottery_claim row inside
|
||||
// the draw tx; Claim.ClaimData is declared `gorm:"type:json"` and MySQL error
|
||||
// 3140 rejects the Go zero value "" on JSON columns. Same class of bug as
|
||||
// PR E (UnmetReasons="[]") and PR F (GrantLedger.Payload="{}").
|
||||
//
|
||||
// The regression before the fix: 100% of manual-prize draws (crypto /
|
||||
// physical / manual_other) hit `100500 insert lottery_claim for draw N:
|
||||
// Error 3140 (22032): Invalid JSON text: "The document is empty."`.
|
||||
//
|
||||
// sqlmock does no JSON validation, but this per-arg matcher snapshots the
|
||||
// exact ClaimData bind value and fails if it's the empty string — the exact
|
||||
// Go-layer bug real MySQL would reject downstream.
|
||||
func TestDraw_ManualClaimClaimDataIsValidJSON(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{
|
||||
Id: 70, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeManualOther,
|
||||
Name: "定制手办",
|
||||
Config: `{"sku_name":"定制"}`,
|
||||
Weight: 100,
|
||||
})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(7777, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
unmetReasonsNotEmpty{t}, // F4 guard survives
|
||||
evaluatedAtNotZero{t}, // F5 guard survives
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// Actual GORM bind order (nullable *time.Time fields — SubmittedAt,
|
||||
// ReviewedAt, PaidAt — are elided when nil):
|
||||
// draw_id, user_id, activity_id, prize_type, claim_data, status,
|
||||
// expires_at, reviewed_by, reject_reason, tx_hash, delivery_ref,
|
||||
// created_at, updated_at.
|
||||
// Assert ClaimData (position 5) is valid JSON — never "".
|
||||
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // prize_type
|
||||
claimDataIsValidJSON{t}, // MUST be valid JSON, not ""
|
||||
sqlmock.AnyArg(), // status
|
||||
sqlmock.AnyArg(), // expires_at
|
||||
sqlmock.AnyArg(), // reviewed_by
|
||||
sqlmock.AnyArg(), // reject_reason
|
||||
sqlmock.AnyArg(), // tx_hash
|
||||
sqlmock.AnyArg(), // delivery_ref
|
||||
sqlmock.AnyArg(), // created_at
|
||||
sqlmock.AnyArg(), // updated_at
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{
|
||||
lottery.PrizeTypeManualOther: &recordingHandler{handlerType: lottery.PrizeTypeManualOther, auto: false},
|
||||
}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
if _, err := svc.Draw(context.Background(), Request{UserId: 87437, ActivityId: 100, ClientNonce: "hif-11-regression"}); err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// claimDataIsValidJSON is a per-arg matcher: the value MUST be a non-empty
|
||||
// string that parses as valid JSON. The exact regression (HIF-11) is
|
||||
// ClaimData=="" — MySQL error 3140 rejects it on JSON columns.
|
||||
type claimDataIsValidJSON struct{ t *testing.T }
|
||||
|
||||
func (m claimDataIsValidJSON) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
m.t.Fatalf("F7 guard: expected string for ClaimData, got %T (%v)", v, v)
|
||||
}
|
||||
if s == "" {
|
||||
m.t.Fatalf("F7 regression (HIF-11): ClaimData must not be empty string (MySQL error 3140)")
|
||||
}
|
||||
if !json.Valid([]byte(s)) {
|
||||
m.t.Fatalf("F7 guard: ClaimData must be valid JSON, got %q", s)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- E2E: real VPNDurationHandler wired into the draw flow -----------------
|
||||
|
||||
// fakeE2ELedger 让真实 VPNDurationHandler 的幂等账本 Reserve 永远返回“新建行”,
|
||||
// 从而走真实发放分支(而非幂等命中)。
|
||||
type fakeE2ELedger struct{}
|
||||
|
||||
func (fakeE2ELedger) Reserve(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 77, ExternalRef: entry.ExternalRef}, false, nil
|
||||
}
|
||||
|
||||
// fakeNoSubUserModel 嵌入 usermodel.Model(其余方法不会被调用),仅覆盖
|
||||
// FindActiveSubscribe 返回“无活跃订阅”,模拟问题2 的复现前提。
|
||||
type fakeNoSubUserModel struct {
|
||||
usermodel.Model
|
||||
}
|
||||
|
||||
func (fakeNoSubUserModel) FindActiveSubscribe(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// TestDraw_E2E_VPNDurationAutoCreatesSubscription 串起完整链路(问题2 的端到端回归):
|
||||
// 抽中 vpn_duration → 真实 handler 派发 → 用户无活跃订阅 + 奖品配了 subscribe_id →
|
||||
// 同一抽奖事务内加载套餐、新建订阅、发放时长、回写账本,最终整体 COMMIT。
|
||||
func TestDraw_E2E_VPNDurationAutoCreatesSubscription(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{
|
||||
Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration,
|
||||
Name: "5 天",
|
||||
Config: `{"duration_days":5,"subscribe_id":7}`,
|
||||
Weight: 100,
|
||||
},
|
||||
)
|
||||
|
||||
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
|
||||
UserModel: fakeNoSubUserModel{},
|
||||
Ledger: fakeE2ELedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
|
||||
})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
// unlimited stock → no lottery_prize UPDATE
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1234, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// handler.Dispatch: findActiveSubscribe 的 DB 回退查询 → 无行
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
// 加载 subscribe 套餐计划
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
|
||||
AddRow(int64(7), int64(2048), int64(3)))
|
||||
// 新建 user_subscribe
|
||||
mock.ExpectExec("INSERT INTO `user_subscribe`").
|
||||
WillReturnResult(sqlmock.NewResult(900, 1))
|
||||
// 回写账本 payload
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// finalize draw.dispatch_state
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 1},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: realHandler}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if !res.IsWin {
|
||||
t.Fatalf("expected IsWin=true for vpn_duration")
|
||||
}
|
||||
if !res.Claim.AutoClaimed {
|
||||
t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected auto-create message, got %q", res.Message)
|
||||
}
|
||||
if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration {
|
||||
t.Fatalf("expected vpn_duration prize summary, got %+v", res.Prize)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraw_E2E_VPNDurationNoPlanSkipsGrant 覆盖对照组:同样无活跃订阅,但奖品未配
|
||||
// subscribe_id → 真实 handler 走“跳过发放”,不产生任何订阅相关写操作,抽奖照常提交。
|
||||
func TestDraw_E2E_VPNDurationNoPlanSkipsGrant(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{
|
||||
Id: 31, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration,
|
||||
Name: "5 天",
|
||||
Config: `{"duration_days":5}`,
|
||||
Weight: 100,
|
||||
},
|
||||
)
|
||||
|
||||
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
|
||||
UserModel: fakeNoSubUserModel{},
|
||||
Ledger: fakeE2ELedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
|
||||
})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1235, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// findActiveSubscribe fallback → none; then skip (no subscribe/insert)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 1},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: realHandler}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected skip message, got %q", res.Message)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -88,6 +91,9 @@ 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,用于幂等重放时返回同一
|
||||
@@ -99,6 +105,12 @@ type vpnDurationPayload struct {
|
||||
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 {
|
||||
@@ -145,13 +157,31 @@ func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lott
|
||||
if payload.Message == "" {
|
||||
payload.Message = "已加到订阅"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。查用户的活跃订阅。
|
||||
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
|
||||
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
// 与 grantGiftDays 一致:无活跃订阅时记录 skipped 但不失败。
|
||||
// 无活跃订阅:
|
||||
// - 若奖品配置了 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,
|
||||
@@ -160,7 +190,7 @@ func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lott
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
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)
|
||||
@@ -194,7 +224,7 @@ func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lott
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: vpnDurationUserMessage(cfg.DurationDays)}, nil
|
||||
}
|
||||
|
||||
// findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
|
||||
@@ -225,6 +255,41 @@ func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int
|
||||
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 {
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||
}
|
||||
if res.Message != "已加 3 天到订阅" {
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected stored message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed even on skip, got %q", res.State)
|
||||
}
|
||||
if !strings.Contains(res.Message, "跳过") {
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("expected skip message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
|
||||
if !updateCalled {
|
||||
t.Fatalf("expected UpdateSubscribe to be called")
|
||||
}
|
||||
if res.Message != "已加 3 天到订阅" {
|
||||
if !strings.Contains(res.Message, "免费时长将会自动添加") {
|
||||
t.Fatalf("unexpected message: %q", res.Message)
|
||||
}
|
||||
}
|
||||
@@ -343,3 +343,144 @@ func TestVPNDuration_BadConfigRejected(t *testing.T) {
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -101,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),
|
||||
@@ -109,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,
|
||||
|
||||
@@ -77,7 +77,7 @@ type Activity struct {
|
||||
StartAt time.Time `gorm:"not null;comment:开始时间"`
|
||||
EndAt time.Time `gorm:"not null;comment:结束时间"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:'draft';comment:状态"`
|
||||
GridSize int `gorm:"type:tinyint;not null;default:9;comment:九宫格数量"`
|
||||
GridSize int `gorm:"type:tinyint;not null;default:8;comment:九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格)"`
|
||||
Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"`
|
||||
ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"`
|
||||
UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"`
|
||||
|
||||
@@ -23,8 +23,12 @@ func NewWeightedPicker(seed int64) WeightedPicker {
|
||||
}
|
||||
}
|
||||
|
||||
// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重
|
||||
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。
|
||||
// Pick 从 candidates 中返回一个索引。以下奖品不参与随机:
|
||||
// - 权重 <= 0
|
||||
// - is_fallback=true 的保底奖(只在限量奖售罄时兜底发放,绝不能被随机抽中,
|
||||
// 否则真实奖品会被“谢谢参与”类保底项挤占)
|
||||
//
|
||||
// 累计权重为 0(无任何可抽奖品)返回 ErrEmptyPool。
|
||||
//
|
||||
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
|
||||
// 不需要预分配,并对小池(<20 项)足够快。
|
||||
@@ -32,9 +36,12 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
||||
if len(candidates) == 0 {
|
||||
return 0, ErrEmptyPool
|
||||
}
|
||||
// eligible 判定:非保底 && 权重为正,才计入随机池。
|
||||
eligible := func(c Prize) bool { return !c.IsFallback && c.Weight > 0 }
|
||||
|
||||
var total int64
|
||||
for _, c := range candidates {
|
||||
if c.Weight > 0 {
|
||||
if eligible(c) {
|
||||
total += int64(c.Weight)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +55,7 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
||||
|
||||
var cum int64
|
||||
for i, c := range candidates {
|
||||
if c.Weight <= 0 {
|
||||
if !eligible(c) {
|
||||
continue
|
||||
}
|
||||
cum += int64(c.Weight)
|
||||
@@ -56,9 +63,9 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。
|
||||
// 走到这里说明浮点/累加异常,回退到最后一个参与随机的项。
|
||||
for i := len(candidates) - 1; i >= 0; i-- {
|
||||
if candidates[i].Weight > 0 {
|
||||
if eligible(candidates[i]) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,34 @@ func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_ExcludesFallback(t *testing.T) {
|
||||
p := NewWeightedPicker(3)
|
||||
pool := []Prize{
|
||||
{Id: 1, Weight: 100, IsFallback: true}, // 保底奖:即便权重很高也绝不被随机抽中
|
||||
{Id: 2, Weight: 5, Type: PrizeTypeVPNDuration}, // 唯一可抽真实奖品
|
||||
}
|
||||
for i := 0; i < 300; i++ {
|
||||
idx, err := p.Pick(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("Pick err: %v", err)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Fatalf("fallback prize must never be picked; expected idx 1, got %d", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_AllFallbackYieldsEmptyPool(t *testing.T) {
|
||||
p := NewWeightedPicker(9)
|
||||
pool := []Prize{
|
||||
{Id: 1, Weight: 10, IsFallback: true},
|
||||
{Id: 2, Weight: 20, IsFallback: true},
|
||||
}
|
||||
if _, err := p.Pick(pool); err != ErrEmptyPool {
|
||||
t.Fatalf("expected ErrEmptyPool when only fallback prizes exist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
|
||||
pool := []Prize{
|
||||
{Id: 1, Weight: 1},
|
||||
|
||||
@@ -38,6 +38,7 @@ var AllProtocols = []string{
|
||||
"tuic",
|
||||
"hysteria",
|
||||
"hysteria2",
|
||||
"simnet",
|
||||
}
|
||||
|
||||
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
|
||||
"tuic",
|
||||
"hysteria",
|
||||
"hysteria2",
|
||||
"simnet",
|
||||
}
|
||||
set := make(map[string]struct{}, len(AllProtocols))
|
||||
for _, p := range AllProtocols {
|
||||
|
||||
@@ -2,6 +2,8 @@ package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -71,17 +73,20 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalProtocols Marshal server protocols to json
|
||||
// MarshalProtocols Marshal server protocols to json.
|
||||
// Uniqueness is scoped to type+port (a protocol entry is one listener instance),
|
||||
// which allows the same protocol on multiple ports, e.g. simnet:443 and simnet:8443.
|
||||
func (m *Server) MarshalProtocols(list []Protocol) error {
|
||||
var validate = make(map[string]bool)
|
||||
for _, protocol := range list {
|
||||
if protocol.Type == "" {
|
||||
if strings.TrimSpace(protocol.Type) == "" {
|
||||
return errors.New("protocol type is required")
|
||||
}
|
||||
if _, exists := validate[protocol.Type]; exists {
|
||||
return errors.New("duplicate protocol type: " + protocol.Type)
|
||||
key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port)
|
||||
if _, exists := validate[key]; exists {
|
||||
return errors.New("duplicate protocol type+port: " + key)
|
||||
}
|
||||
validate[protocol.Type] = true
|
||||
validate[key] = true
|
||||
}
|
||||
data, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
@@ -153,6 +158,192 @@ type Protocol struct {
|
||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
|
||||
|
||||
// Simnet protocol fields. Field set and json tags mirror the Pro reference
|
||||
// (NPanel-backend internal/model/server/protocol.go) so OmnXT/SlagClient
|
||||
// see an identical wire contract.
|
||||
SimnetPsk string `json:"simnet_psk,omitempty"` // server-side PSK (key_id=0), never sent to normal users
|
||||
SimnetKeyID int `json:"simnet_key_id,omitempty"` // server key id (0)
|
||||
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||
SimnetPath string `json:"simnet_path,omitempty"`
|
||||
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSimnetInboundMaxStreamsPerSession int = 128
|
||||
defaultSimnetInboundMaxUDPStreamsPerSession int = 64
|
||||
defaultSimnetInboundMaxHandlerTasksPerSession int = 128
|
||||
defaultSimnetStreamEventChannelCapacity int = 256
|
||||
defaultSimnetStreamDataChannelCapacity int = 128
|
||||
defaultSimnetTargetDialTimeoutMs int = 12_000
|
||||
defaultSimnetTargetMaxConcurrentDials int = 256
|
||||
defaultSimnetSessionWindow int = 4 * 1024 * 1024
|
||||
defaultSimnetMaxConcurrentStreams int = 100
|
||||
defaultSimnetInitialWindowSize int = 65_535
|
||||
defaultSimnetMaxFrameSize int = 16_384
|
||||
defaultSimnetClientMaxConcurrentStreams int = 32
|
||||
defaultSimnetClientMaxStreamsPerSession int = 512
|
||||
defaultSimnetClientSessionIdleTimeoutSecs int = 90
|
||||
defaultSimnetClientMaxUDPSessions int = 64
|
||||
)
|
||||
|
||||
// NormalizeSimnet applies simnet defaults and clears mutually-exclusive fields.
|
||||
// Ported from the Pro reference (NPanel-backend protocol.go NormalizeSimnet) so
|
||||
// the runtime config matches what OmnXT expects.
|
||||
func (m *Protocol) NormalizeSimnet() {
|
||||
if m == nil || m.Type != "simnet" {
|
||||
return
|
||||
}
|
||||
if m.Port == 0 {
|
||||
m.Port = 443
|
||||
}
|
||||
if strings.TrimSpace(m.SimnetPath) == "" {
|
||||
m.SimnetPath = "/simnet/session"
|
||||
}
|
||||
// Carrier: only h2 is supported in v1; coerce empty/grpc to h2 (matches the
|
||||
// Pro reference normalizeSimnetProtocol).
|
||||
if c := strings.TrimSpace(m.SimnetCarrier); c == "" || c == "grpc" {
|
||||
m.SimnetCarrier = "h2"
|
||||
}
|
||||
m.applySimnetResourceDefaults()
|
||||
if !m.SimnetFallbackEnabled || strings.TrimSpace(m.SimnetFallbackTargetHost) == "" {
|
||||
m.SimnetFallbackEnabled = false
|
||||
m.SimnetFallbackTargetScheme = ""
|
||||
m.SimnetFallbackTargetHost = ""
|
||||
m.SimnetFallbackTargetPort = 0
|
||||
m.SimnetFallbackHostHeader = ""
|
||||
m.SimnetFallbackTLSSNI = ""
|
||||
} else {
|
||||
m.SimnetFallbackTargetHost = strings.TrimSpace(m.SimnetFallbackTargetHost)
|
||||
m.SimnetFallbackHostHeader = strings.TrimSpace(m.SimnetFallbackHostHeader)
|
||||
m.SimnetFallbackTLSSNI = strings.TrimSpace(m.SimnetFallbackTLSSNI)
|
||||
switch strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme)) {
|
||||
case "http", "https":
|
||||
m.SimnetFallbackTargetScheme = strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme))
|
||||
default:
|
||||
m.SimnetFallbackTargetScheme = "https"
|
||||
}
|
||||
}
|
||||
if !m.SimnetAfEnabled {
|
||||
m.SimnetAfPathMode = ""
|
||||
m.SimnetAfMagicMode = ""
|
||||
m.SimnetAfPathPrefix = ""
|
||||
m.SimnetAfPathSuffix = ""
|
||||
m.SimnetAfResponseJitterMs = 0
|
||||
m.SimnetAfHandshakePolymorphism = false
|
||||
m.SimnetAfSettingsJitter = false
|
||||
m.SimnetAfFakeHeaderInjection = false
|
||||
return
|
||||
}
|
||||
if m.SimnetAfPathMode == "" {
|
||||
m.SimnetAfPathMode = "api"
|
||||
}
|
||||
if m.SimnetAfMagicMode == "" {
|
||||
m.SimnetAfMagicMode = "derived"
|
||||
}
|
||||
if m.SimnetAfResponseJitterMs == 0 {
|
||||
m.SimnetAfResponseJitterMs = 50
|
||||
}
|
||||
if !m.SimnetAfHandshakePolymorphism {
|
||||
m.SimnetAfHandshakePolymorphism = true
|
||||
}
|
||||
if !m.SimnetAfSettingsJitter {
|
||||
m.SimnetAfSettingsJitter = true
|
||||
}
|
||||
if !m.SimnetAfFakeHeaderInjection {
|
||||
m.SimnetAfFakeHeaderInjection = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Protocol) applySimnetResourceDefaults() {
|
||||
if m.SimnetInboundMaxStreamsPerSession <= 0 {
|
||||
m.SimnetInboundMaxStreamsPerSession = defaultSimnetInboundMaxStreamsPerSession
|
||||
}
|
||||
if m.SimnetInboundMaxUDPStreamsPerSession <= 0 {
|
||||
m.SimnetInboundMaxUDPStreamsPerSession = defaultSimnetInboundMaxUDPStreamsPerSession
|
||||
}
|
||||
if m.SimnetInboundMaxHandlerTasksPerSession <= 0 {
|
||||
m.SimnetInboundMaxHandlerTasksPerSession = defaultSimnetInboundMaxHandlerTasksPerSession
|
||||
}
|
||||
if m.SimnetStreamEventChannelCapacity <= 0 {
|
||||
m.SimnetStreamEventChannelCapacity = defaultSimnetStreamEventChannelCapacity
|
||||
}
|
||||
if m.SimnetStreamDataChannelCapacity <= 0 {
|
||||
m.SimnetStreamDataChannelCapacity = defaultSimnetStreamDataChannelCapacity
|
||||
}
|
||||
if m.SimnetTargetDialTimeoutMs <= 0 {
|
||||
m.SimnetTargetDialTimeoutMs = defaultSimnetTargetDialTimeoutMs
|
||||
}
|
||||
if m.SimnetTargetMaxConcurrentDials <= 0 {
|
||||
m.SimnetTargetMaxConcurrentDials = defaultSimnetTargetMaxConcurrentDials
|
||||
}
|
||||
if m.SimnetSendWindow <= 0 {
|
||||
m.SimnetSendWindow = defaultSimnetSessionWindow
|
||||
}
|
||||
if m.SimnetRecvWindow <= 0 {
|
||||
m.SimnetRecvWindow = defaultSimnetSessionWindow
|
||||
}
|
||||
if m.SimnetMaxConcurrentStreams <= 0 {
|
||||
m.SimnetMaxConcurrentStreams = defaultSimnetMaxConcurrentStreams
|
||||
}
|
||||
if m.SimnetInitialWindowSize <= 0 {
|
||||
m.SimnetInitialWindowSize = defaultSimnetInitialWindowSize
|
||||
}
|
||||
if m.SimnetMaxFrameSize <= 0 {
|
||||
m.SimnetMaxFrameSize = defaultSimnetMaxFrameSize
|
||||
}
|
||||
if m.SimnetClientMaxConcurrentStreams <= 0 {
|
||||
m.SimnetClientMaxConcurrentStreams = defaultSimnetClientMaxConcurrentStreams
|
||||
}
|
||||
if m.SimnetClientMaxStreamsPerSession <= 0 {
|
||||
m.SimnetClientMaxStreamsPerSession = defaultSimnetClientMaxStreamsPerSession
|
||||
}
|
||||
if m.SimnetClientSessionIdleTimeoutSecs <= 0 {
|
||||
m.SimnetClientSessionIdleTimeoutSecs = defaultSimnetClientSessionIdleTimeoutSecs
|
||||
}
|
||||
if m.SimnetClientMaxUDPSessions <= 0 {
|
||||
m.SimnetClientMaxUDPSessions = defaultSimnetClientMaxUDPSessions
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal protocol to json
|
||||
|
||||
@@ -261,10 +261,12 @@ type CreateAdminLotteryPrizeRequest struct {
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryPrizeRequest updates mutable prize fields.
|
||||
// UpdateAdminLotteryPrizeRequest updates mutable prize fields. Id comes from
|
||||
// the URL path (PUT /prizes/:id), not the body.
|
||||
type UpdateAdminLotteryPrizeRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Id int64 `path:"id" json:"-" validate:"required"`
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IconUrl string `json:"icon_url,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
@@ -283,9 +285,9 @@ type ListAdminLotteryPrizesResponse struct {
|
||||
List []AdminLotteryPrize `json:"list"`
|
||||
}
|
||||
|
||||
// AdminPrizeIdRequest is used by DELETE / GET single.
|
||||
// AdminPrizeIdRequest is used by DELETE /prizes/:id. Id comes from the URL path.
|
||||
type AdminPrizeIdRequest struct {
|
||||
Id int64 `form:"id" json:"id" validate:"required"`
|
||||
Id int64 `path:"id" form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
|
||||
@@ -387,3 +389,56 @@ type AdminLotteryClaimsStatusCount struct {
|
||||
Reviewing int64 `json:"reviewing"`
|
||||
Paying int64 `json:"paying"`
|
||||
}
|
||||
|
||||
// ---- Stage 3 后台抽奖记录(发放流水)--------------------------------------
|
||||
|
||||
// ListAdminLotteryDrawsRequest 列出抽奖记录,支持活动/用户/中奖/发放状态/奖品类型/时间窗过滤。
|
||||
type ListAdminLotteryDrawsRequest struct {
|
||||
ActivityId int64 `form:"activity_id,omitempty"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
PrizeType string `form:"prize_type,omitempty"`
|
||||
DispatchState string `form:"dispatch_state,omitempty"`
|
||||
Win string `form:"win,omitempty"` // "1"=只看中奖, "0"=只看未中奖, ""=全部
|
||||
From int64 `form:"from,omitempty"`
|
||||
To int64 `form:"to,omitempty"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// AdminLotteryDrawUser 抽奖记录里附带的用户简况。
|
||||
type AdminLotteryDrawUser struct {
|
||||
Id int64 `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLotteryDrawPrize 抽奖记录里的奖品快照(抽奖时刻冻结)。
|
||||
type AdminLotteryDrawPrize struct {
|
||||
Slot int `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// AdminLotteryDraw 是后台抽奖记录视图:谁、何时、中了什么、发放状态与结果。
|
||||
type AdminLotteryDraw struct {
|
||||
DrawId int64 `json:"draw_id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
User AdminLotteryDrawUser `json:"user"`
|
||||
IsWin bool `json:"is_win"`
|
||||
Prize *AdminLotteryDrawPrize `json:"prize,omitempty"`
|
||||
DispatchState string `json:"dispatch_state"`
|
||||
DispatchError string `json:"dispatch_error,omitempty"`
|
||||
// GrantAmount / GrantMessage 来自 lottery_grant_ledger(自动奖发放结果):
|
||||
// vpn_duration=天数,commission=分;message 如"已新建订阅并加 30 天"。
|
||||
GrantAmount int64 `json:"grant_amount,omitempty"`
|
||||
GrantMessage string `json:"grant_message,omitempty"`
|
||||
DrawnAt int64 `json:"drawn_at"`
|
||||
DispatchedAt int64 `json:"dispatched_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryDrawsResponse 分页。
|
||||
type ListAdminLotteryDrawsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []AdminLotteryDraw `json:"list"`
|
||||
}
|
||||
|
||||
+92
-47
@@ -2302,53 +2302,98 @@ type PromoUsage struct {
|
||||
}
|
||||
|
||||
type Protocol struct {
|
||||
Type string `json:"type"`
|
||||
Port uint16 `json:"port"`
|
||||
Enable bool `json:"enable"`
|
||||
Security string `json:"security,omitempty"`
|
||||
SNI string `json:"sni,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
RealityServerAddr string `json:"reality_server_addr,omitempty"`
|
||||
RealityServerPort int `json:"reality_server_port,omitempty"`
|
||||
RealityPrivateKey string `json:"reality_private_key,omitempty"`
|
||||
RealityPublicKey string `json:"reality_public_key,omitempty"`
|
||||
RealityShortId string `json:"reality_short_id,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
ServerKey string `json:"server_key,omitempty"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
HopPorts string `json:"hop_ports,omitempty"`
|
||||
HopInterval int `json:"hop_interval,omitempty"`
|
||||
ObfsPassword string `json:"obfs_password,omitempty"`
|
||||
DisableSNI bool `json:"disable_sni,omitempty"`
|
||||
ReduceRtt bool `json:"reduce_rtt,omitempty"`
|
||||
UDPRelayMode string `json:"udp_relay_mode,omitempty"`
|
||||
CongestionController string `json:"congestion_controller,omitempty"`
|
||||
Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high
|
||||
PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme
|
||||
UpMbps int `json:"up_mbps,omitempty"` // upload speed limit
|
||||
DownMbps int `json:"down_mbps,omitempty"` // download speed limit
|
||||
Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls'
|
||||
ObfsHost string `json:"obfs_host,omitempty"` // obfs host
|
||||
ObfsPath string `json:"obfs_path,omitempty"` // obfs path
|
||||
XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode
|
||||
XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path
|
||||
Encryption string `json:"encryption,omitempty"` // encryption,'none', 'mlkem768x25519plus'
|
||||
EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode,'native', 'xorpub', 'random'
|
||||
EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt,'0rtt', '1rtt'
|
||||
EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket
|
||||
EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding
|
||||
EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key
|
||||
EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding
|
||||
EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password
|
||||
Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1
|
||||
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
|
||||
Type string `json:"type"`
|
||||
Port uint16 `json:"port"`
|
||||
Enable bool `json:"enable"`
|
||||
Security string `json:"security,omitempty"`
|
||||
SNI string `json:"sni,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
RealityServerAddr string `json:"reality_server_addr,omitempty"`
|
||||
RealityServerPort int `json:"reality_server_port,omitempty"`
|
||||
RealityPrivateKey string `json:"reality_private_key,omitempty"`
|
||||
RealityPublicKey string `json:"reality_public_key,omitempty"`
|
||||
RealityShortId string `json:"reality_short_id,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
ServerKey string `json:"server_key,omitempty"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
HopPorts string `json:"hop_ports,omitempty"`
|
||||
HopInterval int `json:"hop_interval,omitempty"`
|
||||
ObfsPassword string `json:"obfs_password,omitempty"`
|
||||
DisableSNI bool `json:"disable_sni,omitempty"`
|
||||
ReduceRtt bool `json:"reduce_rtt,omitempty"`
|
||||
UDPRelayMode string `json:"udp_relay_mode,omitempty"`
|
||||
CongestionController string `json:"congestion_controller,omitempty"`
|
||||
Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high
|
||||
PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme
|
||||
UpMbps int `json:"up_mbps,omitempty"` // upload speed limit
|
||||
DownMbps int `json:"down_mbps,omitempty"` // download speed limit
|
||||
Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls'
|
||||
ObfsHost string `json:"obfs_host,omitempty"` // obfs host
|
||||
ObfsPath string `json:"obfs_path,omitempty"` // obfs path
|
||||
XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode
|
||||
XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path
|
||||
Encryption string `json:"encryption,omitempty"` // encryption,'none', 'mlkem768x25519plus'
|
||||
EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode,'native', 'xorpub', 'random'
|
||||
EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt,'0rtt', '1rtt'
|
||||
EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket
|
||||
EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding
|
||||
EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key
|
||||
EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding
|
||||
EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password
|
||||
Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1
|
||||
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"`
|
||||
}
|
||||
|
||||
type PubilcRegisterConfig struct {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
// Package result 定义 HTTP 响应统一 envelope。
|
||||
//
|
||||
// HIF-4 F9:所有响应必须是三字段固定 shape {code, msg, data},data 为空时
|
||||
// 也要显式 `null` —— 而不是靠 omitempty 丢字段。App 端强类型 decoder(Retrofit /
|
||||
// serde / typed structs)依赖这个 shape,缺字段会解码失败。
|
||||
//
|
||||
// 修改前:
|
||||
// - Success 的 Data 带 omitempty,nil payload 时 JSON 里没有 data 键
|
||||
// - Error 结构体没有 Data 字段,错误响应永远缺 data 键
|
||||
// 修改后:Success 和 Error 都有 Data 字段,且都 **不带 omitempty**。空场景下 JSON
|
||||
// 一律出现 "data":null。加字段对旧 client 无 breaking impact(JSON 忽略未知字段/
|
||||
// 已知字段变 null 都能 decode 过)。
|
||||
package result
|
||||
|
||||
type ResponseSuccessBean struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Data interface{} `json:"data"` // F9: 不带 omitempty,空 payload 也返 data:null
|
||||
}
|
||||
type NullJson struct{}
|
||||
|
||||
@@ -11,11 +23,14 @@ func Success(data interface{}) *ResponseSuccessBean {
|
||||
return &ResponseSuccessBean{200, "success", data}
|
||||
}
|
||||
|
||||
// ResponseErrorBean 与 ResponseSuccessBean 结构对齐(都有 Data 字段),
|
||||
// Data 在错误场景永远为 nil;序列化后 JSON 里显式为 "data":null。
|
||||
type ResponseErrorBean struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data interface{} `json:"data"` // F9: 错误响应也必须有 data 字段(值永远为 null)
|
||||
}
|
||||
|
||||
func Error(errCode uint32, errMsg string) *ResponseErrorBean {
|
||||
return &ResponseErrorBean{errCode, errMsg}
|
||||
return &ResponseErrorBean{errCode, errMsg, nil}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// responseBean_test.go — HIF-4 F9 护栏:
|
||||
// 所有 HTTP 响应必须是 {code, msg, data} 三字段固定 shape,data 为空时也要
|
||||
// 显式 "null"(不是丢字段)。App 端强类型 decoder 依赖这个 shape。
|
||||
package result
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResponseErrorBean_HasDataField 断言错误响应的 JSON 一定包含 data 字段
|
||||
// 且为 null。如果未来有人给 Data 加了 omitempty,或者删掉 Data 字段,这里会挂。
|
||||
func TestResponseErrorBean_HasDataField(t *testing.T) {
|
||||
body, err := json.Marshal(Error(40003, "User token is invalid"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `"data":null`) {
|
||||
t.Fatalf("F9 regression: error response missing 'data:null', got %s", s)
|
||||
}
|
||||
// 顺带断言 code + msg 也在(防止未来无脑重构删字段)
|
||||
if !strings.Contains(s, `"code":40003`) || !strings.Contains(s, `"msg":"User token is invalid"`) {
|
||||
t.Fatalf("error response missing code/msg, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseSuccessBean_DataAlwaysPresent 断言成功响应在 data 为 nil 时
|
||||
// 也显式返回 "data":null(不是丢字段)。这是 F9 的另一半——如果有人未来把
|
||||
// omitempty 加回去,只解决错误响应的对称性会被打破。
|
||||
func TestResponseSuccessBean_DataAlwaysPresent(t *testing.T) {
|
||||
body, err := json.Marshal(Success(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `"data":null`) {
|
||||
t.Fatalf("F9 regression: Success(nil) missing 'data:null' (omitempty regression?), got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseSuccessBean_WithPayload 副断言:非 nil payload 时
|
||||
// data 字段确实带上,且序列化正常。
|
||||
func TestResponseSuccessBean_WithPayload(t *testing.T) {
|
||||
body, err := json.Marshal(Success(map[string]any{"id": 42, "name": "abc"}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `"data":{`) || !strings.Contains(s, `"id":42`) {
|
||||
t.Fatalf("success payload not serialized correctly, got %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package simnet holds shared, stateless helpers for the simnet protocol.
|
||||
//
|
||||
// The per-user simnet credentials are DERIVED from the existing user_subscribe
|
||||
// record (id + uuid), not stored in a dedicated table. This mirrors the Pro
|
||||
// reference (NPanel-backend internal/data/delivery_node.go deriveSimnetUserPSK
|
||||
// and internal/biz/public/subscription/template.go key_id derivation), and is
|
||||
// required for wire compatibility with OmnXT / SlagClient:
|
||||
//
|
||||
// - key_id MUST equal the user_subscribe.id (the node's StaticKeyResolver /
|
||||
// assembler identifies the user by this id in the handshake frame header).
|
||||
// - psk is the user's subscription UUID normalized to lowercase 32-hex.
|
||||
package simnet
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// serverKeyID is the reserved key id for the server-side PSK (AF derivation).
|
||||
const serverKeyID = 0
|
||||
|
||||
// DeriveKeyID maps a user_subscribe.id to the simnet key_id.
|
||||
// It matches the reference: subscribeID % (2^31-1), avoiding 0 which collides
|
||||
// with the server key id.
|
||||
func DeriveKeyID(subscribeID int64) int {
|
||||
keyID := int(subscribeID % (1<<31 - 1))
|
||||
if keyID == serverKeyID {
|
||||
keyID = 1
|
||||
}
|
||||
return keyID
|
||||
}
|
||||
|
||||
// DeriveUserPSK normalizes a subscription UUID/password into the per-user PSK.
|
||||
// - canonical UUID -> lowercase hex without dashes (32 chars)
|
||||
// - already 32-char ASCII hex -> lowercased
|
||||
// - otherwise -> hex-encoded bytes of the raw value
|
||||
func DeriveUserPSK(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if isCanonicalUUID(trimmed) {
|
||||
return strings.ToLower(strings.ReplaceAll(trimmed, "-", ""))
|
||||
}
|
||||
if len(trimmed) == 32 && isASCIIHex(trimmed) {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
return hex.EncodeToString([]byte(trimmed))
|
||||
}
|
||||
|
||||
func isCanonicalUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for idx, ch := range value {
|
||||
switch idx {
|
||||
case 8, 13, 18, 23:
|
||||
if ch != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !isASCIIHexRune(ch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIHex(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if !isASCIIHexRune(ch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIHexRune(ch rune) bool {
|
||||
return (ch >= '0' && ch <= '9') ||
|
||||
(ch >= 'a' && ch <= 'f') ||
|
||||
(ch >= 'A' && ch <= 'F')
|
||||
}
|
||||
|
||||
// officialClientKeywords are User-Agent substrings of first-party clients/SDKs
|
||||
// allowed to receive experimental protocols. Mirrors the Pro reference
|
||||
// (internal/biz/public/subscribe/subscribe.go officialClientKeywords).
|
||||
var officialClientKeywords = []string{"omnxt", "slag/", "slaglab"}
|
||||
|
||||
// experimentalProtocols must be hidden from non-official clients.
|
||||
var experimentalProtocols = map[string]struct{}{
|
||||
"simnet": {},
|
||||
}
|
||||
|
||||
// ClientSupportsExperimental reports whether the client UA is a first-party
|
||||
// client permitted to receive experimental protocols (simnet).
|
||||
func ClientSupportsExperimental(userAgent string) bool {
|
||||
ua := strings.ToLower(userAgent)
|
||||
for _, kw := range officialClientKeywords {
|
||||
if strings.Contains(ua, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsExperimentalProtocol reports whether a protocol type is experimental
|
||||
// (hidden from non-official clients).
|
||||
func IsExperimentalProtocol(protocol string) bool {
|
||||
_, ok := experimentalProtocols[strings.ToLower(strings.TrimSpace(protocol))]
|
||||
return ok
|
||||
}
|
||||
Reference in New Issue
Block a user