修复节点信息API
Some checks failed
build / build (api, amd64, linux) (push) Failing after -57s
build / build (api, arm64, linux) (push) Failing after -56s
build / build (api.exe, amd64, windows) (push) Failing after -57s

This commit is contained in:
CN-JS-HuiBai
2026-04-18 01:59:24 +08:00
parent 4245bee1d1
commit 60c3082950
8 changed files with 305 additions and 20 deletions

1
.gitignore vendored
View File

@@ -5,5 +5,4 @@ development/
dist/ dist/
frontend/admin/reverse/node_modules/ frontend/admin/reverse/node_modules/
log log
sqldes
api.exe api.exe

View File

@@ -85,6 +85,15 @@ func FulfillSubscription(c *gin.Context, user *model.User) {
} }
// 3b. Add Actual Node Links // 3b. Add Actual Node Links
if service.MustGetBool("subscribe_protocol_prefix_enable", true) {
for i := range servers {
prefix := strings.ToUpper(servers[i].Type)
if !strings.HasPrefix(servers[i].Name, "["+prefix+"]") {
servers[i].Name = "[" + prefix + "] " + servers[i].Name
}
}
}
nodeLinks := protocol.GenerateGeneralLinks(servers, *user) nodeLinks := protocol.GenerateGeneralLinks(servers, *user)
if nodeLinks != "" { if nodeLinks != "" {
finalLinks = append(finalLinks, strings.Split(nodeLinks, "\n")...) finalLinks = append(finalLinks, strings.Split(nodeLinks, "\n")...)
@@ -128,6 +137,11 @@ func generateInfoNodes(user *model.User) []string {
} }
nodes = append(nodes, buildInfoLink("套餐到期:"+expireDate)) nodes = append(nodes, buildInfoLink("套餐到期:"+expireDate))
// Reset Day Info
if days := service.GetResetDay(user); days != nil {
nodes = append(nodes, buildInfoLink(fmt.Sprintf("距离重置: %d 天", *days)))
}
// Traffic Info // Traffic Info
remaining := int64(user.TransferEnable) - (int64(user.U) + int64(user.D)) remaining := int64(user.TransferEnable) - (int64(user.U) + int64(user.D))
if remaining < 0 { if remaining < 0 {
@@ -197,15 +211,25 @@ func ClientAppConfigV2(c *gin.Context) {
"logo": service.MustGetString("logo", ""), "logo": service.MustGetString("logo", ""),
"version": service.MustGetString("app_version", "1.0.0"), "version": service.MustGetString("app_version", "1.0.0"),
}, },
"features": gin.H{ "ui_config": gin.H{
"enable_register": service.MustGetBool("app_enable_register", true), "app_primary_color": service.MustGetString("app_primary_color", "#000000"),
"enable_invite_system": service.MustGetBool("app_enable_invite_system", true), "app_home_screen_style": service.MustGetString("app_home_screen_style", "default"),
"enable_telegram_bot": service.MustGetBool("telegram_bot_enable", false), "app_server_list_style": service.MustGetString("app_server_list_style", "default"),
"enable_ticket_system": service.MustGetBool("app_enable_ticket_system", true), "app_theme_mode": service.MustGetString("app_theme_mode", "light"),
"enable_commission_system": service.MustGetBool("app_enable_commission_system", true), "app_show_traffic_chart": service.MustGetBool("app_show_traffic_chart", true),
"enable_traffic_log": service.MustGetBool("app_enable_traffic_log", true), },
"enable_knowledge_base": service.MustGetBool("app_enable_knowledge_base", true), "business_rules": gin.H{
"enable_announcements": service.MustGetBool("app_enable_announcements", true), "enable_register": service.MustGetBool("app_enable_register", true),
"enable_invite_system": service.MustGetBool("app_enable_invite_system", true),
"enable_telegram_bot": service.MustGetBool("telegram_bot_enable", false),
"enable_ticket_system": service.MustGetBool("app_enable_ticket_system", true),
"enable_commission_system": service.MustGetBool("app_enable_commission_system", true),
"enable_traffic_log": service.MustGetBool("app_enable_traffic_log", true),
"enable_knowledge_base": service.MustGetBool("app_enable_knowledge_base", true),
"enable_announcements": service.MustGetBool("app_enable_announcements", true),
"enable_speed_test": service.MustGetBool("app_enable_speed_test", true),
"enable_global_search": service.MustGetBool("app_enable_global_search", true),
"enable_real_name_verification": service.MustGetBool("app_enable_real_name_verification", false),
}, },
"security_config": gin.H{ "security_config": gin.H{
"tos_url": service.MustGetString("tos_url", ""), "tos_url": service.MustGetString("tos_url", ""),
@@ -218,6 +242,10 @@ func ClientAppConfigV2(c *gin.Context) {
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""), "recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""), "turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
}, },
"server_config": gin.H{
"default_kernel": service.MustGetString("default_kernel", "sing-box"),
"default_protocol": service.MustGetString("default_protocol", "vless"),
},
} }
Success(c, config) Success(c, config)

