mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-27 20:53:01 +00:00
feat: add client center for centralized client management
This commit is contained in:
parent
f754246e3e
commit
8b72cfd870
8 changed files with 936 additions and 1 deletions
|
|
@ -36,6 +36,8 @@ func initModels() error {
|
||||||
&model.OutboundTraffics{},
|
&model.OutboundTraffics{},
|
||||||
&model.Setting{},
|
&model.Setting{},
|
||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
|
&model.MasterClient{},
|
||||||
|
&model.MasterClientInbound{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,30 @@ type Client struct {
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MasterClient stores centralized client profile data managed from the Clients page.
|
||||||
|
// Actual protocol clients are still created per inbound and linked via MasterClientInbound.
|
||||||
|
type MasterClient struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserId int `json:"userId" gorm:"index"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
EmailPrefix string `json:"emailPrefix" gorm:"index"`
|
||||||
|
TotalGB int64 `json:"totalGB"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
|
LimitIP int `json:"limitIp"`
|
||||||
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterClientInbound maps a master client to a concrete inbound client record.
|
||||||
|
type MasterClientInbound struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
MasterClientId int `json:"masterClientId" gorm:"index;uniqueIndex:idx_master_inbound,priority:1"`
|
||||||
|
InboundId int `json:"inboundId" gorm:"index;uniqueIndex:idx_master_inbound,priority:2"`
|
||||||
|
AssignmentEmail string `json:"assignmentEmail" gorm:"uniqueIndex"`
|
||||||
|
ClientKey string `json:"clientKey"` // id/password/email key used by inbound update/delete endpoints
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type APIController struct {
|
||||||
BaseController
|
BaseController
|
||||||
inboundController *InboundController
|
inboundController *InboundController
|
||||||
serverController *ServerController
|
serverController *ServerController
|
||||||
|
clientController *ClientCenterController
|
||||||
Tgbot service.Tgbot
|
Tgbot service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +49,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
a.serverController = NewServerController(server)
|
a.serverController = NewServerController(server)
|
||||||
|
|
||||||
|
// Client Center API
|
||||||
|
clients := api.Group("/clients")
|
||||||
|
a.clientController = NewClientCenterController(clients)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
web/controller/client_center.go
Normal file
127
web/controller/client_center.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientCenterController manages centralized client profiles and inbound assignments.
|
||||||
|
type ClientCenterController struct {
|
||||||
|
service service.ClientCenterService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientCenterController(g *gin.RouterGroup) *ClientCenterController {
|
||||||
|
a := &ClientCenterController{}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/list", a.list)
|
||||||
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
g.POST("/add", a.add)
|
||||||
|
g.POST("/update/:id", a.update)
|
||||||
|
g.POST("/del/:id", a.del)
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientCenterUpsertForm struct {
|
||||||
|
Name string `form:"name"`
|
||||||
|
EmailPrefix string `form:"emailPrefix"`
|
||||||
|
TotalGB int64 `form:"totalGB"`
|
||||||
|
ExpiryTime int64 `form:"expiryTime"`
|
||||||
|
LimitIP int `form:"limitIp"`
|
||||||
|
Enable bool `form:"enable"`
|
||||||
|
Comment string `form:"comment"`
|
||||||
|
InboundIds []int `form:"inboundIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) list(c *gin.Context) {
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
items, err := a.service.ListMasterClients(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "get client center list", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, items, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) inbounds(c *gin.Context) {
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
items, err := a.service.ListInbounds(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "get inbounds", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, items, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) add(c *gin.Context) {
|
||||||
|
form := &clientCenterUpsertForm{}
|
||||||
|
if err := c.ShouldBind(form); err != nil {
|
||||||
|
jsonMsg(c, "invalid client payload", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
item, err := a.service.CreateMasterClient(user.Id, service.UpsertMasterClientInput{
|
||||||
|
Name: form.Name,
|
||||||
|
EmailPrefix: form.EmailPrefix,
|
||||||
|
TotalGB: form.TotalGB,
|
||||||
|
ExpiryTime: form.ExpiryTime,
|
||||||
|
LimitIP: form.LimitIP,
|
||||||
|
Enable: form.Enable,
|
||||||
|
Comment: form.Comment,
|
||||||
|
InboundIds: form.InboundIds,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "create master client", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsgObj(c, "master client created", item, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) update(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "invalid client id", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form := &clientCenterUpsertForm{}
|
||||||
|
if err := c.ShouldBind(form); err != nil {
|
||||||
|
jsonMsg(c, "invalid client payload", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
item, err := a.service.UpdateMasterClient(user.Id, id, service.UpsertMasterClientInput{
|
||||||
|
Name: form.Name,
|
||||||
|
EmailPrefix: form.EmailPrefix,
|
||||||
|
TotalGB: form.TotalGB,
|
||||||
|
ExpiryTime: form.ExpiryTime,
|
||||||
|
LimitIP: form.LimitIP,
|
||||||
|
Enable: form.Enable,
|
||||||
|
Comment: form.Comment,
|
||||||
|
InboundIds: form.InboundIds,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "update master client", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsgObj(c, "master client updated", item, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientCenterController) del(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "invalid client id", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
err = a.service.DeleteMasterClient(user.Id, id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "delete master client", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, "master client deleted", nil)
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
g.GET("/clients", a.clients)
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
||||||
|
|
@ -43,6 +44,11 @@ func (a *XUIController) inbounds(c *gin.Context) {
|
||||||
html(c, "inbounds.html", "pages.inbounds.title", nil)
|
html(c, "inbounds.html", "pages.inbounds.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clients renders the centralized clients management page.
|
||||||
|
func (a *XUIController) clients(c *gin.Context) {
|
||||||
|
html(c, "clients.html", "clients", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// settings renders the settings management page.
|
// settings renders the settings management page.
|
||||||
func (a *XUIController) settings(c *gin.Context) {
|
func (a *XUIController) settings(c *gin.Context) {
|
||||||
html(c, "settings.html", "pages.settings.title", nil)
|
html(c, "settings.html", "pages.settings.title", nil)
|
||||||
|
|
|
||||||
304
web/html/clients.html
Normal file
304
web/html/clients.html
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
{{ template "page/head_start" .}}
|
||||||
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
|
{{ template "page/body_start" .}}
|
||||||
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' clients-page'">
|
||||||
|
<a-sidebar></a-sidebar>
|
||||||
|
<a-layout id="content-layout">
|
||||||
|
<a-layout-content>
|
||||||
|
<a-spin :spinning="loading" :delay="200" tip="Loading...">
|
||||||
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
||||||
|
<a-col>
|
||||||
|
<a-card hoverable>
|
||||||
|
<template #title>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" icon="plus" @click="openAdd">Add Master Client</a-button>
|
||||||
|
<a-button icon="sync" @click="refresh">Refresh</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<a-alert
|
||||||
|
v-if="errorMsg"
|
||||||
|
type="error"
|
||||||
|
:message="errorMsg"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="errorMsg = ''"
|
||||||
|
:style="{ marginBottom: '12px' }"
|
||||||
|
></a-alert>
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="clients"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="isMobile ? {} : { x: 1000 }"
|
||||||
|
:pagination="{ pageSize: 10 }"
|
||||||
|
>
|
||||||
|
<template slot="assignments" slot-scope="text, row">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-tag v-for="ib in row.assignments" :key="`${row.id}-${ib.id}`" color="blue">
|
||||||
|
[[ ib.remark ]] ([[ ib.port ]])
|
||||||
|
</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template slot="usage" slot-scope="text, row">
|
||||||
|
[[ SizeFormatter.sizeFormat((row.usageUp || 0) + (row.usageDown || 0)) ]]
|
||||||
|
</template>
|
||||||
|
<template slot="expiry" slot-scope="text, row">
|
||||||
|
[[ formatExpiry(row.expiryTime) ]]
|
||||||
|
</template>
|
||||||
|
<template slot="actions" slot-scope="text, row">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="openEdit(row)">Edit</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="Delete this master client and detach all assignments?"
|
||||||
|
@confirm="remove(row)"
|
||||||
|
ok-text="Delete"
|
||||||
|
cancel-text="Cancel"
|
||||||
|
>
|
||||||
|
<a-button size="small" type="danger">Delete</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
:visible="modal.visible"
|
||||||
|
:title="modal.editingId ? 'Edit Master Client' : 'Add Master Client'"
|
||||||
|
@ok="submit"
|
||||||
|
@cancel="closeModal"
|
||||||
|
ok-text="Save"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="Display Name">
|
||||||
|
<a-input v-model.trim="form.name" placeholder="e.g. Team-A Premium"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Email Prefix (for generated assignment emails)">
|
||||||
|
<a-input v-model.trim="form.emailPrefix" placeholder="e.g. teama"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="Traffic Quota (GB)">
|
||||||
|
<a-input-number v-model="form.totalGB" :min="0" :style="{ width: '100%' }"></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="IP Limit">
|
||||||
|
<a-input-number v-model="form.limitIp" :min="0" :style="{ width: '100%' }"></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
<a-form-item label="Expiry Date">
|
||||||
|
<a-date-picker
|
||||||
|
show-time
|
||||||
|
:style="{ width: '100%' }"
|
||||||
|
:value="form.expiryMoment"
|
||||||
|
@change="onExpiryChange"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
></a-date-picker>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Assigned Inbounds">
|
||||||
|
<a-select
|
||||||
|
mode="multiple"
|
||||||
|
v-model="form.inboundIds"
|
||||||
|
:style="{ width: '100%' }"
|
||||||
|
placeholder="Select inbounds"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="ib in availableInbounds" :key="ib.id" :value="ib.id">
|
||||||
|
[[ ib.remark ]] (port [[ ib.port ]], [[ ib.protocol ]])
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-switch v-model="form.enable"></a-switch>
|
||||||
|
<span :style="{ marginLeft: '8px' }">Enabled</span>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Comment">
|
||||||
|
<a-input v-model.trim="form.comment" placeholder="Optional note"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</a-spin>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
{{template "page/body_scripts" .}}
|
||||||
|
{{template "component/aSidebar" .}}
|
||||||
|
{{template "component/aThemeSwitch" .}}
|
||||||
|
<script>
|
||||||
|
const app = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
mixins: [MediaQueryMixin],
|
||||||
|
el: '#app',
|
||||||
|
data: {
|
||||||
|
themeSwitcher,
|
||||||
|
loading: false,
|
||||||
|
errorMsg: '',
|
||||||
|
clients: [],
|
||||||
|
availableInbounds: [],
|
||||||
|
columns: [
|
||||||
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Prefix', dataIndex: 'emailPrefix', key: 'emailPrefix' },
|
||||||
|
{ title: 'Quota', dataIndex: 'totalGB', key: 'totalGB', customRender: (v) => `${v} GB` },
|
||||||
|
{ title: 'IP Limit', dataIndex: 'limitIp', key: 'limitIp' },
|
||||||
|
{ title: 'Expiry', key: 'expiry', scopedSlots: { customRender: 'expiry' } },
|
||||||
|
{ title: 'Assigned Inbounds', key: 'assignments', scopedSlots: { customRender: 'assignments' } },
|
||||||
|
{ title: 'Usage', key: 'usage', scopedSlots: { customRender: 'usage' } },
|
||||||
|
{
|
||||||
|
title: 'Enabled',
|
||||||
|
dataIndex: 'enable',
|
||||||
|
key: 'enable',
|
||||||
|
customRender: (v) => v ? 'Yes' : 'No'
|
||||||
|
},
|
||||||
|
{ title: 'Actions', key: 'actions', scopedSlots: { customRender: 'actions' } },
|
||||||
|
],
|
||||||
|
modal: {
|
||||||
|
visible: false,
|
||||||
|
editingId: 0,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
name: '',
|
||||||
|
emailPrefix: '',
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
expiryMoment: null,
|
||||||
|
limitIp: 0,
|
||||||
|
enable: true,
|
||||||
|
comment: '',
|
||||||
|
inboundIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatExpiry(ts) {
|
||||||
|
if (!ts || ts <= 0) return 'Never';
|
||||||
|
return moment(ts).format('YYYY-MM-DD HH:mm');
|
||||||
|
},
|
||||||
|
onExpiryChange(value) {
|
||||||
|
this.form.expiryMoment = value;
|
||||||
|
this.form.expiryTime = value ? value.valueOf() : 0;
|
||||||
|
},
|
||||||
|
resetForm() {
|
||||||
|
this.form = {
|
||||||
|
name: '',
|
||||||
|
emailPrefix: '',
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
expiryMoment: null,
|
||||||
|
limitIp: 0,
|
||||||
|
enable: true,
|
||||||
|
comment: '',
|
||||||
|
inboundIds: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
closeModal() {
|
||||||
|
this.modal.visible = false;
|
||||||
|
this.modal.editingId = 0;
|
||||||
|
this.resetForm();
|
||||||
|
},
|
||||||
|
openAdd() {
|
||||||
|
this.resetForm();
|
||||||
|
this.modal.editingId = 0;
|
||||||
|
this.modal.visible = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = {
|
||||||
|
name: row.name,
|
||||||
|
emailPrefix: row.emailPrefix,
|
||||||
|
totalGB: row.totalGB,
|
||||||
|
expiryTime: row.expiryTime || 0,
|
||||||
|
expiryMoment: row.expiryTime > 0 ? moment(row.expiryTime) : null,
|
||||||
|
limitIp: row.limitIp,
|
||||||
|
enable: !!row.enable,
|
||||||
|
comment: row.comment || '',
|
||||||
|
inboundIds: (row.assignments || []).map(a => a.id),
|
||||||
|
};
|
||||||
|
this.modal.editingId = row.id;
|
||||||
|
this.modal.visible = true;
|
||||||
|
},
|
||||||
|
async fetchInbounds() {
|
||||||
|
const res = await axios.get('panel/api/clients/inbounds');
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.msg || 'Failed to fetch inbounds');
|
||||||
|
}
|
||||||
|
this.availableInbounds = res.data.obj || [];
|
||||||
|
},
|
||||||
|
async fetchClients() {
|
||||||
|
const res = await axios.get('panel/api/clients/list');
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.msg || 'Failed to fetch clients');
|
||||||
|
}
|
||||||
|
this.clients = res.data.obj || [];
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
try {
|
||||||
|
await Promise.all([this.fetchInbounds(), this.fetchClients()]);
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = e.message || String(e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (!this.form.name) {
|
||||||
|
return app.$message.error('Name is required');
|
||||||
|
}
|
||||||
|
if (!this.form.emailPrefix) {
|
||||||
|
return app.$message.error('Email prefix is required');
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: this.form.name,
|
||||||
|
emailPrefix: this.form.emailPrefix,
|
||||||
|
totalGB: this.form.totalGB || 0,
|
||||||
|
expiryTime: this.form.expiryTime || 0,
|
||||||
|
limitIp: this.form.limitIp || 0,
|
||||||
|
enable: !!this.form.enable,
|
||||||
|
comment: this.form.comment || '',
|
||||||
|
inboundIds: this.form.inboundIds || [],
|
||||||
|
};
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (this.modal.editingId) {
|
||||||
|
res = await axios.post(`panel/api/clients/update/${this.modal.editingId}`, payload);
|
||||||
|
} else {
|
||||||
|
res = await axios.post('panel/api/clients/add', payload);
|
||||||
|
}
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.msg || 'Save failed');
|
||||||
|
}
|
||||||
|
app.$message.success(res.data.msg || 'Saved');
|
||||||
|
this.closeModal();
|
||||||
|
await this.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = e.message || String(e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async remove(row) {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`panel/api/clients/del/${row.id}`);
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.msg || 'Delete failed');
|
||||||
|
}
|
||||||
|
app.$message.success(res.data.msg || 'Deleted');
|
||||||
|
await this.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = e.message || String(e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{template "page/body_end" .}}
|
||||||
|
|
@ -54,6 +54,11 @@
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
title: '{{ i18n "menu.inbounds"}}'
|
title: '{{ i18n "menu.inbounds"}}'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '{{ .base_path }}panel/clients',
|
||||||
|
icon: 'team',
|
||||||
|
title: 'Clients'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '{{ .base_path }}panel/settings',
|
key: '{{ .base_path }}panel/settings',
|
||||||
icon: 'setting',
|
icon: 'setting',
|
||||||
|
|
|
||||||
459
web/service/client_center.go
Normal file
459
web/service/client_center.go
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientCenterService provides client-first management on top of inbound-scoped clients.
|
||||||
|
// It stores master client profiles and synchronizes assigned inbound clients safely.
|
||||||
|
type ClientCenterService struct {
|
||||||
|
inboundService InboundService
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientCenterInboundInfo struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MasterClientView struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
EmailPrefix string `json:"emailPrefix"`
|
||||||
|
TotalGB int64 `json:"totalGB"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
|
LimitIP int `json:"limitIp"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Assignments []ClientCenterInboundInfo `json:"assignments"`
|
||||||
|
UsageUp int64 `json:"usageUp"`
|
||||||
|
UsageDown int64 `json:"usageDown"`
|
||||||
|
UsageAllTime int64 `json:"usageAllTime"`
|
||||||
|
LastSeenOnlineAt int64 `json:"lastSeenOnlineAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertMasterClientInput struct {
|
||||||
|
Name string
|
||||||
|
EmailPrefix string
|
||||||
|
TotalGB int64
|
||||||
|
ExpiryTime int64
|
||||||
|
LimitIP int
|
||||||
|
Enable bool
|
||||||
|
Comment string
|
||||||
|
InboundIds []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) ListInbounds(userId int) ([]ClientCenterInboundInfo, error) {
|
||||||
|
inbounds, err := s.inboundService.GetInbounds(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ClientCenterInboundInfo, 0, len(inbounds))
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
if !supportsManagedClients(inbound.Protocol) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ClientCenterInboundInfo{
|
||||||
|
Id: inbound.Id,
|
||||||
|
Remark: inbound.Remark,
|
||||||
|
Protocol: string(inbound.Protocol),
|
||||||
|
Port: inbound.Port,
|
||||||
|
Enable: inbound.Enable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Id < out[j].Id })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) ListMasterClients(userId int) ([]MasterClientView, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
masters := make([]model.MasterClient, 0)
|
||||||
|
if err := db.Where("user_id = ?", userId).Order("id asc").Find(&masters).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(masters) == 0 {
|
||||||
|
return []MasterClientView{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
masterIDs := make([]int, 0, len(masters))
|
||||||
|
for _, m := range masters {
|
||||||
|
masterIDs = append(masterIDs, m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
links := make([]model.MasterClientInbound, 0)
|
||||||
|
if err := db.Where("master_client_id IN ?", masterIDs).Order("id asc").Find(&links).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inbounds, err := s.inboundService.GetInbounds(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inboundByID := map[int]*model.Inbound{}
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
inboundByID[inbound.Id] = inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
emails := make([]string, 0, len(links))
|
||||||
|
for _, l := range links {
|
||||||
|
emails = append(emails, l.AssignmentEmail)
|
||||||
|
}
|
||||||
|
trafficByEmail := map[string]xray.ClientTraffic{}
|
||||||
|
if len(emails) > 0 {
|
||||||
|
stats := make([]xray.ClientTraffic, 0)
|
||||||
|
if err := db.Where("email IN ?", emails).Find(&stats).Error; err == nil {
|
||||||
|
for _, st := range stats {
|
||||||
|
trafficByEmail[strings.ToLower(st.Email)] = st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
linksByMaster := map[int][]model.MasterClientInbound{}
|
||||||
|
for _, l := range links {
|
||||||
|
linksByMaster[l.MasterClientId] = append(linksByMaster[l.MasterClientId], l)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]MasterClientView, 0, len(masters))
|
||||||
|
for _, m := range masters {
|
||||||
|
view := MasterClientView{
|
||||||
|
Id: m.Id,
|
||||||
|
Name: m.Name,
|
||||||
|
EmailPrefix: m.EmailPrefix,
|
||||||
|
TotalGB: m.TotalGB,
|
||||||
|
ExpiryTime: m.ExpiryTime,
|
||||||
|
LimitIP: m.LimitIP,
|
||||||
|
Enable: m.Enable,
|
||||||
|
Comment: m.Comment,
|
||||||
|
}
|
||||||
|
for _, link := range linksByMaster[m.Id] {
|
||||||
|
if inbound, ok := inboundByID[link.InboundId]; ok {
|
||||||
|
view.Assignments = append(view.Assignments, ClientCenterInboundInfo{
|
||||||
|
Id: inbound.Id,
|
||||||
|
Remark: inbound.Remark,
|
||||||
|
Protocol: string(inbound.Protocol),
|
||||||
|
Port: inbound.Port,
|
||||||
|
Enable: inbound.Enable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if st, ok := trafficByEmail[strings.ToLower(link.AssignmentEmail)]; ok {
|
||||||
|
view.UsageUp += st.Up
|
||||||
|
view.UsageDown += st.Down
|
||||||
|
view.UsageAllTime += st.AllTime
|
||||||
|
if st.LastOnline > view.LastSeenOnlineAt {
|
||||||
|
view.LastSeenOnlineAt = st.LastOnline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(view.Assignments, func(i, j int) bool { return view.Assignments[i].Id < view.Assignments[j].Id })
|
||||||
|
result = append(result, view)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) CreateMasterClient(userId int, input UpsertMasterClientInput) (*model.MasterClient, error) {
|
||||||
|
normalized, err := normalizeMasterInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
master := &model.MasterClient{
|
||||||
|
UserId: userId,
|
||||||
|
Name: normalized.Name,
|
||||||
|
EmailPrefix: normalized.EmailPrefix,
|
||||||
|
TotalGB: normalized.TotalGB,
|
||||||
|
ExpiryTime: normalized.ExpiryTime,
|
||||||
|
LimitIP: normalized.LimitIP,
|
||||||
|
Enable: normalized.Enable,
|
||||||
|
Comment: normalized.Comment,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.Create(master).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.syncMasterAssignments(userId, master, normalized.InboundIds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) UpdateMasterClient(userId, masterID int, input UpsertMasterClientInput) (*model.MasterClient, error) {
|
||||||
|
normalized, err := normalizeMasterInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
master := &model.MasterClient{}
|
||||||
|
if err := db.Where("id = ? AND user_id = ?", masterID, userId).First(master).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
master.Name = normalized.Name
|
||||||
|
master.EmailPrefix = normalized.EmailPrefix
|
||||||
|
master.TotalGB = normalized.TotalGB
|
||||||
|
master.ExpiryTime = normalized.ExpiryTime
|
||||||
|
master.LimitIP = normalized.LimitIP
|
||||||
|
master.Enable = normalized.Enable
|
||||||
|
master.Comment = normalized.Comment
|
||||||
|
master.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
if err := db.Save(master).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.syncMasterAssignments(userId, master, normalized.InboundIds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) DeleteMasterClient(userId, masterID int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
master := &model.MasterClient{}
|
||||||
|
if err := db.Where("id = ? AND user_id = ?", masterID, userId).First(master).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
links := make([]model.MasterClientInbound, 0)
|
||||||
|
if err := db.Where("master_client_id = ?", masterID).Find(&links).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range links {
|
||||||
|
if err := s.removeAssignment(link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Where("master_client_id = ?", masterID).Delete(&model.MasterClientInbound{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Delete(master).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) syncMasterAssignments(userId int, master *model.MasterClient, desiredInboundIDs []int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
links := make([]model.MasterClientInbound, 0)
|
||||||
|
if err := db.Where("master_client_id = ?", master.Id).Find(&links).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
desired := map[int]bool{}
|
||||||
|
for _, id := range desiredInboundIDs {
|
||||||
|
desired[id] = true
|
||||||
|
}
|
||||||
|
existing := map[int]model.MasterClientInbound{}
|
||||||
|
for _, l := range links {
|
||||||
|
existing[l.InboundId] = l
|
||||||
|
}
|
||||||
|
|
||||||
|
inbounds, err := s.inboundService.GetInbounds(userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inboundByID := map[int]*model.Inbound{}
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
inboundByID[inbound.Id] = inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
for inboundID := range desired {
|
||||||
|
inbound, ok := inboundByID[inboundID]
|
||||||
|
if !ok {
|
||||||
|
return common.NewError("inbound not found for user:", inboundID)
|
||||||
|
}
|
||||||
|
if !supportsManagedClients(inbound.Protocol) {
|
||||||
|
return common.NewError("inbound protocol is not multi-client:", inbound.Protocol)
|
||||||
|
}
|
||||||
|
if link, exists := existing[inboundID]; exists {
|
||||||
|
if err := s.updateAssignment(master, inbound, link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.createAssignment(master, inboundID, inbound); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for inboundID, link := range existing {
|
||||||
|
if desired[inboundID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.removeAssignment(link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Delete(&link).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) createAssignment(master *model.MasterClient, inboundID int, inbound *model.Inbound) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
assignEmail := s.newAssignmentEmail(master.EmailPrefix, inboundID)
|
||||||
|
client, clientKey := buildProtocolClient(master, assignEmail, inbound.Protocol)
|
||||||
|
|
||||||
|
payload := map[string]any{"clients": []model.Client{client}}
|
||||||
|
settingsJSON, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := &model.Inbound{Id: inboundID, Settings: string(settingsJSON)}
|
||||||
|
if _, err := s.inboundService.AddInboundClient(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
link := &model.MasterClientInbound{
|
||||||
|
MasterClientId: master.Id,
|
||||||
|
InboundId: inboundID,
|
||||||
|
AssignmentEmail: assignEmail,
|
||||||
|
ClientKey: clientKey,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return db.Create(link).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) updateAssignment(master *model.MasterClient, inbound *model.Inbound, link model.MasterClientInbound) error {
|
||||||
|
client, _ := buildProtocolClient(master, link.AssignmentEmail, inbound.Protocol)
|
||||||
|
payload := map[string]any{"clients": []model.Client{client}}
|
||||||
|
settingsJSON, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := &model.Inbound{Id: inbound.Id, Settings: string(settingsJSON)}
|
||||||
|
if _, err := s.inboundService.UpdateInboundClient(data, link.ClientKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return database.GetDB().Save(&link).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) removeAssignment(link model.MasterClientInbound) error {
|
||||||
|
_, err := s.inboundService.DelInboundClient(link.InboundId, link.ClientKey)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "no client remained") {
|
||||||
|
return common.NewError("cannot detach from inbound because it would leave inbound without clients")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsManagedClients(protocol model.Protocol) bool {
|
||||||
|
switch protocol {
|
||||||
|
case model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMasterInput(input UpsertMasterClientInput) (UpsertMasterClientInput, error) {
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.EmailPrefix = strings.TrimSpace(strings.ToLower(input.EmailPrefix))
|
||||||
|
input.Comment = strings.TrimSpace(input.Comment)
|
||||||
|
if input.Name == "" {
|
||||||
|
return input, errors.New("name is required")
|
||||||
|
}
|
||||||
|
if input.EmailPrefix == "" {
|
||||||
|
return input, errors.New("email prefix is required")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(input.EmailPrefix, " @") {
|
||||||
|
return input, errors.New("email prefix cannot contain spaces or '@'")
|
||||||
|
}
|
||||||
|
if input.TotalGB < 0 {
|
||||||
|
return input, errors.New("totalGB cannot be negative")
|
||||||
|
}
|
||||||
|
if input.LimitIP < 0 {
|
||||||
|
return input, errors.New("limitIp cannot be negative")
|
||||||
|
}
|
||||||
|
if input.ExpiryTime < 0 {
|
||||||
|
return input, errors.New("expiryTime cannot be negative")
|
||||||
|
}
|
||||||
|
input.InboundIds = dedupeInboundIDs(input.InboundIds)
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeInboundIDs(ids []int) []int {
|
||||||
|
set := map[int]bool{}
|
||||||
|
out := make([]int, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || set[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) newAssignmentEmail(prefix string, inboundID int) string {
|
||||||
|
base := strings.TrimSpace(strings.ToLower(prefix))
|
||||||
|
if base == "" {
|
||||||
|
base = "client"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s.%s.%s@local", base, strconv.Itoa(inboundID), random.Seq(6))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProtocolClient(master *model.MasterClient, assignmentEmail string, protocol model.Protocol) (model.Client, string) {
|
||||||
|
client := model.Client{
|
||||||
|
Email: assignmentEmail,
|
||||||
|
LimitIP: master.LimitIP,
|
||||||
|
TotalGB: master.TotalGB,
|
||||||
|
ExpiryTime: master.ExpiryTime,
|
||||||
|
Enable: master.Enable,
|
||||||
|
SubID: random.Seq(16),
|
||||||
|
Comment: master.Comment,
|
||||||
|
Reset: 0,
|
||||||
|
}
|
||||||
|
switch protocol {
|
||||||
|
case model.Trojan:
|
||||||
|
client.Password = random.Seq(18)
|
||||||
|
return client, client.Password
|
||||||
|
case model.Shadowsocks:
|
||||||
|
client.Password = random.Seq(18)
|
||||||
|
return client, client.Email
|
||||||
|
case model.VMESS:
|
||||||
|
client.ID = uuid.NewString()
|
||||||
|
client.Security = "auto"
|
||||||
|
return client, client.ID
|
||||||
|
default: // vless and other UUID-based protocols
|
||||||
|
client.ID = uuid.NewString()
|
||||||
|
return client, client.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) GetMasterClient(userId, masterID int) (*model.MasterClient, error) {
|
||||||
|
master := &model.MasterClient{}
|
||||||
|
err := database.GetDB().Where("id = ? and user_id = ?", masterID, userId).First(master).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) EnsureTablesReady() error {
|
||||||
|
// No-op helper to keep service extension points explicit.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientCenterService) IsNotFound(err error) bool {
|
||||||
|
return err == gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue