Flask Mega-Tutorial 中文教程 V2.0 第8章:关注与被关注

最近在Flask Web Development作者博客看到第二版Flask Mega-Tutorial已在2017年底更新,现翻译给大家参考,希望帮助大家学习flask。

这是Flask Mega-Tutorial系列的第八章,其中我将告诉你如何实现类似于Twitter和其他社交网络的“关注”功能。

供您参考,以下是本系列文章的列表。

在本章中,我将更多地介绍应用程序的数据库。我希望应用程序的用户能够轻松选择他们想要关注的用户。因此,我将扩展数据库,以便知道谁关注谁,这比你想象的更难。

本章的GitHub链接是:BrowseZipDiff


重论数据库关系

我上面说过,我想为每个用户维护一个“被关注”和“关注”的用户列表。不幸的是,关系型数据库没有列表类型的字段来保存它们,我们只能通过表的现有字段和它们之间的关系来实现

数据库有一个表示用户的表,所以剩下的就是如何正确地组织他们之间的关注和被关注的关系。现在是回顾数据库基本关系类型的好时机:

一对多

我已经在第4章中使用了一对多的关系。以下是此关系的图表:

ch04-users-posts

通过此关系关联的两个实体是users和posts。我们说用户会有很多帖子,帖子会有一个用户(或作者)。这种关系在数据库中的表示就是在“多”的这一边中使用外键。在上面的例子中,外键就是posts表的user_id。这个字段将每个帖子关联到users表的作者的数据记录上。

很明显,user_id字段提供了对给定帖子的作者的直接入口,但反方向操作呢?利用数据库的关系,我应该能够获得给定用户编写的帖子列表。posts表中的user_id字段是足够给出答案,因为数据库有高效的查询索引允许我们进行类似“获取用户 user_id 为 X 的所有帖子”的操作。

多对多

多对多关系有点复杂。例如,考虑一个拥有studentsteachers的数据库。我们可以说学生有很多老师,也可以说老师有很多学生。这就像两端(学生和老师)都是一对多的关系。

对于这种类型的关系,我应该能够查询数据库,并在一个teachers类中获取教某一个学生的老师列表,以及一个老师下所有教的学生的列表。表示上述关系是相当棘手的,它不能简单地在已存在的表中添加外键。

多对多关系的表示需要使用一种称为关联表的辅助

下面是数据库如何表示学生和教师的关系的例子:

ch08-many-to-many

虽然它可能不会看起来很简单,两个外键的关联表能够有效地回答关于该关系的所有查询。如:

  • 哪些老师教学生 S?
  • 哪些学生是老师 T 教的?
  • 老师 T 有多少个学生?
  • 学生 S 有多少个老师?
  • 老师 T 正在教学生 S 吗?
  • 学生 S 在老师 T 的班里吗?

多对一和一对一

多对一类似于一对多的关系。不同的事,这种关系是从“多”的角度来看。

一对一是一对多的特例。表示方式是类似的,但是向数据库添加约束以防止“多”的这一边有多个链接。虽然某些情况下,这种类型的关系是有用的,但它并不像其他关系类型那样常见。

表示“关注者”和“被关注者”

从上面讲述到的关系来说,我们很容易确定表示关注者和被关注者最适合的模型是多对多关系。因为一个用户可以关注许多用户,同样一个用户可以有许多用户关注。但这有一个问题。在学生和教师的例子中,多对多关系关联了两个实体(表)。对于被关注者,这个用户还可以关注其他用户,但是我们只有用户一个实体(表)。那么多对多关系的第二个实体(表)是什么?

这种关系的第二个实体(表)也是用户。

如果一个表是指向自己的关系则称为自引用关系,这正是我们现在需要的。

以下是多对多关系的图:

ch08-followers-schema

followers表示我们的关联表。外键都是来自于用户表中,因为我们是用户关联到用户。在这个表中的每一个记录都是表示关注者以及被关注者的一个关系。像学生和老师的例子,像这样的设计允许数据库回答所有关于关注和被关注的问题。

译者注:对于关注和被关注的关系,可以这么理解。就像在csdn之类的博客中,你会看到一个“关注”按钮,点击之后会变成“已关注”。如果你在一个用户主页看到“已关注”,就表示你关注了此用户,而此用户就是“被关注者”,你就是“关注者”,你也可以被称为此用户的“粉丝”。

数据模型

首先让我们在数据库中添加关注者和被关注者。也就是followers关联表:

# app/models.py: Followers association table

followers = db.Table('followers',
    db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
    db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
)

