Compare commits

..

No commits in common. 'main' and 'master' have entirely different histories.
main ... master

3
.gitignore vendored

@ -0,0 +1,3 @@
.idea
__pycache__/
*.log

@ -0,0 +1,7 @@
FROM python:3.12.4-slim
WORKDIR /app
COPY requirements.txt requirements.txt
RUN pip config set global.index-url http://mirrors.aliyun.com/pypi/simple && pip config set install.trusted-host mirrors.aliyun.com
RUN pip install -r requirements.txt
COPY . ./
CMD ["gunicorn", "-w", "4", "--bind", "0.0.0.0:3002", "myapp:app" ]

1
H_D

@ -0,0 +1 @@
Subproject commit 50b2c113694b3c40ed3aea797ce341532d5bebd7

@ -50,4 +50,4 @@ def create_app():
app.register_blueprint(id_card_bp)
app.register_blueprint(order_bp)
return app
return app

@ -34,4 +34,4 @@ def query():
# 模拟验证成功,返回
state = {"state": "successful", "pay_time": datetime.datetime.now()}
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=state)), 200
return jsonify(create_response(StateCode.SUCCESS, data=state)), 200

@ -15,4 +15,4 @@ def idCardVerify():
"result": bool(re.match(pattern, id_number))
}
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=state)), 200
return jsonify(create_response(StateCode.SUCCESS, data=state)), 200

@ -0,0 +1,25 @@
from flask import request
from loguru import logger
class LogService:
@staticmethod
def configure_logging():
logger.remove() # 移除默认的日志配置
logger.add(
"logs/app.log", # 日志文件名
rotation="1 week", # 每周轮换
retention="1 month", # 保留一个月
level="INFO", # 记录 INFO 及以上级别的日志
format="{time} - {level} - {message}", # 日志格式
backtrace=True, # 打印完整的异常堆栈
diagnose=True # 打印详细的异常信息
)
@staticmethod
def log():
logger.info(f"Request Path: {request.path}")
logger.info(f"Request Method: {request.method}")
logger.info(f"Request Headers: {request.headers}")
logger.info(f"Request args: {request.args}")
logger.info(f"Request Body: {request.get_data(as_text=True)}")

@ -0,0 +1,25 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import create_access_token
from app import LogService
from app.models import Passenger
from presenter import PassengerPresenter
from utils import StateCode, create_response
login_bp = Blueprint('login', __name__)
@login_bp.route('/login', methods=['POST'])
def login():
data = request.json
account = data.get('account')
password = data.get('password')
if not account or not password:
return jsonify(create_response(StateCode.PARAMS_ERROR)), 400
user = Passenger.verifyPassenger(account, password)
if user:
access_token = create_access_token(identity=user.id)
user_presenter = PassengerPresenter(user, {"token": access_token}).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=user_presenter)), 200
return jsonify(create_response(StateCode.PASSWORD_INCORRECT)), 400

@ -0,0 +1,49 @@
import random
import re
from flask import current_app, jsonify, request, Blueprint
import utils
from app import redis_client, LogService
from presenter import MobileCodePresenter
from utils import create_response, StateCode
key = current_app.config['SECRET_KEY']
mobile_bp = Blueprint('mobile_server', __name__)
@mobile_bp.route('/mobile/get_verify_code', methods=['POST'])
# 发送一次性密码(验证码)
def getVerifyCode():
mobile_no = request.json.get('mobileNo')
if not utils.checkMobile(mobile_no):
return jsonify(create_response(StateCode.MOBILE_ERROR)), 400
sent_code = getVerificationCode(mobile_no)
mobile_present = MobileCodePresenter(sent_code).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=mobile_present)), 200
@mobile_bp.route('/mobile/check', methods=['POST'])
def checkMobile():
pattern = r'^1[3-9]\d{9}$'
number = request.json.get('mobileNo')
state = {
"result": bool(re.match(pattern, number))
}
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=state)), 200
def getVerificationCode(mobile_no):
verification_code = generateRandomNumber() # 生成验证码
if redis_client.set(mobile_no, verification_code, 300):
return verification_code
else:
print(f"{redis_client.client}")
# 生成6位随机数字
def generateRandomNumber(length=6):
# 确保生成的数字至少有6位即使前几位是0
number = random.randint(10 ** (length - 1), 10 ** length - 1)
return number

@ -0,0 +1,6 @@
from app import db
from .order_lib import Order
from .passenger_lib import Passenger
from .station_lib import Station
from .train_lib import Train

