# 保质期提醒小程序 - 开发指南 本文档提供技术实现细节和开发规范。 --- ## 1. 技术架构 ### 1.1 整体架构 ``` ┌─────────────────┐ │ 微信小程序端 │ │ (原生开发) │ └────────┬────────┘ │ HTTPS │ JWT Token ┌────────▼────────┐ │ Gin Router │ │ (路由层) │ └────────┬────────┘ │ ┌────────▼────────┐ │ Handler │ │ (控制器层) │ └────────┬────────┘ │ ┌────────▼────────┐ │ Service │ │ (业务逻辑层) │ └────────┬────────┘ │ ┌────────▼────────┐ │ Repository │ │ (数据访问层) │ └────────┬────────┘ │ ┌────────▼────────┐ │ MySQL │ │ (数据存储) │ └─────────────────┘ ``` ### 1.2 目录结构 ``` internal/expiry/ ├── handler.go # HTTP 处理器 ├── service.go # 业务逻辑 ├── repository.go # 数据访问 ├── model.go # 数据模型 └── dto.go # 数据传输对象(可选) docs/expiry/ ├── PRD.md # 产品需求文档 ├── API.md # 接口文档 ├── README.md # 项目说明 ├── ISSUES.md # 开发任务 └── DEVELOPMENT.md # 本文档 docs/sql/ └── expiry.sql # 数据库初始化脚本 ``` --- ## 2. 核心实现 ### 2.1 过期状态计算 **关键逻辑**: ```go // 计算剩余天数 func (item *ExpiryItem) CalculateDaysLeft() int { now := time.Now().Truncate(24 * time.Hour) expiry := item.ExpiryDate.Truncate(24 * time.Hour) return int(expiry.Sub(now).Hours() / 24) } // 计算状态 func (item *ExpiryItem) CalculateStatus() string { daysLeft := item.CalculateDaysLeft() if daysLeft < 0 { return "expired" } else if daysLeft <= 7 { return "expiring" } return "normal" } ``` **注意事项**: - 使用 `Truncate(24 * time.Hour)` 确保只比较日期,忽略时分秒 - 状态计算在查询时动态进行,不存储到数据库 - 前端根据 `days_left` 显示不同颜色 ### 2.2 自动计算过期日期 **场景**:用户填写生产日期 + 保质期天数,自动计算过期日期 ```go func (s *ExpiryService) CreateItem(userID uint, req CreateItemRequest) (*ExpiryItem, error) { item := &ExpiryItem{ UserID: userID, MiniProgramID: req.MiniProgramID, Name: req.Name, Category: req.Category, ProductionDate: req.ProductionDate, Quantity: req.Quantity, Location: req.Location, Remark: req.Remark, } // 如果提供了生产日期和保质期天数,自动计算过期日期 if req.ProductionDate != nil && req.ShelfLifeDays != nil { expiryDate := req.ProductionDate.AddDate(0, 0, *req.ShelfLifeDays) item.ExpiryDate = expiryDate item.ShelfLifeDays = req.ShelfLifeDays } else if req.ExpiryDate != nil { item.ExpiryDate = *req.ExpiryDate } else { return nil, errors.New("必须提供过期日期或生产日期+保质期天数") } if err := s.repo.Create(item); err != nil { return nil, err } return item, nil } ``` ### 2.3 汇总统计实现 **SQL 查询优化**: ```go func (r *ExpiryRepository) GetSummary(userID uint) (map[string]int, error) { var result struct { TotalItems int64 ExpiringSoon int64 Expired int64 Normal int64 Used int64 Discarded int64 } now := time.Now().Truncate(24 * time.Hour) sevenDaysLater := now.AddDate(0, 0, 7) db := r.db.Model(&ExpiryItem{}).Where("user_id = ?", userID) // 总数(不含已使用/已丢弃) db.Where("status NOT IN ?", []string{"used", "discarded"}).Count(&result.TotalItems) // 即将过期(7天内) db.Where("expiry_date BETWEEN ? AND ?", now, sevenDaysLater). Where("status NOT IN ?", []string{"used", "discarded"}). Count(&result.ExpiringSoon) // 已过期 db.Where("expiry_date < ?", now). Where("status NOT IN ?", []string{"used", "discarded"}). Count(&result.Expired) // 正常 db.Where("expiry_date > ?", sevenDaysLater). Where("status NOT IN ?", []string{"used", "discarded"}). Count(&result.Normal) // 已使用 db.Where("status = ?", "used").Count(&result.Used) // 已丢弃 db.Where("status = ?", "discarded").Count(&result.Discarded) return map[string]int{ "total_items": int(result.TotalItems), "expiring_soon": int(result.ExpiringSoon), "expired": int(result.Expired), "normal": int(result.Normal), "used": int(result.Used), "discarded": int(result.Discarded), }, nil } ``` **优化建议**: - 使用索引 `idx_user_expiry (user_id, expiry_date)` - 考虑使用 Redis 缓存(5分钟过期) ### 2.4 列表查询与筛选 **Repository 实现**: ```go func (r *ExpiryRepository) FindByUser( userID uint, filters map[string]interface{}, page, pageSize int, ) ([]ExpiryItem, int64, error) { var items []ExpiryItem var total int64 db := r.db.Model(&ExpiryItem{}).Where("user_id = ?", userID) // 状态筛选 if status, ok := filters["status"].(string); ok && status != "all" { if status == "expiring" { now := time.Now().Truncate(24 * time.Hour) sevenDaysLater := now.AddDate(0, 0, 7) db = db.Where("expiry_date BETWEEN ? AND ?", now, sevenDaysLater) } else if status == "expired" { now := time.Now().Truncate(24 * time.Hour) db = db.Where("expiry_date < ?", now) } else { db = db.Where("status = ?", status) } } // 分类筛选 if category, ok := filters["category"].(string); ok && category != "all" { db = db.Where("category = ?", category) } // 排序 sort := "expiry_date" if s, ok := filters["sort"].(string); ok { sort = s } db = db.Order(sort + " ASC") // 统计总数 db.Count(&total) // 分页 offset := (page - 1) * pageSize db = db.Offset(offset).Limit(pageSize) if err := db.Find(&items).Error; err != nil { return nil, 0, err } return items, total, nil } ``` --- ## 3. 前端实现要点 ### 3.1 API 请求封装 **utils/request.js**: ```javascript const BASE_URL = 'https://your-domain.com/api'; function request(options) { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else if (res.data.code === 401) { // Token 过期,重新登录 wx.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { wx.showToast({ title: res.data.message || '请求失败', icon: 'none' }); reject(res.data); } }, fail: reject }); }); } module.exports = { request }; ``` ### 3.2 日期格式化 **utils/date.js**: ```javascript // 计算剩余天数颜色 function getDaysLeftColor(daysLeft) { if (daysLeft < 0) return '#FF4444'; // 红色:已过期 if (daysLeft <= 3) return '#FF8800'; // 橙色:3天内 if (daysLeft <= 7) return '#FFBB00'; // 黄色:7天内 return '#00CC66'; // 绿色:正常 } // 格式化日期 function formatDate(date) { const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } // 计算剩余天数文本 function getDaysLeftText(daysLeft) { if (daysLeft < 0) return `已过期 ${Math.abs(daysLeft)} 天`; if (daysLeft === 0) return '今天过期'; if (daysLeft === 1) return '明天过期'; return `还剩 ${daysLeft} 天`; } module.exports = { getDaysLeftColor, formatDate, getDaysLeftText }; ``` ### 3.3 分类图标映射 **utils/category.js**: ```javascript const CATEGORY_CONFIG = { food: { name: '食品', icon: '🍎', color: '#FF6B6B' }, medicine: { name: '药品', icon: '💊', color: '#4ECDC4' }, cosmetic: { name: '化妆品', icon: '💄', color: '#FFB6C1' }, other: { name: '其他', icon: '📦', color: '#95A5A6' } }; function getCategoryConfig(category) { return CATEGORY_CONFIG[category] || CATEGORY_CONFIG.other; } module.exports = { getCategoryConfig, CATEGORY_CONFIG }; ``` --- ## 4. 数据库优化 ### 4.1 索引设计 ```sql -- 用户 + 过期日期(最常用查询) CREATE INDEX idx_user_expiry ON expiry_items(user_id, expiry_date); -- 用户 + 分类 CREATE INDEX idx_user_category ON expiry_items(user_id, category); -- 用户 + 状态 CREATE INDEX idx_user_status ON expiry_items(user_id, status); -- 软删除 CREATE INDEX idx_deleted_at ON expiry_items(deleted_at); ``` ### 4.2 查询优化建议 1. **避免全表扫描**:所有查询都带上 `user_id` 2. **使用覆盖索引**:常用字段加入索引 3. **分页查询**:使用 `LIMIT` 和 `OFFSET` 4. **缓存热点数据**:summary 数据使用 Redis 缓存 --- ## 5. 安全性考虑 ### 5.1 权限控制 - 所有接口必须通过 JWT 认证 - 查询时必须验证 `user_id`,防止越权访问 - 删除操作使用软删除,可恢复 ### 5.2 输入验证 ```go // 验证物品名称 if len(req.Name) == 0 || len(req.Name) > 100 { return nil, errors.New("物品名称长度必须在1-100之间") } // 验证分类 validCategories := []string{"food", "medicine", "cosmetic", "other"} if !contains(validCategories, req.Category) { return nil, errors.New("无效的分类") } // 验证日期 if req.ExpiryDate != nil && req.ExpiryDate.Before(time.Now()) { // 允许添加已过期物品,只是警告 // 可根据业务需求调整 } ``` --- ## 6. 测试策略 ### 6.1 单元测试 **测试 Model 层**: ```go func TestCalculateDaysLeft(t *testing.T) { item := &ExpiryItem{ ExpiryDate: time.Now().AddDate(0, 0, 5), } daysLeft := item.CalculateDaysLeft() assert.Equal(t, 5, daysLeft) } func TestCalculateStatus(t *testing.T) { tests := []struct { daysLeft int expected string }{ {-1, "expired"}, {0, "expiring"}, {5, "expiring"}, {7, "expiring"}, {8, "normal"}, } for _, tt := range tests { item := &ExpiryItem{ ExpiryDate: time.Now().AddDate(0, 0, tt.daysLeft), } assert.Equal(t, tt.expected, item.CalculateStatus()) } } ``` ### 6.2 集成测试 使用 Postman 或编写测试脚本: ```bash # 登录获取 Token TOKEN=$(curl -X POST http://localhost:8080/api/auth/login \ -d '{"code":"xxx","mini_program_id":1}' | jq -r '.data.token') # 添加物品 curl -X POST http://localhost:8080/api/expiry/items \ -H "Authorization: Bearer $TOKEN" \ -d '{ "name": "牛奶", "category": "food", "expiry_date": "2026-03-10", "quantity": 2 }' # 获取列表 curl -X GET "http://localhost:8080/api/expiry/items?status=expiring" \ -H "Authorization: Bearer $TOKEN" ``` --- ## 7. 部署建议 ### 7.1 环境变量 ```bash # .env SERVER_PORT=8080 DB_HOST=localhost DB_PORT=3306 DB_USER=root DB_PASSWORD=your_password DB_NAME=wx_service JWT_SECRET=your_jwt_secret GIN_MODE=release ``` ### 7.2 Docker 部署(可选) ```dockerfile FROM golang:1.23-alpine AS builder WORKDIR /app COPY . . RUN go build -o wx_service ./cmd/api FROM alpine:latest WORKDIR /app COPY --from=builder /app/wx_service . COPY .env . EXPOSE 8080 CMD ["./wx_service"] ``` --- ## 8. 监控与日志 ### 8.1 日志记录 ```go // 记录关键操作 log.Printf("[Expiry] User %d created item: %s", userID, item.Name) log.Printf("[Expiry] User %d deleted item: %d", userID, itemID) ``` ### 8.2 性能监控 - 接口响应时间 - 数据库查询耗时 - 错误率统计 --- ## 9. 常见问题 ### Q1: 时区问题如何处理? 统一使用 UTC 时间存储,前端根据用户时区显示。Go 中使用 `time.Now().UTC()`。 ### Q2: 如何处理大量过期物品? 定期清理已过期且标记为 `discarded` 的物品(物理删除),或者归档到历史表。 ### Q3: 如何优化首页加载速度? - 使用 Redis 缓存 summary 数据 - 首页只加载即将过期的物品(限制数量) - 使用 CDN 加速静态资源 --- ## 10. 后续优化方向 1. **OCR 识别**:集成 OCR 识别生产日期和保质期 2. **推送通知**:使用小程序订阅消息推送临期提醒 3. **数据分析**:统计用户消费习惯,提供采购建议 4. **社交功能**:家庭成员共享物品列表 --- **文档维护**: 随着项目迭代,及时更新本文档