Skip to content

🔖 代码注解

SunAdmin 通过 PHP 8 属性(attribute)声明路由、依赖注入等元数据。本页集中列出项目中实际使用的所有注解:签名、用途、工作机制、典型示例、注意事项。

🎯 本页目标

读完本章节后,应能:

  • 区分项目自定义注解(#[Inject]#[ApiDoc]#[OpenGet]#[OpenPost])与 Spatie 提供的路由注解。
  • 知道每个注解应该写在类还是方法、可以传哪些参数。
  • 写公开接口、登录接口、尝试登录接口时正确选择注解组合。
  • 在需要时扩展自定义路由注解或属性级注解。

🧾 注解一览

注解来源是否项目自定义适用范围
#[Inject]App\Core\Attributes\Inject✅ 项目自定义类属性
#[ApiDoc]App\Core\Attributes\ApiDoc✅ 项目自定义控制器类 / 控制器方法
#[Group]Spatie\RouteAttributes\Attributes\Group❌ Spatie控制器类
#[Get]Spatie\RouteAttributes\Attributes\Get❌ Spatie控制器方法
#[Post]Spatie\RouteAttributes\Attributes\Post❌ Spatie控制器方法
#[Put]Spatie\RouteAttributes\Attributes\Put❌ Spatie控制器方法
#[Patch]Spatie\RouteAttributes\Attributes\Patch❌ Spatie控制器方法
#[Delete]Spatie\RouteAttributes\Attributes\Delete❌ Spatie控制器方法
#[Options]Spatie\RouteAttributes\Attributes\Options❌ Spatie控制器方法
#[Resource]Spatie\RouteAttributes\Attributes\Resource❌ Spatie控制器类
#[OpenGet]App\Core\Attributes\OpenGet✅ 项目自定义控制器方法
#[OpenPost]App\Core\Attributes\OpenPost✅ 项目自定义控制器方法

#[Put]#[Patch]#[Delete]#[Options]#[Get]/#[Post] 行为一致,仅 HTTP 方法不同;#[Resource] 用于在控制器类上一次性声明 RESTful 资源路由。Spatie 包还提供更多扩展注解,按 Spatie 文档使用即可。

🪛 依赖注入:#[Inject]

用途

把容器解析得到的对象,自动注入到类属性,省去构造函数手写一长串依赖。Controller、Dao、Service、Action、Middleware、Job 都可以用。

签名

php
namespace App\Core\Attributes;

#[Attribute(Attribute::TARGET_PROPERTY)]
class Inject
{
    public function __construct(
        public readonly ?string $abstract = null,
    ) {}
}
参数类型说明
abstract?string容器绑定 key。null 时使用属性声明类型,绝大多数场景留空即可

工作机制

app/Core/Container/PropertyInjector.php 反射扫描类属性:

  1. 找出所有带 #[Inject] 的属性。
  2. 读属性声明类型;要求是非内置(非 int/string/array/...)的 class/interface。
  3. 调用 app()->make($abstract ?? $type) 解析依赖,赋值给属性。
  4. 反射结果按类缓存,后续创建同类对象不再重复扫描。

被注入对象会随项目容器解析流程一起处理,业务层无需手动触发。

典型示例

控制器注入 Dao:

php
use App\Core\Attributes\Inject;
use App\Core\Http\Controllers\ApiController;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Group;

#[Group(prefix: 'article')]
class ArticleController extends ApiController
{
    #[Inject]
    protected ArticleDao $dao;

    #[Get('index')]
    public function index()
    {
        return success($this->dao->getList([]));
    }
}

Dao 注入 Model(用于复用同一个模型实例):

php
use App\Core\Attributes\Inject;
use App\Core\Dao\BaseDao;

class ArticleDao extends BaseDao
{
    #[Inject]
    protected Article $articleModel;

    protected function model(): Article
    {
        return $this->articleModel;
    }
}

显式指定接口绑定:

php
#[Inject(CouponServiceInterface::class)]
protected CouponServiceInterface $coupon;

与构造函数注入的对比

#[Inject] 不替代 Laravel 的构造函数注入,两种写法容器都能正常解析。差异在于"样板代码量"和"依赖位置":

不使用 #[Inject](传统构造函数注入):

php
use App\Core\Http\Controllers\ApiController;
use Modules\System\Application\Services\Auth\LoginService;
use Modules\System\Dao\Article\ArticleDao;
use Modules\System\Dao\Article\CategoryDao;

class ArticleController extends ApiController
{
    public function __construct(
        protected ArticleDao $articleDao,
        protected CategoryDao $categoryDao,
        protected LoginService $loginService,
    ) {
        parent::__construct();
    }

    public function index()
    {
        return success($this->articleDao->getList([]));
    }
}

使用 #[Inject](属性注入):

php
use App\Core\Attributes\Inject;
use App\Core\Http\Controllers\ApiController;
use Modules\System\Application\Services\Auth\LoginService;
use Modules\System\Dao\Article\ArticleDao;
use Modules\System\Dao\Article\CategoryDao;

class ArticleController extends ApiController
{
    #[Inject]
    protected ArticleDao $articleDao;

    #[Inject]
    protected CategoryDao $categoryDao;

    #[Inject]
    protected LoginService $loginService;

    public function index()
    {
        return success($this->articleDao->getList([]));
    }
}

两种写法的取舍:

维度构造函数注入#[Inject] 属性注入
样板代码需要写 __constructparent::__construct()不需要
依赖位置集中在构造函数参数列表散落在每个属性声明上
增删依赖改构造函数签名加/删一行属性
子类有自己的 __construct必须显式调用 parent::__construct() 把父类依赖往上传子类无需关心父类依赖
直接 new 创建对象直接传依赖即可必须走容器 app()->make(),否则属性为 null
依赖是否可变readonly 友好,构造后不可变反射赋值,初始化后仍可被外部覆盖

项目约定:Controller、Dao、Service、Action、Job 这类由容器解析、不会被手动 new 的类优先使用 #[Inject],减少构造函数样板;如果某个类需要在脚本、测试中频繁手动 new,或希望依赖 readonly 不可变,仍可走构造函数注入。

注意事项

  • 属性必须声明非内置类型,不能写 mixedintstring 等。
  • privateprotected 属性都可以注入,由 PropertyInjector 通过反射赋值。
  • 已经在构造函数里初始化的属性会被跳过,不会被注入覆盖。

📘 接口文档:#[ApiDoc]

用途

给 OpenAPI 导出补充接口中文名称、tag、描述和参数。控制器类上声明 tag,控制接口归属目录;控制器方法上声明 summary,控制具体接口名称。所有对外接口都应通过 #[ApiDoc] 声明文档名称,避免到导出器里反查集中兜底字典。

签名

php
namespace App\Core\Attributes;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class ApiDoc
{
    public function __construct(
        public string $summary = '',
        public string $tag = '',
        public string $description = '',
        public array $parameters = [],
    ) {}
}
参数类型说明
summarystring接口名称,例如“装修数据”“同步知识库”
tagstring控制器分组名称,通常写在控制器类上
descriptionstring接口说明,会追加到 OpenAPI operation description
parametersarray手动补充 OpenAPI 参数,适合复杂或动态参数

示例

php
use App\Core\Attributes\ApiDoc;
use App\Core\Attributes\OpenGet;

#[ApiDoc(tag: '装修')]
class DecorateController extends ApiController
{
    #[OpenGet('page')]
    #[ApiDoc(summary: '按 Key 获取装修页')]
    public function page()
    {
        $params = $this->request()->pick([
            ['key', 'home'],
        ]);

        return success($this->decorateAction->page((string) $params['key']));
    }
}

OpenapiExporter 会读取类上的 tag 生成分组,读取方法上的 summary 作为接口中文名,并从 $this->request()->pick([...]) 推导 key 这类简单参数。复杂场景可以手动补充:

php
#[ApiDoc(
    summary: '按 Key 获取装修页',
    parameters: [
        ['name' => 'key', 'type' => 'string', 'description' => '装修页面 Key', 'example' => 'home'],
    ],
)]

