第四章 字段

我们在第一部分第三章时,已经认识过常见的几种字段类型以及它们的使用方法和应用场景,本章我们将深入了解字段的组成,并学会如何创建一个新的类型的字段.

字段的本质

首先,我们需要认识到,字段的本质其实也是一个类.我们在模型中定义的字段属性, 是通过Python中的描述符协议添加到属性实例中的,这一点与其他的流行框架(Django, Flask)并无不同.

其次,odoo中的字段类由一个元类派生而来,这个字段的元类(MetaField),定义了两个规则:

  • 每个字段都必须有一个type属性,用来标识字段的类型
  • 字段的属性被分成了关联性属性描述性属性两类
  • 每个特定类型的字段的_related_开头的属性将被添加到related_attrs属性中
  • 每个特定类型的字段的_description_开头的属性将被添加到description_attrs属性中

关联性属性指的是在字段的定义中关联了其他对象的字段, 那么字段的属性将被存储在_related_开头的属性中. 描述性属性指的是常规的字段定义中的属性,通常是我们常见的属性,例如string,help,model_name等属性.

字段属性及其作用

我们在第一部分中简要的介绍过常见的几种属性及其作用,但是并不完整,下面我们将详细了解字段的属性和作用.

type

字段中最重要的属性, 在元类中强制规定了每个字段必须要定义的属性. 通常用来标识字段的类型,例如:

class Char(_String):
    type = 'char'
    ...

源码

此属性通常在定义字段类型的时候使用,我们在模型中实例化的字段属性已经由具体的字段类实现,因此不用关心此属性的值.

为了方便展示,我们这里创建了一个图书模块的shell环境:

book = self.env['book_store.book'].create({'name':"TEST"})

relational

标识改字段是否为关系字段.

>>>book._fields['name'].relational
False

translate

标识改字段是否可以进行翻译

>>book._fields['name'].translate
False

对于可翻译字段,系统中在字段右上角显示一个当前语言简写(例如中文,ZH)的按钮,点击之后,用户可以对该字段的翻译值进行翻译。拥有翻译值的字段,在打印报告中将跟随业务伙伴的语言设置而显示。(中文客户看到中文,西语客户看到西语)。 16.0+开始,使用translate属性为True的字段在postgresq中将使用jsonb的字段类型替代vary char。

@property
def column_type(self):
   return ('jsonb', 'jsonb') if self.translate else ('varchar', pg_varchar(self.size))

column_type

数据库中的字段类型

>>book._fields['name'].column_type
('varchar', 'VARCHAR')

column_type是个与元组, 其第一个元素为字段的标识, 第二个元素为字段在数据库中的类型.

column_format

数据库查询语句中的占位符, 默认为%s.

>>book._fields['name'].column_format
'%s'

company_dependent

company_dependent属性用来标识本字段独立于各个公司, 该属性会将字段的值存储导ir.property对象中, 并不像常规字段一样存储在模型表中. 比较典型的一个列子,就是产品的成本价字段, 它独立于每个公司, 即每个公司的产品成本值都可以不同。

company_dependent属性并非所有字段都可以使用,只有如下列表中的字段类型可以使用company_dependent

char
float
boolean
integer
text
binary
many2one
date
datetime
selection

html属性不被支持,虽然社区有人提出要加入html支持,官方也应允,但事实是直到现在(16.0),依旧不支持Html属性。详情见PR

欧姆网络的客户可以正常使用html属性,我们已经在基础解决方案中加入了此特性。

column_cast_from

可以被转换的类型

>>book._fields['name'].column_cast_from
('text',)

column_cast_from也是一个元组,其值是可以转换成此类型的其他数据库类型.

write_sequence

write方法调用时字段的写入顺序, 默认为0

>>book._fields['name'].write_sequence
0

args

用来初始化字段的参数

_module

字段的模块名称

>>> book._fields['name']._module
'book_store'

_modules

定义了该字段的魔窟列表

>>> book._fields['name']._modules
('book_store',)

_modules是个元组, 其内容是所有定义了该字段的模块列表

model_name

model_name记录了该字段所属于的模型对象.(通常为M2O类型)

comodel_name

comodel_name属性记录了本字段所关联的对象模型.(关系型字段)

_setup_done

字段是否挂载完成, 默认为True

>>> book._fields['name']._setup_done
True

