Browse Source

feat(providers): implement apply engine with DB writes, preimage capture, and exclusion support

- Enhanced applyProviderBatchPatch to write to DB via updateProvidersBatch
- Added excludeProviderIds support for selective application
- Capture preimage of changed fields for undo support
- Map snake_case patch fields to camelCase repository format
- Invalidate provider cache after successful write
- Store preimage and patch in undo snapshot for T10
- 15 new tests covering writes, exclusion, idempotency, field mapping
ding113 2 weeks ago
parent
commit
1c145dfef7
2 changed files with 508 additions and 2 deletions
  1. 83 2
      src/actions/providers.ts
  2. 425 0
      tests/unit/actions/providers-apply-engine.test.ts

+ 83 - 2
src/actions/providers.ts

@@ -19,6 +19,7 @@ import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provi
 import { logger } from "@/lib/logger";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
 import {
+  buildProviderBatchApplyUpdates,
   hasProviderBatchPatchChanges,
   normalizeProviderBatchPatchDraft,
   PROVIDER_PATCH_ERROR_CODES,
@@ -45,6 +46,7 @@ import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
 import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url";
 import { CreateProviderSchema, UpdateProviderSchema } from "@/lib/validation/schemas";
 import {
+  type BatchProviderUpdates,
   createProvider,
   deleteProvider,
   findAllProviders,
@@ -54,6 +56,7 @@ import {
   resetProviderTotalCostResetAt,
   updateProvider,
   updateProviderPrioritiesBatch,
+  updateProvidersBatch,
 } from "@/repository/provider";
 import {
   backfillProviderEndpointsFromProviders,
@@ -72,6 +75,7 @@ import type {
   CodexReasoningSummaryPreference,
   CodexTextVerbosityPreference,
   Provider,
+  ProviderBatchApplyUpdates,
   ProviderBatchPatch,
   ProviderBatchPatchField,
   ProviderDisplay,
@@ -1057,6 +1061,7 @@ const ApplyProviderBatchPatchSchema = z
     providerIds: ProviderBatchPatchProviderIdsSchema,
     patch: z.unknown().optional().default({}),
     idempotencyKey: z.string().trim().min(1).max(128).optional(),
+    excludeProviderIds: z.array(z.number().int().positive()).optional().default([]),
   })
   .strict();
 
@@ -1123,6 +1128,8 @@ interface ProviderPatchUndoSnapshot {
   undoExpiresAt: number;
   operationId: string;
   providerIds: number[];
+  preimage: Record<number, Record<string, unknown>>;
+  patch: ProviderBatchPatch;
 }
 
 const providerBatchPatchPreviewStore = new Map<string, ProviderBatchPatchPreviewSnapshot>();
@@ -1205,6 +1212,40 @@ function buildNoChangesError(): ProviderPatchActionError {
   };
 }
 
+function mapApplyUpdatesToRepositoryFormat(
+  applyUpdates: ProviderBatchApplyUpdates
+): BatchProviderUpdates {
+  const result: BatchProviderUpdates = {};
+  if (applyUpdates.is_enabled !== undefined) {
+    result.isEnabled = applyUpdates.is_enabled;
+  }
+  if (applyUpdates.priority !== undefined) {
+    result.priority = applyUpdates.priority;
+  }
+  if (applyUpdates.weight !== undefined) {
+    result.weight = applyUpdates.weight;
+  }
+  if (applyUpdates.cost_multiplier !== undefined) {
+    result.costMultiplier = applyUpdates.cost_multiplier.toString();
+  }
+  if (applyUpdates.group_tag !== undefined) {
+    result.groupTag = applyUpdates.group_tag;
+  }
+  if (applyUpdates.model_redirects !== undefined) {
+    result.modelRedirects = applyUpdates.model_redirects;
+  }
+  if (applyUpdates.allowed_models !== undefined) {
+    result.allowedModels = applyUpdates.allowed_models;
+  }
+  if (applyUpdates.anthropic_thinking_budget_preference !== undefined) {
+    result.anthropicThinkingBudgetPreference = applyUpdates.anthropic_thinking_budget_preference;
+  }
+  if (applyUpdates.anthropic_adaptive_thinking !== undefined) {
+    result.anthropicAdaptiveThinking = applyUpdates.anthropic_adaptive_thinking;
+  }
+  return result;
+}
+
 const PATCH_FIELD_TO_PROVIDER_KEY: Record<ProviderBatchPatchField, keyof Provider> = {
   is_enabled: "isEnabled",
   priority: "priority",
@@ -1433,6 +1474,44 @@ export async function applyProviderBatchPatch(
       };
     }
 
+    const excludeSet = new Set(parsed.data.excludeProviderIds ?? []);
+    const effectiveProviderIds = providerIds.filter((id) => !excludeSet.has(id));
+    if (effectiveProviderIds.length === 0) {
+      return {
+        ok: false,
+        error: "排除后无可应用的供应商",
+        errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY,
+      };
+    }
+
+    const updatesResult = buildProviderBatchApplyUpdates(normalizedPatch.data);
+    if (!updatesResult.ok) {
+      return {
+        ok: false,
+        error: updatesResult.error.message,
+        errorCode: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE,
+      };
+    }
+
+    const allProviders = await findAllProvidersFresh();
+    const effectiveIdSet = new Set(effectiveProviderIds);
+    const matchedProviders = allProviders.filter((p) => effectiveIdSet.has(p.id));
+    const changedFields = getChangedPatchFields(normalizedPatch.data);
+    const preimage: Record<number, Record<string, unknown>> = {};
+    for (const provider of matchedProviders) {
+      const fieldValues: Record<string, unknown> = {};
+      for (const field of changedFields) {
+        const providerKey = PATCH_FIELD_TO_PROVIDER_KEY[field];
+        fieldValues[providerKey] = provider[providerKey];
+      }
+      preimage[provider.id] = fieldValues;
+    }
+
+    const repositoryUpdates = mapApplyUpdatesToRepositoryFormat(updatesResult.data);
+    const dbUpdatedCount = await updateProvidersBatch(effectiveProviderIds, repositoryUpdates);
+
+    await publishProviderCacheInvalidation();
+
     const appliedAt = new Date(nowMs).toISOString();
     const undoToken = createProviderPatchUndoToken();
     const undoExpiresAtMs = nowMs + PROVIDER_PATCH_UNDO_TTL_MS;
@@ -1440,7 +1519,7 @@ export async function applyProviderBatchPatch(
     const applyResult: ApplyProviderBatchPatchResult = {
       operationId: createProviderPatchOperationId(),
       appliedAt,
-      updatedCount: providerIds.length,
+      updatedCount: dbUpdatedCount,
       undoToken,
       undoExpiresAt: new Date(undoExpiresAtMs).toISOString(),
     };
@@ -1454,7 +1533,9 @@ export async function applyProviderBatchPatch(
       undoToken,
       undoExpiresAt: undoExpiresAtMs,
       operationId: applyResult.operationId,
-      providerIds,
+      providerIds: effectiveProviderIds,
+      preimage,
+      patch: normalizedPatch.data,
     });
 
     return { ok: true, data: applyResult };

+ 425 - 0
tests/unit/actions/providers-apply-engine.test.ts

@@ -0,0 +1,425 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
+
+const getSessionMock = vi.fn();
+const findAllProvidersFreshMock = vi.fn();
+const updateProvidersBatchMock = vi.fn();
+const publishCacheInvalidationMock = vi.fn();
+
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+vi.mock("@/repository/provider", () => ({
+  findAllProvidersFresh: findAllProvidersFreshMock,
+  updateProvidersBatch: updateProvidersBatchMock,
+  deleteProvidersBatch: vi.fn(),
+}));
+
+vi.mock("@/lib/cache/provider-cache", () => ({
+  publishProviderCacheInvalidation: publishCacheInvalidationMock,
+}));
+
+vi.mock("@/lib/circuit-breaker", () => ({
+  clearProviderState: vi.fn(),
+  clearConfigCache: vi.fn(),
+  resetCircuit: vi.fn(),
+  getAllHealthStatusAsync: vi.fn(),
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    trace: vi.fn(),
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+function makeProvider(id: number, overrides: Record<string, unknown> = {}) {
+  return {
+    id,
+    name: `Provider-${id}`,
+    url: "https://api.example.com/v1",
+    key: "sk-test",
+    providerVendorId: null,
+    isEnabled: true,
+    weight: 100,
+    priority: 1,
+    groupPriorities: null,
+    costMultiplier: 1.0,
+    groupTag: null,
+    providerType: "claude",
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    totalCostResetAt: null,
+    limitConcurrentSessions: null,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 1800000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 10000,
+    requestTimeoutNonStreamingMs: 600000,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    swapCacheTtlBilling: false,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: new Date("2025-01-01"),
+    updatedAt: new Date("2025-01-01"),
+    deletedAt: null,
+    ...overrides,
+  };
+}
+
+describe("Apply Provider Batch Patch Engine", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.resetModules();
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findAllProvidersFreshMock.mockResolvedValue([]);
+    updateProvidersBatchMock.mockResolvedValue(0);
+    publishCacheInvalidationMock.mockResolvedValue(undefined);
+  });
+
+  /** Helper: create preview then apply with optional overrides */
+  async function setupPreviewAndApply(
+    providerIds: number[],
+    patch: Record<string, unknown>,
+    applyOverrides: Record<string, unknown> = {}
+  ) {
+    const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
+      "@/actions/providers"
+    );
+
+    const preview = await previewProviderBatchPatch({ providerIds, patch });
+    if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`);
+
+    const applyInput = {
+      previewToken: preview.data.previewToken,
+      previewRevision: preview.data.previewRevision,
+      providerIds,
+      patch,
+      ...applyOverrides,
+    };
+
+    const apply = await applyProviderBatchPatch(applyInput);
+    return { preview, apply, applyProviderBatchPatch };
+  }
+
+  it("should call updateProvidersBatch with correct IDs and updates", async () => {
+    const providers = [makeProvider(1, { groupTag: "old" }), makeProvider(2, { groupTag: "old" })];
+    findAllProvidersFreshMock.mockResolvedValue(providers);
+    updateProvidersBatchMock.mockResolvedValue(2);
+
+    const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "new-group" } });
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledOnce();
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1, 2],
+      expect.objectContaining({ groupTag: "new-group" })
+    );
+  });
+
+  it("should publish cache invalidation after successful write", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { apply } = await setupPreviewAndApply([1], { is_enabled: { set: false } });
+
+    expect(apply.ok).toBe(true);
+    expect(publishCacheInvalidationMock).toHaveBeenCalledOnce();
+  });
+
+  it("should fetch providers for preimage during apply", async () => {
+    const providers = [
+      makeProvider(1, { groupTag: "alpha", priority: 5 }),
+      makeProvider(2, { groupTag: "beta", priority: 10 }),
+    ];
+    findAllProvidersFreshMock.mockResolvedValue(providers);
+    updateProvidersBatchMock.mockResolvedValue(2);
+
+    const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "gamma" } });
+
+    expect(apply.ok).toBe(true);
+    // preview calls findAllProvidersFresh once, apply calls it once more
+    expect(findAllProvidersFreshMock).toHaveBeenCalledTimes(2);
+  });
+
+  it("should only apply to non-excluded providers with excludeProviderIds", async () => {
+    const providers = [
+      makeProvider(1, { groupTag: "a" }),
+      makeProvider(2, { groupTag: "b" }),
+      makeProvider(3, { groupTag: "c" }),
+    ];
+    findAllProvidersFreshMock.mockResolvedValue(providers);
+    updateProvidersBatchMock.mockResolvedValue(2);
+
+    const { apply } = await setupPreviewAndApply(
+      [1, 2, 3],
+      { group_tag: { set: "unified" } },
+      { excludeProviderIds: [2] }
+    );
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1, 3],
+      expect.objectContaining({ groupTag: "unified" })
+    );
+  });
+
+  it("should return NOTHING_TO_APPLY when all providers are excluded", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]);
+
+    const { apply } = await setupPreviewAndApply(
+      [1, 2],
+      { group_tag: { set: "x" } },
+      { excludeProviderIds: [1, 2] }
+    );
+
+    expect(apply.ok).toBe(false);
+    if (apply.ok) return;
+    expect(apply.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY);
+    expect(updateProvidersBatchMock).not.toHaveBeenCalled();
+  });
+
+  it("should set updatedCount from updateProvidersBatch return value", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([
+      makeProvider(1),
+      makeProvider(2),
+      makeProvider(3),
+    ]);
+    updateProvidersBatchMock.mockResolvedValue(3);
+
+    const { apply } = await setupPreviewAndApply([1, 2, 3], { weight: { set: 50 } });
+
+    expect(apply.ok).toBe(true);
+    if (!apply.ok) return;
+    expect(apply.data.updatedCount).toBe(3);
+  });
+
+  it("should reflect exclusions in updatedCount", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([
+      makeProvider(1),
+      makeProvider(2),
+      makeProvider(3),
+    ]);
+    updateProvidersBatchMock.mockResolvedValue(2);
+
+    const { apply } = await setupPreviewAndApply(
+      [1, 2, 3],
+      { weight: { set: 50 } },
+      { excludeProviderIds: [3] }
+    );
+
+    expect(apply.ok).toBe(true);
+    if (!apply.ok) return;
+    expect(apply.data.updatedCount).toBe(2);
+  });
+
+  it("should return PREVIEW_EXPIRED for unknown preview token", async () => {
+    const { applyProviderBatchPatch } = await import("@/actions/providers");
+
+    const result = await applyProviderBatchPatch({
+      previewToken: "provider_patch_preview_nonexistent",
+      previewRevision: "rev",
+      providerIds: [1],
+      patch: { group_tag: { set: "x" } },
+    });
+
+    expect(result.ok).toBe(false);
+    if (result.ok) return;
+    expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED);
+  });
+
+  it("should return PREVIEW_STALE for mismatched patch", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
+
+    const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
+      "@/actions/providers"
+    );
+
+    const preview = await previewProviderBatchPatch({
+      providerIds: [1],
+      patch: { group_tag: { set: "original" } },
+    });
+    if (!preview.ok) throw new Error("Preview should succeed");
+
+    const result = await applyProviderBatchPatch({
+      previewToken: preview.data.previewToken,
+      previewRevision: preview.data.previewRevision,
+      providerIds: [1],
+      patch: { group_tag: { set: "different" } },
+    });
+
+    expect(result.ok).toBe(false);
+    if (result.ok) return;
+    expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE);
+  });
+
+  it("should return cached result for same idempotencyKey without re-writing to DB", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]);
+    updateProvidersBatchMock.mockResolvedValue(2);
+
+    const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
+      "@/actions/providers"
+    );
+
+    const preview = await previewProviderBatchPatch({
+      providerIds: [1, 2],
+      patch: { group_tag: { set: "idem" } },
+    });
+    if (!preview.ok) throw new Error("Preview should succeed");
+
+    const applyInput = {
+      previewToken: preview.data.previewToken,
+      previewRevision: preview.data.previewRevision,
+      providerIds: [1, 2],
+      patch: { group_tag: { set: "idem" } },
+      idempotencyKey: "idem-key-1",
+    };
+
+    const first = await applyProviderBatchPatch(applyInput);
+    const second = await applyProviderBatchPatch(applyInput);
+
+    expect(first.ok).toBe(true);
+    expect(second.ok).toBe(true);
+    if (!first.ok || !second.ok) return;
+
+    expect(second.data.operationId).toBe(first.data.operationId);
+    expect(updateProvidersBatchMock).toHaveBeenCalledOnce();
+  });
+
+  it("should prevent double-apply by marking snapshot as applied", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
+      "@/actions/providers"
+    );
+
+    const preview = await previewProviderBatchPatch({
+      providerIds: [1],
+      patch: { group_tag: { set: "x" } },
+    });
+    if (!preview.ok) throw new Error("Preview should succeed");
+
+    const applyInput = {
+      previewToken: preview.data.previewToken,
+      previewRevision: preview.data.previewRevision,
+      providerIds: [1],
+      patch: { group_tag: { set: "x" } },
+    };
+
+    const first = await applyProviderBatchPatch(applyInput);
+    const second = await applyProviderBatchPatch(applyInput);
+
+    expect(first.ok).toBe(true);
+    expect(second.ok).toBe(false);
+    if (second.ok) return;
+    expect(second.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE);
+  });
+
+  it("should map cost_multiplier to string for repository", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([makeProvider(1, { costMultiplier: 1.0 })]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { apply } = await setupPreviewAndApply([1], { cost_multiplier: { set: 2.5 } });
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1],
+      expect.objectContaining({ costMultiplier: "2.5" })
+    );
+  });
+
+  it("should map multiple fields correctly to repository format", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([
+      makeProvider(1, { groupTag: "old", weight: 100, priority: 1 }),
+    ]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { apply } = await setupPreviewAndApply([1], {
+      group_tag: { set: "new" },
+      weight: { set: 80 },
+      priority: { set: 5 },
+    });
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1],
+      expect.objectContaining({
+        groupTag: "new",
+        weight: 80,
+        priority: 5,
+      })
+    );
+  });
+
+  it("should map clear mode to null for clearable fields", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([
+      makeProvider(1, { groupTag: "has-tag", modelRedirects: { a: "b" } }),
+    ]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { apply } = await setupPreviewAndApply([1], {
+      group_tag: { clear: true },
+      model_redirects: { clear: true },
+    });
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1],
+      expect.objectContaining({
+        groupTag: null,
+        modelRedirects: null,
+      })
+    );
+  });
+
+  it("should map anthropic_thinking_budget_preference clear to inherit", async () => {
+    findAllProvidersFreshMock.mockResolvedValue([
+      makeProvider(1, { anthropicThinkingBudgetPreference: "8192" }),
+    ]);
+    updateProvidersBatchMock.mockResolvedValue(1);
+
+    const { apply } = await setupPreviewAndApply([1], {
+      anthropic_thinking_budget_preference: { clear: true },
+    });
+
+    expect(apply.ok).toBe(true);
+    expect(updateProvidersBatchMock).toHaveBeenCalledWith(
+      [1],
+      expect.objectContaining({
+        anthropicThinkingBudgetPreference: "inherit",
+      })
+    );
+  });
+});