@ -0,0 +1,103 @@
import pdb
import random
import time
import uuid
from datetime import timedelta, datetime
from sqlalchemy import func
from app.models import db
from app.models.ticket_lib import Ticket
from app.models.train_station_lib import TrainStation
class Order(db.Model):
__tablename__ = 'order'
id = db.Column(db.Integer, primary_key=True)
order_no = db.Column(db.String(120), unique=True, nullable=False)
price = db.Column(db.Numeric(8, 2))
payment_time = db.Column(db.DateTime)
state = db.Column(db.Integer, default=0)
passenger_id = db.Column(db.Integer, db.ForeignKey('passenger.id'), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
passenger = db.relationship('Passenger', backref=db.backref('orders'))
def __repr__(self):
return f'<Order {self.id}>'
@classmethod
def setOrderState(cls, order_no, passenger_id, **kwargs):
order = cls.query.filter_by(order_no=order_no, passenger_id=passenger_id).first()
if order:
# 更新订单的属性
for key, value in kwargs.items():
setattr(order, key, value)
if key == 'state' and value == 1:
for ticket in order.tickets:
Ticket.updateState(ticket) # Update state to 1
# 提交更改到数据库
db.session.commit()
return order
else:
# 处理订单未找到的情况
return None
def generate_unique_id(length=4):
# 获取当前的秒级时间戳
timestamp_seconds = int(time.time())
# 生成 4 位随机数
random_number = random.randint(0, 10 ** length - 1)
# 确保随机数是 4 位数字
random_number_str = str(random_number).zfill(length)
# 生成唯一标识符
unique_id = f"{timestamp_seconds}{random_number_str}"
return unique_id
def generateOrder(params, passenger_id):
# Create a new Order object
order = Order(
order_no=generate_unique_id(5),
passenger_id=passenger_id
)
for e in params['tickets']:
# 创建Ticket对象
ticket = Ticket.generateTicket(e, passenger_id)
# 计算起始站和终点站
from_station = TrainStation.query.filter_by(train_no=ticket.train_no, station_name=ticket.from_station).one()
to_station = TrainStation.query.filter_by(train_no=ticket.train_no, station_name=ticket.to_station).one()
# 计算区间价格
train_stations = TrainStation.query.filter(
TrainStation.train_no == ticket.train_no,
TrainStation.index.between(from_station.index, to_station.index)
).all()
ticket.price = sum(e.price for e in train_stations)
# 计算座位号
seat_count = db.session.query(func.count(Ticket.id)).filter_by(
train_no=ticket.train_no,
date=ticket.date
).scalar()
ticket.seat_no = seat_count + 1
# 转换时间 计算出发时间
interval_days = (
datetime.strptime(ticket.date, "%Y/%m/%d") - datetime.combine(from_station.departure_time.date(),
datetime.min.time())).days
ticket.departure_time = from_station.departure_time + timedelta(days=interval_days)
ticket.arrival_time = to_station.arrival_time + timedelta(days=interval_days)
order.tickets.append(ticket)
order.price = sum(ticket.price for ticket in order.tickets)
return order

@ -0,0 +1,58 @@
from sqlalchemy import func
from app.models import db
from werkzeug.security import generate_password_hash, check_password_hash
def isAvailable(account):
Passenger.query.filter_by(account=account).first()
class Passenger(db.Model):
__tablename__ = 'passenger'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120))
account = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_digest = db.Column(db.String(2000), nullable=False)
id_card_no = db.Column(db.String(120))
mobile_no = db.Column(db.String(120))
bank_card_no = db.Column(db.String(120))
state = db.Column(db.Integer, default=0)
member_type = db.Column(db.Integer)
last_login_time = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
def check_password(self, password):
return check_password_hash(self.password_digest, password) # 验证密码
@classmethod
def verifyPassenger(cls, account, password):
passenger = cls.query.filter_by(account=account).first()
if passenger and passenger.check_password(password):
return passenger
return None
@classmethod
def create(cls, data):
passenger = cls(
account=data.get('account'),
password_digest=generate_password_hash(data.get('password')),
name=data.get('name'),
id_card_no=data.get('idCardNo'),
mobile_no=data.get('mobileNo'),
bank_card_no=data.get('bankCard'),
)
db.session.add(passenger)
db.session.commit()
return passenger
@classmethod
def destroy(cls, passenger_id):
passenger = cls.query.get(passenger_id)
if passenger:
db.session.delete(passenger)
db.session.commit()
return True
return False

