← API | 列表 | 同伴App_后端开发规范
提示信息
# 同伴 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》*