_sequence

字段的排序

>>> book._fields['name']._setup_done
True

_base_fields

15.0新增属性

重载字段的集合. 如果有多个模块同时定义了一个字段,那么这个字段的处理逻辑是将他们合并起来, 而_base_fields的作用就是记录这些重载的字段类型. 此字段对于toplevel的字段来说,但字段挂载完成以后,就会被置空以释放内存,因此对于direct和toplevel字段, 挂载后的值一直是空

>>> book._fields['serial_name']._base_fields
()

_extral_keys

15.0新增属性

设置字段时传入的未知字段.

_direct

15.0新增属性

是否可以被"直接"使用(共享的).

>>> book._fields['serial']._direct
True

_toplevel

15.0新增属性

>>> book._fields['name']._toplevel
False
>>> book._fields['serial_name']._toplevel
True

toplevel指的是只挂载一次, 一旦挂载完成将丢弃args和_base_fields内容, 因为他们不再需要这些数据了.

states

状态属性, 可以根据此属性设置改字段是否为只读必填项. states属性的值是一个字典,key为readonly或required, 值是相应的state字段中的状态和布尔值组成的元组列表.

name = fields.Char("名称" ,readonly=True,states={'draft':[('readonly',False)]})

inverse

inverse是字段的一个逆向关联属性,其作用是将本字段关联的对象逆向关联到记录中。

我们知道,如果一个字段定义了compute属性还没有声明store=True, 那么它在视图中的表现将是一个只读字段。但是如果字段同时又定义了inverse属性,那么本字段的默认行为将会被设置为可以编辑的。

if attrs.get('compute'):
    # by default, computed fields are not stored, computed in superuser
    # mode if stored, not copied (unless stored and explicitly not
    # readonly), and readonly (unless inversible)
    attrs['store'] = store = attrs.get('store', False)
    attrs['compute_sudo'] = attrs.get('compute_sudo', store)
    if not (attrs['store'] and not attrs.get('readonly', True)):
        attrs['copy'] = attrs.get('copy', False)
    attrs['readonly'] = attrs.get('readonly', not attrs.get('inverse'))

下面我们看一个inverse的例子:

upper = fields.Char(compute='_compute_upper',
                    inverse='_inverse_upper',
                    search='_search_upper')

@api.depends('employee_id')
def _compute_upper(self):
    for rec in self:
        rec.upper = rec.employee_name.upper() if rec.employee_name else False

def _inverse_upper(self):
    for rec in self:
        rec.employee_name = rec.upper.lower() if rec.upper else False

=================================
1. Employee: zhangsan Upper: ZHANGSAN
2. Employee: zhangsan123 Upper: ZHANGSAN123

可以看出,inverse属性的用法是用来逆向计算关联记录的值,是计算字段的一种回溯方法。

其他重要属性

  • store: store属性用来标识该字段是否存储在数据库中。

  • compute: compute属性用来指明计算方法,根据定义,如果一个计算字段进行了存储,那么它默认将会使用管理员权限(compute_sudo)进行计算。

  • compute_sudo:布尔值,compute_sudo属性表明该字段是否应该使用超级用户权限进行计算。

  • related_sudo: 默认情况下,关联字段(realted)使用管理员权限进行计算,并且不存储在数据库中。而relate_sudo属性就是用来制定关联字段是否进行提权计算的。

字段值的转换

字段类型中定义了一系列的"转换方法"来将字段的值转成不同的格式,以适应不同的应用场景. 字段类本身只是简单定义了这样一系列的方法, 具体到特定的字段类型时, 需要该类型的字段根据自身的需求重载这些方法以到达合适使用的目的.

convert_to_column

def convert_to_column(self, value, record, values=None, validate=True):
    if value is None or value is False:
        return None
    return pycompat.to_text(value)

convert_to_column方法的作用是将value重新格式化为SQL可以使用的文本.

convert_to_record

def convert_to_record(self, value, record):
    """ Convert ``value`` from the cache format to the record format.
    If the value represents a recordset, it should share the prefetching of
    ``record``.
    """
    return False if value is None else value

convert_to_record方法作用是将值从缓存的格式转换为记录集可以使用的格式.

convert_to_read

