Skip to content

从零到一:构建全栈「金金价」实时珠宝金价查询小程序

在当今黄金价格波动频繁的背景下,无论是普通消费者还是投资者,都需要一个聚合且实时的金价查询工具。今天,我将和大家分享如何从零开始,构建一个基于 Python Flask 后端 + UniApp (Vue3) 前端的全栈项目,通过这个项目你可以学习到爬虫设计、API 封装、JWT 鉴权、定时任务调度以及微信订阅通知功能等核心技术。


📌 项目概述

「金金价」是一个聚合主流珠宝品牌(周大福、六福珠宝、老庙黄金、老凤祥等)实时金价数据的微信小程序。用户可以随时查看各品牌金价、查看历史趋势,并订阅特定品牌的金价变动通知。

GitHub 仓库https://github.com/acheding/gold-price


🛠 技术栈

层级技术选型
后端Python 3.8+ / Flask / Flask-SQLAlchemy / APScheduler
数据库MySQL 8.0
前端UniApp / Vue3 (Composition API) / SCSS
爬虫BeautifulSoup4 / Requests
认证JWT (PyJWT)
部署Docker / Docker Compose

🏗 1. 后端架构设计

1.1 项目结构

后端/
├── app.py                 # Flask 应用入口 (工厂模式)
├── config.py              # 配置文件
├── models.py              # 数据库模型定义
├── requirements.txt       # Python 依赖
├── Dockerfile             # Docker 镜像构建
├── api/
│   └── routes.py          # API 路由与业务逻辑
├── crawler/
│   ├── gold_crawler.py    # 爬虫调度器
│   ├── utils.py           # 爬虫工具函数
│   └── brands/            # 各品牌爬虫实现
│       ├── chow_tai_fook.py
│       ├── luk_fook.py
│       └── ...
├── scheduler.py           # 定时任务配置
└── utils/
    └── wechat.py          # 微信 API 封装

1.2 数据库模型设计

采用 SQLAlchemy ORM,共设计三张核心表:

python
# models.py

class Brand(db.Model):
    """品牌信息表"""
    __tablename__ = 'brands'
    id = db.Column(db.Integer, primary_key=True)
    code = db.Column(db.String(50), unique=True)  # 如 'chow-tai-fook'
    name = db.Column(db.String(100))               # 如 '周大福'
    logo = db.Column(db.String(255))               # 品牌 Logo
    remark = db.Column(db.String(255))             # 备注说明

class BrandPrice(db.Model):
    """金价记录表"""
    __tablename__ = 'brand_prices'
    id = db.Column(db.Integer, primary_key=True)
    brand_id = db.Column(db.Integer, db.ForeignKey('brands.id'))
    price = db.Column(JSON)    # 灵活存储多种金价类型(足金、金条等)
    unit = db.Column(db.String(20), default='元/克')
    update_time = db.Column(db.DateTime)
    record_time = db.Column(db.DateTime, default=datetime.now)

class Subscription(db.Model):
    """用户订阅表"""
    __tablename__ = 'subscriptions'
    id = db.Column(db.Integer, primary_key=True)
    openid = db.Column(db.String(100))   # 微信用户唯一标识
    brand_id = db.Column(db.Integer, db.ForeignKey('brands.id'))
    created_at = db.Column(db.DateTime, default=datetime.now)

    # 联合唯一索引,防止重复订阅
    __table_args__ = (
        db.UniqueConstraint('openid', 'brand_id', name='uq_user_brand'),
    )

设计亮点:使用 JSON 类型存储金价数据,可以灵活应对不同品牌的价格结构差异(如足金、投资金条、工艺品金等)。

1.3 Flask 应用工厂

python
# app.py

def create_app(test_config=None):
    app = Flask(__name__)
    app.config.from_object(Config)

    # 解决中文乱码
    app.config['JSON_AS_ASCII'] = False

    # 跨域支持 (小程序需要)
    CORS(app)

    # 初始化数据库
    db.init_app(app)

    # 注册 API 蓝图
    app.register_blueprint(api_bp, url_prefix='/api/v1')

    with app.app_context():
        db.create_all()  # 自动建表

    # 启动定时任务
    if not app.config.get('TESTING'):
        init_scheduler(app)

    return app

