mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
33a469315a
* feat(clients): preserve traffic counters in portable export/import ExportAll now attaches client_traffics up/down (plus resetCount and last-seen fields) on each portable payload, and ImportClients restores them only for newly created emails so skipped/existing clients keep their live counters. Fixes #5858. * fix(clients): restore imported traffic only onto rows the import created Review of the portable-traffic export/import (#5858) found four defects: - An orphan's restored row was hand-built, dropping reset_weekday and forcing enable=true; a row kept by a keepTraffic delete kept the old client's limits. depletedClientsClause then matched a weekly-renewing over-quota orphan and DelDepleted deleted it. Orphan rows now go through AddClientStat, whose upsert refreshes config and keeps counters, so the unused traffic.total field is dropped from the export. - Created clients were inferred from Skipped emails, so a duplicate email in the file left the created copy with zero counters. bulkCreate now reports which payloads inserted a record, and only those are restored. - Each client took its own serialized-writer commit: 2000 clients spent 3.66s instead of 0.52s. Counters now apply in batched transactions (0.51s). - importClients discarded needRestart when the late restore step failed after clients were committed; it now flags and notifies first, as create already does. The /clients/export and /clients/import API docs now describe traffic. * fix(groups): keep imported traffic out of group totals Group totals keep a deleted client's usage (#5675), and the portable import restores that same usage onto the re-created client. Export, delete, re-import therefore counted it twice in ListGroups, and a fresh panel showed the migrated usage as consumption of its groups. Restored counters are usage from before the import, so the import now shifts each group's baseline up by what it restored, in the same transaction. A group total no longer moves at import time; only traffic consumed afterwards counts. The baseline shift reuses the #5675 helper, now signed. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
122 lines
4.0 KiB
Go
122 lines
4.0 KiB
Go
// Package service implements the panel's business-logic layer.
|
|
//
|
|
// ClientService owns the lifecycle of VPN clients: creation, update, deletion,
|
|
// attach/detach to inbounds, bulk operations, group membership, traffic resets,
|
|
// and the paginated clients listing. Its surface is split across client_*.go
|
|
// files by responsibility (see each file's contents); they all belong to the
|
|
// same package, so the split is purely organizational. ClientService and
|
|
// InboundService are mutually dependent — most ClientService methods take an
|
|
// *InboundService and InboundService embeds a ClientService — which is why the
|
|
// client code lives in package service rather than a sub-package.
|
|
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
type ClientWithAttachments struct {
|
|
model.ClientRecord
|
|
InboundIds []int `json:"inboundIds"`
|
|
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
|
}
|
|
|
|
// MarshalJSON is required because model.ClientRecord defines its own
|
|
// MarshalJSON. Go promotes the embedded method to the outer struct, so without
|
|
// this the encoder would call ClientRecord.MarshalJSON for the whole value and
|
|
// silently drop InboundIds and Traffic from the API response.
|
|
func (c ClientWithAttachments) MarshalJSON() ([]byte, error) {
|
|
rec, err := json.Marshal(c.ClientRecord)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
extras := struct {
|
|
InboundIds []int `json:"inboundIds"`
|
|
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
|
}{InboundIds: c.InboundIds, Traffic: c.Traffic}
|
|
extra, err := json.Marshal(extras)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 {
|
|
return rec, nil
|
|
}
|
|
const maxMarshalSize = 256 << 20
|
|
if len(rec) > maxMarshalSize || len(extra) > maxMarshalSize {
|
|
return rec, nil
|
|
}
|
|
out := make([]byte, 0, len(rec)+len(extra))
|
|
out = append(out, rec[:len(rec)-1]...)
|
|
if len(rec) > 2 {
|
|
out = append(out, ',')
|
|
}
|
|
out = append(out, extra[1:]...)
|
|
return out, nil
|
|
}
|
|
|
|
type ClientService struct{}
|
|
|
|
// ErrClientNotInInbound is returned (wrapped) when a client cannot be located
|
|
// in an inbound's settings during deletion. Deletion treats it as non-fatal so
|
|
// the operation stays idempotent and tolerant of pre-existing data drift
|
|
// between the clients table and the inbound's settings JSON.
|
|
var ErrClientNotInInbound = errors.New("client not found in inbound")
|
|
|
|
type ClientCreatePayload struct {
|
|
Client model.Client `json:"client"`
|
|
InboundIds []int `json:"inboundIds"`
|
|
LimitHwid int `json:"-"`
|
|
Traffic *ClientPortableTraffic `json:"traffic,omitempty"`
|
|
}
|
|
|
|
const sqlInChunk = 400
|
|
|
|
type clientPayloadWithHwid struct {
|
|
model.Client
|
|
LimitHwid int `json:"limitHwid"`
|
|
}
|
|
|
|
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
|
|
var raw struct {
|
|
Client json.RawMessage `json:"client"`
|
|
InboundIds []int `json:"inboundIds"`
|
|
Traffic *ClientPortableTraffic `json:"traffic"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
var withHwid clientPayloadWithHwid
|
|
if len(raw.Client) > 0 {
|
|
if err := json.Unmarshal(raw.Client, &withHwid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
p.Client = withHwid.Client
|
|
p.InboundIds = raw.InboundIds
|
|
p.LimitHwid = withHwid.LimitHwid
|
|
p.Traffic = raw.Traffic
|
|
// Omit enable → true (legacy API); explicit false is preserved (#6478).
|
|
var keys map[string]json.RawMessage
|
|
if len(raw.Client) > 0 && json.Unmarshal(raw.Client, &keys) == nil {
|
|
if _, ok := keys["enable"]; !ok {
|
|
p.Client.Enable = true
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(struct {
|
|
Client clientPayloadWithHwid `json:"client"`
|
|
InboundIds []int `json:"inboundIds"`
|
|
Traffic *ClientPortableTraffic `json:"traffic,omitempty"`
|
|
}{
|
|
Client: clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
|
|
InboundIds: p.InboundIds,
|
|
Traffic: p.Traffic,
|
|
})
|
|
}
|