mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(clients): switch client API endpoints from id to email
All client-scoped routes now use the unique email as the path key (get, update, del, attach, detach, links). Email is the stable, protocol-independent identifier — UUIDs don't exist for trojan or shadowsocks, and internal numeric ids leaked panel implementation detail into the public API. Removed the redundant /traffic/byId/:id endpoint (covered by /traffic/:email) and collapsed /links/:id/:email into /links/:email, which now returns links across every attached inbound for the client. Frontend selection, bulk delete, and toggle state are now keyed by email as well, dropping the id→email lookup workaround. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
79fb392a58
commit
1f4e2707a0
11 changed files with 162 additions and 172 deletions
|
|
@ -381,10 +381,10 @@ export const sections = [
|
|||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/:id',
|
||||
summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.',
|
||||
path: '/panel/api/clients/get/:email',
|
||||
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
|
||||
|
|
@ -402,30 +402,30 @@ export const sections = [
|
|||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update/:id',
|
||||
summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.',
|
||||
path: '/panel/api/clients/update/:email',
|
||||
summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
|
||||
],
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del/:id',
|
||||
summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||
path: '/panel/api/clients/del/:email',
|
||||
summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:id/attach',
|
||||
path: '/panel/api/clients/:email/attach',
|
||||
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
|
||||
],
|
||||
body: '{\n "inboundIds": [7, 9]\n}',
|
||||
|
|
@ -433,10 +433,10 @@ export const sections = [
|
|||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:id/detach',
|
||||
path: '/panel/api/clients/:email/detach',
|
||||
summary: 'Detach a client from one or more inbounds without deleting the client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
|
||||
],
|
||||
body: '{\n "inboundIds": [5]\n}',
|
||||
|
|
@ -508,15 +508,6 @@ export const sections = [
|
|||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/traffic/byId/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": [\n {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/subLinks/:subId',
|
||||
|
|
@ -530,12 +521,11 @@ export const sections = [
|
|||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/links/:id/:email',
|
||||
path: '/panel/api/clients/links/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ async function onSubmit() {
|
|||
const toDetach = [...original].filter((id) => !next.has(id));
|
||||
msg = await props.save(clientPayload, {
|
||||
isEdit: true,
|
||||
id: props.client.id,
|
||||
email: props.client.email,
|
||||
attach: toAttach,
|
||||
detach: toDetach,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,17 +62,17 @@ useWebSocket({
|
|||
invalidate: applyInvalidate,
|
||||
});
|
||||
|
||||
const togglingId = ref(null);
|
||||
const togglingEmail = ref(null);
|
||||
|
||||
async function onToggleEnable(row, next) {
|
||||
togglingId.value = row.id;
|
||||
togglingEmail.value = row.email;
|
||||
try {
|
||||
const msg = await setEnable(row, next);
|
||||
if (!msg?.success) {
|
||||
message.error(msg?.msg || t('somethingWentWrong'));
|
||||
}
|
||||
} finally {
|
||||
togglingId.value = null;
|
||||
togglingEmail.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,19 +99,19 @@ const rowSelection = computed(() => ({
|
|||
onChange: (keys) => { selectedRowKeys.value = keys; },
|
||||
}));
|
||||
|
||||
function toggleSelect(id, checked) {
|
||||
function toggleSelect(email, checked) {
|
||||
const cur = new Set(selectedRowKeys.value);
|
||||
if (checked) cur.add(id);
|
||||
else cur.delete(id);
|
||||
if (checked) cur.add(email);
|
||||
else cur.delete(email);
|
||||
selectedRowKeys.value = Array.from(cur);
|
||||
}
|
||||
|
||||
function isSelected(id) {
|
||||
return selectedRowKeys.value.includes(id);
|
||||
function isSelected(email) {
|
||||
return selectedRowKeys.value.includes(email);
|
||||
}
|
||||
|
||||
function selectAll(checked) {
|
||||
selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.id) : [];
|
||||
selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : [];
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
|
|
@ -127,11 +127,11 @@ function onBulkAdd() {
|
|||
}
|
||||
|
||||
function onBulkDelete() {
|
||||
const ids = [...selectedRowKeys.value];
|
||||
if (ids.length === 0) return;
|
||||
const emails = [...selectedRowKeys.value];
|
||||
if (emails.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: t('pages.clients.bulkDeleteConfirmTitle', { count: ids.length })
|
||||
|| `Delete ${ids.length} clients?`,
|
||||
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length })
|
||||
|| `Delete ${emails.length} clients?`,
|
||||
content: t('pages.clients.bulkDeleteConfirmContent')
|
||||
|| 'Each client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.',
|
||||
okText: t('delete'),
|
||||
|
|
@ -140,8 +140,8 @@ function onBulkDelete() {
|
|||
onOk: async () => {
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
const msg = await remove(id);
|
||||
for (const email of emails) {
|
||||
const msg = await remove(email);
|
||||
if (msg?.success) ok++;
|
||||
else failed++;
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ function onDelete(row) {
|
|||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(row.id);
|
||||
const msg = await remove(row.email);
|
||||
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
|
||||
},
|
||||
});
|
||||
|
|
@ -370,15 +370,14 @@ async function onSave(payload, meta) {
|
|||
if (!meta?.isEdit) {
|
||||
return create(payload);
|
||||
}
|
||||
const id = meta.id;
|
||||
const updateMsg = await update(id, payload);
|
||||
const updateMsg = await update(meta.email, payload);
|
||||
if (!updateMsg?.success) return updateMsg;
|
||||
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
|
||||
const r = await attach(id, meta.attach);
|
||||
const r = await attach(meta.email, meta.attach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
|
||||
const r = await detach(id, meta.detach);
|
||||
const r = await detach(meta.email, meta.detach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
return updateMsg;
|
||||
|
|
@ -595,7 +594,7 @@ const columns = computed(() => [
|
|||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-table v-if="!isMobile" :columns="columns" :data-source="filteredClients" :loading="loading" row-key="id"
|
||||
<a-table v-if="!isMobile" :columns="columns" :data-source="filteredClients" :loading="loading" row-key="email"
|
||||
:row-selection="rowSelection"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10', '20', '50', '100'] }"
|
||||
size="small">
|
||||
|
|
@ -640,7 +639,7 @@ const columns = computed(() => [
|
|||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-switch :checked="record.enable" size="small" :loading="togglingId === record.id"
|
||||
<a-switch :checked="record.enable" size="small" :loading="togglingEmail === record.email"
|
||||
@change="(next) => onToggleEnable(record, next)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
|
|
@ -699,11 +698,11 @@ const columns = computed(() => [
|
|||
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="row in filteredClients" :key="row.id" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(row.id) }">
|
||||
<div v-for="row in filteredClients" :key="row.email" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(row.email) }">
|
||||
<div class="card-head">
|
||||
<a-checkbox :checked="isSelected(row.id)"
|
||||
@change="(e) => toggleSelect(row.id, e.target.checked)" />
|
||||
<a-checkbox :checked="isSelected(row.email)"
|
||||
@change="(e) => toggleSelect(row.email, e.target.checked)" />
|
||||
<a-badge :color="bucketTagColor(clientBucket(row))" />
|
||||
<span class="tag-name">{{ row.email }}</span>
|
||||
<a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
|
||||
|
|
@ -716,7 +715,7 @@ const columns = computed(() => [
|
|||
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="row.enable" size="small" :loading="togglingId === row.id"
|
||||
<a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
|
||||
@change="(next) => onToggleEnable(row, next)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
|
|
|
|||
|
|
@ -54,29 +54,37 @@ export function useClients() {
|
|||
return msg;
|
||||
}
|
||||
|
||||
async function update(id, client) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS);
|
||||
async function update(email, client) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function remove(id, keepTraffic = false) {
|
||||
async function remove(email, keepTraffic = false) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${id}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${id}`;
|
||||
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encoded}`;
|
||||
const msg = await HttpUtil.post(url);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function attach(id, inboundIds) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS);
|
||||
async function attach(email, inboundIds) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function detach(id, inboundIds) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS);
|
||||
async function detach(email, inboundIds) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
|
@ -102,7 +110,7 @@ export function useClients() {
|
|||
}
|
||||
|
||||
async function setEnable(client, enable) {
|
||||
if (!client?.id) return null;
|
||||
if (!client?.email) return null;
|
||||
const payload = {
|
||||
email: client.email,
|
||||
subId: client.subId,
|
||||
|
|
@ -115,7 +123,7 @@ export function useClients() {
|
|||
comment: client.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
return update(client.id, payload);
|
||||
return update(client.email, payload);
|
||||
}
|
||||
|
||||
function applyTrafficEvent(payload) {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
|||
spaPages := map[string]bool{
|
||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||
"/panel/clients": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/xray": true, "/panel/api-docs": true,
|
||||
}
|
||||
if spaPages[r.Path] {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package controller
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
|
@ -26,12 +25,12 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
|
|||
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.delete)
|
||||
g.POST("/:id/attach", a.attach)
|
||||
g.POST("/:id/detach", a.detach)
|
||||
g.POST("/update/:email", a.update)
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||
|
|
@ -41,9 +40,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||
g.GET("/traffic/byId/:id", a.getTrafficsByClientID)
|
||||
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||
g.GET("/links/:id/:email", a.getClientLinks)
|
||||
g.GET("/links/:email", a.getClientLinks)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
|
|
@ -56,17 +54,13 @@ func (a *ClientController) list(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
email := c.Param("email")
|
||||
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
rec, err := a.clientService.GetByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
|
|
@ -92,17 +86,13 @@ func (a *ClientController) create(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
var updated model.Client
|
||||
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
|
@ -114,13 +104,9 @@ func (a *ClientController) update(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
|
||||
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
|
@ -136,17 +122,13 @@ type attachDetachBody struct {
|
|||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
|
||||
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
|
@ -277,16 +259,6 @@ func (a *ClientController) getTrafficByEmail(c *gin.Context) {
|
|||
jsonObj(c, traffic, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getTrafficsByClientID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
traffics, err := a.inboundService.GetClientTrafficByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, traffics, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getSubLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||
if err != nil {
|
||||
|
|
@ -297,12 +269,7 @@ func (a *ClientController) getSubLinks(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (a *ClientController) getClientLinks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
||||
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
|
|
@ -311,17 +278,13 @@ func (a *ClientController) getClientLinks(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
|
||||
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
|
||||
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
name string
|
||||
input string
|
||||
// wantCertFile / wantKeyFile: expected presence after sanitize
|
||||
wantCertFile bool
|
||||
wantKeyFile bool
|
||||
|
|
@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) {
|
|||
wantKeyFile: false,
|
||||
},
|
||||
{
|
||||
name: "empty stream settings",
|
||||
name: "empty stream settings",
|
||||
input: "",
|
||||
// empty input returns empty, nothing to check
|
||||
},
|
||||
|
|
|
|||
|
|
@ -496,6 +496,50 @@ func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int,
|
|||
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
|
||||
}
|
||||
|
||||
func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Attach(inboundSvc, rec.Id, inboundIds)
|
||||
}
|
||||
|
||||
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Detach(inboundSvc, rec.Id, inboundIds)
|
||||
}
|
||||
|
||||
func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Delete(inboundSvc, rec.Id, keepTraffic)
|
||||
}
|
||||
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Update(inboundSvc, rec.Id, updated)
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
|
|
@ -571,11 +615,11 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
|
|||
|
||||
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
|
||||
return submitTrafficWrite(func() error {
|
||||
return s.resetAllClientTrafficsLocked(inboundSvc, id)
|
||||
return s.resetAllClientTrafficsLocked(id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ClientService) resetAllClientTrafficsLocked(inboundSvc *InboundService, id int) error {
|
||||
func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
||||
db := database.GetDB()
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
|||
model.Hysteria: true,
|
||||
model.Hysteria2: true,
|
||||
}
|
||||
|
||||
|
||||
if !protocolsWithStream[inbound.Protocol] {
|
||||
inbound.StreamSettings = ""
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
|||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
|
||||
|
||||
exist, err := s.checkPortConflict(inbound, 0)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
|
|
@ -527,7 +527,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
|
||||
|
||||
exist, err := s.checkPortConflict(inbound, inbound.Id)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
|
|
@ -1015,7 +1015,6 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID
|
|||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
|
||||
const resetGracePeriodMs int64 = 30000
|
||||
|
||||
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
|
||||
|
|
@ -1689,8 +1688,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
|||
}
|
||||
|
||||
type target struct {
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
NodeID *int `gorm:"column:node_id"`
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
NodeID *int `gorm:"column:node_id"`
|
||||
Tag string
|
||||
Email string
|
||||
}
|
||||
|
|
@ -1982,7 +1981,6 @@ func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraff
|
|||
return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail)
|
||||
}
|
||||
|
||||
|
||||
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
|
||||
return submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
|
|
@ -2468,33 +2466,6 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64,
|
|||
})
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var traffics []xray.ClientTraffic
|
||||
|
||||
err := db.Model(xray.ClientTraffic{}).Where(`email IN(
|
||||
SELECT JSON_EXTRACT(client.value, '$.email') as email
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE
|
||||
JSON_EXTRACT(client.value, '$.id') in (?)
|
||||
)`, id).Find(&traffics).Error
|
||||
|
||||
if err != nil {
|
||||
logger.Debug(err)
|
||||
return nil, err
|
||||
}
|
||||
// Reconcile enable flag with client settings per email to avoid stale DB value
|
||||
for i := range traffics {
|
||||
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
|
||||
traffics[i].Enable = client.Enable
|
||||
traffics[i].UUID = client.ID
|
||||
traffics[i].SubId = client.SubID
|
||||
}
|
||||
}
|
||||
return traffics, err
|
||||
}
|
||||
|
||||
func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.ClientTraffic, err error) {
|
||||
db := database.GetDB()
|
||||
inbound := &model.Inbound{}
|
||||
|
|
@ -2882,13 +2853,28 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
|
|||
}
|
||||
return registeredSubLinkProvider.SubLinksForSubId(host, subId)
|
||||
}
|
||||
func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) {
|
||||
if email == "" {
|
||||
return nil, common.NewError("client email is required")
|
||||
}
|
||||
if registeredSubLinkProvider == nil {
|
||||
return nil, common.NewError("sub link provider not registered")
|
||||
}
|
||||
return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
|
||||
rec, err := s.clientService.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inboundIds, err := s.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var links []string
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, getErr := s.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return nil, getErr
|
||||
}
|
||||
links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...)
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|||
|
||||
now := time.Now().UnixMilli()
|
||||
type trafficRow struct {
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
Email string
|
||||
Enable bool
|
||||
Total int64
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ type Status struct {
|
|||
ErrorMsg string `json:"errorMsg"`
|
||||
Version string `json:"version"`
|
||||
} `json:"xray"`
|
||||
PanelVersion string `json:"panelVersion"`
|
||||
Uptime uint64 `json:"uptime"`
|
||||
Loads []float64 `json:"loads"`
|
||||
TcpCount int `json:"tcpCount"`
|
||||
UdpCount int `json:"udpCount"`
|
||||
NetIO struct {
|
||||
PanelVersion string `json:"panelVersion"`
|
||||
Uptime uint64 `json:"uptime"`
|
||||
Loads []float64 `json:"loads"`
|
||||
TcpCount int `json:"tcpCount"`
|
||||
UdpCount int `json:"udpCount"`
|
||||
NetIO struct {
|
||||
Up uint64 `json:"up"`
|
||||
Down uint64 `json:"down"`
|
||||
} `json:"netIO"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue