IPv6 订阅流程锁定
Some checks failed
build / build (api, amd64, linux) (push) Failing after -56s
build / build (api, arm64, linux) (push) Failing after -57s
build / build (api.exe, amd64, windows) (push) Failing after -57s

This commit is contained in:
CN-JS-HuiBai
2026-04-18 10:11:31 +08:00
parent 6e75b7d7d5
commit d237d80eec
12 changed files with 4808 additions and 281 deletions

View File

@@ -57,7 +57,6 @@ jobs:
run: | run: |
cp README.md dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/README.md cp README.md dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/README.md
cp .env.example dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/.env.example cp .env.example dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/.env.example
cp sqldes dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/sqldes
cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/ cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/
cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/ cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/
cp -r submodule/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/submodule/ cp -r submodule/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/submodule/

View File

@@ -295,6 +295,7 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin.GET("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigFetch) admin.GET("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigFetch)
admin.POST("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigSave) admin.POST("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigSave)
admin.POST("/user-add-ipv6-subscription/enable/:userId", handler.AdminIPv6SubscriptionEnable) admin.POST("/user-add-ipv6-subscription/enable/:userId", handler.AdminIPv6SubscriptionEnable)
admin.POST("/user-add-ipv6-subscription/disable/:userId", handler.AdminIPv6SubscriptionDisable)
admin.POST("/user-add-ipv6-subscription/sync-password/:userId", handler.AdminIPv6SubscriptionSyncPassword) admin.POST("/user-add-ipv6-subscription/sync-password/:userId", handler.AdminIPv6SubscriptionSyncPassword)
} }

View File