View File

@@ -17,7 +17,7 @@ func buildGuestConfig() gin.H {
"tos_url": service.MustGetString("tos_url", ""), "tos_url": service.MustGetString("tos_url", ""),
"is_email_verify": boolToInt(service.MustGetBool("email_verify", false)), "is_email_verify": boolToInt(service.MustGetBool("email_verify", false)),
"is_invite_force": boolToInt(service.MustGetBool("invite_force", false)), "is_invite_force": boolToInt(service.MustGetBool("invite_force", false)),
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""), "email_whitelist_suffix": getEmailWhitelistSuffix(),
"is_captcha": boolToInt(service.MustGetBool("captcha_enable", false)), "is_captcha": boolToInt(service.MustGetBool("captcha_enable", false)),
"captcha_type": service.MustGetString("captcha_type", "recaptcha"), "captcha_type": service.MustGetString("captcha_type", "recaptcha"),
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""), "recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
@@ -44,7 +44,20 @@ func GuestPlanFetch(c *gin.Context) {
Fail(c, 500, "failed to fetch plans") Fail(c, 500, "failed to fetch plans")
return return
} }
Success(c, plans)
formatted := make([]gin.H, 0, len(plans))
for i := range plans {
formatted = append(formatted, service.FormatPlan(&plans[i]))
}
Success(c, formatted)
}
func getEmailWhitelistSuffix() any {
if !service.MustGetBool("email_whitelist_enable", false) {
return 0
}
return service.MustGetString("email_whitelist_suffix", "")
} }
func boolToInt(value bool) int { func boolToInt(value bool) int {

View File

@@ -26,7 +26,7 @@ func NodeUser(c *gin.Context) {
return return
} }
Success(c, gin.H{"users": users}) c.JSON(http.StatusOK, gin.H{"users": users})
} }
func NodeShadowsocksTidalabUser(c *gin.Context) { func NodeShadowsocksTidalabUser(c *gin.Context) {

View File

@@ -89,9 +89,9 @@ func UserGetSubscribe(c *gin.Context) {
"device_limit": user.DeviceLimit, "device_limit": user.DeviceLimit,
"speed_limit": user.SpeedLimit, "speed_limit": user.SpeedLimit,
"next_reset_at": user.NextResetAt, "next_reset_at": user.NextResetAt,
"plan": user.Plan, "plan": service.FormatPlan(user.Plan),
"subscribe_url": strings.TrimRight(baseURL, "/") + "/" + subscribePath + "/" + user.Token, "subscribe_url": strings.TrimRight(baseURL, "/") + "/" + subscribePath + "/" + user.Token,
"reset_day": nil, "reset_day": service.GetResetDay(user),
}) })
} }

View File