@ -0,0 +1,45 @@
import pdb
from datetime import datetime
from pypinyin import pinyin, Style
from sqlalchemy import func
from app.models import db
class Station(db.Model):
__tablename__: str = 'station'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), unique=True, nullable=False, index=True)
pinyin = db.Column(db.String(120))
province = db.Column(db.String(120))
city = db.Column(db.String(120))
district = db.Column(db.String(120))
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
def __repr__(self):
return f'<Station {self.name}>'
@classmethod
def create(cls, data):
station = cls(
name=data.get('name'),
pinyin=''.join([item[0] for item in pinyin(data.get('name'), style=Style.NORMAL)]).upper(),
province=data.get('province'),
city=data.get('city'),
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.session.add(station)
db.session.commit()
return station
@classmethod
def destroy(cls, station_id):
station = cls.query.get(station_id)
if station:
db.session.delete(station)
db.session.commit()
return True
return False

@ -0,0 +1,45 @@
from sqlalchemy import func
from app.models import db
class Ticket(db.Model):
__tablename__: str = 'ticket'
id = db.Column(db.Integer, primary_key=True)
seat_no = db.Column(db.String(120))
seat_class = db.Column(db.String(120))
price = db.Column(db.Numeric(8, 2))
state = db.Column(db.Integer, default=0)
train_no = db.Column(db.String(120), db.ForeignKey('train.train_no'), nullable=False, index=True)
passenger_id = db.Column(db.Integer, db.ForeignKey('passenger.id'), nullable=False, index=True)
order_id = db.Column(db.Integer, db.ForeignKey('order.id'), nullable=False, index=True)
from_station = db.Column(db.String(120))
to_station = db.Column(db.String(120))
date = db.Column(db.String(120))
departure_time = db.Column(db.DateTime)
arrival_time = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
order = db.relationship('Order', backref=db.backref('tickets'))
passenger = db.relationship('Passenger', backref=db.backref('tickets'))
def __repr__(self):
return f'<Ticket {self.id}>'
def updateState(self):
self.state = 1
return self
@classmethod
def generateTicket(cls, item, passenger_id):
# Create a new Ticket object
ticket = Ticket(
seat_class=item["seatClass"],
train_no=item["trainNo"],
from_station=item["from"],
to_station=item["to"],
date=item["date"],
passenger_id=passenger_id
)
return ticket

@ -0,0 +1,91 @@
from sqlalchemy import func
from app.models import db
from app.models.train_station_lib import TrainStation
class Train(db.Model):
__tablename__: str = 'train'
id = db.Column(db.Integer, primary_key=True)
train_no = db.Column(db.String(120), unique=True, nullable=False, index=True)
departure_station = db.Column(db.String(120))
arrival_station = db.Column(db.String(120))
departure_time = db.Column(db.DateTime)
expiration_time = db.Column(db.DateTime)
effective_time = db.Column(db.DateTime)
arrival_time = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
def __repr__(self):
return f'<Train {self.id}>'
@classmethod
def create(cls, new_train):
db.session.add(new_train)
db.session.commit()
return new_train
@classmethod
def queryTrains(cls, from_station, to_station, date):
# Query for train stations where the station name matches `from_station`
from_train = TrainStation.query.filter_by(station_name=from_station).all()
# Query for train stations where the station name matches `to_station`
to_train = TrainStation.query.filter_by(station_name=to_station).all()
# Extract train_no from both query results
from_train_nos = {ts.train_no for ts in from_train}
to_train_nos = {ts.train_no for ts in to_train}
# Find the common train_no between the two stations
common_train_nos = from_train_nos & to_train_nos
# Filter train numbers where the index of the from station is less than the index of the to station
valid_train_nos = [
train_no for train_no in common_train_nos
if next(ts.index for ts in from_train if ts.train_no == train_no) <
next(ts.index for ts in to_train if ts.train_no == train_no)
]
# Query trains by the filtered train numbers and the given date (assuming date filtering)
trains = Train.query.filter(
Train.effective_time >= date,
Train.train_no.in_(valid_train_nos)
).all()
# Assuming you have a presenter or serializer for the trains
return trains
def buildTrain(params):
# Create a new Train object
train = Train(
train_no=params['trainNo'],
effective_time=params['effective_time'],
expiration_time=params['expiration_time']
)
# Extract indexes for determining first and last station
indexes = [e["index"] for e in params['stations']]
for e in params['stations']:
# Create and associate TrainStation objects
train_station = TrainStation(
station_name=e["name"],
price=e["price"],
departure_time=e["depTime"],
arrival_time=e["arrTime"],
index=e["index"]
)
train.train_stations.append(train_station)
# Determine the departure time and station for the first station
if e["index"] == 0:
train.departure_time = e["depTime"]
train.departure_station = e["name"]
# Determine the arrival time and station for the last station
if e["index"] == max(indexes):
train.arrival_time = e["arrTime"]
train.arrival_station = e["name"]
return train

@ -0,0 +1,21 @@
from sqlalchemy import func
from app.models import db
class TrainStation(db.Model):
__tablename__: str = 'train_station'
train_no = db.Column(db.String(120), db.ForeignKey('train.train_no'), primary_key=True, index=True)
station_name = db.Column(db.String(120), db.ForeignKey('station.name'), primary_key=True)
price = db.Column(db.Numeric(8, 2))
arrival_time = db.Column(db.DateTime)
departure_time = db.Column(db.DateTime)
index = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=func.now())
updated_at = db.Column(db.DateTime, default=func.now())
station = db.relationship('Station', backref=db.backref('train_stations'))
train = db.relationship('Train', backref=db.backref('train_stations'))

