Compare commits

...

10 Commits

Author SHA1 Message Date
shanshanzhong147 634b5a7bd0 feat(simnet): add SimNet protocol end-to-end support
- model: SimNet protocol fields + NormalizeSimnet; type+port protocol uniqueness
- server config: OmnXT runtime config delivery via compatible() simnet case
- credentials: derive per-user psk/key_id from subscription (pkg/simnet), no new table
- subscription: adapter buildOmnxtSimnetConfigs + base64 buildOmnxtProtocolLinks + OmnXT SimNet application (migration 02161)
- UA gating: hide experimental protocols from non first-party clients (download + JSON node-list)
- admin: normalize simnet on create/update and on GET responses
- tests: 21 simnet unit tests; full suite green
2026-07-27 00:17:02 -07:00
shanshanzhong147 5ef3f2717e feat(#4): 抽奖中奖用户文案调整
- 免费时长: 用户消息改为「稍后您的 N 天免费时长将会自动添加至您的账户。
  如果超过24小时未添加成功,请联系人工客服处理。」(N=中奖天数动态)
  ledger.payload.message 仍保留descriptive内部文案供后台对账
- 人工发放(crypto/manual): 消息改为「请凭此截图直接联系人工客服兑换奖励。」

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 02:56:38 -07:00
shanshanzhong147 2c1ee78bc4 修复(#4): 奖品更新支持修改 type(原来 type 被静默忽略)
UpdateLotteryPrize 之前可改字段漏了 type,导致 PUT 带 type 不生效、
只能删了重建。补上 UpdateAdminLotteryPrizeRequest.Type + fields["type"]。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:03:42 -07:00
shanshanzhong147 13bafd5847 feat(#4): 删除抽奖活动接口 DELETE /admin/lottery/activities/:id
- 软删活动 + 硬删其奖品(同事务)+ 审计日志
- 运行中的活动禁止删除(需先暂停)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:16:34 -07:00
shanshanzhong147 e8e3a3a72b feat(#4): 后台抽奖记录列表 GET /admin/lottery/draws
- 分页列出 lottery_draw,join 奖品快照 + 用户邮箱 + 发放账本(grant_ledger)
- 支持 activity/user/win/dispatch_state/prize_type/时间窗过滤
- 展示中奖人/奖品/发放状态/发放结果(如"已新建订阅并加 30 天")

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:36:53 -07:00
shanshanzhong147 58c346abec 重构(#4): 抽奖奖品改/删 id 走 URL path(RESTful)
- PUT /admin/lottery/prizes/:id、DELETE /admin/lottery/prizes/:id
- handler 从 c.Param("id") 取 id;types 用 path:"id"
- 同步 apis/admin/lottery.api 定义

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:45:23 -07:00
shanshanzhong147 abd8c068b6 修复(#4): 抽奖发奖修正 — 保底奖不参与随机 + 无订阅时按套餐自动新建订阅
- weighted_picker: Pick 排除 is_fallback 保底奖,避免真实奖被"谢谢参与"挤占
- vpn_duration handler: 无活跃订阅且奖品配置 subscribe_id 时,按该套餐在抽奖
  事务内新建订阅并发放时长;未配置则沿用旧的安全跳过
- 补充单测:picker 排除保底奖、vpn_duration 自动新建订阅、draw 端到端链路

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:08 -07:00
shanshanzhong147 eac0137069 修复(#4): 统一 API 响应格式 — ResponseErrorBean 补 data 字段
Closes part of HIF-4 (Stage 2 前端集成反馈 F9 - 全站响应格式)

F9 (P1): 全站响应统一 {code, msg, data} 三字段固定 shape
- ResponseSuccessBean.Data 去掉 omitempty → 空 payload 也返 "data":null
- ResponseErrorBean 加 Data interface{} 字段 → 错误响应也带 "data":null
- App 端强类型 decoder 依赖固定字段 shape,缺 data 键会解码失败

回归护栏 3 用例:
- TestResponseErrorBean_HasDataField: 错误响应必须含 "data":null
- TestResponseSuccessBean_DataAlwaysPresent: Success(nil) 也含 "data":null(防 omitempty 回退)
- TestResponseSuccessBean_WithPayload: 正常 payload 序列化正确

影响面:全站所有响应(不止 lottery),JSON 加字段/字段值变 null 对现有 client 无 breaking(宽松 decoder 全兼容)
CI 全绿;单 file 改动 revert 一步搞定
2026-07-12 19:55:35 -07:00
shanshanzhong147 cce147108c 修复(#4): 抽奖 8 宫格默认值 + 100500 msg 脱敏
Closes part of HIF-4 (Stage 2 前端集成反馈 F8 + F10)

F8 (P1): Activity.GridSize 默认值 9 → 8
- migration 02160 ALTER DEFAULT 8(幂等)
- 02156 up.sql 注释同步更新
- admin/lottery.go 兜底值 9 → 8
- 布局 A:3x3 挖中心,中心是抽奖按钮不是奖品

F10 (P1): wrapInternal msg 脱敏
- 内部错误 err.Error() 只写日志,不外传
- 用户端 msg 只带通用文案 (xerr.MapErrMsg lookup)
- 回归护栏:TestWrapInternal_ScrubsErrorDetailsFromMsg + TestWrapInternal_PreservesCodeErrors

CI 全绿;无 API 契约变更;F9 独立在 PR #45 处理
2026-07-12 19:55:25 -07:00
shanshanzhong147 980e5adb90 修复(#11): 抽奖 Stage 2 Claim.ClaimData 空字符串违反 MySQL JSON 校验
Closes HIF-11 (Stage 2 P0)

F7 (P0): Claim.ClaimData=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:service.go:450 dispatchOrEnqueueClaim 构造 Claim 时显式 ClaimData=\"{}\"
- 完全同构 PR C PrizeSnapshot.Config / PR E UnmetReasons=\"[]\" / PR F Payload=\"{}\" 三处守卫

回归护栏:TestDraw_ManualClaimClaimDataIsValidJSON + claimDataIsValidJSON per-arg matcher(既拒 \"\" 也 json.Valid 校验)
Backend 反向自检:git stash fix 后测试立即抛 F7 regression 明确错误
CI 全绿;2 files, +116/-5;无 DB / API / flag 变更

架构师复盘:这是 code review 第 4 次漏检查同类 JSON 空串守卫(Stage 1 的 F4/F6 + Stage 2 的 F7)。感谢 QA 三次挖坑救场。Stage 3 起 code review 硬性 checklist 第一步:grep -RIn type:json sweep 全库。
2026-07-12 19:09:54 -07:00
42 changed files with 2787 additions and 145 deletions
+71 -47
View File
@@ -113,53 +113,77 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
proxies = append( proxies = append(
proxies, proxies,
Proxy{ Proxy{
Sort: item.Sort, Sort: item.Sort,
Name: item.Name, Name: item.Name,
Server: item.Address, Server: item.Address,
Port: item.Port, Port: item.Port,
Type: item.Protocol, Type: item.Protocol,
Tags: strings.Split(item.Tags, ","), Tags: strings.Split(item.Tags, ","),
Security: protocol.Security, Security: protocol.Security,
SNI: protocol.SNI, SNI: protocol.SNI,
AllowInsecure: protocol.AllowInsecure, AllowInsecure: protocol.AllowInsecure,
Fingerprint: protocol.Fingerprint, Fingerprint: protocol.Fingerprint,
RealityServerAddr: protocol.RealityServerAddr, RealityServerAddr: protocol.RealityServerAddr,
RealityServerPort: protocol.RealityServerPort, RealityServerPort: protocol.RealityServerPort,
RealityPrivateKey: protocol.RealityPrivateKey, RealityPrivateKey: protocol.RealityPrivateKey,
RealityPublicKey: protocol.RealityPublicKey, RealityPublicKey: protocol.RealityPublicKey,
RealityShortId: protocol.RealityShortId, RealityShortId: protocol.RealityShortId,
Transport: protocol.Transport, Transport: protocol.Transport,
Host: protocol.Host, Host: protocol.Host,
Path: protocol.Path, Path: protocol.Path,
ServiceName: protocol.ServiceName, ServiceName: protocol.ServiceName,
Method: protocol.Cipher, Method: protocol.Cipher,
ServerKey: protocol.ServerKey, ServerKey: protocol.ServerKey,
Flow: protocol.Flow, Flow: protocol.Flow,
HopPorts: protocol.HopPorts, HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval, HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword, ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps, UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps, DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI, DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt, ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode, UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController, CongestionController: protocol.CongestionController,
PaddingScheme: protocol.PaddingScheme, PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex, Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode, XhttpMode: protocol.XhttpMode,
XhttpExtra: protocol.XhttpExtra, XhttpExtra: protocol.XhttpExtra,
Encryption: protocol.Encryption, Encryption: protocol.Encryption,
EncryptionMode: protocol.EncryptionMode, EncryptionMode: protocol.EncryptionMode,
EncryptionRtt: protocol.EncryptionRtt, EncryptionRtt: protocol.EncryptionRtt,
EncryptionTicket: protocol.EncryptionTicket, EncryptionTicket: protocol.EncryptionTicket,
EncryptionServerPadding: protocol.EncryptionServerPadding, EncryptionServerPadding: protocol.EncryptionServerPadding,
EncryptionPrivateKey: protocol.EncryptionPrivateKey, EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding, EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword, EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio, Ratio: protocol.Ratio,
CertMode: protocol.CertMode, CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider, CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv, CertDNSEnv: protocol.CertDNSEnv,
SimnetPsk: protocol.SimnetPsk,
SimnetKeyID: protocol.SimnetKeyID,
SimnetTicketID: protocol.SimnetTicketID,
SimnetPath: protocol.SimnetPath,
SimnetCarrier: protocol.SimnetCarrier,
SimnetAfEnabled: protocol.SimnetAfEnabled,
SimnetAfPathMode: protocol.SimnetAfPathMode,
SimnetAfPathPrefix: protocol.SimnetAfPathPrefix,
SimnetAfPathSuffix: protocol.SimnetAfPathSuffix,
SimnetAfMagicMode: protocol.SimnetAfMagicMode,
SimnetAfResponseJitterMs: protocol.SimnetAfResponseJitterMs,
SimnetAfHandshakePolymorphism: protocol.SimnetAfHandshakePolymorphism,
SimnetAfSettingsJitter: protocol.SimnetAfSettingsJitter,
SimnetAfFakeHeaderInjection: protocol.SimnetAfFakeHeaderInjection,
SimnetFallbackEnabled: protocol.SimnetFallbackEnabled,
SimnetFallbackTargetScheme: protocol.SimnetFallbackTargetScheme,
SimnetFallbackTargetHost: protocol.SimnetFallbackTargetHost,
SimnetFallbackTargetPort: protocol.SimnetFallbackTargetPort,
SimnetFallbackHostHeader: protocol.SimnetFallbackHostHeader,
SimnetFallbackTLSSNI: protocol.SimnetFallbackTLSSNI,
SimnetClientMaxConcurrentStreams: protocol.SimnetClientMaxConcurrentStreams,
SimnetClientMaxStreamsPerSession: protocol.SimnetClientMaxStreamsPerSession,
SimnetClientSessionIdleTimeoutSecs: protocol.SimnetClientSessionIdleTimeoutSecs,
SimnetClientMaxUDPSessions: protocol.SimnetClientMaxUDPSessions,
}, },
) )
} }
+32 -1
View File
@@ -81,10 +81,38 @@ type Proxy struct {
CertMode string // Certificate mode, `none``http``dns``self` CertMode string // Certificate mode, `none``http``dns``self`
CertDNSProvider string // DNS provider for certificate CertDNSProvider string // DNS provider for certificate
CertDNSEnv string // Environment for DNS provider CertDNSEnv string // Environment for DNS provider
// Simnet Options (server-side config; per-user psk/key_id are derived at
// render time from UserInfo, never stored on the Proxy).
SimnetPsk string // server-side PSK (key_id=0), used for AF derivation
SimnetKeyID int // server key id (0)
SimnetTicketID string
SimnetPath string
SimnetCarrier string
SimnetAfEnabled bool
SimnetAfPathMode string
SimnetAfPathPrefix string
SimnetAfPathSuffix string
SimnetAfMagicMode string
SimnetAfResponseJitterMs int
SimnetAfHandshakePolymorphism bool
SimnetAfSettingsJitter bool
SimnetAfFakeHeaderInjection bool
SimnetFallbackEnabled bool
SimnetFallbackTargetScheme string
SimnetFallbackTargetHost string
SimnetFallbackTargetPort int
SimnetFallbackHostHeader string
SimnetFallbackTLSSNI string
SimnetClientMaxConcurrentStreams int
SimnetClientMaxStreamsPerSession int
SimnetClientSessionIdleTimeoutSecs int
SimnetClientMaxUDPSessions int
} }
type User struct { type User struct {
Password string Password string
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
ExpiredAt time.Time ExpiredAt time.Time
Download int64 Download int64
Upload int64 Upload int64
@@ -104,7 +132,10 @@ type Client struct {
func (c *Client) Build() ([]byte, error) { func (c *Client) Build() ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate) funcMap := sprig.TxtFuncMap()
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+273
View File
@@ -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
View File
@@ -43,17 +43,21 @@ service ppanel {
@handler UpdateLotteryRules @handler UpdateLotteryRules
put /activities/rules (UpdateAdminLotteryRulesRequest) put /activities/rules (UpdateAdminLotteryRulesRequest)
@doc "Delete activity (soft-delete; running must be paused first)"
@handler DeleteLotteryActivity
delete /activities/:id (AdminActivityIdRequest)
@doc "Create prize" @doc "Create prize"
@handler CreateLotteryPrize @handler CreateLotteryPrize
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize) post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
@doc "Update prize" @doc "Update prize"
@handler UpdateLotteryPrize @handler UpdateLotteryPrize
put /prizes (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize) put /prizes/:id (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
@doc "Delete prize" @doc "Delete prize"
@handler DeleteLotteryPrize @handler DeleteLotteryPrize
delete /prizes (AdminPrizeIdRequest) delete /prizes/:id (AdminPrizeIdRequest)
@doc "List prizes on an activity" @doc "List prizes on an activity"
@handler ListLotteryPrizes @handler ListLotteryPrizes
@@ -83,4 +87,8 @@ service ppanel {
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)" @doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
@handler MarkPaidLotteryClaim @handler MarkPaidLotteryClaim
post /claims/mark-paid (AdminMarkPaidClaimRequest) post /claims/mark-paid (AdminMarkPaidClaimRequest)
@doc "List lottery draws (grant records)"
@handler ListLotteryDraws
get /draws (ListAdminLotteryDrawsRequest) returns (ListAdminLotteryDrawsResponse)
} }
+45
View File
@@ -1045,6 +1045,51 @@ type (
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self` CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self`
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
SimnetPsk string `json:"simnet_psk,omitempty"`
SimnetKeyID int `json:"simnet_key_id,omitempty"`
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
SimnetPath string `json:"simnet_path,omitempty"`
SimnetCarrier string `json:"simnet_carrier,omitempty"`
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
} }
// reset user subscribe token // reset user subscribe token
ResetUserSubscribeTokenRequest { ResetUserSubscribeTokenRequest {
+813
View File
@@ -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. 阶段 3OmnXT 服务端配置下发
### 目标
让 OmnXT 节点通过旧后端节点 API 拉到可运行的 `simnet` 服务端配置。
### 具体任务
1. 检查 `apis/node/node.api``GetServerConfigRequest` 是否有 `secret_key``server_id``protocol`
2.`GetServerConfigLogic` 中加入 `protocol=simnet` 分支。
3. 根据 `server_id + protocol + port` 找到启用的 `simnet` 协议配置。
4. 验证 `secret_key`,失败时返回明确错误,并记录来源 IP 和 server_id。
5. 构造 OmnXT 服务端运行 DTO,包含 Server 运行需要的 PSK、path、carrier、TLS、SNI、AF、fallback、reverse 默认关闭字段。
6. 不把管理端专用字段、无关协议字段原样塞给 OmnXT。
7. 缓存 key 要包含 `server_id + protocol + port`,避免同端口多协议污染缓存。
8. OmnXT 配置变更后要能通过更新 Server 或清理缓存生效。
### 服务端 DTO 建议
```json
{
"protocol": "simnet",
"port": 443,
"listen": ":443",
"simnet_psk": "server-side-secret",
"simnet_path": "/simnet/session",
"simnet_carrier": "h2",
"security": "tls",
"sni": "example.com",
"allow_insecure": false,
"simnet_af_enabled": true,
"simnet_fallback_enabled": true
}
```
### 预计修改位置
- `apis/node/node.api`
- `internal/types/types.go`
- `internal/logic/server/getServerConfigLogic.go`
- `internal/logic/server/constant.go`
- `internal/middleware/serverMiddleware.go`
- 可新增 `internal/logic/server/simnet_config.go`
### 依赖关系
- 阶段 1 和阶段 2。
- OmnXT 实际接口字段确认完成。
### 验收条件
1. `secret_key` 正确时,OmnXT 能拉到 `simnet` 服务端配置。
2. `secret_key` 错误时,请求被拒绝。
3. 修改管理端 `simnet_path` 后,OmnXT 重启或刷新能拿到新 path。
4. Server PSK 只出现在 OmnXT 服务端配置中,不出现在普通用户订阅中。
### 回滚点
关闭 `simnet.enable` 或回滚 `GetServerConfigLogic``simnet` 分支;旧协议节点不受影响。
## 9. 阶段 4:用户级 Simnet 凭据
### 目标
为每个有效用户订阅生成独立 `simnet` 凭据,避免所有用户共享 Server PSK,支持单用户封禁、重置和流量归属。
### 具体任务
1. 新增用户级凭据模型,建议按 `user_subscribe_id + server_id + protocol + port` 维度唯一。
2. 字段建议包括:`id``user_id``user_subscribe_id``server_id``protocol``port``key_id``psk``ticket_id``enabled``created_at``updated_at``rotated_at`
3. 添加数据库 migration,并在初始化兼容逻辑中保证表存在。
4. 用户第一次订阅或节点第一次拉用户列表时懒生成凭据。
5. 支持管理员重置某个用户订阅 token 时同步重置 `simnet` 凭据,避免旧凭据继续可用。
6. 凭据生成使用加密安全随机;`key_id` 可用递增 id 或稳定 hash,但必须避免全局冲突。
7. 保留 `ticket_id` 字段,第一版可为空或由 OmnXT 需要时生成。
### 预计修改位置
- `internal/model/user/*` 或新增 `internal/model/simnet/*`
- `initialize/migrate/*`
- `initialize/schema_compat.go`
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
- `internal/logic/server/getServerUserListLogic.go`
- 用户订阅 token 重置逻辑:`internal/logic/admin/user/resetUserSubscribeTokenHandler.go` 对应 logic
### 依赖关系
- 阶段 0 确认 OmnXT 和 SlagClient 需要的用户凭据格式。
- 阶段 1 的协议模型完成。
### 验收条件
1. 同一用户同一节点多次订阅拿到稳定凭据。
2. 不同用户拿到不同凭据。
3. 重置用户订阅 token 后旧凭据失效,新凭据生效。
4. 凭据表有唯一约束,重复生成不会产生两条有效凭据。
### 回滚点
可以停止向 OmnXT 下发 `simnet` 用户授权,并禁用 `simnet` 节点。数据库表可保留,不影响旧协议。
## 10. 阶段 5OmnXT 用户授权同步
### 目标
让 OmnXT 拉取用户列表时获得 `simnet` 可认证用户,并且用户到期、限额、禁用、套餐节点组变化后同步生效。
### 具体任务
1.`GetServerUserListLogic` 中加入 `simnet` 用户映射。
2. 沿用旧系统的有效用户筛选条件:订阅有效、未到期、流量未超限、用户未禁用、节点组有权限。
3.`simnet` 用户返回 `user_id``subscribe_id``uuid``key_id``psk``ticket_id`、限速字段。
4. OmnXT 请求 `protocol=simnet` 时,只返回有 `simnet` 权限的用户。
5. 缓存 key 加入 `protocol + port`,用户订阅变更、流量变更、节点组变更时能失效。
6.`hysteria2` 等旧兼容映射不做破坏;`normalizeServerUserListProtocol` 仅新增 `simnet` 透传。
### 预计修改位置
- `apis/node/node.api`
- `internal/types/types.go`
- `internal/logic/server/getServerUserListLogic.go`
- `internal/logic/server/constant.go`
- 用户订阅、节点组、流量相关 model/service
- 可新增 `internal/logic/server/simnet_user.go`
### 依赖关系
- 阶段 4 用户级凭据。
- 现有用户有效性判断需要梳理清楚。
### 验收条件
1. OmnXT 拉用户列表时能看到有效用户的 `simnet` 凭据。
2. 用户到期、禁用或流量超限后,从 OmnXT 用户列表消失。
3. 套餐节点组取消该节点后,从 OmnXT 用户列表消失。
4. 老协议用户列表响应不变。
### 回滚点
保留凭据表,但关闭 `GetServerUserListLogic``simnet` 分支或禁用节点。
## 11. 阶段 6:公共订阅与 SlagClient
### 目标
让 SlagClient 冷启动、重启、重新订阅时都能拿到完整 `simnet` 节点,并正确构造连接。
### 具体任务
1.`apis/public/subscribe.api``UserSubscribeNodeInfo` 加入用户连接需要的顶层 `simnet_*` 字段。
2. 保留 `protocols` JSON,确保 SlagClient 旧解析路径仍可读取。
3.`QueryUserSubscribeNodeListLogic` 中解析 Server 的 `protocols` JSON,并把匹配 `node.protocol + node.port``simnet` 配置映射到订阅响应。
4. 订阅响应只下发用户级 `simnet_key_id`、用户级 `simnet_psk`、可公开 path/carrier/TLS/SNI/AF/Fallback 字段。
5. 不下发 Server 级 `simnet_psk`、DNS provider env、管理端密钥字段。
6. 新增 capability header 判断,例如 `X-Client-Capabilities: simnet` 或当前 SlagClient 实际 header。
7. 如果没有 capability header,则使用 User-Agent 作为兼容兜底;不应单纯依赖 UA。
8. 对不支持 `simnet` 的客户端隐藏 `simnet` 节点,避免客户端崩溃或展示不可用节点。
9. 如果 SlagClient 同时支持 `protocols` JSON 和顶层字段,优先让顶层字段完整,`protocols` 作为兼容冗余。
### 订阅 DTO 建议
```json
{
"id": 1,
"name": "HK simnet",
"protocol": "simnet",
"port": 443,
"address": "node.example.com",
"sni": "net.example.com",
"simnet_key_id": 10001,
"simnet_psk": "user-side-secret",
"simnet_ticket_id": "",
"simnet_path": "/simnet/session",
"simnet_carrier": "h2",
"security": "tls",
"allow_insecure": false,
"simnet_af_enabled": true,
"simnet_af_path_mode": "api",
"simnet_af_magic_mode": "derived",
"simnet_fallback_enabled": true,
"simnet_fallback_target_scheme": "https",
"simnet_fallback_target_host": "www.example.com",
"simnet_fallback_target_port": 443
}
```
### 预计修改位置
- `apis/public/subscribe.api`
- `internal/types/types.go`
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
- `internal/logic/common/subscriptionTrace.go`,如有订阅 UA 或设备记录
- 可新增 `internal/logic/public/subscribe/simnet_mapper.go`
### 依赖关系
- 阶段 4 用户级凭据。
- SlagClient capability header 契约确认。
### 验收条件
1. SlagClient 冷启动订阅后能看到 `simnet` 节点。
2. SlagClient 重启后仍能从订阅恢复连接配置。
3. 不支持 `simnet` 的客户端订阅不返回 `simnet` 节点。
4. 普通用户订阅响应不包含 Server 级 PSK。
### 回滚点
订阅侧隐藏 `simnet` 节点或关闭 capability 开关;旧协议订阅不受影响。
## 12. 阶段 7:流量和在线用户映射
### 目标
让 OmnXT 上报的 `simnet` 在线用户和流量能正确归属到用户订阅,并触发旧系统现有的限额、日志、后台统计。
### 具体任务
1. 确认 OmnXT 上报用户标识是 `uuid``key_id``user_id` 还是其他字段。
2. 如果 OmnXT 上报 `key_id`,后端通过用户级凭据表反查 `user_subscribe_id``user_id`
3. 如果 OmnXT 上报 `uuid`,需要确认 `uuid``simnet` 凭据绑定关系,不允许跨用户伪造。
4.`serverPushUserTrafficLogic` 中加入 `simnet` 标识解析。
5.`pushOnlineUsersLogic` 中加入 `simnet` 在线用户映射。
6. 更新后台节点在线数统计,确保 `simnet:443` 与其他协议隔离。
7. 失败上报要记录协议、server_id、port、用户标识和错误原因,方便排查。
### 预计修改位置
- `apis/node/node.api`
- `internal/types/types.go`
- `internal/logic/server/serverPushUserTrafficLogic.go`
- `internal/logic/server/pushOnlineUsersLogic.go`
- `internal/model/traffic/*`
- `internal/model/node/*`
- 凭据表 model
### 依赖关系
- 阶段 4 用户级凭据。
- OmnXT 上报格式确认。
### 验收条件
1. `simnet` 连接产生流量后,用户已用流量增加。
2. 节点后台能看到 `simnet` 在线人数。
3. 用户超限后 OmnXT 用户列表不再包含该用户。
4. 旧协议流量统计不受影响。
### 回滚点
禁用 `simnet` 流量上报分支或关闭 `simnet` 节点;旧协议统计不受影响。
## 13. 阶段 8TLS、AF 与 Fallback
### 目标
把当前实际部署需要的 TLS/SNI、AF 和 HTTPS Fallback 做到可配置、可验证、可运维。
### 具体任务
1. TLS:支持 `security=tls``sni``allow_insecure=false`
2. 证书模式:第一版支持 `cert_mode=http`DNS provider 字段先保留,不在普通订阅下发。
3. AF:支持 `simnet_af_enabled``path_mode=api``magic_mode=derived``response_jitter_ms`
4. Fallback:支持 fallback scheme、host、port、host header、TLS SNI。
5. Reverse:字段保存和下发给 OmnXT,但默认关闭;如果开启必须要求 target host/port 完整。
6. 添加配置快照日志,OmnXT 拉取时打印非敏感字段,便于确认线上配置是否生效。
7. 对真实节点做 `443` 端口监听、证书申请、fallback 站点访问验证。
### 预计修改位置
- `internal/logic/admin/server/protocol_simnet.go`
- `internal/logic/server/simnet_config.go`
- `internal/logic/public/subscribe/simnet_mapper.go`
- `etc/ppanel.yaml`,如需要新增全局开关
- 节点部署文档或运维脚本,视 OmnXT 实际需求决定
### 依赖关系
- 阶段 3 OmnXT 配置下发。
- 节点服务器域名、证书、端口和 fallback 目标准备完成。
### 验收条件
1. OmnXT 能在 `443` 启动 `simnet` H2 TLS。
2. SNI 与证书匹配。
3. AF 开启后 SlagClient 仍可连接。
4. Fallback 目标在非协议请求时可访问。
5. OmnXT 重启后配置仍然生效。
### 回滚点
关闭 AF 或 fallback;必要时把 `simnet.enable=false`,保留旧协议节点承载用户。
## 14. 阶段 9:自动化测试
### 目标
用测试保护 `simnet` 的关键契约,减少后续修改协议字段时再次出现“面板有配置、节点拿不到、客户端不识别”的问题。
### 具体任务
1. 协议模型测试:默认值、校验、marshal/unmarshal。
2. 管理端测试:create/update Server 保存 `simnet` 字段完整。
3. 节点配置测试:`secret_key` 正确/错误、`simnet` DTO 字段筛选。
4. 用户凭据测试:生成稳定性、用户隔离、重置失效。
5. 订阅测试:capability header 支持时返回 `simnet`;不支持时隐藏。
6. 敏感字段测试:普通订阅中不得出现 Server PSK、DNS env。
7. 流量测试:OmnXT 上报 `key_id` 后可归属用户。
8. 回归测试:现有 vless、trojan、hysteria2、shadowsocks 订阅不变。
### 预计修改位置
- `tests/acceptance/*`
- `internal/logic/admin/server/*_test.go`
- `internal/logic/server/*_test.go`
- `internal/logic/public/subscribe/*_test.go`
- `internal/model/simnet/*_test.go`
### 依赖关系
- 阶段 1 到阶段 7 基本实现完成。
### 验收条件
1. `go test ./...` 通过,或项目当前可执行测试集全部通过。
2. 新增测试能覆盖 Server、OmnXT、SlagClient、Traffic 四条主链路。
3. 任意敏感字段泄露测试失败时,CI 阻断。
### 回滚点
测试本身不影响生产;如果某阶段实现回滚,相应测试应标记待实现或一并回滚。
## 15. 阶段 10:灰度发布与回滚
### 目标
`simnet` 以可控方式上线,先让一个节点和少量测试用户跑通,再扩大范围。
### 具体任务
1. 增加全局或配置级开关:`simnet_enabled`
2. 管理端先创建一个独立测试 Server 和一个 `simnet` front node。
3. 只给测试套餐或测试节点组分配该节点。
4. 部署 OmnXT,确认能拉配置、拉用户、启动监听。
5. 用测试用户订阅 SlagClient,验证冷启动、重启、切换网络、重拉订阅。
6. 观察在线用户、流量上报、错误日志、证书续期和 fallback 访问。
7. 稳定后把节点加入正式套餐节点组。
8. 保留旧协议节点作为回退路径,不把全部用户一次性切到 `simnet`
### 预计修改位置
- `etc/ppanel.yaml`,如需要全局开关
- `internal/config/config.go`
- `internal/svc/serviceContext.go`
- 运维部署文档
### 依赖关系
- 阶段 1 到阶段 9 完成。
- 测试节点服务器、域名、证书、OmnXT 可用。
### 验收条件
1. 测试用户能稳定连接 `simnet`
2. SlagClient 重启后无需人工操作即可恢复。
3. OmnXT 重启后能自动拉配置和用户授权。
4. 管理端能看到在线和流量。
5. 关闭 `simnet` 后用户可回退到旧协议节点。
### 回滚点
1. 管理端将 `simnet` 协议 `enable=false`
2. 从套餐节点组移除 `simnet` 节点。
3. OmnXT 停止 `simnet` inbound。
4. 回滚后端到上一版本。
5. 保留凭据表和字段,后续排查后可再次启用。
## 16. 文件改动范围
预计完整生产可用版本会影响 27-45 个业务/配置文件、12-20 个测试文件,新增约 3,000-6,000 行代码和测试。实际数量取决于 goctl 生成文件体积、现有 model 组织方式和 OmnXT/SlagClient 契约是否稳定。
### 必改范围
- `apis/types.api`
- `apis/admin/server.api`
- `apis/node/node.api`
- `apis/public/subscribe.api`
- `internal/types/types.go`
- `internal/model/node/*`
- `internal/logic/admin/server/createServerLogic.go`
- `internal/logic/admin/server/updateServerLogic.go`
- `internal/logic/admin/server/getServerProtocolsLogic.go`
- `internal/logic/admin/server/filterServerListLogic.go`
- `internal/logic/server/getServerConfigLogic.go`
- `internal/logic/server/getServerUserListLogic.go`
- `internal/logic/server/serverPushUserTrafficLogic.go`
- `internal/logic/server/pushOnlineUsersLogic.go`
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
### 可能新增范围
- `internal/model/simnet/*`
- `internal/logic/admin/server/protocol_simnet.go`
- `internal/logic/server/simnet_config.go`
- `internal/logic/server/simnet_user.go`
- `internal/logic/public/subscribe/simnet_mapper.go`
- `initialize/migrate/*simnet*`
- `tests/simnet/*`
- `docs/simnet-contract.md`
### 前端联动范围
如果管理端前端也要同步配置,需要在前端仓库补齐:
- Server 创建/编辑表单的 `simnet` 协议字段
- 协议默认值填充
- 字段校验提示
- Node 创建/更新时允许 `protocol=simnet`
- 隐藏 Server PSK 的展示或复制入口
## 17. 提交拆分
建议按以下提交拆分,方便 review 和回滚:
1. `simnet: add protocol model fields and validation`
2. `simnet: support admin server create/update/list`
3. `simnet: expose server runtime config for OmnXT`
4. `simnet: add per-user credentials`
5. `simnet: sync OmnXT user authorization`
6. `simnet: expose public subscribe fields for SlagClient`
7. `simnet: map traffic and online reports`
8. `simnet: add tls af fallback handling`
9. `simnet: add tests and rollout switch`
每个提交都应该能单独说明行为变化,并尽量避免把 goctl 生成文件和手写逻辑混在一个巨大提交里。如果生成文件不可避免较大,提交说明中要明确哪些是生成结果。
## 18. 验收标准
最终验收必须覆盖下面场景:
1. 管理端能创建 Server,协议为 `simnet`,端口 `443`TLS/SNI、AF、Fallback 字段保存完整。
2. 管理端能创建或更新 Node`protocol=simnet``address` 指向实际节点服务器。
3. OmnXT 使用正确 `secret_key` 能拉取 `simnet` 服务端运行配置。
4. OmnXT 使用错误 `secret_key` 被拒绝。
5. OmnXT 重启后自动恢复 `simnet` inbound。
6. 有效用户能通过 OmnXT 用户列表获得授权。
7. 不同用户的 `simnet_key_id``simnet_psk` 不相同。
8. 用户禁用、到期或流量超限后,OmnXT 用户列表移除该用户。
9. SlagClient 冷启动能通过订阅拿到 `simnet` 节点并连接。
10. SlagClient 重启后不丢失协议配置。
11. 不支持 `simnet` 的客户端订阅不会收到 `simnet` 节点。
12. 普通用户订阅响应不泄露 Server PSK、DNS provider env、节点 `secret_key`
13. `simnet` 连接产生流量后,用户流量、节点流量、后台日志同步更新。
14. 关闭 `simnet` 后,旧协议订阅、节点运行和流量统计不受影响。
15. `go test ./...` 或项目当前有效测试集通过。
## 19. 风险清单
| 风险 | 影响 | 控制方式 |
| --- | --- | --- |
| Server PSK 被下发给普通用户 | 所有用户共享密钥,泄露后整节点风险扩大 | DTO 分层,订阅敏感字段测试阻断 |
| OmnXT 和后端字段名不一致 | 节点启动失败或配置不生效 | 阶段 0 固化契约,用真实 OmnXT 请求回放测试 |
| SlagClient 只读顶层字段或只读 protocols JSON | 客户端拿到节点但无法连接 | 双格式兼容,顶层字段和 protocols 都保持可读 |
| 单用户凭据缺失 | 无法隔离用户,封禁和流量归属困难 | 阶段 4 必须先做凭据表,不走全员共享 PSK |
| capability 判断不准确 | 老客户端看到不可用节点 | capability header 优先,UA 只兜底,默认隐藏不支持客户端 |
| 缓存 key 未包含 port | 多协议或同协议多端口串配置 | cache key 包含 `server_id + protocol + port` |
| 流量上报标识不明确 | 用户流量无法入账或串账 | 与 OmnXT 明确上报 `key_id`,后端反查凭据表 |
| TLS/证书/fallback 运维失败 | 节点 443 无法正常服务 | 灰度节点先跑,保留旧协议回退 |
| goctl 生成覆盖手写改动 | 代码冲突或字段丢失 | 所有类型先改 api 文件,再生成;手写扩展放独立文件 |
## 20. 工期估算
在 OmnXT 和 SlagClient 契约清楚、测试环境可用的情况下:
- 阶段 00.5-1 天
- 阶段 1-21.5-2 天
- 阶段 31-1.5 天
- 阶段 41.5-2 天
- 阶段 51-1.5 天
- 阶段 61-1.5 天
- 阶段 71-2 天
- 阶段 81 天
- 阶段 92-3 天
- 阶段 101 天
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
## 21. 推荐执行顺序
第一周先完成最小闭环:
1. 阶段 0:确认契约。
2. 阶段 1:协议模型与校验。
3. 阶段 2:管理端保存和查询。
4. 阶段 3OmnXT 配置下发。
第二周完成用户链路:
1. 阶段 4:用户级凭据。
2. 阶段 5OmnXT 用户授权。
3. 阶段 6SlagClient 订阅。
4. 阶段 7:流量和在线用户映射。
最后做生产化:
1. 阶段 8TLS、AF、Fallback 运维验证。
2. 阶段 9:自动化测试补齐。
3. 阶段 10:灰度发布和回滚演练。
## 22. 当前结论
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS `lottery_activity` (
`start_at` DATETIME NOT NULL COMMENT '开始时间', `start_at` DATETIME NOT NULL COMMENT '开始时间',
`end_at` DATETIME NOT NULL COMMENT '结束时间', `end_at` DATETIME NOT NULL COMMENT '结束时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended', `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 嵌套规则)', `eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant', `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', `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 F8lottery_activity.grid_size 默认值从 9 改成 8
--
-- 前端与产品对齐后确认布局 A:3×3 挖中心 → 中心是"点击抽奖"按钮(不是奖品格),
-- 其余 8 格挂奖品。因此 grid_size 的默认值应为 8,不再是 9。
--
-- 兼容性:
-- * up.sql 的 CREATE TABLE 已在 02156 里跑过,MySQL 的 CREATE TABLE IF NOT EXISTS
-- 不会改动既存表结构。所以老部署的 lottery_activity.grid_size 默认值仍是 9
-- 必须用一条独立的 ALTER 迁移把默认值改过来。
-- * 已有数据(grid_size=9 的老活动)不动 —— ALTER DEFAULT 只影响新插入行且未提供
-- grid_size 的场景;Go 侧 admin/lottery.go 的兜底也已配套改成 8。
--
-- 幂等:ALTER COLUMN ... SET DEFAULT 在 MySQL 8.0+ 是幂等的(重复执行等值 SET
-- 不会报错),重跑安全。
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 8;
@@ -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) 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 package lottery
import ( import (
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery" adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
"github.com/perfect-panel/server/internal/svc" "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) { return func(c *gin.Context) {
var req types.UpdateAdminLotteryPrizeRequest var req types.UpdateAdminLotteryPrizeRequest
_ = c.ShouldBind(&req) _ = 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 { if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err) result.ParamErrorResult(c, err)
return return
@@ -132,6 +137,9 @@ func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context)
return func(c *gin.Context) { return func(c *gin.Context) {
var req types.AdminPrizeIdRequest var req types.AdminPrizeIdRequest
_ = c.ShouldBind(&req) _ = 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 { if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err) result.ParamErrorResult(c, err)
return return
@@ -167,3 +175,20 @@ func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context)
result.HttpResult(c, nil, l.GrantLotteryChance(&req)) 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))
}
}
+6 -2
View File
@@ -33,10 +33,11 @@ func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx)) adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx))
adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx)) adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx))
adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx)) adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx))
adminGroup.DELETE("/activities/:id", adminLottery.DeleteLotteryActivityHandler(serverCtx))
adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx)) adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
adminGroup.PUT("/prizes", adminLottery.UpdateLotteryPrizeHandler(serverCtx)) adminGroup.PUT("/prizes/:id", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
adminGroup.DELETE("/prizes", adminLottery.DeleteLotteryPrizeHandler(serverCtx)) adminGroup.DELETE("/prizes/:id", adminLottery.DeleteLotteryPrizeHandler(serverCtx))
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx)) adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(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/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx)) adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(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) { func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx) l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
resp, err := l.QueryUserSubscribeNodeList() resp, err := l.QueryUserSubscribeNodeList()
result.HttpResult(c, resp, err) result.HttpResult(c, resp, err)
} }
+204
View File
@@ -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
}
+55 -1
View File
@@ -87,7 +87,9 @@ func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdmi
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock), UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
} }
if activity.GridSize <= 0 { if activity.GridSize <= 0 {
activity.GridSize = 9 // HIF-4 F8: 布局 A 3×3 挖中心 → 8 个奖品格。前端约定中心是"点击抽奖"按钮,
// 不渲染为奖品;后台仍允许挂 slot=4 但前端会忽略。
activity.GridSize = 8
} }
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { 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) 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) ---------------------------------------- // ---- UpdateLotteryRules (with caps) ----------------------------------------
type UpdateLotteryRulesLogic struct { type UpdateLotteryRulesLogic struct {
@@ -467,6 +518,9 @@ func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotte
if req.Slot != nil { if req.Slot != nil {
fields["slot"] = *req.Slot fields["slot"] = *req.Slot
} }
if req.Type != "" {
fields["type"] = req.Type
}
if req.Name != "" { if req.Name != "" {
fields["name"] = 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) protocols = append(protocols, protocol)
} }
@@ -54,6 +54,10 @@ func (l *FilterServerListLogic) FilterServerList(req *types.FilterServerListRequ
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error()) l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
continue continue
} }
// Normalize simnet defaults on the response (safe: dst is a fresh slice).
for i := range dst {
dst[i].NormalizeSimnet()
}
tool.DeepCopy(&protocols, dst) tool.DeepCopy(&protocols, dst)
server.Protocols = protocols server.Protocols = protocols
@@ -41,6 +41,12 @@ func (l *GetServerProtocolsLogic) GetServerProtocols(req *types.GetServerProtoco
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error()) l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
} }
// Normalize simnet defaults on the response so the admin UI always sees a
// consistent config even for legacy/hand-inserted rows. dst is a fresh slice
// (not shared with the DB), so mutating it here is safe.
for i := range dst {
dst[i].NormalizeSimnet()
}
tool.DeepCopy(&protocols, dst) tool.DeepCopy(&protocols, dst)
return &types.GetServerProtocolsResponse{ return &types.GetServerProtocolsResponse{
@@ -100,6 +100,8 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error {
} }
} }
} }
// Simnet: apply defaults / normalize (no-op for other protocols)
protocol.NormalizeSimnet()
protocols = append(protocols, protocol) protocols = append(protocols, protocol)
} }
err = data.MarshalProtocols(protocols) err = data.MarshalProtocols(protocols)
+22 -7
View File
@@ -38,6 +38,7 @@ import (
"github.com/perfect-panel/server/internal/logic/lottery/handler" "github.com/perfect-panel/server/internal/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/pkg/limit" "github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"gorm.io/gorm" "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 return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
} }
if s.deps.Registry == 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) prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
if err != nil { if err != nil {
if errors.Is(err, lottery.ErrHandlerNotRegistered) { 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) 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, UserId: req.UserId,
ActivityId: req.ActivityId, ActivityId: req.ActivityId,
PrizeType: prize.Type, PrizeType: prize.Type,
Status: lottery.ClaimStatusPendingClaim, // ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
ExpiresAt: expiresAt, // 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
// GrantLedger.Payload 的守卫风格一致。
ClaimData: "{}",
Status: lottery.ClaimStatusPendingClaim,
ExpiresAt: expiresAt,
} }
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil { 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)) 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{ return lottery.DispatchResult{
State: lottery.DispatchStatePendingClaim, State: lottery.DispatchStatePendingClaim,
Message: "等待填写领奖信息", Message: "请凭此截图直接联系人工客服兑换奖励。",
}, pendingClaimInfo{ }, pendingClaimInfo{
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
ClaimFormSchema: schema, ClaimFormSchema: schema,
@@ -596,15 +602,24 @@ func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) {
return &tree, nil return &tree, nil
} }
// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。
//
// HIF-4 F10msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文
// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种
// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。
func wrapInternal(err error) error { func wrapInternal(err error) error {
if err == nil { if err == nil {
return nil return nil
} }
// Preserve already-coded errors. // Preserve already-coded errors (their msg 是设计过的对外文案,不动).
if _, ok := err.(*xerr.CodeError); ok { if _, ok := err.(*xerr.CodeError); ok {
return err 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 ---------------------------------------- // ---- Rate limiter production wiring ----------------------------------------
+319 -3
View File
@@ -13,7 +13,9 @@ import (
"time" "time"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
handler "github.com/perfect-panel/server/internal/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/internal/model/lottery"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
@@ -514,6 +516,52 @@ func errAsCode(err error) (uint32, bool) {
return 0, false 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 // TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard
// (kept from PR E — must survive Stage 2 rebase). // (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 // 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 // (which does no JSON validation) was happy. This test snapshots the exact
// INSERT arg values and asserts: // INSERT arg values and asserts:
// 1. UnmetReasons must never be "" (it should be "[]") // 1. UnmetReasons must never be "" (it should be "[]")
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects // 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
// '0000-00-00 00:00:00' on DATETIME NOT NULL) // '0000-00-00 00:00:00' on DATETIME NOT NULL)
// //
// sqlmock cannot catch the JSON validity itself — only real MySQL can — but // 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 // 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) 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)
}
}
+69 -4
View File
@@ -16,8 +16,11 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
"github.com/perfect-panel/server/internal/model/lottery" "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" usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/uuidx"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -88,6 +91,9 @@ func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
// vpnDurationConfig 是奖品 Config JSON 的解码目标。 // vpnDurationConfig 是奖品 Config JSON 的解码目标。
type vpnDurationConfig struct { type vpnDurationConfig struct {
DurationDays int `json:"duration_days"` DurationDays int `json:"duration_days"`
// SubscribeId 指定“无活跃订阅时新建订阅”所用的套餐计划 ID。
// 0 表示不新建:延续历史行为(无活跃订阅则记录 skipped 不发放)。
SubscribeId int64 `json:"subscribe_id"`
} }
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一 // vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
@@ -99,6 +105,12 @@ type vpnDurationPayload struct {
Message string `json:"message"` Message string `json:"message"`
} }
// vpnDurationUserMessage 是免费时长中奖后返回给用户的提示文案(N=中奖天数,动态)。
// 内部对账用的详细结果仍写在 ledger.payload.message(如"已加 N 天到订阅")。
func vpnDurationUserMessage(days int) string {
return fmt.Sprintf("稍后您的 %d 天免费时长将会自动添加至您的账户。如果超过24小时未添加成功,请联系人工客服处理。", days)
}
// Dispatch 在 caller 的事务内发放订阅时长。 // Dispatch 在 caller 的事务内发放订阅时长。
func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) { func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
if tx == nil { if tx == nil {
@@ -145,13 +157,31 @@ func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lott
if payload.Message == "" { if payload.Message == "" {
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) activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
if errors.Is(findErr, gorm.ErrRecordNotFound) { 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{ payload := vpnDurationPayload{
EffectiveUserID: effectiveUserID, EffectiveUserID: effectiveUserID,
Days: cfg.DurationDays, 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 { if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
return lottery.DispatchResult{}, err 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 { if findErr != nil {
return lottery.DispatchResult{}, fmt.Errorf("find active subscribe for user %d: %w", effectiveUserID, findErr) 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 { if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
return lottery.DispatchResult{}, err 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;未找到则回退到 // findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
@@ -225,6 +255,41 @@ func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int
return &fallback, nil 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 { func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
raw, err := json.Marshal(payload) raw, err := json.Marshal(payload)
if err != nil { if err != nil {
@@ -130,7 +130,7 @@ func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
if res.State != lottery.DispatchStateAutoClaimed { if res.State != lottery.DispatchStateAutoClaimed {
t.Fatalf("expected auto_claimed, got %q", res.State) 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) t.Fatalf("expected stored message, got %q", res.Message)
} }
} }
@@ -176,7 +176,7 @@ func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
if res.State != lottery.DispatchStateAutoClaimed { if res.State != lottery.DispatchStateAutoClaimed {
t.Fatalf("expected auto_claimed even on skip, got %q", res.State) 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) t.Fatalf("expected skip message, got %q", res.Message)
} }
} }
@@ -234,7 +234,7 @@ func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
if !updateCalled { if !updateCalled {
t.Fatalf("expected UpdateSubscribe to be called") t.Fatalf("expected UpdateSubscribe to be called")
} }
if res.Message != "已加 3 天到订阅" { if !strings.Contains(res.Message, "免费时长将会自动添加") {
t.Fatalf("unexpected message: %q", res.Message) t.Fatalf("unexpected message: %q", res.Message)
} }
} }
@@ -343,3 +343,144 @@ func TestVPNDuration_BadConfigRejected(t *testing.T) {
} }
_ = json.Unmarshal _ = 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/internal/types"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/simnet"
"github.com/perfect-panel/server/pkg/tool" "github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -20,16 +21,18 @@ import (
type QueryUserSubscribeNodeListLogic struct { type QueryUserSubscribeNodeListLogic struct {
logger.Logger logger.Logger
ctx context.Context ctx context.Context
svcCtx *svc.ServiceContext svcCtx *svc.ServiceContext
userAgent string
} }
// Get user subscribe node info // Get user subscribe node info
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic { func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext, userAgent string) *QueryUserSubscribeNodeListLogic {
return &QueryUserSubscribeNodeListLogic{ return &QueryUserSubscribeNodeListLogic{
Logger: logger.WithContext(ctx), Logger: logger.WithContext(ctx),
ctx: ctx, ctx: ctx,
svcCtx: svcCtx, svcCtx: svcCtx,
userAgent: userAgent,
} }
} }
@@ -163,6 +166,12 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
if server == nil { if server == nil {
continue continue
} }
// Hide experimental protocols (simnet) — and their server material in
// the raw protocols JSON — from non first-party clients. Mirrors the
// Pro reference FilterExperimentalNodesForClient.
if simnet.IsExperimentalProtocol(n.Protocol) && !simnet.ClientSupportsExperimental(l.userAgent) {
continue
}
userSubscribeNode := &types.UserSubscribeNodeInfo{ userSubscribeNode := &types.UserSubscribeNodeInfo{
Id: n.Id, Id: n.Id,
Name: n.Name, Name: n.Name,
+1
View File
@@ -12,6 +12,7 @@ const (
// Deprecated: Hysteria2 is deprecated, use Hysteria instead // Deprecated: Hysteria2 is deprecated, use Hysteria instead
// TODO: remove in future versions // TODO: remove in future versions
Hysteria2 = "hysteria2" Hysteria2 = "hysteria2"
Simnet = "simnet"
) )
type SecurityConfig struct { type SecurityConfig struct {
@@ -234,6 +234,22 @@ func (l *GetServerConfigLogic) compatible(config node.Protocol) map[string]inter
}, },
} }
case Simnet:
// Simnet ships its whole normalized protocol as the node runtime config
// (server PSK key_id=0, path, carrier, TLS, AF, fallback, reverse, resource
// limits), matching the Pro reference (compat_legacy.go simnet case).
config.NormalizeSimnet()
// Config snapshot log — non-sensitive fields only (never log the PSK).
l.Infow("[GetServerConfig] simnet runtime config",
logger.Field("port", config.Port),
logger.Field("path", config.SimnetPath),
logger.Field("carrier", config.SimnetCarrier),
logger.Field("security", config.Security),
logger.Field("af_enabled", config.SimnetAfEnabled),
logger.Field("fallback_enabled", config.SimnetFallbackEnabled),
)
result = config
} }
var resp map[string]interface{} var resp map[string]interface{}
s, _ := json.Marshal(result) s, _ := json.Marshal(result)
@@ -38,6 +38,7 @@ func TestNormalizeServerUserListProtocol(t *testing.T) {
{"tuic unchanged", "tuic", "tuic"}, {"tuic unchanged", "tuic", "tuic"},
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"}, {"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
{"anytls unchanged", "anytls", "anytls"}, {"anytls unchanged", "anytls", "anytls"},
{"simnet unchanged", "simnet", "simnet"},
{"empty unchanged", "", ""}, {"empty unchanged", "", ""},
} }
for _, c := range cases { for _, c := range cases {
+27
View File
@@ -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 { if err != nil {
return nil, err return nil, err
} }
// Experimental protocols (simnet) are only delivered to their own clients/SDK
// (UA hits omnxt/slag/slaglab). Hide them from every other client so a
// generic template never renders a broken/unusable node. Mirrors the Pro
// reference FilterExperimentalNodesForClient.
servers = filterExperimentalNodesForClient(servers, userAgent)
a := adapter.NewAdapter( a := adapter.NewAdapter(
targetApp.SubscribeTemplate, targetApp.SubscribeTemplate,
adapter.WithServers(servers), adapter.WithServers(servers),
@@ -109,6 +114,7 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
adapter.WithOutputFormat(targetApp.OutputFormat), adapter.WithOutputFormat(targetApp.OutputFormat),
adapter.WithUserInfo(adapter.User{ adapter.WithUserInfo(adapter.User{
Password: userSubscribe.UUID, Password: userSubscribe.UUID,
SubscribeID: userSubscribe.Id,
ExpiredAt: userSubscribe.ExpireTime, ExpiredAt: userSubscribe.ExpireTime,
Download: userSubscribe.Download, Download: userSubscribe.Download,
Upload: userSubscribe.Upload, Upload: userSubscribe.Upload,
+1 -1
View File
@@ -77,7 +77,7 @@ type Activity struct {
StartAt time.Time `gorm:"not null;comment:开始时间"` StartAt time.Time `gorm:"not null;comment:开始时间"`
EndAt time.Time `gorm:"not null;comment:结束时间"` EndAt time.Time `gorm:"not null;comment:结束时间"`
Status string `gorm:"type:varchar(16);not null;default:'draft';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 嵌套规则)"` Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"`
ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"` ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"`
UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"` UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"`
+13 -6
View File
@@ -23,8 +23,12 @@ func NewWeightedPicker(seed int64) WeightedPicker {
} }
} }
// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重 // Pick 从 candidates 中返回一个索引。以下奖品不参与随机
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。 // - 权重 <= 0
// - is_fallback=true 的保底奖(只在限量奖售罄时兜底发放,绝不能被随机抽中,
// 否则真实奖品会被“谢谢参与”类保底项挤占)
//
// 累计权重为 0(无任何可抽奖品)返回 ErrEmptyPool。
// //
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、 // 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
// 不需要预分配,并对小池(<20 项)足够快。 // 不需要预分配,并对小池(<20 项)足够快。
@@ -32,9 +36,12 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
if len(candidates) == 0 { if len(candidates) == 0 {
return 0, ErrEmptyPool return 0, ErrEmptyPool
} }
// eligible 判定:非保底 && 权重为正,才计入随机池。
eligible := func(c Prize) bool { return !c.IsFallback && c.Weight > 0 }
var total int64 var total int64
for _, c := range candidates { for _, c := range candidates {
if c.Weight > 0 { if eligible(c) {
total += int64(c.Weight) total += int64(c.Weight)
} }
} }
@@ -48,7 +55,7 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
var cum int64 var cum int64
for i, c := range candidates { for i, c := range candidates {
if c.Weight <= 0 { if !eligible(c) {
continue continue
} }
cum += int64(c.Weight) cum += int64(c.Weight)
@@ -56,9 +63,9 @@ func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
return i, nil return i, nil
} }
} }
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。 // 走到这里说明浮点/累加异常,回退到最后一个参与随机的项。
for i := len(candidates) - 1; i >= 0; i-- { for i := len(candidates) - 1; i >= 0; i-- {
if candidates[i].Weight > 0 { if eligible(candidates[i]) {
return i, nil 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) { func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
pool := []Prize{ pool := []Prize{
{Id: 1, Weight: 1}, {Id: 1, Weight: 1},
+1
View File
@@ -38,6 +38,7 @@ var AllProtocols = []string{
"tuic", "tuic",
"hysteria", "hysteria",
"hysteria2", "hysteria2",
"simnet",
} }
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。 // ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
+1
View File
@@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
"tuic", "tuic",
"hysteria", "hysteria",
"hysteria2", "hysteria2",
"simnet",
} }
set := make(map[string]struct{}, len(AllProtocols)) set := make(map[string]struct{}, len(AllProtocols))
for _, p := range AllProtocols { for _, p := range AllProtocols {
+196 -5
View File
@@ -2,6 +2,8 @@ package node
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings"
"time" "time"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@@ -71,17 +73,20 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error {
return nil return nil
} }
// MarshalProtocols Marshal server protocols to json // MarshalProtocols Marshal server protocols to json.
// Uniqueness is scoped to type+port (a protocol entry is one listener instance),
// which allows the same protocol on multiple ports, e.g. simnet:443 and simnet:8443.
func (m *Server) MarshalProtocols(list []Protocol) error { func (m *Server) MarshalProtocols(list []Protocol) error {
var validate = make(map[string]bool) var validate = make(map[string]bool)
for _, protocol := range list { for _, protocol := range list {
if protocol.Type == "" { if strings.TrimSpace(protocol.Type) == "" {
return errors.New("protocol type is required") return errors.New("protocol type is required")
} }
if _, exists := validate[protocol.Type]; exists { key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port)
return errors.New("duplicate protocol type: " + protocol.Type) if _, exists := validate[key]; exists {
return errors.New("duplicate protocol type+port: " + key)
} }
validate[protocol.Type] = true validate[key] = true
} }
data, err := json.Marshal(list) data, err := json.Marshal(list)
if err != nil { if err != nil {
@@ -153,6 +158,192 @@ type Protocol struct {
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self` CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self`
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
// Simnet protocol fields. Field set and json tags mirror the Pro reference
// (NPanel-backend internal/model/server/protocol.go) so OmnXT/SlagClient
// see an identical wire contract.
SimnetPsk string `json:"simnet_psk,omitempty"` // server-side PSK (key_id=0), never sent to normal users
SimnetKeyID int `json:"simnet_key_id,omitempty"` // server key id (0)
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
SimnetPath string `json:"simnet_path,omitempty"`
SimnetCarrier string `json:"simnet_carrier,omitempty"`
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
}
const (
defaultSimnetInboundMaxStreamsPerSession int = 128
defaultSimnetInboundMaxUDPStreamsPerSession int = 64
defaultSimnetInboundMaxHandlerTasksPerSession int = 128
defaultSimnetStreamEventChannelCapacity int = 256
defaultSimnetStreamDataChannelCapacity int = 128
defaultSimnetTargetDialTimeoutMs int = 12_000
defaultSimnetTargetMaxConcurrentDials int = 256
defaultSimnetSessionWindow int = 4 * 1024 * 1024
defaultSimnetMaxConcurrentStreams int = 100
defaultSimnetInitialWindowSize int = 65_535
defaultSimnetMaxFrameSize int = 16_384
defaultSimnetClientMaxConcurrentStreams int = 32
defaultSimnetClientMaxStreamsPerSession int = 512
defaultSimnetClientSessionIdleTimeoutSecs int = 90
defaultSimnetClientMaxUDPSessions int = 64
)
// NormalizeSimnet applies simnet defaults and clears mutually-exclusive fields.
// Ported from the Pro reference (NPanel-backend protocol.go NormalizeSimnet) so
// the runtime config matches what OmnXT expects.
func (m *Protocol) NormalizeSimnet() {
if m == nil || m.Type != "simnet" {
return
}
if m.Port == 0 {
m.Port = 443
}
if strings.TrimSpace(m.SimnetPath) == "" {
m.SimnetPath = "/simnet/session"
}
// Carrier: only h2 is supported in v1; coerce empty/grpc to h2 (matches the
// Pro reference normalizeSimnetProtocol).
if c := strings.TrimSpace(m.SimnetCarrier); c == "" || c == "grpc" {
m.SimnetCarrier = "h2"
}
m.applySimnetResourceDefaults()
if !m.SimnetFallbackEnabled || strings.TrimSpace(m.SimnetFallbackTargetHost) == "" {
m.SimnetFallbackEnabled = false
m.SimnetFallbackTargetScheme = ""
m.SimnetFallbackTargetHost = ""
m.SimnetFallbackTargetPort = 0
m.SimnetFallbackHostHeader = ""
m.SimnetFallbackTLSSNI = ""
} else {
m.SimnetFallbackTargetHost = strings.TrimSpace(m.SimnetFallbackTargetHost)
m.SimnetFallbackHostHeader = strings.TrimSpace(m.SimnetFallbackHostHeader)
m.SimnetFallbackTLSSNI = strings.TrimSpace(m.SimnetFallbackTLSSNI)
switch strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme)) {
case "http", "https":
m.SimnetFallbackTargetScheme = strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme))
default:
m.SimnetFallbackTargetScheme = "https"
}
}
if !m.SimnetAfEnabled {
m.SimnetAfPathMode = ""
m.SimnetAfMagicMode = ""
m.SimnetAfPathPrefix = ""
m.SimnetAfPathSuffix = ""
m.SimnetAfResponseJitterMs = 0
m.SimnetAfHandshakePolymorphism = false
m.SimnetAfSettingsJitter = false
m.SimnetAfFakeHeaderInjection = false
return
}
if m.SimnetAfPathMode == "" {
m.SimnetAfPathMode = "api"
}
if m.SimnetAfMagicMode == "" {
m.SimnetAfMagicMode = "derived"
}
if m.SimnetAfResponseJitterMs == 0 {
m.SimnetAfResponseJitterMs = 50
}
if !m.SimnetAfHandshakePolymorphism {
m.SimnetAfHandshakePolymorphism = true
}
if !m.SimnetAfSettingsJitter {
m.SimnetAfSettingsJitter = true
}
if !m.SimnetAfFakeHeaderInjection {
m.SimnetAfFakeHeaderInjection = true
}
}
func (m *Protocol) applySimnetResourceDefaults() {
if m.SimnetInboundMaxStreamsPerSession <= 0 {
m.SimnetInboundMaxStreamsPerSession = defaultSimnetInboundMaxStreamsPerSession
}
if m.SimnetInboundMaxUDPStreamsPerSession <= 0 {
m.SimnetInboundMaxUDPStreamsPerSession = defaultSimnetInboundMaxUDPStreamsPerSession
}
if m.SimnetInboundMaxHandlerTasksPerSession <= 0 {
m.SimnetInboundMaxHandlerTasksPerSession = defaultSimnetInboundMaxHandlerTasksPerSession
}
if m.SimnetStreamEventChannelCapacity <= 0 {
m.SimnetStreamEventChannelCapacity = defaultSimnetStreamEventChannelCapacity
}
if m.SimnetStreamDataChannelCapacity <= 0 {
m.SimnetStreamDataChannelCapacity = defaultSimnetStreamDataChannelCapacity
}
if m.SimnetTargetDialTimeoutMs <= 0 {
m.SimnetTargetDialTimeoutMs = defaultSimnetTargetDialTimeoutMs
}
if m.SimnetTargetMaxConcurrentDials <= 0 {
m.SimnetTargetMaxConcurrentDials = defaultSimnetTargetMaxConcurrentDials
}
if m.SimnetSendWindow <= 0 {
m.SimnetSendWindow = defaultSimnetSessionWindow
}
if m.SimnetRecvWindow <= 0 {
m.SimnetRecvWindow = defaultSimnetSessionWindow
}
if m.SimnetMaxConcurrentStreams <= 0 {
m.SimnetMaxConcurrentStreams = defaultSimnetMaxConcurrentStreams
}
if m.SimnetInitialWindowSize <= 0 {
m.SimnetInitialWindowSize = defaultSimnetInitialWindowSize
}
if m.SimnetMaxFrameSize <= 0 {
m.SimnetMaxFrameSize = defaultSimnetMaxFrameSize
}
if m.SimnetClientMaxConcurrentStreams <= 0 {
m.SimnetClientMaxConcurrentStreams = defaultSimnetClientMaxConcurrentStreams
}
if m.SimnetClientMaxStreamsPerSession <= 0 {
m.SimnetClientMaxStreamsPerSession = defaultSimnetClientMaxStreamsPerSession
}
if m.SimnetClientSessionIdleTimeoutSecs <= 0 {
m.SimnetClientSessionIdleTimeoutSecs = defaultSimnetClientSessionIdleTimeoutSecs
}
if m.SimnetClientMaxUDPSessions <= 0 {
m.SimnetClientMaxUDPSessions = defaultSimnetClientMaxUDPSessions
}
} }
// Marshal protocol to json // Marshal protocol to json
+59 -4
View File
@@ -261,10 +261,12 @@ type CreateAdminLotteryPrizeRequest struct {
IsFallback bool `json:"is_fallback"` 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 { type UpdateAdminLotteryPrizeRequest struct {
Id int64 `json:"id" validate:"required"` Id int64 `path:"id" json:"-" validate:"required"`
Slot *int `json:"slot,omitempty"` Slot *int `json:"slot,omitempty"`
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
IconUrl string `json:"icon_url,omitempty"` IconUrl string `json:"icon_url,omitempty"`
Config json.RawMessage `json:"config,omitempty"` Config json.RawMessage `json:"config,omitempty"`
@@ -283,9 +285,9 @@ type ListAdminLotteryPrizesResponse struct {
List []AdminLotteryPrize `json:"list"` 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 { 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 // GrantAdminLotteryChanceRequest gives a specified user N chances on an
@@ -387,3 +389,56 @@ type AdminLotteryClaimsStatusCount struct {
Reviewing int64 `json:"reviewing"` Reviewing int64 `json:"reviewing"`
Paying int64 `json:"paying"` 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
View File
@@ -2302,53 +2302,98 @@ type PromoUsage struct {
} }
type Protocol struct { type Protocol struct {
Type string `json:"type"` Type string `json:"type"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
Security string `json:"security,omitempty"` Security string `json:"security,omitempty"`
SNI string `json:"sni,omitempty"` SNI string `json:"sni,omitempty"`
AllowInsecure bool `json:"allow_insecure,omitempty"` AllowInsecure bool `json:"allow_insecure,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"` Fingerprint string `json:"fingerprint,omitempty"`
RealityServerAddr string `json:"reality_server_addr,omitempty"` RealityServerAddr string `json:"reality_server_addr,omitempty"`
RealityServerPort int `json:"reality_server_port,omitempty"` RealityServerPort int `json:"reality_server_port,omitempty"`
RealityPrivateKey string `json:"reality_private_key,omitempty"` RealityPrivateKey string `json:"reality_private_key,omitempty"`
RealityPublicKey string `json:"reality_public_key,omitempty"` RealityPublicKey string `json:"reality_public_key,omitempty"`
RealityShortId string `json:"reality_short_id,omitempty"` RealityShortId string `json:"reality_short_id,omitempty"`
Transport string `json:"transport,omitempty"` Transport string `json:"transport,omitempty"`
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
ServiceName string `json:"service_name,omitempty"` ServiceName string `json:"service_name,omitempty"`
Cipher string `json:"cipher,omitempty"` Cipher string `json:"cipher,omitempty"`
ServerKey string `json:"server_key,omitempty"` ServerKey string `json:"server_key,omitempty"`
Flow string `json:"flow,omitempty"` Flow string `json:"flow,omitempty"`
HopPorts string `json:"hop_ports,omitempty"` HopPorts string `json:"hop_ports,omitempty"`
HopInterval int `json:"hop_interval,omitempty"` HopInterval int `json:"hop_interval,omitempty"`
ObfsPassword string `json:"obfs_password,omitempty"` ObfsPassword string `json:"obfs_password,omitempty"`
DisableSNI bool `json:"disable_sni,omitempty"` DisableSNI bool `json:"disable_sni,omitempty"`
ReduceRtt bool `json:"reduce_rtt,omitempty"` ReduceRtt bool `json:"reduce_rtt,omitempty"`
UDPRelayMode string `json:"udp_relay_mode,omitempty"` UDPRelayMode string `json:"udp_relay_mode,omitempty"`
CongestionController string `json:"congestion_controller,omitempty"` CongestionController string `json:"congestion_controller,omitempty"`
Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high
PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme
UpMbps int `json:"up_mbps,omitempty"` // upload speed limit UpMbps int `json:"up_mbps,omitempty"` // upload speed limit
DownMbps int `json:"down_mbps,omitempty"` // download speed limit DownMbps int `json:"down_mbps,omitempty"` // download speed limit
Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls' Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls'
ObfsHost string `json:"obfs_host,omitempty"` // obfs host ObfsHost string `json:"obfs_host,omitempty"` // obfs host
ObfsPath string `json:"obfs_path,omitempty"` // obfs path ObfsPath string `json:"obfs_path,omitempty"` // obfs path
XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode
XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path
Encryption string `json:"encryption,omitempty"` // encryption'none', 'mlkem768x25519plus' Encryption string `json:"encryption,omitempty"` // encryption'none', 'mlkem768x25519plus'
EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode'native', 'xorpub', 'random' EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode'native', 'xorpub', 'random'
EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt'0rtt', '1rtt' EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt'0rtt', '1rtt'
EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket
EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding
EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key
EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding
EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password
Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1 Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self` CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none``http``dns``self`
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
SimnetPsk string `json:"simnet_psk,omitempty"`
SimnetKeyID int `json:"simnet_key_id,omitempty"`
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
SimnetPath string `json:"simnet_path,omitempty"`
SimnetCarrier string `json:"simnet_carrier,omitempty"`
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
} }
type PubilcRegisterConfig struct { type PubilcRegisterConfig struct {
+19 -4
View File
@@ -1,9 +1,21 @@
// Package result 定义 HTTP 响应统一 envelope。
//
// HIF-4 F9:所有响应必须是三字段固定 shape {code, msg, data}data 为空时
// 也要显式 `null` —— 而不是靠 omitempty 丢字段。App 端强类型 decoderRetrofit /
// serde / typed structs)依赖这个 shape,缺字段会解码失败。
//
// 修改前:
// - Success 的 Data 带 omitemptynil payload 时 JSON 里没有 data 键
// - Error 结构体没有 Data 字段,错误响应永远缺 data 键
// 修改后:Success 和 Error 都有 Data 字段,且都 **不带 omitempty**。空场景下 JSON
// 一律出现 "data":null。加字段对旧 client 无 breaking impactJSON 忽略未知字段/
// 已知字段变 null 都能 decode 过)。
package result package result
type ResponseSuccessBean struct { type ResponseSuccessBean struct {
Code uint32 `json:"code"` Code uint32 `json:"code"`
Msg string `json:"msg"` Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data"` // F9: 不带 omitempty,空 payload 也返 data:null
} }
type NullJson struct{} type NullJson struct{}
@@ -11,11 +23,14 @@ func Success(data interface{}) *ResponseSuccessBean {
return &ResponseSuccessBean{200, "success", data} return &ResponseSuccessBean{200, "success", data}
} }
// ResponseErrorBean 与 ResponseSuccessBean 结构对齐(都有 Data 字段),
// Data 在错误场景永远为 nil;序列化后 JSON 里显式为 "data":null。
type ResponseErrorBean struct { type ResponseErrorBean struct {
Code uint32 `json:"code"` Code uint32 `json:"code"`
Msg string `json:"msg"` Msg string `json:"msg"`
Data interface{} `json:"data"` // F9: 错误响应也必须有 data 字段(值永远为 null)
} }
func Error(errCode uint32, errMsg string) *ResponseErrorBean { func Error(errCode uint32, errMsg string) *ResponseErrorBean {
return &ResponseErrorBean{errCode, errMsg} return &ResponseErrorBean{errCode, errMsg, nil}
} }
+54
View File
@@ -0,0 +1,54 @@
// responseBean_test.go — HIF-4 F9 护栏:
// 所有 HTTP 响应必须是 {code, msg, data} 三字段固定 shapedata 为空时也要
// 显式 "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)
}
}
+112
View File
@@ -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
}