数据库 Schema 生成 CRUD 接口

解读

国内一线/二线互联网公司 PHP 面试中,这道题通常出现在“代码实战”或“系统设计”环节,面试官真正想考察的是:

  1. 能否把“数据库表结构”快速映射成“RESTful 接口”,并保证字段、类型、约束、索引、关联关系不丢失;
  2. 是否熟悉主流 PHP 生态工具(Doctrine、Laravel Migration、Eloquent、Symfony Maker、ThinkPHP6 模型生成器、Hyperf 代码生成器等),以及能否在 5~10 分钟内给出可运行、可单元测试、可部署的代码;
  3. 对性能、安全、事务、幂等、分页、软删除、乐观锁、字段脱敏等工程细节是否有落地经验;
  4. 是否具备“可配置”“可扩展”思维:表字段变化后,接口能否零成本重新生成,而不把手工改代码作为常态。

因此,回答时必须给出“一条命令生成全部代码”的完整思路,并现场解释生成规则、目录约定、命名规范、测试策略,而不是只写几个 SQL 和路由。

知识点

  1. Schema 元数据读取:information_schema、SHOW FULL COLUMNS、PDO::getAttribute、doctrine/dbal SchemaManager。
  2. 代码生成器模式:Stub 模板 + AST 改写 + PSR-4 自动加载,避免复制粘贴。
  3. RESTful 规范:URI 复数、HTTP 动词、201/204/422/404 语义、Location 头、分页头 X-Total-Count。
  4. 字段类型映射:MySQL datetime → string date-format,JSON → array,decimal → string 防精度丢失。
  5. 验证规则自动推导:NOT NULL → required,UNIQUE → unique:表名,字段名,ENUM → in:列表。
  6. 关联关系推断:外键名后缀 _id → BelongsTo/HasMany,中间表 → BelongsToMany。
  7. 安全加固:批量赋值白名单 fillableSQL注入通过ORM参数绑定、敏感字段fillable、SQL 注入通过 ORM 参数绑定、敏感字段 hidden、频率限制中间件。
  8. 性能:预加载 with()、索引提示、游标分页(Cursor Pagination)、OPcache 预热。
  9. 事务与幂等:DB::transaction、分布式幂等令牌(Idempotency-Key)。
  10. 测试策略:Factory 生成假数据、PHPUnit + SQLite in-memory、Postman 集合自动化、GitHub Action 持续集成。

答案

以下示例基于 Laravel 10 + PHP 8.2,假设 MySQL 8.0 已有 shop 库,表结构如下:

