Symfony 与 Doctrine ORM:从实体和迁移到安全的数据库查询

原文:Symfony 文档贡献者,Databases and the Doctrine ORM。本稿按 2026-10-05 读取的 Symfony 8.1 官方正文翻译、整理。原文连同代码示例采用 CC BY-SA 3.0;本中文改编按相同许可提供。原始归属保留,安全性与版本补充均标为编者说明。

Doctrine 把关系数据库中的记录映射成 PHP 对象,让应用通过实体表达领域数据,通过仓库查询实体,通过 Entity Manager 管理对象变化。本文从一个 Product 实体出发,走完连接数据库、生成迁移、保存、读取、更新、删除以及编写查询的过程。

本文范围是关系数据库 ORM。需要底层 SQL 操作时可阅读 Doctrine DBAL,MongoDB 则使用相应的 DoctrineMongoDBBundle。本文的环境边界为 Symfony 8.1、PHP 8.4+;实际项目还需检查已安装的 Doctrine、驱动与数据库版本。所有命令和 PHP 代码均未执行,仅作静态审核。

Doctrine 数据流程图:请求先做鉴权与校验,实体由Entity Manager跟踪,flush才把变更写入数据库,Repository负责查询
未完纪原创示意图。persist 建立管理关系;flush 才发送数据库变更,校验和授权需要由应用显式安排。

一、安装 ORM 与配置数据库

在现有 Symfony 项目中安装 ORM pack,再安装用于生成实体与控制器的开发工具 MakerBundle:

composer require symfony/orm-pack
composer require --dev symfony/maker-bundle

连接信息一般使用 DATABASE_URL。开发环境可以在 .env.local 覆盖默认配置,真实密码不应提交到版本库。下面保留原文的数据库类型示例;用户名、密码、库名和版本号都只是说明写法的占位值,必须替换为目标环境的实际值。旧服务版本不是安装建议。

# .env.local:只演示格式,勿提交真实凭据
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=8.0.37"

# MariaDB:
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=10.5.8-MariaDB"

# Unix 本地 MySQL/MariaDB socket:
# DATABASE_URL="mysql://db_user:db_password@localhost/db_name?serverVersion=8.0.37&unix_socket=/var/run/mysqld/mysqld.sock"

# SQLite:
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"

# PostgreSQL(版本必须按实际服务填写):
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=12.19%20%28Debian%2012.19-1.pgdg120%2B1%29&charset=utf8"

# Oracle:
# DATABASE_URL="oci8://db_user:db_password@127.0.0.1:1521/db_name"

这里把原文 PostgreSQL 版本字符串中的空格、括号和加号做了 URL 编码。127.0.0.1 明确使用 IPv4,避免 localhost 被解析为 ::1 而数据库只监听 IPv4 的不一致。Unix socket 写法只适用于对应的类 Unix 本地服务。

用户名、密码、主机名或库名含 URI 保留字符时也必须正确编码,例如 :、/、@、#、& 等。原文允许使用 urlencode 或环境变量处理器,并提醒使用这类环境变量处理时移除 Doctrine URL 配置中的 resolve: 前缀,改成 url: '%env(DATABASE_URL)%'。

也可以完全避免把凭据拼成 URL,使用独立参数。环境文件中的值用单引号括起,可避免 $ 和 # 被解释。不要同时保留一个会覆盖这些值的 URL 配置:

# config/packages/doctrine.yaml
doctrine:
    dbal:
        user: '%env(DATABASE_USER)%'
        password: '%env(DATABASE_PASSWORD)%'
        host: '%env(DATABASE_HOST)%'
        port: '%env(DATABASE_PORT)%'
        dbname: '%env(DATABASE_NAME)%'
        driver: pdo_mysql

需要对应 PDO/数据库驱动,以及创建数据库的权限。核对连接指向独立开发库后,才考虑执行:

php bin/console doctrine:database:create
php bin/console list doctrine

