mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form
Bulk add/delete were serial on the frontend (one toast per call, N round-trips)
and the backend race exposed by parallelizing them lost client attachments and
hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also
had no Start-After-First-Use option, and the table never showed the delayed
duration.
Backend (web/service/client.go):
- Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on
the same inbound don't lose the read-modify-write of settings JSON.
- SyncInbound skips create+join when the email is tombstoned so a concurrent
maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn-
Settings) that did a stale RMW can't resurrect a just-deleted client with a
fresh id.
- compactOrphans sweeps settings.clients entries whose ClientRecord no longer
exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each
user-initiated mutation self-heals the inbound's settings.
- DelInboundClient uses Pluck instead of First for the stats lookup so a
missing row doesn't abort the delete with a noisy ErrRecordNotFound log.
Frontend:
- HttpUtil.{get,post} accept a silent option that suppresses the auto-toast.
- ClientBulkAddModal fires creates in parallel + silent + one summary toast.
- useClients.removeMany runs deletes in parallel + silent and refreshes once;
ClientsPage bulk delete uses it and shows one aggregate toast.
- useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket
invalidate events from the backend collapses into a single refresh.
- ClientsPage pagination is reactive (paginationState ref + tablePagination
computed); onTableChange persists page-size and page changes.
- ClientFormModal gains a Start-After-First-Use switch + Duration days input
alongside the existing Expiry Date picker; on edit-mode open a negative
expiryTime is decoded back to delayed mode + days; on submit the payload
sends -86400000 * days or the absolute timestamp.
- ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip
Start After First Use: Nd) instead of infinity.
- Telegram ID field in the form is hidden when /panel/setting/defaultSettings
reports tgBotEnable=false; Comment then fills the row.
- Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4)
when ipLimitEnable is on, else UUID + Total GB at 12/12.
- useInbounds.rollupClients counts only clients with a matching clientStats
row, so orphans in settings.clients no longer inflate the inbound's count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
086a74328a
commit
072d266f50
7 changed files with 226 additions and 48 deletions
|
|
@ -136,10 +136,9 @@ async function submit() {
|
|||
if (emails.length === 0) return;
|
||||
|
||||
saving.value = true;
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
|
||||
try {
|
||||
for (const email of emails) {
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const client = {
|
||||
email,
|
||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||
|
|
@ -154,14 +153,24 @@ async function submit() {
|
|||
enable: true,
|
||||
};
|
||||
const payload = { client, inboundIds: form.inboundIds };
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
|
||||
}));
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
let firstError = '';
|
||||
for (const msg of results) {
|
||||
if (msg?.success) ok++;
|
||||
else failed++;
|
||||
else {
|
||||
failed++;
|
||||
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||
} else {
|
||||
message.warning(t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||
message.warning(firstError
|
||||
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||
}
|
||||
emit('saved');
|
||||
close();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const props = defineProps({
|
|||
inbounds: { type: Array, default: () => [] },
|
||||
attachedIds: { type: Array, default: () => [] },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
save: { type: Function, required: true },
|
||||
});
|
||||
|
||||
|
|
@ -34,7 +35,9 @@ function emptyForm() {
|
|||
flow: '',
|
||||
reverseTag: '',
|
||||
totalGB: 0,
|
||||
expiryTime: null,
|
||||
expiryDate: null,
|
||||
delayedStart: false,
|
||||
delayedDays: 0,
|
||||
limitIp: 0,
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
|
|
@ -59,7 +62,16 @@ watch(
|
|||
form.flow = props.client.flow || '';
|
||||
form.reverseTag = props.client.reverse?.tag || '';
|
||||
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
||||
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
|
||||
const et = Number(props.client.expiryTime) || 0;
|
||||
if (et < 0) {
|
||||
form.delayedStart = true;
|
||||
form.delayedDays = Math.round(et / -86400000);
|
||||
form.expiryDate = null;
|
||||
} else {
|
||||
form.delayedStart = false;
|
||||
form.delayedDays = 0;
|
||||
form.expiryDate = et > 0 ? dayjs(et) : null;
|
||||
}
|
||||
form.limitIp = props.client.limitIp || 0;
|
||||
form.tgId = Number(props.client.tgId) || 0;
|
||||
form.comment = props.client.comment || '';
|
||||
|
|
@ -186,6 +198,14 @@ function regenerateEmail() {
|
|||
form.email = RandomUtil.randomLowerAndNum(12);
|
||||
}
|
||||
|
||||
function onDelayedStartToggle(next) {
|
||||
if (next) {
|
||||
form.expiryDate = null;
|
||||
} else {
|
||||
form.delayedDays = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.email || form.email.trim() === '') {
|
||||
message.error(`${t('pages.clients.email')} *`);
|
||||
|
|
@ -195,6 +215,9 @@ async function onSubmit() {
|
|||
message.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const expiryTime = form.delayedStart
|
||||
? -86400000 * (Number(form.delayedDays) || 0)
|
||||
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
|
||||
const clientPayload = {
|
||||
email: form.email.trim(),
|
||||
subId: form.subId,
|
||||
|
|
@ -203,7 +226,7 @@ async function onSubmit() {
|
|||
auth: form.auth,
|
||||
flow: showFlow.value ? (form.flow || '') : '',
|
||||
totalGB: gbToBytes(form.totalGB),
|
||||
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
|
||||
expiryTime,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
tgId: Number(form.tgId) || 0,
|
||||
comment: form.comment,
|
||||
|
|
@ -285,7 +308,7 @@ async function onSubmit() {
|
|||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="ipLimitEnable ? 12 : 24">
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.clients.uuid')">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||
|
|
@ -293,7 +316,12 @@ async function onSubmit() {
|
|||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="ipLimitEnable" :span="12">
|
||||
<a-col :span="ipLimitEnable ? 8 : 12">
|
||||
<a-form-item :label="t('pages.clients.totalGB')">
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="ipLimitEnable" :span="4">
|
||||
<a-form-item :label="t('pages.clients.limitIp')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
|
@ -302,13 +330,16 @@ async function onSubmit() {
|
|||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.clients.totalGB')">
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
||||
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
|
||||
<a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item v-else :label="t('pages.clients.expiryTime')">
|
||||
<a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.clients.expiryTime')">
|
||||
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
|
||||
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
|
@ -330,14 +361,13 @@ async function onSubmit() {
|
|||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-col v-if="tgBotEnable" :span="12">
|
||||
<a-form-item :label="t('pages.clients.telegramId')">
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
|
||||
:placeholder="t('pages.clients.telegramIdPlaceholder')"
|
||||
style="width: 100%" />
|
||||
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-col :span="tgBotEnable ? 12 : 24">
|
||||
<a-form-item :label="t('pages.clients.comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
|
|
|||
|
|
@ -40,11 +40,13 @@ const {
|
|||
fetched,
|
||||
subSettings,
|
||||
ipLimitEnable,
|
||||
tgBotEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
attach,
|
||||
detach,
|
||||
resetTraffic,
|
||||
|
|
@ -136,18 +138,24 @@ function onBulkDelete() {
|
|||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const results = await removeMany(emails);
|
||||
selectedRowKeys.value = [];
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const email of emails) {
|
||||
const msg = await remove(email);
|
||||
let firstError = '';
|
||||
for (const msg of results) {
|
||||
if (msg?.success) ok++;
|
||||
else failed++;
|
||||
else {
|
||||
failed++;
|
||||
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||
}
|
||||
}
|
||||
selectedRowKeys.value = [];
|
||||
if (failed === 0) {
|
||||
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
||||
} else {
|
||||
message.warning(t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
|
||||
message.warning(firstError
|
||||
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -405,17 +413,26 @@ function remainingColor(row) {
|
|||
}
|
||||
|
||||
function expiryLabel(row) {
|
||||
if (!row.expiryTime || row.expiryTime <= 0) return '∞';
|
||||
if (!row.expiryTime) return '∞';
|
||||
if (row.expiryTime < 0) {
|
||||
const days = Math.round(row.expiryTime / -86400000);
|
||||
return `${t('pages.clients.delayedStart')}: ${days}d`;
|
||||
}
|
||||
return IntlUtil.formatDate(row.expiryTime);
|
||||
}
|
||||
|
||||
function expiryRelative(row) {
|
||||
if (!row.expiryTime || row.expiryTime <= 0) return '';
|
||||
if (!row.expiryTime) return '';
|
||||
if (row.expiryTime < 0) {
|
||||
const days = Math.round(row.expiryTime / -86400000);
|
||||
return `${days}d`;
|
||||
}
|
||||
return IntlUtil.formatRelativeTime(row.expiryTime);
|
||||
}
|
||||
|
||||
function expiryColor(row) {
|
||||
if (!row.expiryTime || row.expiryTime <= 0) return 'purple';
|
||||
if (!row.expiryTime) return 'purple';
|
||||
if (row.expiryTime < 0) return 'blue';
|
||||
const now = Date.now();
|
||||
if (row.expiryTime <= now) return 'red';
|
||||
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
|
||||
|
|
@ -423,6 +440,7 @@ function expiryColor(row) {
|
|||
}
|
||||
|
||||
const sortState = ref({ column: null, order: null });
|
||||
const paginationState = ref({ current: 1, pageSize: 20 });
|
||||
|
||||
function sortableCol(col, key) {
|
||||
return {
|
||||
|
|
@ -465,13 +483,28 @@ const sortedClients = computed(() => {
|
|||
return order === 'descend' ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
function onTableChange(_pag, _filters, sorter) {
|
||||
function onTableChange(pag, _filters, sorter) {
|
||||
if (pag) {
|
||||
paginationState.value = {
|
||||
current: pag.current || 1,
|
||||
pageSize: pag.pageSize || paginationState.value.pageSize,
|
||||
};
|
||||
}
|
||||
sortState.value = {
|
||||
column: sorter?.columnKey || sorter?.field || null,
|
||||
order: sorter?.order || null,
|
||||
};
|
||||
}
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: paginationState.value.current,
|
||||
pageSize: paginationState.value.pageSize,
|
||||
total: sortedClients.value.length,
|
||||
showSizeChanger: sortedClients.value.length > 10,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize,
|
||||
}));
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.clients.actions'), key: 'actions', width: 200 },
|
||||
sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
|
||||
|
|
@ -638,9 +671,7 @@ const columns = computed(() => [
|
|||
</div>
|
||||
|
||||
<a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
|
||||
:row-selection="rowSelection"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: sortedClients.length > 10, pageSizeOptions: ['10', '20', '50', '100'], hideOnSinglePage: sortedClients.length <= 10 }"
|
||||
size="small" @change="onTableChange">
|
||||
:row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'email'">
|
||||
<div class="email-cell">
|
||||
|
|
@ -677,7 +708,7 @@ const columns = computed(() => [
|
|||
<template v-else-if="column.key === 'expiryTime'">
|
||||
<a-tooltip :title="expiryLabel(record)">
|
||||
<a-tag :color="expiryColor(record)">
|
||||
{{ record.expiryTime > 0 ? expiryRelative(record) : '∞' }}
|
||||
{{ record.expiryTime ? expiryRelative(record) : '∞' }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
|
@ -792,7 +823,8 @@ const columns = computed(() => [
|
|||
</a-layout>
|
||||
|
||||
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
|
||||
:attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable" :save="onSave" />
|
||||
:attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
|
||||
:tg-bot-enable="tgBotEnable" :save="onSave" />
|
||||
<ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
|
||||
:is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
|
||||
<ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function useClients() {
|
|||
const fetched = ref(false);
|
||||
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
|
||||
const ipLimitEnable = ref(false);
|
||||
const tgBotEnable = ref(false);
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export function useClients() {
|
|||
subJsonEnable: !!s.subJsonEnable,
|
||||
};
|
||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||
tgBotEnable.value = !!s.tgBotEnable;
|
||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
}
|
||||
|
|
@ -73,6 +75,18 @@ export function useClients() {
|
|||
return msg;
|
||||
}
|
||||
|
||||
async function removeMany(emails, keepTraffic = false) {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const silentOpts = { silent: true };
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, silentOpts);
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}
|
||||
|
||||
async function attach(email, inboundIds) {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
|
|
@ -159,11 +173,15 @@ export function useClients() {
|
|||
if (touched) clients.value = [...next];
|
||||
}
|
||||
|
||||
let invalidateTimer = null;
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type === 'inbounds' || payload.type === 'clients') {
|
||||
if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
refresh();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -178,12 +196,14 @@ export function useClients() {
|
|||
fetched,
|
||||
subSettings,
|
||||
ipLimitEnable,
|
||||
tgBotEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
attach,
|
||||
detach,
|
||||
resetTraffic,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,14 @@ export function useInbounds() {
|
|||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||
function rollupClients(dbInbound, inbound) {
|
||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||
const clients = inbound?.clients || [];
|
||||
const allClients = inbound?.clients || [];
|
||||
const statsEmails = new Set();
|
||||
for (const s of clientStats) {
|
||||
if (s && s.email) statsEmails.add(s.email);
|
||||
}
|
||||
const clients = clientStats.length > 0
|
||||
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
|
||||
: allClients;
|
||||
const active = [];
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
|
|
|
|||
|
|
@ -33,29 +33,31 @@ export class HttpUtil {
|
|||
}
|
||||
|
||||
static async get(url, params, options = {}) {
|
||||
const { silent, ...axiosOpts } = options;
|
||||
try {
|
||||
const resp = await axios.get(url, { params, ...options });
|
||||
const resp = await axios.get(url, { params, ...axiosOpts });
|
||||
const msg = this._respToMsg(resp);
|
||||
this._handleMsg(msg);
|
||||
if (!silent) this._handleMsg(msg);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('GET request failed:', error);
|
||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||
this._handleMsg(errorMsg);
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
|
||||
static async post(url, data, options = {}) {
|
||||
const { silent, ...axiosOpts } = options;
|
||||
try {
|
||||
const resp = await axios.post(url, data, options);
|
||||
const resp = await axios.post(url, data, axiosOpts);
|
||||
const msg = this._respToMsg(resp);
|
||||
this._handleMsg(msg);
|
||||
if (!silent) this._handleMsg(msg);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('POST request failed:', error);
|
||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||
this._handleMsg(errorMsg);
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,71 @@ var (
|
|||
|
||||
const deleteTombstoneTTL = 90 * time.Second
|
||||
|
||||
var (
|
||||
inboundMutationLocksMu sync.Mutex
|
||||
inboundMutationLocks = map[int]*sync.Mutex{}
|
||||
)
|
||||
|
||||
func lockInbound(inboundId int) *sync.Mutex {
|
||||
inboundMutationLocksMu.Lock()
|
||||
defer inboundMutationLocksMu.Unlock()
|
||||
m, ok := inboundMutationLocks[inboundId]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
inboundMutationLocks[inboundId] = m
|
||||
}
|
||||
m.Lock()
|
||||
return m
|
||||
}
|
||||
|
||||
func compactOrphans(db *gorm.DB, clients []any) []any {
|
||||
if len(clients) == 0 {
|
||||
return clients
|
||||
}
|
||||
emails := make([]string, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e, _ := cm["email"].(string); e != "" {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return clients
|
||||
}
|
||||
var existingEmails []string
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
|
||||
logger.Warning("compactOrphans pluck:", err)
|
||||
return clients
|
||||
}
|
||||
if len(existingEmails) == len(emails) {
|
||||
return clients
|
||||
}
|
||||
existing := make(map[string]struct{}, len(existingEmails))
|
||||
for _, e := range existingEmails {
|
||||
existing[e] = struct{}{}
|
||||
}
|
||||
out := make([]any, 0, len(existingEmails))
|
||||
for _, c := range clients {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, c)
|
||||
continue
|
||||
}
|
||||
e, _ := cm["email"].(string)
|
||||
if e == "" {
|
||||
out = append(out, c)
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[e]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tombstoneClientEmail(email string) {
|
||||
if email == "" {
|
||||
return
|
||||
|
|
@ -138,6 +203,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
|||
return err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if isClientEmailTombstoned(email) {
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -887,6 +955,8 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
|
|||
}
|
||||
|
||||
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
|
||||
defer lockInbound(data.Id).Unlock()
|
||||
|
||||
clients, err := inboundSvc.GetClients(data)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -957,6 +1027,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||
}
|
||||
|
||||
oldClients := oldSettings["clients"].([]any)
|
||||
oldClients = compactOrphans(database.GetDB(), oldClients)
|
||||
oldClients = append(oldClients, interfaceClients...)
|
||||
|
||||
oldSettings["clients"] = oldClients
|
||||
|
|
@ -1044,6 +1115,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||
}
|
||||
|
||||
func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
|
||||
defer lockInbound(data.Id).Unlock()
|
||||
|
||||
clients, err := inboundSvc.GetClients(data)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -1295,6 +1368,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||
}
|
||||
|
||||
func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
|
||||
defer lockInbound(inboundId).Unlock()
|
||||
|
||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
logger.Error("Load Old Data Error")
|
||||
|
|
@ -1337,6 +1412,8 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||
return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
newClients = compactOrphans(db, newClients)
|
||||
if newClients == nil {
|
||||
newClients = []any{}
|
||||
}
|
||||
|
|
@ -1348,8 +1425,6 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -1365,12 +1440,13 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||
needRestart := false
|
||||
|
||||
if len(email) > 0 {
|
||||
notDepleted := true
|
||||
err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(¬Depleted).Error
|
||||
var enables []bool
|
||||
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
|
||||
if err != nil {
|
||||
logger.Error("Get stats error")
|
||||
return false, err
|
||||
}
|
||||
notDepleted := len(enables) > 0 && enables[0]
|
||||
if !emailShared {
|
||||
err = inboundSvc.DelClientStat(db, email)
|
||||
if err != nil {
|
||||
|
|
@ -1419,6 +1495,8 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||
}
|
||||
|
||||
func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
|
||||
defer lockInbound(inboundId).Unlock()
|
||||
|
||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
logger.Error("Load Old Data Error")
|
||||
|
|
@ -1455,6 +1533,8 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
|||
if !found {
|
||||
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
|
||||
}
|
||||
db := database.GetDB()
|
||||
newClients = compactOrphans(db, newClients)
|
||||
if newClients == nil {
|
||||
newClients = []any{}
|
||||
}
|
||||
|
|
@ -1466,8 +1546,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
|||
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
|
|||
Loading…
Reference in a new issue