diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index aecedf75..42004d35 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -4,7 +4,7 @@ const Protocols = { TROJAN: 'trojan', SHADOWSOCKS: 'shadowsocks', TUNNEL: 'tunnel', - MIXED: 'mixed', + SOCKS: 'socks', HTTP: 'http', WIREGUARD: 'wireguard', }; @@ -140,8 +140,22 @@ class XrayCommonClass { return new XrayCommonClass(); } + /** + * 【最佳实践】中文注释:这是一个智能的、通用的序列化方法。 + * 1. 使用 ...this 创建一个当前对象所有属性的浅拷贝。 + * 2. 遍历拷贝后的对象,删除所有以下划线 "_" 开头的属性。 + * 这些带下划线的属性被约定为仅供前端 UI 逻辑使用(如 _expiryTime),不应提交给后端。 + * 3. 返回一个干净的、只包含持久化数据的对象。 + * 这个方法将被所有子类继承,无需在每个子类中重复实现,保证了代码的健壮性和可维护性。 + */ toJson() { - return this; + const obj = { ...this }; + for (const key in obj) { + if (key.startsWith('_')) { + delete obj[key]; + } + } + return obj; } toString(format = true) { @@ -729,8 +743,8 @@ class RealityStreamSettings extends XrayCommonClass { constructor( show = false, xver = 0, - target = 'google.com:443', - serverNames = 'google.com,www.google.com', + target = 'tesla.com:443', + serverNames = 'tesla.com,www.tesla.com', privateKey = '', minClientVer = '', maxClientVer = '', @@ -1713,7 +1727,7 @@ Inbound.Settings = class extends XrayCommonClass { case Protocols.TROJAN: return new Inbound.TrojanSettings(protocol); case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings(protocol); case Protocols.TUNNEL: return new Inbound.TunnelSettings(protocol); - case Protocols.MIXED: return new Inbound.MixedSettings(protocol); + case Protocols.SOCKS: return new Inbound.SocksSettings(protocol); case Protocols.HTTP: return new Inbound.HttpSettings(protocol); case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol); default: return null; @@ -1727,7 +1741,7 @@ Inbound.Settings = class extends XrayCommonClass { case Protocols.TROJAN: return Inbound.TrojanSettings.fromJson(json); case Protocols.SHADOWSOCKS: return Inbound.ShadowsocksSettings.fromJson(json); case Protocols.TUNNEL: return Inbound.TunnelSettings.fromJson(json); - case Protocols.MIXED: return Inbound.MixedSettings.fromJson(json); + case Protocols.SOCKS: return Inbound.SocksSettings.fromJson(json); case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json); case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json); default: return null; @@ -1784,6 +1798,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { security = USERS_SECURITY.AUTO, email = RandomUtil.randomLowerAndNum(8), limitIp = 0, + speedLimit = 0, // totalGB = 0, expiryTime = 0, enable = true, @@ -1799,6 +1814,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { this.security = security; this.email = email; this.limitIp = limitIp; + this.speedLimit = speedLimit; this.totalGB = totalGB; this.expiryTime = expiryTime; this.enable = enable; @@ -1809,13 +1825,15 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { this.created_at = created_at; this.updated_at = updated_at; } - + + static fromJson(json = {}) { return new Inbound.VmessSettings.VMESS( json.id, json.security, json.email, json.limitIp, + json.speedLimit ?? 0, json.totalGB, json.expiryTime, json.enable, @@ -1859,16 +1877,15 @@ Inbound.VLESSSettings = class extends Inbound.Settings { protocol, vlesses = [new Inbound.VLESSSettings.VLESS()], decryption = "none", - encryption = "none", + encryption = "", fallbacks = [], - selectedAuth = undefined, ) { super(protocol); this.vlesses = vlesses; this.decryption = decryption; this.encryption = encryption; this.fallbacks = fallbacks; - this.selectedAuth = selectedAuth; + this.selectedAuth = "X25519, not Post-Quantum"; } addFallback() { @@ -1879,24 +1896,24 @@ Inbound.VLESSSettings = class extends Inbound.Settings { this.fallbacks.splice(index, 1); } + // decryption should be set to static value static fromJson(json = {}) { const obj = new Inbound.VLESSSettings( Protocols.VLESS, (json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)), json.decryption, json.encryption, - Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []), - json.selectedAuth + Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []) ); + obj.selectedAuth = json.selectedAuth || "X25519, not Post-Quantum"; return obj; } - toJson() { const json = { clients: Inbound.VLESSSettings.toJsonArray(this.vlesses), }; - + if (this.decryption) { json.decryption = this.decryption; } @@ -1914,8 +1931,8 @@ Inbound.VLESSSettings = class extends Inbound.Settings { return json; } - - + + }; Inbound.VLESSSettings.VLESS = class extends XrayCommonClass { @@ -1924,6 +1941,7 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass { flow = '', email = RandomUtil.randomLowerAndNum(8), limitIp = 0, + speedLimit = 0, totalGB = 0, expiryTime = 0, enable = true, @@ -1939,6 +1957,7 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass { this.flow = flow; this.email = email; this.limitIp = limitIp; + this.speedLimit = speedLimit; this.totalGB = totalGB; this.expiryTime = expiryTime; this.enable = enable; @@ -1949,6 +1968,7 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass { this.created_at = created_at; this.updated_at = updated_at; } + static fromJson(json = {}) { return new Inbound.VLESSSettings.VLESS( @@ -1956,6 +1976,7 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass { json.flow, json.email, json.limitIp, + json.speedLimit ?? 0, json.totalGB, json.expiryTime, json.enable, @@ -2069,6 +2090,7 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass { password = RandomUtil.randomSeq(10), email = RandomUtil.randomLowerAndNum(8), limitIp = 0, + speedLimit = 0, totalGB = 0, expiryTime = 0, enable = true, @@ -2083,6 +2105,7 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass { this.password = password; this.email = email; this.limitIp = limitIp; + this.speedLimit = speedLimit; this.totalGB = totalGB; this.expiryTime = expiryTime; this.enable = enable; @@ -2099,6 +2122,7 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass { password: this.password, email: this.email, limitIp: this.limitIp, + speedLimit: this.speedLimit, totalGB: this.totalGB, expiryTime: this.expiryTime, enable: this.enable, @@ -2116,6 +2140,7 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass { json.password, json.email, json.limitIp, + json.speedLimit ?? 0, json.totalGB, json.expiryTime, json.enable, @@ -2238,6 +2263,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass { password = RandomUtil.randomShadowsocksPassword(), email = RandomUtil.randomLowerAndNum(8), limitIp = 0, + speedLimit = 0, totalGB = 0, expiryTime = 0, enable = true, @@ -2253,6 +2279,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass { this.password = password; this.email = email; this.limitIp = limitIp; + this.speedLimit = speedLimit; this.totalGB = totalGB; this.expiryTime = expiryTime; this.enable = enable; @@ -2263,13 +2290,14 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass { this.created_at = created_at; this.updated_at = updated_at; } - + toJson() { return { method: this.method, password: this.password, email: this.email, limitIp: this.limitIp, + speedLimit: this.speedLimit, totalGB: this.totalGB, expiryTime: this.expiryTime, enable: this.enable, @@ -2288,6 +2316,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass { json.password, json.email, json.limitIp, + json.speedLimit ?? 0, json.totalGB, json.expiryTime, json.enable, @@ -2366,8 +2395,8 @@ Inbound.TunnelSettings = class extends Inbound.Settings { } }; -Inbound.MixedSettings = class extends Inbound.Settings { - constructor(protocol, auth = 'password', accounts = [new Inbound.MixedSettings.SocksAccount()], udp = false, ip = '127.0.0.1') { +Inbound.SocksSettings = class extends Inbound.Settings { + constructor(protocol, auth = 'password', accounts = [new Inbound.SocksSettings.SocksAccount()], udp = false, ip = '127.0.0.1') { super(protocol); this.auth = auth; this.accounts = accounts; @@ -2387,11 +2416,11 @@ Inbound.MixedSettings = class extends Inbound.Settings { let accounts; if (json.auth === 'password') { accounts = json.accounts.map( - account => Inbound.MixedSettings.SocksAccount.fromJson(account) + account => Inbound.SocksSettings.SocksAccount.fromJson(account) ) } - return new Inbound.MixedSettings( - Protocols.MIXED, + return new Inbound.SocksSettings( + Protocols.SOCKS, json.auth, accounts, json.udp, @@ -2408,7 +2437,7 @@ Inbound.MixedSettings = class extends Inbound.Settings { }; } }; -Inbound.MixedSettings.SocksAccount = class extends XrayCommonClass { +Inbound.SocksSettings.SocksAccount = class extends XrayCommonClass { constructor(user = RandomUtil.randomSeq(10), pass = RandomUtil.randomSeq(10)) { super(); this.user = user; @@ -2416,7 +2445,7 @@ Inbound.MixedSettings.SocksAccount = class extends XrayCommonClass { } static fromJson(json = {}) { - return new Inbound.MixedSettings.SocksAccount(json.user, json.pass); + return new Inbound.SocksSettings.SocksAccount(json.user, json.pass); } }; diff --git a/web/html/form/client.html b/web/html/form/client.html index 908f28d2..739d3573 100644 --- a/web/html/form/client.html +++ b/web/html/form/client.html @@ -68,6 +68,27 @@ + + + + + + diff --git a/web/service/inbound.go b/web/service/inbound.go index 2646b1e7..25e9bb90 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -512,6 +512,10 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { cm["created_at"] = nowTs } cm["updated_at"] = nowTs + + + cm["speedLimit"] = clients[i].SpeedLimit + interfaceClients[i] = cm } } @@ -592,6 +596,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { "flow": client.Flow, "password": client.Password, "cipher": cipher, + + + "level": client.SpeedLimit, }) if err1 == nil { logger.Debug("Client added by api:", client.Email) @@ -781,6 +788,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin } newMap["created_at"] = preservedCreated newMap["updated_at"] = time.Now().Unix() * 1000 + newMap["speedLimit"] = clients[0].SpeedLimit interfaceClients[0] = newMap } } @@ -855,6 +863,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin "flow": clients[0].Flow, "password": clients[0].Password, "cipher": cipher, + "level": clients[0].SpeedLimit, }) if err1 == nil { logger.Debug("Client edited by api:", clients[0].Email) @@ -2112,6 +2121,10 @@ func (s *InboundService) MigrationRequirements() { c["created_at"] = time.Now().Unix() * 1000 } c["updated_at"] = time.Now().Unix() * 1000 + + if _, ok := c["speedLimit"]; !ok { + c["speedLimit"] = 0 + } newClients = append(newClients, any(c)) } settings["clients"] = newClients diff --git a/web/service/xray.go b/web/service/xray.go index f23ce9c4..918bdd83 100644 --- a/web/service/xray.go +++ b/web/service/xray.go @@ -5,9 +5,11 @@ import ( "errors" "runtime" "sync" + "strconv" "x-ui/logger" "x-ui/xray" + json_util "x-ui/util/json_util" "go.uber.org/atomic" ) @@ -30,6 +32,13 @@ func (s *XrayService) IsXrayRunning() bool { return p != nil && p.IsRunning() } +func (s *XrayService) GetApiPort() int { + if p == nil { + return 0 + } + return p.GetAPIPort() +} + func (s *XrayService) GetXrayErr() error { if p == nil { return nil @@ -91,16 +100,234 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { return nil, err } - s.inboundService.AddTraffic(nil, nil) + inbounds, err := s.inboundService.GetAllInbounds() if err != nil { return nil, err } + + + uniqueSpeeds := make(map[int]bool) for _, inbound := range inbounds { if !inbound.Enable { continue } + + + dbClients, _ := s.inboundService.GetClients(inbound) + for _, dbClient := range dbClients { + if dbClient.SpeedLimit > 0 { + uniqueSpeeds[dbClient.SpeedLimit] = true + } + } + } + + + var finalPolicy map[string]interface{} + if xrayConfig.Policy != nil { + if err := json.Unmarshal(xrayConfig.Policy, &finalPolicy); err != nil { + logger.Warningf("Failed to parse the policy in the template: %v", err) + finalPolicy = make(map[string]interface{}) + } + } else { + finalPolicy = make(map[string]interface{}) + } + + + var policyLevels map[string]interface{} + if levels, ok := finalPolicy["levels"].(map[string]interface{}); ok { + policyLevels = levels + } else { + policyLevels = make(map[string]interface{}) + } + + // 3. [Important modification]: Ensure the integrity of the level 0 policy, which is key to enabling device restrictions and default user statistics. + var level0 map[string]interface{} + if l0, ok := policyLevels["0"].(map[string]interface{}); ok { + // If level 0 already exists in the template, use it as the base for modifications. + level0 = l0 + } else { + // If it does not exist in the template, create a brand new map. + level0 = make(map[string]interface{}) + } + // [Chinese comment]: Regardless of whether level 0 exists, supplement or override the following key parameters for it. + // handshake and connIdle are prerequisites to activate Xray connection statistics, + // uplinkOnly and downlinkOnly set to 0 mean no speed limit, which is the default behavior for level 0 users. + // statsUserUplink and statsUserDownlink ensure that user traffic can be counted. + level0["handshake"] = 4 + level0["connIdle"] = 300 + level0["uplinkOnly"] = 0 + level0["downlinkOnly"] = 0 + level0["statsUserUplink"] = true + level0["statsUserDownlink"] = true + // [Added]: Add this key option to enable Xray-core's online IP statistics feature. + // This is a prerequisite for the proper functioning of the "device restriction" feature. + + level0["statsUserOnline"] = true + + // Write the fully configured level 0 back to policyLevels to ensure the final generated config.json is correct. + policyLevels["0"] = level0 + + // 4. Iterate through all collected speed limits and create a corresponding level for each unique speed limit + for speed := range uniqueSpeeds { + // Create a level for each speed, where the level's name is the string representation of the speed + // For example, the speed 1024 KB/s corresponds to the level "1024" + policyLevels[strconv.Itoa(speed)] = map[string]interface{}{ + "downlinkOnly": speed, + "uplinkOnly": speed, + "handshake": 4, + "connIdle": 300, + "statsUserUplink": true, + "statsUserDownlink": true, + "statsUserOnline": true, + } + } + // 5. Write the modified levels back to the policy object, serialize it back to xrayConfig.Policy, and apply the generated policy to the Xray configuration + finalPolicy["levels"] = policyLevels + policyJSON, err := json.Marshal(finalPolicy) + if err != nil { + return nil, err + } + xrayConfig.Policy = json_util.RawMessage(policyJSON) + // ================================================================= + // Add logs here to print the final generated speed limit policy + // ================================================================= + + if len(uniqueSpeeds) > 0 { + finalPolicyLog, _ := json.Marshal(policyLevels) + logger.Infof("已为Xray动态生成〔限速策略〕: %s", string(finalPolicyLog)) + } + + + s.inboundService.AddTraffic(nil, nil) + + for _, inbound := range inbounds { + if !inbound.Enable { + continue + } + + inboundConfig := inbound.GenXrayInboundConfig() + + + speedByEmail := make(map[string]int) + speedById := make(map[string]int) + dbClients, _ := s.inboundService.GetClients(inbound) + for _, dbc := range dbClients { + if dbc.Email != "" { + speedByEmail[dbc.Email] = dbc.SpeedLimit + } + + if dbc.ID != "" { + speedById[dbc.ID] = dbc.SpeedLimit + } + } + + + var settings map[string]interface{} + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + logger.Warningf("Failed to parse inbound.Settings (inbound %d): %v, skipping this inbound", inbound.Id, err) + continue + } + + originalClients, ok := settings["clients"].([]interface{}) + if ok { + clientStats := inbound.ClientStats + + var xrayClients []interface{} + for _, clientRaw := range originalClients { + c, ok := clientRaw.(map[string]interface{}) + if !ok { + continue + } + + if en, ok := c["enable"].(bool); ok && !en { + if em, _ := c["email"].(string); em != "" { + logger.Infof("User marked as disabled in settings removed from Xray config: %s", em) + } + continue + } + + + email, _ := c["email"].(string) + idStr, _ := c["id"].(string) + disabledByStat := false + for _, stat := range clientStats { + if stat.Email == email && !stat.Enable { + disabledByStat = true + break + } + } + if disabledByStat { + logger.Infof("User disabled and removed from Xray config: %s", email) + continue + } + + + + xrayClient := make(map[string]interface{}) + if id, ok := c["id"]; ok { xrayClient["id"] = id } + if email != "" { xrayClient["email"] = email } + + + if flow, ok := c["flow"]; ok { + if fs, ok2 := flow.(string); ok2 && fs == "xtls-rprx-vision-udp443" { + xrayClient["flow"] = "xtls-rprx-vision" + } else { + xrayClient["flow"] = flow + } + } + if password, ok := c["password"]; ok { xrayClient["password"] = password } + if method, ok := c["method"]; ok { xrayClient["method"] = method } + + + level := 0 + if email != "" { + if v, ok := speedByEmail[email]; ok && v > 0 { + level = v + } + } + if level == 0 && idStr != "" { + if v, ok := speedById[idStr]; ok && v > 0 { + level = v + } + } + if level == 0 { + if sl, ok := c["speedLimit"]; ok { + switch vv := sl.(type) { + case float64: + level = int(vv) + case int: + level = vv + case int64: + level = int(vv) + case string: + if n, err := strconv.Atoi(vv); err == nil { + level = n + } + } + } + } + + if level > 0 && email != "" { + logger.Infof("Applied independent speed limit for user %s: %d KB/s", email, level) + } + + // ================================================================= + + xrayClient["level"] = level + + xrayClients = append(xrayClients, xrayClient) + } + + settings["clients"] = xrayClients + finalSettingsForXray, err := json.Marshal(settings) + if err != nil { + logger.Warningf("Failed to serialize inbound settings for Xray in GetXrayConfig for inbound %d: %v, skipping this inbound", inbound.Id, err) + continue + } + inboundConfig.Settings = json_util.RawMessage(finalSettingsForXray) + } // get settings clients settings := map[string]any{} json.Unmarshal([]byte(inbound.Settings), &settings) @@ -176,7 +403,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { inbound.StreamSettings = string(newStream) } - inboundConfig := inbound.GenXrayInboundConfig() + xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) } return xrayConfig, nil @@ -251,4 +478,4 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool { // Check if Xray is not running and wasn't stopped manually, i.e. crashed func (s *XrayService) DidXrayCrash() bool { return !s.IsXrayRunning() && !isManuallyStopped.Load() -} +} \ No newline at end of file diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 19ac810c..1545979a 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -239,6 +239,8 @@ "telegramDesc" = "Please provide Telegram Chat ID. (use '/id' command in the bot) or (@userinfobot)" "subscriptionDesc" = "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients." "info" = "Info" +"speedLimit"="Independent speedLimit" +"speedLimitDesc"="Set the maximum upload/download speed for this user in KB/s. 0 means unlimited speed." "same" = "Same" "inboundData" = "Inbound's Data" "exportInbound" = "Export Inbound" diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml index 7e3869a0..71c807d4 100644 --- a/web/translation/translate.vi_VN.toml +++ b/web/translation/translate.vi_VN.toml @@ -235,6 +235,8 @@ "IPLimitlog" = "Lịch sử IP" "IPLimitlogDesc" = "Lịch sử đăng nhập IP (trước khi kích hoạt điểm vào sau khi bị vô hiệu hóa bởi giới hạn IP, bạn nên xóa lịch sử)." "IPLimitlogclear" = "Xóa Lịch sử" +"speedLimit" = "Giới hạn tốc độ riêng" +"speedLimitDesc" = "Thiết lập tốc độ tối đa〔tải lên/tải xuống〕cho người dùng này,\r\nđơn vị KB/s, 0 có nghĩa là không giới hạn" "setDefaultCert" = "Đặt chứng chỉ từ bảng điều khiển" "telegramDesc" = "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc (@userinfobot)" "subscriptionDesc" = "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau" diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml index 18a8c97c..d02cb581 100644 --- a/web/translation/translate.zh_CN.toml +++ b/web/translation/translate.zh_CN.toml @@ -235,6 +235,8 @@ "IPLimitlog" = "IP 日志" "IPLimitlogDesc" = "IP 历史日志(要启用被禁用的入站流量,请清除日志)" "IPLimitlogclear" = "清除日志" +"speedLimit"="独立限速" +"speedLimitDesc"="设置该用户的最大〔上传/下载速度〕,\r\n单位 KB/s,0 表示不限速" "setDefaultCert" = "从面板设置证书" "telegramDesc" = "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或(@userinfobot" "subscriptionDesc" = "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。" diff --git a/web/translation/translate.zh_TW.toml b/web/translation/translate.zh_TW.toml index 758781e7..d4633b60 100644 --- a/web/translation/translate.zh_TW.toml +++ b/web/translation/translate.zh_TW.toml @@ -235,6 +235,8 @@ "IPLimitlog" = "IP 日誌" "IPLimitlogDesc" = "IP 歷史日誌(要啟用被禁用的入站流量,請清除日誌)" "IPLimitlogclear" = "清除日誌" +"speedLimit"="獨立限速" +"speedLimitDesc"="設定該使用者的最大〔上傳/下載速度〕,\r\n單位 KB/s,0 表示不限速" "setDefaultCert" = "從面板設定證書" "telegramDesc" = "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或(@userinfobot" "subscriptionDesc" = "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。"