移除被错误提交的测试用exe文件
修复前后端逻辑
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
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; }
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
/* Animation Utilities */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
714
internal/handler/admin_resource_api.go
Normal file
714
internal/handler/admin_resource_api.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func intValue(value *int) any {
|
||||
if value == nil {
|
||||
parsed := intFromAny(value)
|
||||
if parsed <= 0 {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
return int64(parsed)
|
||||
}
|
||||
|
||||
func int64Value(value *int64) any {
|
||||
if value == nil {
|
||||
func nullableString(value any) any {
|
||||
text := strings.TrimSpace(stringFromAny(value))
|
||||
if text == "" {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -63,12 +63,19 @@ func AdminAppPage(c *gin.Context) {
|
||||
"title": service.MustGetString("app_name", "XBoard") + " Admin",
|
||||
"securePath": securePath,
|
||||
"api": map[string]string{
|
||||
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
|
||||
"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",
|
||||
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
|
||||
"saveConfig": "/api/v2/" + securePath + "/config/save",
|
||||
"dashboardSummary": "/api/v2/" + securePath + "/dashboard/summary",
|
||||
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
|
||||
"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{
|
||||
|
||||
Reference in New Issue
Block a user