警告:如果这是一个文件夹,包括子文件夹和文档在内的所有内容将被删除。
为您的Wiki部分定义基于路径的访问规则。规则按顺序评估。首次匹配生效。
创建和管理您的 Wiki 数据备份。备份包括所有文档、图像和配置文件。
面向维吾尔语用户的猜谜语小程序:用户通过猜谜语获得积分,积分达到兑换额度后,添加客服微信进行人工兑换(1 积分 = 1 元)。后台可配置每日答题次数,次数用完后可观看激励广告获取额外次数。不引入微信支付,小程序内不出现"提现"字样,统一以"积分/兑换"表达。
界面语言与方向:
| 层级 | 技术选型 | 说明 |
|---|---|---|
| 小程序前端 | 微信小程序原生开发 (WXML/WXSS/JS) | 无框架依赖,性能与审核兼容性最佳 |
| 界面方向 | RTL 适配(自定义 tabBar + WXSS direction/flex 反转) | 维吾尔语从右到左布局 |
| 管理后台 | Vue3 + Element Plus | 后台管理系统(中文) |
| 后端服务 | Go + Gin | RESTful API 服务 |
| 数据库 | MySQL 8.0 | 主数据库 |
| 对象存储 | 腾讯云 COS | 题目图/选项图/头像/客服二维码等静态资源 |
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(64) UNIQUE NOT NULL COMMENT '微信openid',
unionid VARCHAR(64) DEFAULT '' COMMENT '微信unionid',
nickname VARCHAR(64) DEFAULT '' COMMENT '昵称',
avatar VARCHAR(255) DEFAULT '' COMMENT '头像',
phone VARCHAR(20) DEFAULT '' COMMENT '手机号',
points INT DEFAULT 0 COMMENT '当前积分(答题积分)',
total_earned INT DEFAULT 0 COMMENT '累计获得积分',
total_redeemed INT DEFAULT 0 COMMENT '累计兑换积分',
invite_points INT DEFAULT 0 COMMENT '邀请积分余额(用于兑换答题卡)',
answer_cards INT DEFAULT 0 COMMENT '答题卡余额(答题次数)',
invite_code VARCHAR(20) DEFAULT '' COMMENT '我的邀请码(唯一)',
inviter_id BIGINT DEFAULT 0 COMMENT '邀请人用户ID(被邀请时记录)',
status TINYINT DEFAULT 1 COMMENT '1正常 2禁用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_openid (openid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 谜语表 (支持4种题型)
CREATE TABLE riddles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id BIGINT DEFAULT 0 COMMENT '分类ID',
question_type TINYINT NOT NULL DEFAULT 1 COMMENT '题型 1文字单选 2文字判断 3文字图片单选 4图片答案单选',
title VARCHAR(500) NOT NULL COMMENT '题目文字(谜面)',
question_image VARCHAR(255) DEFAULT '' COMMENT '题目图片(题型3/4必填)',
options JSON COMMENT '选项列表 [{"key":"A","text":"选项文字","image":"图片URL"},...] 题型2为[{"key":"T","text":"对"},{"key":"F","text":"错"}] 题型4选项只填image',
answer VARCHAR(20) NOT NULL COMMENT '正确答案key 如"A"(判断题用T/F)',
difficulty TINYINT DEFAULT 1 COMMENT '难度 1简单 2中等 3困难',
points INT DEFAULT 10 COMMENT '答对获得积分',
hint VARCHAR(500) DEFAULT '' COMMENT '提示',
status TINYINT DEFAULT 1 COMMENT '1启用 2禁用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category_id),
INDEX idx_question_type (question_type),
INDEX idx_difficulty (difficulty)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 谜语分类表
CREATE TABLE riddle_categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL COMMENT '分类名称',
icon VARCHAR(255) DEFAULT '' COMMENT '图标',
sort_order INT DEFAULT 0 COMMENT '排序',
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 答题记录表
CREATE TABLE answer_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
riddle_id BIGINT NOT NULL,
selected_answer VARCHAR(20) DEFAULT '' COMMENT '用户所选答案key(T/F/A/B/C/D)',
is_correct TINYINT DEFAULT 0 COMMENT '0错误 1正确',
points_earned INT DEFAULT 0 COMMENT '获得积分',
answer_time INT DEFAULT 0 COMMENT '答题用时(秒)',
source TINYINT DEFAULT 1 COMMENT '1正常次数 2广告次数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, created_at),
INDEX idx_riddle (riddle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 每日答题次数记录
CREATE TABLE daily_quota (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
date DATE NOT NULL COMMENT '日期',
used_count INT DEFAULT 0 COMMENT '已使用次数(正常)',
ad_count INT DEFAULT 0 COMMENT '已使用广告次数',
ad_reward_count INT DEFAULT 0 COMMENT '通过广告获得的次数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_date (user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 兑换记录表
CREATE TABLE redeem_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
points INT NOT NULL COMMENT '兑换积分(1积分=1元)',
contact VARCHAR(100) DEFAULT '' COMMENT '用户联系方式(微信昵称/备注)',
status TINYINT DEFAULT 0 COMMENT '0待兑换 1已兑换 2已取消',
remark VARCHAR(200) DEFAULT '' COMMENT '备注',
operator_id BIGINT DEFAULT 0 COMMENT '处理管理员ID',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 兑换额度表 (后台内部亦可称"提现额度表")
-- 每用户一条记录, 用于控制兑换上限与频率
CREATE TABLE redeem_limits (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
total_limit INT DEFAULT 0 COMMENT '累计兑换额度上限(积分), 0=不限制',
used_total INT DEFAULT 0 COMMENT '已累计兑换积分',
daily_limit INT DEFAULT 0 COMMENT '每日兑换上限(积分), 0=不限制',
monthly_limit INT DEFAULT 0 COMMENT '每月兑换上限(积分), 0=不限制',
max_once INT DEFAULT 0 COMMENT '单次最大兑换积分, 0=不限制',
daily_count INT DEFAULT 0 COMMENT '当日已兑换次数',
monthly_count INT DEFAULT 0 COMMENT '当月已兑换次数',
last_redeem_date DATE DEFAULT NULL COMMENT '最后兑换日期(用于日/月计数重置)',
status TINYINT DEFAULT 1 COMMENT '1正常 2冻结兑换',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 邀请记录表 (每次成功邀请一条记录)
CREATE TABLE invite_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
inviter_id BIGINT NOT NULL COMMENT '邀请人用户ID',
invitee_id BIGINT NOT NULL COMMENT '被邀请人用户ID(唯一, 防重复绑定)',
points INT DEFAULT 0 COMMENT '本次获得邀请积分',
status TINYINT DEFAULT 1 COMMENT '1已发放',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_invitee (invitee_id),
INDEX idx_inviter (inviter_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 邀请积分兑换答题卡记录表
CREATE TABLE invite_exchange_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
cost_points INT NOT NULL COMMENT '消耗邀请积分',
cards INT NOT NULL COMMENT '获得答题卡(答题次数)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 签到记录表 (7天一轮, 按累计签到次数定位, 无连续逻辑)
CREATE TABLE signin_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
sign_date DATE NOT NULL COMMENT '签到日期',
round_no INT DEFAULT 1 COMMENT '第几轮(1,2,3...)',
day_no INT DEFAULT 1 COMMENT '本轮第几天(1-7)',
total_count INT DEFAULT 1 COMMENT '累计签到次数',
reward_kind TINYINT DEFAULT 1 COMMENT '奖励形式 1答题卡 2积分(第7天)',
reward_value INT DEFAULT 0 COMMENT '奖励数值',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_date (user_id, sign_date),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 系统配置表
CREATE TABLE sys_configs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
config_key VARCHAR(100) UNIQUE NOT NULL,
config_value TEXT NOT NULL,
description VARCHAR(200) DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 广告配置表 (多类型多广告位统一管理)
-- 广告位 ID 固定复用, 一个广告位=一个ID, 与用户无关; 展示内容由微信平台动态下发
-- 同一类型可配多条(如多个Banner), 通过 page/position 区分展示场景
-- ad_type: 1激励视频 2插屏 3Banner 4原生模板 5开屏(无代码接入) 6格子广告
CREATE TABLE ad_configs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ad_unit_id VARCHAR(100) DEFAULT '' COMMENT '广告单元ID(流量主后台创建, 开屏广告留空)',
ad_type TINYINT DEFAULT 1 COMMENT '广告类型 1激励视频 2插屏 3Banner 4原生模板 5开屏 6格子广告',
name VARCHAR(50) DEFAULT '' COMMENT '广告位名称(如: 首页顶部Banner)',
page VARCHAR(50) DEFAULT '' COMMENT '展示页面(如: index首页 game答题页 mine我的页)',
position VARCHAR(50) DEFAULT '' COMMENT '展示位置/场景(如: game_quota答题次数)',
daily_limit INT DEFAULT 10 COMMENT '每日每人观看上限(激励视频风控用)',
sort_order INT DEFAULT 0 COMMENT '排序(同页多广告位时)',
remark VARCHAR(200) DEFAULT '' COMMENT '备注',
status TINYINT DEFAULT 1 COMMENT '1启用 2禁用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 管理员表
CREATE TABLE admins (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role TINYINT DEFAULT 1 COMMENT '1超级管理员 2普通管理员',
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 管理员操作日志表 (后台审计)
CREATE TABLE admin_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
admin_id BIGINT NOT NULL COMMENT '操作管理员ID',
action VARCHAR(50) NOT NULL COMMENT '操作动作(如: riddle.update / redeem.handle)',
target_type VARCHAR(30) DEFAULT '' COMMENT '操作对象类型(riddle/user/redeem/config...)',
target_id VARCHAR(50) DEFAULT '' COMMENT '操作对象ID',
detail TEXT COMMENT '操作详情(JSON, 含变更前后值)',
ip VARCHAR(50) DEFAULT '' COMMENT '操作IP',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_admin (admin_id),
INDEX idx_action (action),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
系统支持 4 种题型,由 riddles.question_type 字段区分:
| 题型 | question_type | 题目构成 | 选项构成 | 答案 |
|---|---|---|---|---|
| 文字单选 | 1 | 纯文字题目 | 2/3/4 个文字选项 | 正确选项 key(A/B/C/D) |
| 文字判断 | 2 | 纯文字题目(陈述句) | 固定 2 个选项「对 / 错」 | T 或 F |
| 文字图片单选 | 3 | 文字 + 图片题目 | 2/3/4 个文字选项 | 正确选项 key(A/B/C/D) |
| 图片答案单选 | 4 | 文字 + 图片题目 | 2/3/4 个图片选项 | 正确选项 key(A/B/C/D) |
options 字段格式(JSON)
// 题型1/3:文字选项
[{"key":"A","text":"老虎","image":""},
{"key":"B","text":"狮子","image":""},
{"key":"C","text":"熊猫","image":""},
{"key":"D","text":"大象","image":""}]
// 题型2:对错判断(固定)
[{"key":"T","text":"对","image":""},
{"key":"F","text":"错","image":""}]
// 题型4:图片答案选项(text 可为空)
[{"key":"A","text":"","image":"https://cdn.xxx.com/opt_a.png"},
{"key":"B","text":"","image":"https://cdn.xxx.com/opt_b.png"},
{"key":"C","text":"","image":"https://cdn.xxx.com/opt_c.png"}]
INSERT INTO sys_configs (config_key, config_value, description) VALUES
('daily_free_count', '5', '每日免费答题次数'),
('ad_reward_count', '3', '观看广告奖励次数'),
('ad_reward_points', '0', '观看广告额外积分奖励'),
('min_redeem', '10', '最低兑换积分'),
('redeem_switch', '1', '兑换开关 1开 0关'),
('redeem_daily_limit', '0', '每日兑换积分上限, 0=不限制(默认取 redeem_limits 表, 0表示不限制)'),
('redeem_monthly_limit', '0', '每月兑换积分上限, 0=不限制'),
('redeem_max_once', '0', '单次最大兑换积分, 0=不限制'),
('customer_service_wechat', 'kefu_wechat_id', '客服微信号(用于兑换)'),
('customer_service_qrcode', '', '客服微信二维码图片地址'),
('invite_switch', '1', '邀请开关 1开 0关'),
('invite_points_per_person', '10', '每成功邀请1人获得邀请积分'),
('invite_exchange_cost', '30', '兑换1次所需邀请积分'),
('invite_exchange_cards', '5', '兑换1次获得答题卡数'),
('invite_code_length', '6', '邀请码长度'),
('signin_switch', '1', '签到开关 1开 0关'),
('signin_day_cards', '1', '第1-6天每天签到奖励答题卡数'),
('signin_day7_kind', '1', '第7天奖励形式 1答题卡 2积分'),
('signin_day7_cards', '10', '第7天奖励答题卡数(当kind=1)'),
('signin_day7_points', '50', '第7天奖励积分(当kind=2)'),
('register_gift_switch', '1', '新人礼包开关 1开 0关'),
('register_gift_cards', '5', '新人注册赠送答题卡'),
('register_gift_points', '20', '新人注册赠送积分'),
('webhook_switch', '0', '消息推送开关 1开 0关'),
('webhook_wecom', '', '企业微信机器人webhook地址'),
('webhook_dingtalk', '', '钉钉机器人webhook地址'),
('webhook_feishu', '', '飞书机器人webhook地址(可选)'),
('webhook_level', '2', '推送级别 1仅严重 2警告+严重 3全部'),
('app_name', '猜谜乐园', '小程序名称'),
('welcome_text', '欢迎来到猜谜乐园', '欢迎语');
⚠️ 以上所有数值均为示例默认值(每邀请奖励积分/兑换所需积分/兑换获得答题卡/每日签到答题卡/第7天奖励/每日次数/广告奖励等),全部通过管理后台「系统配置」页动态修改,修改即时生效,无需发版。配置项统一汇总见 4.5 节。
server/
├── cmd/
│ └── main.go # 入口
├── internal/
│ ├── config/ # 配置管理
│ │ └── config.go
│ ├── middleware/ # 中间件
│ │ ├── auth.go # JWT认证
│ │ ├── cors.go
│ │ └── logger.go
│ ├── model/ # 数据模型
│ │ ├── user.go
│ │ ├── riddle.go
│ │ ├── answer.go
│ │ ├── redeem.go
│ │ ├── signin.go # 签到记录
│ │ ├── invite.go # 邀请记录
│ │ └── config.go
│ ├── handler/ # 控制器
│ │ ├── auth.go # 登录注册
│ │ ├── riddle.go # 谜语相关
│ │ ├── game.go # 答题逻辑
│ │ ├── points.go # 积分相关
│ │ ├── redeem.go # 积分兑换
│ │ ├── signin.go # 签到
│ │ ├── task.go # 任务(答题卡获取)
│ │ ├── invite.go # 邀请
│ │ ├── admin/ # 后台管理
│ │ │ ├── auth.go
│ │ │ ├── riddle.go
│ │ │ ├── user.go
│ │ │ ├── config.go
│ │ │ ├── redeem.go
│ │ │ ├── invite.go # 邀请配置+记录
│ │ │ ├── stats.go # 数据统计报表
│ │ │ ├── log.go # 操作日志
│ │ │ └── notify.go # webhook推送配置
│ │ └── ad.go # 广告回调
│ ├── service/ # 业务逻辑
│ │ ├── auth.go
│ │ ├── riddle.go
│ │ ├── game.go
│ │ ├── points.go
│ │ ├── redeem.go
│ │ ├── signin.go
│ │ ├── task.go
│ │ └── invite.go
│ ├── repository/ # 数据访问层
│ │ ├── user.go
│ │ ├── riddle.go
│ │ ├── answer.go
│ │ ├── redeem.go # 兑换记录 + 兑换额度(redeem_limits)
│ │ ├── signin.go
│ │ ├── invite.go
│ │ ├── log.go # 操作日志
│ │ └── config.go
│ └── pkg/
│ ├── wechat/ # 微信SDK
│ │ └── miniapp.go
│ ├── notify/ # webhook消息推送(企业微信/钉钉/飞书)
│ │ └── webhook.go
│ ├── response/ # 统一响应
│ └── utils/
├── go.mod
├── go.sum
├── config.yaml # 配置文件
└── Makefile
| Method | Path | 说明 | 鉴权 |
|---|---|---|---|
| POST | /api/v1/auth/login | 微信登录 | 否 |
| GET | /api/v1/user/profile | 获取用户信息 | 是 |
| GET | /api/v1/riddle/random | 获取随机谜语(含题型+选项列表) | 是 |
| GET | /api/v1/riddle/categories | 获取分类列表 | 是 |
| POST | /api/v1/game/answer | 提交答案(所选选项key) | 是 |
| GET | /api/v1/game/quota | 获取今日剩余次数 | 是 |
| GET | /api/v1/ads?page=xx&type=xx | 获取启用广告位配置(按页面/类型过滤) | 否 |
| GET | /api/v1/upload/token | 获取COS上传临时密钥(签名直传) | 是 |
| POST | /api/v1/ad/reward | 广告回调(发放次数) | 是 |
| GET | /api/v1/points/records | 积分明细 | 是 |
| GET | /api/v1/redeem/info | 兑换规则 + 客服微信信息 | 是 |
| POST | /api/v1/redeem/apply | 提交兑换申请 | 是 |
| GET | /api/v1/redeem/records | 兑换记录 | 是 |
| GET | /api/v1/rank/list | 排行榜 | 是 |
| GET | /api/v1/signin/info | 签到信息(累计次数/本轮天数/7天列表与奖励) | 是 |
| POST | /api/v1/signin/check | 执行签到(按累计次数定位本轮第几天) | 是 |
| GET | /api/v1/task/list | 任务列表(答题卡获取任务) | 是 |
| GET | /api/v1/invite/info | 邀请信息(邀请码/邀请积分/兑换比例/记录) | 是 |
| POST | /api/v1/invite/bind | 绑定邀请码(新用户) | 是 |
| POST | /api/v1/invite/exchange | 邀请积分兑换答题卡 | 是 |
| GET | /api/v1/invite/poster | 生成邀请海报 | 是 |
| Method | Path | 说明 |
|---|---|---|
| POST | /admin/v1/auth/login | 管理员登录 |
| GET | /admin/v1/stats/dashboard | 首页统计(总览卡片) |
| GET | /admin/v1/stats/overview | 统计总览(用户/答题/积分/兑换/广告汇总) |
| GET | /admin/v1/stats/daily?start=&end= | 每日趋势(DAU/答题量/新增用户/广告观看) |
| GET | /admin/v1/stats/ads?start=&end= | 广告数据(观看次数/预估收入) |
| GET | /admin/v1/stats/redeems?start=&end= | 兑换数据(申请数/兑换积分支出) |
| CRUD | /admin/v1/riddles | 谜语管理(4种题型+选项+图片上传) |
| CRUD | /admin/v1/categories | 分类管理 |
| GET | /admin/v1/users | 用户列表 |
| PUT | /admin/v1/users/:id/status | 用户状态 |
| GET | /admin/v1/redeems | 兑换记录列表 |
| PUT | /admin/v1/redeems/:id | 处理兑换(扣积分+标记完成) |
| GET | /admin/v1/users/:id/redeem-limit | 查看用户兑换额度 |
| PUT | /admin/v1/users/:id/redeem-limit | 设置用户兑换额度(上限/冻结) |
| GET/PUT | /admin/v1/configs | 系统配置 |
| CRUD | /admin/v1/ads | 广告配置 |
| GET/PUT | /admin/v1/invite-config | 邀请配置(每邀请奖励积分/兑换所需积分/兑换获得答题卡) |
| GET | /admin/v1/invites | 邀请记录列表 |
| GET | /admin/v1/invite-exchanges | 邀请积分兑换答题卡流水 |
| GET | /admin/v1/logs?admin_id=&action=&date= | 操作日志列表(筛选) |
| GET/PUT | /admin/v1/notify-config | webhook推送配置(开关/渠道地址/级别) |
用户发起答题请求
↓
校验登录状态
↓
校验次数: 当日免费次数 → 答题卡余额(answer_cards) → 引导看广告
↓ [次数不足]
提示次数用完 → 引导看广告
↓ [有次数]
随机获取谜语 (排除今日已答, 返回题目+选项列表)
↓
用户点选选项(文字/图片/对错) 提交所选 key
↓
校验所选 key == 谜语 answer
↓ [正确]
增加积分 → 记录答题(含所选答案) → 扣减次数(免费优先,再答题卡) → 返回结果
↓ [错误]
记录答题 → 扣减次数 → 返回结果
用户点击"获取额外次数"
↓
调用 wx.createRewardedVideoAd
↓
广告播放完成 (onClose 回调, isEnded=true 完整观看)
↓
前端上报观看 (含广告位ID)
↓
后端风控校验: 每日观看上限(ad_configs.daily_limit) + 观看间隔 + 频率限制
↓ [校验通过]
增加答题卡(answer_cards) → 记录广告观看
↓ [校验失败]
拒绝增加次数
用户积分达到最低兑换额度
↓
点击"兑换" → 查看兑换规则 + 客服微信(二维码/微信号)
↓
提交兑换申请
↓
后端校验兑换额度 (redeem_limits 表 + 全局配置):
累计上限 / 每日上限 / 每月上限 / 单次上限 / 是否冻结
↓ [额度不足或冻结]
拒绝申请 → 提示原因
↓ [校验通过]
记录待兑换申请 (redeem_records)
↓
添加客服微信,向客服出示小程序昵称/OpenID 说明兑换
↓
客服在后台查看兑换申请 → 线下转账给用户
↓ [转账完成]
后台将兑换记录标记为"已兑换" → 扣除用户积分
→ 更新 redeem_limits 已用额度/日计数/月计数
↓ [用户取消]
后台将兑换记录标记为"已取消" → 不扣积分
// 答题核心逻辑 (选项式答题: answer 为用户所选选项 key, 如 "A"/"B"/"T"/"F")
func (s *GameService) Answer(userID, riddleID int64, selectedKey string) (*AnswerResult, error) {
// 1. 获取谜语
riddle, err := s.riddleRepo.GetByID(riddleID)
if err != nil {
return nil, err
}
// 2. 校验所选选项合法性 (key 必须在 options 中)
if !riddle.HasOption(selectedKey) {
return nil, errors.New("无效的选项")
}
// 3. 检查是否已答过
if s.answerRepo.HasAnsweredToday(userID, riddleID) {
return nil, errors.New("今日已答过此题")
}
// 4. 校验次数
quota, err := s.GetDailyQuota(userID)
if err != nil {
return nil, err
}
if quota.FreeRemain <= 0 && quota.AdRemain <= 0 {
return nil, errors.New("今日次数已用完")
}
// 5. 判断答案 (比对选项 key)
isCorrect := selectedKey == riddle.Answer
// 6. 计算积分
var pointsEarned int
source := 1 // 正常次数
if isCorrect {
pointsEarned = riddle.Points
}
if quota.FreeRemain <= 0 {
source = 2 // 广告次数
}
// 7. 事务处理
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 记录答题
s.answerRepo.Create(&AnswerRecord{
UserID: userID,
RiddleID: riddleID,
SelectedAnswer: selectedKey,
IsCorrect: isCorrect,
PointsEarned: pointsEarned,
Source: source,
}, tx)
// 扣减次数
s.quotaRepo.DecrementCount(userID, source, tx)
// 增加积分
if pointsEarned > 0 {
s.userRepo.AddPoints(userID, pointsEarned, tx)
}
tx.Commit()
return &AnswerResult{
IsCorrect: isCorrect,
AnswerKey: riddle.Answer, // 正确选项key(如"A")
AnswerText: riddle.CorrectOption(), // 正确选项内容(文字/图片URL), 用于结果页展示
PointsEarned: pointsEarned,
Hint: riddle.Hint,
}, nil
}
// 兑换额度校验 (redeem_limits 表 + 全局配置)
func (s *RedeemService) CheckLimit(userID int64, points int) error {
// 1. 获取用户额度记录 (无则按全局配置初始化默认值)
limit, err := s.redeemRepo.GetOrCreateLimit(userID)
if err != nil {
return err
}
// 2. 用户是否被冻结兑换
if limit.Status == 2 {
return errors.New("兑换功能已被冻结, 请联系客服")
}
// 3. 累计额度上限
if limit.TotalLimit > 0 && limit.UsedTotal+points > limit.TotalLimit {
return errors.New("累计兑换额度已达上限")
}
// 4. 每日上限 (按 last_redeem_date 重置日计数)
if limit.LastRedeemDate != today() {
limit.DailyCount = 0
}
if limit.DailyLimit > 0 && limit.DailyCount*limit.MaxOnce+points > limit.DailyLimit {
return errors.New("今日兑换额度已达上限")
}
// 5. 单次上限
if limit.MaxOnce > 0 && points > limit.MaxOnce {
return errors.New("单次兑换超过上限")
}
return nil
}
// 兑换成功后更新额度计数 (事务内调用)
func (s *RedeemService) ConsumeLimit(userID int64, points int) error {
return s.redeemRepo.Consume(userID, points) // used_total+points, 日/月计数+1, last_redeem_date=today
}
// 签到逻辑 (7天一轮, 按累计签到次数定位, 无连续签到概念)
func (s *SigninService) Check(userID int64) (*SigninResult, error) {
// 1. 今日是否已签到 (uk_user_date)
if s.signinRepo.HasSignedToday(userID) {
return nil, errors.New("今日已签到")
}
// 2. 累计签到次数 → 定位本轮第几天
totalCount := s.signinRepo.TotalCount(userID) + 1 // 本次是第 N 次签到
dayNo := totalCount % 7
if dayNo == 0 {
dayNo = 7 // 第7/14/21...次 → 本轮第7天(大奖日)
}
roundNo := (totalCount-1)/7 + 1
// 3. 计算奖励
var kind, value int
if dayNo < 7 {
kind, value = 1, cfg.SigninDayCards // 1-6天: +每日签到答题卡
} else {
kind = cfg.SigninDay7Kind // 第7天: 后台配置形式
if kind == 1 {
value = cfg.SigninDay7Cards // 答题卡
} else {
value = cfg.SigninDay7Points // 积分余额
}
}
// 4. 事务: 写 signin_records(round/day/total) + 发放奖励
// kind=1 → users.answer_cards += value
// kind=2 → users.points += value
return &SigninResult{
RoundNo: roundNo,
DayNo: dayNo,
TotalCount: totalCount,
RewardKind: kind,
RewardValue: value,
}, nil
}
// 签到页数据 (第2轮展示8-14天, 第3轮15-21天...)
func (s *SigninService) GetSigninPage(userID int64) *SigninPage {
total := s.signinRepo.TotalCount(userID) // 已签到次数
dayNo := total % 7
if dayNo == 0 { dayNo = 7 }
roundNo := (total-1)/7 + 1
startDay := (roundNo-1)*7 + 1 // 本轮起始天: 1/8/15...
// 返回 startDay ~ startDay+6 共7天的列表,
// 每格: 天序号, 是否已签, 奖励说明(1-6天p答题卡, 第7天大奖), 第7天样式高亮
return &SigninPage{
RoundNo: roundNo,
DayNo: dayNo, // 今天在本轮的位置(未签则显示今天)
StartDay: startDay,
Days: buildDays(startDay, total), // 7个格子
}
}
// 答题消耗逻辑: 每日免费次数 → 答题卡余额 → 引导看广告
func (s *GameService) ConsumeAnswerChance(userID int64) error {
quota := s.GetDailyQuota(userID)
if quota.FreeRemain > 0 {
return s.quotaRepo.DecrementFree(userID) // 1. 当日免费次数
}
if s.userRepo.AnswerCards(userID) > 0 {
return s.userRepo.DeductAnswerCard(userID) // 2. 答题卡余额
}
return errors.New("次数用尽, 请观看广告获取") // 3. 引导广告
}
核心模型:邀请成功 1 人 → 获得每邀请奖励积分个邀请积分;累计兑换所需积分个邀请积分 → 可兑换兑换获得答题卡张答题卡(答题次数)。以上数值全部后台可配置。
老用户: 邀请页查看邀请码 → 分享卡片 / 生成海报(含小程序码+邀请码)
↓
新用户: 通过分享进入小程序 (路径参数 invite=ABC123 或 海报小程序码 scene)
↓
首次微信登录注册 → 自动绑定邀请码 (users.inviter_id = 老用户ID)
↓
邀请成功: 老用户 invite_points += 每邀请奖励积分 → 记录 invite_records
↓
老用户在邀请页/任务页: 点击"兑换答题卡"
↓
校验 invite_points >= 兑换所需积分 → 扣除 → 发放 兑换获得答题卡 张(answer_cards += 兑换获得答题卡)
→ 记录 invite_exchange_records
| 项目 | 规则 |
|---|---|
| 生成 | 用户注册时自动生成(首次进邀请页时兜底生成) |
| 格式 | 长度后台可配(默认 6 位),大写字母+数字随机组合,全局唯一 |
| 传递方式 | ① 分享卡片路径参数 pages/index/index?invite=ABC123 ② 海报小程序码 scene=invite_ABC123 ③ 手动输入(兜底) |
| 绑定时机 | 新用户首次登录注册时自动绑定;已绑定用户不可重复绑定/不可改绑 |
| 参数名 | 配置项 key | 默认值 | 含义 |
|---|---|---|---|
| 每邀请奖励积分 | invite_points_per_person |
10 | 每成功邀请 1 人获得的邀请积分 |
| 兑换所需积分 | invite_exchange_cost |
30 | 兑换 1 次所需的邀请积分 |
| 兑换获得答题卡 | invite_exchange_cards |
5 | 兑换 1 次获得的答题卡(答题次数) |
| 邀请码长度 | invite_code_length |
6 | 邀请码长度 |
| 邀请开关 | invite_switch |
1 | 总开关 |
示例(默认值):邀请 3 人 → 30 邀请积分 → 兑换 1 次 → +5 答题卡。
邀请积分独立于答题积分(users.invite_points),只用于兑换答题卡,不可兑换现金。
// 1. 邀请成功触发: 新用户注册绑定邀请码时
func (s *InviteService) OnInviteSuccess(inviterID, inviteeID int64) error {
// 校验: 邀请码存在 && 邀请人 != 新用户 && 新用户未绑定(uk_invitee)
// 事务:
// users.inviter_id = inviterID (被邀请人)
// users.invite_points += cfg.InvitePointsPerPerson (邀请人)
// 写 invite_records(邀请人, 被邀请人, 奖励邀请积分)
}
// 2. 兑换触发: 用户用邀请积分换答题卡
func (s *InviteService) Exchange(userID int64) error {
// 读取配置: 兑换所需积分, 兑换获得答题卡
// 校验: users.invite_points >= 兑换所需积分
// 事务:
// users.invite_points -= 兑换所需积分
// users.answer_cards += 兑换获得答题卡
// 写 invite_exchange_records(消耗积分, 获得答题卡)
}
UNIQUE KEY uk_invitee,一个被邀请人只能被邀请一次,防止反复绑定刷邀请积分管理员提供企业微信 / 钉钉(可选飞书)机器人的 webhook key,后台配置后,系统的警告与关键事件在记录到后端日志的同时,同步推送到这些渠道,便于管理员随时掌握运营状况。
| 配置项 | 说明 |
|---|---|
webhook_switch |
推送总开关(默认关) |
webhook_wecom |
企业微信机器人 webhook 地址 |
webhook_dingtalk |
钉钉机器人 webhook 地址 |
webhook_feishu |
飞书机器人 webhook 地址(可选) |
webhook_level |
推送级别:1 仅严重 / 2 警告+严重(默认)/ 3 全部 |
| 级别 | 事件 | 示例内容 |
|---|---|---|
| 🔴 严重 | 服务异常 | 数据库连接失败、500 错误率突增、服务启动失败 |
| 🟠 警告 | 业务关键事件 | 新兑换申请待处理、防刷拦截次数突增、大量异常注册 |
| 🔵 信息 | 运营日报 | 每日汇总:新增用户/答题量/广告收入/兑换支出(可选) |
// pkg/notify/webhook.go
type Event struct {
Level int // 1严重 2警告 3信息
Title string // 标题(维吾尔语/中文均可, 推给管理员用中文)
Content string // 内容(markdown)
}
// 统一入口: 写日志 + 按配置推送到已启用的渠道
func Send(e Event) {
// 1. 写后端日志(结构化, 含时间/级别/事件)
log.Warnf("[notify][%d] %s: %s", e.Level, e.Title, e.Content)
// 2. 级别过滤: e.Level <= webhook_level 才推送
// 3. 企业微信: POST {msgtype:"markdown", markdown:{content}}
// 4. 钉钉: POST {msgtype:"markdown", markdown:{title,text}}
// 5. 失败静默降级(不影响主流程), 记录推送失败日志
}
// 触发点示例
func (s *RedeemService) Apply(...) {
// ... 兑换申请入库后
notify.Send(notify.Event{
Level: 2, Title: "新兑换申请",
Content: fmt.Sprintf("用户 %s 申请兑换 %d 积分, 请及时处理", nickname, points),
})
}
新用户首次注册成功时自动发放礼包(答题卡 + 积分),数值后台可配置。
配置项:register_gift_switch(开关)/ register_gift_cards(赠送答题卡)/ register_gift_points(赠送积分)
注册流程(auth 注册事务内)
func (s *AuthService) Register(wxCode, inviteCode string) (*User, error) {
// 1. 微信登录获取 openid, 已存在则直接登录
// 2. 新用户: 创建 users 记录 + 生成邀请码
// 3. 新人礼包 (开关开启时):
// users.answer_cards += cfg.RegisterGiftCards
// users.points += cfg.RegisterGiftPoints
// 4. 邀请绑定 (若带 inviteCode): 走 InviteService.OnInviteSuccess
// (新人礼包与邀请奖励可叠加)
// 5. 事务提交
}
新人礼包、邀请奖励、签到、广告、任务获得的答题卡/积分全部可叠加,统一计入
users.answer_cards/users.points。
miniapp/
├── app.js # 小程序入口(登录态/全局数据)
├── app.json # 全局配置(页面路由/窗口样式/tabBar)
├── app.wxss # 全局样式
├── project.config.json # 开发者工具项目配置
├── sitemap.json # 索引配置
├── custom-tab-bar/ # 自定义tabBar (RTL从右到左)
│ ├── index.js
│ ├── index.wxml
│ ├── index.wxss
│ └── index.json
├── pages/
│ ├── index/ # 首页 [tab·最右] 猜谜语+谜语卡片+今日排行
│ ├── redeem/ # 兑换 [tab] 积分详情+兑换列表
│ ├── signin/ # 签到 [tab] 7天一轮签到(按累计次数定位)
│ ├── task/ # 任务 [tab] 答题卡获取(看广告等)
│ ├── invite/ # 邀请 [tab·最左] 邀请好友(奖励后台可配置)
│ ├── result/ # 答题结果 (二级页)
│ ├── rank/ # 排行榜 (二级页)
│ └── history/ # 答题历史 (二级页)
├── components/ # 自定义组件 (每个组件4文件: js/json/wxml/wxss)
│ ├── riddle-card/ # 谜语卡片(题目+选项+倒计时)
│ ├── text-options/ # 题型1/3 文字选项组件
│ ├── true-false/ # 题型2 对错判断组件
│ ├── image-options/ # 题型4 图片答案宫格组件
│ ├── signin-week/ # 7天签到列表组件(按轮次渲染, 第7天突出)
│ ├── task-card/ # 任务卡片组件
│ ├── invite-poster/ # 邀请海报组件
│ ├── banner-ad/ # Banner广告组件(按 page 渲染对应广告位)
│ ├── ad-button/ # 激励视频广告按钮
│ ├── count-down/ # 倒计时
│ ├── points-badge/ # 积分徽章
│ └── empty/ # 空状态
├── api/
│ ├── request.js # 请求封装(wx.request + token)
│ ├── auth.js
│ ├── game.js
│ ├── ad.js # 广告位配置接口
│ └── user.js
├── utils/
│ ├── auth.js # 登录/token 管理
│ ├── adManager.js # 广告管理器(按类型分发: 激励视频/插屏/Banner/原生模板)
│ ├── storage.js # wx.setStorage 封装
│ └── ug.js # 维吾尔语文案集中管理(i18n, 唯一文案来源)
└── images/ # 静态图片资源
🌐 所有页面为**维吾尔语 + RTL(从右到左)**布局:标题在右、内容右对齐、返回键在右、tabBar 从右到左(首页在最右)。下方原型图按 RTL 展示,tabBar 行从左到右显示为「邀请 任务 签到 兑换 首页」。
底部 tabBar 共 5 个入口,从右到左依次:首页 / 兑换 / 签到 / 任务 / 邀请(首页在最右,符合维吾尔语阅读习惯):
┌──────────────────────────────────────────┐
│ 👥 邀请 📋 任务 📅 签到 💰 兑换 🏠 首页 │
└──────────────────────────────────────────┘
←—— 维吾尔语阅读方向(从右到左)——←
必须使用自定义 tabBar(custom-tab-bar):微信原生 tabBar 固定从左到右,不支持 RTL,需用 custom-tab-bar 组件实现从右到左排列。
custom-tab-bar 结构
custom-tab-bar/
├── index.js
├── index.wxml
├── index.wxss
└── index.json
app.json 开启自定义 tabBar
{
"tabBar": {
"custom": true,
"color": "#999999",
"selectedColor": "#FF6B35",
"backgroundColor": "#ffffff",
"list": [
{ "pagePath": "pages/index/index", "text": "باش بەت" },
{ "pagePath": "pages/redeem/redeem", "text": "ئالماشتۇرۇش" },
{ "pagePath": "pages/signin/signin", "text": "دىم ئېلىش" },
{ "pagePath": "pages/task/task", "text": "ۋەزىپە" },
{ "pagePath": "pages/invite/invite", "text": "تەكلىپ" }
]
}
}
注:
app.json中text仅作占位,实际 tabBar 文案由自定义 tab-bar 组件从utils/ug.js读取(维吾尔语文案示例:باش بەت=首页 / ئالماشتۇرۇش=兑换 / دىم ئېلىش=签到 / ۋەزىپە=任务 / تەكلىپ=邀请,最终文案以实际翻译为准)。
custom-tab-bar RTL 实现要点
<!-- index.wxml: flex 反转实现从右到左 -->
<view class="tab-bar">
<view wx:for="{{list}}" wx:key="index" class="tab-item" bindtap="switchTab">
<image src="{{item.selected ? item.selectedIconPath : item.iconPath}}" />
<text>{{item.text}}</text>
</view>
</view>
/* index.wxss */
.tab-bar {
display: flex;
flex-direction: row-reverse; /* 关键: 从右到左排列, 首页在最右 */
/* 或 direction: rtl */
}
┌─────────────────────────────┐
│ 🎯 猜谜乐园 🔔 │
├─────────────────────────────┤
│ 👤 头像 昵称 💰150积分 │
│ 📖 今日次数: 3/5 [看广告+3] │
├─────────────────────────────┤
│ 分类: [动物] [成语] [地名] │
│ [植物] [更多▾] │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ ⏱️ 00:30 ⭐⭐ 成语 │ │
│ │ │ │
│ │ 谜面: 七十二小时 │ │
│ │ │ │
│ │ ○ A. 晶 ○ B. 众 │ │
│ │ ○ C. 品 ○ D. 森 │ │
│ │ [提示] [提交答案] │ │
│ └─────────────────────┘ │
│ (题型2: 对/错 按钮) │
│ (题型3: 题目图+文字选项) │
│ (题型4: 题目图+图片宫格) │
├─────────────────────────────┤
│ 🏆 今日排行 [更多>] │
│ 1. 张三 150分 │
│ 2. 李四 120分 │
│ 3. 王五 100分 │
├─────────────────────────────┤
│ [首页] [兑换] [签到] [任务] [邀请] │
└─────────────────────────────┘
说明:首页顶部为"用户信息 + 次数栏",中部为谜语卡片(根据
question_type渲染 4 种题型),底部为今日排行 TOP3,点击"更多"进入排行榜二级页。
┌─────────────────────────────┐
│ 💰 积分兑换 │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 当前积分: 150 │ │
│ │ 可兑换: 150 元 │ │
│ │ (1积分=1元) │ │
│ │ 最低兑换: 10 积分 │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 📱 添加客服微信兑换 │ │
│ │ 微信号: kefu_xxx │ │
│ │ [保存客服二维码] │ │
│ └─────────────────────┘ │
│ [📝 提交兑换申请] │
│ │
│ ── 积分详情 ── │
│ 12-01 +20 答对谜语 │
│ 12-01 +3 看广告奖励 │
│ 11-30 +20 答对谜语 │
│ [查看全部>] │
│ │
│ ── 兑换记录 ── │
│ 11-28 兑换50积分 已兑换 │
│ 11-20 兑换100积分 待兑换 │
├─────────────────────────────┤
│ [首页] [兑换] [签到] [任务] [邀请] │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 📅 签到 第 2 轮 │
├─────────────────────────────┤
│ 累计签到: 9 次 本轮: 8-14天 │
│ [✅ 今日签到] │
│ (第9次签到 = 本轮第2天) │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 第8天 第9天 第10天 │ │
│ │ ✅ ✅ [今日] │ │
│ │ +1卡 +1卡 +1卡 │ │
│ │ 第11天 第12天 第13天 │ │
│ │ +1卡 +1卡 +1卡 │ │
│ │ ⭐第14天(大奖日)⭐ │ │
│ │ 🎁 大奖: +10答题卡 │ │
│ │ (后台可配置) │ │
│ └─────────────────────┘ │
│ 规则: │
│ · 第1-6天签到 +每日签到答题卡 │
│ · 第7天为大奖日(突出显示) │
│ 奖励: 较多答题卡或积分 │
│ (后台可配置) │
│ · 完成一轮后进入下一轮 │
│ (8-14 / 15-21 ... 展示) │
├─────────────────────────────┤
│ [首页] [兑换] [签到] [任务] [邀请] │
└─────────────────────────────┘
签到定位规则:第 N 次签到 = 本轮第
N % 7天(余 0 为第 7 天大奖日)。昨天签了一次算第 1 天,今天没签,明天签则是第 2 天——不要求连续,位置只由累计次数决定。完成第 7 次签到后,页面切换为下一轮(第 8-14 天),奖励逻辑不变。每日签到答题卡与第 7 天奖励均为示例值,后台可配置。
┌─────────────────────────────┐
│ 📋 任务中心 │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 🎬 看广告得答题卡 │ │
│ │ 今日剩余: 2/5 次 │ │
│ │ [立即观看 +3答题卡] │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 📝 每日答题任务 │ │
│ │ 答对3题 +10积分 │ │
│ │ 进度: 2/3 [去答题>] │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 📖 签到领次数 │ │
│ │ 签到即得 +1答题卡 │ │
│ │ [去签到>] │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 👥 邀请好友 │ │
│ │ 邀请1人 +m邀请积分 │ │
│ │ n邀请积分换o答题卡 │ │
│ │ [去邀请>] │ │
│ └─────────────────────┘ │
│ 答题卡 = 1次答题机会 │
│ (所有奖励数值后台可配置) │
├─────────────────────────────┤
│ [首页] [兑换] [签到] [任务] [邀请] │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 👥 邀请好友 │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 邀请奖励(数值示例, │ │
│ │ 后台均可配置) │ │
│ │ · 邀请1人 +10 邀请积分│ │
│ │ · 30 邀请积分兑换 │ │
│ │ +5 答题卡 │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 我的邀请码: ABC123 │ │
│ │ 已邀请: 3 人 │ │
│ │ 邀请积分: 30 │ │
│ │ [⚡ 兑换 5 答题卡] │ │
│ │ (需30邀请积分) │ │
│ └─────────────────────┘ │
│ [🖼️ 生成邀请海报] │
│ [📤 微信分享给好友] │
│ │
│ ── 邀请记录 ── │
│ 张三 12-01 +10 邀请积分 │
│ 李四 11-30 +10 邀请积分 │
│ │
│ ── 兑换记录 ── │
│ 12-02 -30邀请积分 +5答题卡 │
├─────────────────────────────┤
│ [首页] [兑换] [签到] [任务] [邀请] │
└─────────────────────────────┘
答题结果页(result):答对/答错反馈 + 获得积分 + 正确答案展示 + 继续答题/看下一题按钮。
┌─────────────────────────────┐
│ ✅ 回答正确! │
│ +20 积分 │
│ 答案: C. 品 │
│ [下一题] [分享给好友] │
└─────────────────────────────┘
排行榜页(rank):今日/本周/总榜切换 + TOP100 列表。
┌─────────────────────────────┐
│ 🏆 排行榜 今日|本周|总榜 │
├─────────────────────────────┤
│ 🥇 张三 150分 │
│ 🥈 李四 120分 │
│ 🥉 王五 100分 │
│ 4. 赵六 90分 │
│ ... │
└─────────────────────────────┘
1. 分享带邀请码(onShareAppMessage)
// pages/invite/invite.js
Page({
onShareAppMessage() {
return {
title: 'ئايلىنىپ كەلگەن مېھماننى تەكلىپ قىلىڭ', // 维吾尔语: 邀请好友
path: `/pages/index/index?invite=${this.data.myInviteCode}`
}
}
})
2. 新用户进入时解析并自动绑定
// app.js 或 index.js onLoad
Page({
onLoad(options) {
// 路径参数形式
let inviteCode = options.invite
// 小程序码 scene 形式 (需 decodeURIComponent)
if (options.scene) {
const scene = decodeURIComponent(options.scene) // 如 "invite_ABC123"
inviteCode = scene.replace('invite_', '')
}
if (inviteCode) {
api.invite.bind({ inviteCode }) // 后端校验唯一性, 已绑定则忽略
}
}
})
3. 邀请海报(canvas 生成小程序码)
// 生成小程序码(后端调微信接口) → 前端 canvas 绘制: 小程序码 + 昵称 + 邀请码 + 文案
wx.getImageInfo({ src: qrcodeUrl, success: img => {
// canvas 绘制海报, 导出图片 wx.canvasToTempFilePath
// 用户可保存分享
}})
ad_configs 中仅记录其状态多类型广告管理器(utils/adManager.js):启动时拉取后台启用广告位配置,按类型分发、按场景取用,新增广告位只需后台配置、无需发版。
// utils/adManager.js
const api = require('../../api/ad')
// ad_type: 1激励视频 2插屏 3Banner 4原生模板 5开屏 6格子广告
let ads = []
let rewardedAds = {} // position -> 激励视频实例
// 启动/进入页面时调用: 拉取该页面启用的广告位
function loadAds(page = '') {
return api.getAds({ page }).then(res => {
ads = res.data || []
// 预创建激励视频实例 (固定ID复用, 每个position一个实例)
ads.filter(a => a.ad_type === 1).forEach(a => {
if (!rewardedAds[a.position]) {
rewardedAds[a.position] = wx.createRewardedVideoAd({ adUnitId: a.ad_unit_id })
}
})
})
}
// 激励视频: 按场景取实例展示
function showRewarded(position, onEnded) {
const ad = rewardedAds[position]
if (!ad) return
ad.onClose(res => {
if (res && res.isEnded) onEnded()
})
ad.show().catch(() => ad.load().then(() => ad.show()))
}
// 插屏: 按场景获取实例 (页面按需展示)
function getInterstitial(position) {
const cfg = ads.find(a => a.ad_type === 2 && a.position === position)
return cfg ? wx.createInterstitialAd({ adUnitId: cfg.ad_unit_id }) : null
}
module.exports = { loadAds, showRewarded, getInterstitial }
页面接入示例(原生 WXML + JS)
<!-- 首页 index.wxml: Banner 广告 -->
<banner-ad page="index" />
<!-- 答题页 game.wxml: 次数用完时激励视频按钮 -->
<ad-button position="game_quota" bind:ended="onAdEnded" />
// 答题结果页 result.js: 插屏广告进入页面时展示
const { getInterstitial } = require('../../utils/adManager')
Page({
onShow() {
const ad = getInterstitial('result')
ad && ad.show()
}
})
⚠️ 注意:小程序激励视频广告无官方服务端验证接口,
isEnded来自客户端,后端必须自行风控(每日观看上限ad_configs.daily_limit+ 观看间隔 + 频率限制)。
微信小程序不原生支持全局 RTL,需要按以下方案逐层适配:
1. 全局基础样式(app.wxss)
/* app.wxss */
page {
direction: rtl; /* 全局从右到左 (WebView 支持) */
}
/* 保险做法: 容器统一 flex 反转 */
.row {
display: flex;
flex-direction: row-reverse;
}
/* 文本对齐 */
text, view, button {
text-align: right;
}
2. 适配清单
| 项目 | RTL 处理 |
|---|---|
| tabBar | 自定义 tabBar + flex-direction: row-reverse,从右到左:首页→兑换→签到→任务→邀请 |
| 返回按钮 | 放在右侧(RTL 中"返回"方向指向右) |
| 箭头/方向图标 | 左右翻转(transform: scaleX(-1) 或使用镜像图标) |
| 选项 key | 保持 A/B/C/D(维吾尔语字母表中位置无歧义) |
| 数字/积分/倒计时 | 保持 LTR 数字 0-9(维吾尔语常用阿拉伯数字),direction: ltr 局部隔离 |
| 时间/日期 | 数字部分 LTR,文本部分 RTL,用 unicode-bidi: embed 隔离 |
| 图片内容 | 题目图/选项图不翻转(内容图);装饰性图标需镜像 |
| 客服微信号 | 保持 LTR(微信号是拉丁字符) |
| 排行榜名次 | 排名数字放右侧,名字放左侧 |
3. 每页需要检查的 RTL 细节
placeholder 与光标位置text-overflow 在 RTL 下自动适配)4. 维吾尔语文案
原则:所有页面/组件不写死任何维吾尔语文字,统一从 utils/ug.js 调取,该文件是唯一文案来源。
1. ug.js 结构(按页面/组件分组 + 通用文案)
// utils/ug.js — 维吾尔语文案集中管理(ISO语言代码: ug)
// 所有界面文字都在此维护, 修改文案只需改本文件, 无需动页面代码
module.exports = {
common: { // 通用
confirm: 'جەزملەش', // 确定
cancel: 'بىكار قىلىش', // 取消
loading: 'يۈكلىنىۋاتىدۇ...', // 加载中
retry: 'قايتا سىناڭ', // 重试
},
tab: { // tabBar
home: 'باش بەت', // 首页
redeem: 'ئالماشتۇرۇش', // 兑换
signin: 'دىم ئېلىش', // 签到
task: 'ۋەزىپە', // 任务
invite: 'تەكلىپ', // 邀请
},
home: { // 首页
title: 'تېپىشماق', // 猜谜语
todayCount: 'بۈگۈنكى قېتىم: {0}/{1}', // 今日次数: {0}/{1} (占位符)
watchAd: 'ئېلان كۆرۈپ +{0} قېتىم', // 看广告 +{0}次
},
result: { // 答题结果
correct: 'توغرا!', // 回答正确
wrong: 'خاتا', // 回答错误
gotPoints: '+{0} ئۇپۇن', // +{0}积分
},
// ... 每个页面/组件一个分组
// 后端错误码 → 维吾尔语提示 映射表
errors: {
1001: 'سىستېما خاتالىقى', // 系统错误
1002: 'قېتىم سانى تۈگىدى', // 次数用尽
2001: 'بۈگۈن ئاللىبۇرۇن دىم ئالغان', // 今日已签到
// ...
},
// 动态文案格式化
format(tpl, ...args) {
return tpl.replace(/\{(\d+)\}/g, (m, i) => args[i] ?? '')
}
}
2. 调用方式(JS + WXML)
// 页面 index.js
const ug = require('../../utils/ug')
Page({
data: { ug }, // 注入 data, WXML 中直接 {{ug.home.title}}
onLoad() {
// JS 中调用
const text = ug.format(ug.home.todayCount, 3, 5) // "今日次数: 3/5"
this.setData({ remainingText: text })
// 错误码转维吾尔语
wx.showToast({ title: ug.errors[res.code] || ug.common.retry })
}
})
<!-- 页面 index.wxml: 通过 data 注入的 ug 取文案 -->
<view class="title">{{ug.home.title}}</view>
<button>{{ug.tab.signin}}</button>
// 自定义组件同样处理: const ug = require('../../utils/ug'); properties 或 data 注入
3. 规范
{{ug.分组.key}} 或 ug.分组.key{0}{1} + ug.format()lang/ug.js + lang/zh.js + 切换逻辑,现有 key 结构无需改动admin/
├── src/
│ ├── views/
│ │ ├── dashboard/ # 首页统计
│ │ │ └── index.vue
│ │ ├── stats/ # 数据统计报表(趋势图表)
│ │ │ ├── overview.vue # 总览
│ │ │ ├── daily.vue # 每日趋势(DAU/答题/新增)
│ │ │ ├── ads.vue # 广告数据(观看/收入)
│ │ │ └── redeems.vue # 兑换数据(申请/支出)
│ │ ├── riddle/ # 谜语管理
│ │ │ ├── list.vue
│ │ │ └── edit.vue
│ │ ├── category/ # 分类管理
│ │ │ └── list.vue
│ │ ├── user/ # 用户管理
│ │ │ └── list.vue
│ │ ├── redeem/ # 兑换管理
│ │ │ └── list.vue
│ │ ├── invite/ # 邀请管理(奖励配置+记录)
│ │ │ ├── config.vue
│ │ │ └── records.vue
│ │ ├── config/ # 系统配置
│ │ │ └── index.vue
│ │ ├── notify/ # webhook推送配置
│ │ │ └── index.vue
│ │ ├── ad/ # 广告配置
│ │ │ └── list.vue
│ │ ├── logs/ # 操作日志
│ │ │ └── list.vue
│ │ └── admin/ # 管理员
│ │ └── list.vue
│ ├── api/
│ ├── components/
│ ├── router/
│ ├── store/
│ ├── utils/
│ ├── App.vue
│ └── main.ts
├── package.json
└── vite.config.ts
谜语编辑表单根据 question_type 动态渲染:
| 题型 | 表单字段 |
|---|---|
| 1 文字单选 | 题目文字 + 选项个数(2/3/4) + 各选项文字 + 勾选正确答案 |
| 2 文字判断 | 题目文字(陈述句) + 固定「对/错」+ 勾选正确答案 |
| 3 文字图片单选 | 题目图片上传 + 题目文字 + 选项个数 + 各选项文字 + 正确答案 |
| 4 图片答案单选 | 题目图片上传 + 题目文字 + 选项个数 + 各选项图片上传 + 正确答案 |
支持 Excel 批量导入/导出(按题型模板),图片上传至腾讯云 COS。谜面/选项/答案内容为维吾尔语(utf8mb4 存储,无需特殊处理)。
广告列表支持多类型、多广告位统一增删改查:
| 功能 | 说明 |
|---|---|
| 类型筛选 | 按激励视频/插屏/Banner/原生模板/开屏/格子广告筛选 |
| 新增/编辑 | 类型下拉 + 广告位ID + 名称 + 展示页面 + 展示位置 + 每日上限 + 排序 + 备注 + 启停开关 |
| 页面维度 | 可查看"首页/兑换/签到/任务/邀请/结果页"各挂了哪些广告位 |
| 开屏广告 | 无 adUnitId、无代码接入,仅记录启停状态(实际控制权在流量主后台) |
| 上线切换 | 测试广告位 → 正式广告位只需改后台配置,无需发版 |
系统配置页面需要支持以下配置项的动态修改:
以下数值均为示例默认值,全部可在后台「系统配置」页修改,即时生效、无需发版:
| 参数 | 含义 | 示例默认值 | 配置项 key |
|---|---|---|---|
| 每日免费答题次数 | 每天免费可答题次数 | 5 | daily_free_count |
| 广告奖励答题卡 | 看 1 次广告获得答题卡 | 3 | ad_reward_count |
| 广告额外积分 | 看 1 次广告额外积分 | 0 | ad_reward_points |
| 每邀请奖励积分 | 每成功邀请 1 人获得邀请积分 | 10 | invite_points_per_person |
| 兑换所需积分 | 兑换 1 次所需邀请积分 | 30 | invite_exchange_cost |
| 兑换获得答题卡 | 兑换 1 次获得答题卡 | 5 | invite_exchange_cards |
| 每日签到答题卡 | 签到第 1-6 天每天奖励答题卡 | 1 | signin_day_cards |
| 第 7 天奖励形式 | 1答题卡 / 2积分 | 1 | signin_day7_kind |
| 第 7 天奖励数值 | 大奖答题卡数 | 10 | signin_day7_cards |
| 第 7 天奖励积分 | 大奖积分(当kind=2) | 50 | signin_day7_points |
| 最低兑换积分 | 兑换门槛 | 10 | min_redeem |
| 兑换额度 | 日/月/单次上限 | 0(不限) | redeem_* |
| 邀请码长度 | 邀请码位数 | 6 | invite_code_length |
| 新人礼包答题卡 | 注册赠送答题卡 | 5 | register_gift_cards |
| 新人礼包积分 | 注册赠送积分 | 20 | register_gift_points |
| 报表 | 内容 |
|---|---|
| 总览卡片 | 用户总数/今日新增/DAU/今日答题量/今日广告观看/待处理兑换数/积分发放与支出 |
| 每日趋势 | 按日折线图:DAU、答题量、新增用户、广告观看次数(可选时间范围) |
| 广告数据 | 各广告位观看次数/点击/预估收入(对接微信广告后台数据或本地计数) |
| 兑换数据 | 兑换申请数、兑换积分支出、待处理/已完成分布 |
| 数据来源 | 各业务表聚合统计(answer_records/users/redeem_records/daily_quota),列表页支持按日期筛选 |
admin_logs 表| 任务 | 说明 |
|---|---|
| Go项目初始化 | Gin框架 + 项目结构 |
| 数据库设计与初始化 | 表结构 + 种子数据 |
| 微信登录集成 | openid获取 + JWT签发 |
| 原生小程序项目初始化 | app.js/json/wxss + 页面骨架 + project.config.json |
| RTL 基础适配 | 自定义tabBar(从右到左) + app.wxss全局direction:rtl + 布局反转基础样式 |
| Vue管理后台初始化 | Element Plus + 路由 + 布局 |
| 任务 | 说明 |
|---|---|
| 谜语CRUD(后台) | 4种题型增删改查 + 选项编辑 + 图片上传 + 批量导入 |
| 答题逻辑(后端) | 次数校验 + 选项key比对判题 + 积分计算 + 事务 |
| 答题页面(小程序) | 首页内嵌谜语卡片: 4种题型自定义组件(wx:if按题型渲染) + 倒计时 + 结果页 |
| RTL 页面适配 | 各页布局/图标/箭头镜像 + 输入框与弹窗方向检查 |
| 文案集中管理 | 建立 ug.js 全量文案(所有页面/组件/错误码) + 页面替换为 {{ug.xxx}} 调用 |
| 图片上传 | 腾讯云COS集成(服务端签名直传) + 题目图/选项图/头像上传接口 |
| 积分系统 | 积分记录 + 明细展示 |
| 每日次数限制 | daily_quota 表计数 + 配置读取 |
| 任务 | 说明 |
|---|---|
| 广告位配置接口 | GET /api/v1/ads 拉取启用广告位 + 后台 CRUD |
| 广告管理器 | adManager(wx API) 按类型分发(激励视频/插屏/Banner/原生模板) + banner-ad组件 |
| 激励视频次数发放 | 观看回调 + 每日上限风控 + 次数增加 |
| 签到功能 | 7天一轮签到页(按累计次数定位) + 第7天大奖日突出 + 奖励后台可配置 |
| 任务中心 | 任务列表(看广告得答题卡/每日答题/签到领次数) 后台可配置启停与奖励 |
| 新人礼包 | 注册赠送答题卡+积分(开关与数值后台可配置, 与邀请奖励叠加) |
| 兑换功能 | 兑换规则页 + 客服微信展示 + 兑换申请(额度校验) + 兑换记录 |
| 任务 | 说明 |
|---|---|
| 邀请机制 | 邀请码绑定 + 邀请积分奖励模型(每邀请1人得每邀请奖励积分, 兑换所需积分换兑换获得答题卡, 后台可配置) + 邀请海报 + 兑换记录 |
| 用户管理 | 列表 + 搜索 + 禁用 |
| 广告配置管理 | 多类型多广告位 CRUD + 类型筛选 + 页面维度查看 |
| 兑换管理 | 兑换申请列表 + 标记完成/取消 + 手动扣积分 + 用户兑换额度设置/冻结 |
| 系统配置 | 动态配置项管理(含新人礼包/签到/邀请/webhook) |
| 数据统计报表 | 总览卡片 + 每日趋势 + 广告数据 + 兑换数据(4.6) |
| 管理员操作日志 | 写操作埋点 + 日志列表筛选(4.7) |
| webhook推送 | notify模块(企业微信/钉钉/飞书) + 推送配置页 + 测试发送(2.6) |
| 任务 | 说明 |
|---|---|
| 性能优化 | SQL优化 + 索引优化 + 缓存静态数据 |
| 安全加固 | 按7.1逐项实现: 令牌桶限流 + 防刷 + 广告风控 + 邀请防刷 + 数据脱敏 + 参数化查询审查 + JWT双Token |
| 测试 | 单元测试 + 集成测试 |
| 部署上线 | app单镜像(Go+Nginx+管理端) + compose编排(MySQL独立容器) + 小程序审核 |
server:
port: 8080
mode: release # debug / release
database:
host: localhost
port: 3306
user: root
password: ""
database: riddle_game
wechat:
miniapp:
appid: ""
secret: ""
# 腾讯云 COS 对象存储 (题目图/选项图/头像/客服二维码/邀请海报)
cos:
secret_id: ""
secret_key: ""
bucket: "riddle-game-1250000000" # 存储桶名称
region: "ap-guangzhou" # 地域
domain: "https://riddle-game-1250000000.cos.ap-guangzhou.myqcloud.com" # 访问域名(可绑CDN)
path_prefix: "riddle/" # 存储路径前缀(按业务分目录)
upload_mode: "server-sign" # 服务端签名直传: 后端签发临时密钥, 小程序直传COS
jwt:
secret: "your-secret-key"
expire: 7200 # 2小时
# 从数据库 sys_configs 表读取的配置
# daily_free_count: 5
# ad_reward_count: 3
# min_redeem: 10
# customer_service_wechat: 客服微信号
# customer_service_qrcode: 客服微信二维码
COS 上传方式(推荐:服务端签名直传)
小程序选择图片
↓
GET /api/v1/upload/token?type=riddle_image # 后端签发COS临时密钥(带路径前缀+过期时间)
↓
小程序用临时密钥直传 COS (wx.uploadFile 或 COS SDK)
↓
上传成功 → 返回 fileKey/URL → 随表单提交给后端保存
优点:图片不经过应用服务器,节省带宽;临时密钥 30 分钟过期,安全可控。上传文件类型/大小限制在后端签发密钥时统一校验。
1. 接口限流(令牌桶,内存实现)
golang.org/x/time/rate 标准令牌桶库config.yaml → rate_limit: { qps: 100, burst: 200 } 全局限流middleware/ratelimit.go,按 IP 维度限流,超限返回 429POST /api/v1/auth/login:单 IP 5 次/分钟(防撞库)POST /api/v1/game/answer:单用户 60 次/分钟POST /api/v1/ad/reward:单用户 10 次/分钟2. 防刷机制(请求频率)
middleware/antifraud.go)3. 广告风控(微信无服务端验证,后端自控)
isEnded 来自客户端不可全信ad_configs.daily_limit(默认 10 次/人/天),超过拒绝发放daily_quota.ad_count 计数,同一上报只发放一次(事务内 校验+发放)handler/ad.go,事务内 读取 daily_quota → 校验 → 增加答题卡 → 更新计数4. 邀请防刷
invite_records.uk_invitee 唯一索引(一个被邀请人只能被邀请一次),数据库层兜底inviter_id != invitee_idinvite_exchange_records 按用户限制每日兑换次数(后台可配,如 3 次/日)users.status=2)5. 数据脱敏
138****1234 格式6. SQL 注入防护
fmt.Sprintf 拼接 SQL 模式7. JWT 安全
POST /api/v1/auth/refresh 用 refresh_token 换新 access_token;refresh_token 服务端可吊销app 镜像内包含:Go 二进制 + 管理端(Vue)打包产物 + Nginx,Nginx 同时承担静态文件服务和 API 反向代理,进程由入口脚本统一托管;MySQL 使用独立容器,通过 docker-compose 编排。
┌────────────────────────────────────────────┐
│ docker-compose (单机部署) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ app 容器 (一个镜像) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Nginx │──▶│ Go 二进制 │ │ │
│ │ │ :80 │ │ :8080 │ │ │
│ │ │ │ └──────┬───────┘ │ │
│ │ │ / → 管理端静态文件 │ │ │
│ │ │ /api/* → Go │ │ │
│ │ │ /admin/* → Go │ │ │
│ │ └─────────────────────┘ │ │
│ └──────────────────┬─────────────────┘ │
│ │ │
│ ┌──────────────────▼─────────────────┐ │
│ │ mysql 容器 (:3306) │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────────┘
# ========== 阶段1: 构建 Go 后端 ==========
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main ./cmd
# ========== 阶段2: 构建 Vue 管理端 ==========
FROM node:20-alpine AS admin-builder
WORKDIR /build
COPY admin/package.json admin/package-lock.json ./
RUN npm ci
COPY admin/ .
RUN npm run build
# ========== 阶段3: 最终镜像 ==========
FROM nginx:1.25-alpine
# Go 二进制
COPY --from=builder /app/main /usr/local/bin/app
# 管理端静态文件
COPY --from=admin-builder /build/dist /usr/share/nginx/html
# Nginx 配置 + 入口脚本
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
server {
listen 80;
server_name _;
# 管理端静态文件 (Vue build 产物)
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
# 小程序端 API
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 管理端 API
location /admin/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
#!/bin/sh
set -e
# 启动 Go 后端 (监听 :8080)
/usr/local/bin/app &
# 启动 Nginx (前台运行, 保持容器存活)
nginx -g 'daemon off;'
version: "3.8"
services:
app:
image: riddle-game:latest # 单镜像: Go + 管理端 + Nginx
ports:
- "80:80"
environment:
- DB_HOST=mysql
- DB_PORT=3306
- DB_USER=riddle
- DB_PASSWORD=riddle_pass
- DB_NAME=riddle_game
- WECHAT_APPID=xxx
- WECHAT_SECRET=xxx
depends_on:
- mysql
restart: always
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=root_pass
- MYSQL_DATABASE=riddle_game
- MYSQL_USER=riddle
- MYSQL_PASSWORD=riddle_pass
volumes:
- mysql_data:/var/lib/mysql
- ./deploy/init.sql:/docker-entrypoint-initdb.d/init.sql # 建表+种子数据
restart: always
volumes:
mysql_data:
deploy/
├── Dockerfile # 多阶段构建
├── nginx.conf # Nginx 配置
├── entrypoint.sh # 双进程入口
├── docker-compose.yml # 单机编排
└── init.sql # 数据库初始化脚本
# 一键构建并启动
docker compose up -d --build
# 管理端访问: http://服务器IP/
# 小程序请求域名: https://服务器IP (需配置SSL证书 + 微信后台配置request合法域名)
决定:本项目采用 8.1 compose 分离 MySQL 方案(已选定):
- app 镜像(~100MB):Go 二进制 + 管理端静态文件 + Nginx,单镜像内双进程托管
- mysql 镜像(独立容器):数据卷持久化,可独立升级、独立重启
放弃"全内置单镜像"的原因:
mysql:8.0基础镜像约 565MB(磁盘占用 1GB+,且无官方 Alpine 版无法瘦身),会导致应用镜像膨胀到 600MB+;分离后 app 镜像仅约 100MB,升级 MySQL 也无需重建应用镜像。