Connect FUC announcements through isolated signed ATOM protocol

This commit is contained in:
hellcat 2026-10-26 12:51:29 +08:00
parent fce8b3c9f6
commit 5ab97694d4
12 changed files with 472 additions and 6 deletions

View File

@ -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');

View File

@ -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';

View File

@ -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;

View File

@ -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链路尚待获批发布时核实,不能称已接通线上。

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Api\Atom;
use App\Models\Ann;
use App\Services\Atom\AnnouncementBridge;
use Psr\Http\Message\ResponseInterface;
use Slim\Http\Response;
use Slim\Http\ServerRequest;
final class AnnouncementController
{
public function handle(ServerRequest $request, Response $response, array $args = []): ResponseInterface
{
$settings = [];
foreach (['enabled', 'key', 'project', 'environment', 'instance'] as $field) {
$settings[$field] = $_ENV['atom_announcements_'.$field] ?? null;
}
$bridge = new AnnouncementBridge(Ann::resolveConnection('default'), $settings);
$result = $bridge->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']);
}
}

View File

@ -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[] = [

View File

@ -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)
{

View File

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Services\Atom;
use Illuminate\Database\Connection;
use Illuminate\Database\QueryException;
use voku\helper\AntiXSS;
/** A narrow, independently authenticated adapter. It never calls notification services. */
final class AnnouncementBridge
{
public const SCHEMA = 'ship-announcements.v1';
public const PATH = '/api/atom/v1/announcements';
private array $settings;
public function __construct(private Connection $db, array $settings)
{
$this->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);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\Atom;
final class AnnouncementException extends \RuntimeException
{
public function __construct(public readonly int $status, public readonly string $reason)
{
parent::__construct($reason);
}
}

View File

@ -2566,7 +2566,7 @@ final class Cron
public static function sendDailyTrafficReport(): void
{
$users = (new User())->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 = '<br><br>';

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Tests\App\Services\Atom;
use App\Models\Ann;
use App\Services\Atom\AnnouncementBridge;
use Illuminate\Database\Capsule\Manager;
use PHPUnit\Framework\TestCase;
final class AnnouncementBridgeTest extends TestCase
{
private Manager $db;
private array $config;
private AnnouncementBridge $bridge;
protected function setUp(): void
{
$this->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' => '<p>测试公告</p>', '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'] = '<p>正文<script>alert(1)</script></p>';
$a = $this->request($data);
self::assertSame(200, $a['status']);
self::assertStringNotContainsString('<script', $a['data']['announcement']['content']);
$data['operation'] = 'cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee'; $data['fields']['status'] = 2;
self::assertSame(422, $this->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']);
}
}

View File

@ -0,0 +1,25 @@
<?php
// Local test server only. Never includes the site's configuration or Web/Cron bootstrap.
declare(strict_types=1);
require dirname(__DIR__, 2).'/vendor/autoload.php';
if (getenv('SHIP_ANNOUNCEMENTS_FIXTURE') !== '1') { http_response_code(503); exit; }
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) === '/__ready') { echo 'fixture-ready'; exit; }
$db = new Illuminate\Database\Capsule\Manager();
$db->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' => '<p>原有内容</p>', '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'];