基于Django构建灵活的权限系统

注:本文更多的是对Django的权限扩展以及如何通过扩展解决实际的业务问题展开讨论,并不会对Django权限系统的具体实现和使用方法进行介绍,这方面知识未储备好的,请先自行脑补。

一、Django的权限系统

在介绍Django的权限系统前,我们先来认识一下RBAC。RBAC全称Role-Based Access Control,即基于角色的访问控制。太学术的东西就不讲了,我们可以从企业运营的角度来理解RBAC,企业运营过程中,需要做很多的事情来达成企业的目标,但这些事情并非所有人都可以去做的,比如业务人员不可能去处理公司的财务问题,所以需要给要做的这些事情设置相应的权限,然后将这些权限分配给指定的人,但问题来了,如果某个员工离职了,换了一个新员工来顶替他的位置,这时你需要一一的告诉该新员工他应该做什么,他不能做什么,当工作项非常多的时候,这将是非常繁琐的过程,且非常容易出错,这时就引入了角色的概念,角色大家可以理解为企业管理中的岗位,通过将工作权限分配给岗位,再将员工安排至相应的工作岗位,那么该员工就拥有了该岗位应有的权限,当人员变动时,只需要将新员工安排至该岗位即可,整个过程就变得简单多了。这时你也许会问,那岗位的工作内容变动时,你不是一样需要重新给岗位一一分配权限吗,增加了角色反而增加了系统的复杂性?话是没错,但在企业管理中,岗位是相对稳定的,而人员则是易变的,所以引入角色将极大的提升系统的灵活性和易用性。关于RBAC就简单介绍到这,当然RBAC还包含很多的内容,希望深入了解的朋友可以Google相关的信息。

Django的权限系统,可以认为是轻量级的RBAC系统,其中组可以对应RBAC的角色,通过将权限分配给组,再将用户分配到组中,从而实现了授权的过程,同时Django还支持直接对用户进行授权,这对应对一些特殊情况是十分有用的。关于Django的权限使用的详情,可以参考官方文档,在此不做详述。

Django的权限系统实现了最基本的权限需求,但在实际的业务开发过程中,仍然存在许多无法满足的需求,好在Django是一套异常强大的系统,系统内部提供了一套灵活的机制,使得开发人员可以方便的对系统的权限进行扩展。本文就我在实际项目过程中所遇到的各种权限方面的问题,看我如何通过扩展Django的权限系统,实现灵活的权限管理。

二、扩展Django的权限系统

2.1 对象级权限

在开发过程中,遇到最多的问题应该就是对象级权限的问题了。Django内置的权限授权是针对Model进行的授权,每个Model默认都会生成三个权限:add、change、delete。如一旦授予某用户Change权限,那么该用户就拥有了该Model所有对象的Change权限,但有些时候我们希望仅允许用户对该Model下的指定对象进行操作,比如新闻发布系统可以允许所有的编辑人员进行新闻的发布,但仅允许发布新闻的编辑人员修改自己所发布的新闻,这时候Django的权限系统就无法满足我们的需求了,我们需要引入更细粒度的对象级权限支持(此处新闻的例子在下文的系统组的实现一节中有更好的解决方案)。

所谓对象级权限,即针对的Model对象实例进行授权操作,使用我们可以精确的对单个对象进行权限的控制,以实现更细精度的权限控制。

虽然Django的权限系统没有直接提供对象权限的实现,但确提供了基础支持(相关的API都提供有对象参数的传入),使得我可以通过扩展认证后端从而实现对象级的权限支持。目前已经有非常好的对象级权限的第三方实现django-guardian,在此就不具体探讨了,想深入了解的朋友可以自己查看相关的文档。

注:此节内容感觉有点虚,没有太多实质性的东西,但很多业务上的权限需求是需要结合多种权限机制实现的,在此列出是为方便后文的展开,让各位先有个概念上的认识。

2.2 系统组的实现