def convert_to_read(self, value, record, use_name_get=True):
    """ Convert ``value`` from the record format to the format returned by
    method :meth:`BaseModel.read`.

    :param bool use_name_get: when True, the value's display name will be
        computed using :meth:`BaseModel.name_get`, if relevant for the field
    """
    return False if value is None else value

convert_to_read方法的作用是将值从记录集的格式转换为可以被ORM中的read方法返回的值. 该方法接受一个额外的参数user_name_get, 如果为True,那么字段的显示名称将使用name_get方法返回的值.

convert_to_write

def convert_to_write(self, value, record):
    """ Convert ``value`` from any format to the format of method
    :meth:`BaseModel.write`.
    """
    cache_value = self.convert_to_cache(value, record, validate=False)
    record_value = self.convert_to_record(cache_value, record)
    return self.convert_to_read(record_value, record)

convert_to_write方法的作用是将任何格式的值,转换为可以被write方法使用的格式.

convert_to_onchange

def convert_to_onchange(self, value, record, names):
    """ Convert ``value`` from the record format to the format returned by
    method :meth:`BaseModel.onchange`.

    :param names: a tree of field names (for relational fields only)
    """
    return self.convert_to_read(value, record)

convert_to_onchange方法的作用是将值转换为可以被onchang方法返回的值格式.

convert_to_export

def convert_to_export(self, value, record):
    """ Convert ``value`` from the record format to the export format. """
    if not value:
        return ''
    return value

convert_to_export方法作用是将值转换为可以被导出的格式.

convert_to_display_name

def convert_to_display_name(self, value, record):
    """ Convert ``value`` from the record format to a suitable display name. """
    return ustr(value)

convert_to_display_name方法的作用是将值转为为合适的可以用来显示名称的格式.

描述符协议

我们在使用self.x的方式读取记录中某个字段的值的时候,实际上是使用了Python的描述符协议,Odoo把字段的获取逻辑也封装在了描述符协议中。接下来,我们详细看一下字段的读取过程。

odoo在读取某个字段时,会执行如下的逻辑:

if record is None:
    return self         # the field is accessed through the owner class

if not record._ids:
    # null record -> return the null value for this field
    value = self.convert_to_cache(False, record, validate=False)
    return self.convert_to_record(value, record)
  1. 先判断当前记录是否为None,如果是None,则直接返回。

  2. 如果当前记录是空记录(没有ids),则返回一个空值。这就是我们之前测试的例子中,为什么有时候会出现计算字段的方法不会被触发的原因。

     env = record.env
    
     # only a single record may be accessed
     record.ensure_one()
    
     if self.compute and (record.id in env.all.tocompute.get(self, ())) \
             and not env.is_protected(self, record):
         # self must be computed on record
         if self.recursive:
             recs = record
         else:
             ids = expand_ids(record.id, env.all.tocompute[self])
             recs = record.browse(itertools.islice(ids, PREFETCH_MAX))
         try:
             self.compute_value(recs)
         except (AccessError, MissingError):
             self.compute_value(record)
    
  3. 如果字段是存储的计算字段,则重新计算字段的逻辑(recompute)。

     try:
         value = env.cache.get(record, self)
     except KeyError:
         # real record
         if record.id and self.store:
             recs = record._in_cache_without(self)
             try:
                 recs._fetch_field(self)
             except AccessError:
                 record._fetch_field(self)
             if not env.cache.contains(record, self) and not record.exists():
                 raise MissingError("\n".join([
                     _("Record does not exist or has been deleted."),
                     _("(Record: %s, User: %s)") % (record, env.uid),
                 ]))
             value = env.cache.get(record, self)
    
         elif self.compute:
             if env.is_protected(self, record):
                 value = self.convert_to_cache(False, record, validate=False)
                 env.cache.set(record, self, value)
             else:
                 recs = record if self.recursive else record._in_cache_without(self)
                 try:
                     self.compute_value(recs)
                 except (AccessError, MissingError):
                     self.compute_value(record)
                 value = env.cache.get(record, self)
    
         elif (not record.id) and record._origin:
             value = self.convert_to_cache(record._origin[self.name], record)
             env.cache.set(record, self, value)
    
         elif (not record.id) and self.type == 'many2one' and self.delegate:
             # special case: parent records are new as well
             parent = record.env[self.comodel_name].new()
             value = self.convert_to_cache(parent, record)
             env.cache.set(record, self, value)
    
         else:
             value = self.convert_to_cache(False, record, validate=False)
             env.cache.set(record, self, value)
             defaults = record.default_get([self.name])
             if self.name in defaults:
                 # The null value above is necessary to convert x2many field values.
                 # For instance, converting [(4, id)] accesses the field's current
                 # value, then adds the given id. Without an initial value, the
                 # conversion ends up here to determine the field's value, and this
                 # generates an infinite recursion.
                 value = self.convert_to_cache(defaults[self.name], record)
                 env.cache.set(record, self, value)
    
  4. 从缓存中尝试读取相应的字段值,如果命中异常,则执行下面的逻辑:

    • 对于真实存储的字段,先从缓存中找出没有缓存该字段的记录集, 然后执行_fetch_fields方法,重新获取字段值, 从更新后的缓存中返回值
    • 如果是计算值, 则重新触发计算逻辑后, 从缓存中返回值.
    • 如果是关联字段, 则从关联字段中获取值后,更新到缓存中
    • 如果是Many2one类型的委托字段, 则从委托对象中更新字段,并更新到缓存中

