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: |
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 sqldes dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/sqldes
cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/
cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/
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.POST("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigSave)
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)
}

View File

@@ -330,6 +330,7 @@
}
if (action === "node-edit") {
event.preventDefault();
const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
state.modal = { type: "node", data: item || {} };
render();
@@ -337,6 +338,7 @@
}
if (action === "node-copy") {
event.preventDefault();
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, {
id: Number(actionEl.getAttribute("data-id"))
});
@@ -345,6 +347,7 @@
}
if (action === "node-delete") {
event.preventDefault();
if (confirm("确认删除该节点吗?")) {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, {
id: Number(actionEl.getAttribute("data-id"))
@@ -354,6 +357,32 @@
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") {
event.preventDefault();
const input = actionEl.querySelector("input");
@@ -963,7 +992,8 @@
renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)),
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}"`)
])
]);

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 (
"fmt"
"net/http"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
func AdminPortal(c *gin.Context) {
// Load settings for the portal
var appNameSetting model.Setting
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
appName := appNameSetting.Value
if appName == "" {
appName = "XBoard Admin"
}
// Load settings for the portal using service to ensure quote normalization
appName := service.MustGetString("app_name", "XBoard Admin")
securePath := c.Param("path")
if securePath == "" {

View File

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

View File

@@ -7,18 +7,14 @@ import (
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
// RealNameIndex renders the beautified plugin management page.
func RealNameIndex(c *gin.Context) {
var appNameSetting model.Setting
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
appName := appNameSetting.Value
if appName == "" {
appName = "XBoard"
}
appName := service.MustGetString("app_name", "XBoard")
securePath := c.Param("path")
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 {
return false
}
@@ -85,7 +85,9 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
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")
return false
}
@@ -114,8 +116,15 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.D = 0
ipv6User.T = 0
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
}
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) == "" {
return defaultValue
}
return value
return normalizeWrappedString(value)
}
func MustGetInt(name string, defaultValue int) int {