🕷 2. 爬虫系统设计

2.1 爬虫调度器

项目采用 策略模式 + 定时任务 的设计:

python
# crawler/gold_crawler.py

class GoldCrawler:
    BRANDS_INFO = {
        'chow-tai-fook': {'code': 'chow-tai-fook', 'name': '周大福', 'logo': '...'},
        'luk-fook': {'code': 'luk-fook', 'name': '六福珠宝', 'logo': '...'},
        # ... 共 8 个主流品牌
    }

    def crawl_all(self):
        with self.app.app_context():
            self.crawl_brand_prices()

    def crawl_brand_prices(self):
        # 品牌爬虫映射
        crawler_map = {
            'chow-tai-fook': chow_tai_fook.crawl,
            'luk-fook': luk_fook.crawl,
            # ...
        }

        for brand in brands:
            try:
                crawler_func = crawler_map.get(brand.code)
                if crawler_func:
                    crawler_func(brand)
            except Exception as e:
                print(f"爬取品牌 {brand.name} 失败: {e}")

2.2 定时任务 (APScheduler)

python
# scheduler.py

from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()

@scheduler.scheduled_job('cron', hour='*', minute=0)
def hourly_crawl():
    """每小时执行一次爬虫"""
    crawler = GoldCrawler(app)
    crawler.crawl_all()

🔐 3. 微信登录与 JWT 鉴权

3.1 小程序登录流程

用户点击登录 → uni.login() 获取 code → 发送给后端 →
后端调用微信 jscode2session → 获取 openid →
生成 JWT Token 返回前端 → 前端存储 Token

3.2 后端实现

python
# api/routes.py