@ -0,0 +1,49 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import get_jwt_identity, jwt_required
from app import db, LogService
from app.models.order_lib import generateOrder, Order
from presenter.order import OrderPresenter
from utils import create_response, StateCode
from utils.server import verifyOrderPayment
order_bp = Blueprint('order', __name__)
@order_bp.route('/orders', methods=['POST'])
@jwt_required()
def createOrder():
current_user = get_jwt_identity()
data = request.json
new_order = generateOrder(data, current_user)
db.session.add(new_order)
db.session.commit()
order_presenter = OrderPresenter(new_order).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=order_presenter)), 200
@order_bp.route('/orders/<string:order_no>/query_payment', methods=['POST'])
@jwt_required()
def queryPayment(order_no):
current_user = get_jwt_identity()
response = verifyOrderPayment(order_no)
if response.status_code == 200 and response.json().get("data")["state"] == "successful":
order = Order.setOrderState(order_no, current_user,
state=1, payment_time=response.json().get("data")["pay_time"])
order_presenter = OrderPresenter(order).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=order_presenter)), 200
else:
LogService.log()
return jsonify(create_response(StateCode.ORDER_PAY_ERROR)), 400
@order_bp.route('/orders', methods=['GET'])
@jwt_required()
def queryOrder():
state = request.args.get("state")
current_user = get_jwt_identity()
orders = Order.query.filter(Order.passenger_id == current_user, Order.state == state).all()
order_presenter = [OrderPresenter(order).as_dict() for order in orders]
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=order_presenter)), 200

@ -0,0 +1,62 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
from app import redis_client, LogService
from app.models.passenger_lib import Passenger, isAvailable
from presenter import PassengerPresenter
from utils import StateCode, create_response, checkMobile, checkIdCard, checkBankCard
register_bp = Blueprint('register', __name__)
@register_bp.route('/register', methods=['POST'])
def register():
data = request.json
account = data.get('account')
password = data.get('password')
mobile_no = data.get('mobileNo')
mobile_code = data.get('mobileCode')
id_card_no = data.get('idCardNo')
bank_card_no = data.get('bankCard')
if not account or not password:
return jsonify(create_response(StateCode.PARAMS_ERROR)), 400
if not checkMobile(mobile_no):
return jsonify(create_response(StateCode.MOBILE_ERROR)), 400
if not verifyCode(mobile_no, mobile_code):
return jsonify(create_response(StateCode.MOBILE_CODE_ERROR)), 400
if not checkIdCard(id_card_no):
return jsonify(create_response(StateCode.ID_CARD_ERROR)), 400
if isAvailable(account):
return jsonify(create_response(StateCode.USER_ALREADY_EXISTS)), 400
if not checkBankCard(bank_card_no):
return jsonify(create_response(StateCode.BANK_CARD_ERROR)), 400
new_passenger = Passenger.create(data=data)
access_token = create_access_token(identity=new_passenger.id)
user_presenter = PassengerPresenter(new_passenger, {"token": access_token}).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=user_presenter)), 200
@register_bp.route('/auth', methods=['GET'])
@jwt_required()
def auth():
current_user = get_jwt_identity()
user = Passenger.query.get(current_user)
if not user:
return jsonify(create_response(StateCode.USER_NOT_FOUND)), 400
user_presenter = PassengerPresenter(user, {}).as_dict()
LogService.log()
return jsonify(create_response(code=StateCode.SUCCESS, data=user_presenter)), 200
def verifyCode(mobile_no, code):
stored_code = redis_client.get(mobile_no)
return stored_code == code

@ -0,0 +1,46 @@
import iso8601
from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required
from app import LogService
from app.models import Station
from app.models.ticket_lib import Ticket
from app.models.train_lib import Train
from presenter import StationPresenter
from presenter.ticket import TicketPresenter
from presenter.train import TrainPresenter
from utils import create_response, StateCode
query_bp = Blueprint('query', __name__)
@query_bp.route('/trains/query_train', methods=['GET'])
def queryTrains(): # 查询车次方法
from_station = request.args.get('from')
to_station = request.args.get('to')
date = iso8601.parse_date(request.args.get('date'))
if not from_station or not to_station or not date:
return jsonify(create_response(StateCode.PARAMS_ERROR)), 400
trains = Train.queryTrains(from_station, to_station, date)
trains_presenters = [TrainPresenter(train).as_dict() for train in trains]
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=trains_presenters)), 200
@query_bp.route('/tickets', methods=['GET'])
@jwt_required()
def getTickets(): # 查询车票方法
current_user = get_jwt_identity() # 解码JWT得到用户ID
tickets = Ticket.query.filter_by(passenger_id=current_user).all()
tickets_presenters = [TicketPresenter(ticket).as_dict() for ticket in tickets]
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=tickets_presenters)), 200
@station_bp.route('/stations', methods=['GET'])
def queryStations(): # 查询站点方法
stations = Station.query.all()
stations_presenters = [StationPresenter(station).as_dict() for station in stations]
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=stations_presenters)), 200

