修复节点无法编辑的错误
Some checks failed
build / build (api, amd64, linux) (push) Failing after -50s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -51s

This commit is contained in:
CN-JS-HuiBai
2026-04-18 10:31:31 +08:00
parent 6e75b7d7d5
commit 98379b21f4
6 changed files with 275 additions and 130 deletions

View File

@@ -139,8 +139,7 @@ func registerUserRoutes(v1 *gin.RouterGroup) {
user.GET("/real-name-verification/status", handler.PluginRealNameStatus) user.GET("/real-name-verification/status", handler.PluginRealNameStatus)
user.POST("/real-name-verification/submit", handler.PluginRealNameSubmit) user.POST("/real-name-verification/submit", handler.PluginRealNameSubmit)
user.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP) user.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP)
user.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable) // User IPv6 subscription - read-only status check (enable/disable is admin-only)
user.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword)
user.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check) user.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check)
} }
@@ -295,6 +294,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

@@ -173,8 +173,12 @@
const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`); const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`);
state.devices = normalizeListPayload(payload); state.devices = normalizeListPayload(payload);
} else if (state.route === "user-ipv6-subscription") { } else if (state.route === "user-ipv6-subscription") {
const payload = await request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`); const [payload, plans] = await Promise.all([
request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`),
request(cfg.api.plans)
]);
state.ipv6 = normalizeListPayload(payload); state.ipv6 = normalizeListPayload(payload);
state.plans = toArray(unwrap(plans));
} else if (state.route === "system-config") { } else if (state.route === "system-config") {
state.config = unwrap(await request(cfg.api.adminConfig)); state.config = unwrap(await request(cfg.api.adminConfig));
} }
@@ -337,18 +341,20 @@
} }
if (action === "node-copy") { if (action === "node-copy") {
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { const nodeId = Number(actionEl.getAttribute("data-id"));
id: Number(actionEl.getAttribute("data-id")) try {
}); await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { id: nodeId });
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute(); await hydrateRoute();
return; return;
} }
if (action === "node-delete") { if (action === "node-delete") {
if (confirm("确认删除该节点吗?")) { if (confirm("确认删除该节点吗?")) {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { const nodeId = Number(actionEl.getAttribute("data-id"));
id: Number(actionEl.getAttribute("data-id")) try {
}); await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { id: nodeId });
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute(); await hydrateRoute();
} }
return; return;
@@ -356,15 +362,20 @@
if (action === "node-toggle-visible") { if (action === "node-toggle-visible") {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
const nodeId = Number(actionEl.getAttribute("data-id"));
const input = actionEl.querySelector("input"); const input = actionEl.querySelector("input");
if (!input) { if (!input) {
return; return;
} }
input.checked = !input.checked; const newShow = !input.checked;
await adminPost(`${cfg.api.adminBase}/server/manage/update`, { input.checked = newShow;
id: Number(actionEl.getAttribute("data-id")), try {
show: input.checked ? 1 : 0 await adminPost(`${cfg.api.adminBase}/server/manage/update`, {
}); id: nodeId,
show: newShow ? 1 : 0
});
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute(); await hydrateRoute();
return; return;
} }
@@ -436,10 +447,20 @@
return; return;
} }
if (action === "ipv6-enable") { if (action === "ipv6-enable-modal") {
const userId = actionEl.getAttribute("data-user-id"); const userId = actionEl.getAttribute("data-user-id");
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, {}); const userEmail = actionEl.getAttribute("data-email") || "";
await hydrateRoute(); state.modal = { type: "ipv6-enable", data: { userId, email: userEmail } };
render();
return;
}
if (action === "ipv6-disable") {
const userId = actionEl.getAttribute("data-user-id");
if (confirm("确认关闭该用户的 IPv6 子账号?(软禁用,可恢复)")) {
await adminPost(`${cfg.api.ipv6Base}/disable/${userId}`, {});
await hydrateRoute();
}
return; return;
} }
@@ -505,6 +526,14 @@
} }
if (!target) { if (!target) {
if (action === "ipv6-enable-save") {
const userId = body.user_id;
const planId = Number(body.plan_id) || 0;
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, { plan_id: planId });
state.modal = null;
await hydrateRoute();
return;
}
return; return;
} }
@@ -953,23 +982,34 @@
} }
function renderIPv6Manage() { function renderIPv6Manage() {
const rows = state.ipv6.list.map((item) => [ const rows = state.ipv6.list.map((item) => {
renderRelationChip(item.id, item.shadow_user_id || "-"), const isActive = item.is_active || item.status === "active";
[ const isDisabled = item.status === "disabled";
`<strong>${escapeHtml(item.email || "-")}</strong>`, const actions = [];
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>` if (isActive) {
].join(""), actions.push(buttonAction("关闭", "ipv6-disable", null, `data-user-id="${item.id}"`));
escapeHtml(item.plan_name || "-"), actions.push(buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`));
renderStatus(item.status_label || item.status || "-"), } else {
escapeHtml(formatDate(item.updated_at)), actions.push(buttonAction("开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
renderActionRow([ }
buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`), if (isDisabled) {
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`) actions.unshift(buttonAction("重新开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
]) }
]); return [
`<code>${item.id}</code>`,
[
`<strong>${escapeHtml(item.email || "-")}</strong>`,
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>`
].join(""),
escapeHtml(item.plan_name || "-"),
renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)),
renderActionRow(actions)
];
});
return [ return [
wrapTable(["主从关系", "账号", "套餐", "状态", "更新时间", "操作"], rows), wrapTable(["用户ID", "账号", "IPv6 套餐", "状态", "更新时间", "操作"], rows),
renderPagination(state.ipv6.pagination) renderPagination(state.ipv6.pagination)
].join(""); ].join("");
} }
@@ -1034,7 +1074,9 @@
? renderGroupForm(modal.data) ? renderGroupForm(modal.data)
: modal.type === "route" : modal.type === "route"
? renderRouteForm(modal.data) ? renderRouteForm(modal.data)
: ""; : modal.type === "ipv6-enable"
? renderIPv6EnableForm(modal.data)
: "";
return [ return [
'<div class="modal-card">', '<div class="modal-card">',
@@ -1276,6 +1318,18 @@
return `<div class="toolbar toolbar-spaced"><button class="btn btn-primary" type="submit">${escapeHtml(label)}</button></div>`; return `<div class="toolbar toolbar-spaced"><button class="btn btn-primary" type="submit">${escapeHtml(label)}</button></div>`;
} }
function renderIPv6EnableForm(data) {
return [
`<h2>开通 IPv6 子账号</h2>`,
`<p>用户: <strong>${escapeHtml(data.email || data.userId)}</strong></p>`,
'<form data-form="ipv6-enable-save">',
hiddenField("user_id", data.userId),
selectField("IPv6 套餐", "plan_id", state.plans, 0, true),
submitRow("确认开通"),
"</form>"
].join("");
}
function setBusy(value) { function setBusy(value) {
state.busy = value; state.busy = value;
render(); render();
@@ -1293,9 +1347,14 @@
} }
async function adminPost(url, body) { async function adminPost(url, body) {
const response = await request(url, { method: "POST", body }); try {
show("操作成功", "success"); const response = await request(url, { method: "POST", body });
return response; show("操作成功", "success");
return response;
} catch (error) {
show(error.message || "操作失败", "error");
throw error;
}
} }
async function request(url, options) { async function request(url, options) {
@@ -1382,7 +1441,7 @@
return; return;
} }
if (["id", "group_id", "transfer_enable", "speed_limit", "balance", "plan_id", "device_limit", "server_port", "value"].includes(key)) { if (["id", "group_id", "transfer_enable", "speed_limit", "balance", "plan_id", "device_limit", "server_port", "value", "user_id"].includes(key)) {
values[key] = rawValue === "" ? null : Number(rawValue); values[key] = rawValue === "" ? null : Number(rawValue);
return; return;
} }
@@ -1492,6 +1551,8 @@
type = "ok"; type = "ok";
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) { } else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
type = "warn"; type = "warn";
} else if (/(disabled)/.test(normalized)) {
type = "warn";
} }
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`; return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;
} }

View File

@@ -761,10 +761,9 @@
const list = payload.list || []; const list = payload.list || [];
document.getElementById("thead").innerHTML = ` document.getElementById("thead").innerHTML = `
<tr> <tr>
<th>主从关系</th> <th>用户 ID</th>
<th>主账号</th> <th>主账号</th>
<th>IPv6 账号</th> <th>IPv6 套餐</th>
<th>套餐</th>
<th>状态</th> <th>状态</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
@@ -772,26 +771,110 @@
document.getElementById("tbody").innerHTML = list.length document.getElementById("tbody").innerHTML = list.length
? list ? list
.map( .map(
(item) => ` (item) => {
const isActive = item.is_active || item.status === "active";
const isDisabled = item.status === "disabled";
let actionButtons = "";
if (isActive) {
actionButtons = `
<button class="btn" onclick="ipv6Disable(${item.id})">关闭</button>
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
`;
} else {
actionButtons = `
<button class="btn btn-primary" onclick="ipv6ShowEnableDialog(${item.id}, '${(item.email || '').replace(/'/g, "\\'")}')">开通</button>
`;
}
if (isDisabled) {
actionButtons = `
<button class="btn btn-primary" onclick="ipv6ShowEnableDialog(${item.id}, '${(item.email || '').replace(/'/g, "\\'")}')">重新开通</button>
` + actionButtons;
}
return `
<tr> <tr>
<td>${relationChip(item.id, item.shadow_user_id || "-")}</td> <td class="mono">${item.id}</td>
<td>${item.email || "-"}</td> <td>${item.email || "-"}</td>
<td class="mono">${item.ipv6_email || "-"}</td>
<td>${item.plan_name || "-"}</td> <td>${item.plan_name || "-"}</td>
<td>${statusBadge(item.status_label || item.status)}</td> <td>${statusBadge(item.status_label || item.status)}</td>
<td> <td>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" onclick="ipv6Enable(${item.id})">开通并同步</button> ${actionButtons}
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
</div> </div>
</td> </td>
</tr>`, </tr>`;
},
) )
.join("") .join("")
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`; : `<tr><td colspan="5" class="empty">暂无数据</td></tr>`;
return payload.pagination || { current: 1, last_page: 1, total: list.length }; return payload.pagination || { current: 1, last_page: 1, total: list.length };
} }
let ipv6Plans = [];
async function loadIpv6Plans() {
if (ipv6Plans.length > 0) return;
try {
const data = await request(`${apiBase}/plan/fetch`);
ipv6Plans = Array.isArray(data) ? data : (data && data.data ? data.data : []);
} catch (e) {
ipv6Plans = [];
}
}
function ipv6ShowEnableDialog(userId, email) {
loadIpv6Plans().then(() => {
const options = ipv6Plans.map(p => `<option value="${p.id}">${p.name || p.id}</option>`).join("");
const dialogHtml = `
<div id="ipv6-enable-dialog" class="dialog-mask is-open" style="z-index:1000">
<div class="dialog" style="width:min(420px,100%);max-height:none">
<div style="padding:20px">
<h3 style="margin:0 0 8px">开通 IPv6 子账号</h3>
<p style="margin:0 0 16px;color:var(--muted);font-size:14px">用户: <strong>${email || userId}</strong></p>
<label style="display:block;margin-bottom:6px;font-size:13px;font-weight:600">选择 IPv6 套餐</label>
<select id="ipv6-plan-select" class="field" style="width:100%;margin-bottom:20px">
<option value="0">使用默认套餐</option>
${options}
</select>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button class="btn" onclick="ipv6CloseEnableDialog()">取消</button>
<button class="btn btn-primary" onclick="ipv6ConfirmEnable(${userId})">确认开通</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML("beforeend", dialogHtml);
});
}
function ipv6CloseEnableDialog() {
const dialog = document.getElementById("ipv6-enable-dialog");
if (dialog) dialog.remove();
}
async function ipv6ConfirmEnable(userId) {
const select = document.getElementById("ipv6-plan-select");
const planId = select ? Number(select.value) || 0 : 0;
ipv6CloseEnableDialog();
await request(`${apiBase}/user-add-ipv6-subscription/enable/${userId}`, "POST", { plan_id: planId });
await loadData(state.page);
}
async function ipv6Enable(id) {
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6Disable(id) {
if (!confirm("确认关闭该用户的 IPv6 子账号?(软禁用,可恢复)")) return;
await request(`${apiBase}/user-add-ipv6-subscription/disable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6SyncPassword(id) {
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
await loadData(state.page);
}
async function loadData(page = 1) { async function loadData(page = 1) {
const payload = await request(endpoint(page)); const payload = await request(endpoint(page));
const pagination = const pagination =
@@ -814,16 +897,6 @@
await loadData(state.page); await loadData(state.page);
} }
async function ipv6Enable(id) {
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6SyncPassword(id) {
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
await loadData(state.page);
}
function openMenuDialog() { function openMenuDialog() {
document.getElementById("menu-dialog").classList.add("is-open"); document.getElementById("menu-dialog").classList.add("is-open");
document.getElementById("menu-dialog").setAttribute("aria-hidden", "false"); document.getElementById("menu-dialog").setAttribute("aria-hidden", "false");

View File

@@ -404,16 +404,6 @@
state.selectedTicket = null; state.selectedTicket = null;
render(); render();
} }
if (action === "enable-ipv6") {
handleEnableIpv6(actionEl);
return;
}
if (action === "sync-ipv6-password") {
handleSyncIpv6Password(actionEl);
return;
}
if (action === "copy-node-info") { if (action === "copy-node-info") {
handleCopyNodeInfo(actionEl.getAttribute("data-node-id")); handleCopyNodeInfo(actionEl.getAttribute("data-node-id"));
@@ -916,39 +906,7 @@
} }
} }
async function handleEnableIpv6(actionEl) {
actionEl.disabled = true;
try {
var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
var payload = unwrap(response) || {};
if (payload.auth_data) {
saveIpv6Token(payload.auth_data);
state.ipv6AuthToken = getStoredIpv6Token();
}
showMessage("IPv6 订阅已开启,正在刷新...", "success");
// Try to login to IPv6 account if possible, or just refresh dashboard
// Since we don't have the password here (state doesn't keep it),
// the user might need to sync password first or we assume it was synced during creation.
await loadDashboard(true);
render();
} catch (error) {
showMessage(error.message || "开启失败", "error");
} finally {
actionEl.disabled = false;
}
}
async function handleSyncIpv6Password(actionEl) {
actionEl.disabled = true;
try {
await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" });
showMessage("密码已同步到 IPv6 账户", "success");
} catch (error) {
showMessage(error.message || "同步失败", "error");
} finally {
actionEl.disabled = false;
}
}
async function loadUplinkMetric() { async function loadUplinkMetric() {
if (state.authToken) { if (state.authToken) {
@@ -1589,17 +1547,28 @@
var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security"; var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security";
if (isIpv6 && !state.ipv6AuthToken) { if (isIpv6 && !state.ipv6AuthToken) {
// If IPv6 not enabled, we still want to show the section with an "Enable" button // IPv6 not enabled - show guidance to submit a ticket
// But we need to make sure we don't crash on 'sub' or 'user'
} else if (isIpv6 && !sub) { } else if (isIpv6 && !sub) {
return ""; return "";
} }
var ipv6FooterHtml = "";
if (isIpv6 && !state.ipv6AuthToken) {
var statusText = ipv6Eligibility.status_label || "未开通";
var guidanceText = "IPv6 订阅需要通过工单由管理员开通,请提交工单申请。";
if (ipv6Eligibility.is_active) {
guidanceText = "您的 IPv6 已开通,如有问题请联系管理员。";
}
ipv6FooterHtml = '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px">' +
'<div class="empty-state" style="padding:0;text-align:left">' +
'<strong>' + escapeHtml(statusText) + '</strong> — ' + escapeHtml(guidanceText) +
'</div></div>';
}
var html = [ var html = [
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">', '<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">', '<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>', '<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
isIpv6 ? '<button class="btn btn-primary" data-action="sync-ipv6-password">同步密码</button>' : '',
"</div></div>", "</div></div>",
'<div class="kpi-row">', '<div class="kpi-row">',
!isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "", !isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "",
@@ -1607,20 +1576,9 @@
kpiBox("重置日", String((sub && sub.reset_day) || "-")), kpiBox("重置日", String((sub && sub.reset_day) || "-")),
!isIpv6 ? kpiBox("邮箱", '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || "-") + "</span>") : "", !isIpv6 ? kpiBox("邮箱", '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || "-") + "</span>") : "",
"</div>", "</div>",
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">开启 IPv6 订阅</button></div>' : '', ipv6FooterHtml,
"</article>" "</article>"
].join(""); ].join("");
if (isIpv6 && !state.ipv6AuthToken) {
if (!ipv6Eligibility.allowed) {
html = html.replace(
/<div class="card-footer" style="margin-top:16px;border-top:1px solid var\(--border-color\);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">[\s\S]*?<\/button><\/div>/,
'<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><div class="empty-state" style="padding:0;text-align:left">' + escapeHtml(ipv6Eligibility.reason || ipv6Eligibility.status_label || "Current account is not eligible for IPv6 self-service") + '</div></div>'
);
}
if (!ipv6Eligibility.is_active) {
html = html.replace(/<button class="btn btn-primary" data-action="sync-ipv6-password">[\s\S]*?<\/button>/, "");
}
}
return html; return html;
} }

View File

@@ -18,6 +18,8 @@ func ipv6StatusPresentation(status string, allowed bool) (string, string, string
switch status { switch status {
case "active": case "active":
return "active", "IPv6 enabled", "" return "active", "IPv6 enabled", ""
case "disabled":
return "disabled", "IPv6 disabled", ""
case "eligible": case "eligible":
return "eligible", "Ready to enable", "" return "eligible", "Ready to enable", ""
case "", "not_allowed", "not_eligible", "disallowed": case "", "not_allowed", "not_eligible", "disallowed":
@@ -233,7 +235,7 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
planNameValue := planNames[shadowPlanID] planNameValue := planNames[shadowPlanID]
if hasShadowUser { if hasShadowUser {
planID = intFromPointer(shadowUser.PlanID) planID = intFromPointer(shadowUser.PlanID)
planNameValue = planName(shadowUser.Plan) planNameValue = firstString(planName(shadowUser.Plan), planNames[planID])
} }
shadowUserID := 0 shadowUserID := 0
shadowUpdatedAt := int64(0) shadowUpdatedAt := int64(0)
@@ -346,6 +348,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 +363,35 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) {
return return
} }
if !service.SyncIPv6ShadowAccount(&user) { if !service.SyncIPv6ShadowAccountWithPlan(&user, payload.PlanID) {
Fail(c, 403, "user plan does not support ipv6 subscription") Fail(c, 500, "failed to create/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
}
var user model.User
if err := database.DB.First(&user, userID).Error; err != nil {
Fail(c, 404, "user not found")
return
}
if !service.DisableIPv6ShadowAccount(&user) {
Fail(c, 500, "failed to disable IPv6 subscription")
return
}
SuccessMessage(c, "IPv6 subscription disabled", 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 {

View File

@@ -74,19 +74,14 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
} }
func SyncIPv6ShadowAccount(user *model.User) bool { func SyncIPv6ShadowAccount(user *model.User) bool {
if user == nil { return SyncIPv6ShadowAccountWithPlan(user, 0)
return false }
}
var plan *model.Plan // SyncIPv6ShadowAccountWithPlan creates or syncs an IPv6 shadow account.
if user.PlanID != nil { // If overridePlanID > 0, it overrides the global ipv6_plan_id config for this user.
var loadedPlan model.Plan // This function no longer checks PluginUserAllowed — it is admin-only.
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil { func SyncIPv6ShadowAccountWithPlan(user *model.User, overridePlanID int) bool {
plan = &loadedPlan if user == nil {
}
}
if !PluginUserAllowed(user, plan) {
syncIPv6SubscriptionRecord(user, nil, false, "not_allowed")
return false return false
} }
@@ -113,9 +108,13 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.U = 0 ipv6User.U = 0
ipv6User.D = 0 ipv6User.D = 0
ipv6User.T = 0 ipv6User.T = 0
ipv6User.Banned = false
ipv6User.UpdatedAt = now ipv6User.UpdatedAt = now
if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 { // Determine which plan to assign: override > global config
if overridePlanID > 0 {
ipv6User.PlanID = &overridePlanID
} else if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); 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 {
@@ -134,6 +133,32 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
return true return true
} }
// DisableIPv6ShadowAccount soft-disables the IPv6 shadow account for a user.
// It bans the shadow user and marks the subscription as disabled.
func DisableIPv6ShadowAccount(user *model.User) bool {
if user == nil {
return false
}
ipv6Email := IPv6ShadowEmail(user.Email)
var ipv6User model.User
if err := database.DB.Where("email = ? AND parent_id = ?", ipv6Email, user.ID).First(&ipv6User).Error; err != nil {
// No shadow user found, just update subscription record
syncIPv6SubscriptionRecord(user, nil, false, "disabled")
return true
}
// Ban the shadow user (soft disable)
now := time.Now().Unix()
_ = database.DB.Model(&model.User{}).Where("id = ?", ipv6User.ID).Updates(map[string]any{
"banned": true,
"updated_at": now,
}).Error
syncIPv6SubscriptionRecord(user, &ipv6User, false, "disabled")
return true
}
func PluginPlanAllowed(plan *model.Plan) bool { func PluginPlanAllowed(plan *model.Plan) bool {
return PluginUserAllowed(nil, plan) return PluginUserAllowed(nil, plan)
} }