TL;DR: 本文介绍一个基于 Expo + React Native 的智能购物助手,核心能力是通过条形码扫描识别食品,结合用户饮食目标、过敏原和家庭成员需求提供实时风险提醒与替代建议。实现了"地区感知 + 多源兜底"的扫码查询链路(Open Food Facts 优先 + 阿里云 API 兜底),基于关键词匹配的过敏原冲突检测,以及购物车维度的营养汇总与合规评分系统。
一、为什么做这个项目
食品安全关系到每个家庭的健康。食品包装上的配料表信息密集,普通消费者难以在短时间内判断一款产品是否符合自己的饮食限制——过敏用户需要识别过敏原,健身用户需要控制糖分和热量,家有婴幼儿的家长需要避免特定添加剂。
这个项目的目标是:把专业营养师和过敏咨询的能力,装进每个人的手机里。
本项目面向有 React Native 基础、了解 REST API 调用的移动开发者。熟悉 Expo Camera 和 AsyncStorage 的读者更佳,但不强制要求。
二、系统架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ Expo App (React Native) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Home │ │ Scan │ │ Product │ │ Cart │ │ Profile │ │
│ │ 主页 │ │ 扫码 │ │ 详情 │ │ 购物车 │ │ 用户档案 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Zustand Store (持久化状态) │ │
│ │ useCartStore │ useProductStore │ useProfileStore │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Services Layer │ │
│ │ openFoodFactsApi │ aliyunBarcodeApi │ nutritionAnalyzer conflictResolver│
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Utils / Constants │ │
│ │ barcodeUtils │ complianceChecker │ nutritionThresholds │ allergens │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
↓
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ Open Food Facts API │ │ 阿里云条码 API │ │ i18next │
│ cn.openfoodfacts.org │ │ (可选兜底) │ │ (en / zh-HK) │
│ world.openfoodfacts │ │ │ │ │
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘三、核心模块实现
3.1 条码查询链路:地区感知 + 多源兜底
这是本项目最关键的数据获取层。条码查询采用分层降级策略:
用户扫描条码
↓
本地缓存检查(10min TTL)
↓ 有缓存
直接返回缓存结果
↓ 无缓存
In-flight 请求去重(防止重复请求)
↓
并发直查 Open Food Facts(按地区优先host)
├─ 中国内地 (690-699) → cn.openfoodfacts.org 优先
├─ 香港 (489) → world.openfoodfacts 优先
└─ 其他地区 → world.openfoodfacts 优先
↓ 失败
搜索回退(search endpoint + page_size=5)
↓ 仍失败
阿里云条码 API 兜底(可选,需配置) // openFoodFactsApi.ts
const REQUEST_TIMEOUT_MS = 2600;
const SEARCH_TIMEOUT_MS = 3200;
const BARCODE_CACHE_TTL_MS = 10 * 60 * 1000;
const BARCODE_MISS_CACHE_TTL_MS = 45 * 1000;
const barcodeCache = new Map<string, CacheEntry>();
const inflightBarcodeRequests = new Map<string, Promise<Product | null>>();
export async function fetchProductByBarcode(barcode: string): Promise<Product | null> {
// 1. 规范化条码(UPC-A 12位 → EAN-13 13位)
const normalizedBarcode = normalizeBarcode(barcode);
const barcodeCandidates = getBarcodeCandidates(normalizedBarcode);
// 2. 缓存检查
for (const candidate of barcodeCandidates) {
const cached = getCachedBarcodeValue(candidate);
if (cached !== undefined) return cached;
}
// 3. In-flight 去重
const cacheKey = barcodeCandidates[0];
if (inflightBarcodeRequests.has(cacheKey)) {
return inflightBarcodeRequests.get(cacheKey);
}
// 4. 分层查询
const request = fetchProductByBarcodeInternal(normalizedBarcode)
.then((product) => {
setCachedBarcodeValue(barcodeCandidates, product);
return product;
})
.finally(() => inflightBarcodeRequests.delete(cacheKey));
inflightBarcodeRequests.set(cacheKey, request);
return request;
}并发 + first-successful 模式避免等待所有 host 返回:
async function firstSuccessfulProduct(
tasks: (() => Promise<Product | null>)[]
): Promise<Product | null> {
return new Promise((resolve) => {
let pendingCount = tasks.length;
let hasResolved = false;
const finishIfNeeded = () => {
if (!hasResolved && --pendingCount === 0) resolve(null);
};
tasks.forEach((task) => {
task()
.then((product) => {
if (!hasResolved && product) {
hasResolved = true;
resolve(product);
}
})
.catch(() => undefined)
.finally(finishIfNeeded);
});
});
}3.2 条码地区识别与候选码生成
// barcodeUtils.ts
export function getBarcodeRegion(barcode: string): BarcodeRegion {
const prefix3 = Number(cleanBarcode.slice(0, 3));
if (prefix3 >= 690 && prefix3 <= 699) return 'mainland-china';
if (prefix3 === 489) return 'hong-kong'; // 香港
if (prefix3 === 471) return 'taiwan'; // 台湾
if (prefix3 >= 450 && prefix3 <= 459) return 'japan';
if (prefix3 >= 490 && prefix3 <= 499) return 'japan';
if (prefix3 >= 870 && prefix3 <= 879) return 'netherlands';
if (prefix2 === 50) return 'uk';
if (prefix3 >= 0 && prefix3 <= 139) return 'north-america';
return 'international';
}
// EAN-13 和 UPC-A 相互转换
export function normalizeBarcode(barcode: string): string {
const cleanBarcode = sanitizeBarcodeInput(barcode);
if (cleanBarcode.length === 12) {
return `0${cleanBarcode}`; // UPC-A → EAN-13
}
return cleanBarcode;
}
// 生成多个可能的条码候选(处理前缀差异)
export function getBarcodeCandidates(barcode: string): string[] {
const candidates = [normalizedBarcode, cleanBarcode];
if (normalizedBarcode.length === 13 && normalizedBarcode.startsWith('0')) {
candidates.push(normalizedBarcode.slice(1)); // 去掉前导0
}
return [...new Set(candidates)];
}3.3 过敏原冲突检测:关键词多级匹配
过敏原检测采用关键词逐级匹配策略,而非简单的字符串包含:
// constants/allergens.ts
export function checkAllergenMatch(
allergenSelections: Record<AllergenCategory, AllergenSelection>,
productKeywords: string[] // 产品名称 + 品牌 + 分类 + 过敏原标签
): { matched: boolean; category?: AllergenCategoryData; subItem?: string } {
for (const category of ALLERGEN_CATEGORIES) {
const selection = allergenSelections[category.id];
if (selection.isAllSelected) {
// 全选模式:category 名称匹配 OR 任意 subItem 关键词匹配
for (const keyword of productKeywords) {
const lowerKeyword = keyword.toLowerCase();
for (const subItem of category.subItems) {
if (subItem.keywords.some(k => lowerKeyword.includes(k.toLowerCase()))) {
return { matched: true, category, subItem: subItem.name };
}
}
}
} else {
// 细粒度模式:只检查用户选中的 subItem
for (const subItemId of selection.selectedSubItems) {
const subItem = category.subItems.find(s => s.id === subItemId);
if (subItem) {
for (const keyword of productKeywords) {
if (subItem.keywords.some(k => keyword.toLowerCase().includes(k.toLowerCase()))) {
return { matched: true, category, subItem: subItem.name };
}
}
}
}
}
}
return { matched: false };
}过敏原分类涵盖 14 大类,包含中西式食品常见过敏原:
3.4 营养阈值与告警分级
// utils/complianceChecker.ts
export function determineAlertLevel(product: Product, profile: UserProfile): AlertInfo {
// 1. 过敏原冲突 → emergency 级别
const allergenCheck = checkAllergenConflictFromSelections(product, profile.allergenSelections);
if (allergenCheck.hasConflict) {
return {
level: 'emergency',
message: `Contains ${allergenCheck.conflictingAllergens.join(', ')}. Matches your allergen list.`,
...
};
}
// 2. 素食者肉类检测
if (profile.goals.includes('vegetarian') && !checkVegetarianCompliance(product)) {
return { level: 'emergency', message: 'Not vegetarian. Contains meat or fish related ingredients.', ... };
}
// 3. 营养阈值违规 → suggestion 级别
const { violations } = checkNutritionThresholds(product, thresholds);
if (violations.length > 0) {
return { level: 'suggestion', message: `High in: ${violations[0]}`, ... };
}
// 4. 接近阈值 → optimization 级别
const { warnings } = checkNutritionThresholds(product, thresholds);
if (warnings.length > 0) {
return { level: 'optimization', message: warnings[0], ... };
}
// 5. 合规
return { level: 'compliant', message: 'This product meets your dietary goals', ... };
}阈值判定采用 80% 预警机制(避免用户在边界线上频繁踩雷):
if (nutrition.calories > thresholds.calories.max) {
violations.push(`Calories: ${nutrition.calories}kcal (max: ${thresholds.calories.max}kcal)`);
} else if (nutrition.calories > thresholds.calories.max * 0.8) {
warnings.push(`Calories: ${nutrition.calories}kcal (approaching limit)`);
}3.5 购物车汇总与家庭成员分配
// stores/useCartStore.ts
const calculateSummary = (items: CartItem[]): CartNutritionSummary => {
const totals = items.reduce((acc, item) => {
return {
totalCalories: acc.totalCalories + item.product.nutrition.calories * item.quantity,
totalSugar: acc.totalSugar + item.product.nutrition.sugar * item.quantity,
totalSodium: acc.totalSodium + item.product.nutrition.sodium * item.quantity,
totalFat: acc.totalFat + item.product.nutrition.fat * item.quantity,
totalProtein: acc.totalProtein + item.product.nutrition.protein * item.quantity,
totalFiber: acc.totalFiber + item.product.nutrition.fiber * item.quantity,
};
}, { totalCalories: 0, totalSugar: 0, ... });
const complianceScore = calculateOverallComplianceScore(
items.map(i => i.product),
profile
);
return { ...totals, itemCount, complianceScore, alerts };
};购物车合规评分(加权平均,每项 0-100 分):
3.6 替代品推荐
// openFoodFactsApi.ts
export async function findAlternatives(
product: Product,
maxSugar?: number,
maxSodium?: number,
minProtein?: number
): Promise<Product[]> {
// 按同类目查询,附加营养过滤
const categories = product.categories.slice(0, 2).join(',');
let endpoint = `${API_BASE}/search?categories_tags=${categories}&page_size=12`;
if (maxSugar !== undefined) {
endpoint += `&nutriment_sugars_100g=${maxSugar}&nutriment_compare=sugars_100g`;
}
// 按钠/蛋白质过滤,返回 Top 5
return candidates.filter(candidate => {
if (maxSodium !== undefined && candidate.nutrition.sodium > maxSodium) return false;
if (minProtein !== undefined && candidate.nutrition.protein < minProtein) return false;
return true;
}).slice(0, 5);
}四、关键设计决策
4.1 离线缓存策略
命中缓存 → 直接返回(0ms 网络延迟)
未命中 → 并发查询 → 结果写入缓存
缓存击穿(同一条码大量并发请求):
→ In-flight Map 去重,同一条码共享同一个 Promise
缓存穿透(无效条码):
→ 写入 null 值,45s TTL 防止短期内重复查询无效码4.2 家庭成员维度冲突检测
// services/conflictResolver.ts
export function resolveFamilyConflicts(product, familyMembers): ConflictResolution {
const conflicts: FamilyConflict[] = [];
familyMembers.forEach(member => {
const thresholds = getCombinedThresholds(member.goals);
const allergenCheck = checkAllergenConflictFromSelections(product, member.allergenSelections);
if (allergenCheck.hasConflict) {
conflicts.push({
memberId: member.id,
memberName: member.name,
conflictType: 'allergen',
severity: allergenCheck.riskLevel === 'high' ? 'high' : 'medium',
});
}
});
// high severity 冲突 → 禁止加入购物车
const canAdd = !conflicts.some(c => c.severity === 'high');
return { canAdd, conflicts, suggestions, recommendedFor, notRecommendedFor };
}五、项目结构
Proactive-Diet-Shopping-Assistant/
├── src/
│ ├── app/ # Expo Router 路由入口
│ ├── screens/ # 页面(Home / Scan / Product / Cart / Profile)
│ ├── components/ # 可复用 UI 组件
│ ├── services/ # API 层
│ │ ├── openFoodFactsApi.ts # Open Food Facts 查询(含并发+缓存)
│ │ ├── aliyunBarcodeApi.ts # 阿里云条码 API 兜底
│ │ ├── barcodeUtils.ts # 条码规范化与地区识别
│ │ ├── nutritionAnalyzer.ts # 营养分析与评分
│ │ └── conflictResolver.ts # 家庭成员冲突检测
│ ├── stores/ # Zustand 状态管理
│ │ ├── useCartStore.ts # 购物车(AsyncStorage 持久化)
│ │ ├── useProductStore.ts # 商品缓存
│ │ └── useProfileStore.ts # 用户档案
│ ├── utils/
│ │ ├── complianceChecker.ts # 阈值判定与告警级别
│ │ └── nutritionThresholds.ts # 阈值配置
│ ├── constants/
│ │ ├── allergens.ts # 14 类过敏原定义 + 关键词
│ │ ├── dietGoals.ts # 饮食目标常量
│ │ └── colors.ts # 颜色编码
│ ├── types/ # TypeScript 类型定义
│ └── i18n/ # 国际化(en / zh-HK)
├── app.json # Expo 配置
├── eas.json # EAS Build 配置
└── package.json六、技术栈
七、Trade-offs 与局限性
八、快速开始
# 安装依赖
npm install
# 启动开发服务器
npx expo start
# Web 预览
npm run web
# Android 预览(需 Expo Go)
npx expo start --android
# 配置阿里云兜底(可选)
cp .env.example .env
# 填写 EXPO_PUBLIC_ALIYUN_BARCODE_ENDPOINT 和 APPCODE
评论区