修复节点信息API
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,5 +5,4 @@ development/
|
||||
dist/
|
||||
frontend/admin/reverse/node_modules/
|
||||
log
|
||||
sqldes
|
||||
api.exe
|
||||
@@ -85,6 +85,15 @@ func FulfillSubscription(c *gin.Context, user *model.User) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if nodeLinks != "" {
|
||||
finalLinks = append(finalLinks, strings.Split(nodeLinks, "\n")...)
|
||||
@@ -128,6 +137,11 @@ func generateInfoNodes(user *model.User) []string {
|
||||
}
|
||||
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
|
||||
remaining := int64(user.TransferEnable) - (int64(user.U) + int64(user.D))
|
||||
if remaining < 0 {
|
||||
@@ -197,7 +211,14 @@ func ClientAppConfigV2(c *gin.Context) {
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"version": service.MustGetString("app_version", "1.0.0"),
|
||||
},
|
||||
"features": gin.H{
|
||||
"ui_config": gin.H{
|
||||
"app_primary_color": service.MustGetString("app_primary_color", "#000000"),
|
||||
"app_home_screen_style": service.MustGetString("app_home_screen_style", "default"),
|
||||
"app_server_list_style": service.MustGetString("app_server_list_style", "default"),
|
||||
"app_theme_mode": service.MustGetString("app_theme_mode", "light"),
|
||||
"app_show_traffic_chart": service.MustGetBool("app_show_traffic_chart", true),
|
||||
},
|
||||
"business_rules": gin.H{
|
||||
"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),
|
||||
@@ -206,6 +227,9 @@ func ClientAppConfigV2(c *gin.Context) {
|
||||
"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{
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
@@ -218,6 +242,10 @@ func ClientAppConfigV2(c *gin.Context) {
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_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)
|
||||
|
||||
@@ -17,7 +17,7 @@ func buildGuestConfig() gin.H {
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
"is_email_verify": boolToInt(service.MustGetBool("email_verify", 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)),
|
||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
|
||||
@@ -44,7 +44,20 @@ func GuestPlanFetch(c *gin.Context) {
|
||||
Fail(c, 500, "failed to fetch plans")
|
||||
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 {
|
||||
|
||||
@@ -26,7 +26,7 @@ func NodeUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"users": users})
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func NodeShadowsocksTidalabUser(c *gin.Context) {
|
||||
|
||||
@@ -89,9 +89,9 @@ func UserGetSubscribe(c *gin.Context) {
|
||||
"device_limit": user.DeviceLimit,
|
||||
"speed_limit": user.SpeedLimit,
|
||||
"next_reset_at": user.NextResetAt,
|
||||
"plan": user.Plan,
|
||||
"plan": service.FormatPlan(user.Plan),
|
||||
"subscribe_url": strings.TrimRight(baseURL, "/") + "/" + subscribePath + "/" + user.Token,
|
||||
"reset_day": nil,
|
||||
"reset_day": service.GetResetDay(user),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ var shadowsocks2022CipherConfigs = map[string]struct {
|
||||
type NodeUser struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
SpeedLimit *int `json:"speed_limit,omitempty"`
|
||||
DeviceLimit *int `json:"device_limit,omitempty"`
|
||||
SpeedLimit *int `json:"speed_limit"`
|
||||
DeviceLimit *int `json:"device_limit"`
|
||||
}
|
||||
|
||||
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("banned = ?", 0).
|
||||
Scan(&users).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []NodeUser{}, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
@@ -373,7 +374,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any {
|
||||
switch getMapString(settings, "cipher") {
|
||||
case "2022-blake3-aes-128-gcm":
|
||||
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)
|
||||
default:
|
||||
response["server_key"] = nil
|
||||
@@ -395,7 +396,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any {
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["flow"] = getMapAny(settings, "flow")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
response["decryption"] = "none"
|
||||
response["decryption"] = nil
|
||||
if encryption, ok := settings["encryption"].(map[string]any); ok {
|
||||
if enabled, ok := encryption["enabled"].(bool); ok && enabled {
|
||||
if dec := stringify(encryption["decryption"]); dec != "" {
|
||||
|
||||
125
internal/service/plan.go
Normal file
125
internal/service/plan.go
Normal 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, ",")
|
||||
}
|
||||
119
internal/service/traffic_reset.go
Normal file
119
internal/service/traffic_reset.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user