什么是系统组?Django默认的权限系统中只有组的概念,没有什么系统组啊?其实系统组只是我们为了区别与现有组的一种称呼而已,你可以给它起一个更酷的名字。那么如何理解系统组呢?我们知道Django里的组都需要我们进行硬性的绑定才会生效的,比如你创建一个“编辑”的组,那么你需要给编辑这个组授予相应内容的编辑权限,同时你需要将相应的用户分配至该“编辑”组,那么用户才会真正的拥有了编辑应有的权限,这在大多数情况下是没有问题的,但这种硬性的绑定极大的限制了系统的灵活性,因为很多时候系统是需要根据运行时的环境来决定用户应该属于哪些组的。结合上文对象级权限中新闻发布的例子,如果使用对象级权限的方式来实现的话,我们就必需在用户发布新闻的同时,向对象级权限系统中添加一条权限分配的记录(对象级权限系统是需要独立的数据表来记录用户或组与对象的权限关系),这好像也没什么问题,无非是重写save方法或都添加一条signals就可以应对了,但如果我们希望用户的上级领导也能够修改该信息呢,你可能会说给上级领导也添加一条对象级权限啊,但如果该用户的上级领导变更了呢?如果我们又希望用户同部门的员工允许修改呢?是不是开始变得麻烦了,如果有一种机制来处理这种动态关系,那事情就变得简单多了,而实现这一机制的就是系统组。系统组就是在系统运行期,根据运行环境来决定用户所隶属的组,从而实现灵活的授权机制。

看完上面的解释,好像有点明白了,但具体该怎么实现呢?

首先,大家需要理解的一点是系统组本身并非系统运行时动态创建的,而是在开发阶段根据业务需要创建的,系统组机制只是在运行时由系统决定用户与系统组的关系而已。因而在业务开发阶段,我们就必需确定好业务所需的系统组。当然我们也可以从所有的业务中抽象出一些具备全局通用性的系统组,以下列出我们抽象出来的具备通用性的系统组供大家参考:

1) Everyone:所有人,无论是用户还是访客,都属于Everyone组。
  2) Anonymous:匿名用户,非认证的用户都属于Anonymous组。
  3) Users:用户,所有认证的用户都属于Users组。
  4) Creator:创建者,针对具体信息的创建者,都属于Creator组。
  5) Owner:所有者,具体信息的拥有者,都属于Owner组。

看了上边所列出的系统组,大家是不是感觉好像又更理解了一些呢,做过Windows文件授权的朋友,可能还有点似曾相识的感觉,对,这和Windows系统里的特殊组是一样一样的。除了以上所列出的系统组,我们还可以针对特定的业务创建针对性的系统组,如上例中,允许用户的上级领导修改该用户所发布的信息,那么我们可以创建一个名为“信息所有者的上级领导”这样的系统组。

# 看下面代码,我们声明了我们所需的系统组的常量,声明为常量是便于代码的调用
SYSTEM_GROUP_EVERYONE = "Everyone"      # 所有人
SYSTEM_GROUP_ANONYMOUS = "Anonymous"    # 匿名用户
SYSTEM_GROUP_USERS = "Users"            # 用户
SYSTEM_GROUP_STAFFS = "Staffs"          # 职员
SYSTEM_GROUP_CREATOR = "Creator"        # 创建者
SYSTEM_GROUP_OWNER = "Owner"            # 所有者

根据上文所述,系统组是在开发阶段就应该确定,那么确定后的系统组应该如何在系统中体现呢?其实系统组只是我们定义的一个概念,为了保证Django系统权限调用接口的一至性,它仍然是一个组对象,只是是一个我们赋予了特殊意义的组,我们仍然需要在Django的“组”这个Model中创建相应的系统组对象(当然如果考虑到系统组的特殊性,可以通过一些机制来限制对系统组的修改和删除操作,以保证系统组始终可用,如何限制不是权限系统的核心,在此不做详述)。你可以通过声明一个系统组初始化的方法,并在适当的地方调用他,当然你也可以使用Django提供的初始数据方法来初始系统组。看代码:

from django.contrib.auth.models import Group

def init_systemgroups():
    """
    初始化系统组。
    :return:
    """
    Group.objects.get_or_create(name=SYSTEM_GROUP_EVERYONE)
    Group.objects.get_or_create(name=SYSTEM_GROUP_ANONYMOUS)
    Group.objects.get_or_create(name=SYSTEM_GROUP_USERS)
    Group.objects.get_or_create(name=SYSTEM_GROUP_STAFFS)
    Group.objects.get_or_create(name=SYSTEM_GROUP_CREATOR)
    Group.objects.get_or_create(name=SYSTEM_GROUP_OWNER)