@ -0,0 +1,26 @@
from flask import request, jsonify, Blueprint
from app import LogService
from app.models import Station
from presenter import StationPresenter
from utils import create_response, StateCode
station_bp = Blueprint('stations', __name__)
@station_bp.route('/stations', methods=['POST'])
def createStation(): # 创建站点方法
data = request.json
new_station = Station.create(data=data)
station_presenter = StationPresenter(new_station).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=station_presenter)), 200
@station_bp.route('/stations/quantity_create', methods=['POST'])
def quantityCreate(): # 批量创建站点方法
stations = request.json.get("stations")
for name in stations:
station_hash = {"name": name}
Station.create(station_hash)
LogService.log()
return jsonify(create_response(StateCode.SUCCESS)), 200

@ -0,0 +1,27 @@
from flask import request, jsonify, Blueprint
from app import db, LogService
from app.models import Train
from app.models.train_lib import buildTrain
from presenter.train import TrainPresenter
from utils import create_response, StateCode
trains_bp = Blueprint('trains', __name__)
@trains_bp.route('/trains', methods=['GET'])
def queryAllTrains():
trains = Train.query.all()
trains_presenters = [TrainPresenter(train).as_dict() for train in trains]
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=trains_presenters)), 200
@trains_bp.route('/trains', methods=['POST'])
def createTrain():
data = request.json
new_train = buildTrain(data)
db.session.add(new_train)
db.session.commit()
train_presenter = TrainPresenter(new_train).as_dict()
LogService.log()
return jsonify(create_response(StateCode.SUCCESS, data=train_presenter)), 200

@ -1,4 +1,3 @@
# H_D
import os
@ -14,4 +13,3 @@ class Config:
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or ('postgresql://postgres:123456@localhost:54321'
'/mini12306_python')
SQLALCHEMY_TRACK_MODIFICATIONS = False

@ -0,0 +1,40 @@
version: '3.8'
services:
python:
build:
context: ./
dockerfile: ./Dockerfile
restart: always
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/mini12306_python
- REDIS_URL=redis
- SERVER_LIB_URL=http://py12306.learnerhub.net
ports:
- "3002:3002"
depends_on:
- db
- redis
volumes:
- /var/log/mini12306_python:/app/logs
redis:
image: redis:alpine
container_name: 12306_redis
volumes:
- redis_data:/data
db:
image: postgres:15
container_name: 12306_postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mini12306_python
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
redis_data:

@ -0,0 +1 @@
init folder

@ -0,0 +1 @@
Single-database configuration for Flask.

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

