提示信息
# 同伴 App — 后端开发规范
> PHP 8.4 · Slim 4 · PostgreSQL 18 · Eloquent ORM · PHP-DI · zircote/swagger-php v4
> 版本:V4.3
---
## 一、项目结构
```
companion-api/
├── app/
│ ├── Services/ # 直接处理 Request,返回 Response,无 Controller 层
│ │ ├── AppService.php
│ │ ├── AuthService.php
│ │ ├── UserService.php
│ │ ├── SignalService.php
│ │ ├── ChatService.php
│ │ ├── PayService.php
│ │ ├── OssService.php
│ │ ├── NotifyService.php
│ │ ├── VipService.php
│ │ └── DocService.php
│ ├── Models/ # Eloquent 模型 + 跨 Service 复用的查询方法
│ │ ├── User/
│ │ │ ├── User.php # getUserMap() / isVip()
│ │ │ ├── UserDevice.php
│ │ │ ├── UserFollow.php # getMutualStatus()
│ │ │ ├── UserBlock.php
│ │ │ └── UserVisitor.php
│ │ ├── Signal/
│ │ │ ├── Signal.php
│ │ │ ├── SignalReceive.php
│ │ │ └── SignalReply.php
│ │ ├── Pay/
│ │ │ ├── Order.php
│ │ │ └── Product.php
│ │ └── Notify/
│ │ └── Notification.php
│ ├── Enums/
│ │ ├── SignalCategory.php
│ │ ├── SignalStatus.php
│ │ ├── SignalType.php
│ │ ├── ForbiddenType.php
│ │ ├── OrderStatus.php
│ │ ├── PayType.php
│ │ └── UserStatus.php
│ ├── Middleware/
│ │ ├── SignMiddleware.php
│ │ ├── AuthMiddleware.php
│ │ ├── RateLimitMiddleware.php
│ │ ├── BanCheckMiddleware.php
│ │ └── OpenImCallbackMiddleware.php
│ ├── Resources/ # readonly class,格式化响应字段
│ │ ├── UserResource.php
│ │ ├── SignalResource.php
│ │ └── OrderResource.php
│ ├── Exceptions/
│ │ ├── AppException.php
│ │ └── Handler.php
│ └── Support/
│ ├── BaseService.php # success() / fail() / uid() / params() / validate()
│ ├── RedisClient.php # Redis 封装,统一 key 前缀和常用操作
│ └── helpers.php # config() / logger() 全局函数
├── config/
│ ├── container.php # PHP-DI 容器
│ ├── app.php # 应用基础配置(环境、调试、签名密钥等)
│ ├── database.php # 数据库连接配置
│ ├── redis.php # Redis 连接配置
│ ├── oss.php # 阿里云 OSS 配置
│ ├── openim.php # OpenIM 配置
│ ├── jwt.php # JWT 配置
│ ├── log.php # 日志配置
│ └── scramble.php # API 文档配置
├── database/
│ └── schema/
│ ├── 00_types.sql # 自定义枚举 TYPE,最先执行
│ ├── 01_users.sql
│ ├── 02_user_devices.sql
│ ├── 03_user_follows.sql
│ ├── 04_user_blocks.sql
│ ├── 05_user_visitors.sql
│ ├── 06_user_login_logs.sql
│ ├── 07_user_risk_logs.sql
│ ├── 08_user_forbidden.sql
│ ├── 09_signals.sql
│ ├── 10_signal_receives.sql
│ ├── 11_signal_replies.sql
│ ├── 12_notifications.sql
│ ├── 13_orders.sql
│ └── 14_products.sql
├── docs/ # 项目文档、AI Skill、开发约束
│ ├── 后端开发规范.md # 本文档
│ ├── API接口设计.md
│ └── skill/ # AI 协作上下文模板
│ ├── context.md # 每次新对话粘贴的上下文
│ └── templates.md # 标准提问模板
├── logs/ # 运行日志(gitignore)
│ ├── app-{date}.log # 业务日志
│ └── error-{date}.log # 错误日志
├── routes/
│ └── api.php
├── public/
│ └── index.php
├── .env # 本地环境变量(gitignore)
├── .env.example # 完整字段模板(必须与 .env 字段保持同步)
└── composer.json
```
---
## 二、依赖
```json
{
"require": {
"php": "^8.3",
"slim/slim": "^4.0",
"slim/psr7": "^1.6",
"php-di/php-di": "^7.0",
"illuminate/database": "^11.0",
"illuminate/pagination": "^11.0",
"illuminate/events": "^11.0",
"firebase/php-jwt": "^6.0",
"predis/predis": "^2.2",
"webmozart/assert": "^1.11",
"aliyuncs/oss-sdk-php": "^2.7",
"vlucas/phpdotenv": "^5.6",
"monolog/monolog": "^3.5",
"dedoc/scramble": "^0.11"
},
"require-dev": {
"phpstan/phpstan": "^1.10"
},
"config": {
"platform": { "php": "8.3" }
},
"autoload": {
"psr-4": { "App\\": "app/" },
"files": ["app/Support/helpers.php"]
}
}
```
---
## 三、API 注释与文档规范
本项目使用 `zircote/swagger-php` (OpenAPI) 生成文档,并结合 `DocService.php` 进行自动化处理。
### 3.1 API 认证与签名规范 (强制)
所有业务接口均需满足统一的认证与签名要求,详细规则见:
[**API 认证与签名规范.md**](./API认证与签名规范.md)
- **签名算法 (X-Sign)**:所有请求必须包含签名校验,具体算法见 [**API 认证与签名规范.md**](./API认证与签名规范.md)。仅当请求不是 `multipart/form-data` 时,才会将 Body 参入 `sortedParams`。
- **签名校验逻辑**:由 `SignMiddleware.php` 统一处理。
- **简化原则 (强制)**:业务 Service 的方法注释中,**严禁**手动添加上述公共 Header 参数。保持代码极致整洁,专属参数才需手动编写。
### 3.2 PublicApi 与 鉴权 (bearerAuth)
1. **白名单接口**:如果接口是完全公开的(无需签名、无需公共 Header),请在方法上标记 `#[\App\Support\Annotations\PublicApi]`。Processor 会识别此属性并**跳过**自动注入。
```php
#[OA\Get(path: "/app/config", summary: "配置接口")]
#[\App\Support\Annotations\PublicApi]
public function config(...) { ... }
```
2. **需要登录 (JWT Token) 的业务接口**:需在 `#[OA\Get]` 或 `#[OA\Post]` 中**显式声明** `security: [["bearerAuth" => []]]`。Processor 会自动将 `ApiSignature` 合并进去,达成 (Token AND 签名) 都需要的安全判定。
```php
#[OA\Get(path: "/user/profile", summary: "个人信息", security: [["bearerAuth" => []]])]
public function profile(...) { ... }
```
### 3.3 AI Assistant (AI 版文档)
文档 UI 包含“AI 极简版本”面板,为开发者提供剔除公共参数后的业务逻辑视图,支持一键复制供 AI 工具进行接口实现或文档编写。
---
## 四、配置管理
### 4.1 分模块配置文件
配置按模块拆分到 `config/` 下独立文件,每个文件返回数组,启动时统一加载进全局,业务代码通过 `config('模块.字段')` 取值,**不通过构造函数注入配置**。
```php
// config/app.php
return [
'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => $_ENV['APP_DEBUG'] === 'true',
'name' => '同伴',
'version' => '1.0.0',
'sign_time_window' => 300, // 签名时间窗口 ±5分钟(秒)
'sign_nonce_ttl' => 600, // nonce Redis 过期时间(秒)
'keys' => require __DIR__ . '/app_keys.php', // app_key => app_secret
'ad_pull_unlock' => 5, // 看广告解锁收取次数
'signal_max' => 5, // 每人最多发布信号数
'signal_daily_pull' => 20, // 每日免费收取次数
];
// config/database.php
return [
'driver' => 'pgsql',
'host' => $_ENV['DB_HOST'],
'port' => $_ENV['DB_PORT'] ?? '5432',
'database' => $_ENV['DB_DATABASE'],
'username' => $_ENV['DB_USERNAME'],
'password' => $_ENV['DB_PASSWORD'],
'charset' => 'utf8',
'schema' => 'public',
'sslmode' => $_ENV['DB_SSLMODE'] ?? 'prefer',
];
// config/redis.php
return [
'host' => $_ENV['REDIS_HOST'],
'port' => (int)($_ENV['REDIS_PORT'] ?? 6379),
'password' => $_ENV['REDIS_PASSWORD'] ?: null,
'database' => (int)($_ENV['REDIS_DB'] ?? 0),
'prefix' => $_ENV['REDIS_PREFIX'] ?? 'companion:',
];
// config/oss.php
return [
'access_key_id' => $_ENV['OSS_ACCESS_KEY_ID'],
'access_key_secret' => $_ENV['OSS_ACCESS_KEY_SECRET'],
'endpoint' => $_ENV['OSS_ENDPOINT'],
'bucket_public' => $_ENV['OSS_BUCKET_PUBLIC'],
'bucket_private' => $_ENV['OSS_BUCKET_PRIVATE'],
'sts_role_arn' => $_ENV['OSS_STS_ROLE_ARN'],
'token_expire' => 3600,
];
// config/jwt.php
return [
'secret' => $_ENV['JWT_SECRET'],
'expire' => (int)($_ENV['JWT_EXPIRE'] ?? 7200),
'refresh_expire' => (int)($_ENV['JWT_REFRESH_EXPIRE'] ?? 2592000),
'algo' => 'HS256',
];
// config/openim.php
return [
'api_url' => $_ENV['OPENIM_API_URL'],
'secret' => $_ENV['OPENIM_SECRET'],
'admin_uid' => $_ENV['OPENIM_ADMIN_UID'],
];
// config/log.php
return [
'level' => $_ENV['LOG_LEVEL'] ?? 'info', // debug | info | warning | error
'path' => __DIR__ . '/../logs',
'app_log' => 'app-{date}.log',
'err_log' => 'error-{date}.log',
];
```
---
### 4.2 全局辅助函数 + 启动流程
```php
// app/Support/helpers.php — 自动加载(composer autoload.files)
if (!function_exists('config')) {
function config(string $key, mixed $default = null): mixed
{
// 支持点号访问:config('app.debug')
$parts = explode('.', $key, 2);
$cfg = $GLOBALS['_config'][$parts[0]] ?? null;
if (count($parts) === 1) return $cfg ?? $default;
return $cfg[$parts[1]] ?? $default;
}
function logger(string $channel = 'app'): \Monolog\Logger
{
return $GLOBALS['_loggers'][$channel]
?? throw new \RuntimeException("Logger '{$channel}' not initialized");
}
}
```
```php
// public/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
// 1. 加载环境变量
Dotenv\Dotenv::createImmutable(__DIR__ . '/../')->load();
// 2. 加载全局配置
$GLOBALS['_config'] = [
'app' => require __DIR__ . '/../config/app.php',
'db' => require __DIR__ . '/../config/database.php',
'redis' => require __DIR__ . '/../config/redis.php',
'oss' => require __DIR__ . '/../config/oss.php',
'jwt' => require __DIR__ . '/../config/jwt.php',
'openim' => require __DIR__ . '/../config/openim.php',
'log' => require __DIR__ . '/../config/log.php',
];
// 3. 初始化日志(见第四章)
$GLOBALS['_loggers'] = require __DIR__ . '/../app/Support/LoggerFactory.php';
// 4. 初始化数据库(Eloquent)
$capsule = new \Illuminate\Database\Capsule\Manager();
$capsule->addConnection(config('db'));
$capsule->setAsGlobal();
$capsule->bootEloquent();
// 5. 启动 Slim + PHP-DI
$container = require __DIR__ . '/../config/container.php';
$app = \Slim\Factory\AppFactory::createFromContainer($container);
require __DIR__ . '/../app/Exceptions/Handler.php';
require __DIR__ . '/../routes/api.php';
$app->run();
```
---
### 4.3 PHP-DI 容器
配置已全局加载,容器只管理共享对象实例,Service 全部 autowire。
```php
// config/container.php
use DI\ContainerBuilder;
$builder = new ContainerBuilder();
$builder->addDefinitions([
// Redis 单例:注入 RedisClient 封装,不直接注入 Predis\Client
\App\Support\RedisClient::class => function () {
$cfg = config('redis');
$predis = new \Predis\Client([
'scheme' => 'tcp',
'host' => $cfg['host'],
'port' => $cfg['port'],
'password' => $cfg['password'],
'database' => $cfg['database'],
]);
return new \App\Support\RedisClient($predis, $cfg['prefix']);
},
// 其余 Service 全部 autowire,PHP-DI 自动解析构造函数
]);
return $builder->build();
```
---
## 四、Redis 封装
直接操作 `Predis\Client` 容易出现 key 拼错、忘加前缀、序列化不一致等问题。统一封装 `RedisClient`,所有 Service 注入此类而非原始 Predis。
```php
// app/Support/RedisClient.php
namespace App\Support;
use Predis\Client;
class RedisClient
{
public function __construct(
private readonly Client $redis,
private readonly string $prefix = 'companion:',
) {}
private function key(string $key): string
{
return $this->prefix . $key;
}
// ── 基础操作 ──────────────────────────────────────────
public function get(string $key): mixed
{
$val = $this->redis->get($this->key($key));
return $val !== null ? json_decode($val, true) ?? $val : null;
}
public function set(string $key, mixed $value, int $ttl = 0): void
{
$val = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
$ttl > 0
? $this->redis->setex($this->key($key), $ttl, $val)
: $this->redis->set($this->key($key), $val);
}
public function del(string $key): void
{
$this->redis->del($this->key($key));
}
public function exists(string $key): bool
{
return (bool) $this->redis->exists($this->key($key));
}
public function expire(string $key, int $ttl): void
{
$this->redis->expire($this->key($key), $ttl);
}
public function incr(string $key, int $by = 1): int
{
return $by === 1
? (int) $this->redis->incr($this->key($key))
: (int) $this->redis->incrby($this->key($key), $by);
}
public function ttl(string $key): int
{
return (int) $this->redis->ttl($this->key($key));
}
// ── Set 操作(信号池)────────────────────────────────
public function sAdd(string $key, mixed ...$members): void
{
$this->redis->sadd($this->key($key), ...$members);
}
public function sRandMember(string $key): mixed
{
return $this->redis->srandmember($this->key($key));
}
public function sRem(string $key, mixed ...$members): void
{
$this->redis->srem($this->key($key), ...$members);
}
public function sCard(string $key): int
{
return (int) $this->redis->scard($this->key($key));
}
public function sIsMember(string $key, mixed $member): bool
{
return (bool) $this->redis->sismember($this->key($key), $member);
}
// ── 业务语义化方法(Signal 相关)──────────────────────
/** 今日收取次数 */
public function signalPullCount(int $uid): int
{
return (int) ($this->get("signal_limit:{$uid}:" . date('Ymd')) ?? 0);
}
public function signalPullIncr(int $uid): void
{
$key = "signal_limit:{$uid}:" . date('Ymd');
$this->incr($key);
// 当天剩余时间自动过期
if ($this->ttl($key) < 0) {
$this->expire($key, strtotime('tomorrow') - time());
}
}
/** Nonce 防重放 */
public function nonceExists(string $nonce): bool
{
return $this->exists("nonce:{$nonce}");
}
public function nonceSet(string $nonce, int $ttl = 600): void
{
$this->set("nonce:{$nonce}", 1, $ttl);
}
/** Token 黑名单 */
public function tokenBlock(string $jti, int $ttl): void
{
$this->set("token_blacklist:{$jti}", 1, $ttl);
}
public function tokenBlocked(string $jti): bool
{
return $this->exists("token_blacklist:{$jti}");
}
/** 用户在线心跳 */
public function heartbeat(int $uid): void
{
$this->set("user_online:{$uid}", time(), 120);
}
}
```
**核心业务逻辑复用:**
跨模块逻辑一律通过 Model 同步或查询,严禁 Service 循环依赖。
---
## 五、参数校验规范
**统一使用 `webmozart/assert`**,在 `BaseService` 中封装 `validate()` 方法捕获断言异常并转换为 `AppException`,保持调用侧代码整洁。
```php
// app/Support/BaseService.php(校验相关部分)
use Webmozart\Assert\Assert;
use Webmozart\Assert\InvalidArgumentException;
abstract class BaseService
{
/**
* 统一参数校验入口
* 将 webmozart/assert 的异常转换为 AppException(code=1002)
*
* 用法:$this->validate(function() use ($params) {
* Assert::keyExists($params, 'category', 'category 不能为空');
* Assert::integer($params['category'], 'category 必须是整数');
* Assert::range($params['category'], 1, 5, 'category 超出范围');
* });
*/
protected function validate(callable $assertions): void
{
try {
$assertions();
} catch (InvalidArgumentException $e) {
throw new \App\Exceptions\AppException($e->getMessage(), 1002);
}
}
// ... success() / fail() / uid() / params() / query() 见架构说明章节
}
```
**实际使用示例:**
```php
public function publish(Request $request, Response $response): Response
{
$uid = $this->uid($request);
$params = $this->params($request);
// ✅ 统一 validate 块,失败自动抛 AppException(1002)
$this->validate(function () use ($params) {
Assert::keyExists($params, 'category', 'category 不能为空');
Assert::integer((int)$params['category'], 'category 必须是整数');
Assert::range((int)$params['category'], 1, 5, 'category 超出范围 1-5');
Assert::false(
empty($params['content']) && empty($params['attachment']),
'content 和 attachment 至少填一个'
);
if (!empty($params['content'])) {
Assert::maxLength($params['content'], 500, 'content 最多500字');
}
if (!empty($params['type'])) {
Assert::inArray($params['type'], ['text', 'image', 'voice'], 'type 非法');
}
if (isset($params['attachment']) && is_string($params['attachment'])) {
Assert::true(json_validate($params['attachment']), 'attachment 必须是合法 JSON');
}
});
// 业务逻辑继续...
$category = SignalCategory::from((int)$params['category']);
// ...
}
```
**常用断言速查:**
```php
Assert::notEmpty($val, '不能为空');
Assert::string($val, '必须是字符串');
Assert::integer($val, '必须是整数');
Assert::boolean($val, '必须是布尔值');
Assert::inArray($val, [...], '非法值');
Assert::range($val, $min, $max, '超出范围');
Assert::minLength($val, $n, '太短');
Assert::maxLength($val, $n, '太长');
Assert::regex($val, '/pattern/', '格式错误');
Assert::keyExists($arr, 'key', '缺少字段');
Assert::nullOrString($val); // 允许 null
Assert::allString($arr); // 数组每项都是字符串
Assert::true(json_validate($v), '..'); // 配合 PHP 8.3 json_validate
```
---
## 六、日志规范
### 6.1 日志初始化
```php
// app/Support/LoggerFactory.php
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Processor\UidProcessor;
$logCfg = config('log');
$logPath = $logCfg['path'];
$level = Logger::toMonologLevel($logCfg['level']);
// 公共 processor:注入 Request ID(在 Handler.php 里初始化)
$requestIdProcessor = new class extends \Monolog\Processor\AbstractProcessor {
public function __invoke(array $record): array {
$record['extra']['request_id'] = $GLOBALS['_request_id'] ?? '-';
return $record;
}
};
$formatter = new JsonFormatter();
// app 日志:业务信息、警告
$appHandler = new RotatingFileHandler("{$logPath}/app.log", 30, $level);
$appHandler->setFilenameFormat('{filename}-{date}', 'Y-m-d');
$appHandler->setFormatter($formatter);
// error 日志:仅 ERROR 及以上
$errHandler = new RotatingFileHandler("{$logPath}/error.log", 30, Logger::ERROR);
$errHandler->setFilenameFormat('{filename}-{date}', 'Y-m-d');
$errHandler->setFormatter($formatter);
$appLogger = new Logger('app');
$appLogger->pushProcessor($requestIdProcessor);
$appLogger->pushProcessor(new UidProcessor());
$appLogger->pushHandler($appHandler);
$appLogger->pushHandler($errHandler);
return ['app' => $appLogger];
```
### 6.2 Request ID 注入
每个请求生成唯一 ID,写入响应头 `X-Request-Id`,方便日志关联排查。
```php
// app/Middleware/RequestIdMiddleware.php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RequestIdMiddleware
{
public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$requestId = $request->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(8));
// 存入全局,日志 processor 会自动附加
$GLOBALS['_request_id'] = $requestId;
$response = $handler->handle($request->withAttribute('request_id', $requestId));
return $response->withHeader('X-Request-Id', $requestId);
}
}
```
在 `index.php` 的中间件链最外层加上:
```php
$app->add(\App\Middleware\RequestIdMiddleware::class);
```
### 6.3 日志写入规范
| 场景 | 级别 | 通道 | 强制字段 |
|------|------|------|----------|
| 业务异常(AppException) | `warning` | app | uid, path, app_code, message |
| 未捕获异常(500) | `error` | app | request_id, path, message, trace |
| 支付回调 | `info` | app | platform, order_no, params(脱敏) |
| OpenIM 回调 | `info` | app | callback_type, params |
| 登录风控触发 | `warning` | app | uid, device_id, risk_type |
```php
// app/Exceptions/Handler.php — 日志记录示例
$app->addErrorMiddleware(true, true, true)
->setDefaultErrorHandler(function ($request, $exception, $displayDetails) use ($app) {
$response = $app->getResponseFactory()->createResponse();
$requestId = $GLOBALS['_request_id'] ?? '-';
$path = $request->getUri()->getPath();
if ($exception instanceof \App\Exceptions\AppException) {
// 业务异常:warning 级别,不记录堆栈
logger()->warning('app_exception', [
'uid' => $request->getAttribute('uid'),
'path' => $path,
'app_code' => $exception->getAppCode(),
'message' => $exception->getMessage(),
]);
$body = [
'code' => $exception->getAppCode(),
'msg' => $exception->getMessage(),
'data' => $exception->getExtraData(),
'server_time' => time(),
];
} else {
// 系统异常:error 级别,记录堆栈
logger()->error('unhandled_exception', [
'path' => $path,
'message' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
$body = [
'code' => 1009,
'msg' => $displayDetails ? $exception->getMessage() : '服务器内部错误',
'data' => null,
'server_time' => time(),
];
}
$response->getBody()->write(json_encode($body, JSON_UNESCAPED_UNICODE));
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('X-Request-Id', $requestId);
});
```
**核心节点强制记录(以 OpenIM 回调为例):**
```php
public function callback(Request $request, Response $response): Response
{
$params = $this->params($request);
// ✅ 核心回调节点:强制记录入参
logger()->info('openim_callback', [
'callback_type' => $params['callbackCommand'] ?? 'unknown',
'from_uid' => $params['sendID'] ?? null,
'to_uid' => $params['recvID'] ?? null,
'msg_type' => $params['contentType'] ?? null,
]);
// 业务逻辑...
}
```
---
## 七、架构说明
### 两层结构
| 层 | 文件位置 | 职责 |
|----|----------|------|
| **Service** | `app/Services/` | 接收 PSR-7 Request,验参,业务逻辑,返回 Response |
| **Model** | `app/Models/` | 表映射、scope、跨 Service 复用的查询方法 |
**没有 Controller 层**,路由直接指向 Service 方法。
**跨模块复用逻辑放 Model**,不放 Service,避免 Service 互相引用:
- `User::getUserMap(array $uids, int $myUid)` — 多个 Service 需要带关系的用户信息
- `User::isVip(int $uid)` — 判断会员
- `UserFollow::getMutualStatus(int $uid, int $targetUid)` — 查双向关注状态
### 路由直接指向 Service
```php
// routes/api.php(节选)
$app->group('/v1', function ($group) {
// 白名单(无需 Token)
$group->post('/app/start', [\App\Services\AppService::class, 'start']);
$group->get ('/app/config', [\App\Services\AppService::class, 'config']);
$group->post('/auth/login/phone', [\App\Services\AuthService::class, 'loginPhone']);
$group->post('/pay/callback/wechat', [\App\Services\PayService::class, 'callbackWechat']);
// 需要登录
$group->get ('/signal/publish/config', [\App\Services\SignalService::class, 'publishConfig']);
$group->post('/signal/publish', [\App\Services\SignalService::class, 'publish']);
$group->post('/signal/pull', [\App\Services\SignalService::class, 'pull']);
$group->post('/signal/reply', [\App\Services\SignalService::class, 'reply']);
$group->get ('/signal/inbox/list', [\App\Services\SignalService::class, 'inboxList']);
// ...(完整路由见接口总览)
})->add(\App\Middleware\SignMiddleware::class)
->add(\App\Middleware\RateLimitMiddleware::class);
```
### 风控降权处理(X-Device-Risk-Flag)
前端检测到高风险设备时(Root/越狱/Hook/模拟器),在请求 Header 中携带 `X-Device-Risk-Flag`,其值为 JSON 编码的风险标识符数组。
**详细规范见**:[**API 认证与签名规范.md**](./API认证与签名规范.md)
- **状态示例**:
- `["safe"]`: 环境安全。
- `["unverified"]`: 未经审核(非生产环境或启动超时)。
- `["simulator", "privileged_access"]`: 检测到模拟器及系统提权。
- **后端处理逻辑**:
- **不拒绝请求**(避免暴露防御逻辑)。
- 写入 `user_risk_logs` 表记录具体的风险标识符。
- 对该账号执行降权:限制信号曝光、减少每日收取配额等。
- 高风险标记累计后可由后台触发封禁。
```php
// 在 AuthMiddleware 或 SignMiddleware 中读取
$riskFlagJson = $request->getHeaderLine('X-Device-Risk-Flag');
$riskFlags = json_decode($riskFlagJson, true) ?? ['unverified'];
if (!in_array('safe', $riskFlags) && !in_array('unverified', $riskFlags)) {
logger()->warning('device_risk_detected', [
'uid' => $uid,
'ip' => $clientIp,
'risks' => $riskFlags
]);
// 写 user_risk_logs,触发后续降权逻辑
}
```
---
## 五、数据库设计规范
### 5.1 PostgreSQL 字段类型规范
| 场景 | MySQL 旧习惯 | PostgreSQL 正确写法 |
|------|-------------|---------------------|
| 自增主键 | `BIGINT AUTO_INCREMENT` | `BIGINT GENERATED ALWAYS AS IDENTITY` |
| 时间戳 | `INT DEFAULT 0`(Unix) | `TIMESTAMPTZ NOT NULL DEFAULT NOW()` |
| 软删除 | `deleted_at INT DEFAULT 0` | `deleted_at TIMESTAMPTZ NULL DEFAULT NULL` |
| 字符串 | `VARCHAR(50) NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` + CHECK 约束 |
| IP 地址 | `VARCHAR(45)` | `INET` |
| 布尔值 | `TINYINT(1)` / `SMALLINT` | `BOOLEAN NOT NULL DEFAULT FALSE` |
| 枚举状态 | `TINYINT` | 自定义 `TYPE`(`CREATE TYPE`)|
| JSON | `JSON` / `TEXT` | `JSONB` |
| 金额 | `INT`(分) | `INT`(分,保留,不用 DECIMAL 避免精度问题) |
### 5.2 时间字段说明
**全部使用 `TIMESTAMPTZ`**,而不是 Unix 时间戳整型:
- 支持时区,存储、查询、排序原生高效
- 可以直接用 `NOW()`、`INTERVAL`、`DATE_TRUNC` 等函数
- 索引效率比 INT 更好(B-Tree 原生支持范围查询)
- 软删除用 `NULL` 表示未删除,语义直观,`IS NULL` / `IS NOT NULL` 查询清晰
```sql
-- 查未删除记录
SELECT * FROM signals WHERE deleted_at IS NULL;
-- 查今日创建
SELECT * FROM signals WHERE created_at >= NOW() - INTERVAL '1 day';
-- 查 VIP 未过期
SELECT * FROM users WHERE vip_expire_at > NOW();
```
```php
// Eloquent 对应查询
Signal::whereNull('deleted_at')->get(); // 未删除
Signal::where('created_at', '>=', now()->subDay())->get(); // 今日
User::where('vip_expire_at', '>', now())->get(); // VIP 有效
```
### 5.3 软删除约定
```php
// BaseModel 软删除
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('deleted_at'); // ✅ TIMESTAMPTZ NULL 用 whereNull
}
public function softDelete(): bool
{
return $this->update(['deleted_at' => now()]);
}
```
### 5.4 自定义枚举 TYPE
PostgreSQL 原生支持 `CREATE TYPE ... AS ENUM`,比 SMALLINT 更安全,字段值自带约束,插入非法值直接报错。
```sql
-- 00_types.sql(最先执行)
CREATE TYPE signal_category_t AS ENUM ('love', 'game', 'sport', 'offline', 'curious');
CREATE TYPE signal_status_t AS ENUM ('normal', 'review', 'illegal');
CREATE TYPE signal_type_t AS ENUM ('text', 'image', 'voice');
CREATE TYPE order_status_t AS ENUM ('pending', 'paid', 'canceled', 'refunded');
CREATE TYPE pay_type_t AS ENUM ('wechat', 'alipay', 'apple');
CREATE TYPE user_status_t AS ENUM ('active', 'canceling', 'canceled');
CREATE TYPE forbidden_type_t AS ENUM ('login', 'chat', 'post', 'match', 'full', 'device');
CREATE TYPE gender_t AS ENUM ('unknown', 'male', 'female');
CREATE TYPE login_type_t AS ENUM ('phone', 'wechat', 'apple');
```
> **与 PHP Enum 的映射关系**:PHP Enum 用整型(方便序列化和 JSON 传输),数据库 TYPE 用字符串(方便直接阅读),在 Model 的 `$casts` 里做转换。若觉得维护两套麻烦,可以只用 PHP Enum + SMALLINT,二选一即可,项目中选其一保持一致。
---
## 六、数据库 Schema
> Schema 文件位于 `database/schema/`,按编号顺序执行(先跑 `00_types.sql`)。
| 文件 | 表 / 说明 |
|------|-----------|
| `00_types.sql` | 所有自定义枚举 TYPE,最先执行 |
| `01_users.sql` | `users` — 用户主表 |
| `02_user_devices.sql` | `user_devices` — 设备 + 推送 token |
| `03_user_follows.sql` | `user_follows` — 关注关系 |
| `04_user_blocks.sql` | `user_blocks` — 拉黑关系 |
| `05_user_visitors.sql` | `user_visitors` — 访客记录 |
| `06_user_login_logs.sql` | `user_login_logs` — 登录日志 |
| `07_user_risk_logs.sql` | `user_risk_logs` — 风控日志 |
| `08_user_forbidden.sql` | `user_forbidden` — 封禁记录 |
| `09_signals.sql` | `signals` — 信号主表 |
| `10_signal_receives.sql` | `signal_receives` — 收取记录 |
| `11_signal_replies.sql` | `signal_replies` — 回复 |
| `12_notifications.sql` | `notifications` — 通知消息 |
| `13_orders.sql` | `orders` — 支付订单 |
| `14_products.sql` | `products` — 商品(VIP 套餐)|
**枚举类型(`00_types.sql`):**
```sql
CREATE TYPE signal_category_t AS ENUM ('love', 'game', 'sport', 'offline', 'curious');
CREATE TYPE signal_status_t AS ENUM ('normal', 'review', 'illegal');
CREATE TYPE signal_type_t AS ENUM ('text', 'image', 'voice');
CREATE TYPE order_status_t AS ENUM ('pending', 'paid', 'canceled', 'refunded');
CREATE TYPE pay_type_t AS ENUM ('wechat', 'alipay', 'apple');
CREATE TYPE user_status_t AS ENUM ('active', 'canceling', 'canceled');
CREATE TYPE forbidden_type_t AS ENUM ('login', 'chat', 'post', 'match', 'full', 'device');
CREATE TYPE gender_t AS ENUM ('unknown', 'male', 'female');
CREATE TYPE login_type_t AS ENUM ('phone', 'wechat', 'apple');
```
> 其余表结构详见各 `.sql` 文件。PHP Enum 用整型、数据库 TYPE 用字符串,在 Model `$casts` 里转换;或全用 PHP Enum + SMALLINT,二选一保持一致。
---
## 七、Eloquent Model 规范
> 详见 `app/Models/`,以下为强制规则摘要。
- 所有 Model 继承 `BaseModel`(`app/Models/BaseModel.php`)
- 必须声明 `protected $keyType = 'int'`(PostgreSQL IDENTITY 返回字符串,不写会导致类型错误)
- `public $timestamps = false`,时间字段手动维护
- 软删除:`deleted_at TIMESTAMPTZ NULL`,查询用 `scopeActive()`(`whereNull('deleted_at')`),软删调 `softDelete()`
- 游标分页:`scopeCursorBefore(?int $cursor, string $col = 'id')`
- 跨 Service 复用的查询方法放在 Model,如 `User::getUserMap()`, `User::isVip()`
- `$fillable` 显式声明,禁止 `$guarded = []`
- 枚举列在 `$casts` 里映射 PHP Enum,时间列 cast 为 `'datetime'`
**BaseModel 核心方法速查:**
```php
scopeActive() // whereNull('deleted_at')
softDelete() // update(['deleted_at' => now()])
scopeCursorBefore(?int $cursor, string $col = 'id') // 游标分页辅助
```
---
## 八、Service 规范
> 详见 `app/Support/BaseService.php`,以下为可用方法速查。
```php
$this->success($response, $data) // code=0 成功响应
$this->fail($response, $code, $msg, $data) // 业务失败(一般改用 throw AppException)
$this->uid($request) // 从 JWT 中间件取 uid
$this->params($request) // POST body → array
$this->query($request) // GET query → array
```
**规则:**
- Service 方法签名固定:`public function xxx(Request $request, Response $response): Response`
- 验参失败抛 `AppException(message, 1002)`,业务逻辑失败抛对应错误码;错误码定义见 **《同伴 App — 错误码参考 V2.1》**(`docs/错误码参考.md`)
- 数据库事务用 `DB::transaction(fn() => ...)`
- 跨多个 Service 复用的查询逻辑放到 Model,不要 Service 互相依赖
- 依赖注入用 Constructor Property Promotion:`public function __construct(private readonly XxxService $xxx) {}`
---
## 九、AppException 与错误码
> 完整错误码定义(含义、触发场景、extraData 结构、前端处理方式)→ **《同伴 App — 错误码参考 V2.1》**(`docs/错误码参考.md`)。
> 本章只记录抛错方式与 Handler 响应映射。
### 9.1 AppException 结构
```php
// app/Exceptions/AppException.php
namespace App\Exceptions;
class AppException extends \RuntimeException
{
public function __construct(
string $message,
private readonly int $appCode = 1002,
private readonly mixed $extraData = null,
) {
parent::__construct($message);
}
public function getAppCode(): int { return $this->appCode; }
public function getExtraData(): mixed { return $this->extraData; }
}
```
### 9.2 Handler.php 响应映射
```php
// app/Exceptions/Handler.php
$app->addErrorMiddleware(true, true, true)
->setDefaultErrorHandler(function ($request, $exception, $displayDetails) use ($app) {
$response = $app->getResponseFactory()->createResponse();
$body = $exception instanceof \App\Exceptions\AppException
? ['code' => $exception->getAppCode(), 'msg' => $exception->getMessage(),
'data' => $exception->getExtraData(), 'server_time' => time()]
: ['code' => 1009, 'msg' => $displayDetails ? $exception->getMessage() : '服务器内部错误',
'data' => null, 'server_time' => time()];
$response->getBody()->write(json_encode($body, JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
});
```
| 异常类型 | HTTP 状态码 | `code` 字段 |
|----------|-------------|-------------|
| `AppException` | 400 | `appCode`(业务自定义,见错误码参考)|
| JWT 验证失败 | 401 | `1001` |
| 签名验证失败 | 403 | `1003` |
| 未捕获异常 | 500 | `1009` |
### 9.3 抛错速查
```php
// 参数校验失败 → 自动 code=1002,通过 validate() 块触发
$this->validate(fn() => Assert::keyExists($params, 'category', 'category 不能为空'));
// 业务逻辑失败 → 使用错误码参考中定义的 code
throw new AppException('信号数量已达上限', 3001);
throw new AppException('今日收取已达上限,观看广告后可解锁额度', 3007, [
'ad_config' => config('app.ad_config'),
]);
// 封禁(普通违规)→ code=1008,携带 ban_info
throw new AppException('您的账号因违规已被封禁', 1008, ['ban_info' => [...]]);
// 封禁(涉嫌诈骗)→ code=1013,携带 ban_info
throw new AppException('您的账号涉嫌诈骗行为已被封禁', 1013, ['ban_info' => [...]]);
// ❌ 禁止:直接 return fail() 处理业务异常
```
---
## 十、Swagger 接口文档 (PHP 8 Attributes)
> 依赖:`zircote/swagger-php v4`;UI 地址:`/docs/api`
> **详细示例和属性速查 → 粘贴 `docs/skill/backend_swagger.md`**
### 核心规则
| 场景 | 写法 |
|------|------|
| 公共 Header(X-App-Key 等) | **不写**,CommonParametersProcessor 自动注入 |
| 需要登录(JWT) | `#[OA\Post(... security:[["bearerAuth"=>[]]])]` |
| 公开白名单接口 | 额外加 `#[\App\Support\Annotations\PublicApi]` |
| 成功响应 data | `allOf: [ApiSuccess, 自定义 data schema]` |
| 失败响应 | `content: new OA\JsonContent(ref: "#/components/schemas/ApiError")` |
### 属性拆分规则(强制)
`#[OA\Post]` 只写路径、tags、summary、security,**禁止嵌套** `requestBody:` 和 `responses:`。
每个 `#[OA\RequestBody]`、`#[OA\Parameter]`、`#[OA\Response]` 单独一行。
```php
// ✅ 正确:各 Attribute 独立分行
#[OA\Post(path: "/signal/reply", tags: ["信号"], summary: "回复信号", security: [["bearerAuth" => []]])]
#[OA\RequestBody(content: new OA\JsonContent(required: ["receive_id"], properties: [...]))]
#[OA\Response(response: 200, description: "业务成功", content: new OA\JsonContent(allOf: [...]))]
#[OA\Response(response: 400, description: "业务失败", content: new OA\JsonContent(ref: "#/components/schemas/ApiError"))]
public function reply(Request $request, Response $response): Response {}
// ❌ 错误:requestBody / responses 嵌套在 Post 里
#[OA\Post(path: "...", requestBody: new OA\RequestBody(...), responses: [...])]
```
---
## 十一、配合 AI 开发规范
> AI 开发上下文模板、提问模板、模块开发顺序 → 详见 **`docs/skill/backend.md`**
> 编写 OA 注解时额外粘贴 **`docs/skill/backend_swagger.md`**
---
## 十二、接口总览
| 模块 | 方法 | 路径 | 白名单 |
|------|------|------|--------|
| App | POST | `/app/start` | ✅ |
| App | GET | `/app/config` | ✅ |
| App | GET | `/app/version` | ✅ |
| App | POST | `/app/heartbeat` | |
| Auth | POST | `/auth/sms/send` | ✅ |
| Auth | POST | `/auth/login/phone` | ✅ |
| Auth | POST | `/auth/token/refresh` | ✅ |
| Auth | POST | `/auth/logout` | |
| User | GET | `/user/profile/me` | |
| User | POST | `/user/profile/update` | |
| User | POST | `/user/avatar/upload` | |
| User | GET | `/user/profile/detail` | |
| User | POST | `/user/follow/add` | |
| User | POST | `/user/follow/remove` | |
| User | POST | `/user/follow/top` | |
| User | GET | `/user/follow/list` | |
| User | POST | `/user/follower/remove` | |
| User | POST | `/user/block/add` | |
| User | POST | `/user/block/remove` | |
| User | GET | `/user/block/list` | |
| User | POST | `/user/report/submit` | |
| User | GET | `/user/visitor/list` | |
| User | GET | `/user/visited/list` | |
| User | POST | `/user/visitor/report` | |
| Upload | POST | `/upload/oss/token` | |
| Upload | POST | `/upload/oss/token/batch` | |
| Upload | POST | `/upload/oss/sign` | |
| Signal | GET | `/signal/publish/config` | |
| Signal | POST | `/signal/publish` | |
| Signal | POST | `/signal/update` | |
| Signal | GET | `/signal/my/list` | |
| Signal | POST | `/signal/pull` | |
| Signal | POST | `/signal/pull/ad/unlock` | |
| Signal | POST | `/signal/reply/collect` | |
| Signal | POST | `/signal/reply` | |
| Signal | GET | `/signal/inbox/list` | |
| Signal | GET | `/signal/reply/unread` | |
| Signal | POST | `/signal/inbox/read` | |
| Signal | POST | `/signal/inbox/delete` | |
| Chat | POST | `/chat/message/send` | |
| Chat | POST | `/chat/callback/openim` | ✅ |
| Notify | GET | `/notify/list` | |
| Notify | POST | `/notify/read` | |
| Notify | POST | `/notify/push/register` | |
| Notify | POST | `/notify/push/unregister` | |
| Pay | POST | `/pay/order/create` | |
| Pay | GET | `/pay/order/detail` | |
| Pay | GET | `/pay/order/list` | |
| Pay | POST | `/pay/apple/verify` | |
| Pay | POST | `/pay/callback/wechat` | ✅ |
| Pay | POST | `/pay/callback/alipay` | ✅ |
| Pay | POST | `/pay/callback/refund` | ✅ |
| Vip | GET | `/vip/products` | |
| Vip | POST | `/vip/purchase` | |
---
## 十三、.env.example(完整字段模板)
> **规则:** `.env` 加入 `.gitignore`,`.env.example` 必须提交到版本库。新增任何环境变量必须同步到此文件,字段、注释、默认值保持完整,确保任何人 clone 后能直接参照配置。
```dotenv
# ══════════════════════════════════════════════════
# 应用基础
# ══════════════════════════════════════════════════
APP_ENV=local # local | staging | production
APP_DEBUG=true # 生产环境必须设为 false
# ══════════════════════════════════════════════════
# 数据库(PostgreSQL)
# ══════════════════════════════════════════════════
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=companion
DB_USERNAME=companion_user
DB_PASSWORD=your_password_here
DB_SSLMODE=prefer # 生产环境改为 require
# ══════════════════════════════════════════════════
# Redis
# ══════════════════════════════════════════════════
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD= # 无密码留空
REDIS_DB=0
REDIS_PREFIX=companion: # key 前缀,多项目共用 Redis 时区分
# ══════════════════════════════════════════════════
# JWT
# ══════════════════════════════════════════════════
JWT_SECRET=请替换为至少32位随机字符串
JWT_EXPIRE=7200 # access token 有效期(秒),默认2小时
JWT_REFRESH_EXPIRE=2592000 # refresh token 有效期(秒),默认30天
# ══════════════════════════════════════════════════
# 阿里云 OSS
# ══════════════════════════════════════════════════
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_BUCKET_PUBLIC=companion-public # 头像、公开图片等
OSS_BUCKET_PRIVATE=companion-private # 语音消息、私密附件等
OSS_STS_ROLE_ARN=acs:ram::xxxxx:role/oss-upload # STS 授权角色
# ══════════════════════════════════════════════════
# OpenIM(即时通讯)
# ══════════════════════════════════════════════════
OPENIM_API_URL=http://127.0.0.1:10002
OPENIM_SECRET=请替换为 OpenIM 后台配置的回调密钥
OPENIM_ADMIN_UID=openIMAdmin
# ══════════════════════════════════════════════════
# 日志
# ══════════════════════════════════════════════════
LOG_LEVEL=debug # debug | info | warning | error(生产建议 info)
# ══════════════════════════════════════════════════
# 应用签名(客户端请求签名)
# ══════════════════════════════════════════════════
# 格式见 config/app_keys.php,每个客户端一组 key/secret
APP_KEY_IOS_PROD=companion_ios_prod
APP_KEY_ANDROID_PROD=companion_android_prod
APP_SECRET_PROD=sk_请替换为随机字符串
APP_KEY_IOS_DEV=companion_ios_dev
APP_KEY_ANDROID_DEV=companion_android_dev
APP_SECRET_DEV=sk_dev_请替换为随机字符串
```
---
*END — 同伴 App 后端开发规范 V4.3 · 错误码定义见《同伴 App — 错误码参考 V2.1》*