server_version 或 URL 中的 serverVersion 会影响 Doctrine 生成的 SQL,不应照抄与实际服务不符的值。生产连接宜使用最小权限账户,建库与结构迁移权限可与日常运行权限分离。

二、定义 Product 实体

运行 php bin/console make:entity,实体名填 Product,先增加 name(string,长度 255,不可为 null)和 price(integer,不可为 null)。Maker 会生成 src/Entity/Product.php 与相应仓库。

实体只是 PHP 类;#[ORM\Entity] 声明它受 ORM 管理,#[ORM\Column] 把属性映射为列,主键属性增加 Id、GeneratedValue。价格使用整数是示例设计,例如把 1999 解释为最小货币单位,可避免二进制浮点直接存金额的舍入问题,但业务仍需记录币种与精度。

稍后新增 description 后,完整实体可整理如下。这里补全访问器和基础业务校验约束,是相对原文省略片段的编者整理;nullable PHP 初始值不等于数据库列允许 null。

<?php
// src/Entity/Product.php
namespace App\Entity;

use App\Repository\ProductRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    private ?string $name = null;

    #[ORM\Column]
    #[Assert\NotNull]
    #[Assert\PositiveOrZero]
    private ?int $price = null;

    #[ORM\Column(type: Types::TEXT)]
    #[Assert\NotBlank]
    private ?string $description = null;

    public function getId(): ?int { return $this->id; }
    public function getName(): ?string { return $this->name; }
    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }
    public function getPrice(): ?int { return $this->price; }
    public function setPrice(int $price): self
    {
        $this->price = $price;
        return $this;
    }
    public function getDescription(): ?string { return $this->description; }
    public function setDescription(string $description): self
    {
        $this->description = $description;
        return $this;
    }
}

可向 make:entity 传 --with-uuid 或 --with-ulid,把主键生成成相应 Uid 类型。生成器只是辅助工具,字段、方法和映射仍由开发者维护。表名或列名应避免 GROUP、USER 等 SQL 保留字;可通过 #[ORM\Table(name: 'groups')] 或列的 name 参数改名。

原文还保留 MySQL 5.6 及更早版本 InnoDB 767 字节索引前缀限制的历史说明:utf8mb4 下 255 字符的唯一索引列可能超限,示例建议长度 190。这是兼容旧系统的说明,不是建议新项目继续使用该旧版本。

字段类型:枚举、UUID、ULID 与 DatePoint

Doctrine 支持数值、字符串、日期、JSON、二进制等映射。使用 PHP 枚举时,必须是具有标量值的 backed enum,数据库保存的是其标量值:

<?php
// src/Enum/Suit.php
namespace App\Enum;

enum Suit: string
{
    case Hearts = 'H';
    case Diamonds = 'D';
    case Clubs = 'C';
    case Spades = 'S';
}
use App\Enum\Suit;
use Doctrine\ORM\Mapping as ORM;

// 位于实体类内:
#[ORM\Column(enumType: Suit::class)]
public Suit $suit;

Symfony 的 UuidType 和 UlidType 在数据库支持时使用原生 GUID 类型,否则使用 16 字节二进制。原文的字段写法分别是:

use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\Ulid;

// 实体字段片段;需在构造器或业务流程中初始化。
#[ORM\Column(type: UuidType::NAME)]
private Uuid $sku;

#[ORM\Column(type: UlidType::NAME)]
private Ulid $identifier;

Clock 组件的 DatePoint 有三种映射:date_point 对应 datetime_immutable,day_point 对应 date_immutable,time_point 对应 time_immutable。类型提示为 DatePoint 时可自动推断 date_point,也可显式指定:

use Symfony\Component\Clock\DatePoint;

// 实体字段片段:
#[ORM\Column]
private DatePoint $createdAt;

#[ORM\Column(type: 'date_point')]
private DatePoint $updatedAt;

#[ORM\Column(type: 'day_point')]
public DatePoint $releaseDate;