@api_bp.route('/get-openid', methods=['GET'])
def get_openid():
    code = request.args.get('code')

    # 调用微信 API
    url = "https://api.weixin.qq.com/sns/jscode2session"
    params = {
        "appid": WX_APPID,
        "secret": WX_SECRET,
        "js_code": code,
        "grant_type": "authorization_code"
    }
    res = requests.get(url, params=params)
    openid = res.json().get('openid')

    # 生成 JWT Token,7天有效期
    payload = {
        'openid': openid,
        'exp': datetime.utcnow() + timedelta(days=7)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

    return jsonify({'code': 200, 'content': {'token': token}})

3.3 Token 验证装饰器

python
def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        auth_header = request.headers.get('Authorization')
        if not auth_header or not auth_header.startswith("Bearer "):
            return jsonify({'code': 401, 'msg': '缺少认证 token'}), 401

        token = auth_header.split(" ")[1]
        try:
            payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            g.user_openid = payload.get('openid')
        except jwt.ExpiredSignatureError:
            return jsonify({'code': 401, 'msg': 'Token 已过期'}), 401

        return f(*args, **kwargs)
    return decorated_function

📱 4. 前端架构 (UniApp + Vue3)

4.1 项目结构

前端/
├── pages/
│   └── index/
│       └── index.vue      # 首页 (品牌列表)
├── components/
│   ├── BrandCard/         # 品牌卡片组件
│   ├── BrandDetail/       # 品牌详情弹窗
│   └── PriceTrendChart/   # SVG 走势图
├── utils/
│   └── api.js             # API 请求封装
├── App.vue
├── main.js
├── pages.json
└── manifest.json

4.2 API 请求封装 (无感刷新)

javascript
// utils/api.js

export const request = async (options) => {
  const header = {
    "Content-Type": "application/json",
    ...options.header,
  };

  const token = uni.getStorageSync("token");
  if (token) {
    header["Authorization"] = `Bearer ${token}`;
  }

  const res = await uni.request({
    ...options,
    url: baseURL + options.url,
    header,
  });

  // 401 时自动重新登录并重试
  if (res.statusCode === 401) {
    const newToken = await login();
    header["Authorization"] = `Bearer ${newToken}`;
    return await uni.request({ ...options, header });
  }

  return res;
};

4.3 小程序头部适配

javascript
// pages/index/index.vue

const headerStyle = computed(() => {
  // #ifdef MP-WEIXIN
  const menuButtonInfo = uni.getMenuButtonBoundingClientRect();
  if (menuButtonInfo) {
    return {
      paddingTop: `${menuButtonInfo.top}px`,
      height: `${menuButtonInfo.height}px`,
    };
  }
  // #endif
  return { paddingTop: "50px", height: "32px" };
});

4.4 轻量级 SVG 走势图 (不依赖第三方库)

为了减小小程序包体积,项目使用纯 CSS + SVG 实现走势图:

vue
<!-- PriceTrendChart.vue -->

<script>
const chartDataUri = computed(() => {
  const svg = `
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">
            <defs>
                <linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
                    <stop offset="0%" stop-color="#FFD700" stop-opacity="0.4" />
                    <stop offset="100%" stop-color="#FFD700" stop-opacity="0" />
                </linearGradient>
            </defs>
            <path d="${areaPath}" fill="url(#gradient)" />
            <path d="${linePath}" fill="none" stroke="#FFD700" stroke-width="2" />
        </svg>
    `;
  return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`;
});
</script>

<style>
.price-trend-chart__bg {
  background-image: v-bind(chartDataUri);
  background-size: 100% 100%;
}
</style>

优势:无需引入 ECharts 等庞然大物,包体积增加 < 10KB


📬 5. 订阅消息机制 (重要说明)

5.1 微信小程序订阅消息限制

根据微信官方规定,不同主体类型的小程序支持的订阅类型不同:

主体类型长期订阅一次性订阅
企业主体✅ 支持✅ 支持
个人主体❌ 不支持✅ 支持

5.2 为什么只能使用一次性订阅?

  1. 政策限制:微信从 2022 年开始收紧了对个人主体小程序的订阅消息权限,仅开放「一次性订阅」能力
  2. 长期订阅仅开放类目:长期订阅消息仅开放给特定类目(如政务服务、医疗、交通等民生服务)
  3. 珠宝金价查询不在开放类目:因此只能使用一次性订阅

5.3 当前实现方式

javascript
// 前端:用户点击订阅时调用
uni.requestSubscribeMessage({
  tmplIds: ["your_template_id"],
  success(res) {
    // 用户同意后,调起一次性订阅
    // 注意:每次只能订阅一条消息
    // 想要再次收到通知需要用户再次主动订阅
  },
});

⚠️ 注意:个人主体小程序每次用户点击「订阅」按钮,只能收到一次通知。无法实现「订阅一次,长期推送」的效果。这是微信官方政策限制,与代码实现无关。


🐳 6. Docker 部署

6.1 Dockerfile (后端)

dockerfile
FROM python:3.10-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

6.2 docker-compose.yml

yaml
version: "3.8"

services:
  backend:
    build: ./backend
    ports:
      - "5000:5000"
    environment:
      - DB_HOST=db
      - DB_USER=root
      - DB_PASSWORD=root_password
      - DB_NAME=gold_price_db
    depends_on:
      - db

  db:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=root_password
      - MYSQL_DATABASE=gold_price_db
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

📊 7. API 接口一览

接口方法说明鉴权
/api/v1/get-openidGET微信登录
/api/v1/brandsGET获取所有品牌及最新金价
/api/v1/brands/<code>GET获取品牌详情及历史趋势
/api/v1/crawl/manualPOST手动触发爬虫
/api/v1/subscribePOST订阅品牌
/api/v1/unsubscribePOST取消订阅
/api/v1/subscriptionsGET获取订阅列表

🎯 8. 项目亮点总结

亮点说明
前后端分离Flask RESTful API + UniApp
JWT 无感刷新401 自动重登录,对用户透明
轻量级图表纯 SVG 方案,包体积极小
定时自动爬虫APScheduler 每小时更新
灵活的数据结构JSON 字段适配多变的金价类型
微信生态整合登录 + 订阅消息通知
Docker 一键部署开发生产环境一致

Released under the MIT License.