diff --git a/app/routes.php b/app/routes.php index 7550a7084..9fec31bfd 100755 --- a/app/routes.php +++ b/app/routes.php @@ -13,6 +13,8 @@ use App\Middleware\VerifyNasaToken; use Slim\Routing\RouteCollectorProxy; return static function (Slim\App $app): void { + // Independent, disabled-by-default ATOM announcement protocol. + $app->post('/api/atom/v1/announcements', App\Controllers\Api\Atom\AnnouncementController::class . ':handle'); // test $app->get('/my', App\Controllers\MyController::class . ':index'); diff --git a/config/.config.example.php b/config/.config.example.php index b76962467..51672e56c 100755 --- a/config/.config.example.php +++ b/config/.config.example.php @@ -146,3 +146,11 @@ $_ENV['r2_account_id'] = ''; $_ENV['r2_access_key_id'] = ''; $_ENV['r2_access_key_secret'] = ''; $_ENV['r2_client_download_timeout'] = 10; + +// ATOM multi-ship announcement adapter: enable only after applying its additive SQL. +// Use a dedicated random >=32-character key for each environment/instance. +$_ENV['atom_announcements_enabled'] = false; +$_ENV['atom_announcements_key'] = ''; +$_ENV['atom_announcements_project'] = 'fuc'; +$_ENV['atom_announcements_environment'] = 'local'; +$_ENV['atom_announcements_instance'] = 'default'; diff --git a/db/2026.10.26_atom_announcements.sql b/db/2026.10.26_atom_announcements.sql new file mode 100644 index 000000000..151ca1597 --- /dev/null +++ b/db/2026.10.26_atom_announcements.sql @@ -0,0 +1,13 @@ +-- Additive migration only. Existing announcements are not modified or imported. +CREATE TABLE IF NOT EXISTS `atom_announcement_nonces` ( + `nonce_hash` char(64) NOT NULL, + `expires_at` bigint NOT NULL, + PRIMARY KEY (`nonce_hash`), KEY `expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `atom_announcement_operations` ( + `operation_id` char(36) NOT NULL, + `fingerprint` char(64) NOT NULL, + `result` longtext NOT NULL, + `created_at` bigint NOT NULL, + PRIMARY KEY (`operation_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/docs/maintenance/ATOM_ANNOUNCEMENTS.md b/docs/maintenance/ATOM_ANNOUNCEMENTS.md new file mode 100644 index 000000000..2c4d71332 --- /dev/null +++ b/docs/maintenance/ATOM_ANNOUNCEMENTS.md @@ -0,0 +1,21 @@ +# ATOM 公告接收适配器 + +2026-10-26本地实现与隔离联调完成,未部署/启用生产。ATOM界面、通用协议、双端操作与回滚唯一正文见[Ship公告专题](/Users/hellcat/Documents/GemCodes/atom/codex/output/docs/ship-announcements.md)。此处只维护FUC接收端配置与业务影响。 + +`app/routes.php` 注册 POST `/api/atom/v1/announcements` → `Controllers/Api/Atom/AnnouncementController` → `Services/Atom/AnnouncementBridge`。使用独立HMAC、nonce、精确project/environment/instance,不沿用旧Nasa API或管理员会话;默认关闭。公告保留在原`announcement`表,支持list/get/create/update,status=0撤下,不物理删除;普通编辑保留date和post,显式refresh_date才刷新日期。标题去标签、正文AntiXSS清理;不调用支付、邮件、Telegram或Cron。原日报仍依已有计划可包含可见公告。 + +开启前在目标库备份后执行追加SQL `db/2026.10.26_atom_announcements.sql`(InnoDB nonce和操作回执表,IF NOT EXISTS,不删除/重写业务数据)。在私有`.config.php`中配置下列字段,样例也见`config/.config.example.php`: + +- atom_announcements_enabled:布尔true才开启;回滚设false。 +- atom_announcements_key:每个环境/实例独立至少32字节随机秘密,安全交给ATOM目标encrypted字段,不放文档或日志。 +- atom_announcements_project:对应ATOM project slug,默认fuc。 +- atom_announcements_environment:local或production,样例local,生产需显式改。 +- atom_announcements_instance:对应登记实例,默认default。 + +`AnnouncementBridge`直接使用已初始化的default数据库连接。生产必须核实announcement表为事务引擎InnoDB;非事务表不得启用此适配器,否则无法保证公告/回执原子性。以现有路由加载现场核实,不为验证执行真实Cron或支付。生产首次服务器侧只读检查capabilities/list/get、原公告数量和内容;写公告需独立内容确认。生产发布仍须Git明确提交、私有配置恢复点及数据库备份,不以本地测试代替线上验收。 + +幂等:操作UUID+正文SHA256事务预留并保存完整回执;相同UUID相同内容重试返回原回执,内容变化拒绝。更新先锁行并校验内容version;其他入口修改后拒绝覆盖。nonce防重放与操作去重用途不同。回执表不能按nonce过期策略清理,否则失去重试去重保障。 + +本次另修复Ann::visible及UserController首页/列表/未读、Services/Cron日报仅取status=1;隐藏后不在用户入口出现,全部隐藏时未读不报错。保留原后台/Telegram入口,不迁移旧Nasa权限。 + +验证:`php vendor/bin/phpunit tests/App/Services/Atom/AnnouncementBridgeTest.php --do-not-cache-result`,6 tests / 47 assertions,内存SQLite,无真实配置/通知。双ship真实HTTP隔离11项通过;ATOM本地桌面页面新增隐藏公告确认回执通过;尚未做移动视口或生产验收。MariaDB真实FUC表引擎、生产HTTPS链路尚待获批发布时核实,不能称已接通线上。 diff --git a/src/Controllers/Api/Atom/AnnouncementController.php b/src/Controllers/Api/Atom/AnnouncementController.php new file mode 100644 index 000000000..fcff3ca75 --- /dev/null +++ b/src/Controllers/Api/Atom/AnnouncementController.php @@ -0,0 +1,30 @@ +handle($request->getMethod(), $request->getUri()->getPath(), $request->getUri()->getQuery(), (string) $request->getBody(), [ + 'time' => $request->getHeaderLine('X-Ship-Time'), 'nonce' => $request->getHeaderLine('X-Ship-Nonce'), 'signature' => $request->getHeaderLine('X-Ship-Signature'), + ]); + foreach ($result['headers'] as $name => $value) { + $response = $response->withHeader($name, $value); + } + return $response->withStatus($result['status'])->write($result['body']); + } +} diff --git a/src/Controllers/UserController.php b/src/Controllers/UserController.php index bd9f76de1..085c11338 100755 --- a/src/Controllers/UserController.php +++ b/src/Controllers/UserController.php @@ -44,8 +44,8 @@ final class UserController extends BaseController $captcha = Captcha::generate(); } - $oAnn = Ann::where("sort", ">=", 1)->orderBy('date', 'desc')->first(); - $oAnnLast = Ann::where("sort", 0)->orderBy('date', 'desc')->first(); + $oAnn = Ann::visible()->where("sort", ">=", 1)->orderByDesc('sort')->orderBy('date', 'desc')->first(); + $oAnnLast = Ann::visible()->where("sort", 0)->orderBy('date', 'desc')->first(); // $oHuodong = Huodong::DateHit(); @@ -216,7 +216,7 @@ final class UserController extends BaseController $this->user->ann_last_time = time(); $this->user->save(); - $anns = (new Ann())->where("status", 1)->orderBy('date', 'desc')->get(); + $anns = (new Ann())->visible()->orderByDesc('sort')->orderBy('date', 'desc')->get(); // cmd端需要做验证,不然传错就bug了 $aMapIcon = [ @@ -239,8 +239,8 @@ final class UserController extends BaseController $bNotifiHas = false; $aNotifi = []; - $sAnnDate = (new Ann())->orderBy('date', 'desc')->first()?->date; - $sAnnTime = strtotime($sAnnDate); + $sAnnDate = (new Ann())->visible()->orderBy('date', 'desc')->first()?->date; + $sAnnTime = $sAnnDate !== null ? strtotime($sAnnDate) : 0; if ($sAnnTime > $this->user->ann_last_time) { $aNotifi[] = [ diff --git a/src/Models/Ann.php b/src/Models/Ann.php index 3ac01973d..8e2e7ff05 100755 --- a/src/Models/Ann.php +++ b/src/Models/Ann.php @@ -17,6 +17,12 @@ final class Ann extends Model { protected $connection = 'default'; protected $table = 'announcement'; + + public function scopeVisible($query) + { + return $query->where('status', 1); + } + public function setRankAttribute($value) { diff --git a/src/Services/Atom/AnnouncementBridge.php b/src/Services/Atom/AnnouncementBridge.php new file mode 100644 index 000000000..f22e2b2a2 --- /dev/null +++ b/src/Services/Atom/AnnouncementBridge.php @@ -0,0 +1,219 @@ +settings = $settings; + } + + public static function canonical(array $data): string + { + return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + public static function signature(string $key, string $time, string $nonce, string $method, string $path, string $body): string + { + return hash_hmac('sha256', $time."\n".$nonce."\n".$method."\n".$path."\n".hash('sha256', $body), $key); + } + + /** @return array{status:int,body:string,headers:array} */ + public function handle(string $method, string $path, string $query, string $body, array $headers): array + { + $authenticated = false; + $time = $headers['time'] ?? ''; + $nonce = $headers['nonce'] ?? ''; + $key = (string) ($this->settings['key'] ?? ''); + try { + if (($this->settings['enabled'] ?? false) !== true || strlen($key) < 32) { + throw new AnnouncementException(503, 'adapter_disabled'); + } + if ($method !== 'POST' || $path !== self::PATH || $query !== '' || strlen($body) > 131072) { + throw new AnnouncementException(400, 'invalid_request'); + } + if (! preg_match('/^\d{10}$/D', $time) || abs(time() - (int) $time) > 300 + || ! preg_match('/^[a-f0-9]{32}$/D', $nonce) + || ! hash_equals(self::signature($key, $time, $nonce, $method, $path, $body), $headers['signature'] ?? '')) { + throw new AnnouncementException(401, 'invalid_signature'); + } + $authenticated = true; + $data = json_decode($body, true, 32, JSON_THROW_ON_ERROR); + if (! is_array($data) || ($data['schema'] ?? '') !== self::SCHEMA) { + throw new AnnouncementException(422, 'invalid_schema'); + } + foreach (['project', 'environment', 'instance'] as $field) { + if (! is_string($this->settings[$field] ?? null) || $this->settings[$field] === '' || ($data[$field] ?? null) !== $this->settings[$field]) { + throw new AnnouncementException(403, 'target_mismatch'); + } + } + if (! in_array($data['environment'], ['local', 'production'], true)) { + throw new AnnouncementException(403, 'environment_mismatch'); + } + // Unique nonce is claimed before doing any operation. No session or legacy Nasa key is accepted. + $this->db->table('atom_announcement_nonces')->where('expires_at', '<', time())->delete(); + try { + $this->db->table('atom_announcement_nonces')->insert(['nonce_hash' => hash('sha256', $nonce), 'expires_at' => time() + 600]); + } catch (QueryException $e) { + if ($this->db->table('atom_announcement_nonces')->where('nonce_hash', hash('sha256', $nonce))->exists()) { + throw new AnnouncementException(409, 'nonce_replayed'); + } + throw $e; + } + $action = $data['action'] ?? ''; + $result = match ($action) { + 'capabilities' => ['actions' => ['list', 'get', 'create', 'update'], 'notifications' => false, 'withdrawal' => 'status=0'], + 'list' => $this->listing($data), + 'get' => ['announcement' => $this->record($this->find($this->id($data)))], + 'create', 'update' => $this->write($data, $body), + default => throw new AnnouncementException(422, 'unsupported_action'), + }; + return $this->response(200, ['ok' => true, 'data' => $result], $key, $time, $nonce); + } catch (AnnouncementException $e) { + return $this->response($e->status, ['ok' => false, 'error' => $e->reason], $authenticated ? $key : '', $time, $nonce); + } catch (\JsonException) { + return $this->response(422, ['ok' => false, 'error' => 'invalid_json'], $authenticated ? $key : '', $time, $nonce); + } catch (\Throwable) { + // SQL bindings and original exception text must never leave this adapter. + return $this->response(503, ['ok' => false, 'error' => 'adapter_unavailable'], $authenticated ? $key : '', $time, $nonce); + } + } + + private function response(int $status, array $data, string $key, string $time, string $nonce): array + { + $body = self::canonical(['schema' => self::SCHEMA, 'project' => $this->settings['project'] ?? '', 'environment' => $this->settings['environment'] ?? '', 'instance' => $this->settings['instance'] ?? '', ...$data]); + $headers = ['Cache-Control' => 'no-store', 'Content-Type' => 'application/json; charset=utf-8']; + if ($key !== '') { + $headers['X-Ship-Signature'] = self::signature($key, $time, $nonce, 'RESPONSE', (string) $status, $body); + } + return compact('status', 'body', 'headers'); + } + + private function id(array $data): int + { + if (! is_int($data['id'] ?? null) || $data['id'] < 1) { + throw new AnnouncementException(422, 'invalid_id'); + } + return $data['id']; + } + + private function find(int $id, bool $lock = false): object + { + $q = $this->db->table('announcement')->where('id', $id); + $row = ($lock ? $q->lockForUpdate() : $q)->first(); + if (! $row) { + throw new AnnouncementException(404, 'announcement_not_found'); + } + return $row; + } + + private function record(object $row): array + { + $data = ['id' => (int) $row->id, 'title' => (string) $row->title, 'content' => (string) $row->content, + 'rank' => (string) $row->rank, 'sort' => (int) $row->sort, 'status' => (int) $row->status, + 'date' => (string) $row->date, 'post' => (int) $row->post]; + return [...$data, 'version' => hash('sha256', self::canonical($data))]; + } + + private function listing(array $data): array + { + $page = $data['page'] ?? 1; + if (! is_int($page) || $page < 1 || $page > 100000) { + throw new AnnouncementException(422, 'invalid_page'); + } + $q = $this->db->table('announcement'); + $total = $q->count(); + $rows = $q->orderByDesc('sort')->orderByDesc('date')->orderByDesc('id')->offset(($page - 1) * 20)->limit(20)->get(); + $items = []; + foreach ($rows as $row) { + $item = $this->record($row); + $item['summary'] = mb_substr(strip_tags($item['content']), 0, 200); + unset($item['content']); + $items[] = $item; + } + return ['items' => $items, 'page' => $page, 'per_page' => 20, 'total' => $total]; + } + + private function fields(array $data): array + { + $fields = $data['fields'] ?? null; + if (! is_array($fields) || array_diff(array_keys($fields), ['title', 'content', 'rank', 'sort', 'status'])) { + throw new AnnouncementException(422, 'invalid_fields'); + } + if (! is_string($fields['title'] ?? null) || trim($fields['title']) === '' || mb_strlen($fields['title']) > 200 + || ! is_string($fields['content'] ?? null) || trim($fields['content']) === '' || strlen($fields['content']) > 65536 + || ! in_array($fields['rank'] ?? null, ['none', 'success', 'warning', 'danger'], true) + || ! is_int($fields['sort'] ?? null) || $fields['sort'] < 0 || $fields['sort'] > 9999 + || ! in_array($fields['status'] ?? null, [0, 1], true)) { + throw new AnnouncementException(422, 'invalid_fields'); + } + $fields['content'] = (new AntiXSS())->xss_clean($fields['content']); + $fields['title'] = strip_tags($fields['title']); + if (trim($fields['title']) === '' || trim(strip_tags($fields['content'])) === '') { + throw new AnnouncementException(422, 'empty_announcement'); + } + return $fields; + } + + private function write(array $data, string $body): array + { + $operation = $data['operation'] ?? ''; + if (! is_string($operation) || ! preg_match('/^[a-f0-9-]{36}$/D', $operation)) { + throw new AnnouncementException(422, 'invalid_operation'); + } + $fingerprint = hash('sha256', $body); + try { + return $this->db->transaction(function () use ($data, $operation, $fingerprint) { + $old = $this->db->table('atom_announcement_operations')->where('operation_id', $operation)->lockForUpdate()->first(); + if ($old) { + return $this->replay($old, $fingerprint); + } + // Reserve before modifying announcement; unique PK serializes simultaneous retries. + $this->db->table('atom_announcement_operations')->insert(['operation_id' => $operation, 'fingerprint' => $fingerprint, 'result' => '{}', 'created_at' => time()]); + $fields = $this->fields($data); + if ($data['action'] === 'create') { + $id = $this->db->table('announcement')->insertGetId([...$fields, 'date' => date('Y-m-d H:i:s'), 'post' => 0]); + } else { + $id = $this->id($data); + $old = $this->record($this->find($id, true)); + if (! is_string($data['expected_version'] ?? null) || ! hash_equals($old['version'], $data['expected_version'])) { + throw new AnnouncementException(409, 'version_conflict'); + } + if (($data['refresh_date'] ?? false) === true) { + $fields['date'] = date('Y-m-d H:i:s'); + } + $this->db->table('announcement')->where('id', $id)->update($fields); + } + $result = ['operation' => $operation, 'announcement' => $this->record($this->find($id))]; + $this->db->table('atom_announcement_operations')->where('operation_id', $operation)->update(['result' => self::canonical($result)]); + return $result; + }); + } catch (QueryException $e) { + $old = $this->db->table('atom_announcement_operations')->where('operation_id', $operation)->first(); + if ($old) { + return $this->replay($old, $fingerprint); + } + throw $e; + } + } + + private function replay(object $old, string $fingerprint): array + { + if (! hash_equals($old->fingerprint, $fingerprint)) { + throw new AnnouncementException(409, 'operation_payload_changed'); + } + return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Services/Atom/AnnouncementException.php b/src/Services/Atom/AnnouncementException.php new file mode 100644 index 000000000..f258aa88a --- /dev/null +++ b/src/Services/Atom/AnnouncementException.php @@ -0,0 +1,13 @@ +whereIn('daily_mail_enable', [1, 2])->get(); - $ann_latest_raw = (new Ann())->orderBy('date', 'desc')->first(); + $ann_latest_raw = (new Ann())->visible()->orderBy('date', 'desc')->first(); if ($ann_latest_raw === null) { $ann_latest = '

'; diff --git a/tests/App/Services/Atom/AnnouncementBridgeTest.php b/tests/App/Services/Atom/AnnouncementBridgeTest.php new file mode 100644 index 000000000..9bbf73df2 --- /dev/null +++ b/tests/App/Services/Atom/AnnouncementBridgeTest.php @@ -0,0 +1,129 @@ +db = new Manager(); + $this->db->addConnection(['driver' => 'sqlite', 'database' => ':memory:']); + $this->db->setAsGlobal(); + $this->db->bootEloquent(); + foreach ([ + 'announcement' => 'id integer primary key autoincrement, title text, content text, rank text, sort integer, status integer, date text, post integer', + 'atom_announcement_nonces' => 'nonce_hash text primary key, expires_at integer', + 'atom_announcement_operations' => 'operation_id text primary key, fingerprint text, result text, created_at integer', + ] as $name => $columns) { + $this->db->getConnection()->statement('CREATE TABLE '.$name.' ('.$columns.')'); + } + $this->config = ['enabled' => true, 'key' => str_repeat('fixture-key-', 4), 'project' => 'fuc', 'environment' => 'local', 'instance' => 'default']; + $this->bridge = new AnnouncementBridge($this->db->getConnection(), $this->config); + } + + private function envelope(array $data): array + { + return ['schema' => AnnouncementBridge::SCHEMA, 'project' => 'fuc', 'environment' => 'local', 'instance' => 'default', ...$data]; + } + + private function request(array $data, ?array $headers = null): array + { + $body = AnnouncementBridge::canonical($this->envelope($data)); + $time = (string) time(); $nonce = bin2hex(random_bytes(16)); + $headers ??= ['time' => $time, 'nonce' => $nonce, 'signature' => AnnouncementBridge::signature($this->config['key'], $time, $nonce, 'POST', AnnouncementBridge::PATH, $body)]; + $result = $this->bridge->handle('POST', AnnouncementBridge::PATH, '', $body, $headers); + if (isset($result['headers']['X-Ship-Signature'])) { + self::assertSame(AnnouncementBridge::signature($this->config['key'], $headers['time'], $headers['nonce'], 'RESPONSE', (string) $result['status'], $result['body']), $result['headers']['X-Ship-Signature']); + } + return ['status' => $result['status'], ...json_decode($result['body'], true)]; + } + + private function create(array $overrides = []): array + { + return ['action' => 'create', 'operation' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'fields' => ['title' => '公告', 'content' => '

测试公告

', 'rank' => 'none', 'sort' => 0, 'status' => 1], ...$overrides]; + } + + public function test_disabled_and_unsigned_requests_fail_closed_without_database_changes(): void + { + $off = new AnnouncementBridge($this->db->getConnection(), ['enabled' => false]); + self::assertSame(503, $off->handle('POST', AnnouncementBridge::PATH, '', '{}', [])['status']); + self::assertSame(401, $this->request(['action' => 'list'], [])['status']); + self::assertSame(0, $this->db->table('announcement')->count()); + self::assertSame(0, $this->db->table('atom_announcement_nonces')->count()); + } + + public function test_signature_binds_body_and_nonce_and_time(): void + { + $body = AnnouncementBridge::canonical($this->envelope(['action' => 'list'])); + $time = (string) time(); $nonce = bin2hex(random_bytes(16)); + $h = ['time' => $time, 'nonce' => $nonce, 'signature' => AnnouncementBridge::signature($this->config['key'], $time, $nonce, 'POST', AnnouncementBridge::PATH, $body)]; + self::assertSame(200, $this->request(['action' => 'list'], $h)['status']); + self::assertSame('nonce_replayed', $this->request(['action' => 'list'], $h)['error']); + self::assertSame(401, $this->request($this->create(), $h)['status']); + $h['time'] = (string) (time() - 600); + $h['signature'] = AnnouncementBridge::signature($this->config['key'], $h['time'], $nonce, 'POST', AnnouncementBridge::PATH, $body); + self::assertSame(401, $this->request(['action' => 'list'], $h)['status']); + } + + public function test_environment_project_and_instance_are_separate_even_with_same_key(): void + { + foreach (['project' => 'other-ship', 'environment' => 'production', 'instance' => 'another'] as $key => $value) { + self::assertSame(403, $this->request(['action' => 'list', $key => $value])['status']); + } + self::assertSame(0, $this->db->table('atom_announcement_nonces')->count()); + } + + public function test_retry_is_exactly_once_and_changed_payload_cannot_reuse_operation(): void + { + $first = $this->request($this->create()); + self::assertSame(200, $first['status']); + self::assertSame($first['data'], $this->request($this->create())['data']); + self::assertSame(1, $this->db->table('announcement')->count()); + $changed = $this->create(); $changed['fields']['title'] = '不同内容'; + self::assertSame('operation_payload_changed', $this->request($changed)['error']); + self::assertSame(1, $this->db->table('announcement')->count()); + } + + public function test_native_edit_conflicts_and_withdrawn_announcements_are_not_visible(): void + { + $a = $this->request($this->create())['data']['announcement']; + $this->db->table('announcement')->where('id', $a['id'])->update(['title' => '由原后台修改']); + $update = ['action' => 'update', 'id' => $a['id'], 'operation' => 'bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'expected_version' => $a['version'], 'fields' => $this->create()['fields']]; + self::assertSame('version_conflict', $this->request($update)['error']); + self::assertSame('由原后台修改', $this->db->table('announcement')->value('title')); + $fresh = $this->request(['action' => 'get', 'id' => $a['id']])['data']['announcement']; + $update['expected_version'] = $fresh['version']; $update['fields']['status'] = 0; + $result = $this->request($update); + self::assertSame(200, $result['status']); + self::assertSame($a['date'], $result['data']['announcement']['date']); + self::assertSame(0, Ann::visible()->count()); + self::assertSame(1, Ann::count()); + self::assertSame(0, $this->db->table('announcement')->value('post')); + } + + public function test_sanitization_and_validation_do_not_modify_unrelated_rows(): void + { + $data = $this->create(); $data['fields']['content'] = '

正文

'; + $a = $this->request($data); + self::assertSame(200, $a['status']); + self::assertStringNotContainsString('request($data)['status']); + self::assertSame(1, $this->db->table('announcement')->count()); + $list = $this->request(['action' => 'list']); + self::assertArrayNotHasKey('content', $list['data']['items'][0]); + self::assertSame(1, $list['data']['total']); + self::assertSame(422, $this->request(['action' => 'delete', 'id' => 1])['status']); + } +} diff --git a/tests/Fixtures/atom-announcements-router.php b/tests/Fixtures/atom-announcements-router.php new file mode 100644 index 000000000..090e8592c --- /dev/null +++ b/tests/Fixtures/atom-announcements-router.php @@ -0,0 +1,25 @@ +addConnection(['driver' => 'sqlite', 'database' => getenv('SHIP_FIXTURE_DB')]); +$db->setAsGlobal(); +$c = $db->getConnection(); +$c->statement('CREATE TABLE IF NOT EXISTS announcement (id integer primary key autoincrement, title text, content text, rank text, sort integer, status integer, date text, post integer)'); +$c->statement('CREATE TABLE IF NOT EXISTS atom_announcement_nonces (nonce_hash text primary key, expires_at integer)'); +$c->statement('CREATE TABLE IF NOT EXISTS atom_announcement_operations (operation_id text primary key, fingerprint text, result text, created_at integer)'); +if ($c->table('announcement')->count() === 0) { $c->table('announcement')->insert(['title' => '原有公告(本地测试数据)', 'content' => '

原有内容

', 'rank' => 'none', 'sort' => 0, 'status' => 1, 'date' => '2026-10-01 00:00:00', 'post' => 0]); } +$bridge = new App\Services\Atom\AnnouncementBridge($c, ['enabled' => true, 'key' => getenv('SHIP_FIXTURE_KEY'), 'project' => getenv('SHIP_FIXTURE_PROJECT'), 'environment' => 'local', 'instance' => getenv('SHIP_FIXTURE_INSTANCE')]); +$body = file_get_contents('php://input'); +$result = $bridge->handle($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), $_SERVER['QUERY_STRING'] ?? '', $body, ['time' => $_SERVER['HTTP_X_SHIP_TIME'] ?? '', 'nonce' => $_SERVER['HTTP_X_SHIP_NONCE'] ?? '', 'signature' => $_SERVER['HTTP_X_SHIP_SIGNATURE'] ?? '']); +// Deliberately lose one post-commit acknowledgement to exercise durable idempotent recovery. +$drop = getenv('SHIP_FIXTURE_DROP_FIRST'); +if ($result['status'] === 200 && (json_decode($body, true)['action'] ?? '') === 'create' && $drop && is_file($drop)) { + unlink($drop); http_response_code(503); echo 'LOCAL_FIXTURE_LOST_ACK'; exit; +} +http_response_code($result['status']); +foreach ($result['headers'] as $k => $v) { header($k.': '.$v); } +echo $result['body'];