#[ORM\Column(type: 'time_point')]
public DatePoint $openingTime;

需要 Clock 组件的可测试时间能力时,可选 DatePoint;不需要这些能力时,datetime_immutable 已能表示不可变时间值。以上补充类型并非要求全部加入 Product,使用之前要完成相应字段初始化、依赖和迁移。

三、用迁移同步数据库结构

创建实体不会自动创建数据库表。先生成迁移,再审查生成的 SQL,然后才能对已确认的目标数据库执行:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

make:migration --formatted 可生成格式更整齐的文件。迁移命令只执行尚未应用的迁移;系统用迁移版本记录跟踪进度。应提交迁移文件,让各环境按相同变更序列推进。迁移可能建表、改列或删除数据,本文没有运行这些命令;生产执行前需要备份、审核锁表与回退方案。

后续再次运行 make:entity,选择 Product,增加不可为空的 text 字段 description。Maker 会增加属性、getter 与 setter。再次生成的迁移可能包含:

ALTER TABLE product ADD description LONGTEXT NOT NULL

这是原文用于解释差异的 MySQL 风格 SQL,不是通用跨数据库语句。SQLite 为已有表添加“无默认值的非空列”会报错,原文建议先允许 null。已有数据的其他数据库同样应考虑回填:先增加可空列,按业务规则写入旧行,再在后续迁移收紧为非空。不能把“改成 nullable”当成已经满足业务完整性的永久解决。

手动加属性后,可用 php bin/console make:entity --regenerate 补访问器;加 --overwrite 会重写已有 getter/setter,执行前应检查自定义逻辑与版本差异。

四、持久化:persist 不发 SQL,flush 才写入

Entity Manager 可通过 EntityManagerInterface 自动注入。persist($product) 把新对象纳入管理,此时尚未执行 INSERT;flush() 检查所有受管理对象的变化并提交相应 SQL。新对象需要 INSERT,已载入的对象变更通常需要 UPDATE。

原教程用没有限定 HTTP 方法的 /product 路由演示创建,浏览器访问即可写库。这适合说明 ORM 操作,却不适合作为真实写接口。下面修订为 POST,增加权限与 CSRF 检查,先校验再持久化。它仍以固定示例产品说明流程,不包含面向任意输入的完整产品表单。

<?php
// src/Controller/ProductController.php
namespace App\Controller;

use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class ProductController extends AbstractController
{
    #[Route('/product', name: 'create_product', methods: ['POST'])]
    public function createProduct(
        Request $request,
        EntityManagerInterface $entityManager,
        ValidatorInterface $validator,
    ): Response {
        $this->denyAccessUnlessGranted('ROLE_PRODUCT_EDITOR');

        $token = (string) $request->request->get('_token', '');
        if (!$this->isCsrfTokenValid('product_create', $token)) {
            throw $this->createAccessDeniedException('Invalid CSRF token');
        }

        $product = new Product();
        $product->setName('Keyboard')
            ->setPrice(1999)
            ->setDescription('Ergonomic and stylish!');

        $errors = $validator->validate($product);
        if (count($errors) > 0) {
            return $this->json(['error' => 'Invalid product data'], 400);
        }

        $entityManager->persist($product);
        $entityManager->flush();

        return $this->json(['id' => $product->getId()], 201);
    }
}

这段修订依赖已配置的安全系统、授权角色以及 CSRF 组件。浏览器表单需提交 csrf_token('product_create') 生成的令牌;模板示例如下,创建产品的固定值仍只用于教学:

<form action="{{ path('create_product') }}" method="post">
  <input type="hidden" name="_token" value="{{ csrf_token('product_create') }}">
  <button type="submit">创建示例产品</button>
</form>

如果是无 Cookie 身份凭据的 API,应按实际认证方案设计防护,而非不加区分地照抄表单 CSRF。真正从请求读取字段时,还需限制字段、数据类型、长度、数值范围及业务授权,避免任意字段批量绑定。