@ -0,0 +1,178 @@
"""init databases
Revision ID: 1b3bc6809b30
Revises:
Create Date: 2024-08-15 15:09:26.124279
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b3bc6809b30'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('log_user_agent', sa.String(length=120), nullable=True),
sa.Column('log_method', sa.String(length=120), nullable=True),
sa.Column('log_quest_path', sa.String(length=120), nullable=True),
sa.Column('log_ip', sa.String(length=120), nullable=True),
sa.Column('log_params', sa.String(length=120), nullable=True),
sa.Column('log_controller', sa.String(length=120), nullable=True),
sa.Column('log_controller_action', sa.String(length=120), nullable=True),
sa.Column('log_controller_id', sa.String(length=120), nullable=True),
sa.Column('account_id', sa.String(length=120), nullable=True),
sa.Column('event_name', sa.String(length=120), nullable=True),
sa.Column('event_content', sa.Text(), nullable=True),
sa.Column('operator', sa.String(length=120), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('passenger',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=True),
sa.Column('account', sa.String(length=120), nullable=False),
sa.Column('password_digest', sa.String(length=2000), nullable=False),
sa.Column('id_card_no', sa.String(length=120), nullable=True),
sa.Column('mobile_no', sa.String(length=120), nullable=True),
sa.Column('bank_card_no', sa.String(length=120), nullable=True),
sa.Column('state', sa.Integer(), nullable=True),
sa.Column('member_type', sa.Integer(), nullable=True),
sa.Column('last_login_time', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('passenger', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_passenger_account'), ['account'], unique=True)
op.create_table('station',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('pinyin', sa.String(length=120), nullable=True),
sa.Column('province', sa.String(length=120), nullable=True),
sa.Column('city', sa.String(length=120), nullable=True),
sa.Column('district', sa.String(length=120), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('station', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_station_name'), ['name'], unique=True)
op.create_table('train',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('train_no', sa.String(length=120), nullable=False),
sa.Column('departure_station', sa.String(length=120), nullable=True),
sa.Column('arrival_station', sa.String(length=120), nullable=True),
sa.Column('departure_time', sa.DateTime(), nullable=True),
sa.Column('expiration_time', sa.DateTime(), nullable=True),
sa.Column('effective_time', sa.DateTime(), nullable=True),
sa.Column('arrival_time', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('train', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_train_train_no'), ['train_no'], unique=True)
op.create_table('order',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_no', sa.String(length=120), nullable=False),
sa.Column('price', sa.Numeric(precision=8, scale=2), nullable=True),
sa.Column('payment_time', sa.DateTime(), nullable=True),
sa.Column('state', sa.Integer(), nullable=True),
sa.Column('passenger_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['passenger_id'], ['passenger.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('order_no')
)
with op.batch_alter_table('order', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_order_passenger_id'), ['passenger_id'], unique=False)
op.create_table('train_station',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('train_no', sa.String(length=120), nullable=False),
sa.Column('station_name', sa.String(length=120), nullable=False),
sa.Column('price', sa.Numeric(precision=8, scale=2), nullable=True),
sa.Column('arrival_time', sa.DateTime(), nullable=True),
sa.Column('departure_time', sa.DateTime(), nullable=True),
sa.Column('index', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['station_name'], ['station.name'], ),
sa.ForeignKeyConstraint(['train_no'], ['train.train_no'], ),
sa.PrimaryKeyConstraint('id', 'train_no', 'station_name')
)
with op.batch_alter_table('train_station', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_train_station_train_no'), ['train_no'], unique=False)
op.create_table('ticket',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('seat_no', sa.String(length=120), nullable=True),
sa.Column('seat_class', sa.String(length=120), nullable=True),
sa.Column('price', sa.Numeric(precision=8, scale=2), nullable=True),
sa.Column('state', sa.Integer(), nullable=True),
sa.Column('train_no', sa.String(length=120), nullable=False),
sa.Column('passenger_id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('from_station', sa.String(length=120), nullable=True),
sa.Column('to_station', sa.String(length=120), nullable=True),
sa.Column('date', sa.String(length=120), nullable=True),
sa.Column('departure_time', sa.DateTime(), nullable=True),
sa.Column('arrival_time', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], ),
sa.ForeignKeyConstraint(['passenger_id'], ['passenger.id'], ),
sa.ForeignKeyConstraint(['train_no'], ['train.train_no'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('ticket', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ticket_order_id'), ['order_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ticket_passenger_id'), ['passenger_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ticket_train_no'), ['train_no'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('ticket', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ticket_train_no'))
batch_op.drop_index(batch_op.f('ix_ticket_passenger_id'))
batch_op.drop_index(batch_op.f('ix_ticket_order_id'))
op.drop_table('ticket')
with op.batch_alter_table('train_station', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_train_station_train_no'))
op.drop_table('train_station')
with op.batch_alter_table('order', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_order_passenger_id'))
op.drop_table('order')
with op.batch_alter_table('train', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_train_train_no'))
op.drop_table('train')
with op.batch_alter_table('station', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_station_name'))
op.drop_table('station')
with op.batch_alter_table('passenger', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_passenger_account'))
op.drop_table('passenger')
op.drop_table('log')
# ### end Alembic commands ###

@ -0,0 +1,32 @@
"""remove TrainStation id
Revision ID: 7866a6e8a75b
Revises: 1b3bc6809b30
Create Date: 2024-08-15 15:29:40.410360
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7866a6e8a75b'
down_revision = '1b3bc6809b30'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('train_station', schema=None) as batch_op:
batch_op.drop_column('id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('train_station', schema=None) as batch_op:
batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False))
# ### end Alembic commands ###

@ -0,0 +1,9 @@
# myapp.py
from app import create_app, LogService
from utils import response
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

@ -0,0 +1,3 @@
from .passenger import PassengerPresenter
from .station import StationPresenter
from .mobile_code import MobileCodePresenter

@ -0,0 +1,8 @@
class MobileCodePresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"code": self.data
}

@ -0,0 +1,16 @@
from presenter.ticket import TicketPresenter
class OrderPresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"id": self.data.id,
"orderNo": self.data.order_no,
"price": self.data.price,
"state": self.data.state,
"paymentTime": self.data.payment_time,
"tickets": [TicketPresenter(ticket).as_dict() for ticket in self.data.tickets]
}

@ -0,0 +1,17 @@
class PassengerPresenter:
def __init__(self, data, aid_data=None):
self.data = data
self.aid_data = aid_data
def as_dict(self):
return {
"id": self.data.id,
"account": self.data.account,
"name": self.data.name,
"idCardNo": self.data.id_card_no,
"mobileNo": self.data.mobile_no,
"bankCardNo": self.data.bank_card_no,
"state": self.data.state,
"memberType": self.data.member_type
} | self.aid_data

@ -0,0 +1,10 @@
class StationPresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"id": self.data.id,
"name": self.data.name,
"pinyin": self.data.pinyin,
}

@ -0,0 +1,19 @@
class TicketPresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"id": self.data.id,
"seatNo": self.data.seat_no,
"seatClass": self.data.seat_class,
"price": self.data.price,
"state": self.data.state,
"trainNo": self.data.train_no,
"from": self.data.from_station,
"orderNo": self.data.order.order_no,
"to": self.data.to_station,
"date": self.data.date,
"fromTime": self.data.departure_time,
"toTime": self.data.arrival_time,
}

