編集時は**新値を入力**して保存する方式(回転入力)に * > 統一する。 * > 空欄保存は「変更なし」として扱い、現値を保持する。 * > 現値を削除したい場合は専用の「削除」操作を用意する。 * * Concretely, for each provider in the supplied snapshot's * {@see PluginSettings::providerCredentials()} map, every * secret value that is the empty string after `trim()` is * REPLACED with the corresponding secret from the currently * persisted snapshot's same provider, key-by-key. Non-empty * secret values from the new snapshot win; the `enabled` * provider-toggle flag from the new snapshot ALWAYS wins * (rotation only protects secret values, never the toggle). * * Edge cases: * * - Non-existent provider key in the currently persisted * snapshot: every empty value from the new snapshot is * persisted as the empty string. The caller is responsible * for probing {@see ProviderCredentials::isConfigured()} * after the save and surfacing a "not yet configured" * state in the §13.10 保存 UX response. * * - Whitespace-only value (e.g. `" "`): treated as empty * per §6.3 because the admin form's empty-string detection * happens after sanitization trims whitespace. * * - Non-secret VOs ({@see RenderingDefaults}, * {@see LinkDefaults}, {@see SyncSettings}, * {@see CacheSettings}, and the §13.9 Debug Mode flag) * replace the persisted values WHOLESALE; they do not * carry secret material so the rotation rule does not * apply. * * Authorization is NOT enforced by this use case. The §13 * admin page renderer (slice 8c-2) and the §13.2 * `POST /affilun/v1/settings` REST controller (later slice) are * the canonical capability-check seams; this use case is the * Application-layer primitive both surfaces drive once * authorization has already passed. * * Field-level validation is also NOT performed here. Each * sub-VO ({@see ProviderCredentials}, * {@see RenderingDefaults}, etc.) enforces its own * structural invariants in its constructor; by the time a * {@see PluginSettings} value reaches this use case those * invariants have already held, so an additional validation * pass would be redundant. * * @package Affilun\Application\Settings\UseCase */ declare(strict_types=1); namespace Affilun\Application\Settings\UseCase; use Affilun\Application\Settings\OpenAiSettings; use Affilun\Application\Settings\PluginSettings; use Affilun\Application\Settings\ProviderCredentials; use Affilun\Application\Settings\SettingsRepository; /** * Persist a {@see PluginSettings} snapshot, applying the §6.3 * rotating-input rule to every provider's secret values. * * See the file-level docblock for the rotation rule, the edge * cases (missing current provider, whitespace-only values), * and the rationale for not enforcing authorization or * field-level validation here. */ final class SaveSettingsUseCase { /** * Backing settings repository. */ private SettingsRepository $repository; /** * Construct the use case with the repository it delegates * to. * * @param SettingsRepository $repository Backing settings repository. */ public function __construct( SettingsRepository $repository ) { $this->repository = $repository; } /** * Persist the supplied settings snapshot, applying the * §6.3 rotation rule to every provider's secret values. * * Returns the merged snapshot the repository now holds so * callers can echo the canonical state back to the §13.10 * 保存 UX response without a follow-up * {@see SettingsRepository::load()} round-trip. * * @param PluginSettings $newSettings Caller-supplied snapshot. */ public function execute( PluginSettings $newSettings ): PluginSettings { $current = $this->repository->load(); $newOpenAi = OpenAiSettings::defaults(); $newCloud = $newSettings->cloud(); if ( trim( $newCloud->apiKey() ) === '' && $current->cloud()->apiKey() !== '' ) { $newCloud = $newCloud->withApiKey( $current->cloud()->apiKey() ); } $merged = $newSettings ->withRendering( $newSettings->rendering() ) ->withLinks( $newSettings->links() ) ->withSync( $newSettings->sync() ) ->withCache( $newSettings->cache() ) ->withDebugMode( $newSettings->debugMode() ) ->withOpenAi( $newOpenAi ) ->withCloud( $newCloud ); foreach ( $newSettings->providerCredentials() as $providerKey => $newCreds ) { $mergedCreds = self::mergeProviderCredentials( $newCreds, $current->provider( $providerKey ) ); $merged = $merged->withProviderCredentials( $mergedCreds ); } $this->repository->save( $merged ); return $merged; } /** * Merge a single provider's new credentials with its * currently persisted credentials per the §6.3 rotation * rule. * * For each secret key in `$newCreds`: * * - If `trim($newValue) === ''`, the corresponding value * from `$currentCreds` is preserved (when present). * - Otherwise, the new value wins. * * The `enabled` flag from `$newCreds` always wins; the * resulting credentials carry the new snapshot's toggle * state regardless of whether secrets were preserved. * * @param ProviderCredentials $newCreds New credentials supplied by the caller. * @param ProviderCredentials|null $currentCreds Currently persisted credentials, or null when * the provider key is absent from the persisted * snapshot. */ private static function mergeProviderCredentials( ProviderCredentials $newCreds, ?ProviderCredentials $currentCreds ): ProviderCredentials { $mergedSecrets = array(); foreach ( $newCreds->secrets() as $secretKey => $newValue ) { if ( trim( $newValue ) === '' && $currentCreds !== null ) { $preserved = $currentCreds->secret( $secretKey ); $mergedSecrets[ $secretKey ] = $preserved ?? $newValue; continue; } $mergedSecrets[ $secretKey ] = $newValue; } return new ProviderCredentials( $newCreds->providerKey(), $newCreds->enabled(), $mergedSecrets ); } }