原文用只读 SQL 验证创建结果:

php bin/console dbal:run-sql 'SELECT * FROM product'

在 Windows 的非 PowerShell shell 中可改用双引号。命令只是验证方法,不表示本文已经插入记录。flush() 可能失败,需按所用 ORM 版本的异常类型与事务约定处理;不能在数据库操作失败后仍返回创建成功,也不能向外暴露数据库连接信息。

五、自动推断校验不等于业务校验

Symfony Validator 可从 Doctrine 元数据推断一部分约束,需要配置 Validator 的 auto_mapping 范围。注意它与后面控制器实体解析器的 auto_mapping 是不同配置。

Doctrine 元数据 可能推断的约束 边界
nullable=false NotNull 需要 PropertyInfo;不等于非空字符串
type Type 需要 PropertyInfo;不表达业务值域
unique=true UniqueEntity 仍应保留数据库唯一约束处理并发
length Length 只限制长度

调用 $validator->validate($product) 才执行相应校验流程。Form 组件与 API Platform 因内部使用 Validator,也可受益于自动约束。自动映射不能表达“价格不可为负”“名称不得全为空白”等全部业务规则,因此前面的实体显式补充了约束。

六、通过 Repository 查询对象

查询某类实体时,通常通过它的 Repository。可以从 Entity Manager 获取,也可以直接把 ProductRepository 注入控制器。找不到记录时,应用应返回 404,而不是继续解引用 null。

use App\Repository\ProductRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

// 以下方法放在继承 AbstractController 的控制器中。
#[Route('/product/{id}', name: 'product_show', methods: ['GET'],
    requirements: ['id' => '\\d+'])]
public function show(ProductRepository $products, int $id): Response
{
    $product = $products->find($id);
    if ($product === null) {
        throw $this->createNotFoundException('Product not found');
    }
    return $this->render('product/show.html.twig', ['product' => $product]);
}
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>

原文还展示把 $product->getName() 直接拼入 new Response(...) 的写法。如果名称可被用户控制且响应按 HTML 解释,这会引入存储型 XSS 风险。这里改成默认自动转义的 Twig 输出;不要无故追加 |raw。也可按接口需求使用 JSON 响应。

$repository = $entityManager->getRepository(Product::class);

$product = $repository->find($id);
$product = $repository->findOneBy(['name' => 'Keyboard']);
$product = $repository->findOneBy(['name' => 'Keyboard', 'price' => 1999]);

$products = $repository->findBy(
    ['name' => 'Keyboard'],
    ['price' => 'ASC'],
);
$allProducts = $repository->findAll();

find() 按主键查一项;findOneBy() 按条件查一项;findBy() 返回匹配列表并可指定排序;findAll() 返回全部。数据量大时不要无界读取全部记录,应分页或限制结果数。

开发环境的调试工具栏会显示 SQL 数量与耗时,点击 Doctrine 项可在 Profiler 查看实际查询。需要时可安装 composer require --dev symfony/profiler-pack。查询数偏多可帮助发现 N+1 等问题,但计数颜色不是性能诊断结论;Profiler 不应向未授权的生产用户开放。

七、让 EntityValueResolver 自动取得实体

控制器参数直接声明 Product $product,且路由有 {id} 时,解析器可按主键调用 find()。没找到会自动产生 404。把参数改为 ?Product $product 则可自己处理缺失情况。

#[Route('/product/{id}', methods: ['GET'])]
public function show(Product $product): Response
{
    return $this->render('product/show.html.twig', ['product' => $product]);
}

要按其他字段查找,可使用 {param:argument} 语法,如 /product/{slug:product},含义是把 slug 条件交给 product 参数的解析。这里的 slug 必须先在实体和数据库中真实存在;前面最小 Product 尚未定义它。

也可以通过 MapEntity 显式映射,占位符名是键,实体属性名是值:

use Symfony\Bridge\Doctrine\Attribute\MapEntity;