@@ -330,6 +330,7 @@
} }
if (action === "node-edit") { if (action === "node-edit") {
event.preventDefault();
const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id")); const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
state.modal = { type: "node", data: item || {} }; state.modal = { type: "node", data: item || {} };
render(); render();
@@ -337,6 +338,7 @@
} }
if (action === "node-copy") { if (action === "node-copy") {
event.preventDefault();
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { await adminPost(`${cfg.api.adminBase}/server/manage/copy`, {
id: Number(actionEl.getAttribute("data-id")) id: Number(actionEl.getAttribute("data-id"))
}); });
@@ -345,6 +347,7 @@
} }
if (action === "node-delete") { if (action === "node-delete") {
event.preventDefault();
if (confirm("确认删除该节点吗?")) { if (confirm("确认删除该节点吗?")) {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { await adminPost(`${cfg.api.adminBase}/server/manage/drop`, {
id: Number(actionEl.getAttribute("data-id")) id: Number(actionEl.getAttribute("data-id"))
@@ -354,6 +357,32 @@
return; return;
} }
if (action === "ipv6-enable") {
const id = actionEl.getAttribute("data-user-id");
// Use prompt with plan selection for simplicity in this minimal framework
const planNames = state.plans.map(p => `${p.id}: ${p.name}`).join("\n");
const planID = prompt(`请输入要分配给该用户的 IPv6 套餐 ID:\n\n${planNames}`, "");
if (planID === null) return;
await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/enable/${id}`, { plan_id: Number(planID) || 0 });
await hydrateRoute();
return;
}
if (action === "ipv6-disable") {
const id = actionEl.getAttribute("data-user-id");
if (confirm("确认要关闭该用户的 IPv6 订阅吗?(将封禁从属账号)")) {
await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/disable/${id}`, {});
await hydrateRoute();
}
return;
}
if (action === "ipv6-sync-password") {
await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/sync-password/${actionEl.getAttribute("data-user-id")}`, {});
return;
}
if (action === "node-toggle-visible") { if (action === "node-toggle-visible") {
event.preventDefault(); event.preventDefault();
const input = actionEl.querySelector("input"); const input = actionEl.querySelector("input");
@@ -963,7 +992,8 @@
renderStatus(item.status_label || item.status || "-"), renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)), escapeHtml(formatDate(item.updated_at)),
renderActionRow([ renderActionRow([
buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`), buttonAction("开通/更新", "ipv6-enable", null, `data-user-id="${item.id}"`),
buttonAction("关闭/封禁", "ipv6-disable", null, `data-user-id="${item.id}"`),
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`) buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`)
]) ])
]); ]);

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,14 @@ package handler
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"xboard-go/internal/database" "xboard-go/internal/service"
"xboard-go/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func AdminPortal(c *gin.Context) { func AdminPortal(c *gin.Context) {
// Load settings for the portal // Load settings for the portal using service to ensure quote normalization
var appNameSetting model.Setting appName := service.MustGetString("app_name", "XBoard Admin")
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
appName := appNameSetting.Value
if appName == "" {
appName = "XBoard Admin"
}
securePath := c.Param("path") securePath := c.Param("path")
if securePath == "" { if securePath == "" {

View File

@@ -221,30 +221,38 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
UserID: user.ID, UserID: user.ID,
ShadowUserID: &shadowUser.ID, ShadowUserID: &shadowUser.ID,
IPv6Email: shadowUser.Email, IPv6Email: shadowUser.Email,
Allowed: !user.Banned, Allowed: !user.Banned && shadowUser.Banned == 0,
Status: "active", Status: "active",
UpdatedAt: shadowUser.UpdatedAt, UpdatedAt: shadowUser.UpdatedAt,
} }
hasSubscription = true hasSubscription = true
} }
allowed := service.PluginUserAllowed(&user, user.Plan) allowed := service.PluginUserAllowed(&user, user.Plan)
status := "not_allowed"
planID := shadowPlanID planID := shadowPlanID
planNameValue := planNames[shadowPlanID] planNameValue := planNames[shadowPlanID]
if hasShadowUser { if hasShadowUser {
planID = intFromPointer(shadowUser.PlanID) planID = intFromPointer(shadowUser.PlanID)
planNameValue = planName(shadowUser.Plan) planNameValue = planNames[planID]
if planNameValue == "" && shadowUser.Plan != nil {
planNameValue = shadowUser.Plan.Name
}
} }
shadowUserID := 0 shadowUserID := 0
shadowUpdatedAt := int64(0) shadowUpdatedAt := int64(0)
status := "not_allowed"
if hasSubscription { if hasSubscription {
status = firstString(subscription.Status, status) status = firstString(subscription.Status, "eligible")
if subscription.ShadowUserID != nil { if subscription.ShadowUserID != nil {
shadowUserID = *subscription.ShadowUserID shadowUserID = *subscription.ShadowUserID
} }
shadowUpdatedAt = subscription.UpdatedAt shadowUpdatedAt = subscription.UpdatedAt
if hasShadowUser && shadowUser.Banned != 0 {
status = "banned"
}
} }
effectiveAllowed := allowed || hasSubscription && subscription.Allowed
effectiveAllowed := allowed || (hasSubscription && subscription.Allowed)
status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed) status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed)
list = append(list, gin.H{ list = append(list, gin.H{
@@ -253,7 +261,7 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
"plan_id": planID, "plan_id": planID,
"plan_name": firstString(planNameValue, "-"), "plan_name": firstString(planNameValue, "-"),
"allowed": effectiveAllowed, "allowed": effectiveAllowed,
"is_active": hasSubscription && subscription.Status == "active", "is_active": hasSubscription && status == "active",
"status": status, "status": status,
"status_label": statusLabel, "status_label": statusLabel,
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)), "ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
@@ -346,6 +354,11 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) {
return return
} }
var payload struct {
PlanID int `json:"plan_id"`
}
_ = c.ShouldBindJSON(&payload)
var user model.User var user model.User
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil { if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
Fail(c, 404, "user not found") Fail(c, 404, "user not found")
@@ -356,14 +369,40 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) {
return return
} }
if !service.SyncIPv6ShadowAccount(&user) { if !service.SyncIPv6ShadowAccount(&user, payload.PlanID) {
Fail(c, 403, "user plan does not support ipv6 subscription") Fail(c, 403, "failed to enable/sync ipv6 subscription")
return return
} }
SuccessMessage(c, "IPv6 subscription enabled/synced", true) SuccessMessage(c, "IPv6 subscription enabled/synced", true)
} }
func AdminIPv6SubscriptionDisable(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
return
}
// Find shadow user
var shadowUser model.User
if err := database.DB.Where("parent_id = ?", userID).First(&shadowUser).Error; err != nil {
Fail(c, 404, "IPv6 shadow user not found")
return
}
// Just ban it as per user requirement
if err := database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("banned", 1).Error; err != nil {
Fail(c, 500, "failed to ban shadow user")
return
}
// Update record
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("user_id = ?", userID).Update("status", "banned")
SuccessMessage(c, "IPv6 subscription disabled (banned)", true)
}
func AdminIPv6SubscriptionSyncPassword(c *gin.Context) { func AdminIPv6SubscriptionSyncPassword(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0) userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 { if userID == 0 {
@@ -459,7 +498,6 @@ func PluginUserAddIPv6Check(c *gin.Context) {
"status": status, "status": status,
"status_label": statusLabel, "status_label": statusLabel,
"reason": reason, "reason": reason,
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
}) })
} }
@@ -471,10 +509,14 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
return return
} }
if !service.SyncIPv6ShadowAccount(user) { Fail(c, 403, "IPv6 self-service is disabled. Please contact administrator via ticket to enable it.")
Fail(c, 403, "your plan does not support IPv6 subscription") return
return /*
} if !service.SyncIPv6ShadowAccount(user, 0) {
Fail(c, 403, "your plan does not support IPv6 subscription")
return
}
*/
payload := gin.H{ payload := gin.H{
"ipv6_email": service.IPv6ShadowEmail(user.Email), "ipv6_email": service.IPv6ShadowEmail(user.Email),

View File

@@ -7,18 +7,14 @@ import (
"time" "time"
"xboard-go/internal/database" "xboard-go/internal/database"
"xboard-go/internal/model" "xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// RealNameIndex renders the beautified plugin management page. // RealNameIndex renders the beautified plugin management page.
func RealNameIndex(c *gin.Context) { func RealNameIndex(c *gin.Context) {
var appNameSetting model.Setting appName := service.MustGetString("app_name", "XBoard")
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
appName := appNameSetting.Value
if appName == "" {
appName = "XBoard"
}
securePath := c.Param("path") securePath := c.Param("path")
apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath) apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath)

View File

@@ -73,7 +73,7 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
} }
} }
func SyncIPv6ShadowAccount(user *model.User) bool { func SyncIPv6ShadowAccount(user *model.User, forcedPlanID int) bool {
if user == nil { if user == nil {
return false return false
} }
@@ -85,7 +85,9 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
plan = &loadedPlan plan = &loadedPlan
} }
} }
if !PluginUserAllowed(user, plan) {
// Bypass regular eligibility check if forced by admin
if forcedPlanID == 0 && !PluginUserAllowed(user, plan) {
syncIPv6SubscriptionRecord(user, nil, false, "not_allowed") syncIPv6SubscriptionRecord(user, nil, false, "not_allowed")
return false return false
} }
@@ -114,8 +116,15 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.D = 0 ipv6User.D = 0
ipv6User.T = 0 ipv6User.T = 0
ipv6User.UpdatedAt = now ipv6User.UpdatedAt = now
ipv6User.Banned = 0 // Ensure unbanned when syncing/enabling
if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 { // Use forced plan or default plugin plan
planID := forcedPlanID
if planID <= 0 {
planID = parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0)
}
if planID > 0 {
ipv6User.PlanID = &planID ipv6User.PlanID = &planID
} }
if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 { if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 {

View File

@@ -37,7 +37,7 @@ func MustGetString(name, defaultValue string) string {
if !ok || strings.TrimSpace(value) == "" { if !ok || strings.TrimSpace(value) == "" {
return defaultValue return defaultValue
} }
return value return normalizeWrappedString(value)
} }
func MustGetInt(name string, defaultValue int) int { func MustGetInt(name string, defaultValue int) int {