🧭 路由分组:#[Group]

用途

给控制器内所有路由方法统一加前缀和中间件,避免每个方法重复写。

签名

php
#[Group(
    prefix: ?string,
    middleware: array|string = [],
    where: array = [],
    domain: ?string = null,
    name: ?string = null,
)]

业务里最常用的是 prefixmiddleware

路径拼接

最终接口路径 = 路由扫描根前缀 + Group::prefix + 方法注解 uri

控制器目录扫描根前缀
modules/{Module}/Http/Controllers/Admin/adminapi/{slug}
modules/{Module}/Http/Controllers/Api/api/{slug}

{slug} 由模块 module.jsonslug 字段决定(如 SystemsystemUserCenteruser-center)。

示例

php
#[Group(prefix: 'article/list')]
class ArticleController extends ApiController
{
    #[Get('index')]   // GET /adminapi/system/article/list/index
    public function index() {}

    #[Post('save')]   // POST /adminapi/system/article/list/save
    public function save() {}
}

控制器级中间件:

php
#[Group(prefix: 'upload', middleware: ['throttle:admin-upload'])]
class UploadController extends ApiController { /* ... */ }

📡 HTTP 方法:#[Get] / #[Post] / #[Delete]

签名

php
#[Get(
    uri: string,
    name: ?string = null,
    middleware: array|string = [],
    withoutMiddleware: array|string = [],
    where: array = [],
    domain: ?string = null,
)]

