21 Flask mega-tutorial 第21章 用户通知

如需转载请注明出处。
win10 64位、Python 3.6.3、Notepad++、Chrome 67.0.3396.99(正式版本)(64 位)
注:作者编写时间2018-04-25,linux、python 3.5.2

以下内容均是加入自己的理解与增删,以记录学习过程。不限于翻译,部分不完全照搬作者Miguel Grinberg的博客,版权属于作者,感谢他提供免费学习的资料。

传送门
00 开篇01 Hello world02 模板03 Web表单
04 数据库05 用户登录06 个人资料和头像07 错误处理
08 关注09 分页10 支持QQ邮箱11 美化页面
12 时间和日期13 I18n和L10n 翻译成中文 zh-CN14 Ajax(百度翻译API15 更好的App结构(蓝图)
16 全文搜索17 部署到腾讯云Ubuntu18 部署到Heroku19 部署到Docker容器
20 JavaScript魔法21 用户通知22 后台工作(Redis)23 应用程序编程接口(API)

这章将添加一个私人消息功能,以及导航栏中显示的用户通知,而无需刷新页面。预览:
在这里插入图片描述

本章继续致力于改善Microblog应用程序的用户体验。适用于许多应用程序的一个方面是 向用户呈现警报或通知。社交应用程序会显示这些通知,通知通过在顶部导航栏中显示带有数字的小徽章来获取新的提及或私信。虽然这是最明显的用法,但通知模式可应用于许多其他类型的应用程序,以通知用户某些事情需要他们的注意。

但为了展示构建用户通知所涉及的技术,需要扩展Microblog,其功能可以从中受益,因此在本章的第一部分中,我将构建一个允许任何用户发送的用户消息系统给另一个用户的私人消息。这实际上比它听起来更简单,它将是对Flask核心实践的一个很好的复习,并提醒你如何使用Flask进行精益、高效、有趣的编程。一旦消息传递系统到位,我将讨论一些选项来实现显示未读消息计数的通知标记。

私人消息

我将要实现的私人消息传递功能非常简单。当你访问用户的个人资料页面时,会有一个链接向该用户发送私人消息。该链接将带你进入一个新页面,其中Web表单接收该消息。要阅读发送给您的邮件,页面顶部的导航栏将显示一个新的“邮件”链接,该链接将带您进入与`/index`或`/explore`页面结构相似的页面,而不是显示博客帖子,它会显示其他用户发送给你的消息。

以下部分描述了我为实现此功能所采取的步骤。

私人消息的数据库支持

第一项任务是扩展数据库以支持私人消息。这是一个新的Message模型:
app/models.py:消息模型

#...
class Post(SearchableMixin, db.Model):
	#...

class Message(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	sender_id = db.Column(db.Integer, db.ForeignKey('user.id'))
	recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'))
	body = db.Column(db.String(140))

	def __repr__(self):
		return '<Message {}>'.format(self.body)

此模型类与Post模型类似,唯一的区别是有两个用户外键,一个用于发件人,一个用于收件人
下方 User模型可以获得这两个用户的关系,以及一个新字段,指示用户上次读取其私人消息的时间:
app/models.py:用户模型中的私人消息支持

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

	followed = #...

	messages_sent = db.relationship('Message', foreign_keys='Message.sender_id', backref='author', lazy='dynamic')
	messages_received = db.relationship('Message', foreign_keys='Message.recipient_id', backref='recipient', lazy='dynamic')
	last_message_read_time = db.Column(db.DateTime)
	#...
	@staticmethod
	def verify_reset_password_token(token):
		#...

	def new_message(self):
		last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
		return Message.query.filter_by(recipient=self).filter(Message.timestamp > last_read_time).count()

两个关系将返回给定用户发送和接收的消息,并且在关系的Message一侧将添加authorrecipient返回引用。我使用author backref而不是一个可能更合适的sender原因是,通过使用author我可以使用我用于博客帖子的相同逻辑来呈现这些消息。last_message_read_time字段将使用户最后一次访问消息页面,并将用于确定是否有未读消息,这些消息都将具有比此字段更新的时间戳。辅助方法new_messages(),实际上使用此字段来回报用户有多少未读邮件了。到本章结束时,我将在页面顶部的导航栏中将此编号作为一个很好的徽章。

这样就完成了数据库更改,现在是时候生成新的迁移,并使用它升级数据库了:

(venv) D:\microblog>flask db migrate -m "private messages"
[2018-09-20 14:43:00,385] INFO in __init__: Microblog startup
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'message'
INFO  [alembic.autogenerate.compare] Detected added column 'user.last_message_read_time'
Generating D:\microblog\migrations\versions\e3aae588e547_private_messages.py ... done

(venv) D:\microblog>flask db upgrade
[2018-09-20 14:43:27,554] INFO in __init__: Microblog startup
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 2c59449fbf9a -> e3aae588e547, private messages

发送私人消息

接下来我将继续发送消息。我将需要一个接受该消息的简单Web表单: app/main/forms.py:私信消息表单类
#...
class MessageForm(FlaskForm):
    message = TextAreaField(_l('Message'), validators=[DataRequired(), Length(min=0, max=140)])
    submit = SubmitField(_l('Submit'))

还需要在网页上呈现此表单的HTML模板:
app/templates/send_message.html:发送私人消息HTML模板

{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block app_content %}
	<h1>{{ _('Send Message to %(recipient)s', recipient=recipient) }}</h1>
	<div class="row">
		<div class="col-md-4">
			{{ wtf.quick_form(form) }}
		</div>
	</div>
{% endblock %}

接下来,我将添加一个新的/send_message/<recipient>路由来处理私人消息的实际发送:
app/main/routes.py:发送私人消息路由

from app.main.forms import MessageForm
from app.models import Message

# ...
@bp.route('/send_message/<recipient>', methods=['GET', 'POST'])
@login_required
def send_message(recipient):
    user = User.query.filter_by(username=recipient).first_or_404()
    form = MessageForm()
    if form.validate_on_submit():
        msg = Message(author=current_user, recipient=user, body=form.message.data)
        db.session.add(msg)
        db.session.commit()
        flash(_('Your message has been sent.'))
        return redirect(url_for('main.user', username=recipient))
    return render_template('send_message.html', title=_('Send Message'), form=form, recipient=recipient)

我认为这个视图函数中的逻辑应该是不言自明的。发送私人消息的动作只需通过向数据库添加新Message实例来执行。

将所有内容联系在一起的最后一项更改是在用户配置文件页面中添加指向上述路由的链接:
app/templates/user.html:在用户个人资料页面中发送私人消息链接

				{% if user != current_user %}
					<p>
						<a href="{{ url_for('main.send_message', recipient=user.username) }}"></a>
						{{ _('Send private message') }}
					</p>
				{% endif %}

查看私人消息

此功能的第二个重要部分是查看私人消息。为此,我将在/messages中添加另一条路径,其工作方式与/index/explore页面非常相似,包括完全支持分页:
app/main/routes.py:查看消息路由

def send_message(recipient):
    #...

@bp.route('/messages')
@login_required
def messages():
    current_user.last_message_read_time = datetime.utcnow()
    db.session.commit()
    page = request.args.get('page', 1, type=int)
    messages = current_user.messages_received.order_by(Message.timestamp.desc()).paginate(page, current_app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('main.messages', page=messages.next_num) if messages.has_next else None
    prev_url = url_for('main.messages', page=messages.prev_num) if messages.has_prev else None
    return render_template('messages.html', messages=messages.items, next_url=next_url, prev_url=prev_url)

在上述这个视图函数中做的第一件事是用当前时间更新User.last_message_read_time字段。这基本上将发送给该​​用户的所有消息标记为已读。然后查询Message模型中的消息列表,按时间戳从较新到较旧排序。我决定在这里重用POSTS_PER_PAGE配置项,因为带有帖子和消息的页面看起来非常相似,但当然如果页面分歧,为消息添加单独的配置变量可能是有意义的。分页逻辑与我用于帖子的分页逻辑相同,所以这一切都应该为我们所熟悉。

上面的视图函数以渲染新的/app/templates/messages.html模板文件结束,可以在下面看到:
app/templates/messages.html:查看消息的HTML模板

{% extends "base.html" %}

{% block app_content %}
	<h1>{{ _('Message') }}</h1>
	{% for post in messages %}
		{% include '_post.html' %}
	{% endfor %}
	<nav aria-label="...">
		<ul class="pager">
			<li class="previous{% if not pre_url %} disabled{% endif %}">
				<a href="{{ pre_url or '#' }}">
					<span aria-hidden="true">&larr;</span> {{ _('Newer messages') }}
				</a>
			</li>
			<li class="next{% if not next_url %} disabled{% endif %}">
				<a href="{{ next_url or '#' }}">
					{{ _('Older messages') }} <span aria-hidden="true">&rarr;</span>
				</a>
			</li>
		</ul>
	</nav>
{% endblock %}

在这里,我采取了另一个小技巧。注意到,PostMessage实例都具有几乎相同的结构,该异常Message得到一个额外的recipient关系(即我不需要在消息页面显示,因为它始终是当前用户)。所以我决定重用app/templates /_post.html子模板来渲染私人消息。因此,此模板使用奇怪的for循环for post in messages,因此post子模板中的所有引用也可以使用消息。

为了让用户能够访问新的视图函数,导航页面会获得一个新的“Messages”链接:
app/templates/base.html:导航栏中的消息链接

                    {% if current_user.is_anonymous %}
                    ...
                    {% else %}
                    <li>
                        <a href="{{ url_for('main.messages') }}">
                            {{ _('Messages') }}
                        </a>
                    </li>
                    ...
                    {% endif %}

该功能现已完成,但作为所有这些更改的一部分,在一些地方添加了一些新文本,并且需要将这些文本合并到语言翻译中。第一步是更新所有语言目录:

(venv) D:\microblog>flask translate update
[2018-09-20 15:50:44,376] INFO in __init__: Microblog startup
...
...
writing PO template file to messages.pot
updating catalog app/translations\zh\LC_MESSAGES\messages.po based on messages.pot

然后app/translations中的每种语言都需要使用新的翻译更新其messages.po文件。

静态消息通知徽章

现在实现了私人消息功能,但当然没有任何东西可以告诉用户有私人消息等待读取。导航栏指示器的最简单实现可以使用Bootstrap徽章窗口小部件呈现为基本模板的一部分: app/templates/base.html:导航栏中的静态邮件计数徽章
                    ...
                    <li>
                        <a href="{{ url_for('main.messages') }}">
                            {{ _('Messages') }}
                            {% set new_messages = current_user.new_messages() %}
                            {% if new_messages %}
                            <span class="badge">{{ new_messages }}</span>
                            {% endif %}
                        </a>
                    </li>
                    ...

在这里,我正在调用我直接从模板添加到上面User模型中的new_messages()方法,并将该数字存储在模板变量new_messages中。然后,如果该变量不为零,我只需添加带有“消息”链接旁边的数字的徽章。以下是该页面的显示方式:
在这里插入图片描述

动态消息通知徽章

上一节中介绍的解决方案是显示通知的一种不错的简单方法,但它的缺点是徽章仅在加载新页面时出现。如果用户花费很长时间在一页上阅读内容而不点击任何链接,则在用户最终点击链接并加载新页面之前,将不会显示在此期间出现的新消息。

为了使这个应用程序对我的用户更有用,我希望徽章能够自己更新未读消息的数量,而无需用户点击链接和加载新页面。上一节解决方案的一个问题是,当页面加载时的消息计数非零时,徽章仅呈现给页面。更方便的是始终在导航栏中包含徽章,并在邮件计数为零时将其标记为隐藏。这样可以轻松地使用JavaScript显示徽章:
app/templates/base.html

                    <li>
                        <a href="{{ url_for('main.messages') }}">
                            {{ _('Messages') }}
                            {% set new_messages = current_user.new_messages() %}
                            <span id="message_count" class="badge"
                                  style="visibility: {% if new_messages %}visible
                                                     {% else %}hidden {% endif %};">
                                {{ new_messages }}
                            </span>
                        </a>
                    </li>

随着这个版本的徽章,我总是有它,但visibilityCSS属性设置为visible(当new_messages不为零),或者hidden(如果它是零)。我还在表示徽章的<span>元素中添加了一个id属性,以便使用$('#message_count')jQuery选择器轻松地处理此元素。

接下来,我可以编写一个简短的JavaScript函数来将此徽章更新为新的数字:
app/templates/base.html:导航栏中的静态邮件计数徽章

...
{% block scripts %}
    <script>
        // ...
        function set_message_count(n) {
            $('#message_count').text(n);
            $('#message_count').css('visibility', n ? 'visible' : 'hidden');
        }
    </script>
{% endblock %}

此新set_message_count()函数将设置徽章元素中的消息数,并调整可见性,以便在计数为0时隐藏徽章,否则可见。

向客户提供通知

现在剩下的是添加一种机制,通过该机制,客户端接收有关用户拥有的未读消息数量的定期更新。当其中一个更新发生时,客户端将调用set_message_count()函数以使用户知道更新。

服务器实际上有两种方法可以将这些更新传递给客户端,正如可能猜到的那样,两者都有优点和缺点,因此选择哪一种很大程度上取决于项目。在第一种方法中,客户端通过发送异步请求定期向服务器请求更新。此请求的响应是更新列表,客户端可以使用该更新列表更新页面的不同元素,例如未读消息计数标记。第二种方法需要客户端和服务器之间的特殊类型的连接,允许服务器自由地将数据推送到客户端。请注意,无论采用何种方法,我都希望将通知视为通用实体,以便我可以扩展此框架以支持除未读消息标记之外的其他类型的事件。

第一个解决方案最重要的是它易于实现。我需要做的就是添加另一个到应用程序的路径,比如/ notifications,它返回一个JSON通知列表。然后,客户端应用程序将浏览通知列表,并对每个通知应用必要的更改。此解决方案的缺点是实际事件和通知之间会有延迟,因为客户端将定期请求通知列表。例如,如果客户端每10秒钟要求通知一次,则可以在最迟10秒钟内收到通知。

第二种解决方案需要在协议级别进行更改,因为HTTP没有任何规定服务器在没有客户端询问的情况下将数据发送到客户端。到目前为止,实现服务器启动消息的最常用方法是将服务器扩展为支持HTTP之外的WebSocket连接WebSocket连接是一种与HTTP不同的协议,它在服务器和客户端之间建立永久连接客户端和服务器都可以随时向另一方发送数据,而无需另一方要求。这种机制的优点是,无论何时发生客户感兴趣的事件,服务器都可以发送通知,而不会有任何延迟。缺点是WebSocket需要比HTTP更复杂的设置,因为服务器需要与每个客户端保持永久连接。想象一下,例如,具有四个工作进程的服务器通常可以为几百个HTTP客户端提供服务,因为HTTP中的连接是短暂的并且不断被回收。同一台服务器只能处理四个WebSocket客户端,在绝大多数情况下,这将是不够的。正是出于这种限制,WebSocket应用程序通常是围绕它设计的异步服务器,因为这些服务器在管理大量工作和活动连接方面更有效。

好消息是,无论使用哪种方法,在客户端中都将拥有一个回调函数,该函数将使用更新列表进行调用。所以我可以从第一个更容易实现的解决方案开始,稍后,如果我发现它不够,请迁移到WebSocket服务器,该服务器可以配置为调用相同的客户端回调。在我看来,对于这种类型的应用,第一种解决方案实际上是可以接受的。基于WebSocket的实现对于需要以接近零延迟提供更新的应用程序非常有用。

如果你很好奇,Twitter也会使用第一种方法进行导航栏通知。Facebook使用称为长轮询的变体,它解决了直接轮询的一些限制,同时仍然使用HTTP请求。Stack Overflow和Trello是两个为其通知实现WebSocket的站点。可以通过查看浏览器调试器的“网络”选项卡,找到任何站点上发生的背景活动类型。

那么让我们继续实施轮询解决方案。首先,我将添加一个新模型来跟踪所有用户的通知,以及用户模型中的关系。
app/models.py:通知模型

# ...
from hashlib import md5
import json
from time import time
# ...
# ...

class User(UserMixin, db.Model):
    # ...
    last_message_read_time = db.Column(db.DateTime)
    notifications = db.relationship('Notification', backref='user',
                                    lazy='dynamic')

    # ...

class Message(db.Model):
	# ...
class Notification(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(128), index=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
    timestamp = db.Column(db.Float, index=True, default=time)
    payload_json = db.Column(db.Text)

    def get_data(self):
        return json.loads(str(self.payload_json))

通知将具有名称、关联用户、Unix时间戳和有效负载。时间戳从time.time()函数中获取其默认值。对于每种类型的通知,有效负载都是不同的,所以我将其写为JSON字符串,因为这将允许我编写列表、字典或单个值,如数字或字符串。我添加get_data()方法是为了方便,因此调用者不必担心JSON反序列化。

这些更改需要包含在新的数据库迁移中:

(venv) D:\microblog>flask db migrate -m "notifications"
[2018-09-20 16:44:33,959] INFO in __init__: Microblog startup
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'notification'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_notification_name' on '['name']'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_notification_timestamp' on '['timestamp']'
Generating D:\microblog\migrations\versions\34e8c369893b_notifications.py ... done

(venv) D:\microblog>flask db upgrade
[2018-09-20 16:44:44,265] INFO in __init__: Microblog startup
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade e3aae588e547 -> 34e8c369893b, notifications

为方便起见,我将把新的MessageNotification模型添加到shell上下文中,这样当我用flask shell命令启动shell时,会自动为我导入模型类:
microblog/microblog.py:将Message模型添加到shell上下文

# ...
from app.models import User, Post, Notification, Message

# ...

@app.shell_context_processor
def make_shell_context():
    return {'db': db, 'User': User, 'Post': Post, 'Message': Message,
            'Notification': Notification}

我还将在User模型中添加一个辅助方法add_notification(),以便更轻松地使用这些对象:
app/models.py:Notification模型

# ...
class User(UserMixin, db.Model):
    # ...
	def new_message(self):
		 # ...
    def add_notification(self, name, data):
        self.notifications.filter_by(name=name).delete()
        n = Notification(name=name, payload_json=json.dumps(data), user=self)
        db.session.add(n)
        return n
 # ...

此方法不仅向用户添加数据库通知,还确保如果已存在具有相同名称的通知,则首先将其删除。我打算使用的通知将被调用unread_message_count。如果数据库已经具有此名称的通知,例如值为3,则每当用户收到新消息并且消息计数变为4时,我想替换旧通知。

在未读消息计数发生变化的任何地方,我需要调用add_notification()以便我更新用户的通知。有两个地方会发生变化。首先,当用户收到新的私人消息时,在send_message()视图函数中:
app/main/routes.py:更新用户通知

@bp.route('/send_message/<recipient>', methods=['GET', 'POST'])
@login_required
def send_message(recipient):
    # ...
    if form.validate_on_submit():
        # ...
        user.add_notification('unread_message_count', user.new_messages())
        db.session.commit()
        # ...
    # ...

我需要通知用户的第二个地方是用户进入消息页面,此时未读计数返回到零:
app/main/routes.py:通知视图函数

from app.models import Notification

# ...

@bp.route('/notifications')
@login_required
def notifications():
    since = request.args.get('since', 0.0, type=float)
    notifications = current_user.notifications.filter(
        Notification.timestamp > since).order_by(Notification.timestamp.asc())
    return jsonify([{
        'name': n.name,
        'data': n.get_data(),
        'timestamp': n.timestamp
    } for n in notifications])

这是一个相当简单的函数,它返回一个JSON有效负载,其中包含用户的通知列表。每个通知都以字典形式提供,其中包含三个元素:通知名称,与通知相关的其他数据(例如消息计数)和时间戳。通知按创建顺序传递,从最旧到最新。

我不希望客户端重复通知,所以我给他们的选项是只在给定时间内请求通知。since选项可以包含在请求URL的查询字符串中,其中包含起始时间的unix时间戳,作为浮点数。如果包含此参数,则仅返回此时间之后发生的通知。

完成此功能的最后一部分是在客户端实现实际轮询。执行此操作的最佳位置是在基本模板中,以便所有页面自动继承行为:
app/templates/base.html:轮询通知

{% block scripts %}
    <script>
        // ...
        {% if current_user.is_authenticated %}
        $(function() {
            var since = 0;
            setInterval(function() {
                $.ajax('{{ url_for('main.notifications') }}?since=' + since).done(
                    function(notifications) {
                        for (var i = 0; i < notifications.length; i++) {
                            if (notifications[i].name == 'unread_message_count')
                                set_message_count(notifications[i].data);
                            since = notifications[i].timestamp;
                        }
                    }
                );
            }, 10000);
        });
        {% endif %}
    </script>

此函数包含在条件模板中,因为我只想在用户登录时轮询新消息。对于未登录的用户,将不包括此函数。

已经在第20章看到了jQuery的$(function() { ...})模式。这是在页面加载后注册要执行的函数的方法。对于此功能,我需要在页面加载时执行的操作是设置一个常规计时器,以获取用户的通知。还看到了setTimeout() JavaScript函数,该函数在特定时间过后运行作为参数给出的函数。setInterval()函数使用相同的参数作为setTimeout(),但它不是仅仅触发一次定时器,而是定期调用回调函数。在这种情况下,我的间隔设置为10秒(以毫秒为单位),因此我将看到徽章更新,其分辨率大约为每分钟六次。

与间隔计时器相关联的函数为新通知路由发出Ajax请求,并且在其完成回调中,只是迭代通知列表。收到带有名称unread_message_count的通知后,通过使用通知中给出的计数调用上面定义的函数来调整消息计数标记。

我处理这个since参数的方式可能令人困惑。我首先将此参数初始化为0。参数始终包含在请求URL中,但我不能像之前那样使用Flask的url_for()生成查询字符串,因为url_for()在服务器中运行一次,我需要since参数动态更新。第一次,请求将被发送到/notificationssince=0,但是一旦收到通知,我就会更新since到它的时间戳。这确保我不会收到重复项,因为我总是要求接收自上次通知以来发生的通知。同样重要的是要注意我宣布了 在interval函数之外的变量since,因为我不希望它是一个局部变量,我希望在所有调用中使用相同的变量。

测试本成果的最简单方法是使用两个不同的浏览器。使用不同的用户在两个浏览器上登录Microblog。然后,从其中一个浏览器向另一个用户发送一条或多条消息。另一个浏览器的导航栏应该更新,以显示您在不到10秒的时间内发送的邮件数。当您单击“消息”链接时,未读消息计数将重置为零。
在这里插入图片描述
目前为止,项目结构:

microblog/
	app/
		auth/
			__init__.py
			email.py
			forms.py
			routes.py
		errors/
			__init__.py
			handlers.py
		main/
			__init__.py
			forms.py
			routes.py
		static/
		templates/
			auth/
				login.html
				register.html
				reset_password.html
				reset_password_request.html
			email/
				reset_password.html
				reset_password.txt
			errors/
				404.html
				500.html
			_post.html
			base.html
			edit_profile.html
			index.html
			messages.html
			search.html
			send_message.html
			user.html
			user_popup.html
		translations/
			zh/
				LC_MESSAGES/
					messages.mo
					messages.po
		__init__.py
		cli.py
		email.py
		models.py
		search.py
		translate.py
	logs/
	migrations/
	env/
	app.db
	babel.cfg
	boot.sh
	config.py
	Dockerfile
	microblgo.env
	microblog.py
	Procfile
	readme
	requirements.txt
	tests.py

参考:
作者博客
源代码

如需转载请注明出处。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值