本章基於 token
認證,添加 創建用戶、獲取單個/所有用戶、修改用戶、刪除用戶 等 API
接口,測試工具 HTTPie/Postman
。
1. 拉取最新代碼
# 查看遠程地址
$ git remote -v
origin https://gitee.com/hubery_jun/flask-vuejs-madblog (fetch)
origin https://gitee.com/hubery_jun/flask-vuejs-madblog (push)
# 類似於 git pull,也是用於拉取最新代碼
$ git fetch
# 或拉取指定的遠程主機上的分支,如 origin 上的 master
$ git fetch origin master
git fetch 與 git pull 的區別
git fetch
:- 遠端跟蹤分支:可以更改遠端跟蹤分支
- 拉取:會將數據拉取到本地倉庫,但是不會自動合並或修改當前的工作
commitID
:本地庫中master
的commitID
不變,還是等於 1
git pull
:- 遠端跟蹤分支:無法對遠端跟蹤分支操作,必須先切回到本地分支然后創建一個新的
commit
提交 - 拉取:從遠處獲取最新版本,並合並到本地,會自動合並或修改當前的工作
commitID
:本地庫中master
的commitID
發生改變,變成了 2
- 遠端跟蹤分支:無法對遠端跟蹤分支操作,必須先切回到本地分支然后創建一個新的
創建 dev 分支
git checkout -b dev
git branch
2. 用戶模型設計
2.1 使用 ORM SQLAlchemy
兩個插件:
flask-sqlalchemy
:ORM
相關Flask-Migrate
:用於遷移數據表結構
1、安裝:
pip install flask-sqlalchemy flask-migrate
pip freeze > requirements.txt
2、配置 SQLite
數據庫,修改 back-end/config.py
:
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'), encoding='utf-8')
class Config(object):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
注意:遷移成功后,會生成一個
back-end/app.db
數據庫文件,可以使用Navicat
可視化工具打開!
3、初始化數據庫,app/__init__.py
:
# 數據庫相關
db = SQLAlchemy()
migrate = Migrate()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# 跨域
CORS(app)
# 初始化數據庫
db.init_app(app)
migrate.init_app(app, db)
# 注冊藍圖 blueprint
from app.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api")
return app
from app import models
2.2 定義用戶模型
1、創建 app/models.py
:
class User(db.Model):
"""用戶對象"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True) # index 創建索引
email = db.Column(db.String(120), index=True, unique=True)
password_hash = db.Column(db.String(128)) # 密碼加密(hash),不存明文
def __str__(self):
return '<User {}>'.format(self.username)
2、創建遷移存儲庫:
(flask-vuejs) F:\My Projects\flask-vuejs-madblog\back-end> flask db init
3、生成遷移腳本:
# -m 參數:添加記錄
(flask-vuejs) F:\My Projects\flask-vuejs-madblog\back-end> flask db migrate -m "add users table"
2、將遷移腳本應用到數據庫中:
# flask db upgrade 還可以回滾到上次的遷移,需要指定
(flask-vuejs) F:\My Projects\flask-vuejs-madblog\back-end> flask db upgrade
2.3 密碼哈希
在數據表中,不能直接保存明文密碼,這里我們將使用 werkzeug.security
庫的 generate_password_hash
和 check_password_hash
來創建哈希密碼和驗證密碼的 hash
是否一致。
更新 app/models.py
:
from werkzeug.security import generate_password_hash, check_password_hash
class User(PaginationAPIMixin, db.Model):
"""用戶對象"""
...
def generate_password(self, password):
"""密碼哈希"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""檢查密碼是否正確"""
return check_password_hash(self.password_hash, password)
配置 Flask shell 環境
flask shell
可以與項目環境進行交互(會啟動一個 Python
解釋器包含應用的上下文),默認不支持 db
數據庫模型的使用,需要額外配置。
1、修改 back-end/madblog.py
:
from app import create_app, db
from app.models import User
app = create_app()
@app.shell_context_processor
def make_shell_context():
"""配置flask shell 上下文"""
return {'db': db, 'User': User}
2、在終端進入 flask shell
:
(flask-vuejs) F:\My Projects\flask-vuejs-madblog\back-end>flask shell
Python 3.6.8 (tags/v3.6.8:3c6b436a57, Dec 24 2018, 00:16:47) [MSC v.1916 64 bit (AMD64)] on win32
App: app [production]
Instance: F:\My Projects\flask-vuejs-madblog\back-end\instance
>>> app
<Flask 'app'>
>>> db
<SQLAlchemy engine=sqlite:///F:\My Projects\flask-vuejs-madblog\back-end\app.db>
>>> User
<class 'app.models.User'>
>>> u = User(username='rose', email='rose@qq.com')
>>> u.generate_password('123456')
>>> u.check_password('123456')
True
注意:需要先進入項目虛擬環境!
3. 用戶相關 API 設計
用戶資源相關的 api
:
HTTP方法 | 資源URL | 說明 |
---|---|---|
GET | /api/users | 返回所有用戶的集合 |
POST | /api/users | 注冊一個新用戶 |
GET | /api/users/
|
返回一個用戶 |
PUT | /api/users/
|
修改一個用戶 |
DELETE | /api/users/
|
刪除一個用戶 |
新建:app/api/users.py
:
from app.api import bp
@bp.route('/users', methods=['POST'])
def create_user():
'''注冊一個新用戶'''
pass
@bp.route('/users', methods=['GET'])
def get_users():
'''返回所有用戶的集合'''
pass
@bp.route('/users/<int:id>', methods=['GET'])
def get_user(id):
'''返回一個用戶'''
pass
@bp.route('/users/<int:id>', methods=['PUT'])
def update_user(id):
'''修改一個用戶'''
pass
@bp.route('/users/<int:id>', methods=['DELETE'])
def delete_user(id):
'''刪除一個用戶'''
pass
記得要將 users
添加到 api/__init__.py
:
from app.api import ping, users
3.1 用戶對象轉換成 JSON
因為 API
接口返回給前端的數據為 json
數據,所以封裝 User
模型為 json
形式,方便傳遞,app/models.py
新增:
class User(db.Model):
"""用戶對象"""
...
def to_dict(self, include_email=False):
"""
封裝 User 對象,傳遞給前端只能是 json 格式,不能是實例對象
:param include_email: 只有當用戶請求自己數據時,才包含 email
:return:
"""
data = {
'id': self.id,
'username': self.username,
'_links': {
'self': url_for('api.get_user', id=self.id)
}
}
if include_email:
data['email'] = self.email
return data
include_email
用來標記 email
字段是否在字典中,只有當用戶請求自己的數據時,才包含。
3.2 用戶集合轉換為 JSON
當獲取所有用戶數據時也需要封裝為 json
形式,另外還包含了分頁信息,為了后續能夠重復利用,將其設計為通用設計類,app/models.py
:
import base64
import os
from datetime import datetime, timedelta
from flask import url_for
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
class PaginationAPIMixin:
@staticmethod
def to_collection_dict(query, page, per_page, endpoint, **kwargs):
# 分頁查詢,error_out 表示頁數不是 int 或 超過總頁數時,會報錯,並返回 404,默認為 True
resources = query.paginate(page, per_page, error_out=False)
data = {
'items': [item.to_dict() for item in resources.items],
'_meta': {
'page': page,
'per_page': per_page,
'total_pages': resources.pages, # 總頁數
'total_items': resources.total # 總條數
},
'_links': {
'self': url_for(endpoint, page=page, per_page=per_page, **kwargs), # "/api/users?page=1&per_page=10"
'next': url_for(endpoint, page=page + 1, per_page=per_page, **kwargs) if resources.has_next
else None,
'prev': url_for(endpoint, page=page - 1, per_page=per_page, **kwargs) if resources.has_prev
else None
}
}
return data
然后 User
類只需集成它即可:
class User(PaginationAPIMixin, db.Model):
"""用戶對象"""
3.3 JSON 轉換為用戶對象
將前端傳過來的 JSON
數據轉換為 User
對象,app/models.py
:
class User(PaginationAPIMixin, db.Model):
"""用戶對象"""
....
def from_dict(self, data, new_user=False):
"""
將前端發送過來的 json 對象轉換為 User 對象
:param data:
:param new_user:
:return:
"""
for field in ['username', 'email']:
if field in data:
# 給實例對象添加屬性字典
setattr(self, field, data[field])
if new_user and 'password' in data:
self.generate_password(data['password'])
3.4 錯誤處理
創建 app/api/errors.py
:
from flask import jsonify
from werkzeug.http import HTTP_STATUS_CODES
from app import db
from app.api import bp
def error_response(status_code, message=None):
payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknow error')}
if message:
payload['message'] = message
response = jsonify(payload)
response.status_code = status_code
return response
def bad_request(message):
"""
異常請求,如:400
:param message:
:return:
"""
return error_response(400, message)
3.5 創建新用戶
編輯 app/api/users.py
:
@bp.route('/users', methods=['POST'])
def create_user():
"""創建一個新用戶"""
data = request.get_json()
if not data:
return bad_request("post 必須是 json 數據!")
message = {}
username = data.get('username', None)
email = data.get('email', None)
password = data.get('password', None)
# 判斷是否為空
if 'username' not in data or not username:
message['username'] = "請提供一個有效的用戶名!"
pattern = '^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
if 'email' not in data or not re.match(pattern, email):
message['email'] = "請提供一個有效的郵箱地址!"
if 'password' not in data or not password:
message['password'] = "請提供一個有效的密碼!"
# 檢查數據庫中是否有該用戶
if User.query.filter(or_(User.username == username, User.email == email)).first():
message['username'] = "用戶名或郵箱已存在!"
if message:
return bad_request(message)
# 創建新用戶
user = User()
user.from_dict(data, new_user=True)
db.session.add(user)
db.session.commit()
response = jsonify(user.to_dict())
response.status_code = 201
response.headers['Location'] = url_for('api.get_user', id=user.id) # /api/users/1
return response
使用 HTTPie
模塊來測試 API
接口:
pip install --upgrade httpie
pip freeze > requirements.txt
測試結果:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http POST http://127.0.0.1:5000/api/users username=john password=123456 email=john@qq.com
HTTP/1.0 201 CREATED
Access-Control-Allow-Origin: *
Content-Length: 60
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:42:49 GMT
Location: http://127.0.0.1:5000/api/users/3
Server: Werkzeug/1.0.1 Python/3.6.8
{
"_links": {
"self": "/api/users/3"
},
"id": 3,
"username": "john"
}
3.6 查詢單個用戶
編輯 app/api/users.py
:
@bp.route('/users/<int:id>', methods=['GET'])
def get_user(id):
"""返回一個用戶"""
return jsonify(User.query.get_or_404(id).to_dict())
測試結果:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users/3
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
Content-Length: 60
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:43:34 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"_links": {
"self": "/api/users/3"
},
"id": 3,
"username": "john"
}
可以看到返回的就是 to_dict()
封裝的數據。
構造查詢不存在時返回的數據
當查詢不存在的用戶,也返回一個 JSON
數據,修改 app/api/errors.py
,新增:
@bp.app_errorhandler(404)
def not_found_error(error):
return error_response(404)
@bp.app_errorhandler(500)
def internal_error(error):
db.session.rollback()
return error_response(500)
測試結果:
# 測試不存在的用戶
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users/36
HTTP/1.0 404 NOT FOUND
Access-Control-Allow-Origin: *
Content-Length: 22
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:43:53 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"error": "Not Found"
}
3.7 查詢所有用戶
編輯 app/api/users.py
,新增:
@bp.route('/users', methods=['GET'])
def get_users():
"""用戶集合,分頁"""
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
data = User.to_collection_dict(User.query, page, per_page, 'api.get_users')
return jsonify(data)
page
為當前頁碼數,per_page
為每頁要顯示的條數。
測試結果:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
Content-Length: 331
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:44:57 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"_links": {
"next": null,
"prev": null,
"self": "/api/users?page=1&per_page=10"
},
"_meta": {
"page": 1,
"per_page": 10,
"total_items": 3,
"total_pages": 1
},
"items": [
{
"_links": {
"self": "/api/users/1"
},
"id": 1,
"username": "rose"
},
{
"_links": {
"self": "/api/users/2"
},
"id": 2,
"username": "lila"
},
{
"_links": {
"self": "/api/users/3"
},
"id": 3,
"username": "john"
}
]
}
3.8 修改用戶
編輯 app/api/users.py
,新增:
@bp.route('/users/<int:id>', methods=['PUT'])
def update_user(id):
"""修改一個用戶"""
user = User.query.get_or_404(id)
data = request.get_json()
if not data:
return bad_request("post 必須是 json 數據!")
message = {}
username = data.get('username', None)
email = data.get('email', None)
# 判斷是否為空
if 'username' in data and not username:
message['username'] = "請提供一個有效的用戶名!"
pattern = '^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
if 'email' in data and not re.match(pattern, email):
message['email'] = "請提供一個有效的郵箱地址!"
if 'username' in data and data['username'] != user.username and \
User.query.filter_by(username=data['username']).first():
message['username'] = '請使用一個不同的用戶名!'
if 'email' in data and data['email'] != user.email and \
User.query.filter_by(email=data['email']).first():
message['email'] = '請使用一個不同的郵箱!'
if message:
return bad_request(message)
user.from_dict(data, new_user=False)
db.session.commit()
return jsonify(user.to_dict())
測試結果:
# 輸入要修改的用戶 ID 和要修改的字段
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http PUT http://127.0.0.1:5000/api/users/3 email=john@outlook.com
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
Content-Length: 60
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:49:37 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"_links": {
"self": "/api/users/3"
},
"id": 3,
"username": "john"
}
4. API 認證
所謂 API
認證,即只有得到認證過的請求,才能訪問特定的 API
,比如(登錄認證、token
認證等),這里采用的是 Flask-HTTPAuth
模塊。
它需要使用用戶名和密碼進行 Basic Auth
驗證,然后獲得一個臨時 token
。只要 token
有效,客戶端就可以發送附帶 token
的 API 請求以通過認證。一旦 token
到期,需要申請新的 token
。
安裝:
pip install flask-httpauth
pip freeze > requirements.txt
4.1 User 用戶模型添加 token
編輯 app/models.py
:
import base64
import os
from datetime import datetime, timedelta
class User(PaginationAPIMixin, db.Model):
"""用戶對象"""
....
# token 驗證 API(需要登錄才能請求)
token = db.Column(db.String(32), index=True, unique=True)
token_expiration = db.Column(db.DateTime) # token 過期時間
def get_token(self, expires_in=3600):
now = datetime.utcnow()
# 大於 一分鍾
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode('utf-8') # 生成 token
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def revoke_token(self):
"""撤銷 token,當前 utc 時間減去 1 秒"""
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
@staticmethod
def check_token(token):
"""檢查 token"""
user = User.query.filter_by(token=token).first()
# 若沒有 token 或者 token 已過期,返回 None,不准請求
if user is None or user.token_expiration < datetime.utcnow():
return None
return user
因為新增了字段,所以需要遷移生成新的數據表:
flask db migrate -m "user add tokens"
flask db upgrade
4.2 HTTP Basic Authentication
創建 app/api/auth.py
:
from flask import g
from flask_httpauth import HTTPBasicAuth
from app.models import User
from app.api.errors import error_response
basic_auth = HTTPBasicAuth()
@basic_auth.verify_password
def verify_password(username, password):
'''用於檢查用戶提供的用戶名和密碼'''
user = User.query.filter_by(username=username).first()
if user is None:
return False
g.current_user = user
return user.check_password(password)
@basic_auth.error_handler
def basic_auth_error():
'''用於在認證失敗的情況下返回錯誤響應'''
return error_response(401)
4.3 客戶端申請 token
上面我們已經實現了 Basic Auth
驗證的支持,新增添加一條 token
路由,創建 app/api/tokens.py
:
from app import db
from app.api import bp
from app.api.auth import basic_auth
@bp.route('/tokens', methods=['POST'])
@basic_auth.login_required
def get_token():
token = g.current_user.get_token()
db.session.commit()
return jsonify({'token': token})
裝飾器 @basic_auth.login_required
將指示 Flask-HTTPAuth
驗證身份,當通過 Basic Auth
驗證后,才使用用戶模型的 get_token()
方法來生成 token
,數據庫提交在生成 token
后發出,以確保 token
及其到期時間被寫回到數據庫。
修改 app/api/__init__.py
,在末尾添加:
from app.api import ping, users, tokens
測試
測試生成一個token
,直接請求,會提示需要登錄:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http POST http://127.0.0.1:5000/api/tokens
HTTP/1.0 401 UNAUTHORIZED
Access-Control-Allow-Origin: *
Content-Length: 25
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:50:32 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
WWW-Authenticate: Basic realm="Authentication Required"
{
"error": "Unauthorized"
}
需要帶上用戶登錄信息:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http --auth john:123456 POST http://127.0.0.1:5000/api/tokens
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
Content-Length: 45
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:51:15 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"token": "G4d8FwoEdOODyhBBe8nz30vCe0X+YUAI"
}
4.4 HTTP Token Authentication
用戶通過 Basic Auth
獲取到 token
后,之后的請求需要帶上這個 token
才能訪問其他 API
,修改 app/api/auth.py
:
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
...
token_auth = HTTPTokenAuth()
...
@token_auth.verify_token
def verify_token(token):
'''用於檢查用戶請求是否有token,並且token真實存在,還在有效期內'''
g.current_user = User.check_token(token) if token else None
return g.current_user is not None
@token_auth.error_handler
def token_auth_error():
'''用於在 Token Auth 認證失敗的情況下返回錯誤響應'''
return error_response(401)
4.5 使用 Token 機制保護 API 路由
除了創建用戶不用保護以后,其他路由都需要 Token
保護,app/api/users.py
:
@bp.route('/users', methods=['GET'])
@token_auth.login_required
def get_users():
...
@bp.route('/users/<int:id>', methods=['GET'])
@token_auth.login_required
def get_user(id):
...
...
只需給視圖函數添加 @token_auth.login_required
裝飾器即可。
測試
為攜帶 token
的請求,會得到一個 401
的錯誤:
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users/3
HTTP/1.0 401 UNAUTHORIZED
Access-Control-Allow-Origin: *
Content-Length: 25
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:54:38 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
WWW-Authenticate: Bearer realm="Authentication Required"
{
"error": "Unauthorized"
}
攜帶 token
的請求,返回 200:
# 需要添加 Authorization 頭部
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users/3 "Authorization:Bearer G4d8FwoEdOODyhBBe8nz30vCe0X+YUAI"
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
Content-Length: 60
Content-Type: application/json
Date: Mon, 31 Aug 2020 02:55:20 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
{
"_links": {
"self": "/api/users/3"
},
"id": 3,
"username": "john"
}
4.6 撤銷 token
修改 app/api/tokens.py
:
from app.api.auth import basic_auth, token_auth
...
@bp.route('/tokens', methods=['DELETE'])
@token_auth.login_required
def revoke_token():
g.current_user.revoke_token()
db.session.commit()
return '', 204
測試:
# 刪除 token
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http DELETE http://127.0.0.1:5000/api/tokens "Authorization:Bearer G4d8FwoEdOODyhBBe8nz30vCe0X+YUAI"
HTTP/1.0 204 NO CONTENT
Access-Control-Allow-Origin: *
Content-Type: text/html; charset=utf-8
Date: Mon, 31 Aug 2020 03:02:08 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
# 再使用這條 token 進行請求,發現請求失敗
(flask-vuejs) F:\Envs\flask-vuejs\Scripts>http GET http://127.0.0.1:5000/api/users/3 "Authorization:Bearer G4d8FwoEdOODyhBBe8nz30vCe0X+YUAI"
HTTP/1.0 401 UNAUTHORIZED
Access-Control-Allow-Origin: *
Content-Length: 25
Content-Type: application/json
Date: Mon, 31 Aug 2020 03:02:18 GMT
Server: Werkzeug/1.0.1 Python/3.6.8
WWW-Authenticate: Bearer realm="Authentication Required"
{
"error": "Unauthorized"
}
5. 提交代碼
項目結構:
back-end/
├─app
│ ├─api
│ │ └─__init__.py
│ │ └─auth.py
│ │ └─errors.py
│ │ └─ping.py
│ │ └─tokens.py
│ │ └─users.py
│ └─__init__.py__
│ └─models.py__
├─migrations
└─.env
└─.gitignore
└─app.db
└─config.py
└─madblog.py
└─requirements.txt
合並分支並推送到遠端
$ git add .
$ git commit -m "3. Flask設計User用戶相關API"
$ git checkout master
$ git merge dev
$ git branch -d dev
$ git push -u origin master
打標簽
$ git tag v0.3
hj@DESKTOP-JUS39UG MINGW32 /f/My Projects/flask-vuejs-madblog (master)
$ git push origin v0.3
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Powered by GITEE.COM [GNK-5.0]
To https://gitee.com/hubery_jun/flask-vuejs-madblog
* [new tag] v0.3 -> v0.3