好了,现在我们已经有了系统组数据了,接下来我们就需要实现系统组的核心逻辑了,从Django所提供的权限验证接口User.has_perm(perm, obj=None),我们可以看出存在两种情况:一种是不提供obj参数的情况,我们可以语义化理解为“用户(User)是否拥有perm权限”;一种是提供了obj参数的情况,我们可以语义化理解为“用户(User)是否拥有指定对象(obj)的perm权限”。同样的,我们系统组也可以分为两种情况:一种是与obj参数无关的,我们可以语义化理解为“用户(User)是否为系统组(system group)的成员”,这包括上面所列的Everyone、Anonymous、Users、Staffs等;一种是与obj参数有关的,我们可以语义化理解为“用户(User)是否为对象(obj)的系统组(system group)的成员”,包括上面所列的Creator、Owner等。依赖于相同的参数,使得我们可以保持与Django一至的验证接口,我们现在要做的是根据这些参数,给系统返回恰当的系统组,先来看看与obj参数无关的情况的代码:

def get_user_systemgroups(user):
    """
    获取指定用户所属的系统组集合。
    :param user: 指定的用户。
    :return: set 表示的用户所属的系统组名称集合。
    """
    groups = set()
    groups.add(SYSTEM_GROUP_EVERYONE)
    if user.is_anonymous():
        groups.add(SYSTEM_GROUP_ANONYMOUS)
    else:
        groups.add(SYSTEM_GROUP_USERS)
        if user.is_staff:
            groups.add(SYSTEM_GROUP_STAFFS)

    return groups

OK,是不是很简单,我们只是对User进行简单的验证,就可以获得User有关的系统组了,而第二种与obj参数有关的情况就比较复杂了,比如Creator组,我们必需要获取对象的创建者,我们才能与User进行比较,从而验证User是否为obj的创建者,然而Creator组是我们抽象出来的全局通用的组,意味着我们的Model需要提供一至的方法来获取对象的创建者,这时我们需要定义一个接口(Python没有提供接口的概念,我们只是通过抽象的方法来模拟)来实现:

class CreatorMixin(object):
    """
    实现创建者的 Model 基类。
    """
    def get_creator(self):
        """
        获取对象的创建者,子类重写该方法实现创建者对象的获取。
        :return: 当前对象的创建者。
        """
        return None

    def set_creator(self, user):
        """
        设置对象的创建者,子类重写该方法实现创建者对象的设置。
        :param creator: 要设置为创建者的User对象。
        :return:
        """
        pass
class OwnerMixin(object):
    """
    实现所有者的 Model 基类。
    """
    def get_owner(self):
        """
        获取对象的所有者,子类重写该方法实现所有者对象的获取。
        :return: 当前对象的所有者。
        """
        return None

    def set_owner(self, user):
        """
        设置对象的所有者,子类重写该方法实现所有者对象的设置。
        :param owner: 要设置为所有者的User对象。
        :return:
        """
        pass

现在再来看看第二种情况的实现:

def get_user_systemgroups_for_obj(user, obj):
    """
    获取指定用户相对于指定的对象所属的系统组集合。
    :param user: 指定的用户。
    :param obj: 相对于指定的对象。
    :return: set 表示的用户所属的系统组名称集合。
    """
    groups = set()
    if isinstance(obj, CreatorMixin) and obj.get_creator() == user:
        groups.add(SYSTEM_GROUP_CREATOR)
    if isinstance(obj, OwnerMixin) and obj.get_owner() == user:
        groups.add(SYSTEM_GROUP_OWNER)
    return groups

现在,我们为了保证系统组的扩展性,我们需要定义一套规则,使得你可以在你自己的应用中,扩展实现自己业务所需要的系统组,我们约定在你的应用中应该存在一个模块,模块中应该包含有以上声明的get_user_systemgroups(user)和get_user_systemgroups_for_obj(user, obj)两个方法,同时你需要在项目的settings.py文件中,告诉系统你的系统组实现的模块路径,类似如下:

# 自定义系统组实现
SYSTEM_GROUP_IMPLEMENTERS = ['systemgroups.systemgroups', '你自己实现的系统组的路径'…]

同时,我们需要提供一个方法来根据上面规则,依次获取所有应用中的用户所属的系统组集合,代码如下:

def get_user_systemgroups(user):
    """
    从所有应用中获取指定用户所属的系统组集合。
    :param user: 指定的用户。
    :return: set 表示的用户所属的系统组名称集合。
    """
    imps = SYSTEM_GROUP_IMPLEMENTERS
    groups = set()
    if not imps:
        return groups
    for imp in imps:
        imp = importlib.import_module(imp)
        if hasattr(imp, "get_user_systemgroups"):
            groups.update(imp.get_user_systemgroups(user))
    return groups
def get_user_systemgroups_for_obj(user, obj):
    """
    从所有应用中获取指定用户相对于指定的对象所属的系统组集合。
    :param user: 指定的用户。
    :param obj: 相对于指定的对象。
    :return: set 表示的用户所属的系统组名称集合。
    """
    imps = SYSTEM_GROUP_IMPLEMENTERS
    groups = set()
    if not imps:
        return groups
    for imp in imps:
        imp = importlib.import_module(imp)
        if hasattr(imp, "get_user_systemgroups_for_obj"):
            groups.update(imp.get_user_systemgroups_for_obj(user, obj))
    return groups

最后,我们来实现我们的认证后端:

def get_group_permissions(name):
    """
    获取指定名称的组所拥有的权限集合。
    :param name: 组的名称。
    :return: 权限集合。
    """
    perms = Permission.objects.filter(group__name = name)
    perms = perms.values_list('content_type__app_label', 'codename').order_by()
    return set(["%s.%s" % (ct, name) for ct, name in perms])


def get_groups_permissions(names):
    """
    获取指定名称的组所拥有的权限集合。
    :param names: 组的名称集合。
    :return: 权限集合。
    """
    perms = set()
    for name in names:
        perms.update(get_group_permissions(name))
    return perms


class SystemGroupBackend(object):
    def authenticate(self, username=None, password=None, **kwargs):
        return None

    def has_perm(self, user_obj, perm, obj=None):
        return perm in self.get_all_permissions(user_obj, obj)

    def get_all_permissions(self, user_obj, obj=None):
        perms = self.get_group_permissions(user_obj, obj)
        return perms

    def get_group_permissions(self, user_obj, obj=None):
        result_perms = set()

        groups = get_user_systemgroups(user_obj)
        perms = get_groups_permissions(groups)
        result_perms.update(perms)

        if obj is None:
            return result_perms

        groups = get_user_systemgroups_for_obj(user_obj, obj)
        perms = get_groups_permissions(groups)
        result_perms.update(perms)

        return result_perms

至此,我们完整的实现了系统组的机制,以上代码因篇幅原因删减了部分与主逻辑关系不大的代码,如权限的缓存等,以便于阅读和理解,完整的代码请参考我的GitHub项目:https://github.com/Kidwind/django-systemgroups,同时因项目时间较短,仍然存在很多不足和问题,也欢迎大家指正。

2.3 权限映射

权限映射又是什么呢?要回答这个问题,我们还是以上文中的新闻发布的例子来展开,我们的新闻应该是根据性质进行分类的,比如实事新闻、财经新闻、体育新闻等等,这时我们就形成了新闻类别(InfoCategory)和新闻(Info)这两个一对多关系的Model,随着工作的细分,我们需要将不同的分类授权不同的部门来进行管理,这时你想到的是什么?对,就是上文中所提到的对象级权限,我们给新闻类别(InfoCategory)创建一个用于控制新闻类别下的新闻的修改权限,名为change_info_by_category,通过针对新闻类别(InfoCategory)进行对象级的change_info_by_category授权,如果此时我们要进行某篇新闻的修改权限验证,我们需要对新闻所在栏目进行change_info_by_category的权限验证,像这样user.has_perm(‘app_label. change_info_by_category’, obj=info.category),有什么问题吗?似乎也没什么问题,但我们细细分析一下,我们原本对新闻(Info)进行修改的权限验证方法user.has_perm(‘app_label.change_info’, obj=info),需要人为的转换为上面的权限验证,相应的Django提供的Admin我们需要重写相应的方法来修改权限验证的逻辑,如果新闻(Info)本身还提供对象级的权限检测,我们的逻辑就需要改为要对两个方法都进行验证,还有更多复杂的情况,情况一变,我们就需要重写我们的权限验证逻辑吗,很麻烦不是吗,如果有一种方法能够实现上述权限验证的自动转换,能够保证我们的权限调用方法不变,那事情就简单多了,而这一方法,就是我们所说的权限映射。简而言之,权限映射就是将用户对当前对象所执行的权限验证转换为用户对另一个对象的另一个权限进行验证的过程。看下图:

权限映射示意图.png

好了,分析到这里,相信大家对权限映射的作用有了大概的认识,下面我们来对代码实现做简单的分析和了解。同系统组一样,我们的Model需要提供一至的方法来根据当前的权限验证参数,获取映射后的权限验证参数,我们定义接口如下:

class PermMappableMixin(object):
    """
    实现权限映射的 Model 基类。
    """
    @classmethod
    def mapping_permission(cls, perm, obj=None):
        """
        根据当前的权限验证参数,获取映射后的权限验证参数。(此类方法仅为标记方法,子类应实现相应的方法)
        :param perm: 当前检测的权限。
        :param obj: 当前进行检测权限的对象。
        :return: 返回值包含两个参数:第一个参数为映射后的权限;第二个参数为对应映射后的对象,其应为映射后权限所对应的 Model 的实例。
        """
        return None, None

接下来,我们只需要实现我们的认证后端就可以了:

from django.contrib.contenttypes.models import ContentType

class PermMappingBackend(object):
    def authenticate(self, username=None, password=None, **kwargs):
        return None

    def has_perm(self, user_obj, perm, obj=None):
        app_label, codename = perm.split('.')
        content_types = ContentType.objects.filter(
            app_label = app_label,
            permission__codename = codename)  # 根据权限获取其对应的ContentType实例。
        for content_type in content_types:
            model_class = content_type.model_class()    # 根据 ContentType 实例获取对应的 Model 类
            if issubclass(model_class, PermMappableMixin):
                mapped_perm, mapped_obj = model_class.mapping_permission(perm, obj = obj)
                if mapped_perm and user_obj.has_perm(mapped_perm, obj=mapped_obj):
                    return True
        return False

是不是很简单,接下来看看我们新闻例子的代码:

from django.utils.translation import ugettext as _
from django.db import models

class InfoCategory(models.Model):
    name = models.CharField(max_length=128, verbose_name=_('分类名称'))

    class Meta:
        permissions = (
            ("add_info_by_category", _("允许添加分类信息")),
            ("change_info_by_category", _("允许修改分类信息")),
            ("delete_info_by_category", _("允许删除分类信息")),
        )


class Info(PermMappableMixin, models.Model):
    category = models.ForeignKey(InfoCategory, verbose_name=_("所属分类"))
    title = models.CharField(max_length=256, verbose_name=_('标题'))

    @classmethod
    def mapping_permission(cls, perm, obj=None):
        mapped_perm = None
        mapped_obj = None

        if perm == "permmapping.add_info":
            mapped_perm = "permmapping.add_info_by_category"
        elif perm == "permmapping.change_info":
            mapped_perm = "permmapping.change_info_by_category"
        elif perm == "permmapping.delete_info":
            mapped_perm = "permmapping.delete_info_by_category"

        if isinstance(obj, cls):
            mapped_obj = obj.category

        return mapped_perm, mapped_obj

三、写在最后

Django系统提供了一套灵活的认证系统,使得我们可以通过扩展其实现灵活的权限控制策略,本文结合我在项目过程中的实践经验,为大家展示了通过对象级权限、系统组的实现、权限映射来解决项目中所遇到的几种权限问题,同时实践中也可以通过组合这几种权限机制,实现更多更为复杂的权限策略。其它类的开发语言,也可以借鉴Django及上文所提到的几种权限系统的扩展的思路,实现各自平台的权限系统。

好了,先到这里了,如果大家在实践中有什么问题,可以给我留言,Bye~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 经过对django的初步学习,我们已经对后台的基本流程以及django的运作有了一定的了解,但是这还不足够,dja...
    coder_ben阅读 3,797评论 8 34
  • 本文涉及的技术,已应用于我基于django 1.8+ 开发的博客系统——MayBlog,欢迎交流。 1. Djan...
    Gevin阅读 38,295评论 7 104
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,563评论 25 707
  • 1.iOS7之后 要想改变navigationbar的颜色 可以这样子改 self.navigationContr...
    寒桥阅读 20,854评论 10 34
  • 今天我被拒稿了。这么说好像我没有被拒过一样因为一次的拒稿就受不了。我是第一次被拒,所以才会失落。更因为我是第一次投...
    坛_阅读 785评论 43 17