文章

design

design

设计文档

概述

” 今天吃什么 “ 是一个渐进式 Web 应用,帮助用户解决每日用餐选择困难。系统支持游客模式和注册用户模式,提供菜品管理、智能随机选择、社区分享等功能。应用采用前端静态站点配合 Cloudflare Workers 无服务器后端的架构。

核心功能

  • 个人菜品/餐厅列表管理
  • 基于时间段的随机选择(每餐限制一次)
  • 游客账户与注册账户的平滑升级
  • 社区分享与发现功能
  • 跨设备数据同步

架构

整体架构

1
2
3
[前端 PWA] <--> [Cloudflare Workers API] <--> [Cloudflare Workers KV/Durable Objects]
     |
[本地存储: IndexedDB/localStorage]

技术栈

  • 前端: 原生 JavaScript + CSS + HTML (PWA)
  • 后端: Cloudflare Workers
  • 存储:
    • 本地: IndexedDB (主要数据) + localStorage (会话信息)
    • 云端: Cloudflare Workers KV (用户数据) + Durable Objects (实时共享)
  • 部署: Cloudflare Pages

数据流

  1. 游客模式: 数据仅存储在本地 IndexedDB
  2. 注册用户: 数据同步到 Cloudflare Workers KV,本地缓存提升体验
  3. 社区功能: 使用 Durable Objects 处理实时数据共享

组件和接口

前端组件架构

1. 核心管理组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// UserManager - 用户身份管理
class UserManager {
  async initializeUser()     // 初始化用户身份
  async upgradeToRegistered() // 游客升级为注册用户
  getCurrentUser()           // 获取当前用户信息
}

// MealManager - 菜品列表管理
class MealManager {
  async addMeal(name)        // 添加菜品
  async removeMeal(id)       // 删除菜品
  async getMeals()           // 获取菜品列表
  async syncWithCloud()      // 云端同步
}

// RandomizerEngine - 随机选择引擎
class RandomizerEngine {
  async randomSelect()       // 执行随机选择
  checkMealTimeRestriction() // 检查时间限制
  getCurrentMealPeriod()     // 获取当前餐次
}

2. 用户界面组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// UIController - 主界面控制器
class UIController {
  renderMealList()          // 渲染菜品列表
  showRandomResult()        // 显示随机结果
  showMealTimeRestriction() // 显示时间限制提示
  renderCommunityFeed()     // 渲染社区分享
}

// AnimationManager - 动画效果管理
class AnimationManager {
  playRandomAnimation()     // 随机选择动画
  showSuccessAnimation()    // 成功操作动画
  showErrorAnimation()      // 错误提示动画
}

API 接口设计

1. 用户认证 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// POST /api/auth/register
{
  nickname: string,
  email: string,
  password: string,
  localData?: MealData[] // 游客数据迁移
}

// POST /api/auth/login
{
  email: string,
  password: string
}

// GET /api/auth/profile
Response: UserProfile

2. 菜品管理 API

1
2
3
4
5
6
7
8
9
10
// GET /api/meals
Response: MealItem[]

// POST /api/meals
{
  name: string,
  category?: string
}

// DELETE /api/meals/:id

3. 随机记录 API

1
2
3
4
5
6
7
8
// POST /api/random
{
  mealPeriod: 'breakfast' | 'lunch' | 'dinner',
  selectedMeal: string
}

// GET /api/random/status/:period
Response: { canRandomize: boolean, lastRandomTime?: timestamp }

4. 社区分享 API

1
2
3
4
5
6
7
8
9
// POST /api/community/share
{
  mealName: string,
  mealPeriod: string,
  userNickname: string
}

// GET /api/community/feed/:period
Response: CommunityShare[]

数据模型

1. 用户数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 用户基本信息
interface User {
  id: string;
  nickname: string;
  email?: string;
  isGuest: boolean;
  createdAt: timestamp;
  lastActiveAt: timestamp;
}

// 用户会话信息
interface UserSession {
  userId: string;
  token?: string;
  mealRestrictions: {
    breakfast?: timestamp;
    lunch?: timestamp;
    dinner?: timestamp;
  };
}

2. 菜品数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 菜品项目
interface MealItem {
  id: string;
  name: string;
  category?: string;
  addedAt: timestamp;
  userId: string;
}

// 用户菜品列表
interface UserMealList {
  userId: string;
  meals: MealItem[];
  lastUpdated: timestamp;
}

3. 社区分享模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 分享记录
interface CommunityShare {
  id: string;
  userNickname: string;
  mealName: string;
  mealPeriod: 'breakfast' | 'lunch' | 'dinner';
  sharedAt: timestamp;
  likes?: number;
}