这是上图中关联表的直接翻译。请注意,我们并没有想对users和posts一样,把它声明成一个模型。因为这是一个除了外键之外没有其它数据的辅助表,所以我创建它时并没有关联模型。

现在我可以在users表中声明多对多关系:

# app/models.py: Many-to-many followers relationship

class User(UserMixin, db.Model):
    # ...
    followed = db.relationship(
        'User', secondary=followers,
        primaryjoin=(followers.c.follower_id == id),
        secondaryjoin=(followers.c.followed_id == id),
        backref=db.backref('followers', lazy='dynamic'), lazy='dynamic')

这种关系的建立并非易事。就像我设置posts一对多关系一样,我们使用db.relationship函数来定义关系。这种关系将User实例关联到其他User实例,换句话说,在这种关系关联的一对用户,左侧用户是关注着右侧用户。因为当我们将左边的用户定义为followed时,我们从左边用户查询这种关系,将会得到被关注用户的列表(即右侧的用户)。让我们逐个检查所有调用db.relationship()的参数:

  • User 是关系的右侧实体(左侧实体是父类)。由于这是一种自我引用关系,我必须在两边使用相同的类。
  • secondary 配置用于此关系的辅助表,就是使用我在上面定义的followers。
  • primaryjoin表示将左侧实体(关注者)与辅助表链接的条件。关系左侧的join条件是辅助表的follower_id字段与这个关注者的用户ID匹配 。followers.c.follower_id表达式指向辅助表的follower_id列。
  • secondaryjoin表示将右侧实体(被关注者)与辅助表链接的条件。这个条件与primaryjoin类似,唯一的区别在于,现在我们使用辅助表的followed_id列,这是辅助表中的另一个外键。
  • backref定义如何从右侧实体如何访问此关系。从左侧开始,关系被命名followed,因此在右侧我将使用followers来表示所有左侧用户的列表。附加lazy参数表示此查询的执行模式,dynamic模式将查询设置为在特定请求之前不会立即执行,这也是我如何设置帖子的一对多关系。
  • lazy类似于同名参数backref,但这一个适用于左侧查询而不是右侧。

不要担心这理解起来比较困难。我待会告诉你如何在一瞬间处理这些查询,一切都会变得更加清晰。

由于我们更新了数据库,现在需要生成一个新的数据迁移脚本:

(venv) $ flask db migrate -m "followers"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'followers'
  Generating /home/miguel/microblog/migrations/versions/ae346256b650_followers.py ... done

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ae346256b650, followers

添加和取消“关注”

由于SQLAlchemy ORM,一个用户关注另一个用户的行为可以通过followed关系抽象成一个列表来简便使用。例如,我有两个用户存储在user1user2变量,我可以使用下面简单的语句使user1关注user2

user1.followed.append(user2)

要取消关注用户,我可以这样做:

user1.followed.remove(user2)

尽管添加和取消关注的操作相当容易,但我希望在代码中提高其可重用性,因此我不会在代码中添加“appends”和“removes”。相反,我将在User模型中实现“follow”和“unfollow”方法。最好将应用程序逻辑从视图函数移到模型、其他辅助类、模块中,因为正如您将在本章后面看到的那样,这使得单元测试变得更加容易。

以下是用户模型中添加和移除关注关系的更改:

# app/models.py: Add and remove followers

class User(UserMixin, db.Model):
    #...

    def follow(self, user):
        if not self.is_following(user):
            self.followed.append(user)

    def unfollow(self, user):
        if self.is_following(user):
            self.followed.remove(user)

    def is_following(self, user):
        return self.followed.filter(
            followers.c.followed_id == user.id).count() > 0

如上图所示,follow()unfollow()方法使用关系对象的append()remove()方法。有必要在处理关系之前,使用is_following()方法来确认是否符合操作的条件。例如,如果我让user1关注user2,但事实证明数据库中已经存在这样的关系,我就没必要重复添加。相同的逻辑可以应用于取消关注。

is_following()方法发出一个关于followed关系的查询来检查两个用户之间的关系是否已存在。您之前看到我使用SQLAlchemy查询对象的filter_by()方法,例如查找给定用户名的用户。我在这里使用的filter()方法是类似的,但是更加偏向底层,因为它可以包括任意的过滤条件,而不像filter_by()这样只能检查是否等于一个常量值。我在is_following()中使用的过滤条件是,查找关系表中左侧​​外键设置为self用户,右侧设置为user参数。查询以count()方法结束,返回结果的数量。此查询的结果将是01,因此检查计数是1或大于0实际上是等效的。至于其它的查询结束符,你已经看到我使用过all()first()

