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 package job
import ( import (
"strings"
"time" "time"
"strings" "github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap" ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
"github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/service"
"strconv"
"github.com/google/uuid"
) )
var DefaultTruthyValues = []string{"true", "1", "yes", "on"} var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
type LdapSyncJob struct { type LdapSyncJob struct {
settingService service.SettingService settingService service.SettingService
inboundService service.InboundService inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService xrayService service.XrayService
} }
@ -135,20 +133,31 @@ func (j *LdapSyncJob) Run() {
} }
} }
// --- Execute batch create ---
for tag, newClients := range clientsToCreate { for tag, newClients := range clientsToCreate {
if len(newClients) == 0 { if len(newClients) == 0 {
continue continue
} }
payload := &model.Inbound{Id: inboundMap[tag].Id} ib := inboundMap[tag]
payload.Settings = j.clientsToJSON(newClients) created := 0
if _, err := j.inboundService.AddInboundClient(payload); err != nil { restartNeeded := false
logger.Warningf("Failed to add clients for tag %s: %v", tag, err) for _, c := range newClients {
} else { nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag) 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() j.xrayService.SetToNeedRestart()
} }
} }
}
// --- Execute enable/disable batch --- // --- Execute enable/disable batch ---
for tag, emails := range clientsToEnable { for tag, emails := range clientsToEnable {
@ -206,35 +215,32 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
return c return c
} }
// batchSetEnable enables/disables clients in batch through a single call
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) { func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
if len(emails) == 0 { if len(emails) == 0 {
return return
} }
restartNeeded := false
// Prepare JSON for mass update changed := 0
clients := make([]model.Client, 0, len(emails))
for _, email := range emails { for _, email := range emails {
clients = append(clients, model.Client{ ok, needRestart, err := j.inboundService.SetClientEnableByEmail(email, enable)
Email: email, if err != nil {
Enable: enable, logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
}) continue
} }
if ok {
payload := &model.Inbound{ changed++
Id: ib.Id,
Settings: j.clientsToJSON(clients),
} }
if needRestart {
// Use a single AddInboundClient call to update enable restartNeeded = true
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
return
} }
}
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() j.xrayService.SetToNeedRestart()
} }
}
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart // deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) { func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
continue continue
} }
// Delete in batches
for i := 0; i < len(toDelete); i += batchSize { for i := 0; i < len(toDelete); i += batchSize {
end := min(i+batchSize, len(toDelete)) end := min(i+batchSize, len(toDelete))
batch := toDelete[i:end] batch := toDelete[i:end]
for _, c := range batch { for _, c := range batch {
var clientKey string nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
switch ib.Protocol { if err != nil {
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 {
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v", logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
c.Email, ib.Id, ib.Tag, err) c.Email, ib.Id, ib.Tag, err)
} else { continue
}
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
c.Email, ib.Id, ib.Tag) c.Email, ib.Id, ib.Tag)
// do not restart here if nr {
restartNeeded = true restartNeeded = true
} }
} }
} }
} }
// One time after all batches
if restartNeeded { if restartNeeded {
j.xrayService.SetToNeedRestart() j.xrayService.SetToNeedRestart()
logger.Info("Xray restart scheduled after batch deletion") 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 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) { func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" { if email == "" {
return false, common.NewError("client email is required") 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. // It handles bot commands, user interactions, and status reporting via Telegram.
type Tgbot struct { type Tgbot struct {
inboundService InboundService inboundService InboundService
clientService ClientService
settingService SettingService settingService SettingService
serverService ServerService serverService ServerService
xrayService XrayService xrayService XrayService
@ -2209,27 +2210,43 @@ func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) {
return jsonString, nil 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) { func (t *Tgbot) SubmitAddClient() (bool, error) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
if err != nil { if err != nil {
logger.Warning("getIboundClients run failed:", err) logger.Warning("getIboundClients run failed:", err)
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
} }
jsonString, err := t.BuildJSONForProtocol(inbound.Protocol) tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
if err != nil { client := model.Client{
logger.Warning("BuildJSONForProtocol run failed:", err) Email: client_Email,
return false, errors.New("failed to build JSON for protocol") 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{ switch inbound.Protocol {
Id: receiver_inbound_ID, case model.VMESS:
Settings: jsonString, 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. // checkAdmin checks if the given Telegram ID is an admin.