CREATE TABLE product ( id bigint unsigned NOT NULL AUTO_INCREMENT, sn varchar(32) NOT NULL UNIQUE, title varchar(120) NOT NULL, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, props json DEFAULT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

步骤一:创建自定义 Generator 命令(文件位置 app/Console/Commands/GenCrudCommand.php)

<?php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;

class GenCrudCommand extends Command
{
    protected $signature = 'crud:generate {table} {--module=}';
    protected $description = '根据表结构一键生成 Model+Request+Controller+Route+Test';

    public function handle()
    {
        $table = $this->argument('table');
        $module = $this->option('module') ?: '';
        $namespace = $module ? 'App\\Http\\Controllers\\' . $module : 'App\\Http\\Controllers';

        $columns = $this->getColumns($table);
        $modelName = Str::studly(Str::singular($table));
        $varName = Str::camel($modelName);

        // 1. 生成 Model
        $this->call('make:model', ['name' => $modelName]);
        $this->fillModel($modelName, $table, $columns);

        // 2. 生成 FormRequest
        $requestClass = $modelName . 'Request';
        $this->call('make:request', ['name' => $requestClass]);
        $this->fillRequest($requestClass, $columns);

        // 3. 生成 Controller
        $controller = $modelName . 'Controller';
        $this->call('make:controller', ['name' => $controller, '--api' => true]);
        $this->fillController($controller, $modelName, $requestClass, $namespace);

        // 4. 生成 routes/api.php 片段
        $this->appendRoute($table, $controller);

        // 5. 生成 PHPUnit 测试
        $test = $modelName . 'Test';
        $this->call('make:test', ['name' => $test]);
        $this->fillTest($test, $table, $varName);

        $this->info("✅ {$table} CRUD 已生成,请执行 php artisan migrate:fresh --seed 测试");
    }

    private function getColumns($table)
    {
        return DB::select("SHOW FULL COLUMNS FROM `{$table}`");
    }

    private function fillModel($model, $table, $cols)
    {
        $file = app_path("Models/{$model}.php");
        $fill = [];
        $hidden = [];
        $casts = [];
        foreach ($cols as $c) {
            $field = $c->Field;
            if (in_array($field, ['id', 'created_at', 'updated_at'])) continue;
            $fill[] = "'{$field}'";
            if ($c->Key === 'UNI' || strpos($field, 'password') !== false) $hidden[] = "'{$field}'";
            if (strpos($c->Type, 'json') !== false) $casts[] = "'{$field}' => 'array'";
            if (strpos($c->Type, 'decimal') !== false) $casts[] = "'{$field}' => 'string'";
        }
        $content = file_get_contents($file);
        $arrayToString = fn($a) => '[' . implode(', ', $a) . ']';
        $replace = [
            'use HasFactory;' => "use HasFactory;\n\n    protected \\$table = '{$table}';\n\n    protected \\$fillable = " . $arrayToString($fill) . ";\n\n    protected \\$hidden = " . $arrayToString($hidden) . ";\n\n    protected \\$casts = " . $arrayToString($casts) . ";"
        ];
        file_put_contents($file, str_replace(array_keys($replace), array_values($replace), $content));
    }

    private function fillRequest($class, $cols)
    {
        $rules = [];
        foreach ($cols as $c) {
            $field = $c->Field;
            if ($field === 'id') continue;
            $rule = [];
            if (strpos($c->Type, 'varchar') !== false) {
                preg_match('/varchar\\((\\d+)\\)/', $c->Type, $m);
                $rule[] = 'max:' . $m[1];
            }
            if ($c->Null === 'NO' && $c->Default === null) $rule[] = 'required';
            if ($c->Key === 'UNI') $rule[] = "unique:{$c->Table},{$field}," . ($c->Field === 'sn' ? '' : ',NULL,id');
            if (strpos($c->Type, 'decimal') !== false) $rule[] = 'numeric|min:0';
            if (strpos($c->Type, 'int') !== false) $rule[] = 'integer|min:0';
            $rules[$field] = implode('|', $rule);
        }
        $stub = <<<STUB
<?php
namespace App\\Http\\Requests;

use Illuminate\\Foundation\\Http\\FormRequest;

class {$class} extends FormRequest
{
    public function authorize(){ return true; }
    public function rules(){ return %s; }
}
STUB;
        file_put_contents(app_path("Http/Requests/{$class}.php"), sprintf($stub, var_export($rules, true)));
    }

    private function fillController($controller, $model, $request, $ns)
    {
        $stub = <<<STUB
<?php
namespace {$ns};

use App\\Http\\Requests\\{$request};
use App\\Models\\{$model};
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;

class {$controller} extends Controller
{
    public function index(Request \\$request): JsonResponse
    {
        \\$q = {$model}::query();
        if (\\$kw = \\$request->input('keyword')) {
            \\$q->where('title', 'like', "%{\\$kw}%");
        }
        \\$list = \\$q->orderBy('id', 'desc')
                   ->paginate(\\$request->input('page_size', 15));
        return response()->json(\\$list);
    }

    public function store({$request} \\$request): JsonResponse
    {
        \\$data = \\$request->validated();
        \\$item = {$model}::create(\\$data);
        return response()->json(\\$item, 201);
    }

    public function show(\\$id): JsonResponse
    {
        return response()->json({$model}::findOrFail(\\$id));
    }

    public function update({$request} \\$request, \\$id): JsonResponse
    {
        \\$item = {$model}::findOrFail(\\$id);
        \\$item->update(\\$request->validated());
        return response()->json(\\$item);
    }

    public function destroy(\\$id): JsonResponse
    {
        {$model}::destroy(\\$id);
        return response()->json(null, 204);
    }
}
STUB;
        $path = app_path("Http/Controllers/{$controller}.php");
        if (!is_dir(dirname($path))) mkdir(dirname($path), 0777, true);
        file_put_contents($path, $stub);
    }

    private function appendRoute($table, $controller)
    {
        $api = <<<API

// Auto generated by crud:generate {$table}
Route::apiResource('{$table}', \\App\\Http\\Controllers\\{$controller}::class);
API;
        File::append(base_path('routes/api.php'), $api);
    }

    private function fillTest($test, $table, $var)
    {
        $model = Str::studly(Str::singular($table));
        $stub = <<<STUB
<?php
namespace Tests\\Feature;

use App\\Models\\{$model};
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;

class {$test} extends TestCase
{
    use RefreshDatabase;

    public function test_paginate()
    {
        {$model}::factory()->count(30)->create();
        \\$res = \\$this->getJson('/api/{$table}?page_size=10');
        \\$res->assertOk()->assertJsonCount(10, 'data');
    }

    public function test_store()
    {
        \\$payload = [
            'sn' => uniqid(),
            'title' => 'iPhone',
            'price' => 6999.00,
            'stock' => 100,
            'props' => ['color' => 'red'],
        ];
        \\$res = \\$this->postJson("/api/{$table}", \\$payload);
        \\$res->assertCreated();
        \\$this->assertDatabaseHas('{$table}', ['title' => 'iPhone']);
    }

    public function test_update()
    {
        \\$item = {$model}::factory()->create();
        \\$res = \\$this->putJson("/api/{$table}/{\\$item->id}", ['price' => 5999]);
        \\$res->assertOk();
        \\$this->assertEquals(5999, \\$item->fresh()->price);
    }

    public function test_destroy()
    {
        \\$item = {$model}::factory()->create();
        \\$res = \\$this->deleteJson("/api/{$table}/{\\$item->id}");
        \\$res->assertNoContent();
        \\$this->assertDatabaseMissing('{$table}', ['id' => \\$item->id]);
    }
}
STUB;
        file_put_contents(base_path("tests/Feature/{$test}.php"), $stub);
    }
}

步骤二:注册命令(已在 app/Console/Kernel.php 的 $commands 数组中自动发现)

步骤三:一键生成

php artisan crud:generate product

步骤四:补 Factory(用于测试)

php artisan make:factory ProductFactory

在 factory 中定义生成规则即可。

步骤五:运行测试

php artisan test

全部绿灯即表示 CRUD 接口符合预期。

拓展思考

  1. 如果字段超过 100 个或包含数十张关联表,如何增量生成?可引入 doctine/migrations-diff + AST 比对,只覆盖变更文件,并自动写入 Git 变更记录。
  2. 多租户 SaaS 场景下,表前缀带 tenant_id,生成器需要自动在 Model 全局作用域加 where('tenant_id', auth()->user()->tenant_id),并在 Migration 加复合索引。
  3. 高并发库存扣减,上述生成代码的 update 存在“超卖”风险,需自动识别 stock 字段并在生成器层面加乐观锁(WHERE stock >= ? AND updated_at = ?)或接入 Redis 原子扣减脚本。
  4. 若公司采用前后端分离且前端用 TypeScript,可同步输出 OpenAPI 3.0 文档及 TypeScript Interface,利用 zircote/swagger-php + swagger-ui 自动托管,减少联调成本。
  5. 对于金融/政府项目,字段级脱敏、接口级审计、操作日志、数据权限(行级、列级)必须内置,生成器应提供钩子(Hook)文件,让安全部门插入合规代码,而不是事后人工修补。