非关系字段

字段类型可以分为非关系字段(No-Relational Field)关系字段(Relational Field)两种

非关系字段也被称作标量字段(Scalar Field)

非关系字段指的是像文本字段,整型,浮点型,货币等可以直接存储而又没有对外关系的字段类型.与之对应的, 关系字段则是指M2O, O2M, M2M等与其他表数据有相互关系的字段.

下面我们首先来一下常见的几种非关系字段:

Float

浮点数类型, 我们在第一部分的时候已经简单介绍过它的用法了. 现在我们来看一下它的定义.

浮点数类型最显著的特征莫过于它支持精度设置, 我们也知道可以通过digits属性来设置它的精度值. 但有一种情况是我们前面没有提到过的, 如果我们想要在计算中使用高精度的值(例如7位小数)以保证运算的准确性, 但是我们在系统中的显示结果却想要使用低精度的值(为了美观),这是时候如何处理呢?

这里提供了一种解决方案是在视图中使用digits属性来重新指定精度.例如:

<field name="price" digits="[16,3]"/>

Float类额外为我们提供了三个方法:

  • Float.round(): 使用给定的精度进位
  • Float.is_zero(): 给定的精度下值是否等于0
  • Float.compare(): 给定的精度下两个浮点数是否相等.

Float的digits属性是如何既支持文本又支持元组类型的呢?

这是因为Float在使用中会在内部使用get_digits方法来获取字段的精度:

def get_digits(self, env):
    if isinstance(self._digits, str):
        precision = env['decimal.precision'].precision_get(self._digits)
        return 16, precision
    else:
        return self._digits

我们从get_digits的定义中可以看出, 如果digits属性是文本, 那么odoo会去系统设置里找到指定的精度设置的值来完成精度设置, 而如果不是文本,则会使用指定的精度设置.

Date

Date和Datetime是我们在系统中经常使用到的关于日期类型的字段。

Selection

Selection的基本使用方法我们在第一部分第一章的时候已经介绍过了,这里我们会详细了解Selection的具体实现方式。

_description_selection

字段模型中有一个方法_description_selection用来获取Seleciton类型字段的值。

def _description_selection(self, env):
    """ return the selection list (pairs (value, label)); labels are
        translated according to context language
    """
    selection = self.selection
    if isinstance(selection, str) or callable(selection):
        return determine(selection, env[self.model_name])

    # translate selection labels
    if env.lang:
        return env['ir.model.fields'].get_field_selection(self.model_name, self.name)
    else:
        return selection

此方法返回一个值和描述组成的元素列表,如果环境变量env中指定的语言,返回的值中也会使用指定的语言。

为了方便我们在研发中获取Selection的值的描述,我们在欧姆基础解决方案中添加了一个公共方法get_selection_desc,可以方便使用:

test = fields.Selection([('a','A'),('b','B')])

self.test = 'a'
res = self.get_selection_desc(self.test)
--------
'A'

从13.0+起,Selection类型的字段会在数据库中新建一个表,用来存储模型的Selection值和关系。我们在修改了了Selection字段的字段类型后,通常会碰到下面的这个异常:

Selection的存储方式

从13.0+开始,Selection字段被存储到了一个专用的表中: ir_model_fields_selection,假设我们在一开始定义了一个Selection的字段,后来又希望把这个字段变更为其他类型时, 会发生异常:

