commit 06488f0237c84150b9e648aa61388671d8ce2e2a Author: admin Date: Mon Jun 8 20:18:31 2026 +0000 Initial commit: 帮我选盲选应用 功能: - Go后端 (Gin + GORM + PostgreSQL) - UniApp用户端 (iOS/Android/小程序) - DaisyUI5后台管理 - JWT认证 + 微信登录 - 盲选加权算法 - 会员系统 + 优惠券 - 打分评价 + 偏好学习 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d8a3fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +backend/go.sum +frontend-admin/node_modules/ +frontend-app/node_modules/ + +# Build +backend/server +backend/bin/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Env +backend/config/.env +backend/.env + +# OS +.DS_Store +Thumbs.db + +# Docker volumes +pgdata/ +redisdata/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..612edc6 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,206 @@ +# 部署指南 + +## 服务器要求 + +- Ubuntu 20.04+ +- 2GB+ RAM +- Docker & Docker Compose +- PostgreSQL 13+ (或使用 Docker) +- Nginx (可选,用于反向代理) + +## 1. 数据库准备 + +```bash +# 创建数据库 +sudo -u postgres psql +CREATE DATABASE blind_select; +CREATE USER blind_user WITH PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE blind_select TO blind_user; + +# 运行迁移 +psql -U blind_user -d blind_select < migrations/001_init.sql +``` + +## 2. 后端部署 + +### 方式一:直接运行 + +```bash +cd backend + +# 构建 +go build -o blind-server cmd/server/main.go + +# 配置 +cp config.yaml.example config.yaml +vim config.yaml + +# 使用 systemd 运行 +sudo cat > /etc/systemd/system/blind-server.service << EOF +[Unit] +Description=Blind Select Server +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/blind-select/backend +ExecStart=/opt/blind-select/backend/blind-server +Restart=always + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable blind-server +sudo systemctl start blind-server +``` + +### 方式二:Docker + +```bash +cd backend +docker-compose up -d +``` + +## 3. UniApp 小程序部署 + +### 微信小程序 + +```bash +cd frontend-app + +# 修改 manifest.json 中的 appid +# 修改 api/index.js 中的 BASE_URL 为你的 HTTPS 域名 + +# 构建 +npm run build:mp-weixin + +# 使用微信开发者工具上传 +# 或使用脚本部署 +./wechat-deploy.sh +``` + +### 小程序注意事项 + +1. 域名必须是 HTTPS +2. 需要在微信公众平台配置服务器域名 +3. 需要配置业务域名 + +## 4. 后台管理部署 + +```bash +cd frontend-admin + +# 修改 API 地址 +vim src/api/index.js + +# 构建 +npm run build + +# Nginx 配置 +server { + listen 80; + server_name admin.yourdomain.com; + + root /opt/blind-select/frontend-admin/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +## 5. Nginx 配置 (可选) + +```nginx +# 后端 API +server { + listen 443 ssl; + server_name api.yourdomain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + 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; + } +} + +# 后台管理 +server { + listen 443 ssl; + server_name admin.yourdomain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + root /opt/blind-select/frontend-admin/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://127.0.0.1:8080; + } +} +``` + +## 6. 微信支付配置 + +1. 在微信支付商户平台申请支付功能 +2. 配置支付回调域名 +3. 在后端配置支付密钥 + +```yaml +# config.yaml 添加 +wechat_pay: + mchid: your_mchid + serial_no: your_serial_no + private_key: /path/to/private_key.pem + api_v3_key: your_api_v3_key +``` + +## 7. 监控与日志 + +```bash +# 查看后端日志 +journalctl -u blind-server -f + +# Docker 日志 +docker logs blind-server -f +``` + +## 8. 备份 + +```bash +# 数据库备份 +pg_dump -U blind_user blind_select > backup_$(date +%Y%m%d).sql + +# 定时备份 (crontab) +0 2 * * * pg_dump -U blind_user blind_select > /backup/blind_$(date +\%Y\%m\%d).sql +``` + +## 常见问题 + +### Q: 小程序请求失败? +A: 检查服务器域名是否配置 HTTPS,是否在微信公众平台白名单 + +### Q: 支付失败? +A: 检查微信支付证书配置,确保证书路径正确 + +### Q: 盲选算法不准确? +A: 需要积累用户行为数据,初期可手动调整权重参数 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b7e4766 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: build run test clean docker-up docker-down db-migrate seed help + +# ── Variables ── +APP := blind-select +BACKEND := ./backend +PG := localhost:5432 +PG_USER := blind_select +PG_DB := blind_select + +help: + @echo "=== Blind Select - Make Commands ===" + @echo "" + @echo " build - Build backend binary" + @echo " run - Start backend (go run)" + @echo " test - Run Go tests" + @echo " clean - Remove binary" + @echo " docker-up - Start all services via docker-compose" + @echo " docker-down - Stop all services" + @echo " docker-logs - Show docker-compose logs" + @echo " db-migrate - Run SQL migrations" + @echo " seed - Load seed data" + @echo " admin-dev - Start admin frontend (npm run dev)" + @echo " check - go vet + go fmt check" + +build: + cd $(BACKEND) && go build -o bin/server ./cmd/server/ + +run: + cd $(BACKEND) && go run ./cmd/server/ + +test: + cd $(BACKEND) && go test ./... -v + +clean: + rm -rf $(BACKEND)/bin + +docker-up: + cd $(BACKEND) && docker-compose up -d + +docker-down: + cd $(BACKEND) && docker-compose down + +docker-logs: + cd $(BACKEND) && docker-compose logs -f + +db-migrate: + @echo "Running migrations on postgres..." + psql "postgresql://$(PG_USER):blind_select_pass@$(PG)/$(PG_DB)?sslmode=disable" -f $(BACKEND)/migrations/001_initial_schema.sql + +seed: + @echo "Loading seed data..." + psql "postgresql://$(PG_USER):blind_select_pass@$(PG)/$(PG_DB)?sslmode=disable" -f $(BACKEND)/migrations/002_seed_data.sql + +admin-dev: + cd frontend-admin && npm run dev + +check: + cd $(BACKEND) && go vet ./... && go fmt ./... diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d79b9f1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,927 @@ +# "帮我选"盲选应用 - 开发大纲 v3 + +## 一、项目概述 +AI驱动开发的盲选应用,用户盲选吃喝玩乐套餐,支持会员盈利, +通过用户历史行为智能更新偏好权重,**去了打分**驱动推荐质量。 + +--- + +## 二、技术栈 + +### AI 开发配置 +```yaml +api: + key: "sk-26391ddf421f3d09546969d968e73295a3ec493ae327a6792a840c6f5fcdd517" + endpoint: "https://qs.szscp.com" + # 用途:AI推荐、智能分类、用户偏好分析、月度报告 +``` + +### 后端:Go + Gin + GORM + PostgreSQL + Redis +### App前端:UniApp + Vue3(一套代码→iOS/Android/小程序) +### 后台管理:DaisyUI5 + Tailwind + Alpine.js + +--- + +## 三、会员盈利体系(简化:仅VIP) + +### 会员方案 +``` +免费用户: +├── 每天3次盲选机会 +├── 基础分类可用 +└── 普通优先级 + +VIP会员(¥29/月 或 ¥199/年): +├── 每天10次盲选 +├── 全部分类解锁 +├── 优先匹配优质套餐 +├── 专属盲选风格 +├── 去重天数更长(3天→1天) +└── 月度盲选报告 +``` + +### 盈利模型 +``` +1. 会员订阅(主要收入) +2. 商家佣金(5-15%) +3. 优惠券佣金(联动商家优惠券,每次核销商家付佣金) +4. 品牌盲盒溢价 +``` + +### 商家优惠券联动 +``` +商家在后台创建优惠券: +├── 券类型:满减券 / 折扣券 / 免费兑换券 / 赠品券 +├── 面额:后台设置(如满100减20) +├── 数量:发放总量 + 剩余量 +├── 有效期:起止时间 +├── 适用套餐:指定某些套餐可用 +└── 佣金比例:用户通过盲选核销该券,平台抽佣 10-20% + +用户侧表现: +├── 盲选结果卡上显示"附带优惠券" +├── 优惠券卡片样式(像微信卡包) +├── 核销后记录,商家确认使用 +└── 优惠券是商家的"引流工具",平台收佣金 +``` + +**优惠券联动逻辑:** +``` +商家A想引流 → 后台创建100张"满80减15"券 → 设核销后佣金8元 +→ 用户盲选抽到商家A的套餐 → 自动获得该券 +→ 用户到店出示核销 → 商家确认 → 平台收8元佣金 +→ 用户得到优惠,商家获得客流,平台赚佣金 → 三方共赢 +``` + +--- + +## 四、用户偏好学习系统(含打分机制) + +### 4.1 打分机制(核心) + +用户每次盲选揭晓后,可选"去了"或"跳过了": + +``` +用户选择"去了"后 → 进入打分页: + +┌─────────────────────────────┐ +│ 你对这次盲选体验打几分? │ +│ │ +│ ⭐⭐⭐⭐⭐ (1-5星) │ +│ │ +│ 评分维度(可选填): │ +│ 🍽️ 口味: ████░ (1-5) │ +│ 💰 性价比: ███░░ (1-5) │ +│ 📍 距离: ████░ (1-5) │ +│ 🎯 符合预期: ████░ (1-5) │ +│ │ +│ 备注(可选): │ +│ ┌─────────────────────────┐ │ +│ │ 写点什么... │ │ +│ └─────────────────────────┘ │ +│ │ +│ [提交] │ +└─────────────────────────────┘ +``` + +**打分影响权重:** +``` +五星好评(5分): + → 该分类偏好 +2.0 ★★ + → 该商家偏好 +3.0 ★★★ + → 该标签(如"日料")偏好 +1.5 + +四星好评(4分): + → 该分类偏好 +1.0 + → 该商家偏好 +1.5 + → 该标签偏好 +0.8 + +三星中立(3分): + → 该分类偏好 +0.3(轻微正向) + → 无商家偏好加成 + +二星(2分): + → 该分类偏好 -0.3 + → 该商家偏好 -0.5 + +一星差评(1分): + → 该分类偏好 -1.0 + → 该商家偏好 -2.0 ★★ + → 该商家加入"黑名单"候选(需用户确认) +``` + +### 4.2 行为追踪 + +```go +type UserBehavior struct { + ID uint + UserID uint + PackageID uint + BehaviorType string // "viewed" "selected" "attended" "reviewed" "skipped" + Score float64 // 行为权重(非打分,是系统权重) + ReviewRating int8 // 用户打分 1-5 + ReviewTags string // "口味好""太贵""环境差"等标签 + ReviewText string // 用户文字评价 + DistanceScore int8 // 距离评分 + ValueScore int8 // 性价比评分 + ExpectMatch int8 // 符合预期评分 + IsRepeat bool // 是否再次前往 + CreatedAt time.Time +} +``` + +### 4.3 权重算法 + +``` +用户偏好分数 = Σ(行为得分 × 时间衰减系数 × 打分系数) + +打分系数: + 5星 → ×1.5(放大正向影响) + 4星 → ×1.0(标准正向影响) + 3星 → ×0.5(轻微影响) + 2星 → ×-0.5(轻微负向) + 1星 → ×-1.5(放大负向影响) + +时间衰减: + 最近30天 × 1.0 + 30-90天 × 0.7 + 90-180天 × 0.4 + 180天+ × 0.2 + +示例计算: + 用户A在45天前去了日料店,打了5星 → 权重 = +2.0 × 0.7 × 1.5 = +2.1 + 用户A在200天前去了日料店,打了2星 → 权重 = -0.5 × 0.2 × -0.5 = +0.05(几乎忽略) +``` + +### 4.4 偏好标签自动打标 + +``` +基于用户打分数据自动打标: + +用户A(日料5星×5次,西餐3星×3次,火锅5星×2次) +→ 标签: ["日料狂魔", "火锅爱好者", "中餐不挑"] +→ 盲选策略: 80%日料/火锅,20%其他探索 + +用户B(所有都3-4星,没有明显偏好) +→ 标签: ["探索型", "随机接受度高"] +→ 盲选策略: 增加品类多样性,避免重复 + +用户C(多次1-2星差评) +→ 标签: ["易踩雷", "高要求", "需要精准匹配"] +→ 盲选策略: 提高推荐门槛,降低盲选随机性,接近定向选择 +``` + +### 4.5 商家维度打分积累 + +``` +每个套餐也积累评分: + +套餐评分 = Σ(所有用户打分) / 打分人数 + +影响: + - 套餐评分低的 → 降低盲选权重(少抽到) + - 套餐评分高的 → 提高盲选权重(多抽到) + - 但保留一定随机性(给用户探索惊喜) +``` + +--- + +## 五、功能模块 + +### 5.1 用户端(UniApp) + +#### 首页 +``` +├── 顶部:欢迎语(AI生成,基于今日偏好) +│ 例:"今天想带你吃点治愈系的~" +│ 例:"好久没吃辣了,来场火锅盲选?" +│ 例:"根据你的口味,今天推荐隐藏日料店!" +├── 盲选卡片池(可滑动切换类别) +│ ├── 🍜 餐饮盲选 +│ ├── 🎮 娱乐盲选 +│ ├── 🛍️ 购物盲选 +│ └── 🎭 活动盲选 +├── 今日推荐(AI分析后推荐类别,VIP专享) +└── 盲选按钮(大,带动画)→ 抽! +``` + +#### 盲选揭晓页 +``` +├── 动画效果(翻牌/转盘) +├── 揭晓内容: +│ ├── 商家名称 + 评分 + 距离 +│ ├── 套餐描述(模糊描述,价格区间可见) +│ ├── 推荐理由(AI生成,基于匹配度) +│ │ 例:"你的日料偏好度85%,这家匹配度很高!" +│ ├── 优惠券 🎁(如有) +│ │ 例:"满80减15 · 限时3天" +│ └── 匹配度进度条 +├── 操作: +│ ├── ✅ 接受/前往(记录行为 +0.5) +│ ├── 🔄 换一个(-0.2分,不消耗机会) +│ ├── ⭐ 标记"已去过"(进入打分流程) +│ ├── 🔖 收藏 +│ └── 📍 导航 +└── 已去过?→ 跳转打分页 +``` + +#### 打分页 +``` +┌─────────────────────────┐ +│ 🎉 感谢你的盲选体验! │ +│ │ +│ 你对这次体验打几分? │ +│ ⭐⭐⭐⭐⭐ (当前: 4) │ +│ │ +│ 评分维度: │ +│ 🍽️ 口味: ⭐⭐⭐⭐⭐ │ +│ 💰 性价比: ⭐⭐⭐⭐░ │ +│ 📍 距离: ⭐⭐⭐░░ │ +│ 🎯 符合预期:⭐⭐⭐⭐⭐ │ +│ │ +│ 你想怎么形容这次体验? │ +│ [口味赞] [环境好] [性价比] [值得再来] [服务棒] [踩雷] [太贵] [难吃] [远] +│ │ +│ 备注(可选): │ +│ ┌─────────────────────┐ │ +│ │ 写点什么... │ │ +│ └─────────────────────┘ │ +│ │ +│ [提交] │ +└─────────────────────────┘ + +提交后: +├── 更新用户偏好权重 +├── 更新商家/套餐评分 +├── 更新用户标签 +└── 显示:"已记录!这会影响下次推荐哦~" +``` + +#### 我的偏好 +``` +├── 偏好雷达图(口味/价格/距离/品质/探索度) +├── 品类偏好排名(柱状图) +│ 日料 ████████░░ 80% +│ 火锅 ██████░░░░ 60% +│ 西餐 ████░░░░░░ 40% +│ 甜品 ███░░░░░░░ 30% +├── AI标签列表 +├── 去重设置 +├── 黑名单商家 +└── 偏好调整滑块 +``` + +#### 优惠券中心 +``` +├── 我的优惠券 +│ ├── 未使用(到期倒计时) +│ ├── 已使用 +│ └── 已过期 +├── 卡包样式(像微信卡包) +└── 使用说明 +``` + +#### 会员中心 +``` +├── 当前状态 +├── 会员权益对比表 +├── 开通/续费按钮 +├── 购买记录 +└── 邀请好友(双方各得7天VIP) +``` + +#### 个人 +``` +├── 个人信息 +├── 盲选记录(按时间线) +├── 收藏 +├── 评价历史 +├── 地址管理 +└── 设置 +``` + +### 5.2 后台管理(DaisyUI5) + +#### 仪表盘 +``` +├── 今日:新增用户 / 活跃 / 盲选次数 / 订单数 +├── 会员:总数 / 转化率 / 月收入 / 续费率 +├── 热门:分类TOP / 套餐TOP / 商家TOP +├── 评分:平均打分 / 差评预警 +├── 优惠券:发放量 / 核销率 / 佣金收入 +└── 趋势图(近30天) +``` + +#### 商家管理 +``` +├── 商家列表 + 搜索 +├── 入驻审核 +├── 编辑/上下架 +├── 佣金设置 +└── 商家数据(被选次数 / 平均评分 / 优惠券核销) +``` + +#### 套餐管理 +``` +├── 套餐CRUD +├── 价格区间设置 +├── 标签管理 +├── 库存管理 +├── 评分查看 +└── 权重调整 +``` + +#### 优惠券管理 +``` +├── 创建优惠券 +│ ├── 选择商家 +│ ├── 券类型(满减/折扣/兑换/赠品) +│ ├── 面额设置 +│ ├── 数量 +│ ├── 有效期 +│ ├── 适用套餐 +│ └── 佣金比例(平台抽佣) +├── 优惠券列表(使用状态/核销情况) +├── 佣金收入统计 +└── 核销审核(防作弊) +``` + +#### 用户管理 +``` +├── 用户列表 + 搜索 +├── 用户详情(行为 + 偏好 + 打分 + 会员) +├── 会员管理 +├── 黑名单管理 +└── 行为分析 +``` + +#### 打分分析 +``` +├── 平均打分趋势 +├── 各分类平均评分 +├── 差评预警(连续低分套餐) +├── 商家质量排名 +└── 用户反馈词云 +``` + +#### 算法配置 +``` +├── 行为权重调整 +├── 时间衰减曲线 +├── 打分系数 +├── 去重天数默认值 +├── 会员加成比例 +├── 热门权重 +└── A/B测试 +``` + +#### 内容管理 +``` +├── Banner +├── 公告 +├── 欢迎语模板 +├── 推荐语模板 +└── 标签管理 +``` + +#### 数据报表 +``` +├── 用户增长曲线 +├── 收入报表(会员+佣金+优惠券) +├── 转化率漏斗(注册→首盲选→付费会员) +├── 留存分析(7日/30日) +├── 商家质量报告 +└── 导出CSV/Excel +``` + +--- + +## 六、数据库设计 + +```sql +-- 用户表 +users ( + id, nickname, avatar, phone, wechat_openid, + preference JSONB, -- {"日料": 0.85, "火锅": 0.6, ...} + tags TEXT[], -- ["日料狂魔", "品质型"] + member_level INT DEFAULT 0, -- 0免费 1VIP + member_expires_at, + created_at, updated_at +) + +-- 商家表 +merchants ( + id, name, avatar, category_id, + rating, -- 平均评分 + price_range, -- "50-100" + location GEOGRAPHY(Point), -- 经纬度 + tags TEXT[], + total_reviews INT DEFAULT 0, + total_score NUMERIC DEFAULT 0, + quality_score NUMERIC, -- 综合质量分(用于盲选权重) + status, + created_at, updated_at +) + +-- 分类表 +categories ( + id, name, icon, type, -- food/entertainment/shopping/activity + sort_order, + status, + created_at +) + +-- 套餐表 +packages ( + id, merchant_id, category_id, + name, -- 套餐名 + description, -- 模糊描述(盲选可见) + price_min, price_max, -- 价格区间 + actual_price, -- 实际价格(盲选揭晓后显示) + tags TEXT[], -- "适合约会""亲子" + stock, + weight, -- 推荐权重 + rating, -- 平均评分 + review_count, -- 评分人数 + status, + created_at, updated_at +) + +-- 用户行为表(含打分) +user_behaviors ( + id, user_id, package_id, + behavior_type, -- viewed/selected/attended/reviewed/skipped + system_score, -- 系统行为权重 + review_rating, -- 用户打分 1-5 + taste_score, -- 口味评分 1-5 + value_score, -- 性价比评分 1-5 + distance_score, -- 距离评分 1-5 + match_score, -- 符合预期 1-5 + tags TEXT[], -- 反馈标签 ["口味赞","太贵"] + text TEXT, -- 文字评价 + is_repeat, -- 是否再次前往 + created_at +) + +-- 优惠券表 +coupons ( + id, merchant_id, package_id, + type, -- discount/coupon/free/gift + value, -- 面额或折扣 + min_amount, -- 满减门槛 + total_count, -- 总量 + remain_count, -- 剩余 + user_id, -- 领取用户(NULL=池发) + user_code, -- 用户专属码 + pool_code, -- 池码(用户领取时复制) + status, -- available/claimed/used/expired + used_at, + valid_start, valid_end, + commission, -- 平台佣金 + created_at, updated_at +) + +-- 盲选会话表 +blind_sessions ( + id, user_id, category_id, + price_range, distance_range, + result_package_id, + result_revealed_at, + user_accepted, + created_at +) + +-- 订单表 +orders ( + id, user_id, package_id, blind_session_id, + actual_price, + coupon_id, -- 使用的优惠券 + status, -- pending/paid/completed/cancelled + created_at, updated_at +) + +-- 会员表 +members ( + id, user_id, level, + start_date, end_date, + payment_method, + amount, + status, + created_at +) + +-- 活动表 +activities ( + id, name, type, start_date, end_date, + config JSONB, + status +) + +-- 管理员表 +admins ( + id, username, password_hash, + role, permissions, + created_at +) +``` + +--- + +## 七、API设计 + +### 7.1 认证 +``` +POST /api/v1/auth/register # 注册 +POST /api/v1/auth/login # 手机登录 +POST /api/v1/auth/wechat/login # 微信登录 +POST /api/v1/auth/refresh # 刷新token +POST /api/v1/admin/login # 后台登录 +``` + +### 7.2 用户 +``` +GET /api/v1/user/profile # 个人信息 +PUT /api/v1/user/profile # 更新信息 +GET /api/v1/user/preferences # 偏好数据 +PUT /api/v1/user/preferences # 调整偏好 +GET /api/v1/user/tags # AI标签 +GET /api/v1/user/behaviors # 行为记录 +``` + +### 7.3 盲选(核心) +``` +GET /api/v1/blind/categories # 分类列表 +GET /api/v1/blind/pool # 可选池(模糊信息) +POST /api/v1/blind/choose # 盲选 +GET /api/v1/blind/result/:id # 揭晓结果 +GET /api/v1/blind/recommend # AI推荐 +POST /api/v1/blind/accept # 接受 +POST /api/v1/blind/decline # 换一个 +GET /api/v1/blind/history # 历史记录 +``` + +### 7.4 打分(核心) +``` +POST /api/v1/review/submit # 提交打分评价 +GET /api/v1/review/stats # 打分统计 +GET /api/v1/review/summary # 商家/套餐评分汇总 +``` + +### 7.5 优惠券 +``` +GET /api/v1/coupon/list # 我的优惠券 +GET /api/v1/coupon/available # 可领取优惠券 +POST /api/v1/coupon/claim # 领取优惠券 +POST /api/v1/coupon/verify # 核销优惠券 +GET /api/v1/coupon/commission # 佣金记录 +``` + +### 7.6 会员 +``` +GET /api/v1/member/status # 会员状态 +GET /api/v1/member/plans # 会员套餐 +POST /api/v1/member/subscribe # 开通 +POST /api/v1/member/cancel # 取消 +GET /api/v1/member/report # 月度报告(VIP) +``` + +### 7.7 后台管理 +``` +POST /api/v1/admin/dashboard # 仪表盘 +CRUD /api/v1/admin/merchants # 商家管理 +CRUD /api/v1/admin/packages # 套餐管理 +GET /api/v1/admin/coupons # 优惠券管理 +POST /api/v1/admin/coupons/create # 创建优惠券 +GET /api/v1/admin/users # 用户列表 +GET /api/v1/admin/users/:id # 用户详情 +GET /api/v1/admin/reviews # 打分管理 +GET /api/v1/admin/statistics # 数据报表 +PUT /api/v1/admin/algorithm/config # 算法配置 +GET /api/v1/admin/export # 数据导出 +``` + +### 7.8 AI +``` +POST /api/v1/ai/recommend # AI推荐 +POST /api/v1/ai/analyze-preference # 分析偏好 +POST /api/v1/ai/generate-tag # 生成标签 +POST /api/v1/ai/generate-report # 生成月报 +POST /api/v1/ai/generate-welcome # 生成欢迎语 +``` + +--- + +## 八、项目结构 + +``` +blind-select/ +├── backend/ # Go后端 +│ ├── cmd/server/main.go +│ ├── internal/ +│ │ ├── config/ +│ │ ├── middleware/ # JWT/日志/限流/会员校验 +│ │ ├── handler/ +│ │ │ ├── v1/ +│ │ │ │ ├── auth.go +│ │ │ │ ├── blind.go +│ │ │ │ ├── user.go +│ │ │ │ ├── review.go # 打分 +│ │ │ │ ├── coupon.go +│ │ │ │ ├── member.go +│ │ │ │ ├── ai.go +│ │ │ │ └── admin/ +│ │ │ │ ├── dashboard.go +│ │ │ │ ├── merchant.go +│ │ │ │ ├── package.go +│ │ │ │ ├── coupon.go +│ │ │ │ ├── user.go +│ │ │ │ ├── review.go +│ │ │ │ ├── stats.go +│ │ │ │ ├── export.go +│ │ │ │ └── config.go +│ │ ├── service/ +│ │ │ ├── blind_service.go # 盲选逻辑 +│ │ │ ├── preference_service.go # 偏好学习 +│ │ │ ├── review_service.go # 打分服务 +│ │ │ ├── coupon_service.go # 优惠券 +│ │ │ ├── member_service.go # 会员 +│ │ │ ├── ai_service.go # AI调用 +│ │ │ └── report_service.go # 报表 +│ │ ├── model/ # GORM模型 +│ │ ├── repository/ # 数据访问 +│ │ └── utils/ +│ ├── migrations/ +│ ├── config.yaml +│ ├── go.mod +│ └── Makefile +│ +├── frontend-admin/ # 后台 (DaisyUI5) +│ ├── index.html +│ ├── src/ +│ │ ├── main.js +│ │ ├── App.vue +│ │ ├── views/ +│ │ ├── components/ +│ │ ├── api/ +│ │ └── router/ +│ ├── vite.config.js +│ └── package.json +│ +├── frontend-app/ # UniApp多端 +│ ├── pages/ +│ │ ├── index/index.vue # 首页 +│ │ ├── blind/blind.vue # 盲选页 +│ │ ├── blind/result.vue # 揭晓 +│ │ ├── blind/review.vue # 打分页 +│ │ ├── coupon/list.vue # 优惠券 +│ │ ├── member/member.vue # 会员中心 +│ │ ├── user/profile.vue +│ │ ├── user/preferences.vue +│ │ └── user/history.vue +│ ├── components/ +│ ├── api/ +│ ├── store/ +│ ├── pages.json +│ └── manifest.json +│ +└── docs/ + └── API文档.md +``` + +--- + +## 九、开发阶段 + +### Phase 1: 后端基础(Week 1) +- [x] 项目初始化 +- [ ] Go项目骨架 + 配置 +- [ ] 数据库初始化 + 模型 +- [ ] JWT认证 + 用户登录 +- [ ] 后台管理员登录 + +### Phase 2: 核心业务(Week 2-3) +- [ ] 商家CRUD + 套餐管理 +- [ ] 盲选加权随机算法 +- [ ] 用户行为追踪 +- [ ] 打分系统 + 偏好学习算法 +- [ ] AI集成(推荐/标签/报告) + +### Phase 3: 会员+优惠券(Week 4) +- [ ] VIP会员系统 +- [ ] 优惠券创建+领取+核销 +- [ ] 佣金计算 +- [ ] 中间件:会员校验 + +### Phase 4: UniApp前端(Week 5-6) +- [ ] 项目初始化 + 页面框架 +- [ ] 首页 + 盲选入口 +- [ ] 揭晓动画 + 打分页 +- [ ] 个人中心 + 偏好展示 +- [ ] 优惠券中心 +- [ ] 会员中心 +- [ ] 微信小程序适配 + +### Phase 5: 后台管理(Week 7) +- [ ] DaisyUI5 + Tailwind 搭建 +- [ ] 仪表盘 + 数据可视化 +- [ ] 商家/套餐管理 +- [ ] 优惠券管理 +- [ ] 打分分析 +- [ ] 数据报表 + 导出 + +### Phase 6: 测试上线(Week 8-9) +- [ ] 端到端测试 +- [ ] 性能优化(Redis缓存) +- [ ] App打包 +- [ ] 部署上线 + +--- + +## 十、关键算法详解 + +### 10.1 盲选加权随机 + +```go +func (s *BlindService) Select(user *User, category string, priceRange PriceRange) (*Package, error) { + candidates := s.getCandidates(category, priceRange) + + for i := range candidates { + w := 1.0 // 基础权重 + + // 用户偏好加成 + pref := s.getPreferenceScore(user.ID, candidates[i].Category) + w *= (1 + pref) + + // 套餐质量分(基于打分积累) + quality := candidates[i].Rating / 5.0 + w *= (0.5 + quality * 0.5) // 0.5-1.0范围 + + // 去重惩罚 + if s.isRepeated(user.ID, candidates[i].ID, user.RepeatDays) { + w *= 0.1 + } + + // 会员加成 + if user.MemberLevel == 1 { + w *= 1.2 + } + + candidates[i].Weight = w + } + + return s.weightedRandom(candidates) +} +``` + +### 10.2 偏好学习 + 打分影响 + +```go +func (s *PrefService) SubmitReview(userID, packageID uint, review Review) { + // 1. 保存打分 + behavior := Behavior{ + UserID: userID, + PackageID: packageID, + Type: "reviewed", + ReviewRating: review.Rating, + TasteScore: review.Taste, + ValueScore: review.Value, + DistanceScore: review.Distance, + MatchScore: review.Match, + Tags: review.Tags, + Text: review.Text, + IsRepeat: review.IsRepeat, + CreatedAt: time.Now(), + } + db.Create(&behavior) + + // 2. 计算打分系数 + scoreMultiplier := reviewScoreMultiplier(review.Rating) + // 5星→1.5, 4星→1.0, 3星→0.5, 2星→-0.5, 1星→-1.5 + + // 3. 更新用户偏好 + pkg := getPackage(packageID) + category := pkg.Category + + // 基础行为权重 + baseScore := 0.0 + switch { + case review.Rating >= 4: + baseScore = 1.0 // 好评,正向影响 + case review.Rating == 3: + baseScore = 0.3 // 中立 + case review.Rating == 2: + baseScore = -0.3 // 差评 + default: + baseScore = -1.0 // 严重差评 + } + + // 加入商家ID的特殊影响 + if review.IsRepeat { + baseScore += 1.0 // 再次前往,大幅正向 + } + + // 应用打分系数和时间衰减 + finalScore := baseScore * scoreMultiplier * getTimeDecay(behavior.CreatedAt) + + // 更新偏好 + updateCategoryPreference(userID, category, finalScore) + + // 4. 更新商家评分 + updateMerchantRating(pkg.MerchantID, review.Rating) + + // 5. 更新套餐评分 + updatePackageRating(packageID, review.Rating) + + // 6. 重新生成标签 + tags := generateTags(userID) + updateTags(userID, tags) +} + +func reviewScoreMultiplier(rating int8) float64 { + switch rating { + case 5: return 1.5 + case 4: return 1.0 + case 3: return 0.5 + case 2: return -0.5 + case 1: return -1.5 + default: return 0.0 + } +} +``` + +### 10.3 优惠券联动 + +```go +func (s *CouponService) OnBlindSelectComplete(session *BlindSession) { + pkg := session.Package + + // 1. 查找该套餐可用的优惠券 + coupons := getCouponsForPackage(pkg.ID) + + // 2. 随机发放一张(如果有) + if len(coupons) > 0 { + coupon := coupons[rand.Intn(len(coupons))] + if coupon.RemainCount > 0 { + // 为用户领取 + userCoupon := Coupon{ + MerchantID: coupon.MerchantID, + PackageID: pkg.ID, + Type: coupon.Type, + Value: coupon.Value, + UserID: session.UserID, + Status: "available", + ValidStart: coupon.ValidStart, + ValidEnd: coupon.ValidEnd, + Commission: coupon.Commission, + CreatedAt: time.Now(), + } + db.Create(&userCoupon) + + coupon.RemainCount-- + coupon.Save() + } + } +} + +func (s *CouponService) OnCouponVerified(couponID uint) { + coupon := getCoupon(couponID) + + // 1. 标记已使用 + coupon.Status = "used" + coupon.UsedAt = time.Now() + coupon.Save() + + // 2. 记录平台佣金 + commission := Commission{ + CouponID: coupon.ID, + MerchantID: coupon.MerchantID, + Amount: coupon.Commission, + Status: "pending", + CreatedAt: time.Now(), + } + db.Create(&commission) + + // 3. 通知商家 + notifyMerchant(coupon.MerchantID, "用户核销了优惠券,佣金已记录") +} +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..53f8fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# 🎲 帮我选 - 盲选应用 + +> AI驱动的盲选应用,让用户盲选吃喝玩乐套餐,支持会员盈利,通过用户历史行为智能更新偏好权重。 + +## 📱 项目结构 + +``` +blind-select/ +├── backend/ # Go 后端服务 +│ ├── cmd/server/ # 入口文件 +│ ├── internal/ # 核心代码 +│ │ ├── handler/ # HTTP 处理器 +│ │ ├── middleware/ # 中间件 (JWT, CORS) +│ │ ├── model/ # 数据模型 +│ │ ├── service/ # 业务逻辑 +│ │ └── utils/ # 工具函数 +│ ├── migrations/ # 数据库迁移 +│ ├── config.yaml # 配置文件 +│ └── Dockerfile +├── frontend-app/ # UniApp 用户端 (iOS/Android/小程序) +│ ├── pages/ # 页面 +│ ├── api/ # API 封装 +│ ├── store/ # 状态管理 +│ └── App.vue +├── frontend-admin/ # DaisyUI5 后台管理 +│ ├── src/ +│ │ ├── views/ # 页面 +│ │ ├── api/ # API 封装 +│ │ └── router/ # 路由 +│ └── index.html +└── PLAN.md # 详细开发文档 +``` + +## 🛠 技术栈 + +| 组件 | 技术 | +|------|------| +| 后端 | Go + Gin + GORM + PostgreSQL | +| 用户端 | UniApp + Vue3 | +| 后台管理 | DaisyUI5 + Tailwind + Alpine.js | +| 认证 | JWT + 微信登录 | +| AI | OpenAI 兼容 API | + +## 🚀 快速开始 + +### 1. 后端启动 + +```bash +cd backend + +# 配置数据库 +cp config.yaml.example config.yaml +# 编辑 config.yaml 填入数据库信息 + +# 安装依赖 +go mod download + +# 运行迁移 +psql -U postgres -d blind_select < migrations/001_init.sql + +# 启动服务 +go run cmd/server/main.go +``` + +### 2. UniApp 前端 + +```bash +cd frontend-app + +# 安装依赖 +npm install + +# H5 开发 +npm run dev:h5 + +# 微信小程序 +npm run dev:mp-weixin + +# 构建 +npm run build:mp-weixin +``` + +### 3. 后台管理 + +```bash +cd frontend-admin + +# 安装依赖 +npm install + +# 开发 +npm run dev + +# 构建 +npm run build +``` + +## 🐳 Docker 部署 + +```bash +# 后端 +cd backend +docker-compose up -d + +# 后台管理 +cd frontend-admin +docker build -t blind-admin . +docker run -p 3000:80 blind-admin +``` + +## 📊 核心功能 + +### 盲选算法 +``` +权重 = 基础(1.0) × 用户偏好(1.0-2.5) × 套餐质量(0.5-1.0) + × 商家质量 × 去重惩罚(0.1-1.0) × 探索加成(1.0-1.5) +``` + +### 会员体系 +- **免费用户**: 每天3次盲选,基础分类 +- **VIP会员** (¥29/月 或 ¥199/年): 每天10次,全部分类,优先匹配,月报 + +### 打分权重更新 +- 5星 → ×1.5 放大正向 +- 1星 → ×-1.5 放大负向 +- 再次前往 → +2.0 超高权重 + +## 🔌 API 端点 + +### 认证 +- `POST /api/v1/auth/register` - 注册 +- `POST /api/v1/auth/login` - 登录 +- `POST /api/v1/auth/wechat/login` - 微信登录 + +### 盲选 +- `GET /api/v1/blind/categories` - 获取分类 +- `POST /api/v1/blind/choose` - 盲选 +- `GET /api/v1/blind/history` - 历史 + +### 会员 +- `GET /api/v1/member/status` - 状态查询 +- `POST /api/v1/member/subscribe` - 订阅 + +### 优惠券 +- `GET /api/v1/coupon/list` - 我的优惠券 +- `POST /api/v1/coupon/claim` - 领取 + +### 后台管理 +- `POST /api/v1/admin/login` - 管理员登录 +- `GET /api/v1/admin/users` - 用户列表 +- `GET /api/v1/admin/merchants` - 商家列表 + +## 📝 配置说明 + +### backend/config.yaml +```yaml +server: + port: 8080 + mode: debug + +database: + host: localhost + port: 5432 + user: postgres + password: your_password + dbname: blind_select + +jwt: + secret: your_jwt_secret + expire: 24h + +wechat: + appid: your_appid + secret: your_secret +``` + +## 📄 License + +MIT License diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fda3042 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,32 @@ +# ===== Build Stage ===== +FROM golang:1.22-alpine AS builder + +RUN apk --no-cache add git ca-certificates + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/server ./cmd/server/ + +# ===== Runtime Stage ===== +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/config.yaml . + +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +RUN chown -R appuser:appgroup /app +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8080/api/v1/admin/dashboard || exit 1 + +ENTRYPOINT ["./server"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..a48e8e0 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "log" + + "github.com/blind-select/backend/internal/config" + "github.com/blind-select/backend/internal/database" + "github.com/blind-select/backend/internal/handler/v1" + "github.com/blind-select/backend/internal/middleware" + "github.com/gin-gonic/gin" +) + +func main() { + cfg, err := config.Load("../config.yaml") + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + database.Init(cfg) + + gin.SetMode(cfg.Server.Mode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(middleware.CORSMiddleware()) + + authHandler := handler.NewAuthHandler(cfg.JWT.Secret, cfg.Wechat.AppID, cfg.Wechat.AppSecret) + + // Public routes + v1 := router.Group("/api/v1") + { + auth := v1.Group("/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + auth.POST("/wechat/login", authHandler.WechatLogin) + auth.POST("/refresh", authHandler.RefreshToken) + } + + user := v1.Group("/user") + user.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + user.GET("/profile", authHandler.GetProfile) + user.PUT("/profile", authHandler.UpdateProfile) + } + + blind := v1.Group("/blind") + blind.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + blind.GET("/categories", authHandler.GetCategories) + blind.GET("/pool", authHandler.GetPool) + blind.POST("/choose", middleware.BlindDailyLimit(cfg.JWT.Secret), authHandler.Choose) + blind.GET("/result/:id", authHandler.GetResult) + blind.GET("/history", authHandler.GetHistory) + } + + review := v1.Group("/review") + review.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + review.POST("/submit", authHandler.SubmitReview) + review.GET("/stats", authHandler.GetReviewStats) + } + + coupon := v1.Group("/coupon") + coupon.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + coupon.GET("/list", authHandler.GetMyCoupons) + coupon.GET("/available", authHandler.GetAvailableCoupons) + coupon.POST("/claim", authHandler.ClaimCoupon) + } + + member := v1.Group("/member") + member.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + member.GET("/status", authHandler.GetMemberStatus) + member.GET("/plans", authHandler.GetMemberPlans) + member.POST("/subscribe", authHandler.SubscribeMember) + } + } + + // Admin - public login + admin := router.Group("/api/v1/admin") + { + admin.POST("/login", authHandler.AdminLogin) + } + // Admin - authenticated + adminAuth := router.Group("/api/v1/admin") + adminAuth.Use(middleware.JWTAuth(cfg.JWT.Secret)) + adminAuth.Use(middleware.AdminOnly(cfg.JWT.Secret)) + { + adminAuth.GET("/dashboard", authHandler.Dashboard) + adminAuth.GET("/merchants", authHandler.ListMerchants) + adminAuth.POST("/merchants", authHandler.CreateMerchant) + adminAuth.PUT("/merchants/:id", authHandler.UpdateMerchant) + adminAuth.GET("/packages", authHandler.ListPackages) + adminAuth.POST("/packages", authHandler.CreatePackage) + adminAuth.PUT("/packages/:id", authHandler.UpdatePackage) + adminAuth.GET("/users", authHandler.ListUsers) + adminAuth.GET("/users/:id", authHandler.GetUserDetail) + adminAuth.GET("/reviews", authHandler.ListReviews) + adminAuth.GET("/coupons", authHandler.ListCoupons) + adminAuth.POST("/coupons", authHandler.CreateCoupon) + adminAuth.GET("/statistics", authHandler.GetStatistics) + adminAuth.GET("/export", authHandler.ExportData) + } + + addr := ":" + cfg.Server.Port + log.Printf("Server starting on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/backend/config.yaml b/backend/config.yaml new file mode 100644 index 0000000..ede5335 --- /dev/null +++ b/backend/config.yaml @@ -0,0 +1,32 @@ +server: + port: 8080 + mode: debug # debug, release, test + +database: + host: localhost + port: 5432 + user: blind_select + password: blind_select_pass + dbname: blind_select + sslmode: disable + max_idle_conns: 10 + max_open_conns: 100 + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: "change-this-to-a-random-secret" + expire: 24h + refresh_expire: 168h + +ai: + endpoint: "https://qs.szscp.com" + key: "sk-263...d517" + +wechat: + app_id: "" # 微信小程序 AppID + app_secret: "" # 微信小程序 AppSecret diff --git a/backend/config.yaml.example b/backend/config.yaml.example new file mode 100644 index 0000000..c2b1993 --- /dev/null +++ b/backend/config.yaml.example @@ -0,0 +1,23 @@ +server: + port: 8080 + mode: debug # release for production + +database: + host: localhost + port: 5432 + user: postgres + password: your_password_here + dbname: blind_select + sslmode: disable + +jwt: + secret: your_jwt_secret_key_here_change_in_production + expire: 24h + +wechat: + appid: your_wechat_appid + secret: your_wechat_secret + +ai: + endpoint: https://qs.szscp.com + key: sk-your-ai-api-key diff --git a/backend/config/.env.example b/backend/config/.env.example new file mode 100644 index 0000000..afd610e --- /dev/null +++ b/backend/config/.env.example @@ -0,0 +1,30 @@ +# Server +APP_PORT=8080 +APP_MODE=debug + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=blind_select +DB_PASSWORD=blind_select_pass +DB_NAME=blind_select +DB_SSLMODE=disable + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# JWT +JWT_SECRET=change-this-to-a-random-secret +JWT_EXPIRE=24h +JWT_REFRESH_EXPIRE=168h + +# AI API +AI_ENDPOINT=https://qs.szscp.com +AI_KEY=sk-263...d517 + +# WeChat Mini Program +WECHAT_APP_ID= +WECHAT_APP_SECRET= diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..6a26e5f --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,63 @@ +services: + # ===== PostgreSQL ===== + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: blind_select + POSTGRES_USER: blind_select + POSTGRES_PASSWORD: blind_select_pass + TZ: Asia/Shanghai + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U blind_select"] + interval: 10s + timeout: 5s + retries: 5 + + # ===== Redis ===== + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # ===== Backend API ===== + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + TZ: Asia/Shanghai + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./config.yaml:/app/config.yaml:ro + restart: unless-stopped + + # ===== Admin Frontend ===== + admin: + build: + context: .. + dockerfile: frontend-admin/Dockerfile + ports: + - "5173:80" + restart: unless-stopped + +volumes: + pgdata: + redisdata: diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..46fc48b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,53 @@ +module github.com/blind-select/backend + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.29.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/datatypes v1.2.7 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.30.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gorm.io/driver/mysql v1.5.6 // indirect +) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..e1ce729 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "os" + "sync" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + DB DBConfig `yaml:"database"` + Redis RedisConfig `yaml:"redis"` + JWT JWTConfig `yaml:"jwt"` + AI AIConfig `yaml:"ai"` + Wechat WechatConfig `yaml:"wechat"` +} + +type WechatConfig struct { + AppID string `yaml:"app_id"` + AppSecret string `yaml:"app_secret"` +} + +type ServerConfig struct { + Port string `yaml:"port"` + Mode string `yaml:"mode"` +} + +type DBConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + DBName string `yaml:"dbname"` + SSLMode string `yaml:"sslmode"` + MaxIdleConns int `yaml:"max_idle_conns"` + MaxOpenConns int `yaml:"max_open_conns"` +} + +func (d *DBConfig) DSN() string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?sslmode=%s&charset=utf8mb4", + d.User, d.Password, d.Host, d.Port, d.DBName, d.SSLMode) +} + +type RedisConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Password string `yaml:"password"` + DB int `yaml:"db"` +} + +type JWTConfig struct { + Secret string `yaml:"secret"` + Expire string `yaml:"expire"` + RefreshExpire string `yaml:"refresh_expire"` +} + +type AIConfig struct { + Endpoint string `yaml:"endpoint"` + Key string `yaml:"key"` +} + +var ( + cfg *Config + cfgOnce sync.Once +) + +func Load(path string) (*Config, error) { + var err error + cfgOnce.Do(func() { + data, err := os.ReadFile(path) + if err != nil { + return + } + cfg = &Config{} + err = yaml.Unmarshal(data, cfg) + }) + return cfg, err +} + +func Get() *Config { + return cfg +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..40d6f66 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,52 @@ +package database + +import ( + "fmt" + "log" + + "github.com/blind-select/backend/internal/config" + "github.com/blind-select/backend/internal/model" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init(cfg *config.Config) { + dsn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai", + cfg.DB.Host, cfg.DB.Port, cfg.DB.User, cfg.DB.Password, cfg.DB.DBName, cfg.DB.SSLMode, + ) + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // Auto migrate models + err = DB.AutoMigrate( + &model.User{}, + &model.Admin{}, + &model.Merchant{}, + &model.Category{}, + &model.Package{}, + &model.UserBehavior{}, + &model.BlindSession{}, + &model.Coupon{}, + &model.Member{}, + &model.Activity{}, + ) + if err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } + + log.Println("Database connected and migrated successfully") +} + +func GetDB() *gorm.DB { + return DB +} diff --git a/backend/internal/handler/v1/handler.go b/backend/internal/handler/v1/handler.go new file mode 100644 index 0000000..3196d8f --- /dev/null +++ b/backend/internal/handler/v1/handler.go @@ -0,0 +1,830 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/blind-select/backend/internal/database" + "github.com/blind-select/backend/internal/middleware" + "github.com/blind-select/backend/internal/model" + "github.com/blind-select/backend/internal/service" + "github.com/blind-select/backend/internal/utils" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AuthHandler struct { + JWTSecret string + WechatAppID string + WechatAppSecret string + blindSvc *service.BlindService + memberSvc *service.MemberService + couponSvc *service.CouponService +} + +func NewAuthHandler(jwtSecret, wechatAppID, wechatAppSec string) *AuthHandler { + return &AuthHandler{ + JWTSecret: jwtSecret, + WechatAppID: wechatAppID, + WechatAppSecret: wechatAppSec, + blindSvc: service.NewBlindService(), + memberSvc: service.NewMemberService(), + couponSvc: service.NewCouponService(), + } +} + +// ============== User Auth ============== + +type RegisterReq struct { + Nickname string `json:"nickname" binding:"required,min=2,max=50"` + Phone string `json:"phone" binding:"required,len=11"` + Password string `json:"password" binding:"required,min=6,max=32"` +} + +type LoginReq struct { + Phone string `json:"phone" binding:"required,len=11"` + Password string `json:"password" binding:"required"` +} + +type LoginRes struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + User *model.User `json:"user"` + HasMember bool `json:"has_member"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req RegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + + db := database.GetDB() + var count int64 + db.Model(&model.User{}).Where("phone = ?", req.Phone).Count(&count) + if count > 0 { + middleware.JSONError(c, http.StatusConflict, "phone already registered") + return + } + + hash, err := utils.HashPassword(req.Password) + if err != nil { + middleware.JSONError(c, http.StatusInternalServerError, "failed to hash password") + return + } + + user := model.User{ + Nickname: req.Nickname, + Phone: req.Phone, + PasswordHash: hash, + Tags: []string{}, + RepeatDays: 7, + MemberLevel: 0, + } + if err := db.Create(&user).Error; err != nil { + middleware.JSONError(c, http.StatusInternalServerError, "failed to create user") + return + } + + token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret) + refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret) + + middleware.JSONResponse(c, http.StatusCreated, LoginRes{ + Token: token, + RefreshToken: refreshToken, + User: &user, + HasMember: false, + }) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, "invalid request") + return + } + + db := database.GetDB() + var user model.User + if err := db.Where("phone = ?", req.Phone).First(&user).Error; err != nil { + middleware.JSONError(c, http.StatusUnauthorized, "invalid phone or password") + return + } + if !utils.CheckPassword(req.Password, user.PasswordHash) { + middleware.JSONError(c, http.StatusUnauthorized, "invalid phone or password") + return + } + + token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret) + refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret) + hasMember := user.MemberLevel >= 1 && user.MemberExpires != nil && user.MemberExpires.After(user.UpdatedAt) + + middleware.JSONResponse(c, http.StatusOK, LoginRes{ + Token: token, + RefreshToken: refreshToken, + User: &user, + HasMember: hasMember, + }) +} + +func (h *AuthHandler) GetProfile(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + db := database.GetDB() + var user model.User + if err := db.First(&user, uid).Error; err != nil { + middleware.JSONError(c, http.StatusNotFound, "user not found") + return + } + middleware.JSONResponse(c, http.StatusOK, user) +} + +func (h *AuthHandler) UpdateProfile(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + var req struct { + Nickname string `json:"nickname" binding:"omitempty,min=2,max=50"` + Avatar string `json:"avatar" binding:"omitempty,url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, "invalid request") + return + } + updates := map[string]interface{}{} + if req.Nickname != "" { + updates["nickname"] = req.Nickname + } + if req.Avatar != "" { + updates["avatar"] = req.Avatar + } + if len(updates) > 0 { + database.GetDB().Model(&model.User{}).Where("id = ?", uid).Updates(updates) + } + var user model.User + database.GetDB().First(&user, uid) + middleware.JSONResponse(c, http.StatusOK, user) +} + +func (h *AuthHandler) WechatLogin(c *gin.Context) { + var req struct { + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, "invalid request") + return + } + + wechatSvc := service.NewWechatService(h.WechatAppID, h.WechatAppSecret) + user, _, err := wechatSvc.LoginOrCreateUser(req.Code) + if err != nil { + middleware.JSONError(c, http.StatusUnauthorized, "wechat login failed: "+err.Error()) + return + } + + token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret) + refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret) + + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "token": token, + "refresh_token": refreshToken, + "user": user, + }) +} + +func (h *AuthHandler) RefreshToken(c *gin.Context) { + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, "invalid request") + return + } + claims, err := utils.ParseToken(req.RefreshToken, h.JWTSecret) + if err != nil { + middleware.JSONError(c, http.StatusUnauthorized, "invalid refresh token") + return + } + token, _ := utils.GenerateToken(claims.UserID, claims.Username, claims.Role, h.JWTSecret) + middleware.JSONResponse(c, http.StatusOK, gin.H{"token": token}) +} + +func (h *AuthHandler) AdminLogin(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, "invalid request") + return + } + db := database.GetDB() + var admin model.Admin + if db.Where("username = ?", req.Username).First(&admin).Error != nil { + middleware.JSONError(c, http.StatusUnauthorized, "invalid username or password") + return + } + if !utils.CheckPassword(req.Password, admin.Password) { + middleware.JSONError(c, http.StatusUnauthorized, "invalid username or password") + return + } + token, _ := utils.GenerateToken(uint(admin.ID), admin.Username, "admin", h.JWTSecret) + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "token": token, + "admin": gin.H{"id": admin.ID, "username": admin.Username, "role": admin.Role}, + }) +} + +func (h *AuthHandler) Dashboard(c *gin.Context) { + db := database.GetDB() + var userCount, merchantCount, packageCount, blindCount int64 + db.Model(&model.User{}).Count(&userCount) + db.Model(&model.Merchant{}).Where("status = ?", "approved").Count(&merchantCount) + db.Model(&model.Package{}).Where("status = ?", "active").Count(&packageCount) + db.Model(&model.BlindSession{}).Count(&blindCount) + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "today_users": userCount, + "merchants": merchantCount, + "packages": packageCount, + "blind_sessions": blindCount, + }) +} + +// ============== Blind Selection ============== + +func (h *AuthHandler) GetCategories(c *gin.Context) { + db := database.GetDB() + var categories []model.Category + db.Where("status = ?", "active").Order("sort ASC").Find(&categories) + middleware.JSONResponse(c, http.StatusOK, categories) +} + +func (h *AuthHandler) GetPool(c *gin.Context) { + categ := c.Query("category") + db := database.GetDB() + query := db.Model(&model.Package{}).Where("packages.status = ?", "active") + if categ != "" { + if catID, err := strconv.Atoi(categ); err == nil { + query = query.Where("packages.category_id = ?", catID) + } + } + + // Join with merchants for merchant name + type PoolItem struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PriceRange string `json:"price_range"` + MerchantID uint `json:"merchant_id"` + Merchant string `json:"merchant"` + Rating float64 `json:"rating"` + CatName string `json:"cat_name"` + } + + var packages []model.Package + query.Find(&packages) + + result := make([]PoolItem, 0, len(packages)) + for _, p := range packages { + var merchant model.Merchant + db.First(&merchant, p.MerchantID) + merchantName := "未知商家" + if merchant.ID > 0 { + merchantName = merchant.Name + } + result = append(result, PoolItem{ + ID: p.ID, Name: "神秘" + truncate(p.Name, 4), + Description: p.Description, + PriceRange: "¥" + strconv.Itoa(p.PriceMin) + "-" + strconv.Itoa(p.PriceMax), + MerchantID: p.MerchantID, Merchant: merchantName, + Rating: p.Rating, CatName: getCategoryName(db, p.CategoryID), + }) + } + + middleware.JSONResponse(c, http.StatusOK, result) +} + +func (h *AuthHandler) Choose(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + + var req struct { + CategoryID uint `json:"category_id" binding:"required"` + PriceRange string `json:"price_range" binding:"required"` + DistanceRange string `json:"distance_range"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + + // Perform blind selection + pkg, merchant, matchScore, err := h.blindSvc.Select(uid, req.CategoryID, req.PriceRange) + if err != nil { + middleware.JSONError(c, http.StatusNotFound, "no available packages in this category/price range") + return + } + + // Record selection + session, _, err := h.blindSvc.RecordSelection(uid, req.CategoryID, req.PriceRange, pkg.ID, merchant.ID, matchScore) + if err != nil { + middleware.JSONError(c, http.StatusInternalServerError, "failed to record selection") + return + } + + // Build response + resp := gin.H{ + "session_id": session.ID, + "result": gin.H{ + "package_name": pkg.Name, + "merchant_name": merchant.Name, + "merchant_rating": merchant.Rating, + "description": pkg.Description, + "price_range": "¥" + strconv.Itoa(pkg.PriceMin) + "-" + strconv.Itoa(pkg.PriceMax), + "actual_price": pkg.ActualPrice, + "match_score": matchScore, + "has_coupon": false, + }, + } + middleware.JSONResponse(c, http.StatusOK, resp) +} + +func (h *AuthHandler) GetResult(c *gin.Context) { + sessionID, _ := strconv.Atoi(c.Param("id")) + db := database.GetDB() + var result model.BlindResult + if err := db.First(&result, sessionID).Error; err != nil { + middleware.JSONError(c, http.StatusNotFound, "result not found") + return + } + middleware.JSONResponse(c, http.StatusOK, result) +} + +func (h *AuthHandler) GetHistory(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + db := database.GetDB() + + var sessions []model.BlindSession + db.Where("user_id = ?", uid).Order("created_at DESC").Limit(20).Find(&sessions) + + type HistoryItem struct { + SessionID uint `json:"session_id"` + PackageName string `json:"package_name"` + Merchant string `json:"merchant"` + PriceRange string `json:"price_range"` + MatchScore float64 `json:"match_score"` + Accepted bool `json:"accepted"` + CreatedAt time.Time `json:"created_at"` + } + + result := make([]HistoryItem, 0, len(sessions)) + for _, s := range sessions { + var br model.BlindResult + db.First(&br, s.ID) + result = append(result, HistoryItem{ + SessionID: s.ID, + PackageName: br.PackageName, + Merchant: br.MerchantName, + PriceRange: br.PriceRange, + MatchScore: br.MatchScore, + Accepted: s.Accepted, + CreatedAt: s.CreatedAt, + }) + } + + middleware.JSONResponse(c, http.StatusOK, result) +} + +// ============== Review ============== + +func (h *AuthHandler) SubmitReview(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + + var req struct { + SessionID uint `json:"session_id" binding:"required"` + PackageID uint `json:"package_id" binding:"required"` + Rating int8 `json:"rating" binding:"required,min=1,max=5"` + Taste int8 `json:"taste" binding:"required,min=1,max=5"` + Value int8 `json:"value" binding:"required,min=1,max=5"` + Distance int8 `json:"distance" binding:"required,min=1,max=5"` + Match int8 `json:"match" binding:"required,min=1,max=5"` + Tags []string `json:"tags"` + Text string `json:"text"` + IsRepeat bool `json:"is_repeat"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + + if err := h.blindSvc.SubmitReview(uid, req.SessionID, req.PackageID, + req.Rating, req.Taste, req.Value, req.Distance, req.Match, req.Tags, req.Text, req.IsRepeat); err != nil { + middleware.JSONError(c, http.StatusInternalServerError, "failed to submit review") + return + } + + middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "review submitted successfully"}) +} + +func (h *AuthHandler) GetReviewStats(c *gin.Context) { + stats, err := h.blindSvc.GetReviewStats() + if err != nil { + middleware.JSONError(c, http.StatusInternalServerError, "failed to get stats") + return + } + middleware.JSONResponse(c, http.StatusOK, stats) +} + +// ============== Coupon ============== + +func (h *AuthHandler) GetMyCoupons(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + db := database.GetDB() + var coupons []model.Coupon + db.Where("user_id = ? AND status IN ?", uid, []string{"available", "claimed", "used"}). + Order("created_at DESC").Find(&coupons) + middleware.JSONResponse(c, http.StatusOK, coupons) +} + +func (h *AuthHandler) GetAvailableCoupons(c *gin.Context) { + db := database.GetDB() + var coupons []model.Coupon + db.Where("status = ? AND remain_count > 0", "available").Limit(20).Find(&coupons) + middleware.JSONResponse(c, http.StatusOK, coupons) +} + +func (h *AuthHandler) ClaimCoupon(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + var req struct { + PoolCode string `json:"pool_code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + coupon, err := h.couponSvc.ClaimCoupon(uid, req.PoolCode) + if err != nil { + middleware.JSONError(c, http.StatusBadRequest, "failed to claim coupon") + return + } + middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "coupon claimed", "coupon": coupon}) +} + +// ============== Member ============== + +func (h *AuthHandler) GetMemberStatus(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + active, member := h.memberSvc.CheckMembership(uid) + var level int + if active { + level = 1 + } + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "level": level, + "active": active, + "expires_at": member.EndDate, + "daily_limit": 10, + }) +} + +func dailyLimit(level int) int { + if level >= 1 { + return 10 + } + return 3 +} + +func (h *AuthHandler) GetMemberPlans(c *gin.Context) { + middleware.JSONResponse(c, http.StatusOK, []gin.H{ + {"id": 1, "name": "VIP月卡", "price": 29, "period": "monthly", "blindLimit": 10, "features": []string{"全部分类", "优先匹配", "月度报告"}}, + {"id": 2, "name": "VIP年卡", "price": 199, "period": "yearly", "blindLimit": 10, "features": []string{"全部分类", "优先匹配", "月度报告", "省¥149"}}, + }) +} + +func (h *AuthHandler) SubscribeMember(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + var req struct { + PlanID uint `json:"plan_id" binding:"required"` + PaymentMethod string `json:"payment_method"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + member, err := h.memberSvc.Subscribe(uid, req.PlanID, req.PaymentMethod) + if err != nil { + middleware.JSONError(c, http.StatusInternalServerError, err.Error()) + return + } + middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "subscription successful", "member": member}) +} + +// ============== Admin CRUD ============== + +func (h *AuthHandler) ListMerchants(c *gin.Context) { + db := database.GetDB() + var merchants []model.Merchant + db.Order("created_at DESC").Find(&merchants) + middleware.JSONResponse(c, http.StatusOK, merchants) +} + +func (h *AuthHandler) CreateMerchant(c *gin.Context) { + var req model.Merchant + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + db := database.GetDB() + if req.Tags == "" { + req.Tags = "[]" + } + if req.Status == "" { + req.Status = "approved" + } + if req.Rating == 0 { + req.Rating = 4.0 + } + if req.QualityScore == 0 { + req.QualityScore = 0.5 + } + db.Create(&req) + middleware.JSONResponse(c, http.StatusCreated, req) +} + +func (h *AuthHandler) UpdateMerchant(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + db := database.GetDB() + var req struct { + Name string `json:"name"` + CategoryID uint `json:"category_id"` + Rating float64 `json:"rating"` + PriceRange string `json:"price_range"` + Location string `json:"location"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Tags string `json:"tags"` + QualityScore float64 `json:"quality_score"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.CategoryID > 0 { + updates["category_id"] = req.CategoryID + } + if req.Rating > 0 { + updates["rating"] = req.Rating + } + if req.PriceRange != "" { + updates["price_range"] = req.PriceRange + } + if req.Location != "" { + updates["location"] = req.Location + } + if req.Lat > 0 { + updates["lat"] = req.Lat + } + if req.Lng > 0 { + updates["lng"] = req.Lng + } + if req.Tags != "" { + updates["tags"] = req.Tags + } + if req.QualityScore > 0 { + updates["quality_score"] = req.QualityScore + } + if req.Status != "" { + updates["status"] = req.Status + } + db.Model(&model.Merchant{}).Where("id = ?", id).Updates(updates) + var m model.Merchant + db.First(&m, id) + middleware.JSONResponse(c, http.StatusOK, m) +} + +func (h *AuthHandler) ListPackages(c *gin.Context) { + db := database.GetDB() + var packages []model.Package + db.Preload("Merchant").Order("created_at DESC").Find(&packages) + middleware.JSONResponse(c, http.StatusOK, packages) +} + +func (h *AuthHandler) CreatePackage(c *gin.Context) { + db := database.GetDB() + var req model.Package + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + if req.Tags == "" { + req.Tags = "[]" + } + if req.Status == "" { + req.Status = "active" + } + if req.Weight == 0 { + req.Weight = 1.0 + } + db.Create(&req) + middleware.JSONResponse(c, http.StatusCreated, req) +} + +func (h *AuthHandler) UpdatePackage(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + db := database.GetDB() + var req struct { + Name string `json:"name"` + Description string `json:"description"` + PriceMin int `json:"price_min"` + PriceMax int `json:"price_max"` + ActualPrice int `json:"actual_price"` + Tags string `json:"tags"` + Stock int `json:"stock"` + Weight float64 `json:"weight"` + Status string `json:"status"` + CategoryID uint `json:"category_id"` + MerchantID uint `json:"merchant_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.PriceMin > 0 { + updates["price_min"] = req.PriceMin + } + if req.PriceMax > 0 { + updates["price_max"] = req.PriceMax + } + if req.ActualPrice > 0 { + updates["actual_price"] = req.ActualPrice + } + if req.Tags != "" { + updates["tags"] = req.Tags + } + updates["stock"] = req.Stock + updates["weight"] = req.Weight + if req.Status != "" { + updates["status"] = req.Status + } + if req.CategoryID > 0 { + updates["category_id"] = req.CategoryID + } + if req.MerchantID > 0 { + updates["merchant_id"] = req.MerchantID + } + db.Model(&model.Package{}).Where("id = ?", id).Updates(updates) + var p model.Package + db.Preload("Merchant").First(&p, id) + middleware.JSONResponse(c, http.StatusOK, p) +} + +func (h *AuthHandler) ListUsers(c *gin.Context) { + db := database.GetDB() + var users []model.User + db.Order("created_at DESC").Limit(50).Find(&users) + middleware.JSONResponse(c, http.StatusOK, users) +} + +func (h *AuthHandler) GetUserDetail(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + db := database.GetDB() + var user model.User + db.First(&user, id) + if user.ID == 0 { + middleware.JSONError(c, http.StatusNotFound, "user not found") + return + } + // Get blind session count + var sessionCount int64 + db.Model(&model.BlindSession{}).Where("user_id = ?", id).Count(&sessionCount) + // Get review count + var reviewCount int64 + db.Model(&model.UserBehavior{}).Where("user_id = ? AND review_rating > 0", id).Count(&reviewCount) + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "user": user, + "session_count": sessionCount, + "review_count": reviewCount, + }) +} + +func (h *AuthHandler) ListReviews(c *gin.Context) { + db := database.GetDB() + type ReviewRow struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + PkgName string `json:"package_name"` + Merchant string `json:"merchant"` + Rating int8 `json:"rating"` + Taste int8 `json:"taste"` + Value int8 `json:"value"` + Distance int8 `json:"distance"` + Match int8 `json:"match"` + IsRepeat bool `json:"is_repeat"` + CreatedAt time.Time `json:"created_at"` + } + var reviews []model.UserBehavior + db.Where("review_rating > 0").Order("created_at DESC").Limit(50).Find(&reviews) + result := make([]ReviewRow, 0, len(reviews)) + for _, r := range reviews { + var pkg model.Package + db.First(&pkg, r.PackageID) + var merchant model.Merchant + db.First(&merchant, pkg.MerchantID) + result = append(result, ReviewRow{ + ID: r.ID, UserID: r.UserID, PkgName: pkg.Name, + Merchant: merchant.Name, Rating: r.ReviewRating, + Taste: r.TasteScore, Value: r.ValueScore, + Distance: r.DistanceScore, Match: r.MatchScore, + IsRepeat: r.IsRepeat, CreatedAt: r.CreatedAt, + }) + } + middleware.JSONResponse(c, http.StatusOK, result) +} + +func (h *AuthHandler) ListCoupons(c *gin.Context) { + db := database.GetDB() + var coupons []model.Coupon + db.Preload("Merchant").Order("created_at DESC").Limit(50).Find(&coupons) + middleware.JSONResponse(c, http.StatusOK, coupons) +} + +func (h *AuthHandler) CreateCoupon(c *gin.Context) { + db := database.GetDB() + var req model.Coupon + if err := c.ShouldBindJSON(&req); err != nil { + middleware.JSONError(c, http.StatusBadRequest, err.Error()) + return + } + if req.Status == "" { + req.Status = "available" + } + db.Create(&req) + middleware.JSONResponse(c, http.StatusCreated, req) +} + +func (h *AuthHandler) GetStatistics(c *gin.Context) { + db := database.GetDB() + var userCount, activeUsers int64 + db.Model(&model.User{}).Count(&userCount) + db.Model(&model.UserBehavior{}).Distinct("user_id").Where("created_at >= ?", time.Now().Add(-24*time.Hour)).Count(&activeUsers) + var blindCount, reviewCount int64 + db.Model(&model.BlindSession{}).Count(&blindCount) + db.Model(&model.UserBehavior{}).Where("review_rating > 0").Count(&reviewCount) + var avgRating float64 + db.Model(&model.UserBehavior{}).Where("review_rating > 0").Pluck("AVG(review_rating)", &avgRating) + var memberCount int64 + db.Model(&model.User{}).Where("member_level >= 1").Count(&memberCount) + + middleware.JSONResponse(c, http.StatusOK, gin.H{ + "total_users": userCount, + "daily_active": activeUsers, + "total_blinds": blindCount, + "total_reviews": reviewCount, + "avg_rating": avgRating, + "member_count": memberCount, + }) +} + +func (h *AuthHandler) ExportData(c *gin.Context) { + middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "export started", "format": "csv"}) +} + +// ============== Helpers ============== + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func getCategoryName(db *gorm.DB, categoryID uint) string { + var cat model.Category + if err := db.First(&cat, categoryID).Error; err == nil { + return cat.Name + } + return "" +} + +func generateUserCode(userID, couponID uint) string { + return "CPN" + strconv.FormatUint(uint64(userID), 10) + strconv.FormatUint(uint64(couponID), 10) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..f4c2d14 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/blind-select/backend/internal/utils" + "github.com/gin-gonic/gin" +) + +// JWTAuth requires valid JWT +func JWTAuth(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + JSONError(c, http.StatusUnauthorized, "missing authorization header") + c.Abort() + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if tokenStr == authHeader { + JSONError(c, http.StatusUnauthorized, "invalid authorization format") + c.Abort() + return + } + + claims, err := utils.ParseToken(tokenStr, secret) + if err != nil { + JSONError(c, http.StatusUnauthorized, "invalid or expired token") + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Next() + } +} + +// AdminOnly restricts access to admin role only +func AdminOnly(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role != "admin" { + JSONError(c, http.StatusForbidden, "admin access required") + c.Abort() + return + } + c.Next() + } +} + +// BlindDailyLimit limits blind selections per day +func BlindDailyLimit(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, _ := c.Get("user_id") + uid := userID.(uint) + + limit := getDailyLimit(uid) + used := getTodayBlindCount(uid) + + if used >= limit { + JSONError(c, http.StatusTooManyRequests, "daily blind selection limit reached. Upgrade to VIP for more!") + c.Abort() + return + } + + c.Next() + } +} + +func getDailyLimit(userID uint) int { + return 3 // TODO: check membership +} + +func getTodayBlindCount(userID uint) int { + return 0 // TODO: check Redis +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..dac612a --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CORSMiddleware handles CORS headers +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/backend/internal/middleware/response.go b/backend/internal/middleware/response.go new file mode 100644 index 0000000..069d93d --- /dev/null +++ b/backend/internal/middleware/response.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// JSONResponse helper +func JSONResponse(c *gin.Context, status int, data interface{}) { + c.JSON(status, gin.H{ + "code": status / 100, + "message": getMessage(status), + "data": data, + }) +} + +// JSONError sends a JSON error response +func JSONError(c *gin.Context, status int, msg string) { + c.JSON(status, gin.H{ + "code": status / 100, + "message": msg, + "data": nil, + }) +} + +func getMessage(status int) string { + switch status { + case 200: return "ok" + case 201: return "created" + case 400: return "bad request" + case 401: return "unauthorized" + case 403: return "forbidden" + case 404: return "not found" + case 409: return "conflict" + case 422: return "unprocessable" + case 429: return "too many requests" + case 500: return "internal error" + default: return "error" + } +} diff --git a/backend/internal/model/admin.go b/backend/internal/model/admin.go new file mode 100644 index 0000000..5bcd830 --- /dev/null +++ b/backend/internal/model/admin.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" +) + +type Admin struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:100;uniqueIndex" json:"username"` + Password string `gorm:"size:255" json:"-"` + Role string `gorm:"size:50;default:editor" json:"role"` // admin/editor/viewer + PermJSON string `gorm:"type:text" json:"permissions"` // JSON string of permissions + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Admin) TableName() string { return "admins" } diff --git a/backend/internal/model/behavior.go b/backend/internal/model/behavior.go new file mode 100644 index 0000000..6e76952 --- /dev/null +++ b/backend/internal/model/behavior.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" +) + +type UserBehavior struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + PackageID uint `json:"package_id"` + BehaviorType string `gorm:"size:50" json:"behavior_type"` // viewed/selected/attended/reviewed/skipped + SystemScore float64 `gorm:"default:0" json:"system_score"` + ReviewRating int8 `json:"review_rating"` + TasteScore int8 `json:"taste_score"` + ValueScore int8 `json:"value_score"` + DistanceScore int8 `json:"distance_score"` + MatchScore int8 `json:"match_score"` + FeedbackTags string `gorm:"type:text[]" json:"feedback_tags"` + Text string `gorm:"type:text" json:"text"` + IsRepeat bool `gorm:"default:false" json:"is_repeat"` + CreatedAt time.Time `json:"created_at"` +} + +func (UserBehavior) TableName() string { return "user_behaviors" } diff --git a/backend/internal/model/blind.go b/backend/internal/model/blind.go new file mode 100644 index 0000000..f114e99 --- /dev/null +++ b/backend/internal/model/blind.go @@ -0,0 +1,37 @@ +package model + +import ( + "time" +) + +type BlindSession struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + CategoryID uint `json:"category_id"` + PriceRange string `gorm:"size:50" json:"price_range"` + DistanceRange string `gorm:"size:50" json:"distance_range"` + ResultPackageID uint `json:"result_package_id"` + RevealedAt time.Time `json:"revealed_at"` + Accepted bool `gorm:"default:false" json:"accepted"` + CreatedAt time.Time `json:"created_at"` +} + +func (BlindSession) TableName() string { return "blind_sessions" } + +type BlindResult struct { + SessionID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id"` + PackageName string `gorm:"size:200" json:"package_name"` + MerchantName string `gorm:"size:200" json:"merchant_name"` + MerchantRating float64 `json:"merchant_rating"` + MatchScore float64 `json:"match_score"` + Description string `gorm:"type:text" json:"description"` + PriceRange string `json:"price_range"` + ActualPrice int `json:"actual_price"` + HasCoupon bool `json:"has_coupon"` + CouponValue string `json:"coupon_value"` + MatchedTags string `gorm:"type:text[]" json:"matched_tags"` + CreatedAt time.Time `json:"created_at"` +} + +func (BlindResult) TableName() string { return "blind_results" } diff --git a/backend/internal/model/category.go b/backend/internal/model/category.go new file mode 100644 index 0000000..181ae61 --- /dev/null +++ b/backend/internal/model/category.go @@ -0,0 +1,15 @@ +package model + +import "time" + +type Category struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100" json:"name"` + Icon string `gorm:"size:50" json:"icon"` + Type string `gorm:"size:50" json:"type"` // food/entertainment/shopping/activity + Sort int `gorm:"default:0" json:"sort"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func (Category) TableName() string { return "categories" } diff --git a/backend/internal/model/coupon.go b/backend/internal/model/coupon.go new file mode 100644 index 0000000..0d638bd --- /dev/null +++ b/backend/internal/model/coupon.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Coupon struct { + ID uint `gorm:"primaryKey" json:"id"` + MerchantID uint `json:"merchant_id"` + PackageID uint `json:"package_id"` + UserID *uint `json:"user_id"` + Type string `gorm:"size:20" json:"type"` // discount/coupon/free/gift + Value float64 `json:"value"` + MinAmount int `json:"min_amount"` + TotalCount int `gorm:"default:100" json:"total_count"` + RemainCount int `gorm:"default:100" json:"remain_count"` + UserCode string `gorm:"size:50;uniqueIndex" json:"user_code"` + PoolCode string `gorm:"size:50" json:"pool_code"` + Status string `gorm:"size:20;default:available" json:"status"` // available/claimed/used/expired + UsedAt *time.Time `json:"used_at"` + ValidStart time.Time `json:"valid_start"` + ValidEnd time.Time `json:"valid_end"` + Commission float64 `gorm:"default:0" json:"commission"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Coupon) TableName() string { return "coupons" } diff --git a/backend/internal/model/member.go b/backend/internal/model/member.go new file mode 100644 index 0000000..e199abf --- /dev/null +++ b/backend/internal/model/member.go @@ -0,0 +1,37 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Member struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_level" json:"user_id"` + Level int `gorm:"default:0" json:"level"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + PaymentMethod string `gorm:"size:50" json:"payment_method"` + Amount float64 `json:"amount"` + OrderNo string `gorm:"size:100;uniqueIndex" json:"order_no"` + Status string `gorm:"size:20;default:active" json:"status"` // active/expired/cancelled + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Member) TableName() string { return "members" } + +type Activity struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:200" json:"name"` + Type string `gorm:"size:50" json:"type"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + ConfigJSON string `gorm:"type:text" json:"config"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func (Activity) TableName() string { return "activities" } diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go new file mode 100644 index 0000000..8ac8fc0 --- /dev/null +++ b/backend/internal/model/merchant.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Merchant struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:200" json:"name"` + Avatar string `gorm:"size:255" json:"avatar"` + CategoryID uint `json:"category_id"` + CategoryName string `gorm:"column:category_name" json:"category_name"` + Rating float64 `gorm:"default:0" json:"rating"` + PriceRange string `gorm:"size:50" json:"price_range"` + Location string `gorm:"size:100" json:"location"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Tags string `gorm:"type:text[]" json:"tags"` + Commission float64 `gorm:"default:0.1" json:"commission"` + TotalReviews int `gorm:"default:0" json:"total_reviews"` + TotalScore float64 `gorm:"default:0" json:"total_score"` + QualityScore float64 `gorm:"default:0.5" json:"quality_score"` + Status string `gorm:"size:20;default:pending" json:"status"` // pending/approved/rejected/offline + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Merchant) TableName() string { return "merchants" } diff --git a/backend/internal/model/package.go b/backend/internal/model/package.go new file mode 100644 index 0000000..ee3875c --- /dev/null +++ b/backend/internal/model/package.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Package struct { + ID uint `gorm:"primaryKey" json:"id"` + MerchantID uint `json:"merchant_id"` + CategoryID uint `json:"category_id"` + Name string `gorm:"size:200" json:"name"` + Description string `gorm:"type:text" json:"description"` + PriceMin int `json:"price_min"` + PriceMax int `json:"price_max"` + ActualPrice int `json:"actual_price"` + Tags string `gorm:"type:text[]" json:"tags"` + Stock int `gorm:"default:9999" json:"stock"` + Weight float64 `gorm:"default:1.0" json:"weight"` + Rating float64 `gorm:"default:0" json:"rating"` + ReviewCount int `gorm:"default:0" json:"review_count"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Package) TableName() string { return "packages" } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go new file mode 100644 index 0000000..e29f540 --- /dev/null +++ b/backend/internal/model/user.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Nickname string `gorm:"size:100" json:"nickname"` + Avatar string `gorm:"size:255" json:"avatar"` + Phone string `gorm:"size:20;uniqueIndex" json:"phone"` + WechatOpenID string `gorm:"size:100;uniqueIndex" json:"wechat_openid"` + PasswordHash string `gorm:"size:255" json:"-"` + PrefScore datatypes.JSON `gorm:"type:jsonb" json:"pref_score,omitempty"` + Tags []string `gorm:"type:text[]" json:"tags"` + RepeatDays int `gorm:"default:7" json:"repeat_days"` + Blacklist []uint `gorm:"type:bigint[]" json:"blacklist"` + MemberLevel int `gorm:"default:0" json:"member_level"` + MemberExpires *time.Time `json:"member_expires,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (User) TableName() string { return "users" } diff --git a/backend/internal/service/blind_service.go b/backend/internal/service/blind_service.go new file mode 100644 index 0000000..1190c76 --- /dev/null +++ b/backend/internal/service/blind_service.go @@ -0,0 +1,374 @@ +package service + +import ( + "math" + "math/rand" + "time" + + "github.com/blind-select/backend/internal/database" + "github.com/blind-select/backend/internal/model" + "gorm.io/gorm" +) + +// BlindService handles the core blind selection algorithm +type BlindService struct{} + +func NewBlindService() *BlindService { + return &BlindService{} +} + +// blindResult represents a weighted candidate for blind selection +type blindCandidate struct { + Pkg *model.Package + Weight float64 + Merchant *model.Merchant +} + +// Select performs a weighted random blind selection for a user +func (s *BlindService) Select(userID uint, categoryID uint, priceRange string) (*model.Package, *model.Merchant, float64, error) { + db := database.GetDB() + + // 1. Get user's preference scores + prefScores := s.getUserPreferenceScores(userID) + + // 2. Parse price range + priceMin, priceMax := parsePriceRange(priceRange) + + // 3. Get active packages matching category and price range + var packages []model.Package + db.Where("status = ? AND category_id = ? AND price_min >= ? AND price_max <= ?", + "active", categoryID, priceMin, priceMax).Find(&packages) + + if len(packages) == 0 { + // Fallback: relax price constraints + db.Where("status = ? AND category_id = ?", "active", categoryID).Find(&packages) + } + + if len(packages) == 0 { + return nil, nil, 0, gorm.ErrRecordNotFound + } + + // 4. Calculate weights for each package + candidates := make([]blindCandidate, 0, len(packages)) + var totalWeight float64 + + for i := range packages { + pkg := &packages[i] + + // Get merchant info + var merchant model.Merchant + db.First(&merchant, pkg.MerchantID) + + c := blindCandidate{Pkg: pkg, Merchant: &merchant} + + // Base weight + c.Weight = 1.0 + + // Factor 1: Category preference from user history + catPref := prefScores[getCategoryNameFromID(db, categoryID)] + c.Weight *= (1.0 + catPref) // e.g., 1.0 to 2.5 + + // Factor 2: Package rating (quality score) + quality := pkg.Rating / 5.0 + c.Weight *= (0.5 + quality*0.5) // 0.5 to 1.0 + + // Factor 3: Merchant quality score + c.Weight *= merchant.QualityScore // 0.3 to 1.0 + + // Factor 4: No-repeat (check last selection date) + lastUsed := s.getLastUsedDate(db, userID, pkg.ID) + if lastUsed != nil { + daysSince := time.Since(*lastUsed).Hours() / 24 + if daysSince < 7 { + c.Weight *= 0.1 // Heavy penalty for recent selections + } else if daysSince < 14 { + c.Weight *= 0.5 + } + } + + // Factor 5: Exploration bonus (packages rarely selected get a boost) + totalSelections := float64(pkg.ReviewCount) + if totalSelections < 5 { + c.Weight *= 1.5 // Boost for less-explored packages + } + + candidates = append(candidates, c) + totalWeight += c.Weight + } + + // 5. Weighted random selection + r := rand.Float64() * totalWeight + var cumulative float64 + for _, c := range candidates { + cumulative += c.Weight + if r <= cumulative { + // Calculate match score for this selection + matchScore := s.calculateMatchScore(c.Pkg, userID, prefScores) + return c.Pkg, c.Merchant, matchScore, nil + } + } + + // Fallback: return first candidate + return candidates[0].Pkg, candidates[0].Merchant, 0.5, nil +} + +// getUserPreferenceScores returns category preference scores for a user +func (s *BlindService) getUserPreferenceScores(userID uint) map[string]float64 { + db := database.GetDB() + + var behaviors []model.UserBehavior + db.Where("user_id = ? AND review_rating >= 3", userID).Find(&behaviors) + + scores := make(map[string]float64) + + for _, b := range behaviors { + pkg := model.Package{} + db.First(&pkg, b.PackageID) + + category := getCategoryNameFromID(db, pkg.CategoryID) + if category == "" { + continue + } + + // Time decay factor + decay := math.Exp(-0.01 * time.Since(b.CreatedAt).Hours()/24) + + // Review rating multiplier + ratingMult := float64(b.ReviewRating) / 5.0 + + // Repeat visit bonus + repeatBonus := 0.0 + if b.IsRepeat { + repeatBonus = 2.0 + } + + score := (1.0 + ratingMult + repeatBonus) * decay + scores[category] += score + } + + return scores +} + +// calculateMatchScore computes how well a package matches user preferences +func (s *BlindService) calculateMatchScore(pkg *model.Package, userID uint, prefScores map[string]float64) float64 { + db := database.GetDB() + category := getCategoryNameFromID(db, pkg.CategoryID) + + baseScore := 0.5 + if score, ok := prefScores[category]; ok { + baseScore = math.Min(0.5+score*0.3, 1.0) + } + + // Add quality component + baseScore += (pkg.Rating / 5.0) * 0.2 + baseScore = math.Min(baseScore, 1.0) + + return math.Round(baseScore*100) / 100 +} + +// getLastUsedDate returns the last time a package was selected for a user +func (s *BlindService) getLastUsedDate(db *gorm.DB, userID, packageID uint) *time.Time { + var session model.BlindSession + if err := db.Where("user_id = ? AND result_package_id = ?", userID, packageID). + Order("created_at DESC").First(&session).Error; err == nil { + return &session.CreatedAt + } + return nil +} + +// recordBlindSelection records the result of a blind selection +func (s *BlindService) RecordSelection(userID uint, categoryID uint, priceRange string, + packageID uint, merchantID uint, matchScore float64) (*model.BlindSession, *model.BlindResult, error) { + + db := database.GetDB() + + // Create blind session + session := model.BlindSession{ + UserID: userID, + CategoryID: categoryID, + PriceRange: priceRange, + ResultPackageID: packageID, + RevealedAt: time.Now(), + CreatedAt: time.Now(), + } + if err := db.Create(&session).Error; err != nil { + return nil, nil, err + } + + // Get package and merchant for result + var pkg model.Package + db.First(&pkg, packageID) + var merchant model.Merchant + db.First(&merchant, merchantID) + + // Create blind result + result := model.BlindResult{ + SessionID: session.ID, + UserID: userID, + PackageName: pkg.Name, + MerchantName: merchant.Name, + MerchantRating: merchant.Rating, + MatchScore: matchScore, + Description: pkg.Description, + PriceRange: priceRange, + ActualPrice: pkg.ActualPrice, + CreatedAt: time.Now(), + } + db.Create(&result) + + return &session, &result, nil +} + +// SubmitReview records a user's review after blind selection +func (s *BlindService) SubmitReview(userID, sessionID, packageID uint, + rating, taste, value, distance, match int8, tags []string, text string, isRepeat bool) error { + + db := database.GetDB() + + behavior := model.UserBehavior{ + UserID: userID, + PackageID: packageID, + BehaviorType: "reviewed", + ReviewRating: rating, + TasteScore: taste, + ValueScore: value, + DistanceScore: distance, + MatchScore: match, + FeedbackTags: formatTags(tags), + Text: text, + IsRepeat: isRepeat, + CreatedAt: time.Now(), + } + db.Create(&behavior) + + // Update package rating + s.updatePackageRating(db, packageID, int8(rating)) + + // Update user preference scores + s.updateUserPreference(db, userID, packageID, rating, isRepeat) + + // Update blind session + db.Model(&model.BlindSession{}).Where("id = ?", sessionID).Updates(map[string]interface{}{ + "accepted": true, + }) + + return nil +} + +// updatePackageRating recalculates package average rating +func (s *BlindService) updatePackageRating(db *gorm.DB, packageID uint, rating int8) { + db.Model(&model.Package{}).Where("id = ?", packageID). + Update("review_count", gorm.Expr("review_count + 1")) + + // Recalculate average + var avgRating float64 + db.Model(&model.UserBehavior{}).Where("package_id = ? AND review_rating > 0", packageID). + Pluck("AVG(review_rating)", &avgRating) + + db.Model(&model.Package{}).Where("id = ?", packageID).Update("rating", avgRating) +} + +// updateUserPreference updates the user's category preference scores based on review +func (s *BlindService) updateUserPreference(db *gorm.DB, userID, packageID uint, rating int8, isRepeat bool) { + var pkg model.Package + db.First(&pkg, packageID) + + category := getCategoryNameFromID(db, pkg.CategoryID) + if category == "" { + return + } + + // Get existing preference scores + var prefScores map[string]float64 + var user model.User + db.First(&user, userID) + // Parse pref_score JSONB + // In production, use proper JSON unmarshaling + + // Calculate new score + baseScore := 0.0 + switch { + case rating >= 4: + baseScore = 1.0 + case rating == 3: + baseScore = 0.3 + case rating == 2: + baseScore = -0.3 + default: + baseScore = -1.0 + } + + if isRepeat { + baseScore += 2.0 // Big bonus for revisiting + } + + // Update category preference + // In production: merge with existing scores, apply time decay + _ = prefScores + _ = category + _ = baseScore +} + +// GetReviewStats returns aggregate review statistics +func (s *BlindService) GetReviewStats() (map[string]interface{}, error) { + db := database.GetDB() + + var avgRating float64 + db.Model(&model.UserBehavior{}).Where("review_rating > 0").Pluck("AVG(review_rating)", &avgRating) + + var totalReviews int64 + db.Model(&model.UserBehavior{}).Where("review_rating > 0").Count(&totalReviews) + + // Count by rating + type RatingCount struct { + Rating int8 + Count int64 + } + var ratingCounts []RatingCount + db.Model(&model.UserBehavior{}).Where("review_rating > 0"). + Group("review_rating").Pluck("review_rating, COUNT(*)", &ratingCounts) + + ratingDist := make(map[int8]int64) + for _, rc := range ratingCounts { + ratingDist[rc.Rating] = rc.Count + } + + return map[string]interface{}{ + "avg_rating": avgRating, + "total_reviews": totalReviews, + "rating_dist": ratingDist, + }, nil +} + +func parsePriceRange(rangeStr string) (int, int) { + // Parse "50-100" -> 50, 100 + // Simplified: return defaults if parsing fails + return 0, 10000 +} + +func getCategoryNameFromID(db *gorm.DB, categoryID uint) string { + var cat model.Category + if err := db.First(&cat, categoryID).Error; err == nil { + return cat.Name + } + return "" +} + +func formatTags(tags []string) string { + if len(tags) == 0 { + return "[]" + } + return tagsToString(tags) +} + +func tagsToString(tags []string) string { + result := "[" + for i, t := range tags { + if i > 0 { + result += "," + } + result += `"` + t + `"` + } + result += "]" + return result +} diff --git a/backend/internal/service/member_service.go b/backend/internal/service/member_service.go new file mode 100644 index 0000000..a35f058 --- /dev/null +++ b/backend/internal/service/member_service.go @@ -0,0 +1,239 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/blind-select/backend/internal/database" + "github.com/blind-select/backend/internal/model" +) + +type MemberService struct{} + +func NewMemberService() *MemberService { + return &MemberService{} +} + +// Subscribe activates VIP membership for a user +func (s *MemberService) Subscribe(userID uint, planID uint, paymentMethod string) (*model.Member, error) { + db := database.GetDB() + + plan := getPlan(planID) + if plan == nil { + return nil, fmt.Errorf("invalid plan") + } + + // Cancel existing membership + db.Model(&model.Member{}).Where("user_id = ? AND status = ?", userID, "active"). + Update("status", "cancelled") + + // Create new membership + var startDate, endDate time.Time + var duration time.Duration + + switch plan.Period { + case "monthly": + duration = 30 * 24 * time.Hour + case "yearly": + duration = 365 * 24 * time.Hour + default: + duration = 30 * 24 * time.Hour + } + + startDate = time.Now() + endDate = startDate.Add(duration) + + member := model.Member{ + UserID: userID, + Level: 1, + StartDate: startDate, + EndDate: endDate, + PaymentMethod: paymentMethod, + Amount: plan.Price, + OrderNo: generateOrderNo(), + Status: "active", + } + + if err := db.Create(&member).Error; err != nil { + return nil, err + } + + // Update user member level and expiration + db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "member_level": 1, + "member_expires": endDate, + }) + + return &member, nil +} + +// CheckMembership checks if user has active membership +func (s *MemberService) CheckMembership(userID uint) (bool, *model.Member) { + db := database.GetDB() + var member model.Member + + if err := db.Where("user_id = ? AND status = ?", userID, "active").First(&member).Error; err != nil { + return false, nil + } + + if time.Now().After(member.EndDate) { + db.Model(&member).Update("status", "expired") + db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "member_level": 0, + }) + return false, nil + } + + return true, &member +} + +// GetPlan returns plan info +func (s *MemberService) GetPlan(planID uint) *MemberPlan { + return getPlan(planID) +} + +func getPlan(planID uint) *MemberPlan { + switch planID { + case 1: + return &MemberPlan{ID: 1, Name: "VIP月卡", Price: 29, Period: "monthly", BlindLimit: 10} + case 2: + return &MemberPlan{ID: 2, Name: "VIP年卡", Price: 199, Period: "yearly", BlindLimit: 10} + default: + return nil + } +} + +type MemberPlan struct { + ID uint `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Period string `json:"period"` + BlindLimit int `json:"blind_limit"` +} + +// ============== Coupon Service ============== + +type CouponService struct{} + +func NewCouponService() *CouponService { + return &CouponService{} +} + +// CreateCoupon creates a coupon for a merchant +func (s *CouponService) CreateCoupon(req CreateCouponReq) (*model.Coupon, error) { + db := database.GetDB() + + poolCode := generatePoolCode() + + coupon := model.Coupon{ + MerchantID: req.MerchantID, + PackageID: req.PackageID, + Type: req.Type, + Value: req.Value, + MinAmount: req.MinAmount, + TotalCount: req.TotalCount, + RemainCount: req.TotalCount, + PoolCode: poolCode, + Status: "available", + ValidStart: req.ValidStart, + ValidEnd: req.ValidEnd, + Commission: req.Commission, + } + + if err := db.Create(&coupon).Error; err != nil { + return nil, err + } + + return &coupon, nil +} + +type CreateCouponReq struct { + MerchantID uint `json:"merchant_id" binding:"required"` + PackageID uint `json:"package_id"` + Type string `json:"type" binding:"required"` // discount/coupon/free/gift + Value float64 `json:"value" binding:"required"` + MinAmount int `json:"min_amount"` + TotalCount int `json:"total_count" binding:"required"` + ValidStart time.Time `json:"valid_start" binding:"required"` + ValidEnd time.Time `json:"valid_end" binding:"required"` + Commission float64 `json:"commission"` +} + +// ClaimCoupon allows a user to claim a pool coupon +func (s *CouponService) ClaimCoupon(userID uint, poolCode string) (*model.Coupon, error) { + db := database.GetDB() + + var poolCoupon model.Coupon + if err := db.Where("pool_code = ? AND status = ? AND remain_count > 0", poolCode, "available").First(&poolCoupon).Error; err != nil { + return nil, err + } + + // Deduct count + poolCoupon.RemainCount-- + db.Save(&poolCoupon) + + // Create user-specific coupon + userCode := "UCP" + generateRandomString(8) + userCoupon := model.Coupon{ + MerchantID: poolCoupon.MerchantID, + PackageID: poolCoupon.PackageID, + UserID: &userID, + Type: poolCoupon.Type, + Value: poolCoupon.Value, + MinAmount: poolCoupon.MinAmount, + TotalCount: 1, + RemainCount: 1, + UserCode: userCode, + Status: "claimed", + ValidStart: poolCoupon.ValidStart, + ValidEnd: poolCoupon.ValidEnd, + Commission: poolCoupon.Commission, + } + + if err := db.Create(&userCoupon).Error; err != nil { + // Rollback pool count + poolCoupon.RemainCount++ + db.Save(&poolCoupon) + return nil, err + } + + return &userCoupon, nil +} + +// VerifyCoupon marks a coupon as used (merchant verification) +func (s *CouponService) VerifyCoupon(userCode string) error { + db := database.GetDB() + + var coupon model.Coupon + if err := db.Where("user_code = ? AND status = ?", userCode, "claimed").First(&coupon).Error; err != nil { + return err + } + + coupon.Status = "used" + coupon.UsedAt = ptrTime(time.Now()) + db.Save(&coupon) + + return nil +} + +func ptrTime(t time.Time) *time.Time { + return &t +} + +func generateOrderNo() string { + return "ORD" + time.Now().Format("20060102150405") + generateRandomString(4) +} + +func generatePoolCode() string { + b := make([]byte, 8) + rand.Read(b) + return "CP" + hex.EncodeToString(b) +} + +func generateRandomString(n int) string { + b := make([]byte, n/2) + rand.Read(b) + return hex.EncodeToString(b)[:n] +} diff --git a/backend/internal/service/wechat_service.go b/backend/internal/service/wechat_service.go new file mode 100644 index 0000000..99741a0 --- /dev/null +++ b/backend/internal/service/wechat_service.go @@ -0,0 +1,164 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/blind-select/backend/internal/database" + "github.com/blind-select/backend/internal/model" +) + +// WechatTokenResp is the response from wechat auth.code2Session +type WechatTokenResp struct { + OpenID string `json:"openid"` + SessionKey string `json:"session_key"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// WechatUserInfoResp for openid-only login (no phone) +type WechatUserInfoResp struct { + OpenID string `json:"openid"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + City string `json:"city"` + Province string `json:"province"` + Country string `json:"country"` + HeadImgUrl string `json:"headimgurl"` +} + +// WechatService handles WeChat OAuth login +type WechatService struct { + AppID string + AppSecret string +} + +func NewWechatService(appID, appSecret string) *WechatService { + return &WechatService{AppID: appID, AppSecret: appSecret} +} + +// Code2Session exchanges login code for OpenID +func (s *WechatService) Code2Session(code string) (*WechatTokenResp, error) { + url := fmt.Sprintf( + "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + s.AppID, s.AppSecret, code, + ) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("wechat api call failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read wechat response: %w", err) + } + + var result WechatTokenResp + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse wechat response: %w", err) + } + + if result.ErrCode != 0 { + return nil, fmt.Errorf("wechat error: %d - %s", result.ErrCode, result.ErrMsg) + } + + return &result, nil +} + +// LoginOrCreateUser handles WeChat login: find existing user or create new +func (s *WechatService) LoginOrCreateUser(code string) (*model.User, string, error) { + token, err := s.Code2Session(code) + if err != nil { + return nil, "", err + } + + db := database.GetDB() + + var user model.User + // Try to find by OpenID + if err := db.Where("wechat_openid = ?", token.OpenID).First(&user).Error; err != nil { + // User doesn't exist, create new + nickname := "微信用户" + avatar := "" + + // Try to get user info for better nickname/avatar + userInfo, infoErr := s.GetUserInfo(token.SessionKey) + if infoErr == nil { + nickname = userInfo.Nickname + avatar = userInfo.HeadImgUrl + } + + user = model.User{ + WechatOpenID: token.OpenID, + Nickname: nickname, + Avatar: avatar, + Tags: []string{}, + RepeatDays: 7, + MemberLevel: 0, + } + if err := db.Create(&user).Error; err != nil { + return nil, "", fmt.Errorf("create user: %w", err) + } + } + + return &user, token.SessionKey, nil +} + +// GetUserInfo fetches user profile (requires session_key) +func (s *WechatService) GetUserInfo(sessionKey string) (*WechatUserInfoResp, error) { + // Note: real implementation needs encrypted data from mini-program + // This is a placeholder for now + return &WechatUserInfoResp{Nickname: "微信用户"}, nil +} + +// GenerateUnionID returns the union ID if available (for multi-app ecosystem) +func (s *WechatService) GetUnionID(code string) (string, error) { + token, err := s.Code2Session(code) + if err != nil { + return "", err + } + return token.UnionID, nil +} + +// SendTemplateMessage sends a WeChat template message to user +func (s *WechatService) SendTemplateMessage(openID, templateID, data map[string]interface{}) error { + // Get access token + tokenURL := fmt.Sprintf( + "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", + s.AppID, s.AppSecret, + ) + resp, err := http.Get(tokenURL) + if err != nil { + return err + } + defer resp.Body.Close() + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return err + } + + // Build message payload + msg := map[string]interface{}{ + "touser": openID, + "template_id": templateID, + "data": data, + } + + payload, _ := json.Marshal(msg) + msgURL := fmt.Sprintf( + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s", + tokenResp.AccessToken, + ) + http.Post(msgURL, "application/json", strings.NewReader(string(payload))) + return nil +} diff --git a/backend/internal/utils/jwt.go b/backend/internal/utils/jwt.go new file mode 100644 index 0000000..676a319 --- /dev/null +++ b/backend/internal/utils/jwt.go @@ -0,0 +1,61 @@ +package utils + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateToken(userID uint, username, role string, secret string) (string, error) { + expire := time.Now().Add(24 * time.Hour) + + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expire), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "blind-select", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +func GenerateRefreshToken(userID uint, secret string) (string, error) { + expire := time.Now().Add(7 * 24 * time.Hour) + + claims := jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expire), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "blind-select-refresh", + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +func ParseToken(tokenString, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} diff --git a/backend/internal/utils/password.go b/backend/internal/utils/password.go new file mode 100644 index 0000000..b9da252 --- /dev/null +++ b/backend/internal/utils/password.go @@ -0,0 +1,17 @@ +package utils + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashPassword hashes a password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword compares a password with a hash +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql new file mode 100644 index 0000000..f449167 --- /dev/null +++ b/backend/migrations/001_initial_schema.sql @@ -0,0 +1,196 @@ +-- Initial schema for blind-select + +-- Categories +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + icon VARCHAR(50), + type VARCHAR(50) NOT NULL, -- food/entertainment/shopping/activity + sort INT DEFAULT 0, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +); + +-- Admins +CREATE TABLE IF NOT EXISTS admins ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'editor', -- admin/editor/viewer + perm_json TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + nickname VARCHAR(100), + avatar VARCHAR(255), + phone VARCHAR(20) UNIQUE, + wechat_openid VARCHAR(100) UNIQUE, + password_hash VARCHAR(255), + pref_score JSONB, + tags TEXT[], + repeat_days INT DEFAULT 7, + blacklist BIGINT[], + member_level INT DEFAULT 0, + member_expires TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- Merchants +CREATE TABLE IF NOT EXISTS merchants ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + avatar VARCHAR(255), + category_id INT, + category_name VARCHAR(100), + rating FLOAT8 DEFAULT 0, + price_range VARCHAR(50), + location VARCHAR(100), + lat FLOAT8, + lng FLOAT8, + tags TEXT[], + commission FLOAT8 DEFAULT 0.1, + total_reviews INT DEFAULT 0, + total_score FLOAT8 DEFAULT 0, + quality_score FLOAT8 DEFAULT 0.5, + status VARCHAR(20) DEFAULT 'pending', + description TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- Packages +CREATE TABLE IF NOT EXISTS packages ( + id SERIAL PRIMARY KEY, + merchant_id INT NOT NULL, + category_id INT, + name VARCHAR(200) NOT NULL, + description TEXT, + price_min INT, + price_max INT, + actual_price INT, + tags TEXT[], + stock INT DEFAULT 9999, + weight FLOAT8 DEFAULT 1.0, + rating FLOAT8 DEFAULT 0, + review_count INT DEFAULT 0, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- User behaviors (includes review scores) +CREATE TABLE IF NOT EXISTS user_behaviors ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + package_id INT NOT NULL, + behavior_type VARCHAR(50), -- viewed/selected/attended/reviewed/skipped + system_score FLOAT8 DEFAULT 0, + review_rating INT8, + taste_score INT8, + value_score INT8, + distance_score INT8, + match_score INT8, + feedback_tags TEXT[], + text TEXT, + is_repeat BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Blind sessions +CREATE TABLE IF NOT EXISTS blind_sessions ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + category_id INT, + price_range VARCHAR(50), + distance_range VARCHAR(50), + result_package_id INT, + revealed_at TIMESTAMP, + accepted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Blind results (revealed details) +CREATE TABLE IF NOT EXISTS blind_results ( + session_id INT PRIMARY KEY, + user_id INT NOT NULL, + package_name VARCHAR(200), + merchant_name VARCHAR(200), + merchant_rating FLOAT8, + match_score FLOAT8, + description TEXT, + price_range VARCHAR(50), + actual_price INT, + has_coupon BOOLEAN DEFAULT FALSE, + coupon_value VARCHAR(100), + matched_tags TEXT[], + created_at TIMESTAMP DEFAULT NOW() +); + +-- Coupons +CREATE TABLE IF NOT EXISTS coupons ( + id SERIAL PRIMARY KEY, + merchant_id INT NOT NULL, + package_id INT, + user_id INT, + type VARCHAR(20), -- discount/coupon/free/gift + value FLOAT8, + min_amount INT, + total_count INT DEFAULT 100, + remain_count INT DEFAULT 100, + user_code VARCHAR(50) UNIQUE, + pool_code VARCHAR(50), + status VARCHAR(20) DEFAULT 'available', -- available/claimed/used/expired + used_at TIMESTAMP, + valid_start TIMESTAMP, + valid_end TIMESTAMP, + commission FLOAT8 DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- Members +CREATE TABLE IF NOT EXISTS members ( + id SERIAL PRIMARY KEY, + user_id INT, + level INT DEFAULT 0, + start_date TIMESTAMP, + end_date TIMESTAMP, + payment_method VARCHAR(50), + amount FLOAT8, + order_no VARCHAR(100) UNIQUE, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- Activities +CREATE TABLE IF NOT EXISTS activities ( + id SERIAL PRIMARY KEY, + name VARCHAR(200), + type VARCHAR(50), + start TIMESTAMP, + end TIMESTAMP, + config_json TEXT, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_users_phone ON users(phone); +CREATE INDEX idx_users_wechat ON users(wechat_openid); +CREATE INDEX idx_merchants_status ON merchants(status); +CREATE INDEX idx_packages_merchant ON packages(merchant_id); +CREATE INDEX idx_packages_status ON packages(status); +CREATE INDEX idx_behaviors_user ON user_behaviors(user_id); +CREATE INDEX idx_blind_user ON blind_sessions(user_id); +CREATE INDEX idx_coupons_status ON coupons(status); diff --git a/backend/migrations/002_seed_data.sql b/backend/migrations/002_seed_data.sql new file mode 100644 index 0000000..e96f899 --- /dev/null +++ b/backend/migrations/002_seed_data.sql @@ -0,0 +1,45 @@ +-- Seed data for blind-select + +-- Insert categories +INSERT INTO categories (name, icon, type, sort) VALUES +('中餐', '🍜', 'food', 1), +('日料', '🍣', 'food', 2), +('西餐', '🥩', 'food', 3), +('甜品', '🍰', 'food', 4), +('火锅', '🍲', 'food', 5), +('KTV', '🎤', 'entertainment', 6), +('剧本杀', '🔍', 'entertainment', 7), +('SPA/按摩', '💆', 'entertainment', 8), +('运动', '⚽', 'entertainment', 9), +('盲盒', '🎁', 'shopping', 10), +('演出', '🎭', 'activity', 11), +('展览', '🖼️', 'activity', 12); + +-- Insert admin (password: admin123) +INSERT INTO admins (username, password, role) VALUES +('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'admin'); + +-- Insert sample merchants +INSERT INTO merchants (name, avatar, category_id, category_name, rating, price_range, location, lat, lng, tags, description, status) VALUES +('松阪牛料理', '/avatar1.jpg', 2, '日料', 4.8, '200-400', '朝阳区建国路88号', 39.9042, 116.4074, '["日料","高品质","约会"]', '正宗日式 Omakase,主厨有15年东京银座经验', 'approved'), +('渝味晓宇火锅', '/avatar2.jpg', 5, '火锅', 4.5, '100-200', '朝阳区三里屯路19号', 39.9336, 116.4530, '["火锅","麻辣","聚餐"]', '重庆老火锅底料,现炒底料', 'approved'), +('Bistro L''Atelier', '/avatar3.jpg', 3, '西餐', 4.6, '300-500', '朝阳区亮马河路43号', 39.9489, 116.4675, '["西餐","浪漫","情侣"]', '法意融合菜,米其林推荐餐厅', 'approved'), +('喜茶', '/avatar4.jpg', 4, '甜品', 4.3, '20-40', '朝阳区万达广场', 39.8967, 116.4120, '["甜品","奶茶","轻食"]', '人气新茶饮品牌', 'approved'), +('迷雾剧场剧本杀', '/avatar5.jpg', 7, '剧本杀', 4.7, '80-150', '朝阳区工体北路8号', 39.9320, 116.4460, '["剧本杀","聚会","推理"]', '沉浸式实景剧本杀,50+剧本可选', 'approved'), +('纯K量贩KTV', '/avatar6.jpg', 6, 'KTV', 4.2, '50-100', '朝阳区建国路93号', 39.9070, 116.4750, '["KTV","唱歌","派对"]', '高端量贩KTV,新鲜果盘', 'approved'), +('花间堂SPA', '/avatar7.jpg', 8, 'SPA/按摩', 4.9, '200-400', '朝阳区亮马桥路2号', 39.9510, 116.4600, '["SPA","放松","情侣"]', '日式庭园风格SPA,泰式精油按摩', 'approved'), +('乐刻健身', '/avatar8.jpg', 9, '运动', 4.1, '30-80', '朝阳区大望路', 39.8980, 116.4720, '["运动","健身","自助"]', '24小时自助健身,私教课程', 'approved'); + +-- Insert sample packages +INSERT INTO packages (merchant_id, category_id, name, description, price_min, price_max, actual_price, tags, status) VALUES +(1, 2, '主厨精选7道式', ' seasonal食材,当日最新鲜海产', 298, 398, 358, '["omakase","日料","高品质","约会"]', 'active'), +(1, 2, '人气套餐2-3人', '包含招牌刺身拼盘、烤鳗鱼、寿司等', 598, 798, 698, '["日料","套餐","聚餐"]', 'active'), +(2, 5, '双人麻辣火锅套餐', '含精选肉类拼盘、时蔬、底料', 168, 228, 198, '["火锅","双人","麻辣"]', 'active'), +(2, 5, '4-6人欢聚套餐', '含毛肚三脆、肥牛、虾滑及甜品', 328, 428, 388, '["火锅","多人","聚餐"]', 'active'), +(3, 3, '情侣浪漫晚餐', '3道式套餐,含红酒一杯', 398, 498, 458, '["西餐","情侣","浪漫"]', 'active'), +(3, 3, '商务套餐', '5道式,含前菜、主菜、甜品', 498, 598, 558, '["西餐","商务","高端"]', 'active'), +(4, 4, '四季新品奶茶套装', '当季限定口味组合', 35, 45, 39, '["奶茶","甜品","新品"]', 'active'), +(5, 7, '沉浸式推理剧本', '4-6人本,含道具和服装', 128, 168, 148, '["剧本杀","推理","聚会"]', 'active'), +(6, 6, '商务KTV欢唱套餐', '4小时+果盘+饮料', 88, 128, 98, '["KTV","商务","唱歌"]', 'active'), +(7, 8, '泰式精油SPA体验', '60分钟泰式传统按摩', 198, 298, 238, '["SPA","放松","泰式"]', 'active'), +(8, 9, '周卡健身通票', '7天不限时畅练', 68, 88, 78, '["健身","周卡","运动"]', 'active'); diff --git a/frontend-admin/Dockerfile b/frontend-admin/Dockerfile new file mode 100644 index 0000000..351db04 --- /dev/null +++ b/frontend-admin/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY frontend-admin/package.json frontend-admin/package-lock.json* ./ +RUN npm install --frozen-lockfile 2>/dev/null || npm install +COPY frontend-admin . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +# Proxy API requests to backend +RUN echo 'server { \ + listen 80; \ + location / { \ + root /usr/share/nginx/html; \ + try_files $uri $uri/ /index.html; \ + } \ + location /api/ { \ + proxy_pass http://api:8080; \ + proxy_set_header Host $host; \ + proxy_set_header X-Real-IP $remote_addr; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend-admin/index.html b/frontend-admin/index.html new file mode 100644 index 0000000..b103f9a --- /dev/null +++ b/frontend-admin/index.html @@ -0,0 +1,30 @@ + + + + + + 帮我选 - 后台管理 + + + +
+ +
+
+
+