// 分页分享数据
interface CommunityFeed {
  period: string;
  shares: CommunityShare[];
  total: number;
  hasMore: boolean;
}

4. 本地存储数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// IndexedDB 主要数据存储
interface LocalUserData {
  user: User;
  meals: MealItem[];
  randomHistory: RandomRecord[];
  settings: UserSettings;
}

// localStorage 会话数据
interface SessionData {
  currentUserId: string;
  authToken?: string;
  mealRestrictions: MealRestrictions;
  lastSyncTime?: timestamp;
}

错误处理

1. 网络错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class NetworkErrorHandler {
  // 离线模式检测和处理
  handleOfflineMode() {
    // 显示离线提示
    // 启用本地模式
    // 队列待同步操作
  }
  
  // API 请求失败重试机制
  async retryRequest(request, maxRetries = 3) {
    // 指数退避重试
    // 失败后降级到本地操作
  }
  
  // 数据同步冲突解决
  resolveDataConflict(localData, remoteData) {
    // 时间戳比较策略
    // 用户选择策略
  }
}

2. 数据验证错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ValidationErrorHandler {
  // 用户输入验证
  validateMealName(name) {
    if (!name || name.trim().length === 0) {
      throw new ValidationError('菜品名称不能为空');
    }
    if (name.length > 50) {
      throw new ValidationError('菜品名称过长');
    }
  }
  
  // 邮箱格式验证
  validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new ValidationError('邮箱格式不正确');
    }
  }
}

3. 存储错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StorageErrorHandler {
  // 本地存储空间不足
  handleQuotaExceeded() {
    // 清理旧数据
    // 提示用户
    // 降级到内存模式
  }
  
  // IndexedDB 不可用降级
  fallbackToLocalStorage() {
    // 使用 localStorage 作为备选
    // 功能降级提示
  }
}

时间管理策略

1. 餐次时间定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const MEAL_PERIODS = {
  breakfast: { start: 6, end: 10 },   // 6:00-10:00
  lunch: { start: 11, end: 14 },      // 11:00-14:00
  dinner: { start: 17, end: 21 }      // 17:00-21:00
};

class TimeManager {
  getCurrentMealPeriod() {
    const hour = new Date().getHours();
    
    for (const [period, time] of Object.entries(MEAL_PERIODS)) {
      if (hour >= time.start && hour <= time.end) {
        return period;
      }
    }
    
    // 非用餐时间返回最近的下一餐
    return this.getNextMealPeriod(hour);
  }
  
  canRandomizeForPeriod(period, lastRandomTime) {
    if (!lastRandomTime) return true;
    
    const now = new Date();
    const lastRandom = new Date(lastRandomTime);
    
    // 检查是否为同一天的同一餐次
    return !this.isSameDayAndPeriod(now, lastRandom, period);
  }
}

2. 时区处理

1
2
3
4
5
6
7
8
9
10
11
class TimezoneManager {
  getUserTimezone() {
    return Intl.DateTimeFormat().resolvedOptions().timeZone;
  }
  
  convertToUserTime(utcTime) {
    return new Date(utcTime).toLocaleString('zh-CN', {
      timeZone: this.getUserTimezone()
    });
  }
}

测试策略

1. 单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 核心逻辑测试
describe('RandomizerEngine', () => {
  test('should return random meal from list', () => {
    const meals = ['麻辣烫', '兰州拉面', '黄焖鸡米饭'];
    const result = randomizer.selectMeal(meals);
    expect(meals).toContain(result);
  });
  
  test('should respect meal time restrictions', () => {
    const lastLunchTime = new Date();
    const canRandomize = randomizer.canRandomizeForPeriod('lunch', lastLunchTime);
    expect(canRandomize).toBe(false);
  });
});

// 时间管理测试
describe('TimeManager', () => {
  test('should identify correct meal period', () => {
    const mockDate = new Date('2023-01-01 12:30:00');
    jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
    
    expect(timeManager.getCurrentMealPeriod()).toBe('lunch');
  });
});

2. 集成测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 数据同步测试
describe('Data Sync Integration', () => {
  test('should sync local data to cloud after registration', async () => {
    // 创建游客数据
    await localManager.addMeal('测试菜品');
    
    // 注册账户
    await userManager.register('test@example.com', 'password');
    
    // 验证数据已同步
    const cloudMeals = await apiClient.getMeals();
    expect(cloudMeals).toContainEqual(expect.objectContaining({
      name: '测试菜品'
    }));
  });
});

