diff --git a/README.md b/README.md index 65caad2..f781f70 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均 ## ⭐ Star History -[![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=4)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=5)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) ---
diff --git a/docs/faq.md b/docs/faq.md index 8b0fb1b..18d5218 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,6 +4,50 @@ --- + + +## 账号认证方式与兼容性(Access Token / Cookie) + +### Q: Access Token 和 Cookie 有什么区别?优先用哪个? + +**A:** Metapi 支持两种 Session 凭证来源(都用于管理接口,如余额、签到等): + +| 凭证 | 特点 | 适用场景 | +|------|------|----------| +| **Access Token(系统访问令牌)** | 支持多账号、通常更稳定、更适合长期使用 | 标准 NewAPI / OneAPI / OneHub / DoneHub / Veloera 等站点 | +| **Cookie(浏览器登录态)** | 兼容性强,但可能过期,且多账号体验较差 | 访问令牌不可用、魔改站点、特殊兼容场景 | + +> 结论:优先使用 **Access Token**。仅在站点不支持或令牌不可用时使用 Cookie。 + +### Q: 在 Metapi 里怎么切换认证方式? + +**A:** 在「账号管理 → 添加账号 → Cookie / Token 导入」中: + +1. 选择站点 +2. 在「凭证模式」下拉选择: + - 自动识别(推荐) + - Session 模式(Access Token / Cookie) + - API Key 模式(仅代理) +3. 粘贴凭证并点击「验证 Token」 + +### Q: 以 NewAPI 为例,Access Token 在哪里生成? + +**A:** 通常路径是:**控制台 → 个人设置 → 安全设置 → 系统访问令牌**。 +如果你确实需要 Cookie 兜底,可在浏览器 `F12 → Application → Cookie` 获取对应登录态。 + +### Q: AnyRouter 这类魔改站点为什么更容易报错? + +**A:** AnyRouter 为魔改实现,删除了Access Token入口,也有其他类似的魔改中转站。建议: + +1. 先尝试 Session 模式 + Access Token +2. 若站点不支持或报认证相关错误,再尝试 Cookie + +### Q: Sub2API 站点怎么处理? + +**A:** Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。当前建议: + + + ## 部署相关 ### Q: 启动后无法访问管理后台 diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 584dea8..3e30a48 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -34,6 +34,7 @@ function ensureTokenManagementSchema() { account_id integer NOT NULL, name text NOT NULL, token text NOT NULL, + token_group text, source text DEFAULT 'manual', enabled integer DEFAULT true, is_default integer DEFAULT false, @@ -47,6 +48,10 @@ function ensureTokenManagementSchema() { sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;'); } + if (!tableColumnExists('account_tokens', 'token_group')) { + sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;'); + } + sqlite.exec(` INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at) SELECT @@ -123,6 +128,33 @@ function ensureSiteProxySchema() { } } +function ensureSiteExternalCheckinUrlSchema() { + if (!tableExists('sites')) { + return; + } + + if (!tableColumnExists('sites', 'external_checkin_url')) { + sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`); + } +} + +function ensureSiteGlobalWeightSchema() { + if (!tableExists('sites')) { + return; + } + + if (!tableColumnExists('sites', 'global_weight')) { + sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`); + } + + sqlite.exec(` + UPDATE sites + SET global_weight = 1 + WHERE global_weight IS NULL + OR global_weight <= 0; + `); +} + function ensureRouteGroupingSchema() { if (!tableExists('token_routes') || !tableExists('route_channels')) { return; @@ -184,6 +216,8 @@ function ensureDownstreamApiKeySchema() { ensureTokenManagementSchema(); ensureSiteStatusSchema(); ensureSiteProxySchema(); +ensureSiteExternalCheckinUrlSchema(); +ensureSiteGlobalWeightSchema(); ensureRouteGroupingSchema(); ensureDownstreamApiKeySchema(); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 8a9d1e6..a5563c6 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -5,11 +5,13 @@ export const sites = sqliteTable('sites', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), url: text('url').notNull(), + externalCheckinUrl: text('external_checkin_url'), platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' proxyUrl: text('proxy_url'), status: text('status').notNull().default('active'), // 'active' | 'disabled' isPinned: integer('is_pinned', { mode: 'boolean' }).default(false), sortOrder: integer('sort_order').default(0), + globalWeight: real('global_weight').default(1), apiKey: text('api_key'), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), @@ -48,6 +50,7 @@ export const accountTokens = sqliteTable('account_tokens', { accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), name: text('name').notNull(), token: text('token').notNull(), + tokenGroup: text('token_group'), source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy' enabled: integer('enabled', { mode: 'boolean' }).default(true), isDefault: integer('is_default', { mode: 'boolean' }).default(false), diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 6f2932b..1d585b6 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -215,7 +215,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise ({ + getAdapter: () => ({ + verifyToken: (...args: unknown[]) => verifyTokenMock(...args), + getModels: (...args: unknown[]) => getModelsMock(...args), + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts credential mode', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-credential-mode-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(() => { + verifyTokenMock.mockReset(); + getModelsMock.mockReset(); + getApiTokensMock.mockReset(); + getApiTokensMock.mockResolvedValue([]); + + db.delete(schema.proxyLogs).run(); + db.delete(schema.checkinLogs).run(); + db.delete(schema.routeChannels).run(); + db.delete(schema.tokenRoutes).run(); + db.delete(schema.tokenModelAvailability).run(); + db.delete(schema.modelAvailability).run(); + db.delete(schema.accountTokens).run(); + db.delete(schema.accounts).run(); + db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('uses API-key fast verify path when credentialMode is apikey', async () => { + verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called')); + getModelsMock.mockResolvedValueOnce(['gpt-5-mini', 'gpt-4o-mini']); + + const site = db.insert(schema.sites).values({ + name: 'Fast Verify Site', + url: 'https://fast-verify.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + payload: { + siteId: site.id, + accessToken: 'sk-fast-verify', + credentialMode: 'apikey', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + tokenType: 'apikey', + modelCount: 2, + }); + expect(getModelsMock).toHaveBeenCalledTimes(1); + expect(verifyTokenMock).not.toHaveBeenCalled(); + }); + + it('adds account as proxy-only when credentialMode is apikey', async () => { + verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called')); + getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']); + + const site = db.insert(schema.sites).values({ + name: 'Proxy Only Site', + url: 'https://proxy-only.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'sk-proxy-only', + credentialMode: 'apikey', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { tokenType?: string; capabilities?: { proxyOnly?: boolean } }; + expect(body.tokenType).toBe('apikey'); + expect(body.capabilities?.proxyOnly).toBe(true); + + const accounts = db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.accessToken || '').toBe(''); + expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true); + expect(accounts[0]?.checkinEnabled).toBe(false); + + const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string }; + expect(parsedExtra.credentialMode).toBe('apikey'); + }); + + it('stores managed refresh token for sub2api session account', async () => { + verifyTokenMock.mockResolvedValueOnce({ + tokenType: 'session', + userInfo: { username: 'sub2-user' }, + }); + + const site = db.insert(schema.sites).values({ + name: 'Sub2 Site', + url: 'https://sub2.example.com', + platform: 'sub2api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'jwt-access-token', + refreshToken: 'jwt-refresh-token', + tokenExpiresAt: 1760000000000, + }, + }); + + expect(response.statusCode).toBe(200); + const created = db.select().from(schema.accounts).all()[0]; + const parsedExtra = JSON.parse(created?.extraConfig || '{}') as { + credentialMode?: string; + sub2apiAuth?: { + refreshToken?: string; + tokenExpiresAt?: number; + }; + }; + expect(parsedExtra.credentialMode).toBe('session'); + expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('jwt-refresh-token'); + expect(parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000); + }); + + it('updates and clears managed refresh token via account update API', async () => { + const site = db.insert(schema.sites).values({ + name: 'Sub2 Site', + url: 'https://sub2.example.com', + platform: 'sub2api', + }).returning().get(); + const account = db.insert(schema.accounts).values({ + siteId: site.id, + username: 'sub2-user', + accessToken: 'access-token', + extraConfig: JSON.stringify({ + credentialMode: 'session', + sub2apiAuth: { + refreshToken: 'old-refresh-token', + tokenExpiresAt: 1750000000000, + }, + }), + }).returning().get(); + + const updateResponse = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: { + refreshToken: 'new-refresh-token', + tokenExpiresAt: 1760000000000, + }, + }); + expect(updateResponse.statusCode).toBe(200); + + const updated = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const parsedUpdated = JSON.parse(updated?.extraConfig || '{}') as { + sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number }; + }; + expect(parsedUpdated.sub2apiAuth?.refreshToken).toBe('new-refresh-token'); + expect(parsedUpdated.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000); + + const clearResponse = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: { + refreshToken: '', + }, + }); + expect(clearResponse.statusCode).toBe(200); + + const cleared = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + const parsedCleared = JSON.parse(cleared?.extraConfig || '{}') as { + sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number }; + }; + expect(parsedCleared.sub2apiAuth).toBeUndefined(); + }); +}); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 076a192..4c46f5d 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -5,7 +5,15 @@ import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js'; import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js'; -import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; +import { + getCredentialModeFromExtraConfig, + guessPlatformUserIdFromUsername, + getSub2ApiAuthFromExtraConfig, + mergeAccountExtraConfig, + normalizeCredentialMode as normalizeCredentialModeInput, + resolvePlatformUserId, + type AccountCredentialMode, +} from '../../services/accountExtraConfig.js'; import { encryptAccountPassword } from '../../services/accountCredentialService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js'; @@ -28,11 +36,50 @@ type AccountHealthRefreshResult = { accountId: number; username: string | null; siteName: string; - status: 'success' | 'failed' | 'skipped'; - state: RuntimeHealthState; + status: 'success' | 'failed' | 'skipped'; + state: RuntimeHealthState; message: string; }; +type AccountCapabilities = { + canCheckin: boolean; + canRefreshBalance: boolean; + proxyOnly: boolean; +}; + +function hasSessionTokenValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +function resolveRequestedCredentialMode(input: unknown): AccountCredentialMode { + return normalizeCredentialModeInput(input) || 'auto'; +} + +function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelect): AccountCredentialMode { + const fromConfig = getCredentialModeFromExtraConfig(account.extraConfig); + if (fromConfig && fromConfig !== 'auto') return fromConfig; + return hasSessionTokenValue(account.accessToken) ? 'session' : 'apikey'; +} + +function buildCapabilitiesFromCredentialMode( + credentialMode: AccountCredentialMode, + hasSessionToken: boolean, +): AccountCapabilities { + const sessionCapable = credentialMode === 'session' + ? hasSessionToken + : (credentialMode === 'apikey' ? false : hasSessionToken); + return { + canCheckin: sessionCapable, + canRefreshBalance: sessionCapable, + proxyOnly: !sessionCapable, + }; +} + +function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities { + const credentialMode = resolveStoredCredentialMode(account); + return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken)); +} + function normalizePinnedFlag(input: unknown): boolean | null { if (input === undefined || input === null) return null; if (typeof input === 'boolean') return input; @@ -52,6 +99,21 @@ function normalizeSortOrder(input: unknown): number | null { return Math.max(0, parsed); } +function normalizeManagedRefreshToken(input: unknown): string | undefined { + if (typeof input !== 'string') return undefined; + const trimmed = input.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeManagedTokenExpiresAt(input: unknown): number | undefined { + if (typeof input === 'number' && Number.isFinite(input) && input > 0) return Math.trunc(input); + if (typeof input === 'string') { + const parsed = Number.parseInt(input.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + function getNextAccountSortOrder(): number { const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all(); const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1); @@ -195,24 +257,32 @@ export async function accountsRoutes(app: FastifyInstance) { parsedRewardCountByAccount[log.accountId] = (parsedRewardCountByAccount[log.accountId] || 0) + 1; } - return rows.map((r) => ({ - ...r.accounts, - site: r.sites, - todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, - todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ - day: localDay, - successCount: successCountByAccount[r.accounts.id] || 0, - parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0, - rewardSum: rewardByAccount[r.accounts.id] || 0, - extraConfig: r.accounts.extraConfig, - }) * 1_000_000) / 1_000_000, - runtimeHealth: buildRuntimeHealthForAccount({ - accountStatus: r.accounts.status, - siteStatus: r.sites.status, - extraConfig: r.accounts.extraConfig, - }), - })); - }); + return rows.map((r) => { + const credentialMode = resolveStoredCredentialMode(r.accounts); + return { + ...r.accounts, + site: r.sites, + credentialMode, + capabilities: buildCapabilitiesFromCredentialMode( + credentialMode, + hasSessionTokenValue(r.accounts.accessToken), + ), + todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, + todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ + day: localDay, + successCount: successCountByAccount[r.accounts.id] || 0, + parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0, + rewardSum: rewardByAccount[r.accounts.id] || 0, + extraConfig: r.accounts.extraConfig, + }) * 1_000_000) / 1_000_000, + runtimeHealth: buildRuntimeHealthForAccount({ + accountStatus: r.accounts.status, + siteStatus: r.sites.status, + extraConfig: r.accounts.extraConfig, + }), + }; + }); + }); // Login to a site and auto-create account app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => { @@ -249,12 +319,13 @@ export async function accountsRoutes(app: FastifyInstance) { .where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username))) .get(); - const extraConfigPatch: Record = { - autoRelogin: { - username, - passwordCipher: encryptAccountPassword(password), - updatedAt: new Date().toISOString(), - }, + const extraConfigPatch: Record = { + credentialMode: 'session', + autoRelogin: { + username, + passwordCipher: encryptAccountPassword(password), + updatedAt: new Date().toISOString(), + }, }; if (guessedPlatformUserId) { extraConfigPatch.platformUserId = guessedPlatformUserId; @@ -318,15 +389,45 @@ export async function accountsRoutes(app: FastifyInstance) { }; }); - // Verify a token against a site - auto-detects token type (session vs API key) - app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number } }>('/api/accounts/verify-token', async (request) => { - const { siteId, accessToken, platformUserId } = request.body; - const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get(); - if (!site) return { success: false, message: 'site not found' }; - - const adapter = getAdapter(site.platform); - if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` }; - + // Verify credentials against a site. + app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => { + const { siteId, platformUserId } = request.body; + const accessToken = (request.body.accessToken || '').trim(); + const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode); + const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get(); + if (!site) return { success: false, message: 'site not found' }; + + if (!accessToken) { + return { success: false, message: 'Token 不能为空' }; + } + + const adapter = getAdapter(site.platform); + if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` }; + + if (credentialMode === 'apikey') { + try { + const models = await adapter.getModels(site.url, accessToken, platformUserId); + const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : []; + if (availableModels.length === 0) { + return { + success: false, + message: 'API Key 验证失败:未获取到可用模型', + }; + } + return { + success: true, + tokenType: 'apikey', + modelCount: availableModels.length, + models: availableModels.slice(0, 10), + }; + } catch (err: any) { + return { + success: false, + message: err?.message || 'API Key 验证失败', + }; + } + } + let result: any; try { result = await adapter.verifyToken(site.url, accessToken, platformUserId); @@ -336,103 +437,110 @@ export async function accountsRoutes(app: FastifyInstance) { message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'), }; } - - if (result.tokenType === 'session') { - return { - success: true, - tokenType: 'session', - userInfo: result.userInfo, - balance: result.balance, - apiToken: result.apiToken, - }; - } - - if (result.tokenType === 'apikey') { - return { - success: true, - tokenType: 'apikey', - modelCount: result.models?.length || 0, - models: result.models?.slice(0, 10), - }; - } - - // Try to explain unknown failures: missing user id vs anti-bot challenge page. - type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null; - const detectVerifyFailureReason = async (): Promise => { - const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => { - const text = bodyText || ''; - const ct = (contentType || '').toLowerCase(); - if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|