#[Route('/product/{product_slug}', methods: ['GET'])]
public function show(
    #[MapEntity(mapping: ['product_slug' => 'slug'])]
    Product $product,
): Response {
    return $this->render('product/show.html.twig', ['product' => $product]);
}

全局开启 doctrine.orm.controller_resolver.auto_mapping=true 时,会尝试用所有与实体属性同名的路由通配符做 findOneBy(),忽略不是属性的通配符。原文已不推荐这种较隐式的行为。特定参数可用 #[MapEntity(disabled: true)] 关闭,例如从 #[CurrentUser] 获取的用户不应再次按路由解析。

用表达式或接口定制解析

MapEntity(expr: ...) 可以使用 ExpressionLanguage。表达式中的 repository 是相应实体仓库,路由通配符以及 request 可作为变量:

#[Route('/product/{product_id}', methods: ['GET'])]
public function show(
    #[MapEntity(expr: 'repository.find(product_id)')]
    Product $product,
): Response {
    return $this->render('product/show.html.twig', ['product' => $product]);
}

仓库方法也可返回多个实体,此时参数声明为 iterable,并明确实体 class;原文示例为按作者查最多十篇文章:

#[MapEntity(
    class: Post::class,
    expr: 'repository.findBy({"author": author_id}, {}, 10)',
)]
iterable $posts

这是控制器参数片段,假定已经定义 Post 实体及 {author_id} 路由。多个参数可分别映射,例如 product 用主键自动取得,comment 用 repository.find(comment_id)。编者补充:仅分别查到两条记录,并不能证明评论属于这个产品,也不能证明当前用户有权读取它们;关联条件和对象级授权需补齐。

原文还用 request.query.get("sort", "DESC") 直接决定 createdAt 的排序方向。它说明表达式能读取请求,但真实接口应先把方向限制为 ASC/DESC,不能把任意外部字符串当作可信查询结构。更适合在仓库方法内完成白名单和产品关联条件,再由表达式调用。

若 Product 实现 ProductInterface,可配置 resolve_target_entities,让控制器依赖接口,并对参数加 #[MapEntity]。这样可把控制器与具体实体实现解耦,但解析器仍需要已配置的实际目标实体。

MapEntity 选项 作用
id 指定哪个路由参数是主键,如 id: ‘product_id’
mapping 指定路由参数到实体属性的映射,用于 findOneBy
stripNull 为 true 时,不把 null 条件传给 findOneBy;需注意这可能放宽查询
objectManager 选择非默认对象管理器,如 ‘foo’
evictCache 强制从数据库读取而非缓存
disabled 停止解析该参数
message 定制开发环境 NotFoundHttpException 消息,生产环境不显示该消息

八、更新和删除:对象变化在 flush 时提交

从 Doctrine 查得的对象已经处于管理状态。修改后调用 flush() 即可,不必再 persist()。原文以无方法限制的编辑路由说明三个步骤;这里仅保留持久化核心,并明确要求放在已鉴权、已做对象授权和必要 CSRF 防护的 POST/PATCH 处理器中。

$product = $entityManager->getRepository(Product::class)->find($id);
if ($product === null) {
    throw $this->createNotFoundException('Product not found');
}

// 在此之前完成对象级授权、输入解析与校验。
$product->setName('New product name!');
$entityManager->flush();

return $this->redirectToRoute('product_show', [
    'id' => $product->getId(),
]);

删除调用 remove() 后再 flush()。前者只是登记删除意图,后者才执行 DELETE。以下是有副作用的原理示例,本文没有执行;真实入口还需核对级联删除、关联约束和恢复要求。

$entityManager->remove($product);
$entityManager->flush();

九、把复杂查询放进仓库

Maker 生成的 ProductRepository 继承 ServiceEntityRepository,构造器通过 ManagerRegistry 与 Product 类建立联系。实体上的 repositoryClass 使 getRepository(Product::class) 返回这个仓库。