#[Post]#[Delete]、其他 Spatie 提供的 HTTP 注解签名一致。

参数用途
uri相对 #[Group::prefix] 的路径,支持 {id} 这类参数占位
name命名路由,方便后端 route('name') 反查
middleware当前方法叠加的中间件
withoutMiddleware显式排除继承到的中间件

#[Group] 组合

方法注解的 uri 拼接在 #[Group::prefix] 之后;中间件按"目录默认 + Group + 方法"叠加。

php
#[Group(prefix: 'order', middleware: ['admin.audit-log'])]
class OrderController extends ApiController
{
    // 自动叠加 admin.auth(目录默认)+ admin.audit-log(Group)
    #[Get('index')]
    public function index() {}

    // 在前两个基础上再追加 throttle:admin-export
    #[Get('export', middleware: ['throttle:admin-export'])]
    public function export() {}
}

路径参数

php
#[Get('read/{id}')]
public function read(int $id) {}

id 会作为方法参数注入,Laravel 自动按类型转换。

命名路由

php
#[Post('save', name: 'admin.article.save')]
public function save() {}

后端代码可用 route('admin.article.save') 反查 URL。

🔓 公开接口:#[OpenGet] / #[OpenPost]

用途

声明"不需要登录的对外接口"。Open* 注解构造时会自动把项目三大认证中间件追加到 withoutMiddleware

php
['admin.auth', 'api.auth', 'auth:sanctum']

这样无论控制器位于 Admin 还是 Api 目录、默认中间件是 admin.auth 还是 api.auth,都会被本注解放行。

签名

php
#[OpenGet(
    uri: string,
    name: ?string = null,
    middleware: array|string = [],
    withoutMiddleware: array|string = [],   // 会自动并入三大认证中间件
)]

#[OpenPost] 签名一致。

与普通 #[Get]/#[Post] 的差异

场景普通 #[Get]#[OpenGet]
控制器位于 Admin 目录必须管理员登录公开访问
控制器位于 Api 目录必须用户登录公开访问
显式追加 middleware叠加生效同样叠加生效

示例

后台登录接口(公开 + 限流):

php
#[OpenPost('account', middleware: ['throttle:admin-login'])]
public function account(LoginRequest $form)
{
    return success();
}

用户端公开文章详情:

php
#[OpenGet('article/detail')]
public function detail()
{
    return success();
}

api.try-auth 配合:公开但识别登录态

如果接口允许游客访问,但登录后想给登录用户更多个性化数据:

php
#[OpenGet('article/detail', middleware: ['api.try-auth'])]
public function detail()
{
    $user = $this->user();    // 游客时为 null,登录态时返回用户

    return success([
        'is_login' => filled($user),
    ]);
}

api.try-auth 不会拦截游客,但会在请求头携带有效 token 时自动注入用户身份。

🧮 常见组合速查

场景控制器目录用法
后台必须登录接口Admin/#[Get]#[Post](默认 admin.auth
后台公开接口(登录、验证码)Admin/#[OpenGet] / #[OpenPost]
用户端必须登录接口Api/#[Get]#[Post](默认 api.auth
用户端公开接口(首页、文章)Api/#[OpenGet] / #[OpenPost]
用户端公开但识别登录态Api/#[OpenGet] + middleware: ['api.try-auth']
公开接口 + 限流任意#[OpenPost('account', middleware: ['throttle:admin-login'])]
删除资源任意#[Delete('items/{id}')]

🔗 参考