Files
2026-03-04 16:31:30 +08:00

13 KiB
Raw Permalink Blame History

保质期提醒小程序 - 开发指南

本文档提供技术实现细节和开发规范。


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 查询优化建议

  1. 避免全表扫描:所有查询都带上 user_id
  2. 使用覆盖索引:常用字段加入索引
  3. 分页查询:使用 LIMITOFFSET
  4. 缓存热点数据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. 后续优化方向

  1. OCR 识别:集成 OCR 识别生产日期和保质期
  2. 推送通知:使用小程序订阅消息推送临期提醒
  3. 数据分析:统计用户消费习惯,提供采购建议
  4. 社交功能:家庭成员共享物品列表

文档维护: 随着项目迭代,及时更新本文档