doo.addons.base.models.ir_model: Deleting 1267@ir.model.fields.selection (mommy_delivery_yunex.selection__stock_picking__yunex_product_id__pddbzphr) .

Traceback (most recent call last):
  File "/home/kevin/odoo/odoo-15.0/odoo/modules/registry.py", line 87, in new
    odoo.modules.load_modules(registry, force_demo, status, update_module)
  File "/home/kevin/odoo/odoo-15.0/odoo/modules/loading.py", line 515, in load_modules
    env['ir.model.data']._process_end(processed_modules)
  File "/home/kevin/odoo/odoo-15.0/odoo/addons/base/models/ir_model.py", line 2307, in _process_end
    self._process_end_unlink_record(record)
  File "/home/kevin/odoo/odoo-15.0/odoo/addons/base/models/ir_model.py", line 2236, in _process_end_unlink_record
    record.unlink()
  File "/home/kevin/odoo/odoo-15.0/odoo/addons/base/models/ir_model.py", line 1411, in unlink
    self._process_ondelete()
  File "/home/kevin/odoo/odoo-15.0/odoo/addons/base/models/ir_model.py", line 1459, in _process_ondelete
    ondelete = (field.ondelete or {}).get(selection.value)
AttributeError: 'str' object has no attribute 'get'

处理这个异常有两种方式:

  1. 变更Selection类型前卸载模块
  2. 手动在ir_model_fields_selection表中将提示的id删除

因为selection的值有可能会有很多个,一个一个删不太现实,我们可以使用下面的sql进行快速删除:

delete from ir_model_fields_selection where field_id = 1111;

把1111替换成你要删除的字段id即可。

也有可能会遇见下面的错误:

  File "/home/kevin/codes/odoo/odoo-17.0/odoo/fields.py", line 1041, in update_db_column
    self._convert_db_column(model, column)
  File "/home/kevin/codes/odoo/odoo-17.0/odoo/fields.py", line 1045, in _convert_db_column
    sql.convert_column(model._cr, model._table, self.name, self.column_type[1])
  File "/home/kevin/codes/odoo/odoo-17.0/odoo/tools/sql.py", line 311, in convert_column
    _convert_column(cr, tablename, columnname, columntype, using)
  File "/home/kevin/codes/odoo/odoo-17.0/odoo/tools/sql.py", line 335, in _convert_column
    cr.execute(query, log_exceptions=False)
  File "/home/kevin/codes/odoo/odoo-17.0/odoo/sql_db.py", line 332, in execute
    res = self._obj.execute(query, params)
psycopg2.errors.InvalidTextRepresentation: invalid input syntax for type integer: "xxx"

究其原因就是因为字段类型变了,而存储在数据表ir.model.fields.selection表中的数据没有被清空。

解决方案也很明了,即在改完字段类型之后,升级模块之前把旧的选项值进行删除,或者直接在数据库中表中将对应的模型字段的值清除。

Reference

我们在第一部分曾经提到过Reference字段的两个问题, 我们从Reference字段的定义中可以看出, Reference由Selection字段继承而来, 因此它在数据库里的值就是一个字符串.

class Reference(Selection):

    type = 'reference'

    ...

该字符串的组成是由"对象名,资源ID"的方式组成的, 也就是说, Reference实际上是把关联对象的模型名和所选择的记录ID以字符串的方式存储在数据库当中了.

这样就引出了我们在第一部分提到过的问题, 当我们在树形列表中以Reference字段进行分组的时候, 因为它的值是字符串, odoo在处理过程中并没有对其进行优化, 因此在group的header中显示的就是"对象名,资源ID"的值, 很显然,这对用户来说并不友好.

为了解决这个问题, 笔者专门写了一个模块来处理这个问题, 有兴趣的同学可以看一下具体的实现, 其思路就是重写BaseModel的read_group方法, 在方法中对Reference字段进行特殊化处理. (我也给Odoo官方提过PR,不知道何年能够审核通过.即便审核通过,也只是对15.0+后的版本生效.)

关系字段

_Relational是关系字段的抽象父类, 其子类型包含了:

  • Many2one
  • _RelationnalMulti

其中_RelationnalMulti又是One2many和Many2many的父类.