@@ -56,8 +56,8 @@ var shadowsocks2022CipherConfigs = map[string]struct {
type NodeUser struct { type NodeUser struct {
ID int `json:"id"` ID int `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
SpeedLimit *int `json:"speed_limit,omitempty"` SpeedLimit *int `json:"speed_limit"`
DeviceLimit *int `json:"device_limit,omitempty"` DeviceLimit *int `json:"device_limit"`
} }
type NodeBaseConfig struct { type NodeBaseConfig struct {
@@ -158,8 +158,9 @@ func AvailableUsersForNode(node *model.Server) ([]NodeUser, error) {
Where("(expired_at >= ? OR expired_at IS NULL)", time.Now().Unix()). Where("(expired_at >= ? OR expired_at IS NULL)", time.Now().Unix()).
Where("banned = ?", 0). Where("banned = ?", 0).
Scan(&users).Error Scan(&users).Error
if err != nil { if err != nil {
return nil, err return []NodeUser{}, err
} }
return users, nil return users, nil
@@ -373,7 +374,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any {
switch getMapString(settings, "cipher") { switch getMapString(settings, "cipher") {
case "2022-blake3-aes-128-gcm": case "2022-blake3-aes-128-gcm":
response["server_key"] = serverKey(node.CreatedAt, 16) response["server_key"] = serverKey(node.CreatedAt, 16)
case "2022-blake3-aes-256-gcm": case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
response["server_key"] = serverKey(node.CreatedAt, 32) response["server_key"] = serverKey(node.CreatedAt, 32)
default: default:
response["server_key"] = nil response["server_key"] = nil
@@ -395,7 +396,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any {
response["tls"] = getMapInt(settings, "tls") response["tls"] = getMapInt(settings, "tls")
response["flow"] = getMapAny(settings, "flow") response["flow"] = getMapAny(settings, "flow")
response["multiplex"] = getMapAny(settings, "multiplex") response["multiplex"] = getMapAny(settings, "multiplex")
response["decryption"] = "none" response["decryption"] = nil
if encryption, ok := settings["encryption"].(map[string]any); ok { if encryption, ok := settings["encryption"].(map[string]any); ok {
if enabled, ok := encryption["enabled"].(bool); ok && enabled { if enabled, ok := encryption["enabled"].(bool); ok && enabled {
if dec := stringify(encryption["decryption"]); dec != "" { if dec := stringify(encryption["decryption"]); dec != "" {

125
internal/service/plan.go Normal file
View File

@@ -0,0 +1,125 @@
package service
import (
"encoding/json"
"strconv"
"strings"
"xboard-go/internal/model"
"github.com/gin-gonic/gin"
)
var legacyPeriodMapping = map[string]string{
"month_price": "monthly",
"quarter_price": "quarterly",
"half_year_price": "half_yearly",
"year_price": "yearly",
"two_year_price": "two_yearly",
"three_year_price": "three_yearly",
"onetime_price": "onetime",
"reset_price": "reset_traffic",
}
func FormatPlan(plan *model.Plan) gin.H {
if plan == nil {
return nil
}
res := gin.H{
"id": plan.ID,
"group_id": plan.GroupID,
"name": plan.Name,
"tags": parseTags(plan.Tags),
"transfer_enable": plan.TransferEnable,
"speed_limit": plan.SpeedLimit,
"device_limit": plan.DeviceLimit,
"show": plan.Show,
"sell": plan.Sell,
"renew": plan.Renew,
"reset_traffic_method": plan.ResetTrafficMethod,
"sort": plan.Sort,
"created_at": plan.CreatedAt,
"updated_at": plan.UpdatedAt,
"capacity_limit": plan.CapacityLimit,
}
// 1. Format Pricing (Legacy Keys + 100x Multiplier)
var prices map[string]float64
if plan.Prices != nil && *plan.Prices != "" {
_ = json.Unmarshal([]byte(*plan.Prices), &prices)
}
for legacyKey, newPeriod := range legacyPeriodMapping {
if price, ok := prices[newPeriod]; ok {
res[legacyKey] = int64(price * 100)
} else {
res[legacyKey] = nil
}
}
// 2. Format Content (Placeholders)
content := ""
if plan.Content != nil {
content = *plan.Content
}
res["content"] = formatPlanContent(content, plan)
return res
}
func formatPlanContent(content string, plan *model.Plan) string {
if content == "" {
return ""
}
replacements := map[string]string{
"{{transfer}}": formatIntPtr(plan.TransferEnable),
"{{speed}}": formatIntPtr(plan.SpeedLimit),
"{{devices}}": formatIntPtr(plan.DeviceLimit),
"{{reset_method}}": getResetMethodText(plan.ResetTrafficMethod),
}
for k, v := range replacements {
content = strings.ReplaceAll(content, k, v)
}
return content
}
func formatIntPtr(val *int) string {
if val == nil {
return "无限制" // Localized or default
}
return strings.TrimSpace(strconv.Itoa(*val))
}
func getResetMethodText(method *int) string {
if method == nil {
return "每月重置"
}
switch *method {
case 0:
return "每月1号"
case 1:
return "按月重置"
case 2:
return "不重置"
case 3:
return "每年1月1日"
case 4:
return "按年重置"
default:
return "按月重置"
}
}
func parseTags(tags *string) []string {
if tags == nil || *tags == "" {
return []string{}
}
var res []string
if err := json.Unmarshal([]byte(*tags), &res); err == nil {
return res
}
return strings.Split(*tags, ",")
}

View File

@@ -0,0 +1,119 @@
package service
import (
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
)
const (
ResetTrafficFirstDayMonth = 0
ResetTrafficMonthly = 1
ResetTrafficNever = 2
ResetTrafficFirstDayYear = 3
ResetTrafficYearly = 4
)
func GetResetDay(user *model.User) *int {
nextResetTime := CalculateNextResetTime(user)
if nextResetTime == nil {
return nil
}
now := time.Now()
if nextResetTime.Before(now) {
zero := 0
return &zero
}
days := int(nextResetTime.Sub(now).Hours()/24) + 1
return &days
}
func CalculateNextResetTime(user *model.User) *time.Time {
var plan model.Plan
if user.PlanID == nil {
return nil
}
// We need to fetch the plan's reset method.
// For simplicity in this service, we assume the caller has access or we fetch it here.
// However, usually it's better to pass the plan or the reset method.
// Let's implement it carefully.
// If the user has no plan or reset method is never, return nil.
// Since we don't have the plan object here, we'll need to fetch it if not provided.
// For now, let's look at how Xboard does it (it uses $user->plan).
// Wait, I should check if I can get the plan easily.
var resetMethod int
if err := database.DB.Model(&model.Plan{}).Select("reset_traffic_method").Where("id = ?", *user.PlanID).Scan(&resetMethod).Error; err != nil {
return nil
}
if resetMethod == ResetTrafficNever || user.ExpiredAt == nil {
return nil
}
now := time.Now()
expiredAt := time.Unix(*user.ExpiredAt, 0)
switch resetMethod {
case ResetTrafficFirstDayMonth:
return getNextMonthFirstDay(now)
case ResetTrafficMonthly:
return getNextMonthlyReset(user, now, expiredAt)
case ResetTrafficFirstDayYear:
return getNextYearFirstDay(now)
case ResetTrafficYearly:
return getNextYearlyReset(user, now, expiredAt)
default:
return nil
}
}
func getNextMonthFirstDay(from time.Time) *time.Time {
t := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()).AddDate(0, 1, 0)
return &t
}
func getNextMonthlyReset(user *model.User, from time.Time, expiredAt time.Time) *time.Time {
resetDay := expiredAt.Day()
// Try current month
target := time.Date(from.Year(), from.Month(), resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location())
if target.After(from) {
return &target
}
// Try next month
target = target.AddDate(0, 1, 0)
// Handle cases where next month doesn't have that day (e.g. Feb 30)
if target.Day() != resetDay {
// Go back to last day of intended month
target = time.Date(target.Year(), target.Month(), 0, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location())
}
return &target
}
func getNextYearFirstDay(from time.Time) *time.Time {
t := time.Date(from.Year()+1, 1, 1, 0, 0, 0, 0, from.Location())
return &t
}
func getNextYearlyReset(user *model.User, from time.Time, expiredAt time.Time) *time.Time {
resetMonth := expiredAt.Month()
resetDay := expiredAt.Day()
// Try current year
target := time.Date(from.Year(), resetMonth, resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location())
if target.After(from) {
return &target
}
// Try next year
target = time.Date(from.Year()+1, resetMonth, resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location())
return &target
}