@ -0,0 +1,17 @@
from presenter.train_station import TrainStationPresenter
class TrainPresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"id": self.data.id,
"trainNo": self.data.train_no,
"arrTime": self.data.arrival_time,
"depTime": self.data.departure_time,
"arr": self.data.arrival_station,
"dep": self.data.departure_station,
"stations": [TrainStationPresenter(station).as_dict() for station in self.data.train_stations]
}

@ -0,0 +1,13 @@
class TrainStationPresenter:
def __init__(self, data):
self.data = data
def as_dict(self):
return {
"index": self.data.index,
"trainNo": self.data.train_no,
"name": self.data.station_name,
"arrTime": self.data.arrival_time,
"depTime": self.data.departure_time,
"price": self.data.price,
}

@ -0,0 +1,110 @@
### 目录结构说明
```
Mini-12306
-- app //主要接口业务代码
---- models //定义了数据库表结构与抽象类方法
-- logs //日志存储
-- migrations //执行迁移后的数据库版本文件,请查看Flask-Migrate使用方法。
-- presenter //渲染文件,用于数据序列化
-- utils //工具包
---- response.py //定义返回数据结构及定义状态码
-- Dockerfile //docker容器化文件
-- docker-compose.yml //容器编排文件
```
### 开发环境安装
1. 安装 Python 3.12.x以上版本
2. 使用 pip 安装项目所需要的包:
```
cd 12306mini_python
pip config set global.index-url http://mirrors.aliyun.com/pypi/simple
pip config set install.trusted-host mirrors.aliyun.com
pip install -r requirements.txt
```
3. 安装 PostgreSQL, 并创建数据库
根目录下的config.py文件中 SQLALCHEMY_DATABASE_URI 是定义项目连接数据库的地址(包括数据库账号、密码、名称)。
连接数据库的密码及数据库均可以自行设定。
4. 执行迁移
在项目的根目录下执行下列命令,此命令会将数据表生成。
表的生成逻辑请查看Flask-Migrate使用方法。
```
flask db upgrade
```
5. 运行项目
```
flask run myapp.py
```
### docker部署
1. 找一台Ubuntu服务器安装docker及docker-compose
2. 在服务器上创建日志文件存储位置(该文件用于 docker-compose 中 volumes 关联)
```
mkdir /var/log/mini12306_python
```
3. 构建容器
```
docker-compose build
```
你可能会遇到build失败的问题。
因为国内的原因无法正常自动下载 postgres 和 redis_alpine 这两个软件。
解决方案如下:
下载此链接中两个docker软件包至服务器: postgres_15.tar redis_alpine.tar
https://pan.baidu.com/s/1qMRtQ0Fhy7r5L_58RHe5Fw?pwd=vu3c 提取码: vu3c
可以将文件先下载到本机然后使用scp命令将文件上传至服务器。
文件上传至服务后cd至文件所在的目录然后运行下列指令
```
docker load < postgres_15.tar
docker load < postgres_15.tar
然后再执行 docker-compose build
```
4. 运行容器
```
docker-compose run --rm python flask db upgrade
docker-compose up -d
```
---
### 扩展使用: Flask-Migrate 使用方法
首先,初始化 Alembic 目录migrations/ 文件夹):
```
flask db init
```
当你对模型进行了更改(如添加新表或修改现有表的结构),你可以生成迁移脚本:
```
flask db migrate -m "Initial migration"
```
这将根据你定义的模型生成迁移脚本。
执行迁移
```
flask db upgrade
```
---
### 扩展使用:使用 pdb 工具断点调试
```
pdb.set_trace()
```

@ -0,0 +1,21 @@
Flask~=3.0.3
Flask-SQLAlchemy
psycopg2-binary
PyJWT
Flask-Migrate
itsdangerous
redis~=5.0.8
Flask-JWT-Extended
pypinyin~=0.52.0
Werkzeug~=3.0.3
SQLAlchemy~=2.0.32
alembic~=1.13.2
python-dateutil
requests
gunicorn==22.0.0
Levenshtein==0.25.1
munkres==1.1.4
argparse==1.4.0
flask-cors==4.0.1
loguru
iso8601

@ -0,0 +1,2 @@
from .response import create_response, StateCode
from .server import checkMobile, checkIdCard, checkBankCard

