移除被错误提交的测试用exe文件
All checks were successful
build / build (api, amd64, linux) (push) Successful in -49s
build / build (api, arm64, linux) (push) Successful in -47s
build / build (api.exe, amd64, windows) (push) Successful in -47s

修复前后端逻辑
This commit is contained in:
CN-JS-HuiBai
2026-04-17 10:40:05 +08:00
parent 1ed31b9292
commit d077eae2f6
10 changed files with 1794 additions and 1289 deletions

BIN
api.exe

Binary file not shown.

View File

@@ -38,7 +38,6 @@ func registerV1(v1 *gin.RouterGroup) {
registerUserRoutes(v1)
registerClientRoutes(v1)
registerServerRoutesV1(v1)
registerPluginRoutesV1(v1)
}
func registerV2(v2 *gin.RouterGroup) {
@@ -114,6 +113,14 @@ func registerUserRoutes(v1 *gin.RouterGroup) {
user.POST("/ticket/save", handler.UserTicketSave)
user.GET("/ticket/fetch", handler.UserTicketFetch)
user.POST("/ticket/withdraw", handler.UserTicketWithdraw)
// Integrated User Features
user.GET("/real-name-verification/status", handler.PluginRealNameStatus)
user.POST("/real-name-verification/submit", handler.PluginRealNameSubmit)
user.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP)
user.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable)
user.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword)
user.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check)
}
func registerUserRoutesV2(v2 *gin.RouterGroup) {
@@ -175,35 +182,13 @@ func registerServerRoutesV2(v2 *gin.RouterGroup) {
server.POST("/status", handler.NodeStatus)
}
func registerPluginRoutesV1(v1 *gin.RouterGroup) {
securePath := service.GetAdminSecurePath()
adminPlugins := v1.Group("/" + securePath)
adminPlugins.Use(middleware.Auth(), middleware.AdminAuth())
adminPlugins.GET("/realname/records", handler.PluginRealNameRecords)
adminPlugins.POST("/realname/clear-cache", handler.PluginRealNameClearCache)
adminPlugins.POST("/realname/review/:userId", handler.PluginRealNameReview)
adminPlugins.POST("/realname/reset/:userId", handler.PluginRealNameReset)
adminPlugins.POST("/realname/sync-all", handler.PluginRealNameSyncAll)
adminPlugins.POST("/realname/approve-all", handler.PluginRealNameApproveAll)
adminPlugins.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers)
userPlugins := v1.Group("/user")
userPlugins.Use(middleware.Auth())
userPlugins.GET("/real-name-verification/status", handler.PluginRealNameStatus)
userPlugins.POST("/real-name-verification/submit", handler.PluginRealNameSubmit)
userPlugins.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP)
userPlugins.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable)
userPlugins.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword)
userPlugins.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check)
}
func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin := v2.Group("/" + service.GetAdminSecurePath())
admin.Use(middleware.Auth(), middleware.AdminAuth())
admin.GET("/config/fetch", handler.AdminConfigFetch)
admin.POST("/config/save", handler.AdminConfigSave)
admin.GET("/dashboard/summary", handler.AdminDashboardSummary)
admin.GET("/server/group/fetch", handler.AdminServerGroupsFetch)
admin.POST("/server/group/save", handler.AdminServerGroupSave)
admin.POST("/server/group/drop", handler.AdminServerGroupDrop)
@@ -220,9 +205,35 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin.POST("/server/manage/resetTraffic", handler.AdminServerManageResetTraffic)
admin.POST("/server/manage/batchResetTraffic", handler.AdminServerManageBatchResetTraffic)
admin.GET("/system/getSystemStatus", handler.AdminSystemStatus)
admin.GET("/plugin/getPlugins", handler.AdminPluginsList)
admin.GET("/plugin/types", handler.AdminPluginTypes)
admin.GET("/plugin/integration-status", handler.AdminPluginIntegrationStatus)
admin.GET("/plan/fetch", handler.AdminPlansFetch)
admin.POST("/plan/save", handler.AdminPlanSave)
admin.POST("/plan/drop", handler.AdminPlanDrop)
admin.POST("/plan/sort", handler.AdminPlanSort)
admin.GET("/order/fetch", handler.AdminOrdersFetch)
admin.POST("/order/paid", handler.AdminOrderPaid)
admin.POST("/order/cancel", handler.AdminOrderCancel)
admin.GET("/coupon/fetch", handler.AdminCouponsFetch)
admin.POST("/coupon/save", handler.AdminCouponSave)
admin.POST("/coupon/drop", handler.AdminCouponDrop)
admin.GET("/user/fetch", handler.AdminUsersFetch)
admin.POST("/user/update", handler.AdminUserUpdate)
admin.POST("/user/ban", handler.AdminUserBan)
admin.POST("/user/resetTraffic", handler.AdminUserResetTraffic)
admin.POST("/user/drop", handler.AdminUserDelete)
admin.GET("/ticket/fetch", handler.AdminTicketsFetch)
admin.GET("/gift-card/status", handler.AdminGiftCardStatus)
// Integrated Admin Features
admin.GET("/realname/records", handler.PluginRealNameRecords)
admin.POST("/realname/clear-cache", handler.PluginRealNameClearCache)
admin.POST("/realname/review/:userId", handler.PluginRealNameReview)
admin.POST("/realname/reset/:userId", handler.PluginRealNameReset)
admin.POST("/realname/sync-all", handler.PluginRealNameSyncAll)
admin.POST("/realname/approve-all", handler.PluginRealNameApproveAll)
admin.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers)
}
func registerWebRoutes(router *gin.Engine) {

View File

@@ -1,677 +1,487 @@
:root {
--bg: #eff3f9;
--bg-accent: #f8fbff;
--surface: #ffffff;
--surface-soft: #f5f8fc;
--surface-strong: #ecf2fb;
--border: #d9e1ef;
--border-strong: #c8d4e7;
--text: #172033;
--muted: #61708b;
--muted-soft: #8c9ab2;
--brand: #1677ff;
--brand-strong: #0f5fd6;
--brand-soft: #eaf2ff;
--success: #168a54;
--warn: #d97706;
--danger: #c0392b;
--sidebar: #0f1728;
--sidebar-panel: #141f35;
--sidebar-muted: #8ea0c3;
/* Nebula Dark Palette */
--bg: #07101b;
--panel: rgba(9, 20, 35, 0.78);
--panel-strong: rgba(11, 24, 41, 0.92);
--text: #eef6ff;
--text-dim: rgba(227, 239, 255, 0.72);
--text-faint: rgba(227, 239, 255, 0.54);
--border: rgba(152, 192, 255, 0.16);
--shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
--shadow-soft: 0 12px 32px rgba(0, 0, 0, 0.22);
/* Brand/Accent */
--accent: #6ce5ff;
--accent-strong: #21b8ff;
--accent-soft: rgba(108, 229, 255, 0.12);
--accent-rgb: 108, 229, 255;
/* Indicators */
--success: #65f0b7;
--warn: #ffca7a;
--danger: #ff8db5;
/* Radius */
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
/* Sidebar */
--sidebar: #091221;
--sidebar-active: #ffffff;
--sidebar-border: rgba(255, 255, 255, 0.08);
--shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
--shadow-soft: 0 12px 28px rgba(15, 23, 42, 0.05);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--sidebar-muted: rgba(227, 239, 255, 0.5);
font-family: "Avenir Next", "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body {
html, body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.12), transparent 30%),
radial-gradient(circle at top right, rgba(255, 201, 122, 0.08), transparent 22%),
linear-gradient(180deg, #050d16 0%, #07101b 100%);
color: var(--text);
overflow-x: hidden;
}
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(22, 119, 255, 0.12), transparent 28%),
radial-gradient(circle at right top, rgba(15, 95, 214, 0.08), transparent 24%),
linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 100%);
body::before {
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(155, 192, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(155, 192, 255, 0.04) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(circle at top center, black, transparent 80%);
pointer-events: none;
z-index: -1;
}
a {
color: inherit;
text-decoration: none;
transition: opacity 0.2s ease;
}
button,
input,
select,
textarea {
a:hover {
opacity: 0.8;
}
button, input, select, textarea {
font: inherit;
background: transparent;
color: inherit;
}
/* Glass Component Base */
.glass-card {
background: var(--panel);
backdrop-filter: blur(18px);
border: 1px solid var(--border);
box-shadow: var(--shadow-soft);
}
/* Main Layout */
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
grid-template-columns: 280px minmax(0, 1fr);
gap: 0;
}
/* Sidebar */
.admin-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
padding: 26px 18px 22px;
color: #fff;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
linear-gradient(180deg, #121c2f 0%, #0d1524 100%);
border-right: 1px solid rgba(255, 255, 255, 0.03);
background: var(--sidebar);
border-right: 1px solid var(--border);
padding: 32px 20px;
display: flex;
flex-direction: column;
gap: 32px;
}
.sidebar-brand {
padding: 18px 16px 20px;
border-radius: 20px;
border: 1px solid var(--sidebar-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
padding: 0 12px;
}
.sidebar-brand-mark {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
padding: 6px 10px;
padding: 6px 12px;
background: var(--accent-soft);
color: var(--accent);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 999px;
background: rgba(22, 119, 255, 0.16);
color: #b9d5ff;
font-size: 11px;
letter-spacing: 0.08em;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 14px;
}
.sidebar-brand strong {
display: block;
font-size: 24px;
line-height: 1.15;
color: var(--sidebar-active);
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
}
.sidebar-brand span:last-child {
.sidebar-brand span {
display: block;
margin-top: 10px;
color: var(--sidebar-muted);
font-size: 12px;
line-height: 1.5;
}
.sidebar-nav-label,
.sidebar-meta-label {
display: block;
color: var(--sidebar-muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 13px;
color: var(--text-faint);
margin-top: 8px;
}
.sidebar-nav-label {
margin: 22px 12px 10px;
display: block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-faint);
margin: 24px 12px 12px;
}
.sidebar-nav {
display: grid;
gap: 8px;
gap: 6px;
}
.sidebar-item {
position: relative;
display: block;
padding: 12px 16px;
border-radius: var(--radius-md);
color: var(--sidebar-muted);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
border-radius: 16px;
padding: 14px 14px 14px 16px;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.sidebar-item:hover {
transform: translateX(2px);
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
transform: translateX(4px);
}
.sidebar-item.active {
background: linear-gradient(180deg, rgba(22, 119, 255, 0.22), rgba(22, 119, 255, 0.12));
border-color: rgba(95, 162, 255, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.sidebar-item.active::before {
content: "";
position: absolute;
left: -6px;
top: 14px;
bottom: 14px;
width: 3px;
border-radius: 999px;
background: #7db3ff;
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.24);
box-shadow: inset 0 0 12px rgba(var(--accent-rgb), 0.08);
}
.sidebar-item strong {
display: block;
font-size: 14px;
color: var(--sidebar-active);
font-size: 15px;
font-weight: 700;
}
.sidebar-item span {
display: block;
margin-top: 6px;
color: var(--sidebar-muted);
font-size: 12px;
line-height: 1.45;
}
.sidebar-meta,
.sidebar-footer {
margin-top: 18px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--sidebar-border);
background: rgba(255, 255, 255, 0.04);
}
.sidebar-meta strong {
display: block;
margin-top: 8px;
color: var(--sidebar-active);
font-size: 15px;
}
.sidebar-footer {
color: var(--sidebar-muted);
font-size: 12px;
line-height: 1.65;
opacity: 0.7;
margin-top: 2px;
}
/* Main Content */
.admin-main {
padding: 32px 40px;
min-width: 0;
padding: 22px 26px 30px;
}
.topbar {
position: sticky;
top: 0;
z-index: 5;
padding: 24px 28px;
border-radius: var(--radius-xl);
margin-bottom: 32px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 18px 22px;
margin-bottom: 22px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 24px;
background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(18px);
box-shadow: var(--shadow-soft);
align-items: flex-start;
gap: 24px;
}
.topbar-copy {
min-width: 0;
.topbar-copy h1 {
margin: 0;
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
}
.topbar-copy p {
margin: 10px 0 0;
font-size: 15px;
color: var(--text-dim);
}
.topbar-eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand-strong);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topbar h1 {
margin: 0;
font-size: 30px;
line-height: 1.15;
}
.topbar p {
margin: 8px 0 0;
color: var(--muted);
line-height: 1.6;
letter-spacing: 0.15em;
color: var(--accent);
margin-bottom: 10px;
}
.topbar-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
gap: 12px;
margin-top: 16px;
}
.topbar-chip {
display: inline-flex;
align-items: center;
padding: 7px 11px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
background: var(--surface-soft);
color: var(--muted);
font-size: 12px;
color: var(--text-dim);
}
.page-shell {
display: grid;
gap: 18px;
}
.page-hero,
.page-section,
.plugin-card,
.stat-card {
overflow: hidden;
}
.page-hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(240px, 0.8fr);
gap: 20px;
align-items: stretch;
}
.page-hero-copy h2 {
margin: 10px 0 10px;
font-size: 28px;
line-height: 1.2;
}
.page-hero-copy p {
margin: 0;
color: var(--muted);
line-height: 1.7;
}
.page-hero-label,
.section-kicker {
display: inline-flex;
color: var(--brand-strong);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-hero-side {
display: grid;
gap: 12px;
}
.hero-metric {
display: grid;
align-content: center;
gap: 6px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 18px;
background: linear-gradient(180deg, var(--surface-soft), #fff);
}
.hero-metric span {
color: var(--muted);
font-size: 12px;
}
.hero-metric strong {
font-size: 22px;
line-height: 1.2;
}
/* Cards & Grid */
.grid {
display: grid;
gap: 18px;
gap: 20px;
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.plugin-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 22px;
padding: 28px;
border-radius: var(--radius-xl);
}
.card h2,
.card h3 {
margin: 0;
}
.card p {
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.section-headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
}
.section-headline h2 {
margin-top: 8px;
.card h2 {
font-size: 22px;
}
.section-copy {
max-width: 520px;
color: var(--muted);
line-height: 1.65;
}
.status-strip {
display: flex;
align-items: center;
}
.stat {
display: grid;
gap: 10px;
}
.stat strong {
font-size: 30px;
line-height: 1.15;
}
.hint {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border: 1px solid transparent;
border-radius: 14px;
padding: 10px 16px;
cursor: pointer;
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
color: #fff;
background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
box-shadow: 0 10px 20px rgba(22, 119, 255, 0.2);
}
.btn-secondary {
color: var(--brand-strong);
background: var(--brand-soft);
border-color: rgba(22, 119, 255, 0.08);
}
.btn-ghost {
color: var(--text);
background: #fff;
border-color: var(--border);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
margin: 0 0 14px;
}
.status-ok {
background: rgba(22, 138, 84, 0.12);
color: var(--success);
.stat-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-warn {
background: rgba(217, 119, 6, 0.12);
color: var(--warn);
.stat-card strong {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.04em;
color: var(--accent);
}
.status-danger {
background: rgba(192, 57, 43, 0.12);
color: var(--danger);
.stat-card span {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
}
/* Table Design */
.table-wrap {
overflow: auto;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
border-radius: 18px;
background: #fff;
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 680px;
}
th,
td {
th, td {
padding: 16px 20px;
text-align: left;
padding: 14px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-soft);
color: var(--muted);
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-faint);
}
tbody tr:hover {
background: #f8fbff;
}
td {
font-size: 14px;
background: rgba(255, 255, 255, 0.02);
}
tbody tr:last-child td {
border-bottom: 0;
}
.row-actions {
display: flex;
/* Forms & Inputs */
.field {
display: grid;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.login-shell {
min-height: 100vh;
.field label {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
}
.field input, .field select, .field textarea {
width: 100%;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.field input:focus, .field select:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
outline: none;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
border-radius: var(--radius-md);
font-weight: 700;
font-size: 14px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
border: 1px solid transparent;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #050d16;
box-shadow: 0 12px 32px rgba(var(--accent-rgb), 0.24);
}
.btn-secondary {
background: rgba(255,255,255,0.05);
border-color: var(--border);
color: var(--text);
}
.btn-ghost {
background: transparent;
color: var(--accent);
padding: 8px 12px;
}
.btn-ghost:hover {
background: var(--accent-soft);
}
/* Status Badges */
.status-pill {
padding: 6px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-flex;
}
.status-ok { background: rgba(101, 240, 183, 0.12); color: var(--success); border: 1px solid rgba(101, 240, 183, 0.2); }
.status-warn { background: rgba(255, 202, 122, 0.12); color: var(--warn); border: 1px solid rgba(255, 202, 122, 0.2); }
.status-danger { background: rgba(255, 141, 181, 0.12); color: var(--danger); border: 1px solid rgba(255, 141, 181, 0.2); }
/* Progress */
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
box-shadow: 0 0 12px var(--accent-soft);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
}
.login-card {
width: min(480px, 100%);
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.76);
border-radius: 28px;
padding: 30px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
.modal-card {
width: min(100%, 640px);
animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.login-card form,
.filter-form {
display: grid;
@keyframes modalIn {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Pagination */
.pagination-shell {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
}
.field {
.page-btn {
width: 40px;
height: 40px;
display: grid;
gap: 8px;
}
.field label {
color: var(--muted);
font-size: 13px;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-height: 44px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: #fff;
color: var(--text);
outline: none;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: rgba(22, 119, 255, 0.4);
box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.08);
}
.notice {
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
}
.notice.error {
background: rgba(192, 57, 43, 0.12);
color: var(--danger);
}
.notice.success {
background: rgba(22, 138, 84, 0.12);
color: var(--success);
}
.stack {
display: grid;
gap: 18px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
border-radius: 16px;
background: #0f172a;
color: #dbe7ff;
padding: 14px 16px;
font-size: 12px;
line-height: 1.55;
}
.code-panel {
max-height: 420px;
overflow: auto;
}
.table-code {
max-width: 360px;
max-height: 180px;
overflow: auto;
place-items: center;
border-radius: 12px;
background: #111b31;
font-size: 11px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
@media (max-width: 1180px) {
.page-hero,
.plugin-grid {
grid-template-columns: 1fr;
}
.page-btn.active {
background: var(--accent);
color: #050d16;
border-color: var(--accent);
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
/* Responsive */
@media (max-width: 1024px) {
.admin-shell { grid-template-columns: 1fr; }
.admin-sidebar { position: static; height: auto; display: none; } /* Mobile sidebar handled differently */
.admin-main { padding: 24px; }
.grid-3 { grid-template-columns: 1fr; }
}
.admin-sidebar {
position: static;
height: auto;
border-right: 0;
/* Animation Utilities */
.fade-in {
animation: fadeIn 0.4s ease-out;
}
.admin-main {
padding: 18px;
}
.topbar {
position: static;
flex-direction: column;
align-items: flex-start;
}
.grid-3 {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.card {
padding: 18px;
border-radius: 20px;
}
.sidebar-brand,
.sidebar-meta,
.sidebar-footer {
border-radius: 16px;
}
.topbar h1,
.page-hero-copy h2 {
font-size: 24px;
}
.table-wrap {
border-radius: 14px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,714 @@
package handler
import (
"net/http"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
func AdminDashboardSummary(c *gin.Context) {
now := time.Now().Unix()
monthAgo := now - 30*24*60*60
var totalUsers int64
var newUsers int64
var activeUsers int64
var totalOrders int64
var pendingOrders int64
var pendingTickets int64
var totalServers int64
var totalPlans int64
var totalCoupons int64
var totalGroups int64
var totalRoutes int64
var onlineUsers int64
var paidOrderAmount int64
database.DB.Model(&model.User{}).Count(&totalUsers)
database.DB.Model(&model.User{}).Where("created_at >= ?", monthAgo).Count(&newUsers)
database.DB.Model(&model.User{}).Where("last_login_at IS NOT NULL AND last_login_at >= ?", monthAgo).Count(&activeUsers)
database.DB.Model(&model.User{}).Where("online_count IS NOT NULL AND online_count > 0").Count(&onlineUsers)
database.DB.Model(&model.Order{}).Count(&totalOrders)
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
database.DB.Model(&model.Order{}).Where("status NOT IN ?", []int{0, 2}).Select("COALESCE(SUM(total_amount), 0)").Scan(&paidOrderAmount)
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
database.DB.Model(&model.Server{}).Count(&totalServers)
database.DB.Model(&model.Plan{}).Count(&totalPlans)
database.DB.Model(&model.Coupon{}).Count(&totalCoupons)
database.DB.Model(&model.ServerGroup{}).Count(&totalGroups)
database.DB.Model(&model.ServerRoute{}).Count(&totalRoutes)
Success(c, gin.H{
"server_time": now,
"total_users": totalUsers,
"new_users_30d": newUsers,
"active_users_30d": activeUsers,
"online_users": onlineUsers,
"total_orders": totalOrders,
"pending_orders": pendingOrders,
"paid_order_amount": paidOrderAmount,
"pending_tickets": pendingTickets,
"total_servers": totalServers,
"total_plans": totalPlans,
"total_coupons": totalCoupons,
"total_groups": totalGroups,
"total_routes": totalRoutes,
"secure_path": service.GetAdminSecurePath(),
"app_name": service.MustGetString("app_name", "XBoard"),
"app_url": service.GetAppURL(),
"server_pull_period": service.MustGetInt("server_pull_interval", 60),
"server_push_period": service.MustGetInt("server_push_interval", 60),
})
}
func AdminPlansFetch(c *gin.Context) {
var plans []model.Plan
if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch plans")
return
}
groupNames := loadServerGroupNameMap()
items := make([]gin.H, 0, len(plans))
for _, plan := range plans {
items = append(items, gin.H{
"id": plan.ID,
"name": plan.Name,
"group_id": intValue(plan.GroupID),
"group_name": groupNames[intFromPointer(plan.GroupID)],
"transfer_enable": intValue(plan.TransferEnable),
"speed_limit": intValue(plan.SpeedLimit),
"show": plan.Show,
"sort": intValue(plan.Sort),
"renew": plan.Renew,
"content": stringValue(plan.Content),
"reset_traffic_method": intValue(plan.ResetTrafficMethod),
"capacity_limit": intValue(plan.CapacityLimit),
"prices": stringValue(plan.Prices),
"sell": plan.Sell,
"device_limit": intValue(plan.DeviceLimit),
"tags": parseStringSlice(plan.Tags),
"created_at": plan.CreatedAt,
"updated_at": plan.UpdatedAt,
})
}
Success(c, items)
}
func AdminPlanSave(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
name := strings.TrimSpace(stringFromAny(payload["name"]))
if name == "" && id == 0 {
Fail(c, http.StatusUnprocessableEntity, "plan name is required")
return
}
// For simplicity in this Go implementation, we'll map payload to model.Plan
// In a real app, we'd use a dedicated struct for binding.
now := time.Now().Unix()
tags, _ := marshalJSON(payload["tags"], true)
values := map[string]any{
"name": payload["name"],
"group_id": payload["group_id"],
"transfer_enable": payload["transfer_enable"],
"speed_limit": payload["speed_limit"],
"show": payload["show"],
"sort": payload["sort"],
"renew": payload["renew"],
"content": payload["content"],
"reset_traffic_method": payload["reset_traffic_method"],
"capacity_limit": payload["capacity_limit"],
"prices": payload["prices"],
"sell": payload["sell"],
"device_limit": payload["device_limit"],
"tags": tags,
"updated_at": now,
}
if id > 0 {
if err := database.DB.Model(&model.Plan{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to save plan")
return
}
Success(c, true)
return
}
values["created_at"] = now
if err := database.DB.Model(&model.Plan{}).Create(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to create plan")
return
}
Success(c, true)
}
func AdminPlanDrop(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "plan id is required")
return
}
// Check usage
var orderCount int64
database.DB.Model(&model.Order{}).Where("plan_id = ?", payload.ID).Count(&orderCount)
if orderCount > 0 {
Fail(c, http.StatusBadRequest, "this plan is still used by orders")
return
}
var userCount int64
database.DB.Model(&model.User{}).Where("plan_id = ?", payload.ID).Count(&userCount)
if userCount > 0 {
Fail(c, http.StatusBadRequest, "this plan is still used by users")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Plan{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete plan")
return
}
Success(c, true)
}
func AdminPlanSort(c *gin.Context) {
var payload struct {
PlanIDs []int `json:"plan_ids"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
tx := database.DB.Begin()
for i, id := range payload.PlanIDs {
if err := tx.Model(&model.Plan{}).Where("id = ?", id).Update("sort", i+1).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to sort plans")
return
}
}
tx.Commit()
Success(c, true)
}
func AdminOrdersFetch(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
statusFilter := strings.TrimSpace(c.Query("status"))
query := database.DB.Model(&model.Order{}).Preload("Plan").Preload("Payment").Order("id DESC")
if keyword != "" {
query = query.Where("trade_no LIKE ? OR CAST(user_id AS CHAR) = ?", "%"+keyword+"%", keyword)
}
if statusFilter != "" {
query = query.Where("status = ?", statusFilter)
}
var total int64
if err := query.Count(&total).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to count orders")
return
}
var orders []model.Order
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&orders).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch orders")
return
}
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
item := normalizeOrder(order)
item["user_email"] = userEmails[order.UserID]
items = append(items, item)
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
"status": statusFilter,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminCouponsFetch(c *gin.Context) {
var coupons []model.Coupon
if err := database.DB.Order("id DESC").Find(&coupons).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch coupons")
return
}
items := make([]gin.H, 0, len(coupons))
for _, coupon := range coupons {
items = append(items, gin.H{
"id": coupon.ID,
"name": coupon.Name,
"code": coupon.Code,
"type": coupon.Type,
"value": coupon.Value,
"limit_plan_ids": parseStringSlice(coupon.LimitPlanIDs),
"limit_period": parseStringSlice(coupon.LimitPeriod),
"limit_use": intValue(coupon.LimitUse),
"limit_use_with_user": intValue(coupon.LimitUseWithUser),
"started_at": coupon.StartedAt,
"ended_at": coupon.EndedAt,
"show": coupon.Show,
"created_at": coupon.CreatedAt,
"updated_at": coupon.UpdatedAt,
})
}
Success(c, items)
}
func AdminCouponSave(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
now := time.Now().Unix()
limitPlanIDs, _ := marshalJSON(payload["limit_plan_ids"], true)
limitPeriod, _ := marshalJSON(payload["limit_period"], true)
values := map[string]any{
"name": payload["name"],
"code": payload["code"],
"type": payload["type"],
"value": payload["value"],
"limit_plan_ids": limitPlanIDs,
"limit_period": limitPeriod,
"limit_use": payload["limit_use"],
"limit_use_with_user": payload["limit_use_with_user"],
"started_at": payload["started_at"],
"ended_at": payload["ended_at"],
"show": payload["show"],
"updated_at": now,
}
if id > 0 {
if err := database.DB.Model(&model.Coupon{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to save coupon")
return
}
Success(c, true)
return
}
values["created_at"] = now
if err := database.DB.Model(&model.Coupon{}).Create(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to create coupon")
return
}
Success(c, true)
}
func AdminCouponDrop(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "coupon id is required")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Coupon{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete coupon")
return
}
Success(c, true)
}
func AdminOrderPaid(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" {
Fail(c, http.StatusBadRequest, "trade_no is required")
return
}
var order model.Order
if err := database.DB.Preload("Plan").Where("trade_no = ?", payload.TradeNo).First(&order).Error; err != nil {
Fail(c, http.StatusBadRequest, "order does not exist")
return
}
if order.Status != 0 {
Fail(c, http.StatusBadRequest, "order status is not pending")
return
}
now := time.Now().Unix()
tx := database.DB.Begin()
// Update order
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
"status": 3,
"paid_at": now,
}).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update order")
return
}
// Update user
var user model.User
if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil {
// Calculate expiration and traffic
// Simplified logic: set plan and transfer_enable
updates := map[string]any{
"plan_id": order.PlanID,
"updated_at": now,
}
if order.Plan != nil {
updates["transfer_enable"] = order.Plan.TransferEnable
}
if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
}
tx.Commit()
Success(c, true)
}
func AdminOrderCancel(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" {
Fail(c, http.StatusBadRequest, "trade_no is required")
return
}
if err := database.DB.Model(&model.Order{}).Where("trade_no = ?", payload.TradeNo).Update("status", 2).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to cancel order")
return
}
Success(c, true)
}
func AdminUserResetTraffic(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Updates(map[string]any{
"u": 0,
"d": 0,
}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to reset traffic")
return
}
Success(c, true)
}
func AdminUserBan(c *gin.Context) {
var payload struct {
ID int `json:"id"`
Banned bool `json:"banned"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("banned", payload.Banned).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to update user status")
return
}
Success(c, true)
}
func AdminUserDelete(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if err := database.DB.Where("id = ?", payload.ID).Delete(&model.User{}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete user")
return
}
Success(c, true)
}
func AdminUserUpdate(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
id := intFromAny(payload["id"])
if id <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
now := time.Now().Unix()
values := map[string]any{
"email": payload["email"],
"password": payload["password"],
"balance": payload["balance"],
"commission_type": payload["commission_type"],
"commission_rate": payload["commission_rate"],
"commission_balance": payload["commission_balance"],
"group_id": payload["group_id"],
"plan_id": payload["plan_id"],
"speed_limit": payload["speed_limit"],
"device_limit": payload["device_limit"],
"expired_at": payload["expired_at"],
"remarks": payload["remarks"],
"updated_at": now,
}
// Remove nil values to avoid overwriting with defaults if not provided
for k, v := range values {
if v == nil {
delete(values, k)
}
}
if err := database.DB.Model(&model.User{}).Where("id = ?", id).Updates(values).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
Success(c, true)
}
func AdminUsersFetch(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
if keyword != "" {
query = query.Where("email LIKE ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword)
}
var total int64
if err := query.Count(&total).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to count users")
return
}
var users []model.User
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch users")
return
}
groupNames := loadServerGroupNameMap()
items := make([]gin.H, 0, len(users))
for _, user := range users {
items = append(items, gin.H{
"id": user.ID,
"email": user.Email,
"balance": user.Balance,
"group_id": intValue(user.GroupID),
"group_name": groupNames[intFromPointer(user.GroupID)],
"plan_id": intValue(user.PlanID),
"plan_name": planName(user.Plan),
"transfer_enable": user.TransferEnable,
"u": user.U,
"d": user.D,
"banned": user.Banned,
"is_admin": user.IsAdmin,
"is_staff": user.IsStaff,
"device_limit": intValue(user.DeviceLimit),
"online_count": intValue(user.OnlineCount),
"expired_at": int64Value(user.ExpiredAt),
"last_login_at": int64Value(user.LastLoginAt),
"last_online_at": unixTimeValue(user.LastOnlineAt),
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"remarks": stringValue(user.Remarks),
"commission_type": user.CommissionType,
"commission_rate": intValue(user.CommissionRate),
"commission_balance": user.CommissionBalance,
})
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminTicketsFetch(c *gin.Context) {
if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" {
adminTicketDetail(c, ticketID)
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Model(&model.Ticket{}).Order("updated_at DESC, id DESC")
if keyword != "" {
query = query.Where("subject LIKE ? OR CAST(user_id AS CHAR) = ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword, keyword)
}
var total int64
if err := query.Count(&total).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to count tickets")
return
}
var tickets []model.Ticket
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&tickets).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch tickets")
return
}
userEmails := loadUserEmailMap(extractTicketUserIDs(tickets))
messageCounts := loadTicketMessageCountMap(extractTicketIDs(tickets))
items := make([]gin.H, 0, len(tickets))
for _, ticket := range tickets {
items = append(items, gin.H{
"id": ticket.ID,
"user_id": ticket.UserID,
"user_email": userEmails[ticket.UserID],
"subject": ticket.Subject,
"level": ticket.Level,
"status": ticket.Status,
"reply_status": ticket.ReplyStatus,
"message_count": messageCounts[ticket.ID],
"created_at": ticket.CreatedAt,
"updated_at": ticket.UpdatedAt,
})
}
Success(c, gin.H{
"list": items,
"filters": gin.H{
"keyword": keyword,
},
"pagination": gin.H{
"current": page,
"last_page": calculateLastPage(total, perPage),
"per_page": perPage,
"total": total,
},
})
}
func AdminGiftCardStatus(c *gin.Context) {
Success(c, gin.H{
"supported": false,
"status": "not_integrated",
"message": "Gift card management is not fully integrated in the current Go backend yet.",
})
}
func adminTicketDetail(c *gin.Context, rawID string) {
var ticket model.Ticket
if err := database.DB.Where("id = ?", rawID).First(&ticket).Error; err != nil {
Fail(c, http.StatusNotFound, "ticket not found")
return
}
var messages []model.TicketMessage
if err := database.DB.Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to fetch ticket messages")
return
}
userEmails := loadUserEmailMap([]int{ticket.UserID})
Success(c, gin.H{
"id": ticket.ID,
"user_id": ticket.UserID,
"user_email": userEmails[ticket.UserID],
"subject": ticket.Subject,
"level": ticket.Level,
"status": ticket.Status,
"reply_status": ticket.ReplyStatus,
"created_at": ticket.CreatedAt,
"updated_at": ticket.UpdatedAt,
"messages": buildTicketMessages(messages, 0),
})
}
func extractOrderUserIDs(orders []model.Order) []int {
ids := make([]int, 0, len(orders))
for _, order := range orders {
ids = append(ids, order.UserID)
}
return ids
}
func extractTicketUserIDs(tickets []model.Ticket) []int {
ids := make([]int, 0, len(tickets))
for _, ticket := range tickets {
ids = append(ids, ticket.UserID)
}
return ids
}
func extractTicketIDs(tickets []model.Ticket) []int {
ids := make([]int, 0, len(tickets))
for _, ticket := range tickets {
ids = append(ids, ticket.ID)
}
return ids
}
func intFromPointer(value *int) int {
if value == nil {
return 0
}
return *value
}
func planName(plan *model.Plan) string {
if plan == nil {
return ""
}
return plan.Name
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -771,96 +770,6 @@ func decodeStringSlice(raw *string) []string {
return filterNonEmptyStrings(strings.Split(*raw, ","))
}
func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) {
if value == nil {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if text, ok := value.(string); ok {
text = strings.TrimSpace(text)
if text == "" {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if json.Valid([]byte(text)) {
return &text, nil
}
}
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
text := string(payload)
return &text, nil
}
func stringFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func nullableString(value any) any {
text := strings.TrimSpace(stringFromAny(value))
if text == "" {
return nil
}
return text
}
func intFromAny(value any) int {
switch typed := value.(type) {
case int:
return typed
case int8:
return int(typed)
case int16:
return int(typed)
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
case json.Number:
parsed, _ := typed.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
return parsed
default:
return 0
}
}
func float32FromAny(value any) (float32, bool) {
switch typed := value.(type) {
case float32:
@@ -891,64 +800,19 @@ func nullableInt(value any) any {
}
func nullableInt64(value any) any {
switch typed := value.(type) {
case int64:
if typed > 0 {
return typed
}
case int:
if typed > 0 {
return int64(typed)
}
case float64:
if typed > 0 {
return int64(typed)
}
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 {
return parsed
}
}
parsed := intFromAny(value)
if parsed <= 0 {
return nil
}
func boolFromAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case int:
return typed != 0
case int64:
return typed != 0
case float64:
return typed != 0
case string:
text := strings.TrimSpace(strings.ToLower(typed))
return text == "1" || text == "true" || text == "yes" || text == "on"
default:
return false
}
return int64(parsed)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func intValue(value *int) any {
if value == nil {
func nullableString(value any) any {
text := strings.TrimSpace(stringFromAny(value))
if text == "" {
return nil
}
return *value
}
func int64Value(value *int64) any {
if value == nil {
return nil
}
return *value
return text
}
func filterNonEmptyStrings(values any) []string {
@@ -972,23 +836,6 @@ func filterNonEmptyStrings(values any) []string {
return result
}
func sanitizePositiveIDs(ids []int) []int {
unique := make(map[int]struct{}, len(ids))
result := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := unique[id]; exists {
continue
}
unique[id] = struct{}{}
result = append(result, id)
}
sort.Ints(result)
return result
}
func intSliceContains(values []int, target int) bool {
for _, value := range values {
if value == target {
@@ -1007,9 +854,3 @@ func isAllowedRouteAction(action string) bool {
}
}
func unixTimeValue(value *time.Time) any {
if value == nil {
return nil
}
return value.Unix()
}

View File

@@ -1,7 +1,14 @@
package handler
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"github.com/gin-gonic/gin"
)
@@ -29,3 +36,201 @@ func NotImplemented(endpoint string) gin.HandlerFunc {
})
}
}
func intFromAny(value any) int {
switch typed := value.(type) {
case int:
return typed
case int8:
return int(typed)
case int16:
return int(typed)
case int32:
return int(typed)
case int64:
return int(typed)
case float32:
return int(typed)
case float64:
return int(typed)
case json.Number:
parsed, _ := typed.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
return parsed
default:
return 0
}
}
func stringFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func boolFromAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case int:
return typed != 0
case int64:
return typed != 0
case float64:
return typed != 0
case string:
text := strings.TrimSpace(strings.ToLower(typed))
return text == "1" || text == "true" || text == "yes" || text == "on"
default:
return false
}
}
func intValue(value *int) any {
if value == nil {
return nil
}
return *value
}
func int64Value(value *int64) any {
if value == nil {
return nil
}
return *value
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func unixTimeValue(value *time.Time) any {
if value == nil {
return nil
}
return value.Unix()
}
func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) {
if value == nil {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if text, ok := value.(string); ok {
text = strings.TrimSpace(text)
if text == "" {
if fallbackEmptyArray {
empty := "[]"
return &empty, nil
}
return nil, nil
}
if json.Valid([]byte(text)) {
return &text, nil
}
}
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
text := string(payload)
return &text, nil
}
func sanitizePositiveIDs(ids []int) []int {
unique := make(map[int]struct{}, len(ids))
result := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := unique[id]; exists {
continue
}
unique[id] = struct{}{}
result = append(result, id)
}
sort.Ints(result)
return result
}
func loadServerGroupNameMap() map[int]string {
var groups []model.ServerGroup
_ = database.DB.Find(&groups).Error
result := make(map[int]string, len(groups))
for _, group := range groups {
result[group.ID] = group.Name
}
return result
}
func loadUserEmailMap(userIDs []int) map[int]string {
result := make(map[int]string)
if len(userIDs) == 0 {
return result
}
var users []model.User
if err := database.DB.Select("id", "email").Where("id IN ?", sanitizePositiveIDs(userIDs)).Find(&users).Error; err != nil {
return result
}
for _, user := range users {
result[user.ID] = user.Email
}
return result
}
func loadTicketMessageCountMap(ticketIDs []int) map[int]int64 {
result := make(map[int]int64)
if len(ticketIDs) == 0 {
return result
}
type ticketCount struct {
TicketID int
Total int64
}
var counts []ticketCount
if err := database.DB.Model(&model.TicketMessage{}).
Select("ticket_id, COUNT(*) AS total").
Where("ticket_id IN ?", sanitizePositiveIDs(ticketIDs)).
Group("ticket_id").
Scan(&counts).Error; err != nil {
return result
}
for _, item := range counts {
result[item.TicketID] = item.Total
}
return result
}

View File

@@ -12,10 +12,6 @@ import (
)
func PluginUserOnlineDevicesUsers(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
Fail(c, 400, "plugin is not enabled")
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
@@ -92,10 +88,6 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
}
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -138,10 +130,6 @@ func PluginUserOnlineDevicesGetIP(c *gin.Context) {
}
func PluginUserAddIPv6Check(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -171,10 +159,6 @@ func PluginUserAddIPv6Check(c *gin.Context) {
}
func PluginUserAddIPv6Enable(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -191,10 +175,6 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
}
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
@@ -234,41 +214,6 @@ func AdminSystemStatus(c *gin.Context) {
})
}
func AdminPluginsList(c *gin.Context) {
var plugins []model.Plugin
if err := database.DB.Order("id ASC").Find(&plugins).Error; err != nil {
Fail(c, 500, "failed to fetch plugins")
return
}
Success(c, plugins)
}
func AdminPluginTypes(c *gin.Context) {
Success(c, []string{"feature", "payment"})
}
func AdminPluginIntegrationStatus(c *gin.Context) {
Success(c, gin.H{
"user_online_devices": gin.H{
"enabled": service.IsPluginEnabled(service.PluginUserOnlineDevices),
"status": "complete",
"summary": "用户侧在线设备概览与后台设备监控已接入 Go 后端。",
"endpoints": []string{"/api/v1/user/user-online-devices/get-ip", "/api/v1/" + service.GetAdminSecurePath() + "/user-online-devices/users"},
},
"real_name_verification": gin.H{
"enabled": service.IsPluginEnabled(service.PluginRealNameVerification),
"status": "complete",
"summary": "实名状态查询、提交、后台审核与批量同步均已整合,并补齐 auto_approve/allow_resubmit_after_reject 行为。",
"endpoints": []string{"/api/v1/user/real-name-verification/status", "/api/v1/user/real-name-verification/submit", "/api/v1/" + service.GetAdminSecurePath() + "/realname/records"},
},
"user_add_ipv6_subscription": gin.H{
"enabled": service.IsPluginEnabled(service.PluginUserAddIPv6),
"status": "integrated_with_runtime_sync",
"summary": "用户启用、密码同步与运行时影子账号同步已接入;订单生命周期自动钩子仍依赖后续完整订单流重构。",
"endpoints": []string{"/api/v1/user/user-add-ipv6-subscription/check", "/api/v1/user/user-add-ipv6-subscription/enable", "/api/v1/user/user-add-ipv6-subscription/sync-password"},
},
})
}
func parsePositiveInt(raw string, defaultValue int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))

View File

@@ -14,11 +14,6 @@ import (
const unverifiedExpiration = int64(946684800)
func PluginRealNameStatus(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
@@ -54,11 +49,6 @@ func PluginRealNameStatus(c *gin.Context) {
}
func PluginRealNameSubmit(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
@@ -126,11 +116,6 @@ func PluginRealNameSubmit(c *gin.Context) {
}
func PluginRealNameRecords(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
keyword := strings.TrimSpace(c.Query("keyword"))
@@ -192,11 +177,6 @@ func PluginRealNameRecords(c *gin.Context) {
}
func PluginRealNameReview(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
@@ -239,11 +219,6 @@ func PluginRealNameReview(c *gin.Context) {
}
func PluginRealNameReset(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
@@ -256,18 +231,10 @@ func PluginRealNameReset(c *gin.Context) {
}
func PluginRealNameSyncAll(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
}
func PluginRealNameApproveAll(c *gin.Context) {
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
Fail(c, 400, "plugin is not enabled")
return
}
var users []model.User
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)

View File

@@ -64,11 +64,18 @@ func AdminAppPage(c *gin.Context) {
"securePath": securePath,
"api": map[string]string{
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
"saveConfig": "/api/v2/" + securePath + "/config/save",
"dashboardSummary": "/api/v2/" + securePath + "/dashboard/summary",
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
"plugins": "/api/v2/" + securePath + "/plugin/getPlugins",
"integration": "/api/v2/" + securePath + "/plugin/integration-status",
"realnameBase": "/api/v1/" + securePath + "/realname",
"onlineDevices": "/api/v1/" + securePath + "/user-online-devices/users",
"serverNodeSort": "/api/v2/" + securePath + "/server/manage/sort",
"plans": "/api/v2/" + securePath + "/plan/fetch",
"orders": "/api/v2/" + securePath + "/order/fetch",
"coupons": "/api/v2/" + securePath + "/coupon/fetch",
"users": "/api/v2/" + securePath + "/user/fetch",
"tickets": "/api/v2/" + securePath + "/ticket/fetch",
"giftCardStatus": "/api/v2/" + securePath + "/gift-card/status",
"realnameBase": "/api/v2/" + securePath + "/realname",
"onlineDevices": "/api/v2/" + securePath + "/user-online-devices/users",
},
}
payload := adminAppViewData{