13 KiB
13 KiB
保质期提醒小程序 - 开发指南
本文档提供技术实现细节和开发规范。
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 过期状态计算
关键逻辑:
// 计算剩余天数
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 自动计算过期日期
场景:用户填写生产日期 + 保质期天数,自动计算过期日期
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 查询优化:
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 实现:
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:
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:
// 计算剩余天数颜色
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:
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 索引设计
-- 用户 + 过期日期(最常用查询)
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 查询优化建议
- 避免全表扫描:所有查询都带上
user_id - 使用覆盖索引:常用字段加入索引
- 分页查询:使用
LIMIT和OFFSET - 缓存热点数据:summary 数据使用 Redis 缓存
5. 安全性考虑
5.1 权限控制
- 所有接口必须通过 JWT 认证
- 查询时必须验证
user_id,防止越权访问 - 删除操作使用软删除,可恢复
5.2 输入验证
// 验证物品名称
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 层:
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 或编写测试脚本:
# 登录获取 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 环境变量
# .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 部署(可选)
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 日志记录
// 记录关键操作
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. 后续优化方向
- OCR 识别:集成 OCR 识别生产日期和保质期
- 推送通知:使用小程序订阅消息推送临期提醒
- 数据分析:统计用户消费习惯,提供采购建议
- 社交功能:家庭成员共享物品列表
文档维护: 随着项目迭代,及时更新本文档