@ -0,0 +1,52 @@
from enum import Enum
from typing import Any, Dict, List, Union
class StateCode(Enum):
SUCCESS = 0
NOT_AUTHORIZED = 1002
PARAMS_ERROR = 1003
USER_NOT_FOUND = 2001
USER_ALREADY_EXISTS = 2002
PASSWORD_INCORRECT = 2003
MOBILE_CODE_ERROR = 2004
ID_CARD_ERROR = 2005
BANK_CARD_ERROR = 2006
MOBILE_ERROR = 2007
ORDER_PAY_ERROR = 3001
STATE_MESSAGES = {
StateCode.SUCCESS: "OK",
StateCode.PARAMS_ERROR: "缺少参数",
StateCode.USER_NOT_FOUND: "用户不存在,操作错误",
StateCode.NOT_AUTHORIZED: "未登录",
StateCode.USER_ALREADY_EXISTS: "用户已存在",
StateCode.PASSWORD_INCORRECT: "用户名或密码错误",
StateCode.MOBILE_CODE_ERROR: "验证码错误",
StateCode.ID_CARD_ERROR: "身份证号验证失败",
StateCode.MOBILE_ERROR: "手机号格式有误",
StateCode.BANK_CARD_ERROR: "银行卡验证错误",
StateCode.ORDER_PAY_ERROR: "支付失败"
}
def create_response(
code: StateCode,
message: str = None,
data: Union[Dict[str, Any], List[Dict[str, Any]], Any] = None
) -> Dict[str, Any]:
"""
创建统一的返回格式
:param code: ErrorCode 错误码
:param message: 错误信息可选如果不传递则使用默认的错误信息
:param data: 包含单个或多个 Presenter 转换后的数据单个使用 dict多个使用 list
:return: 返回统一格式的字典
"""
return {
"code": code.value,
"msg": message or STATE_MESSAGES.get(code, "Unknown error"),
"data": data
}

@ -0,0 +1,45 @@
import os
import requests
hosts = os.getenv('SERVER_LIB_URL', "http://127.0.0.1:5000")
def checkIdCard(id_card):
url = hosts + "/id_card/verify"
params = {
'idCardNo': id_card
}
# response = requests.get(url, params=params) 发送get请求
response = requests.post(url, json=params)
return response.status_code == 200 and response.json().get("data")["result"]
def checkBankCard(bank_card):
url = hosts + "/bank/card_verify"
params = {
'bankCard': bank_card
}
response = requests.post(url, json=params)
return response.status_code == 200 and response.json().get("data")["result"]
def checkMobile(mobile_no):
url = hosts + "/mobile/check"
params = {
'mobileNo': mobile_no
}
response = requests.post(url, json=params)
return response.status_code == 200 and response.json().get("data")["result"]
def verifyOrderPayment(order_no):
url = hosts + "/bank/query"
# 模拟向支付中心发送验证请求进行验证
params = {"OrderNo": order_no}
response = requests.post(url, json=params)
return response

@ -1,54 +0,0 @@
Mini-12306后端项目说明
一、目录结构说明
Mini-12306
-- app //主要接口业务代码
---- models //定义了数据库表结构与抽象类方法
-- logs //日志存储
-- migrations //执行迁移后的数据库版本文件,请查看Flask-Migrate使用方法。
-- presenter //渲染文件,用于数据序列化
-- utils //工具包
---- response.py //定义返回数据结构及定义状态码
-- Dockerfile // docker容器化文件
-- docker-compose.yml //容器编排文件
二、开发环境安装
(一)安装 python
请自行搜索 python安装教程版本选用3.12.x以上版本
(二)使用 pip 安装项目所需要的包
cd 12306mini_python
pip config set global.index-url http://mirrors.aliyun.com/pypi/simple
pip config set install.trusted-host mirrors.aliyun.com
pip install -r requirements.txt
(三)安装 PostgreSQL, 并创建数据库
请自行搜索PostgreSQL的安装教程版本选用15以上版本。
根目录下的config.py文件中 SQLALCHEMY_DATABASE_URI 是定义项目连接数据库的地址(包括数据库账号、密码、名称)。
连接数据库的密码及数据库均可以自行设定。
(四)执行迁移
在项目的根目录下执行下列命令,此命令会将数据表生成。
表的生成逻辑请查看Flask-Migrate使用方法。
flask db upgrade
(五)运行项目
flask run myapp.py
三、docker部署
(一)服务器环境安装
找一台Ubuntu服务器安装docker及docker-compose
(二)在服务器上创建日志文件夹
在服务器上创建日志文件存储位置(该文件用于 docker-compose 中 volumes 关联)
mkdir /var/log/mini12306_python
(三)构建容器
docker-compose build
你可能会遇到build失败的问题。
因为国内的原因无法正常自动下载 postgresql 和 redis_alpine 这两个docker软件包。
解决方案如下 下载此链接中两个docker软件包至服务器: postgres_15.tar 和redis_alpine.tar
https://pan.baidu.com/s/1qMRtQ0Fhy7r5L_58RHe5Fw?pwd=vu3c 提取码: vu3c
可以将文件先下载到本机然后使用scp命令将文件上传至服务器。
文件上传至服务后cd至文件所在的目录然后运行下列指令
docker load < postgres_15.tar
docker load < postgres_15.tar
然后再执行 docker-compose build
(四)运行容器
docker-compose run --rm python flask db upgrade
docker-compose up -d
您也可以查看后端项目文件中的readme.md其中的代码块显示比本文档更友好。
Loading…
Cancel
Save