refactor(service): switch tgbot + ldap callers to ClientService

Adds two thin helpers to ClientService (CreateOne, DetachByEmail) and
rewrites tgbot.SubmitAddClient and ldap_sync_job to call ClientService
directly. Removes the JSON-blob payloads (BuildJSONForProtocol output for
add, clientsToJSON/clientToJSON helpers) that callers previously fed to
InboundService.AddInboundClient/DelInboundClient.

ldap_sync_job.batchSetEnable now loops InboundService.SetClientEnableByEmail
per email instead of trying to coerce AddInboundClient into doing the
update — the old path would have failed duplicate-email validation for
existing clients anyway.

The legacy InboundService.AddInboundClient/UpdateInboundClient/
DelInboundClient methods stay in place; they are now only used internally
by ClientService Create/Update/Delete/Attach. Inlining + deleting them
follows in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 10:29:25 +02:00
parent 0fe48124c9
commit 960bd3c832
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 91 additions and 112 deletions

View file

@ -1,18 +1,15 @@
package job
import (
"strings"
"time"
"strings"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
"github.com/mhsanaei/3x-ui/v3/web/service"
"strconv"
"github.com/google/uuid"
)
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
type LdapSyncJob struct {
settingService service.SettingService
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
}
@ -135,20 +133,31 @@ func (j *LdapSyncJob) Run() {
}
}
// --- Execute batch create ---
for tag, newClients := range clientsToCreate {
if len(newClients) == 0 {
continue
}
payload := &model.Inbound{Id: inboundMap[tag].Id}
payload.Settings = j.clientsToJSON(newClients)
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
logger.Warningf("Failed to add clients for tag %s: %v", tag, err)
} else {
logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag)
ib := inboundMap[tag]
created := 0
restartNeeded := false
for _, c := range newClients {
nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
if err != nil {
logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
continue
}
created++
if nr {
restartNeeded = true
}
}
if created > 0 {
logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
}
}
// --- Execute enable/disable batch ---
for tag, emails := range clientsToEnable {
@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
return c
}
// batchSetEnable enables/disables clients in batch through a single call
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
if len(emails) == 0 {
return
}
// Prepare JSON for mass update
clients := make([]model.Client, 0, len(emails))
restartNeeded := false
changed := 0
for _, email := range emails {
clients = append(clients, model.Client{
Email: email,
Enable: enable,
})
ok, needRestart, err := j.inboundService.SetClientEnableByEmail(email, enable)
if err != nil {
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
continue
}
payload := &model.Inbound{
Id: ib.Id,
Settings: j.clientsToJSON(clients),
if ok {
changed++
}
// Use a single AddInboundClient call to update enable
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
return
if needRestart {
restartNeeded = true
}
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag)
}
if changed > 0 {
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
}
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
}
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
continue
}
// Delete in batches
for i := 0; i < len(toDelete); i += batchSize {
end := min(i+batchSize, len(toDelete))
batch := toDelete[i:end]
for _, c := range batch {
var clientKey string
switch ib.Protocol {
case model.Trojan:
clientKey = c.Password
case model.Shadowsocks:
clientKey = c.Email
default: // vless/vmess
clientKey = c.ID
}
if _, err := j.inboundService.DelInboundClient(ib.Id, clientKey); err != nil {
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
if err != nil {
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
c.Email, ib.Id, ib.Tag, err)
} else {
continue
}
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
c.Email, ib.Id, ib.Tag)
// do not restart here
if nr {
restartNeeded = true
}
}
}
}
// One time after all batches
if restartNeeded {
j.xrayService.SetToNeedRestart()
logger.Info("Xray restart scheduled after batch deletion")
}
}
// clientsToJSON serializes an array of clients to JSON
func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
b := strings.Builder{}
b.WriteString("{\"clients\":[")
for i, c := range clients {
if i > 0 {
b.WriteString(",")
}
b.WriteString(j.clientToJSON(c))
}
b.WriteString("]}")
return b.String()
}
// clientToJSON serializes minimal client fields to JSON object string without extra deps
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
// construct minimal JSON manually to avoid importing json for simple case
b := strings.Builder{}
b.WriteString("{")
if c.ID != "" {
b.WriteString("\"id\":\"")
b.WriteString(c.ID)
b.WriteString("\",")
}
if c.Password != "" {
b.WriteString("\"password\":\"")
b.WriteString(c.Password)
b.WriteString("\",")
}
b.WriteString("\"email\":\"")
b.WriteString(c.Email)
b.WriteString("\",")
b.WriteString("\"enable\":")
if c.Enable {
b.WriteString("true")
} else {
b.WriteString("false")
}
b.WriteString(",")
b.WriteString("\"limitIp\":")
b.WriteString(strconv.Itoa(c.LimitIP))
b.WriteString(",")
b.WriteString("\"totalGB\":")
b.WriteString(strconv.FormatInt(c.TotalGB, 10))
if c.ExpiryTime > 0 {
b.WriteString(",\"expiryTime\":")
b.WriteString(strconv.FormatInt(c.ExpiryTime, 10))
}
b.WriteString("}")
return b.String()
}

View file

@ -475,6 +475,24 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, nil
}
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
return s.Create(inboundSvc, &ClientCreatePayload{
Client: client,
InboundIds: []int{inboundId},
})
}
func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (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, []int{inboundId})
}
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")

View file

@ -118,6 +118,7 @@ type LoginAttempt struct {
// It handles bot commands, user interactions, and status reporting via Telegram.
type Tgbot struct {
inboundService InboundService
clientService ClientService
settingService SettingService
serverService ServerService
xrayService XrayService
@ -2209,27 +2210,43 @@ func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) {
return jsonString, nil
}
// SubmitAddClient submits the client addition request to the inbound service.
// SubmitAddClient submits the client addition request to the client service.
func (t *Tgbot) SubmitAddClient() (bool, error) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
if err != nil {
logger.Warning("getIboundClients run failed:", err)
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
jsonString, err := t.BuildJSONForProtocol(inbound.Protocol)
if err != nil {
logger.Warning("BuildJSONForProtocol run failed:", err)
return false, errors.New("failed to build JSON for protocol")
tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
client := model.Client{
Email: client_Email,
Enable: client_Enable,
LimitIP: client_LimitIP,
TotalGB: client_TotalGB,
ExpiryTime: client_ExpiryTime,
SubID: client_SubID,
Comment: client_Comment,
Reset: client_Reset,
TgID: tgIDInt,
}
newInbound := &model.Inbound{
Id: receiver_inbound_ID,
Settings: jsonString,
switch inbound.Protocol {
case model.VMESS:
client.ID = client_Id
client.Security = client_Security
case model.VLESS:
client.ID = client_Id
client.Flow = client_Flow
case model.Trojan:
client.Password = client_TrPassword
case model.Shadowsocks:
client.Password = client_ShPassword
default:
return false, errors.New("unknown protocol")
}
return t.inboundService.AddInboundClient(newInbound)
return t.clientService.CreateOne(&t.inboundService, receiver_inbound_ID, client)
}
// checkAdmin checks if the given Telegram ID is an admin.