Skip to content

CRUD 开发指南

本文以租户后台新增「产品管理」为例,说明普通业务模块的开发流程。插件应用建议优先阅读 插件开发指南

1. 创建数据表

租户级业务表必须包含 tenant_id,否则无法进入自动租户隔离。

sql
CREATE TABLE `products` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `tenant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
    `name` varchar(100) NOT NULL COMMENT '产品名称',
    `category_id` int unsigned DEFAULT 0 COMMENT '分类ID',
    `price` decimal(10,2) DEFAULT 0 COMMENT '价格',
    `cover` varchar(255) DEFAULT '' COMMENT '封面图',
    `description` text COMMENT '描述',
    `status` tinyint DEFAULT 1 COMMENT '状态 1启用 0禁用',
    `sort` int DEFAULT 0 COMMENT '排序',
    `created_at` datetime DEFAULT NULL,
    `updated_at` datetime DEFAULT NULL,
    `deleted_at` datetime DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品表';

WARNING

Repository 查询必须使用 $this->query(),不能直接用 $this->model 起查询。query() 会自动注入当前租户的 tenant_id 条件。

2. 生成 CRUD 代码

bash
cd server
php think make:crud products --module=product --model=Product

自动生成文件:

后端

  • app/model/product/Product.php
  • app/repository/product/ProductRepository.php
  • app/service/product/ProductService.php
  • app/tenantapi/controller/v1/product/ProductController.php
  • app/tenantapi/validate/v1/product/ProductValidate.php
  • app/tenantapi/route/product.php

前端

当前代码生成器的前端输出路径仍保留旧后台目录约定,这是历史兼容行为。SaaS 项目中应将生成结果迁移到 tenant/src/...,或在生成器模板完成迁移后直接生成到 tenant/

  • tenant/src/api/product.ts
  • tenant/src/views/product/product/index.vue
  • tenant/src/views/product/product/components/ProductForm.vue

3. 注册路由

检查 app/tenantapi/route/product.php 是否存在。ThinkPHP 多应用模式会自动加载 route/ 目录下的路由文件。

生成的租户端接口通常形如:

text
GET    /tenantapi/product/product
GET    /tenantapi/product/product/:id
POST   /tenantapi/product/product
PUT    /tenantapi/product/product/:id
DELETE /tenantapi/product/product/:id

具体以生成的路由文件为准。

4. 添加菜单和权限

在租户后台「系统管理 → 菜单管理」中添加:

  1. 目录菜单:产品管理。
  2. 页面菜单:产品列表,组件路径指向生成的页面。
  3. 按钮权限:新增、编辑、删除、状态切换等。

权限标识要和 Controller 中的 #[Permission(...)] 保持一致。

5. 自定义查询

在 Repository 中使用 $this->query()

php
public function getSearchList(array $params, int $page = 1, int $size = 20): array
{
    $query = $this->query();

    if (!empty($params['keyword'])) {
        $query->whereLike('name', "%{$params['keyword']}%");
    }

    if (isset($params['status']) && $params['status'] !== '') {
        $query->where('status', (int) $params['status']);
    }

    if (!empty($params['category_id'])) {
        $query->where('category_id', (int) $params['category_id']);
    }

    return $query->order('sort', 'asc')
        ->order('id', 'desc')
        ->paginate(['page' => $page, 'list_rows' => $size])
        ->toArray();
}

6. 前端 API

租户后台 API 使用 /tenantapi 前缀:

ts
import { myRequest } from '@/utils/request'

export const productApi = {
    list: (params: any) => myRequest.get('/tenantapi/product/product', { params }),
    show: (id: number) => myRequest.get(`/tenantapi/product/product/${id}`),
    create: (data: any) => myRequest.post('/tenantapi/product/product', data),
    update: (id: number, data: any) => myRequest.put(`/tenantapi/product/product/${id}`, data),
    destroy: (id: number) => myRequest.delete(`/tenantapi/product/product/${id}`),
}

7. 常见扩展

文件上传字段

前端表单使用上传组件:

vue
<Upload v-model="formData.cover" type="image" />

上传前后端会走租户存储配额校验,超限时返回 403 或业务错误。

字典值关联

vue
<DictValue :options="dictOptions.product_type" :value="row.type" />
ts
const { optionsData: dictOptions } = useDictOptions<{
    product_type: any[]
}>(['product_type'])

树形数据

树形数据仍然要基于 $this->query() 取列表,再做树转换:

php
public function getTree(): array
{
    $list = $this->query()
        ->order('sort', 'asc')
        ->select()
        ->toArray();

    return $this->listToTree($list);
}

8. 验证

  1. 表包含 tenant_id 并建立索引。
  2. Repository 查询使用 $this->query()
  3. 路由在 tenantapi 下可访问。
  4. 菜单和按钮权限已分配到角色。
  5. 使用两个租户分别创建数据,确认列表不会互相看到。

基于 Apache-2.0 协议开源