获取被关注用户的帖子

对数据库中的关注和被关注功能几近尾声,但我却遗漏了一项重要的功能。在应用程序的索引页面中,我将显示由登录用户所遵循的所有人编写的博客帖子,因此我需要提供一个返回这些帖子的数据库查询。

最显而易见的解决方案是先执行一个查询以返回被关注用户的列表,如您所知,它将是user.followed.all()。然后对于这些返回的每个用户,我都运行查询一次来获取用户帖子。一旦我收到所有帖子,我就可以将它们合并到一个列表中,并按日期对它们进行排序。听起来不错?其实不然。

这种方法有几个问题。如果用户关注了一千人,会发生什么?首先,我需要执行一千个数据库查询来收集所有用户的帖子。然后我需要合并并排序内存中的数千个列表。作为次要问题,考虑到应用程序的主页最终将实现分页,因此它不会显示所有可用的用户帖子,而只显示前几个,并显示一个链接来提供感兴趣的用户查看更多动态。如果我要按日期排序来显示用户帖子,我怎么知道哪些用户帖子是所有被关注用户的最新的呢?除非我首先获得所有用户帖子并对其进行排序,这实际上是一个不好扩展的糟糕解决方案。

实际上没有办法避免博客文章的这种合并和排序,但在应用程序中执行它会导致效率十分低下。而这种工作是关系数据库擅长的。我可以使用数据库的索引,让它以更有效的方式执行查询和排序。所以我真正想要提供的方案是,的定义我想要得到的信息来执行一个数据库查询,然后让数据库找出如何以最有效的方式提取这些信息。

您可以在下面看到此查询:

# app/models.py: Followed posts query

class User(db.Model):
    #...
    def followed_posts(self):
        return Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id).order_by(
                    Post.timestamp.desc())

这是迄今为止我在此应用程序中使用的最复杂的查询。我将尝试一步一步解密这个查询。如果你看一下这个查询的结构,你会注意到有三个主要部分,分别是join()filter()并且order_by(),它们都是SQLAlchemy的查询对象的方法:

Post.query.join(...).filter(...).order_by(...)

Joins

要理解join操作的作用,让我们看一个例子。假设我们有一个包含以下内容的User表:

idusername
1john
2susan
3mary
4david

为了简单起见,我没有显示用户模型中的所有字段,只显示对此查询很重要的字段id和username。

假设followers辅助表表示用户john正在关注用户susandavid,用户susan正在关注mary,用户mary正在关注david。这些的数据如下表所示:

follower_idfollowed_id
12
14
23
34

最后,posts表包含每个用户的一条帖子:

idtextuser_id
1post from susan2
2post from mary3
3post from david4
4post from david1

该表还省略了一些不属于本讨论范围的字段。

这是我再次为此查询定义的join()调用:

Post.query.join(followers, (followers.c.followed_id == Post.user_id))

我在posts表上调用join操作。第一个参数是followers辅助表,第二个参数是join条件。我在这个调用表达的含义是,我希望数据库创建一个临时表,它将用户post表和followers表中的数据结合在一起。数据将根据参数传递的条件进行合并。

我使用的条件表示followers辅助表的followed_id字段必须等于posts表中的user_id表字段。要执行此合并,数据库将从posts表(join的左侧)获取每个记录,并追加followers辅助表(join的右侧)中的匹配条件的所有记录。如果followers辅助表中有多个记录符合条件,则用户帖子将重复出现。如果对于一个给定的帖子,followers辅助表中却没有匹配,那么该用户动态的记录不会出现在join操作的结果中。

使用上面定义的示例数据,join操作的结果如下:

idtextuser_idfollower_idfollowed_id
1post from susan212
2post from mary323
3post from david414
3post from david434

注意上表中,user_idfollowed_id列在join条件下是如何相等的。来自用户john的帖子没有出现在临时表中,因为被关注列表中没有包含john用户,或者换句话说,没有人关注john。而来自david的用户帖子出现两次,因为该用户有两个不同的用户关注。

虽然创建了这个join操作,但却没有得到想要的结果。但继续阅读,因为这只是更大查询的一部分。

过滤器

Join操作给了我一个所有被关注用户的用户帖子的列表,远超出我想要的那部分数据。我只对这个列表的一个子集感兴趣----某个用户关注的用户们的帖子,所以我需要通过调用filter()来剔除所有我不需要的数据。

以下是查询的过滤器部分:

filter(followers.c.follower_id == self.id)

