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('