docs: add expiry docs and sql script
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
# 保质期提醒小程序 - 开发指南
|
||||
|
||||
本文档提供技术实现细节和开发规范。
|
||||
|
||||
---
|
||||
|
||||
## 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. **社交功能**:家庭成员共享物品列表
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 随着项目迭代,及时更新本文档
|
||||
Reference in New Issue
Block a user