由于此查询位于User类的一个方法,因此self.id表达式指向我感兴趣的用户ID。调用filter()筛选临时表中follower_id等于这个用户ID的行,换句话说,我只保留作为被关注者的用户数据。

假设我感兴趣的是id为1的用户john这是从临时表过滤后的结果

idtextuser_idfollower_idfollowed_id
1post from susan212
3post from david414

这些正是我想要的结果!

请记住,查询是在Post类上发出的,所以即使我最终得到了由数据库创建的一个临时表来作为查询的一部分,结果将是此临时表中包含的帖子,而不会存在由于执行join操作添加的其他列。

排序

该过程的最后一步是对结果进行排序。执行该操作的查询语句如下:

order_by(Post.timestamp.desc())

在这里,我希望结果按帖子的时间戳字段进行降序排列。排序之后,第一个结果将是最新的博客文章。

结合自己和被关注者的帖子

我在followed_posts()函数中使用的查询非常有用,但有一个限制。人们希望看到他们自己的帖子包含在他们关注的用户帖子的时间轴中,而查询本身没有该功能。

有两种方法可以扩展此查询以包含用户自己的帖子。最直接的方法是保持查询不变,但要确保所有用户都关注了他们自己。如果您自己关注自己,那么上面显示的查询将会找到您自己的帖子以及您关注的所有人的帖子。这种方法的缺点是它会影响关于被关注者的统计数据。所有的被关注者数量都会增加一个,因此他们必须在展示之前进行调整。第二种方法是创建第二个查询,返回用户自己的帖子,然后使用“union”操作将两个查询合并为一个查询。

深思熟虑之后,我决定选择第二个选项。下面你可以看到followed_posts()函数已被拓展成通过union查询来并入用户自己的帖子:

# app/models.py: Followed posts query with user's own posts.

    def followed_posts(self):
        followed = Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id)
        own = Post.query.filter_by(user_id=self.id)
        return followed.union(own).order_by(Post.timestamp.desc())

请注意排序之前,followedown查询如何合并为一个。

对用户模型进行单元测试

虽然我不认为已经建立了关注和被关注功能是一个“复杂”的功能,但我也并不认为这微不足道。当我编写复杂的代码时,我担心的是在应用程序的不同部分进行修改之后,如何确保本处代码将来会继续工作。确保您已编写的代码将来继续有效工作的最佳方法是,创建一套自动化测试在每次更改后执行测试。

Python包含一个非常有用的unittest包,可以轻松编写和执行单元测试。让我们来为User类中现有方法编写一些单元测试并存放在tests.py模块:

# tests.py: User model unit tests.

from datetime import datetime, timedelta
import unittest
from app import app, db
from app.models import User, Post

class UserModelCase(unittest.TestCase):
    def setUp(self):
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def test_password_hashing(self):
        u = User(username='susan')
        u.set_password('cat')
        self.assertFalse(u.check_password('dog'))
        self.assertTrue(u.check_password('cat'))

    def test_avatar(self):
        u = User(username='john', email='john@example.com')
        self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
                                         'd4c74594d841139328695756648b6bd6'
                                         '?d=identicon&s=128'))

    def test_follow(self):
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        self.assertEqual(u1.followed.all(), [])
        self.assertEqual(u1.followers.all(), [])

        u1.follow(u2)
        db.session.commit()
        self.assertTrue(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 1)
        self.assertEqual(u1.followed.first().username, 'susan')
        self.assertEqual(u2.followers.count(), 1)
        self.assertEqual(u2.followers.first().username, 'john')

        u1.unfollow(u2)
        db.session.commit()
        self.assertFalse(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 0)
        self.assertEqual(u2.followers.count(), 0)

    def test_follow_posts(self):
        # create four users
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        u3 = User(username='mary', email='mary@example.com')
        u4 = User(username='david', email='david@example.com')
        db.session.add_all([u1, u2, u3, u4])

        # create four posts
        now = datetime.utcnow()
        p1 = Post(body="post from john", author=u1,
                  timestamp=now + timedelta(seconds=1))
        p2 = Post(body="post from susan", author=u2,
                  timestamp=now + timedelta(seconds=4))
        p3 = Post(body="post from mary", author=u3,
                  timestamp=now + timedelta(seconds=3))
        p4 = Post(body="post from david", author=u4,
                  timestamp=now + timedelta(seconds=2))
        db.session.add_all([p1, p2, p3, p4])
        db.session.commit()

        # setup the followers
        u1.follow(u2)  # john follows susan
        u1.follow(u4)  # john follows david
        u2.follow(u3)  # susan follows mary
        u3.follow(u4)  # mary follows david
        db.session.commit()

        # check the followed posts of each user
        f1 = u1.followed_posts().all()
        f2 = u2.followed_posts().all()
        f3 = u3.followed_posts().all()
        f4 = u4.followed_posts().all()
        self.assertEqual(f1, [p2, p4, p1])
        self.assertEqual(f2, [p2, p3])
        self.assertEqual(f3, [p3, p4])
        self.assertEqual(f4, [p4])