🎲

+

帮我选

+

后台管理系统

+ + +
+ + + +
+
+
+
+
+ + diff --git a/frontend-admin/package.json b/frontend-admin/package.json new file mode 100644 index 0000000..f55a741 --- /dev/null +++ b/frontend-admin/package.json @@ -0,0 +1,26 @@ +{ + "name": "blind-select-admin", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "axios": "^1.7.0", + "pinia": "^2.2.0", + "alpinejs": "^3.14.0", + "chart.js": "^4.4.0", + "vue-chartjs": "^5.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^5.4.0", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.0", + "autoprefixer": "^10.4.0", + "daisyui": "^4.12.0" + } +} diff --git a/frontend-admin/postcss.config.js b/frontend-admin/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/frontend-admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend-admin/src/App.vue b/frontend-admin/src/App.vue new file mode 100644 index 0000000..befbfb5 --- /dev/null +++ b/frontend-admin/src/App.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend-admin/src/api/index.js b/frontend-admin/src/api/index.js new file mode 100644 index 0000000..6510f4a --- /dev/null +++ b/frontend-admin/src/api/index.js @@ -0,0 +1,65 @@ +const BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080/api/v1' + +const token = () => localStorage.getItem('admin_token') || '' + +async function request(url, opts = {}) { + const headers = { 'Content-Type': 'application/json' } + if (token()) headers['Authorization'] = `Bearer ${token()}` + const res = await fetch(BASE + url, { ...opts, headers: { ...headers, ...opts.headers } }) + const data = await res.json() + if (!res.ok) throw new Error(data.message || res.statusText) + return data.data ?? data +} + +// ── Auth ── +export const authApi = { + login: (body) => request('/admin/login', { method: 'POST', body: JSON.stringify(body) }), +} + +// ── Dashboard ── +export const dashboardApi = { + stats: () => request('/admin/dashboard'), + trends: (days = 30) => request(`/admin/dashboard/trends?days=${days}`), +} + +// ── Merchants ── +export const merchantApi = { + list: (page = 1, size = 20) => request(`/admin/merchants?page=${page}&size=${size}`), + detail: (id) => request(`/admin/merchants/${id}`), + create: (body) => request('/admin/merchants', { method: 'POST', body: JSON.stringify(body) }), + update: (id, body) => request(`/admin/merchants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + toggle: (id) => request(`/admin/merchants/${id}/toggle`, { method: 'POST' }), +} + +// ── Packages ── +export const packageApi = { + list: (page = 1, size = 20) => request(`/admin/packages?page=${page}&size=${size}`), + create: (body) => request('/admin/packages', { method: 'POST', body: JSON.stringify(body) }), + update: (id, body) => request(`/admin/packages/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + toggle: (id) => request(`/admin/packages/${id}/toggle`, { method: 'POST' }), +} + +// ── Coupons ── +export const couponApi = { + list: (page = 1, size = 20) => request(`/admin/coupons?page=${page}&size=${size}`), + create: (body) => request('/admin/coupons', { method: 'POST', body: JSON.stringify(body) }), + toggle: (id) => request(`/admin/coupons/${id}/toggle`, { method: 'POST' }), +} + +// ── Users ── +export const userApi = { + list: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`), + detail: (id) => request(`/admin/users/${id}`), +} + +// ── Reviews ── +export const reviewApi = { + list: (page = 1, size = 20) => request(`/admin/reviews?page=${page}&size=${size}`), + stats: () => request('/admin/reviews/stats'), +} + +// ── Statistics ── +export const statsApi = { + overview: () => request('/admin/statistics'), + export: (type) => request(`/admin/export?type=${type}`), +} diff --git a/frontend-admin/src/main.js b/frontend-admin/src/main.js new file mode 100644 index 0000000..1ba114e --- /dev/null +++ b/frontend-admin/src/main.js @@ -0,0 +1,64 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/dashboard' }, + { + path: '/login', + component: () => import('./views/Login.vue'), + }, + { + path: '/dashboard', + component: () => import('./views/Dashboard.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/merchants', + component: () => import('./views/Merchants.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/packages', + component: () => import('./views/Packages.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/coupons', + component: () => import('./views/Coupons.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/users', + component: () => import('./views/Users.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/reviews', + component: () => import('./views/Reviews.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/statistics', + component: () => import('./views/Statistics.vue'), + meta: { requiresAuth: true }, + }, + ] +}) + +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('admin_token') + if (to.meta.requiresAuth && !token) { + next('/login') + } else { + next() + } +}) + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend-admin/src/router/index.js b/frontend-admin/src/router/index.js new file mode 100644 index 0000000..f1d53db --- /dev/null +++ b/frontend-admin/src/router/index.js @@ -0,0 +1,32 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/login', name: 'Login', component: () => import('../views/Login.vue') }, + { + path: '/', + component: () => import('../App.vue'), + children: [ + { path: '', redirect: '/dashboard' }, + { path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') }, + { path: 'merchants', name: 'Merchants', component: () => import('../views/Merchants.vue') }, + { path: 'packages', name: 'Packages', component: () => import('../views/Packages.vue') }, + { path: 'coupons', name: 'Coupons', component: () => import('../views/Coupons.vue') }, + { path: 'users', name: 'Users', component: () => import('../views/Users.vue') }, + { path: 'reviews', name: 'Reviews', component: () => import('../views/Reviews.vue') }, + { path: 'statistics', name: 'Statistics', component: () => import('../views/Statistics.vue') }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('admin_token') + if (to.name !== 'Login' && !token) next({ name: 'Login' }) + else next() +}) + +export default router diff --git a/frontend-admin/src/style.css b/frontend-admin/src/style.css new file mode 100644 index 0000000..7316b6e --- /dev/null +++ b/frontend-admin/src/style.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; +} diff --git a/frontend-admin/src/views/Coupons.vue b/frontend-admin/src/views/Coupons.vue new file mode 100644 index 0000000..6bd9b28 --- /dev/null +++ b/frontend-admin/src/views/Coupons.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend-admin/src/views/Dashboard.vue b/frontend-admin/src/views/Dashboard.vue new file mode 100644 index 0000000..74d41e8 --- /dev/null +++ b/frontend-admin/src/views/Dashboard.vue @@ -0,0 +1,141 @@ + + + diff --git a/frontend-admin/src/views/Login.vue b/frontend-admin/src/views/Login.vue new file mode 100644 index 0000000..3257e12 --- /dev/null +++ b/frontend-admin/src/views/Login.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend-admin/src/views/Merchants.vue b/frontend-admin/src/views/Merchants.vue new file mode 100644 index 0000000..6cee570 --- /dev/null +++ b/frontend-admin/src/views/Merchants.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend-admin/src/views/Packages.vue b/frontend-admin/src/views/Packages.vue new file mode 100644 index 0000000..324b631 --- /dev/null +++ b/frontend-admin/src/views/Packages.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend-admin/src/views/Reviews.vue b/frontend-admin/src/views/Reviews.vue new file mode 100644 index 0000000..1c29318 --- /dev/null +++ b/frontend-admin/src/views/Reviews.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend-admin/src/views/Statistics.vue b/frontend-admin/src/views/Statistics.vue new file mode 100644 index 0000000..f264ec7 --- /dev/null +++ b/frontend-admin/src/views/Statistics.vue @@ -0,0 +1,240 @@ + + + diff --git a/frontend-admin/src/views/Users.vue b/frontend-admin/src/views/Users.vue new file mode 100644 index 0000000..e910763 --- /dev/null +++ b/frontend-admin/src/views/Users.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend-admin/tailwind.config.js b/frontend-admin/tailwind.config.js new file mode 100644 index 0000000..ee9d103 --- /dev/null +++ b/frontend-admin/tailwind.config.js @@ -0,0 +1,34 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./index.html', './src/**/*.{vue,js,ts}'], + theme: { + extend: { + colors: { + primary: '#FF6B35', + secondary: '#FF8C42', + } + } + }, + plugins: [ + require('daisyui') + ], + daisyui: { + themes: [ + { + blind: { + "primary": "#FF6B35", + "secondary": "#FF8C42", + "accent": "#667eea", + "neutral": "#1a1a2e", + "base-100": "#ffffff", + "base-200": "#f5f5f5", + "base-300": "#e8e8e8", + "info": "#2196f3", + "success": "#4caf50", + "warning": "#ff9800", + "error": "#f44336", + } + } + ] + } +} diff --git a/frontend-admin/vite.config.js b/frontend-admin/vite.config.js new file mode 100644 index 0000000..6541fba --- /dev/null +++ b/frontend-admin/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}) diff --git a/frontend-app/App.vue b/frontend-app/App.vue new file mode 100644 index 0000000..9118cdf --- /dev/null +++ b/frontend-app/App.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend-app/api/index.js b/frontend-app/api/index.js new file mode 100644 index 0000000..6c5e0ce --- /dev/null +++ b/frontend-app/api/index.js @@ -0,0 +1,122 @@ +// API 配置 +// 开发环境: 指向本地后端 +// 小程序生产: 指向你的 HTTPS 域名 + +// #ifdef MP-WEIXIN +// 微信小程序环境 → 生产域名 (替换为你自己的域名) +const BASE_URL = 'https://api.yourdomain.com/api/v1' +// #endif + +// #ifdef H5 +// H5 环境 → 本地或生产 +const BASE_URL = 'http://localhost:8080/api/v1' +// #endif + +// #ifndef MP-WEIXIN && !H5 +// 其他平台 (APP等) +const BASE_URL = 'http://localhost:8080/api/v1' +// #endif + +// 请求封装 +function request(url, method = 'GET', data = null) { + const token = uni.getStorageSync('token') + return new Promise((resolve, reject) => { + uni.request({ + url: BASE_URL + url, + method, + data, + header: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + }, + success: (res) => { + if (res.statusCode === 200 && res.data.code === 2) { + resolve(res.data.data) + } else if (res.statusCode === 401) { + // Token 过期 → 尝试刷新 + refreshAndRetry(url, method, data).then(resolve).catch(reject) + } else { + uni.showToast({ + title: res.data.message || '请求失败', + icon: 'none', + duration: 1500 + }) + reject(new Error(res.data.message || 'request failed')) + } + }, + fail: (err) => { + uni.showToast({ title: '网络错误', icon: 'none' }) + reject(err) + } + }) + }) +} + +// Token 刷新后重试 +async function refreshAndRetry(url, method, data) { + const refreshToken = uni.getStorageSync('refresh_token') + if (!refreshToken) { + // 没有 refresh token → 跳转登录 + uni.removeStorageSync('token') + uni.removeStorageSync('userInfo') + uni.showToast({ title: '请重新登录', icon: 'none' }) + uni.reLaunch({ url: '/pages/login/login' }) + throw new Error('unauthorized') + } + + try { + const authApi = (await import('@/api/index.js')).authApi + const res = await authApi.refreshToken({ refresh_token: refreshToken }) + + uni.setStorageSync('token', res.token) + + // 重试原请求 + return request(url, method, data) + } catch (e) { + // 刷新也失败 → 跳转登录 + uni.removeStorageSync('token') + uni.removeStorageSync('refresh_token') + uni.removeStorageSync('userInfo') + uni.reLaunch({ url: '/pages/login/login' }) + throw e + } +} + +// ============== Auth ============== +export const authApi = { + register: (data) => request('/auth/register', 'POST', data), + login: (data) => request('/auth/login', 'POST', data), + wechatLogin: (data) => request('/auth/wechat/login', 'POST', data), + refreshToken: (data) => request('/auth/refresh', 'POST', data), + getProfile: () => request('/user/profile', 'GET'), + updateProfile: (data) => request('/user/profile', 'PUT', data), +} + +// ============== Blind Selection ============== +export const blindApi = { + getCategories: () => request('/blind/categories', 'GET'), + getPool: (params) => request('/blind/pool?' + new URLSearchParams(params).toString(), 'GET'), + choose: (data) => request('/blind/choose', 'POST', data), + getResult: (id) => request(`/blind/result/${id}`, 'GET'), + getHistory: () => request('/blind/history', 'GET'), +} + +// ============== Review ============== +export const reviewApi = { + submit: (data) => request('/review/submit', 'POST', data), + getStats: () => request('/review/stats', 'GET'), +} + +// ============== Coupon ============== +export const couponApi = { + getMyCoupons: () => request('/coupon/list', 'GET'), + getAvailable: () => request('/coupon/available', 'GET'), + claim: (data) => request('/coupon/claim', 'POST', data), +} + +// ============== Member ============== +export const memberApi = { + getStatus: () => request('/member/status', 'GET'), + getPlans: () => request('/member/plans', 'GET'), + subscribe: (data) => request('/member/subscribe', 'POST', data), +} diff --git a/frontend-app/main.js b/frontend-app/main.js new file mode 100644 index 0000000..51dc280 --- /dev/null +++ b/frontend-app/main.js @@ -0,0 +1,12 @@ +import { createSSRApp } from 'vue' +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import App from './App.vue' + +export function createApp() { + const app = createSSRApp(App) + const pinia = createPinia() + pinia.use(piniaPluginPersistedstate) + app.use(pinia) + return { app } +} diff --git a/frontend-app/manifest.json b/frontend-app/manifest.json new file mode 100644 index 0000000..0f55b64 --- /dev/null +++ b/frontend-app/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "帮我选应用", + "name": "帮我选", + "appid": "", + "distribute": { + "icons": { + "android": { + "hdpi": "static/icons/hdpi.png", + "xhdpi": "static/icons/xhdpi.png", + "xxhdpi": "static/icons/xxhdpi.png" + } + }, + "splash": { + "type": "wdp" + } + } +} diff --git a/frontend-app/package.json b/frontend-app/package.json new file mode 100644 index 0000000..b120d62 --- /dev/null +++ b/frontend-app/package.json @@ -0,0 +1,28 @@ +{ + "name": "blind-select-app", + "version": "1.0.0", + "description": "帮我选 - 盲选应用", + "dcloud": { + "appid": "__UNI__BLIND_SELECT", + "packages": { + "app-plus": {}, + "mp-weixin": {}, + "h5": {} + } + }, + "dependencies": { + "vue": "^3.4.0", + "@dcloudio/uni-app": "^3.0.0", + "@dcloudio/uni-ui": "^1.5.0", + "pinia": "^2.2.0", + "pinia-plugin-persistedstate": "^3.2.0" + }, + "devDependencies": { + "@dcloudio/types": "^3.0.0", + "@dcloudio/uni-automator": "^3.0.0", + "@dcloudio/uni-cli-shared": "^3.0.0", + "@dcloudio/vite-plugin-uni": "^3.0.0", + "sass": "^1.77.0", + "sass-loader": "^14.2.0" + } +} diff --git a/frontend-app/pages.json b/frontend-app/pages.json new file mode 100644 index 0000000..c25776c --- /dev/null +++ b/frontend-app/pages.json @@ -0,0 +1,125 @@ +{ + "easycom": { + "autoscan": true, + "custom": { + "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue" + } + }, + "pages": [ + { + "path": "pages/login/login", + "style": { "navigationBarTitleText": "登录" } + }, + { + "path": "pages/index/index", + "style": { "navigationBarTitleText": "帮我选", "navigationStyle": "custom" } + }, + { + "path": "pages/blind/blind", + "style": { "navigationBarTitleText": "开始盲选" } + }, + { + "path": "pages/blind/result", + "style": { "navigationBarTitleText": "揭晓结果" } + }, + { + "path": "pages/blind/review", + "style": { "navigationBarTitleText": "打分评价" } + }, + { + "path": "pages/coupon/list", + "style": { "navigationBarTitleText": "我的优惠券" } + }, + { + "path": "pages/member/member", + "style": { "navigationBarTitleText": "会员中心" } + }, + { + "path": "pages/user/profile", + "style": { "navigationBarTitleText": "个人中心" } + }, + { + "path": "pages/user/preferences", + "style": { "navigationBarTitleText": "我的偏好" } + }, + { + "path": "pages/user/history", + "style": { "navigationBarTitleText": "盲选记录" } + } + ], + "globalStyle": { + "navigationBarTextStyle": "white", + "navigationBarTitleText": "帮我选", + "navigationBarBackgroundColor": "#FF6B35", + "backgroundColor": "#F8F8F8", + "backgroundTextStyle": "light" + }, + "tabBar": { + "color": "#999999", + "selectedColor": "#FF6B35", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "static/tab-home.png", + "selectedIconPath": "static/tab-home-active.png" + }, + { + "pagePath": "pages/coupon/list", + "text": "优惠券", + "iconPath": "static/tab-coupon.png", + "selectedIconPath": "static/tab-coupon-active.png" + }, + { + "pagePath": "pages/member/member", + "text": "会员", + "iconPath": "static/tab-vip.png", + "selectedIconPath": "static/tab-vip-active.png" + }, + { + "pagePath": "pages/user/profile", + "text": "我的", + "iconPath": "static/tab-user.png", + "selectedIconPath": "static/tab-user-active.png" + } + ] + }, + "permission": { + "scope.userLocation": { + "desc": "用于计算推荐商家距离" + } + }, + "mp-weixin": { + "appid": "YOUR_WECHAT_APPID", + "setting": { + "urlCheck": false, + "es6": true, + "postcss": true, + "minified": true + }, + "usingComponents": true, + "optimization": { + "subPackages": true + }, + "permission": { + "scope.userLocation": { + "desc": "用于计算推荐商家距离" + } + }, + "requiredPrivateInfos": [ + "getLocation", + "chooseAddress" + ] + }, + "h5": { + "devServer": { + "port": 3000 + }, + "title": "帮我选", + "router": { + "mode": "hash" + } + } +} diff --git a/frontend-app/pages/blind/blind.vue b/frontend-app/pages/blind/blind.vue new file mode 100644 index 0000000..109ffb1 --- /dev/null +++ b/frontend-app/pages/blind/blind.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/frontend-app/pages/blind/result.vue b/frontend-app/pages/blind/result.vue new file mode 100644 index 0000000..a86fedb --- /dev/null +++ b/frontend-app/pages/blind/result.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend-app/pages/blind/review.vue b/frontend-app/pages/blind/review.vue new file mode 100644 index 0000000..bce8452 --- /dev/null +++ b/frontend-app/pages/blind/review.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend-app/pages/coupon/list.vue b/frontend-app/pages/coupon/list.vue new file mode 100644 index 0000000..bda7bde --- /dev/null +++ b/frontend-app/pages/coupon/list.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend-app/pages/index/index.vue b/frontend-app/pages/index/index.vue new file mode 100644 index 0000000..d343e80 --- /dev/null +++ b/frontend-app/pages/index/index.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend-app/pages/login/login.vue b/frontend-app/pages/login/login.vue new file mode 100644 index 0000000..3e2a4dd --- /dev/null +++ b/frontend-app/pages/login/login.vue @@ -0,0 +1,214 @@ + + + + + + diff --git a/frontend-app/pages/member/member.vue b/frontend-app/pages/member/member.vue new file mode 100644 index 0000000..294c9f3 --- /dev/null +++ b/frontend-app/pages/member/member.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend-app/pages/user/history.vue b/frontend-app/pages/user/history.vue new file mode 100644 index 0000000..2f41cc8 --- /dev/null +++ b/frontend-app/pages/user/history.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend-app/pages/user/preferences.vue b/frontend-app/pages/user/preferences.vue new file mode 100644 index 0000000..2f90e65 --- /dev/null +++ b/frontend-app/pages/user/preferences.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/frontend-app/pages/user/profile.vue b/frontend-app/pages/user/profile.vue new file mode 100644 index 0000000..0e9c444 --- /dev/null +++ b/frontend-app/pages/user/profile.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend-app/store/user.js b/frontend-app/store/user.js new file mode 100644 index 0000000..e224a7f --- /dev/null +++ b/frontend-app/store/user.js @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia' + +export const useUserStore = defineStore('user', { + state: () => ({ + token: uni.getStorageSync('token') || '', + userInfo: JSON.parse(uni.getStorageSync('userInfo') || '{}'), + hasMember: false, + }), + getters: { + isLoggedIn: (state) => !!state.token, + isMember: (state) => state.hasMember, + }, + actions: { + setToken(token) { + this.token = token + uni.setStorageSync('token', token) + }, + setUserInfo(info) { + this.userInfo = info + uni.setStorageSync('userInfo', JSON.stringify(info)) + }, + setHasMember(hasMember) { + this.hasMember = hasMember + }, + logout() { + this.token = '' + this.userInfo = {} + this.hasMember = false + uni.removeStorageSync('token') + uni.removeStorageSync('userInfo') + } + }, + persist: true, +}) diff --git a/frontend-app/vite.config.js b/frontend-app/vite.config.js new file mode 100644 index 0000000..8c3ccf9 --- /dev/null +++ b/frontend-app/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from '@dcloudio/vite-plugin-uni' + +export default defineConfig({ + build: { + rollupOptions: { + output: { + manualChunks: { + 'vue-vendor': ['vue', 'pinia'], + } + } + } + } +}) diff --git a/frontend-app/wechat-deploy.sh b/frontend-app/wechat-deploy.sh new file mode 100644 index 0000000..1cc4745 --- /dev/null +++ b/frontend-app/wechat-deploy.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# ===== 微信小程序上线 Checklist ===== +# 使用此文件作为上线前的核对清单 + +echo "🎲 微信小程序上线 Checklist" +echo "=============================" +echo "" + +echo "1️⃣ 小程序后台配置" +echo " - 登录 https://mp.weixin.qq.com" +echo " - 获取 AppID 和 AppSecret" +echo " - 配置 request 域名: https://your-domain.com (生产环境)" +echo " - 配置 uploadFile 域名 (如需上传头像)" +echo " - 配置 socket 域名 (如需实时通知)" +echo "" + +echo "2️⃣ 代码配置" +echo " - 编辑 frontend-app/pages.json:" +echo " 'mp-weixin.appid' → 填入你的 AppID" +echo "" +echo " - 编辑 backend/config.yaml:" +echo " wechat.app_id → 填入小程序 AppID" +echo " wechat.app_secret → 填入小程序 AppSecret" +echo "" +echo " - 编辑 backend/config.yaml:" +echo " ai.key → 填入完整的 AI API Key" +echo "" + +echo "3️⃣ TabBar 图标" +echo " - 在 frontend-app/static/ 下放置以下图标 (64x64 PNG):" +echo " tab-home.png / tab-home-active.png" +echo " tab-coupon.png / tab-coupon-active.png" +echo " tab-vip.png / tab-vip-active.png" +echo " tab-user.png / tab-user-active.png" +echo " - 或使用 HBuilderX 自带的图标替换" +echo "" + +echo "4️⃣ 域名配置" +echo " 后端API域名 → 后台配置为 request 合法域名" +echo " 例: https://api.yourdomain.com" +echo "" + +echo "5️⃣ 微信登录流程" +echo " 小程序启动 → onShow() 检查 token" +echo " - 有 token → 进首页" +echo " - 无 token → 进登录页" +echo " 登录页 → wx.login() → 获取 code → POST /api/v1/auth/wechat/login" +echo " 后端 → code2session → 查/建用户 → 返回 JWT" +echo " 前端 → 保存 token → 进首页" +echo "" + +echo "6️⃣ 版本管理" +echo " - 开发: HBuilderX 运行 → 微信小程序 → 扫码预览" +echo " - 调试: HBuilderX → 编译 → 微信小程序 → 添加调试" +echo " - 上传: HBuilderX → 发行 → 微信小程序" +echo " - 审核: 微信后台 → 版本管理 → 提交审核" +echo " - 上线: 审核通过 → 发布" +echo "" + +echo "7️⃣ 隐私合规" +echo " - 配置隐私协议 URL (微信后台)" +echo " - 权限声明: scope.userLocation (定位)" +echo " - requiredPrivateInfos: getLocation" +echo "" + +echo "✅ 检查完成!"