Files
wx_service/docs/expiry/DEVELOPMENT.md
2026-03-04 16:31:30 +08:00

561 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 保质期提醒小程序 - 开发指南
本文档提供技术实现细节和开发规范。
---
## 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. **社交功能**:家庭成员共享物品列表
---
**文档维护**: 随着项目迭代,及时更新本文档