3. 端到端测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 用户流程测试
describe('Complete User Journey', () => {
  test('guest user can upgrade to registered user', async () => {
    // 游客添加菜品
    await page.fill('[data-testid="meal-input"]', '宫保鸡丁');
    await page.click('[data-testid="add-meal"]');
    
    // 随机选择
    await page.click('[data-testid="random-button"]');
    
    // 升级注册
    await page.click('[data-testid="register-button"]');
    await page.fill('[data-testid="email-input"]', 'test@example.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="submit-register"]');
    
    // 验证数据迁移成功
    expect(await page.textContent('[data-testid="meal-list"]')).toContain('宫保鸡丁');
  });
});

4. 性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 大数据量测试
describe('Performance Tests', () => {
  test('should handle large meal lists efficiently', async () => {
    // 添加1000个菜品
    const meals = Array.from({length: 1000}, (_, i) => `菜品${i}`);
    await Promise.all(meals.map(meal => mealManager.addMeal(meal)));
    
    // 测试随机选择性能
    const startTime = performance.now();
    await randomizer.randomSelect();
    const endTime = performance.now();
    
    expect(endTime - startTime).toBeLessThan(100); // 应在100ms内完成
  });
});

5. 离线功能测试

1
2
3
4
5
6
7
8
9
10
11
12
describe('Offline Functionality', () => {
  test('should work offline with local storage', async () => {
    // 模拟离线状态
    await page.setOffline(true);
    
    // 验证基本功能仍可用
    await page.fill('[data-testid="meal-input"]', '离线菜品');
    await page.click('[data-testid="add-meal"]');
    
    expect(await page.textContent('[data-testid="meal-list"]')).toContain('离线菜品');
  });
});

部署架构

1. Cloudflare Pages 部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# wrangler.toml
name = "meal-randomizer"
main = "src/worker.js"
compatibility_date = "2023-01-01"

[env.production]
vars = { ENVIRONMENT = "production" }
kv_namespaces = [
  { binding = "USERS", id = "user-data-kv" },
  { binding = "MEALS", id = "meal-data-kv" }
]

[env.development]
vars = { ENVIRONMENT = "development" }
kv_namespaces = [
  { binding = "USERS", id = "user-data-kv-dev" },
  { binding = "MEALS", id = "meal-data-kv-dev" }
]

2. 渐进式 Web 应用配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// manifest.json
{
  "name": "今天吃什么",
  "short_name": "今天吃什么",
  "description": "帮你解决每日用餐选择困难",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#ff6b6b",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

3. Service Worker 策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 缓存策略
const CACHE_NAME = 'meal-randomizer-v1';
const STATIC_ASSETS = [
  '/',
  '/styles.css',
  '/script.js',
  '/manifest.json'
];

// 离线优先策略
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    // API 请求: 网络优先,离线降级
    event.respondWith(networkFirst(event.request));
  } else {
    // 静态资源: 缓存优先
    event.respondWith(cacheFirst(event.request));
  }
});

安全考虑

1. 认证安全

  • 使用 HTTP-Only cookies 存储认证令牌
  • 实施 CSRF 保护
  • 密码使用 bcrypt 哈希存储
  • JWT 令牌设置合理过期时间

2. 数据安全

  • 用户敏感数据加密存储
  • API 请求参数验证和净化
  • 防止 XSS 攻击的输入处理
  • 实施 Content Security Policy

3. 隐私保护

  • 最小化数据收集
  • 提供数据导出功能
  • 支持账户删除
  • 遵循数据保护法规

性能优化

1. 前端优化

  • 代码分割和懒加载
  • 图片优化和压缩
  • CSS 和 JavaScript 压缩
  • 使用 Web Workers 处理复杂计算

2. 后端优化

  • Cloudflare Workers 边缘计算
  • KV 存储的读写优化
  • API 响应缓存策略
  • 数据库查询优化

3. 网络优化

  • HTTP/2 多路复用
  • 资源预加载
  • 服务端推送
  • CDN 加速

监控和分析

1. 性能监控

  • Core Web Vitals 跟踪
  • API 响应时间监控
  • 错误率统计
  • 用户行为分析

2. 业务指标

  • 用户注册转化率
  • 功能使用频率
  • 社区分享参与度
  • 用户留存率

这个设计文档基于详细的需求分析和技术研究,提供了完整的架构方案,确保应用既能满足用户需求,又具备良好的可扩展性和维护性。

本文由作者按照 CC BY 4.0 进行授权