if __name__ == '__main__':
    unittest.main(verbosity=2)

我添加了四个测试,用于在用户模型中执行密码哈希,用户头像和关注者功能测试。

setUp()tearDown()方法,是分别在单元测试框架每次测试之前和之后执行的特殊方法。

setUp()中实现了一些小技巧,以防止单元测试使用我用于开发的常规数据库。通过将应用程序配置更改为sqlite://,我在测试期间让SQLAlchemy使用SQLite的内存数据库。通过调用db.create_all()创建所有数据库表。这是从头开始创建数据库的快速方法,在测试中相当好用。对于开发环境和生产环境的数据库结构管理,我已经通过数据库迁移向你展示过了。

您可以使用以下命令运行整个测试套件:

(venv) $ python tests.py
test_avatar (__main__.UserModelCase) ... ok
test_follow (__main__.UserModelCase) ... ok
test_follow_posts (__main__.UserModelCase) ... ok
test_password_hashing (__main__.UserModelCase) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.494s

OK

从现在开始,每次对应用程序进行更改时,您都可以重新运行测试以确保正在测试的功能未受到影响。此外,每次向应用程序添加另一个功能时,都应为其编写单元测试。

将应用程序集成关注和被关注的功能

数据库和模型中的关注和被关注功能现已完成,但我没有将此功能合并到应用程序中,所以我现在要添加它。值得高兴的是,实现它没有什么大的挑战,都将基于你已经学过的概念。

让我们在应用程序中添加两个新路由,他们提供了用户关注和取消关注用户的URL和逻辑实现:

# app/routes.py: Follow and unfollow routes.

@app.route('/follow/<username>')
@login_required
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot follow yourself!')
        return redirect(url_for('user', username=username))
    current_user.follow(user)
    db.session.commit()
    flash('You are following {}!'.format(username))
    return redirect(url_for('user', username=username))

@app.route('/unfollow/<username>')
@login_required
def unfollow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot unfollow yourself!')
        return redirect(url_for('user', username=username))
    current_user.unfollow(user)
    db.session.commit()
    flash('You are not following {}.'.format(username))
    return redirect(url_for('user', username=username))

视图函数的逻辑不言而喻,但要注意所有错误检查,以防止出现意外问题,并尝试在发生问题时向用户提供有用的消息。

我将添加这两个视图函数的路由到每个用户的个人主页中。以便其他用户执行关注和取消关注用户的操作:

app/templates/user.html: Follow and unfollow links in user profile page.

        ...
        <h1>User: {{ user.username }}</h1>
        {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
        {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
        <p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>
        {% if user == current_user %}
        <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
        {% elif not current_user.is_following(user) %}
        <p><a href="{{ url_for('follow', username=user.username) }}">Follow</a></p>
        {% else %}
        <p><a href="{{ url_for('unfollow', username=user.username) }}">Unfollow</a></p>
        {% endif %}
        ...

对用户配置文件模板的更改在最近访问的时间戳下方添加一行,以显示该用户拥有多少关注者和被关注者。现在,当您查看自己的个人资料时,具有“Edit”链接的行可能回变成以下三种链接之一:

  • 如果用户正在查看他(她)自己的个人资料,“Edit”链接将像以前一样显示。
  • 如果用户正在查看其他并未关注的用户的个人主页,则会显示“Follow”链接。
  • 如果用户正在查看他关注的用户的个人主页,则会显示“Unfollow”链接。

此时,您可以运行应用程序,创建一些用户并测试一下关注和取消关注用户的功能。唯一需要记住的是,需要手动键入您要关注或取消关注的用户的个人资料页面URL,因为目前无法查看用户列表。例如,如果您想关注用户susan,则需要在浏览器的地址栏中键入http://localhost:5000/user/susan以访问该用户的配置文件页面。请确保在发出关注或取消关注的时候,留意到其关注者和被关注者的数量变化情况。

我应该在应用程序的主页中显示所关注帖子的列表,但由于用户还不能编写博客帖子,因为我还没有完成所有依赖的工作。所以我会暂缓这个页面的完善工作,直到发表用户动态功能的完成。


原文链接:https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-viii-followers

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值