例如查询价格高于某值的产品,用 DQL 描述 PHP 实体与属性,而非底层表名:

<?php
// src/Repository/ProductRepository.php
namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    /** @return Product[] */
    public function findAllGreaterThanPrice(int $price): array
    {
        return $this->getEntityManager()->createQuery(
            'SELECT p FROM App\Entity\Product p
             WHERE p.price > :price
             ORDER BY p.price ASC'
        )->setParameter('price', $price)->getResult();
    }
}

调用 $repository->findAllGreaterThanPrice(1000) 返回 Product 对象数组。查询中的值通过 :price 与 setParameter() 绑定;不要把请求值直接拼入 DQL 字符串。

Query Builder:根据条件组合查询

查询结构随 PHP 条件改变时,可用 Query Builder。下面是与前面最小实体匹配的版本;相对原文,默认片段移除了尚未定义的 available 条件。

public function findAllGreaterThanPriceWithBuilder(int $price): array
{
    return $this->createQueryBuilder('p')
        ->where('p.price > :price')
        ->setParameter('price', $price)
        ->orderBy('p.price', 'ASC')
        ->getQuery()
        ->getResult();
}

原文另外提供 $includeUnavailableProducts 分支:为 false 时加 $qb->andWhere('p.available = TRUE')。使用前必须给 Product 增加 boolean 的 available 映射、默认值、访问器和迁移,并处理旧行回填,否则查询引用不存在的字段。需要只取一项时可对 Query 使用 setMaxResults(1)->getOneOrNullResult()。

Query Builder 不会把所有字符串自动变安全。值应参数化;动态字段名、排序方向和其他查询结构须使用开发者定义的白名单,而不是对任意用户字符串调用 orderBy()。

直接 SQL 返回原始行,不会自动变成实体

public function findRowsGreaterThanPrice(int $price): array
{
    $connection = $this->getEntityManager()->getConnection();
    $sql = 'SELECT * FROM product p
            WHERE p.price > :price
            ORDER BY p.price ASC';

    return $connection->executeQuery($sql, ['price' => $price])
        ->fetchAllAssociative();
}

这段使用 DBAL Connection 执行参数化 SQL,得到的是关联数组的数组,不是受 Entity Manager 跟踪的 Product 实体。修改返回数组不会自动触发数据库 UPDATE。需要从原生 SQL 映射实体时,应使用 Doctrine 的 NativeQuery 等专门机制;不要混淆 DQL 结果与 SQL 数据行。

十、关联、扩展与测试的下一步

Doctrine 支持 ManyToOne、OneToMany、OneToOne 与 ManyToMany,关系的拥有端、级联与懒加载需要另行设计。原文将这些内容指向关联教程,配置细节指向Doctrine 配置参考,数据库测试指向仓库测试指南。

自动记录 createdAt、翻译等常见需求,可查看 Doctrine 社区扩展,并通过 StofDoctrineExtensionsBundle 集成。需要时进一步阅读 Doctrine 事件、自定义 DQL 函数以及多 Entity Manager/连接配置。它们属于后续主题,本稿没有把这些链接伪装成已执行的测试或完整实现。

本次静态审核确认原文使用参数绑定的 DQL 与 SQL 示例没有直接拼接 price;同时识别了无方法限制的写路由、未转义 HTML 响应、直接使用外部排序参数、缺失 available 字段,以及向已有数据添加非空列的实际问题。示例密码是占位文本,没有发现真实凭据;这不等于完整应用不存在漏洞。本文未连接数据库、未执行迁移、未测试权限或性能。

依据 Symfony 官方文档作中文翻译与改编,包含上述明确标出的安全修改;按 CC BY-SA 3.0 共享,不表示 Symfony 或其贡献者背书。翻译整理、安全性补充及原创示意图:未完纪。原文及代码示例的许可说明见 Symfony 官方文档许可说明;CC BY-SA 3.0 完整法律条款适用于本中文改编及原创示意图。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容