关系字段的relation属性始终为True. 关系字段在定义的时候可以在构造函数中传入一个domain和context用来指定关系字段的过滤域和上下文数据. domain不仅可以是一个数组, 也可以是一个返回了数组的函数.

关系字段默认不进行公司校验, 如果设置了check_company为True, 那么关系字段在应用domain的时候会进行公司的过滤.

关系字段的读取

关系字段读取过程中,如果实例属性是一个非空记录集,那么其本质上和使用mapped方法是一致的

def __get__(self, records, owner):
    # base case: do the regular access
    if records is None or len(records._ids) <= 1:
        return super().__get__(records, owner)
    # multirecord case: use mapped
    return self.mapped(records)

Many2one

Many2one是odoo中最常见的一种字段类型, 我们在前面的例子中已经对它非常熟悉了. 现在我们从源码的层次上重新认识一下我们的老朋友.

首先,我们知道每个字段都有一个type属性用来标识它的类型, Many2one的类型就是'many2one'. 它在数据库中的实际存储的是关联对象的ID, 因此它的column_type就是int4.

ondelete

Many2one在定义时可以设置ondelete的值有cascade, set null和restrict. 如果用户没有设置ondelete, 根据下面的规则设置:

  1. 当前模型是瞬态模型,关联对象是存储模型的前提下,如果字段必填,那么ondelete为cascade, 否则为set null

  2. 否则, 必填设置restrict, 非必填设置set null

ondelete的set null和required=True不可以同时设置, 否则引发异常.

ondelete设置为restrct时, comodel不能是ir.model的类型.

Many2oneReference

Many2oneReference类型是从13.0版本开始引入的新的字段类型, Many2oneReference与Reference不同的是Reference的关联对象是个Char字段, Many2oneReference需要将关联字段放在model_field属性中.

class Many2oneReference(Integer):
    ...

Many2oneReference字段由Integer继承而来, 因此它在数据库中是一个int4类型的存储类型. Many2oneReference与Many2one的区别在于Many2oneReference并没有Many2one那样的强外键关联关系.

mapped方法

之前有人说mapped方法会去重,这种观点其实是不正确的,这里我们就来详细了解一下mapped方法及其背后的奥秘。

我们在前面知道对于关系字段,描述符方法调用的就是mapped方法,所以我们在使用关系字段时就可以直接使用点号等价于mapped,但是对于非关系字段来说则不可以替代使用。

我们先来看一下模型中的mapped方法是如何定义的:

if not func:
    return self                 # support for an empty path of fields
if isinstance(func, str):
    recs = self
    for name in func.split('.'):
        recs = recs._fields[name].mapped(recs)
    return recs
else:
    return self._mapped_func(func)

模型中的mapped方法首先会确认参数的类型, 如果参数是字符串,那么直接使用声明的字段的mapped方法获取数据。否则使用_mapped_func方法。

然后,我们再来看一下字段中的mapped方法:

def mapped(self, records):
    """ Return the values of ``self`` for ``records``, either as a list
    (scalar fields), or as a recordset (relational fields).
    This method is meant to be used internally and has very little benefit
    over a simple call to `~odoo.models.BaseModel.mapped()` on a recordset.
    """
    if self.name == 'id':
        # not stored in cache
        return list(records._ids)

    if self.compute and self.store:
        # process pending computations
        self.recompute(records)

    # retrieve values in cache, and fetch missing ones
    vals = records.env.cache.get_until_miss(records, self)
    while len(vals) < len(records):
        # It is important to construct a 'remaining' recordset with the
        # _prefetch_ids of the original recordset, in order to prefetch as
        # many records as possible. If not done this way, scenarios such as
        # [rec.line_ids.mapped('name') for rec in recs] would generate one
        # query per record in `recs`!
        remaining = records._browse(records.env, records[len(vals):]._ids, records._prefetch_ids)
        self.__get__(first(remaining), type(remaining))
        vals += records.env.cache.get_until_miss(remaining, self)

    return self.convert_to_record_multi(vals, records)

对于关系字段(m2o, o2m, m2m), 点号语法的背后就是mapped方法, 其返回结果会将重复的值去重。而对于普通字段,mapped方法返回的是一个列表,其值并不会被去重。

问题: 那么去重这一步究竟是在哪里进行的呢?

results matching ""

    No results matching ""