From 0065b77254eba9c2efcd872e7517b1f576291042 Mon Sep 17 00:00:00 2001 From: homelab Date: Fri, 21 Aug 2026 11:04:56 +0800 Subject: [PATCH] release: 2.4.4 --- README.md | 8 ++ .../RelayBalanceChannelExecutor.swift | 80 +++++++++++++++ .../OhMyUsage/Providers/RelayProvider.swift | 3 +- .../Providers/RelayResponseInterpreter.swift | 25 ++++- Tests/OhMyUsageTests/RelayProviderTests.swift | 99 +++++++++++++++++++ VERSION | 2 +- 6 files changed, 211 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 40027ad..94783d3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ oh-myusage 把官方订阅额度、模型使用窗口、第三方中转余额、 [下载最新版本](https://github.com/Four-JJJJ/oh-myusage/releases/latest) · [安装说明](docs/DOWNLOAD.md) · [支持的服务](docs/PROVIDERS.md) · [扩展指南](docs/EXTENDING.md) · [发布清单](docs/RELEASE_CHECKLIST.md) · [English](docs/README.en.md) +## V2.4.4 更新 + +V2.4.4 为 Xiaomi MIMO 新增按量付费余额查询,在 Token Plan 不可用时自动切换,无需另行配置。 + +| 方向 | 改进 | +| --- | --- | +| MIMO 按量付费 | Token Plan 无订阅、套餐为空或接口返回 404 时,自动查询 `/api/v1/userProfile` 与 `/api/v1/balance`,显示 CNY 余额、已用额度和总额度 | + ## V2.4.3 更新 V2.4.3 修复 Xiaomi MIMO 凭证读取与设置窗口显示问题,并优化 MIMO Token Plan 的鉴权诊断。 diff --git a/Sources/OhMyUsage/Providers/RelayBalanceChannelExecutor.swift b/Sources/OhMyUsage/Providers/RelayBalanceChannelExecutor.swift index 9e64882..58cd6eb 100644 --- a/Sources/OhMyUsage/Providers/RelayBalanceChannelExecutor.swift +++ b/Sources/OhMyUsage/Providers/RelayBalanceChannelExecutor.swift @@ -7,6 +7,7 @@ struct RelayBalanceChannelExecutor { let credentialResolver: RelayCredentialResolver let recoveryPolicy: RelayRecoveryPolicy let httpClient: RelayHTTPClient + let registry: RelayAdapterRegistry func fetch( baseURL: URL, @@ -420,6 +421,20 @@ struct RelayBalanceChannelExecutor { return extracted } catch let error as ProviderError { + if shouldFallbackToXiaomimimoPayAsYouGo(error) { + do { + return try await attemptXiaomimimoPayAsYouGoFetch( + candidates: [candidate], + baseURL: baseURL, + relayConfig: relayConfig + ) + } catch let fallbackError as ProviderError { + // Keep probing the next credential if the pay-as-you-go + // endpoints reject this one too. The subscription error + // remains the most useful failure when all candidates fail. + lastError = fallbackError + } + } switch error { case .invalidResponse: lastError = error @@ -435,4 +450,69 @@ struct RelayBalanceChannelExecutor { throw lastError } + + private func shouldFallbackToXiaomimimoPayAsYouGo(_ error: ProviderError) -> Bool { + guard case .invalidResponse(let detail) = error else { return false } + return detail.localizedCaseInsensitiveContains("no active subscription") + || detail.localizedCaseInsensitiveContains("token plan usage payload missing") + || detail.localizedCaseInsensitiveContains("http 404") + } + + private func attemptXiaomimimoPayAsYouGoFetch( + candidates: [RelayCredentialCandidate], + baseURL: URL, + relayConfig: RelayProviderConfig + ) async throws -> AccountChannelResult { + let manifest = registry.manifest(id: "xiaomimimo") + ?? RelayAdapterRegistry.genericManifest + let requests = RelayRequestResolver.resolveBalanceRequests( + manifest: manifest, + relayConfig: relayConfig + ) + var lastError: ProviderError = .invalidResponse("xiaomimimo pay-as-you-go balance unavailable") + + for candidate in candidates { + for request in requests { + do { + let root = try await httpClient.requestJSON( + url: RelayRequestResolver.relayURL(baseURL: baseURL, rawPath: request.path), + headers: candidate.headers.merging(request.staticHeaders, uniquingKeysWith: { _, rhs in rhs }), + method: request.method, + bodyJSON: request.bodyJSON + ) + var extracted = try await RelayResponseInterpreter.extractAccountValues( + root: root, + baseURL: baseURL, + request: request, + manifest: manifest, + headers: candidate.headers, + candidate: candidate, + requestJSON: httpClient.requestJSON + ) + if let persisted = candidate.persistedCredential { + _ = credentialResolver.persistTokenCandidate(persisted, auth: relayConfig.balanceAuth) + } + extracted.rawMeta["billingMode"] = "payAsYouGo" + extracted.note = extracted.note.replacingOccurrences( + of: "Account remaining", + with: "Pay-as-you-go balance" + ) + extracted.rawMeta["savedCredentialSource"] = candidate.source + return extracted + } catch let error as ProviderError { + switch error { + case .invalidResponse: + lastError = error + continue + case .unauthorized, .unauthorizedDetail: + lastError = error + break + default: + throw error + } + } + } + } + throw lastError + } } diff --git a/Sources/OhMyUsage/Providers/RelayProvider.swift b/Sources/OhMyUsage/Providers/RelayProvider.swift index d282a80..62f8d44 100644 --- a/Sources/OhMyUsage/Providers/RelayProvider.swift +++ b/Sources/OhMyUsage/Providers/RelayProvider.swift @@ -39,7 +39,8 @@ final class RelayProvider: UsageProvider, @unchecked Sendable { descriptor: descriptor, credentialResolver: credentialResolver, recoveryPolicy: recoveryPolicy, - httpClient: httpClient + httpClient: httpClient, + registry: registry ) } diff --git a/Sources/OhMyUsage/Providers/RelayResponseInterpreter.swift b/Sources/OhMyUsage/Providers/RelayResponseInterpreter.swift index e086b1f..b62b058 100644 --- a/Sources/OhMyUsage/Providers/RelayResponseInterpreter.swift +++ b/Sources/OhMyUsage/Providers/RelayResponseInterpreter.swift @@ -20,7 +20,7 @@ struct AccountChannelResult { let accountLabel: String? let planType: String? let quotaWindows: [UsageQuotaWindow] - let note: String + var note: String var rawMeta: [String: String] var recoveryMeta: [String: String] = [:] } @@ -340,9 +340,9 @@ enum RelayResponseInterpreter { /// XiaomiMIMO's platform returns a business envelope with `code: 0` on success. /// When the browser Cookie expires it typically still answers HTTP 200 with a /// non-zero `code` and no `data`, which the usage-shape check below would - /// otherwise misread as "missing usage item". Surface that as an auth error so - /// the executor can fall through to browser recovery instead of a confusing - /// "invalid response". + /// otherwise misread as "missing usage item". Surface auth failures as auth + /// errors, while keeping explicit no-subscription responses distinguishable + /// so the executor can try the pay-as-you-go balance endpoints. private static func xiaomimimoTokenPlanBusinessError(detailRoot: Any, usageRoot: Any) -> ProviderError? { for root in [detailRoot, usageRoot] { guard let dict = root as? [String: Any] else { continue } @@ -353,6 +353,12 @@ enum RelayResponseInterpreter { let suffix = message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? message!.trimmingCharacters(in: .whitespacesAndNewlines) : "code \(code)" + if let message, + isXiaomimimoNoSubscriptionMessage(message) { + return .invalidResponse( + "xiaomimimo token plan has no active subscription; \(message)" + ) + } return .unauthorizedDetail( "XiaomiMIMO Token Plan login missing or expired (\(suffix)). Log in again in platform.xiaomimimo.com and test the connection again." ) @@ -360,6 +366,17 @@ enum RelayResponseInterpreter { return nil } + private static func isXiaomimimoNoSubscriptionMessage(_ message: String) -> Bool { + let normalized = message.lowercased() + return normalized.contains("not subscribed") + || normalized.contains("no subscription") + || normalized.contains("subscription not found") + || normalized.contains("no active plan") + || normalized.contains("未订阅") + || normalized.contains("无套餐") + || normalized.contains("套餐不存在") + } + private static func xiaomimimoBusinessCode(_ dict: [String: Any]) -> Int? { if let number = dict["code"] as? NSNumber { return number.intValue diff --git a/Tests/OhMyUsageTests/RelayProviderTests.swift b/Tests/OhMyUsageTests/RelayProviderTests.swift index c56059c..a3ec3c7 100644 --- a/Tests/OhMyUsageTests/RelayProviderTests.swift +++ b/Tests/OhMyUsageTests/RelayProviderTests.swift @@ -1285,6 +1285,83 @@ final class RelayProviderTests: XCTestCase { XCTAssertEqual(snapshot.quotaWindows.first?.remainingPercent ?? -1, expectedRemainingPercent, accuracy: 0.000001) } + func testXiaomimimoTokenPlanFallsBackToPayAsYouGoBalanceWhenNoSubscription() async throws { + final class RequestPathRecorder: @unchecked Sendable { + private let lock = NSLock() + private var paths: [String] = [] + + func append(_ path: String) { + lock.lock() + paths.append(path) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return paths + } + } + + let requestedPaths = RequestPathRecorder() + RelayMockURLProtocol.requestHandler = { request in + requestedPaths.append(request.url?.path ?? "") + XCTAssertEqual(request.value(forHTTPHeaderField: "Cookie"), "api-platform_serviceToken=abc123; userId=10001") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + switch request.url?.path { + case "/api/v1/tokenPlan/detail": + return (response, Data(#"{"code":0,"data":null}"#.utf8)) + case "/api/v1/tokenPlan/usage": + return (response, Data(#"{"code":0,"data":{"usage":{"items":[]}}}"#.utf8)) + case "/api/v1/userProfile": + return (response, Data(#"{"data":{"nickname":"mimo-payg"}}"#.utf8)) + case "/api/v1/balance": + return (response, Data(#"{"data":{"availableBalance":"12.34","monthlyUsage":"2.00","totalLimit":"50.00"}}"#.utf8)) + default: + XCTFail("Unexpected path \(request.url?.path ?? "nil")") + return (response, Data(#"{}"#.utf8)) + } + } + defer { RelayMockURLProtocol.requestHandler = nil } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RelayMockURLProtocol.self] + let session = URLSession(configuration: config) + let service = "OhMyUsageTests-\(UUID().uuidString)" + let keychain = makeTestKeychain() + XCTAssertTrue(keychain.saveToken( + "api-platform_serviceToken=abc123; userId=10001", + service: service, + account: "platform.xiaomimimo.com/session-cookie" + )) + + let provider = RelayProvider( + descriptor: makeRelayDescriptor( + service: service, + adapterID: "xiaomimimo-token-plan", + baseURL: "https://platform.xiaomimimo.com", + balanceAccount: "platform.xiaomimimo.com/session-cookie" + ), + session: session, + keychain: keychain, + browserCredentialService: BrowserCredentialService() + ) + + let snapshot = try await provider.fetch() + XCTAssertEqual(snapshot.remaining ?? -1, 12.34, accuracy: 0.001) + XCTAssertEqual(snapshot.used ?? -1, 2.00, accuracy: 0.001) + XCTAssertEqual(snapshot.limit ?? -1, 50.00, accuracy: 0.001) + XCTAssertEqual(snapshot.unit, "CNY") + XCTAssertEqual(snapshot.rawMeta["account.billingMode"], "payAsYouGo") + XCTAssertEqual(snapshot.rawMeta["account.endpointPath"], "/api/v1/balance") + XCTAssertEqual(requestedPaths.snapshot(), [ + "/api/v1/tokenPlan/detail", + "/api/v1/tokenPlan/usage", + "/api/v1/userProfile", + "/api/v1/balance" + ]) + } + func testXiaomimimoTokenPlanAcceptsBareServiceTokenValue() async throws { RelayMockURLProtocol.requestHandler = { request in XCTAssertEqual(request.value(forHTTPHeaderField: "Cookie"), "api-platform_serviceToken=abc123") @@ -1418,6 +1495,28 @@ final class RelayProviderTests: XCTestCase { } } + func testXiaomimimoTokenPlanBusinessNoSubscriptionIsFallbackError() throws { + let detailRoot: Any = ["code": 40001, "message": "not subscribed", "data": NSNull()] + let usageRoot: Any = ["code": 40001, "message": "not subscribed", "data": NSNull()] + let candidate = RelayCredentialCandidate( + headers: ["Cookie": "api-platform_serviceToken=abc123"], + source: "savedCookieHeader", + persistedCredential: "api-platform_serviceToken=abc123" + ) + XCTAssertThrowsError( + try RelayResponseInterpreter.extractXiaomimimoTokenPlanValues( + detailRoot: detailRoot, + usageRoot: usageRoot, + candidate: candidate + ) + ) { error in + guard case .invalidResponse(let detail) = error as? ProviderError else { + return XCTFail("Expected invalidResponse, got \(error)") + } + XCTAssertTrue(detail.localizedCaseInsensitiveContains("no active subscription")) + } + } + func testXiaomimimoTokenPlanAcceptsPayloadWrappedUsage() throws { let detailRoot: Any = ["code": 0, "data": ["planCode": "standard", "planName": "Standard"]] let usageRoot: Any = ["code": 0, "data": ["payload": ["usage": ["percent": 10.0, "items": [ diff --git a/VERSION b/VERSION index 6550da6..79a6144 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.3 \ No newline at end of file +2.4.4