From 0368e85abe8664c62053a586811e32e77ca10dc6 Mon Sep 17 00:00:00 2001 From: hera Date: Mon, 6 Apr 2026 13:46:32 +0000 Subject: [PATCH] Initial commit: Essential Oil Formula Cost Calculator --- .gitignore | 6 + Dockerfile | 16 + backend/database.py | 306 ++ backend/defaults.json | 8091 +++++++++++++++++++++++++++ backend/main.py | 1497 +++++ backend/requirements.txt | 3 + deploy/backup-cronjob.yaml | 39 + deploy/cronjob.yaml | 30 + deploy/deployment.yaml | 47 + deploy/ingress.yaml | 23 + deploy/namespace.yaml | 4 + deploy/pvc.yaml | 11 + deploy/service.yaml | 11 + deploy/setup-kubeconfig.sh | 82 + doc/deploy.md | 114 + frontend/apple-touch-icon-180.png | Bin 0 -> 11070 bytes frontend/apple-touch-icon-192.png | Bin 0 -> 8426 bytes frontend/apple-touch-icon.png | Bin 0 -> 12574 bytes frontend/favicon.svg | 1 + frontend/icon-192.png | Bin 0 -> 11902 bytes frontend/index.html | 8441 +++++++++++++++++++++++++++++ scripts/backup.sh | 53 + scripts/remote-backup.sh | 19 + scripts/scan-recipes-folder.py | 46 + 精油配方计算器.html | 2057 +++++++ 25 files changed, 20897 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/database.py create mode 100644 backend/defaults.json create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 deploy/backup-cronjob.yaml create mode 100644 deploy/cronjob.yaml create mode 100644 deploy/deployment.yaml create mode 100644 deploy/ingress.yaml create mode 100644 deploy/namespace.yaml create mode 100644 deploy/pvc.yaml create mode 100644 deploy/service.yaml create mode 100644 deploy/setup-kubeconfig.sh create mode 100644 doc/deploy.md create mode 100644 frontend/apple-touch-icon-180.png create mode 100644 frontend/apple-touch-icon-192.png create mode 100644 frontend/apple-touch-icon.png create mode 100644 frontend/favicon.svg create mode 100644 frontend/icon-192.png create mode 100644 frontend/index.html create mode 100755 scripts/backup.sh create mode 100755 scripts/remote-backup.sh create mode 100644 scripts/scan-recipes-folder.py create mode 100644 精油配方计算器.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8fddbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.pyc +__pycache__/ +deploy/kubeconfig +all_recipes_extracted.json +backups/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a795ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ ./backend/ +COPY frontend/ ./frontend/ + +ENV DB_PATH=/data/oil_calculator.db +ENV FRONTEND_DIR=/app/frontend + +EXPOSE 8000 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fb6d174 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,306 @@ +import sqlite3 +import json +import os +import secrets + +DB_PATH = os.environ.get("DB_PATH", "/data/oil_calculator.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = get_db() + c = conn.cursor() + c.executescript(""" + CREATE TABLE IF NOT EXISTS oils ( + name TEXT PRIMARY KEY, + bottle_price REAL NOT NULL, + drop_count INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + note TEXT DEFAULT '', + sort_order INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS recipe_ingredients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + oil_name TEXT NOT NULL, + drops REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS tags ( + name TEXT PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS recipe_tags ( + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + tag_name TEXT NOT NULL, + PRIMARY KEY (recipe_id, tag_name) + ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + token TEXT UNIQUE NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + display_name TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + target_name TEXT, + detail TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS user_inventory ( + user_id INTEGER NOT NULL, + oil_name TEXT NOT NULL, + PRIMARY KEY (user_id, oil_name) + ); + CREATE TABLE IF NOT EXISTS profit_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + ingredients TEXT NOT NULL DEFAULT '[]', + pricing REAL DEFAULT 0, + note TEXT DEFAULT '', + created_by INTEGER, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS user_diary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_recipe_id INTEGER, + name TEXT NOT NULL, + ingredients TEXT NOT NULL DEFAULT '[]', + note TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS diary_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + diary_id INTEGER NOT NULL REFERENCES user_diary(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS user_favorites ( + user_id INTEGER NOT NULL, + recipe_id INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (user_id, recipe_id) + ); + CREATE TABLE IF NOT EXISTS search_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + query TEXT NOT NULL, + matched_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_role TEXT NOT NULL DEFAULT 'admin', + title TEXT NOT NULL, + body TEXT, + is_read INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS category_modules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + subtitle TEXT DEFAULT '', + icon TEXT DEFAULT '🌿', + bg_image TEXT DEFAULT '', + color_from TEXT DEFAULT '#7a9e7e', + color_to TEXT DEFAULT '#5a7d5e', + tag_name TEXT NOT NULL, + sort_order INTEGER DEFAULT 0 + ); + """) + + # Migration: add password and brand fields to users if missing + user_cols = [row[1] for row in c.execute("PRAGMA table_info(users)").fetchall()] + if "password" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN password TEXT") + + # Create bug_reports table + c.execute("""CREATE TABLE IF NOT EXISTS bug_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + content TEXT NOT NULL, + is_resolved INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + )""") + + # Migration: add priority to bug_reports + bug_cols = [row[1] for row in c.execute("PRAGMA table_info(bug_reports)").fetchall()] + if bug_cols and "priority" not in bug_cols: + c.execute("ALTER TABLE bug_reports ADD COLUMN priority INTEGER DEFAULT 2") + + # Create bug_comments table for activity log + c.execute("""CREATE TABLE IF NOT EXISTS bug_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bug_id INTEGER NOT NULL REFERENCES bug_reports(id) ON DELETE CASCADE, + user_id INTEGER, + action TEXT NOT NULL, + content TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )""") + if "qr_code" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN qr_code TEXT") + if "brand_logo" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN brand_logo TEXT") + if "brand_name" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN brand_name TEXT") + if "brand_bg" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN brand_bg TEXT") + if "brand_align" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'") + + # Migration: add tags to user_diary + diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()] + if diary_cols and "tags" not in diary_cols: + c.execute("ALTER TABLE user_diary ADD COLUMN tags TEXT DEFAULT '[]'") + + # Migration: business verification + if "business_verified" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN business_verified INTEGER DEFAULT 0") + + c.execute("""CREATE TABLE IF NOT EXISTS business_applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + business_name TEXT, + document TEXT, + status TEXT DEFAULT 'pending', + reject_reason TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + reviewed_at TEXT + )""") + + # Translation suggestions table + c.execute("""CREATE TABLE IF NOT EXISTS translation_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER, + recipe_name TEXT NOT NULL, + suggested_en TEXT NOT NULL, + user_id INTEGER, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')) + )""") + + # Migration: add reject_reason to business_applications if missing + biz_cols = [row[1] for row in c.execute("PRAGMA table_info(business_applications)").fetchall()] + if biz_cols and "reject_reason" not in biz_cols: + c.execute("ALTER TABLE business_applications ADD COLUMN reject_reason TEXT DEFAULT ''") + + # Migration: add target_user_id to notifications for user-specific notifications + notif_cols = [row[1] for row in c.execute("PRAGMA table_info(notifications)").fetchall()] + if "target_user_id" not in notif_cols: + c.execute("ALTER TABLE notifications ADD COLUMN target_user_id INTEGER") + + # Migration: add assigned_to to bug_reports + bug_cols2 = [row[1] for row in c.execute("PRAGMA table_info(bug_reports)").fetchall()] + if "assigned_to" not in bug_cols2: + c.execute("ALTER TABLE bug_reports ADD COLUMN assigned_to INTEGER") + + # Migration: add version to recipes for optimistic locking + recipe_cols = [row[1] for row in c.execute("PRAGMA table_info(recipes)").fetchall()] + if "version" not in recipe_cols: + c.execute("ALTER TABLE recipes ADD COLUMN version INTEGER DEFAULT 1") + + # Migration: add retail_price and is_active to oils if missing + oil_cols = [row[1] for row in c.execute("PRAGMA table_info(oils)").fetchall()] + if "retail_price" not in oil_cols: + c.execute("ALTER TABLE oils ADD COLUMN retail_price REAL") + if "is_active" not in oil_cols: + c.execute("ALTER TABLE oils ADD COLUMN is_active INTEGER DEFAULT 1") + + # Migration: add new columns to category_modules if missing + cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()] + if cat_cols and "bg_image" not in cat_cols: + c.execute("DROP TABLE category_modules") + c.execute("""CREATE TABLE category_modules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, subtitle TEXT DEFAULT '', icon TEXT DEFAULT '🌿', + bg_image TEXT DEFAULT '', color_from TEXT DEFAULT '#7a9e7e', color_to TEXT DEFAULT '#5a7d5e', + tag_name TEXT NOT NULL, sort_order INTEGER DEFAULT 0)""") + + # Migration: add owner_id to recipes if missing + cols = [row[1] for row in c.execute("PRAGMA table_info(recipes)").fetchall()] + if "owner_id" not in cols: + c.execute("ALTER TABLE recipes ADD COLUMN owner_id INTEGER") + if "updated_by" not in cols: + c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER") + + # Seed admin user if no users exist + count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if count == 0: + admin_token = os.environ.get("ADMIN_TOKEN", secrets.token_hex(24)) + c.execute( + "INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)", + ("hera", admin_token, "admin", "Hera"), + ) + admin_id = c.lastrowid + # Assign all existing recipes to admin + c.execute("UPDATE recipes SET owner_id = ? WHERE owner_id IS NULL", (admin_id,)) + print(f"[INIT] Admin user created. Token: {admin_token}") + + conn.commit() + conn.close() + + +def log_audit(conn, user_id, action, target_type=None, target_id=None, target_name=None, detail=None): + conn.execute( + "INSERT INTO audit_log (user_id, action, target_type, target_id, target_name, detail) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, action, target_type, str(target_id) if target_id else None, target_name, detail), + ) + + +def seed_defaults(default_oils_meta: dict, default_recipes: list): + """Seed DB with defaults if empty.""" + conn = get_db() + c = conn.cursor() + + # Seed oils + count = c.execute("SELECT COUNT(*) FROM oils").fetchone()[0] + if count == 0: + for name, meta in default_oils_meta.items(): + c.execute( + "INSERT OR IGNORE INTO oils (name, bottle_price, drop_count) VALUES (?, ?, ?)", + (name, meta["bottlePrice"], meta["dropCount"]), + ) + + # Seed recipes + count = c.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] + if count == 0: + # Get admin user id for ownership + admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin_id = admin[0] if admin else None + for r in default_recipes: + c.execute( + "INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", + (r["name"], r.get("note", ""), admin_id), + ) + rid = c.lastrowid + for ing in r["ingredients"]: + c.execute( + "INSERT INTO recipe_ingredients (recipe_id, oil_name, drops) VALUES (?, ?, ?)", + (rid, ing["oil"], ing["drops"]), + ) + for tag in r.get("tags", []): + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + c.execute( + "INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", + (rid, tag), + ) + + conn.commit() + conn.close() diff --git a/backend/defaults.json b/backend/defaults.json new file mode 100644 index 0000000..a8227f6 --- /dev/null +++ b/backend/defaults.json @@ -0,0 +1,8091 @@ +{ + "oils_meta": { + "小豆蔻": { + "bottlePrice": 250, + "dropCount": 93 + }, + "芹菜籽": { + "bottlePrice": 310, + "dropCount": 280 + }, + "芫荽": { + "bottlePrice": 220, + "dropCount": 280 + }, + "小茴香": { + "bottlePrice": 145, + "dropCount": 280 + }, + "生姜": { + "bottlePrice": 415, + "dropCount": 280 + }, + "姜黄": { + "bottlePrice": 275, + "dropCount": 280 + }, + "缬草": { + "bottlePrice": 485, + "dropCount": 280 + }, + "岩兰草": { + "bottlePrice": 520, + "dropCount": 280 + }, + "侧柏": { + "bottlePrice": 195, + "dropCount": 93 + }, + "桦木": { + "bottlePrice": 475, + "dropCount": 93 + }, + "雪松": { + "bottlePrice": 130, + "dropCount": 280 + }, + "斯里兰卡肉桂皮": { + "bottlePrice": 275, + "dropCount": 93 + }, + "夏威夷檀香": { + "bottlePrice": 615, + "dropCount": 93 + }, + "檀香": { + "bottlePrice": 715, + "dropCount": 93 + }, + "古巴香脂": { + "bottlePrice": 279, + "dropCount": 280 + }, + "乳香": { + "bottlePrice": 630, + "dropCount": 280 + }, + "枫香": { + "bottlePrice": 240, + "dropCount": 280 + }, + "没药": { + "bottlePrice": 585, + "dropCount": 280 + }, + "罗勒": { + "bottlePrice": 270, + "dropCount": 280 + }, + "黑云杉": { + "bottlePrice": 170, + "dropCount": 93 + }, + "芫荽叶": { + "bottlePrice": 230, + "dropCount": 280 + }, + "香茅": { + "bottlePrice": 170, + "dropCount": 280 + }, + "丝柏": { + "bottlePrice": 155, + "dropCount": 280 + }, + "道格拉斯冷杉": { + "bottlePrice": 195, + "dropCount": 93 + }, + "尤加利": { + "bottlePrice": 185, + "dropCount": 280 + }, + "扁柏": { + "bottlePrice": 230, + "dropCount": 93 + }, + "柠檬尤加利": { + "bottlePrice": 120, + "dropCount": 280 + }, + "柠檬草": { + "bottlePrice": 115, + "dropCount": 280 + }, + "马郁兰": { + "bottlePrice": 195, + "dropCount": 280 + }, + "香蜂草": { + "bottlePrice": 810, + "dropCount": 93 + }, + "麦卢卡": { + "bottlePrice": 430, + "dropCount": 93 + }, + "西班牙牛至": { + "bottlePrice": 225, + "dropCount": 280 + }, + "广藿香": { + "bottlePrice": 270, + "dropCount": 280 + }, + "椒样薄荷": { + "bottlePrice": 210, + "dropCount": 280 + }, + "苦橙叶": { + "bottlePrice": 220, + "dropCount": 280 + }, + "迷迭香": { + "bottlePrice": 175, + "dropCount": 280 + }, + "西伯利亚冷杉": { + "bottlePrice": 170, + "dropCount": 280 + }, + "西班牙鼠尾草": { + "bottlePrice": 230, + "dropCount": 280 + }, + "绿薄荷": { + "bottlePrice": 250, + "dropCount": 280 + }, + "茶树": { + "bottlePrice": 195, + "dropCount": 280 + }, + "百里香": { + "bottlePrice": 280, + "dropCount": 280 + }, + "冬青": { + "bottlePrice": 235, + "dropCount": 280 + }, + "蓝艾菊": { + "bottlePrice": 700, + "dropCount": 93 + }, + "快乐鼠尾草": { + "bottlePrice": 325, + "dropCount": 280 + }, + "丁香花蕾": { + "bottlePrice": 170, + "dropCount": 280 + }, + "天竺葵": { + "bottlePrice": 385, + "dropCount": 280 + }, + "永久花": { + "bottlePrice": 665, + "dropCount": 93 + }, + "茉莉": { + "bottlePrice": 1210, + "dropCount": 46 + }, + "薰衣草": { + "bottlePrice": 230, + "dropCount": 280 + }, + "罗马洋甘菊": { + "bottlePrice": 420, + "dropCount": 93 + }, + "依兰依兰": { + "bottlePrice": 350, + "dropCount": 280 + }, + "佛手柑": { + "bottlePrice": 345, + "dropCount": 280 + }, + "黑胡椒": { + "bottlePrice": 190, + "dropCount": 93 + }, + "圆柚": { + "bottlePrice": 165, + "dropCount": 280 + }, + "杜松浆果": { + "bottlePrice": 190, + "dropCount": 93 + }, + "柠檬": { + "bottlePrice": 120, + "dropCount": 280 + }, + "莱姆": { + "bottlePrice": 135, + "dropCount": 280 + }, + "山鸡椒": { + "bottlePrice": 190, + "dropCount": 280 + }, + "加州胡椒": { + "bottlePrice": 190, + "dropCount": 93 + }, + "红橘": { + "bottlePrice": 130, + "dropCount": 280 + }, + "野橘": { + "bottlePrice": 105, + "dropCount": 280 + }, + "乐释": { + "bottlePrice": 350, + "dropCount": 280 + }, + "赋活呼吸": { + "bottlePrice": 265, + "dropCount": 280 + }, + "芳香调理": { + "bottlePrice": 275, + "dropCount": 280 + }, + "安定情绪": { + "bottlePrice": 205, + "dropCount": 280 + }, + "柑橘清新": { + "bottlePrice": 190, + "dropCount": 280 + }, + "柑橘绚烂": { + "bottlePrice": 230, + "dropCount": 280 + }, + "温柔呵护": { + "bottlePrice": 445, + "dropCount": 280 + }, + "完美修护": { + "bottlePrice": 320, + "dropCount": 280 + }, + "舒缓": { + "bottlePrice": 305, + "dropCount": 93 + }, + "乐活": { + "bottlePrice": 305, + "dropCount": 280 + }, + "顺畅呼吸": { + "bottlePrice": 225, + "dropCount": 280 + }, + "愈创木": { + "bottlePrice": 160, + "dropCount": 280 + }, + "恬家": { + "bottlePrice": 220, + "dropCount": 280 + }, + "安宁神气": { + "bottlePrice": 335, + "dropCount": 280 + }, + "椰风香草": { + "bottlePrice": 310, + "dropCount": 93 + }, + "清醇薄荷": { + "bottlePrice": 255, + "dropCount": 280 + }, + "新瑞活力": { + "bottlePrice": 240, + "dropCount": 280 + }, + "保卫": { + "bottlePrice": 315, + "dropCount": 280 + }, + "净化清新": { + "bottlePrice": 205, + "dropCount": 280 + }, + "花样年华焕肤油": { + "bottlePrice": 680, + "dropCount": 186 + }, + "天然防护": { + "bottlePrice": 110, + "dropCount": 280 + }, + "西洋蓍草": { + "bottlePrice": 450, + "dropCount": 280 + }, + "元气": { + "bottlePrice": 230, + "dropCount": 280 + }, + "欢欣": { + "bottlePrice": 215, + "dropCount": 93 + }, + "抚慰": { + "bottlePrice": 335, + "dropCount": 93 + }, + "宽容": { + "bottlePrice": 195, + "dropCount": 93 + }, + "鼓舞": { + "bottlePrice": 205, + "dropCount": 93 + }, + "热情": { + "bottlePrice": 355, + "dropCount": 93 + }, + "静谧": { + "bottlePrice": 280, + "dropCount": 93 + }, + "椰子油": { + "bottlePrice": 115, + "dropCount": 2146 + }, + "植物空胶囊": { + "bottlePrice": 32.73, + "dropCount": 280 + }, + "玫瑰": { + "bottlePrice": 2680, + "dropCount": 93 + }, + "玫瑰呵护": { + "bottlePrice": 470, + "dropCount": 186 + }, + "茉莉呵护": { + "bottlePrice": 510, + "dropCount": 186 + }, + "橙花呵护": { + "bottlePrice": 430, + "dropCount": 186 + }, + "桂花呵护": { + "bottlePrice": 480, + "dropCount": 186 + }, + "木兰呵护": { + "bottlePrice": 380, + "dropCount": 186 + }, + "茶树呵护": { + "bottlePrice": 210, + "dropCount": 186 + }, + "特瑞活力": { + "bottlePrice": 350, + "dropCount": 280 + }, + "全神贯注": { + "bottlePrice": 320, + "dropCount": 186 + }, + "新清肌呵护": { + "bottlePrice": 260, + "dropCount": 186 + }, + "新清肌调理": { + "bottlePrice": 260, + "dropCount": 280 + }, + "当归": { + "bottlePrice": 450, + "dropCount": 93 + }, + "月桂叶": { + "bottlePrice": 280, + "dropCount": 280 + }, + "橙花": { + "bottlePrice": 1150, + "dropCount": 93 + }, + "桂花": { + "bottlePrice": 980, + "dropCount": 93 + }, + "穗甘松": { + "bottlePrice": 450, + "dropCount": 93 + }, + "玫瑰草": { + "bottlePrice": 180, + "dropCount": 280 + }, + "罗文莎叶": { + "bottlePrice": 250, + "dropCount": 280 + }, + "甜茴香": { + "bottlePrice": 180, + "dropCount": 280 + }, + "五味子": { + "bottlePrice": 280, + "dropCount": 280 + }, + "印蒿": { + "bottlePrice": 320, + "dropCount": 280 + }, + "柠檬香桃木": { + "bottlePrice": 250, + "dropCount": 280 + } + }, + "recipes": [ + { + "name": "酸痛包", + "note": "", + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "舒缓", + "drops": 2 + }, + { + "oil": "芳香调理", + "drops": 1 + }, + { + "oil": "冬青", + "drops": 1 + }, + { + "oil": "柠檬草", + "drops": 1 + }, + { + "oil": "生姜", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "小v脸", + "note": "", + "ingredients": [ + { + "oil": "丝柏", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "西洋蓍草", + "drops": 1 + }, + { + "oil": "永久花", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "健脾化湿精油浴", + "note": "", + "ingredients": [ + { + "oil": "西伯利亚冷杉", + "drops": 3 + }, + { + "oil": "芫荽", + "drops": 3 + }, + { + "oil": "红橘", + "drops": 2 + }, + { + "oil": "椰子油", + "drops": 20 + } + ], + "tags": [] + }, + { + "name": "一夜好眠精油浴", + "note": "", + "ingredients": [ + { + "oil": "安宁神气", + "drops": 1 + }, + { + "oil": "岩兰草", + "drops": 1 + }, + { + "oil": "乐释", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "安定情绪", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "生发", + "note": "", + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 1 + }, + { + "oil": "丝柏", + "drops": 2 + }, + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "雪松", + "drops": 2 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "安定情绪", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 15 + } + ], + "tags": [] + }, + { + "name": "湿疹舒缓", + "note": "", + "ingredients": [ + { + "oil": "广藿香", + "drops": 1 + }, + { + "oil": "绿薄荷", + "drops": 1 + }, + { + "oil": "麦卢卡", + "drops": 1 + }, + { + "oil": "永久花", + "drops": 1 + }, + { + "oil": "蓝艾菊", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "乳腺疏通", + "note": "", + "ingredients": [ + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "丁香花蕾", + "drops": 1 + }, + { + "oil": "柑橘清新", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "缓解酸痛精油刮痧", + "note": "", + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "舒缓", + "drops": 2 + }, + { + "oil": "芳香调理", + "drops": 1 + }, + { + "oil": "冬青", + "drops": 1 + }, + { + "oil": "柠檬草", + "drops": 1 + }, + { + "oil": "生姜", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "灰指甲", + "note": "", + "ingredients": [ + { + "oil": "西班牙牛至", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 6 + } + ], + "tags": [] + }, + { + "name": "白发转黑", + "note": "", + "ingredients": [ + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "快乐鼠尾草", + "drops": 1 + }, + { + "oil": "依兰依兰", + "drops": 1 + }, + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "扁柏", + "drops": 1 + }, + { + "oil": "雪松", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "紫外线修复", + "note": "有艾草时可加入艾草", + "ingredients": [ + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "西洋蓍草", + "drops": 1 + }, + { + "oil": "蓝艾菊", + "drops": 1 + }, + { + "oil": "麦卢卡", + "drops": 1 + }, + { + "oil": "侧柏", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 15 + } + ], + "tags": [] + }, + { + "name": "瘦身带脉", + "note": "", + "ingredients": [ + { + "oil": "丝柏", + "drops": 1 + }, + { + "oil": "圆柚", + "drops": 1 + }, + { + "oil": "新瑞活力", + "drops": 1 + }, + { + "oil": "永久花", + "drops": 1 + }, + { + "oil": "黑胡椒", + "drops": 1 + }, + { + "oil": "姜黄", + "drops": 1 + }, + { + "oil": "柠檬", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 15 + } + ], + "tags": [] + }, + { + "name": "私密护理", + "note": "", + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 2 + }, + { + "oil": "没药", + "drops": 2 + }, + { + "oil": "快乐鼠尾草", + "drops": 1 + }, + { + "oil": "依兰依兰", + "drops": 1 + }, + { + "oil": "茶树", + "drops": 0.5 + }, + { + "oil": "丝柏", + "drops": 0.5 + }, + { + "oil": "椰子油", + "drops": 8 + }, + { + "oil": "植物空胶囊", + "drops": 1 + } + ], + "tags": [] + }, + { + "name": "脚气/头皮屑", + "note": "", + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 6 + } + ], + "tags": [] + }, + { + "name": "痘痘", + "note": "", + "ingredients": [ + { + "oil": "广藿香", + "drops": 1 + }, + { + "oil": "姜黄", + "drops": 1 + }, + { + "oil": "西洋蓍草", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "麦卢卡", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 20 + } + ], + "tags": [] + }, + { + "name": "淋巴排毒", + "note": "", + "ingredients": [ + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "丝柏", + "drops": 1 + }, + { + "oil": "圆柚", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 1 + }, + { + "oil": "杜松浆果", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "驱寒/祛湿精油浴", + "note": "有艾草时可加入", + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 3 + }, + { + "oil": "广藿香", + "drops": 3 + }, + { + "oil": "生姜", + "drops": 2 + }, + { + "oil": "椰子油", + "drops": 20 + } + ], + "tags": [] + }, + { + "name": "荷尔蒙调节/更年期护理精油浴", + "note": "", + "ingredients": [ + { + "oil": "依兰依兰", + "drops": 2 + }, + { + "oil": "温柔呵护", + "drops": 2 + }, + { + "oil": "天竺葵", + "drops": 2 + }, + { + "oil": "快乐鼠尾草", + "drops": 2 + }, + { + "oil": "岩兰草", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "情绪管理", + "note": "", + "ingredients": [ + { + "oil": "抚慰", + "drops": 1 + }, + { + "oil": "热情", + "drops": 1 + }, + { + "oil": "欢欣", + "drops": 1 + }, + { + "oil": "鼓舞", + "drops": 1 + }, + { + "oil": "宽容", + "drops": 1 + }, + { + "oil": "静谧", + "drops": 1 + }, + { + "oil": "椰子油", + "drops": 10 + } + ], + "tags": [] + }, + { + "name": "发膜", + "note": "", + "ingredients": [ + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 2 + }, + { + "oil": "生姜", + "drops": 2 + } + ], + "tags": [] + }, + { + "name": "脾胃养护1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "红橘", + "drops": 20 + }, + { + "oil": "乐活", + "drops": 20 + }, + { + "oil": "广藿香", + "drops": 15 + }, + { + "oil": "岩兰草", + "drops": 10 + } + ] + }, + { + "name": "脾胃养护2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "红橘", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "乐活", + "drops": 15 + } + ] + }, + { + "name": "招财开运油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 1 + }, + { + "oil": "岩兰草", + "drops": 1 + }, + { + "oil": "天竺葵", + "drops": 2 + }, + { + "oil": "佛手柑", + "drops": 10 + }, + { + "oil": "野橘", + "drops": 20 + } + ] + }, + { + "name": "植物热玛吉(玫瑰纯油版)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 20 + }, + { + "oil": "玫瑰", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "植物热玛吉(玫瑰呵护版)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 20 + }, + { + "oil": "玫瑰", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "十全大补", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "快乐鼠尾草", + "drops": 15 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 8 + }, + { + "oil": "玫瑰呵护", + "drops": 15 + }, + { + "oil": "茉莉呵护", + "drops": 15 + }, + { + "oil": "温柔呵护", + "drops": 10 + } + ] + }, + { + "name": "豪华头疗", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 25 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "夏威夷檀香", + "drops": 10 + }, + { + "oil": "完美修护", + "drops": 20 + }, + { + "oil": "乳香", + "drops": 20 + }, + { + "oil": "生姜", + "drops": 15 + }, + { + "oil": "雪松", + "drops": 15 + }, + { + "oil": "马郁兰", + "drops": 10 + }, + { + "oil": "安定情绪", + "drops": 20 + }, + { + "oil": "依兰依兰", + "drops": 10 + } + ] + }, + { + "name": "酸痛包1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "舒缓", + "drops": 10 + }, + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "冬青", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "酸痛包2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "舒缓", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "明目青睐1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柠檬草", + "drops": 6 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 9 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + } + ] + }, + { + "name": "明目青睐2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "芳香调理", + "drops": 3 + }, + { + "oil": "柠檬草", + "drops": 3 + }, + { + "oil": "快乐鼠尾草", + "drops": 7 + }, + { + "oil": "乳香", + "drops": 7 + } + ] + }, + { + "name": "带脉排毒瘦腰1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "广藿香", + "drops": 5 + }, + { + "oil": "黑胡椒", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 20 + }, + { + "oil": "新瑞活力", + "drops": 30 + }, + { + "oil": "丝柏", + "drops": 30 + }, + { + "oil": "乳香", + "drops": 20 + }, + { + "oil": "杜松浆果", + "drops": 20 + } + ] + }, + { + "name": "带脉瘦腰2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "新瑞活力", + "drops": 10 + } + ] + }, + { + "name": "缓解头痛(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "罗勒", + "drops": 15 + }, + { + "oil": "马郁兰", + "drops": 15 + }, + { + "oil": "舒缓", + "drops": 50 + } + ] + }, + { + "name": "1清咽止咳(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 15 + }, + { + "oil": "尤加利", + "drops": 15 + }, + { + "oil": "小豆蔻", + "drops": 10 + }, + { + "oil": "顺畅呼吸", + "drops": 20 + }, + { + "oil": "西伯利亚冷杉", + "drops": 10 + } + ] + }, + { + "name": "清咽止咳2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "保卫", + "drops": 5 + }, + { + "oil": "顺畅呼吸", + "drops": 10 + } + ] + }, + { + "name": "提升免疫(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西班牙牛至", + "drops": 5 + }, + { + "oil": "侧柏", + "drops": 10 + }, + { + "oil": "百里香", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 20 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "保卫", + "drops": 10 + } + ] + }, + { + "name": "护肝排毒(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柠檬", + "drops": 20 + }, + { + "oil": "元气", + "drops": 20 + }, + { + "oil": "当归", + "drops": 1 + }, + { + "oil": "芹菜籽", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 9 + }, + { + "oil": "迷迭香", + "drops": 15 + } + ] + }, + { + "name": "强心护心(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "百里香", + "drops": 10 + }, + { + "oil": "香蜂草", + "drops": 15 + }, + { + "oil": "古巴香脂", + "drops": 20 + }, + { + "oil": "依兰依兰", + "drops": 15 + }, + { + "oil": "快乐鼠尾草", + "drops": 15 + } + ] + }, + { + "name": "通鼻消炎(强)1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "尤加利", + "drops": 10 + }, + { + "oil": "蓝艾菊", + "drops": 5 + }, + { + "oil": "迷迭香", + "drops": 10 + }, + { + "oil": "顺畅呼吸", + "drops": 20 + } + ] + }, + { + "name": "通鼻消炎2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "尤加利", + "drops": 10 + }, + { + "oil": "顺畅呼吸", + "drops": 15 + } + ] + }, + { + "name": "过敏湿疹(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 10 + }, + { + "oil": "蓝艾菊", + "drops": 20 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "香蜂草", + "drops": 15 + }, + { + "oil": "绿薄荷", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 10 + }, + { + "oil": "广藿香", + "drops": 15 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "强肾化水(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茉莉呵护", + "drops": 10 + }, + { + "oil": "檀香", + "drops": 15 + }, + { + "oil": "黑胡椒", + "drops": 10 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "古巴香脂", + "drops": 10 + }, + { + "oil": "杜松浆果", + "drops": 20 + } + ] + }, + { + "name": "防疫病毒(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "尤加利", + "drops": 5 + }, + { + "oil": "侧柏", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "保卫", + "drops": 10 + } + ] + }, + { + "name": "消富贵包(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "芳香调理", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "舒缓", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 10 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "淋巴排毒(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柑橘清新", + "drops": 5 + }, + { + "oil": "丁香花蕾", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "没药", + "drops": 20 + }, + { + "oil": "乳香", + "drops": 20 + } + ] + }, + { + "name": "祛湿化滞(强)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "黑胡椒", + "drops": 8 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 6 + }, + { + "oil": "圆柚", + "drops": 9 + }, + { + "oil": "岩兰草", + "drops": 8 + }, + { + "oil": "广藿香", + "drops": 9 + }, + { + "oil": "姜黄", + "drops": 10 + }, + { + "oil": "西洋蓍草", + "drops": 10 + } + ] + }, + { + "name": "肺部结节", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "柑橘清新", + "drops": 5 + }, + { + "oil": "赋活呼吸", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 10 + } + ] + }, + { + "name": "尿路感染1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "保卫", + "drops": 5 + }, + { + "oil": "柠檬", + "drops": 5 + } + ] + }, + { + "name": "尿路感染2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西班牙牛至", + "drops": 1 + }, + { + "oil": "柠檬草", + "drops": 1 + } + ] + }, + { + "name": "养前列腺", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "檀香", + "drops": 3 + }, + { + "oil": "杜松浆果", + "drops": 8 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 2 + }, + { + "oil": "芳香调理", + "drops": 6 + }, + { + "oil": "茉莉", + "drops": 1 + }, + { + "oil": "快乐鼠尾草", + "drops": 3 + }, + { + "oil": "五味子", + "drops": 15 + }, + { + "oil": "百里香", + "drops": 2 + } + ] + }, + { + "name": "皮外损伤", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 8 + }, + { + "oil": "没药", + "drops": 4 + }, + { + "oil": "永久花", + "drops": 4 + } + ] + }, + { + "name": "消脂肪肝", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "元气", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "柠檬", + "drops": 3 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "新瑞活力", + "drops": 10 + } + ] + }, + { + "name": "平衡血糖", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "新瑞活力", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 3 + }, + { + "oil": "迷迭香", + "drops": 3 + }, + { + "oil": "芫荽", + "drops": 3 + }, + { + "oil": "山鸡椒", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 3 + } + ] + }, + { + "name": "肚痛腹泻", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乐活", + "drops": 16 + }, + { + "oil": "柠檬草", + "drops": 8 + }, + { + "oil": "生姜", + "drops": 6 + }, + { + "oil": "西班牙牛至", + "drops": 6 + }, + { + "oil": "罗勒", + "drops": 4 + } + ] + }, + { + "name": "韧带扭伤", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "芳香调理", + "drops": 8 + }, + { + "oil": "姜黄", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 8 + }, + { + "oil": "西班牙牛至", + "drops": 3 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 2 + }, + { + "oil": "古巴香脂", + "drops": 6 + } + ] + }, + { + "name": "血脂稳定", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "新瑞活力", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "小茴香", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "元气", + "drops": 5 + } + ] + }, + { + "name": "尿床尿频", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 16 + }, + { + "oil": "柠檬草", + "drops": 12 + }, + { + "oil": "侧柏", + "drops": 8 + }, + { + "oil": "檀香", + "drops": 4 + } + ] + }, + { + "name": "冻疮修复", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "黑胡椒", + "drops": 3 + }, + { + "oil": "没药", + "drops": 3 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 10 + } + ] + }, + { + "name": "皮下囊肿", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 5 + }, + { + "oil": "广藿香", + "drops": 10 + }, + { + "oil": "丁香花蕾", + "drops": 5 + } + ] + }, + { + "name": "疏肝解郁", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "芹菜籽", + "drops": 2 + }, + { + "oil": "岩兰草", + "drops": 5 + }, + { + "oil": "元气", + "drops": 8 + }, + { + "oil": "佛手柑", + "drops": 5 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + } + ] + }, + { + "name": "大脑抗衰", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "特瑞活力", + "drops": 10 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "乐释", + "drops": 5 + }, + { + "oil": "广藿香", + "drops": 5 + }, + { + "oil": "古巴香脂", + "drops": 5 + } + ] + }, + { + "name": "神经麻木", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "特瑞活力", + "drops": 10 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "古巴香脂", + "drops": 10 + }, + { + "oil": "马郁兰", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "口唇疱疹1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "罗马洋甘菊", + "drops": 7 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "玫瑰草", + "drops": 3 + }, + { + "oil": "没药", + "drops": 5 + } + ] + }, + { + "name": "口唇疱疹2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 6 + }, + { + "oil": "罗马洋甘菊", + "drops": 6 + }, + { + "oil": "罗文莎叶", + "drops": 6 + } + ] + }, + { + "name": "春季用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "黑云杉", + "drops": 3 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "芹菜籽", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "莱姆", + "drops": 10 + }, + { + "oil": "元气", + "drops": 10 + } + ] + }, + { + "name": "夏季用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 3 + }, + { + "oil": "西伯利亚冷杉", + "drops": 5 + }, + { + "oil": "广藿香", + "drops": 7 + }, + { + "oil": "乐活", + "drops": 10 + } + ] + }, + { + "name": "秋季用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西伯利亚冷杉", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "小豆蔻", + "drops": 3 + }, + { + "oil": "丁香花蕾", + "drops": 2 + }, + { + "oil": "迷迭香", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "顺畅呼吸", + "drops": 10 + } + ] + }, + { + "name": "冬季用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茉莉呵护", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 5 + }, + { + "oil": "黑胡椒", + "drops": 2 + }, + { + "oil": "檀香", + "drops": 3 + }, + { + "oil": "五味子", + "drops": 3 + }, + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "祛湿丸子", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "斯里兰卡肉桂皮", + "drops": 1 + }, + { + "oil": "圆柚", + "drops": 2 + }, + { + "oil": "姜黄", + "drops": 2 + }, + { + "oil": "黑胡椒", + "drops": 1 + }, + { + "oil": "岩兰草", + "drops": 2 + }, + { + "oil": "广藿香", + "drops": 2 + }, + { + "oil": "西洋蓍草", + "drops": 2 + } + ] + }, + { + "name": "三伏排湿", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "元气", + "drops": 5 + }, + { + "oil": "新瑞活力", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "广藿香", + "drops": 5 + } + ] + }, + { + "name": "三伏晒背", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "蓝艾菊", + "drops": 3 + }, + { + "oil": "西洋蓍草", + "drops": 10 + } + ] + }, + { + "name": "三伏百会", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "特瑞活力", + "drops": 10 + }, + { + "oil": "古巴香脂", + "drops": 10 + } + ] + }, + { + "name": "三伏八髎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "印蒿", + "drops": 3 + }, + { + "oil": "温柔呵护", + "drops": 4 + }, + { + "oil": "完美修护", + "drops": 6 + }, + { + "oil": "花样年华焕肤油", + "drops": 4 + } + ] + }, + { + "name": "三伏大椎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "斯里兰卡肉桂皮", + "drops": 5 + }, + { + "oil": "檀香", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "广藿香", + "drops": 10 + } + ] + }, + { + "name": "三伏檀中", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 2 + }, + { + "oil": "香蜂草", + "drops": 30 + } + ] + }, + { + "name": "太伏太溪", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "元气", + "drops": 15 + }, + { + "oil": "特瑞活力", + "drops": 15 + } + ] + }, + { + "name": "三伏扶阳", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 8 + }, + { + "oil": "广藿香", + "drops": 3 + }, + { + "oil": "依兰依兰", + "drops": 3 + }, + { + "oil": "生姜", + "drops": 8 + }, + { + "oil": "檀香", + "drops": 5 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 4 + }, + { + "oil": "当归", + "drops": 2 + }, + { + "oil": "黑胡椒", + "drops": 8 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "杜松浆果", + "drops": 8 + } + ] + }, + { + "name": "三阴交油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 6 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "杜松浆果", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 4 + } + ] + }, + { + "name": "八虚用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "元气", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "新瑞活力", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + } + ] + }, + { + "name": "冬日小火炉", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "山鸡椒", + "drops": 6 + }, + { + "oil": "生姜", + "drops": 6 + }, + { + "oil": "黑胡椒", + "drops": 6 + }, + { + "oil": "茉莉", + "drops": 2 + }, + { + "oil": "柠檬草", + "drops": 6 + }, + { + "oil": "天竺葵", + "drops": 6 + }, + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "小茴香", + "drops": 4 + } + ] + }, + { + "name": "女性内分泌", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "依兰依兰", + "drops": 5 + }, + { + "oil": "快乐鼠尾草", + "drops": 5 + }, + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 5 + } + ] + }, + { + "name": "提升免疫力", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "茶树", + "drops": 20 + }, + { + "oil": "保卫", + "drops": 20 + } + ] + }, + { + "name": "面部精华", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 10 + } + ] + }, + { + "name": "日常头疗", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "野橘", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "安定情绪", + "drops": 7 + } + ] + }, + { + "name": "肝肾保护", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "杜松浆果", + "drops": 5 + }, + { + "oil": "柠檬", + "drops": 10 + }, + { + "oil": "元气", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + } + ] + }, + { + "name": "情绪能量油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "柑橘绚烂", + "drops": 10 + }, + { + "oil": "柑橘清新", + "drops": 10 + }, + { + "oil": "安宁神气", + "drops": 5 + }, + { + "oil": "安定情绪", + "drops": 5 + } + ] + }, + { + "name": "养心宁神", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "依兰依兰", + "drops": 5 + }, + { + "oil": "古巴香脂", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 15 + } + ] + }, + { + "name": "安稳睡眠", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "安定情绪", + "drops": 10 + } + ] + }, + { + "name": "过敏体质", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 20 + }, + { + "oil": "椒样薄荷", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "柠檬", + "drops": 10 + } + ] + }, + { + "name": "淋巴结节", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 7 + }, + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 7 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "芳香调理", + "drops": 5 + } + ] + }, + { + "name": "甲状腺结节", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "丁香花蕾", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "皮肤过敏", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 8 + }, + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "雪松", + "drops": 5 + }, + { + "oil": "侧柏", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "止鼾安睡", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "安宁神气", + "drops": 10 + }, + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "道格拉斯冷杉", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "顺畅呼吸", + "drops": 10 + } + ] + }, + { + "name": "哮喘缓解", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "顺畅呼吸", + "drops": 10 + }, + { + "oil": "蓝艾菊", + "drops": 5 + }, + { + "oil": "柠檬", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "赋活呼吸", + "drops": 10 + } + ] + }, + { + "name": "带状疱疹", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "广藿香", + "drops": 3 + }, + { + "oil": "佛手柑", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "丁香花蕾", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "香蜂草", + "drops": 3 + }, + { + "oil": "完美修护", + "drops": 7 + }, + { + "oil": "没药", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 7 + } + ] + }, + { + "name": "夏日社痱", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 8 + }, + { + "oil": "椒样薄荷", + "drops": 8 + }, + { + "oil": "薰衣草", + "drops": 6 + }, + { + "oil": "广藿香", + "drops": 8 + } + ] + }, + { + "name": "耳聋耳鸣", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "香蜂草", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "减脂瘦身", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 8 + }, + { + "oil": "杜松浆果", + "drops": 10 + }, + { + "oil": "圆柚", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 7 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "新瑞活力", + "drops": 20 + } + ] + }, + { + "name": "痔疮用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "茶树", + "drops": 20 + } + ] + }, + { + "name": "静脉曲张1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "甜茴香", + "drops": 7 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 8 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + } + ] + }, + { + "name": "静脉曲张2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 3 + }, + { + "oil": "黑胡椒", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 8 + }, + { + "oil": "丝柏", + "drops": 10 + } + ] + }, + { + "name": "痛风缓解", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柠檬草", + "drops": 8 + }, + { + "oil": "西伯利亚冷杉", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 10 + }, + { + "oil": "舒缓", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "香蜂草", + "drops": 3 + } + ] + }, + { + "name": "滑膜炎症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "冬青", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "道格拉斯冷杉", + "drops": 5 + } + ] + }, + { + "name": "腱鞘炎症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 10 + } + ] + }, + { + "name": "腰椎滑脱", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "舒缓", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "檀香", + "drops": 5 + }, + { + "oil": "冬青", + "drops": 5 + }, + { + "oil": "马郁兰", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "暖宫调经1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "黑胡椒", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "温柔呵护", + "drops": 25 + }, + { + "oil": "快乐鼠尾草", + "drops": 15 + } + ] + }, + { + "name": "暖宫调经2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 6 + }, + { + "oil": "黑胡椒", + "drops": 6 + }, + { + "oil": "天竺葵", + "drops": 3 + }, + { + "oil": "温柔呵护", + "drops": 17 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + } + ] + }, + { + "name": "经期止痛", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "黑胡椒", + "drops": 5 + }, + { + "oil": "舒缓", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 20 + }, + { + "oil": "薰衣草", + "drops": 10 + } + ] + }, + { + "name": "乳腺增生", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "丁香花蕾", + "drops": 5 + }, + { + "oil": "柑橘清新", + "drops": 10 + } + ] + }, + { + "name": "丰胸挺拨", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "小茴香", + "drops": 20 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 30 + }, + { + "oil": "西洋蓍草", + "drops": 30 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + } + ] + }, + { + "name": "私密紧致", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "玫瑰呵护", + "drops": 10 + }, + { + "oil": "茉莉呵护", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 15 + } + ] + }, + { + "name": "乳腺结节1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "柑橘清新", + "drops": 5 + }, + { + "oil": "没药", + "drops": 10 + }, + { + "oil": "姜黄", + "drops": 10 + }, + { + "oil": "丁香花蕾", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 20 + } + ] + }, + { + "name": "乳腺结节2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丁香花蕾", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "柑橘清新", + "drops": 10 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 5 + } + ] + }, + { + "name": "手脚冰凉", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 20 + }, + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "岩兰草", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 10 + }, + { + "oil": "黑胡椒", + "drops": 15 + }, + { + "oil": "野橘", + "drops": 10 + } + ] + }, + { + "name": "修复腹直肌", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 10 + }, + { + "oil": "广藿香", + "drops": 10 + }, + { + "oil": "柠檬草", + "drops": 10 + } + ] + }, + { + "name": "子宫肌瘤1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "安定情绪", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 3 + }, + { + "oil": "姜黄", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "子宫肌瘤2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 6 + }, + { + "oil": "天竺葵", + "drops": 10 + } + ] + }, + { + "name": "卵巢囊肿1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 8 + }, + { + "oil": "完美修护", + "drops": 5 + }, + { + "oil": "温柔呵护", + "drops": 50 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 8 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "安定情绪", + "drops": 8 + }, + { + "oil": "乳香", + "drops": 6 + } + ] + }, + { + "name": "卵巢囊肿2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "温柔呵护", + "drops": 2 + }, + { + "oil": "西班牙牛至", + "drops": 1 + }, + { + "oil": "快乐鼠尾草", + "drops": 1 + } + ] + }, + { + "name": "美背", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 10 + }, + { + "oil": "黑云杉", + "drops": 15 + }, + { + "oil": "雪松", + "drops": 10 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "佛手柑", + "drops": 15 + }, + { + "oil": "圆柚", + "drops": 10 + }, + { + "oil": "玫瑰呵护", + "drops": 15 + }, + { + "oil": "当归", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "减轻妊娠纹", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "永久花", + "drops": 7 + }, + { + "oil": "广藿香", + "drops": 5 + }, + { + "oil": "没药", + "drops": 4 + }, + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "薰衣草", + "drops": 7 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + } + ] + }, + { + "name": "更年期症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柠檬", + "drops": 10 + }, + { + "oil": "椒样薄荷", + "drops": 8 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "温柔呵护", + "drops": 30 + }, + { + "oil": "快乐鼠尾草", + "drops": 20 + }, + { + "oil": "罗马洋甘菊", + "drops": 12 + } + ] + }, + { + "name": "妇科炎症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 10 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 20 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "杜松浆果", + "drops": 10 + } + ] + }, + { + "name": "备孕调理", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 5 + }, + { + "oil": "温柔呵护", + "drops": 10 + } + ] + }, + { + "name": "抗衰紧致", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丝柏", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "玫瑰", + "drops": 3 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 20 + } + ] + }, + { + "name": "美白淡斑1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "玫瑰呵护", + "drops": 5 + }, + { + "oil": "丁香花蕾", + "drops": 2 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "扁柏", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "岩兰草", + "drops": 5 + }, + { + "oil": "黑云杉", + "drops": 5 + }, + { + "oil": "芹菜籽", + "drops": 8 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + } + ] + }, + { + "name": "美白淡斑2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "广藿香", + "drops": 3 + }, + { + "oil": "芹菜籽", + "drops": 3 + }, + { + "oil": "黑云杉", + "drops": 3 + }, + { + "oil": "西洋蓍草", + "drops": 3 + }, + { + "oil": "橙花呵护", + "drops": 4 + }, + { + "oil": "花样年华焕肤油", + "drops": 3 + } + ] + }, + { + "name": "水油平衡", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "新清肌调理", + "drops": 15 + }, + { + "oil": "天竺葵", + "drops": 3 + }, + { + "oil": "雪松", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "麦卢卡", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + } + ] + }, + { + "name": "敏感肌", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "夏威夷檀香", + "drops": 2 + }, + { + "oil": "岩兰草", + "drops": 3 + }, + { + "oil": "广藿香", + "drops": 5 + }, + { + "oil": "罗马洋甘菊", + "drops": 10 + } + ] + }, + { + "name": "防晒修复", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "椒样薄荷", + "drops": 4 + }, + { + "oil": "永久花", + "drops": 8 + }, + { + "oil": "柠檬草", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + } + ] + }, + { + "name": "深层净化排毒", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树呵护", + "drops": 20 + }, + { + "oil": "杜松浆果", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 20 + }, + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "蓝艾菊", + "drops": 3 + }, + { + "oil": "丝柏", + "drops": 3 + }, + { + "oil": "侧柏", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 10 + } + ] + }, + { + "name": "蓝月光贵妇面霜", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "玫瑰呵护", + "drops": 10 + }, + { + "oil": "芹菜籽", + "drops": 2 + }, + { + "oil": "永久花", + "drops": 2 + }, + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "蓝艾菊", + "drops": 5 + } + ] + }, + { + "name": "唇部保养", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 3 + }, + { + "oil": "没药", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "麦卢卡", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "皱纹推土机", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "花样年华焕肤油", + "drops": 10 + }, + { + "oil": "桂花呵护", + "drops": 6 + }, + { + "oil": "麦卢卡", + "drops": 4 + }, + { + "oil": "永久花", + "drops": 4 + }, + { + "oil": "黑云杉", + "drops": 5 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "穗甘松", + "drops": 6 + }, + { + "oil": "依兰依兰", + "drops": 10 + } + ] + }, + { + "name": "祛除颈纹", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "穗甘松", + "drops": 10 + } + ] + }, + { + "name": "蓝带神仙水", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "蓝艾菊", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 10 + } + ] + }, + { + "name": "全效紧致乳", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "花样年华焕肤油", + "drops": 50 + }, + { + "oil": "橙花呵护", + "drops": 50 + }, + { + "oil": "茉莉呵护", + "drops": 50 + }, + { + "oil": "古巴香脂", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 10 + }, + { + "oil": "玫瑰", + "drops": 5 + } + ] + }, + { + "name": "眼袋黑眼圈", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "快乐鼠尾草", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 2 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "西洋蓍草", + "drops": 5 + } + ] + }, + { + "name": "清痘无痕", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "新清肌呵护", + "drops": 20 + }, + { + "oil": "净化清新", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + }, + { + "oil": "茶树", + "drops": 10 + } + ] + }, + { + "name": "脂肪粒", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "西班牙牛至", + "drops": 3 + }, + { + "oil": "芫荽", + "drops": 3 + }, + { + "oil": "雪松", + "drops": 3 + }, + { + "oil": "茶树", + "drops": 3 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + } + ] + }, + { + "name": "植物蜂皮", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "橙花呵护", + "drops": 10 + }, + { + "oil": "桂花呵护", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "没药", + "drops": 3 + }, + { + "oil": "麦卢卡", + "drops": 3 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + } + ] + }, + { + "name": "植物水光针", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "芹菜籽", + "drops": 5 + }, + { + "oil": "玫瑰", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "木兰呵护", + "drops": 20 + } + ] + }, + { + "name": "早C精华", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 20 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "夏威夷檀香", + "drops": 3 + }, + { + "oil": "穗甘松", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "玫瑰呵护", + "drops": 10 + } + ] + }, + { + "name": "晚A精华", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "花样年华焕肤油", + "drops": 10 + }, + { + "oil": "麦卢卡", + "drops": 3 + }, + { + "oil": "芹菜籽", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "茉莉呵护", + "drops": 10 + }, + { + "oil": "橙花呵护", + "drops": 10 + }, + { + "oil": "雪松", + "drops": 5 + } + ] + }, + { + "name": "消鸡皮肤", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "圆柚", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "枫香", + "drops": 5 + }, + { + "oil": "柠檬香桃木", + "drops": 5 + }, + { + "oil": "安定情绪", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 6 + } + ] + }, + { + "name": "解荨麻疹", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + }, + { + "oil": "罗马洋甘菊", + "drops": 6 + }, + { + "oil": "薰衣草", + "drops": 4 + }, + { + "oil": "蓝艾菊", + "drops": 6 + } + ] + }, + { + "name": "保湿焕肤", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "没药", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "夏威夷檀香", + "drops": 5 + }, + { + "oil": "玫瑰", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "玫瑰草", + "drops": 5 + } + ] + }, + { + "name": "奶油桂花手霜", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "桂花", + "drops": 8 + }, + { + "oil": "薰衣草", + "drops": 8 + }, + { + "oil": "天竺葵", + "drops": 8 + }, + { + "oil": "椰风香草", + "drops": 8 + } + ] + }, + { + "name": "养护指甲", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "夏威夷檀香", + "drops": 10 + }, + { + "oil": "罗马洋甘菊", + "drops": 10 + }, + { + "oil": "没药", + "drops": 10 + } + ] + }, + { + "name": "皮肤皲裂/脚后跟干裂", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 8 + }, + { + "oil": "没药", + "drops": 8 + } + ] + }, + { + "name": "黑绷带", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "广藿香", + "drops": 6 + }, + { + "oil": "岩兰草", + "drops": 6 + }, + { + "oil": "穗甘松", + "drops": 6 + }, + { + "oil": "夏威夷檀香", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 10 + }, + { + "oil": "没药", + "drops": 10 + }, + { + "oil": "蓝艾菊", + "drops": 10 + } + ] + }, + { + "name": "眉毛睫毛增长滋养液", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 8 + }, + { + "oil": "雪松", + "drops": 8 + }, + { + "oil": "依兰依兰", + "drops": 6 + }, + { + "oil": "薰衣草", + "drops": 8 + } + ] + }, + { + "name": "天鹅颈", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "穗甘松", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "舒缓", + "drops": 10 + }, + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "芳香调理", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "收缩毛孔", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "依兰依兰", + "drops": 7 + }, + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 10 + } + ] + }, + { + "name": "酒糟鼻", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "岩兰草", + "drops": 8 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 8 + } + ] + }, + { + "name": "疤痕修复", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 4 + }, + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "薰衣草", + "drops": 7 + }, + { + "oil": "永久花", + "drops": 7 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + } + ] + }, + { + "name": "太阳油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "依兰依兰", + "drops": 15 + }, + { + "oil": "广藿香", + "drops": 15 + }, + { + "oil": "雪松", + "drops": 15 + }, + { + "oil": "檀香", + "drops": 15 + }, + { + "oil": "杜松浆果", + "drops": 15 + } + ] + }, + { + "name": "月亮油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "温柔呵护", + "drops": 15 + }, + { + "oil": "茉莉呵护", + "drops": 10 + }, + { + "oil": "玫瑰呵护", + "drops": 10 + }, + { + "oil": "佛手柑", + "drops": 5 + }, + { + "oil": "小茴香", + "drops": 5 + }, + { + "oil": "依兰依兰", + "drops": 7 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + } + ] + }, + { + "name": "阿育吠陀", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "姜黄", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "杜松浆果", + "drops": 10 + }, + { + "oil": "苦橙叶", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "柠檬", + "drops": 10 + }, + { + "oil": "圆柚", + "drops": 5 + } + ] + }, + { + "name": "缓解头痛", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 5 + }, + { + "oil": "马郁兰", + "drops": 7 + }, + { + "oil": "舒缓", + "drops": 15 + } + ] + }, + { + "name": "白发变黑(女士)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 7 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + }, + { + "oil": "依兰依兰", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 7 + }, + { + "oil": "薰衣草", + "drops": 5 + } + ] + }, + { + "name": "白发变黑(男士)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "迷迭香", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "雪松", + "drops": 10 + }, + { + "oil": "永久花", + "drops": 5 + } + ] + }, + { + "name": "头屑清爽", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "雪松", + "drops": 20 + }, + { + "oil": "迷迭香", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 15 + }, + { + "oil": "茶树", + "drops": 20 + } + ] + }, + { + "name": "口腔溃疡1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丁香花蕾", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "没药", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "口腔溃疡2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "麦卢卡", + "drops": 10 + }, + { + "oil": "月桂叶", + "drops": 10 + }, + { + "oil": "佛手柑", + "drops": 20 + }, + { + "oil": "薰衣草", + "drops": 20 + } + ] + }, + { + "name": "扁桃腺炎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "百里香", + "drops": 5 + }, + { + "oil": "小豆蔻", + "drops": 5 + }, + { + "oil": "尤加利", + "drops": 5 + }, + { + "oil": "保卫", + "drops": 5 + }, + { + "oil": "顺畅呼吸", + "drops": 5 + } + ] + }, + { + "name": "慢性阑尾炎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "西班牙牛至", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 3 + }, + { + "oil": "乐活", + "drops": 10 + }, + { + "oil": "新瑞活力", + "drops": 5 + }, + { + "oil": "完美修护", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "结石、胆囊炎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "冬青", + "drops": 5 + }, + { + "oil": "柠檬", + "drops": 10 + }, + { + "oil": "天竺葵", + "drops": 5 + }, + { + "oil": "迷迭香", + "drops": 5 + }, + { + "oil": "圆柚", + "drops": 10 + }, + { + "oil": "莱姆", + "drops": 10 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "脚气、灰指甲", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "香蜂草", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 7 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "西班牙牛至", + "drops": 10 + }, + { + "oil": "广藿香", + "drops": 5 + } + ] + }, + { + "name": "甲亢、甲减", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 9 + }, + { + "oil": "丁香花蕾", + "drops": 12 + }, + { + "oil": "柠檬草", + "drops": 15 + }, + { + "oil": "没药", + "drops": 12 + }, + { + "oil": "乳香", + "drops": 12 + } + ] + }, + { + "name": "降压仪式", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "香蜂草", + "drops": 8 + }, + { + "oil": "马郁兰", + "drops": 15 + }, + { + "oil": "乳香", + "drops": 15 + }, + { + "oil": "薰衣草", + "drops": 12 + } + ] + }, + { + "name": "伤筋动骨", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "西班牙牛至", + "drops": 10 + }, + { + "oil": "冬青", + "drops": 15 + }, + { + "oil": "柠檬草", + "drops": 15 + }, + { + "oil": "舒缓", + "drops": 5 + } + ] + }, + { + "name": "干眼症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "薰衣草", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + }, + { + "oil": "古巴香脂", + "drops": 5 + }, + { + "oil": "完美修护", + "drops": 5 + } + ] + }, + { + "name": "儿童抚触", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 3 + }, + { + "oil": "西伯利亚冷杉", + "drops": 5 + }, + { + "oil": "乐活", + "drops": 5 + }, + { + "oil": "没药", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "儿童脾胃", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "红橘", + "drops": 10 + }, + { + "oil": "小茴香", + "drops": 2 + }, + { + "oil": "生姜", + "drops": 3 + }, + { + "oil": "乐活", + "drops": 5 + } + ] + }, + { + "name": "视力养护", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "柠檬草", + "drops": 3 + }, + { + "oil": "永久花", + "drops": 2 + }, + { + "oil": "快乐鼠尾草", + "drops": 5 + } + ] + }, + { + "name": "驱蚊喷雾", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "柠檬尤加利", + "drops": 5 + }, + { + "oil": "尤加利", + "drops": 5 + }, + { + "oil": "香茅", + "drops": 5 + }, + { + "oil": "天竺葵", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "天然防护", + "drops": 20 + } + ] + }, + { + "name": "个子高高", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "檀香", + "drops": 3 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "芳香调理", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "西伯利亚冷杉", + "drops": 5 + } + ] + }, + { + "name": "清咽止咳", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "西伯利亚冷杉", + "drops": 3 + }, + { + "oil": "尤加利", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 2 + }, + { + "oil": "顺畅呼吸", + "drops": 5 + } + ] + }, + { + "name": "通鼻消炎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 2 + }, + { + "oil": "椒样薄荷", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "蓝艾菊", + "drops": 1 + }, + { + "oil": "尤加利", + "drops": 3 + }, + { + "oil": "迷迭香", + "drops": 2 + }, + { + "oil": "顺畅呼吸", + "drops": 5 + } + ] + }, + { + "name": "蚊虫叮咬1", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "罗勒", + "drops": 3 + }, + { + "oil": "天然防护", + "drops": 5 + } + ] + }, + { + "name": "蚊虫叮咬2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "香茅", + "drops": 5 + }, + { + "oil": "柠檬草", + "drops": 5 + }, + { + "oil": "天然防护", + "drops": 10 + } + ] + }, + { + "name": "学霸神助", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "檀香", + "drops": 2 + }, + { + "oil": "完美修护", + "drops": 2 + }, + { + "oil": "椒样薄荷", + "drops": 4 + }, + { + "oil": "全神贯注", + "drops": 8 + }, + { + "oil": "罗勒", + "drops": 2 + }, + { + "oil": "迷迭香", + "drops": 4 + }, + { + "oil": "乳香", + "drops": 4 + } + ] + }, + { + "name": "磨牙安抚", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "佛手柑", + "drops": 5 + }, + { + "oil": "罗勒", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "古巴香脂", + "drops": 5 + }, + { + "oil": "安定情绪", + "drops": 5 + } + ] + }, + { + "name": "免疫助力", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "野橘", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 5 + }, + { + "oil": "侧柏", + "drops": 3 + }, + { + "oil": "姜黄", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "腺样体肥大", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 3 + }, + { + "oil": "百里香", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 3 + }, + { + "oil": "尤加利", + "drops": 2 + }, + { + "oil": "雪松", + "drops": 3 + }, + { + "oil": "顺畅呼吸", + "drops": 5 + } + ] + }, + { + "name": "退烧方案", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "顺畅呼吸", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 5 + }, + { + "oil": "椒样薄荷", + "drops": 5 + }, + { + "oil": "茶树", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "手足口症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 3 + }, + { + "oil": "香蜂草", + "drops": 3 + }, + { + "oil": "丁香花蕾", + "drops": 3 + }, + { + "oil": "月桂叶", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 3 + }, + { + "oil": "古巴香脂", + "drops": 3 + } + ] + }, + { + "name": "湿疹修复", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "蓝艾菊", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 3 + }, + { + "oil": "广藿香", + "drops": 3 + }, + { + "oil": "罗马洋甘菊", + "drops": 5 + } + ] + }, + { + "name": "抽动症", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗马洋甘菊", + "drops": 3 + }, + { + "oil": "安定情绪", + "drops": 5 + }, + { + "oil": "雪松", + "drops": 3 + }, + { + "oil": "岩兰草", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 5 + } + ] + }, + { + "name": "头疗生发", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 10 + }, + { + "oil": "茶树", + "drops": 10 + }, + { + "oil": "迷迭香", + "drops": 10 + }, + { + "oil": "丝柏", + "drops": 20 + }, + { + "oil": "生姜", + "drops": 10 + }, + { + "oil": "雪松", + "drops": 20 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 14 + }, + { + "oil": "安定情绪", + "drops": 14 + } + ] + }, + { + "name": "1、呼吸系统细胞律动(含香蜂草)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "顺畅呼吸", + "drops": 4 + }, + { + "oil": "迷迭香", + "drops": 4 + }, + { + "oil": "尤加利", + "drops": 4 + }, + { + "oil": "香蜂草", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "1、呼吸系统细胞律动(不含香蜂草)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "顺畅呼吸", + "drops": 4 + }, + { + "oil": "迷迭香", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "2、神经系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "百里香", + "drops": 4 + }, + { + "oil": "丁香花蕾", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "香蜂草", + "drops": 4 + }, + { + "oil": "广藿香", + "drops": 4 + }, + { + "oil": "佛手柑", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "3、消化系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "百里香", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "佛手柑", + "drops": 4 + }, + { + "oil": "芫荽", + "drops": 4 + }, + { + "oil": "乐活", + "drops": 4 + }, + { + "oil": "生姜", + "drops": 4 + }, + { + "oil": "天竺葵", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "4、骨骼系统细胞律动(炎症控制)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "冬青", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "西伯利亚冷杉", + "drops": 4 + }, + { + "oil": "舒缓", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "5、淋巴系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "迷迭香", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "新瑞活力", + "drops": 4 + }, + { + "oil": "元气", + "drops": 4 + }, + { + "oil": "柠檬", + "drops": 4 + }, + { + "oil": "圆柚", + "drops": 4 + }, + { + "oil": "生姜", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "6、生殖系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "西班牙牛至", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "薰衣草", + "drops": 4 + }, + { + "oil": "广藿香", + "drops": 4 + }, + { + "oil": "芫荽", + "drops": 4 + }, + { + "oil": "快乐鼠尾草", + "drops": 4 + }, + { + "oil": "檀香", + "drops": 4 + }, + { + "oil": "丁香花蕾", + "drops": 4 + }, + { + "oil": "依兰依兰", + "drops": 4 + }, + { + "oil": "天竺葵", + "drops": 4 + }, + { + "oil": "温柔呵护", + "drops": 4 + }, + { + "oil": "元气", + "drops": 4 + }, + { + "oil": "温柔呵护", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "7、免疫系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "西班牙牛至", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "西伯利亚冷杉", + "drops": 4 + }, + { + "oil": "丝柏", + "drops": 4 + }, + { + "oil": "柠檬", + "drops": 4 + }, + { + "oil": "莱姆", + "drops": 4 + }, + { + "oil": "圆柚", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "丁香花蕾", + "drops": 4 + }, + { + "oil": "百里香", + "drops": 4 + }, + { + "oil": "元气", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "8、循环系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "百里香", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "马郁兰", + "drops": 4 + }, + { + "oil": "罗勒", + "drops": 4 + }, + { + "oil": "薰衣草", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "9、内分泌系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "快乐鼠尾草", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "天竺葵", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "依兰依兰", + "drops": 4 + }, + { + "oil": "小茴香", + "drops": 4 + }, + { + "oil": "薰衣草", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "10、感冒发烧系统细胞律动", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "西班牙牛至", + "drops": 4 + }, + { + "oil": "百里香", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "尤加利", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "11、肌肉系统细胞律动(缓解疼痛)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "生姜", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "冬青", + "drops": 4 + }, + { + "oil": "柠檬草", + "drops": 4 + }, + { + "oil": "西伯利亚冷杉", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "12、芳香调理技术", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "安定情绪", + "drops": 4 + }, + { + "oil": "薰衣草", + "drops": 4 + }, + { + "oil": "茶树", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "芳香调理", + "drops": 4 + }, + { + "oil": "舒缓", + "drops": 4 + }, + { + "oil": "野橘", + "drops": 4 + }, + { + "oil": "椒样薄荷", + "drops": 4 + } + ] + }, + { + "name": "芳心调理技术(单瓶购买)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "静谧", + "drops": 4 + }, + { + "oil": "抚慰", + "drops": 4 + }, + { + "oil": "宽容", + "drops": 4 + }, + { + "oil": "热情", + "drops": 4 + }, + { + "oil": "欢欣", + "drops": 4 + }, + { + "oil": "鼓舞", + "drops": 4 + } + ] + }, + { + "name": "芳心调理技术(套装购买)", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "静谧", + "drops": 4 + }, + { + "oil": "抚慰", + "drops": 4 + }, + { + "oil": "宽容", + "drops": 4 + }, + { + "oil": "热情", + "drops": 4 + }, + { + "oil": "欢欣", + "drops": 4 + }, + { + "oil": "鼓舞", + "drops": 4 + } + ] + }, + { + "name": "头疼", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "尤加利", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "冬青", + "drops": 2 + }, + { + "oil": "椒样薄荷", + "drops": 2 + } + ] + }, + { + "name": "痤疮粉刺", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + } + ] + }, + { + "name": "植物热玛吉", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "玫瑰", + "drops": 5 + }, + { + "oil": "丝柏", + "drops": 10 + }, + { + "oil": "西洋蓍草", + "drops": 20 + }, + { + "oil": "永久花", + "drops": 5 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "近视老花", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + }, + { + "oil": "花样年华焕肤油", + "drops": 10 + } + ] + }, + { + "name": "记忆力", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "罗勒", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 2 + }, + { + "oil": "佛手柑", + "drops": 2 + } + ] + }, + { + "name": "皮肤老化", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "花样年华焕肤油", + "drops": 3 + }, + { + "oil": "西洋蓍草", + "drops": 3 + } + ] + }, + { + "name": "生发配方", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "雪松", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "扁柏", + "drops": 1 + } + ] + }, + { + "name": "头皮屑", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "丝柏", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 1 + } + ] + }, + { + "name": "生发2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 2 + }, + { + "oil": "迷迭香", + "drops": 3 + }, + { + "oil": "雪松", + "drops": 4 + } + ] + }, + { + "name": "白发转黑发", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "完美修护", + "drops": 16 + }, + { + "oil": "乳香", + "drops": 16 + }, + { + "oil": "雪松", + "drops": 16 + }, + { + "oil": "薰衣草", + "drops": 16 + }, + { + "oil": "丝柏", + "drops": 16 + }, + { + "oil": "快乐鼠尾草", + "drops": 16 + }, + { + "oil": "生姜", + "drops": 16 + }, + { + "oil": "侧柏", + "drops": 16 + }, + { + "oil": "扁柏", + "drops": 16 + }, + { + "oil": "依兰依兰", + "drops": 16 + }, + { + "oil": "迷迭香", + "drops": 16 + }, + { + "oil": "檀香", + "drops": 10 + }, + { + "oil": "西伯利亚冷杉", + "drops": 10 + } + ] + }, + { + "name": "鼻炎用油", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "香蜂草", + "drops": 4 + }, + { + "oil": "顺畅呼吸", + "drops": 20 + }, + { + "oil": "椒样薄荷", + "drops": 4 + }, + { + "oil": "罗勒", + "drops": 6 + }, + { + "oil": "尤加利", + "drops": 6 + } + ] + }, + { + "name": "中耳炎", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "罗勒", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 2 + }, + { + "oil": "薰衣草", + "drops": 2 + } + ] + }, + { + "name": "口臭", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "绿薄荷", + "drops": 1 + } + ] + }, + { + "name": "牙龈牙周", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "丁香花蕾", + "drops": 1 + } + ] + }, + { + "name": "烧烫伤", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 2 + }, + { + "oil": "茶树", + "drops": 2 + }, + { + "oil": "薰衣草", + "drops": 2 + } + ] + }, + { + "name": "儿童长高", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西伯利亚冷杉", + "drops": 6 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "檀香", + "drops": 3 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "芳香调理", + "drops": 2 + } + ] + }, + { + "name": "湿疹", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "绿薄荷", + "drops": 1 + }, + { + "oil": "侧柏", + "drops": 5 + }, + { + "oil": "蓝艾菊", + "drops": 5 + }, + { + "oil": "广藿香", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + } + ] + }, + { + "name": "退烧神器", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "杜松浆果", + "drops": 3 + }, + { + "oil": "椒样薄荷", + "drops": 4 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "山鸡椒", + "drops": 3 + }, + { + "oil": "保卫", + "drops": 4 + }, + { + "oil": "柠檬", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 4 + } + ] + }, + { + "name": "感冒咳嗽", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "柠檬", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "西班牙牛至", + "drops": 4 + }, + { + "oil": "保卫", + "drops": 5 + } + ] + }, + { + "name": "腹泻", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西班牙牛至", + "drops": 1 + }, + { + "oil": "乐活", + "drops": 1 + }, + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "罗勒", + "drops": 1 + } + ] + }, + { + "name": "晕车恶心", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 1 + }, + { + "oil": "尤加利", + "drops": 1 + }, + { + "oil": "佛手柑", + "drops": 1 + }, + { + "oil": "椒样薄荷", + "drops": 1 + }, + { + "oil": "乐活", + "drops": 1 + } + ] + }, + { + "name": "醉酒", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乐活", + "drops": 2 + } + ] + }, + { + "name": "鸡眼/疣/痣", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "丁香花蕾", + "drops": 1 + }, + { + "oil": "西班牙牛至", + "drops": 1 + } + ] + }, + { + "name": "脚气", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "西班牙牛至", + "drops": 1 + } + ] + }, + { + "name": "灰指甲2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 8 + }, + { + "oil": "西班牙牛至", + "drops": 8 + }, + { + "oil": "百里香", + "drops": 8 + } + ] + }, + { + "name": "丰胸挺拔", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "小茴香", + "drops": 6 + }, + { + "oil": "依兰依兰", + "drops": 4 + }, + { + "oil": "温柔呵护", + "drops": 10 + }, + { + "oil": "西洋蓍草", + "drops": 10 + }, + { + "oil": "快乐鼠尾草", + "drops": 3 + } + ] + }, + { + "name": "痛经", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "斯里兰卡肉桂皮", + "drops": 1 + }, + { + "oil": "玫瑰", + "drops": 1 + }, + { + "oil": "丁香花蕾", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 2 + } + ] + }, + { + "name": "亲密关系", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "快乐鼠尾草", + "drops": 6 + }, + { + "oil": "茉莉呵护", + "drops": 12 + }, + { + "oil": "檀香", + "drops": 7 + }, + { + "oil": "依兰依兰", + "drops": 6 + }, + { + "oil": "花样年华焕肤油", + "drops": 5 + }, + { + "oil": "玫瑰呵护", + "drops": 12 + } + ] + }, + { + "name": "前列腺养护", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "西班牙牛至", + "drops": 2 + }, + { + "oil": "檀香", + "drops": 3 + }, + { + "oil": "茉莉呵护", + "drops": 4 + }, + { + "oil": "丝柏", + "drops": 3 + }, + { + "oil": "乳香", + "drops": 4 + }, + { + "oil": "永久花", + "drops": 3 + }, + { + "oil": "迷迭香", + "drops": 3 + }, + { + "oil": "快乐鼠尾草", + "drops": 4 + } + ] + }, + { + "name": "手脚冰冷", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "生姜", + "drops": 2 + }, + { + "oil": "圆柚", + "drops": 2 + } + ] + }, + { + "name": "外阴瘙痒", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "没药", + "drops": 1 + }, + { + "oil": "玫瑰草", + "drops": 1 + }, + { + "oil": "佛手柑", + "drops": 1 + } + ] + }, + { + "name": "淋巴排毒2", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "丝柏", + "drops": 1 + }, + { + "oil": "迷迭香", + "drops": 1 + }, + { + "oil": "柠檬草", + "drops": 1 + }, + { + "oil": "永久花", + "drops": 1 + }, + { + "oil": "新瑞活力", + "drops": 1 + } + ] + }, + { + "name": "痔疮", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "没药", + "drops": 1 + }, + { + "oil": "茶树", + "drops": 1 + }, + { + "oil": "丝柏", + "drops": 2 + }, + { + "oil": "永久花", + "drops": 2 + } + ] + }, + { + "name": "便秘积食", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乐活", + "drops": 1 + }, + { + "oil": "柠檬", + "drops": 1 + }, + { + "oil": "马郁兰", + "drops": 1 + }, + { + "oil": "椒样薄荷", + "drops": 1 + } + ] + }, + { + "name": "护肝排毒", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "当归", + "drops": 1 + }, + { + "oil": "芫荽", + "drops": 2 + }, + { + "oil": "元气", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 3 + }, + { + "oil": "迷迭香", + "drops": 7 + }, + { + "oil": "柠檬", + "drops": 13 + } + ] + }, + { + "name": "痛风", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "百里香", + "drops": 6 + }, + { + "oil": "杜松浆果", + "drops": 8 + }, + { + "oil": "天竺葵", + "drops": 10 + }, + { + "oil": "冬青", + "drops": 10 + } + ] + }, + { + "name": "消除富贵包", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "柠檬草", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 5 + }, + { + "oil": "芳香调理", + "drops": 5 + }, + { + "oil": "生姜", + "drops": 2 + }, + { + "oil": "舒缓", + "drops": 3 + }, + { + "oil": "古巴香脂", + "drops": 2 + } + ] + }, + { + "name": "焦虑", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "野橘", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 2 + }, + { + "oil": "天竺葵", + "drops": 2 + } + ] + }, + { + "name": "更年期", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "椒样薄荷", + "drops": 2 + }, + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "薰衣草", + "drops": 3 + }, + { + "oil": "天竺葵", + "drops": 4 + }, + { + "oil": "温柔呵护", + "drops": 8 + }, + { + "oil": "快乐鼠尾草", + "drops": 10 + }, + { + "oil": "罗马洋甘菊", + "drops": 8 + } + ] + }, + { + "name": "失眠多梦", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "雪松", + "drops": 1 + }, + { + "oil": "野橘", + "drops": 1 + }, + { + "oil": "乳香", + "drops": 1 + }, + { + "oil": "檀香", + "drops": 1 + }, + { + "oil": "橙花", + "drops": 1 + }, + { + "oil": "乐释", + "drops": 1 + }, + { + "oil": "苦橙叶", + "drops": 1 + }, + { + "oil": "薰衣草", + "drops": 1 + }, + { + "oil": "马郁兰", + "drops": 1 + }, + { + "oil": "佛手柑", + "drops": 1 + }, + { + "oil": "岩兰草", + "drops": 1 + }, + { + "oil": "罗马洋甘菊", + "drops": 1 + } + ] + }, + { + "name": "稳定血糖", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "新瑞活力", + "drops": 2 + }, + { + "oil": "斯里兰卡肉桂皮", + "drops": 2 + }, + { + "oil": "芫荽", + "drops": 2 + }, + { + "oil": "迷迭香", + "drops": 2 + } + ] + }, + { + "name": "心脑血管护理", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 2 + }, + { + "oil": "香蜂草", + "drops": 1 + } + ] + }, + { + "name": "关节疼痛", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "乳香", + "drops": 3 + }, + { + "oil": "舒缓", + "drops": 2 + }, + { + "oil": "道格拉斯冷杉", + "drops": 2 + }, + { + "oil": "柠檬草", + "drops": 1 + } + ] + }, + { + "name": "高血压保健", + "note": "", + "tags": [], + "ingredients": [ + { + "oil": "依兰依兰", + "drops": 10 + }, + { + "oil": "薰衣草", + "drops": 10 + }, + { + "oil": "马郁兰", + "drops": 10 + }, + { + "oil": "乳香", + "drops": 10 + }, + { + "oil": "香蜂草", + "drops": 5 + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d9e0e0b --- /dev/null +++ b/backend/main.py @@ -0,0 +1,1497 @@ +from fastapi import FastAPI, HTTPException, Request, Depends +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import Optional +import json +import os + +from backend.database import get_db, init_db, seed_defaults, log_audit + +app = FastAPI(title="Essential Oil Formula Calculator API") + +# Periodic WAL checkpoint to ensure data is flushed to main DB file +import threading, time as _time +def _wal_checkpoint_loop(): + while True: + _time.sleep(300) # Every 5 minutes + try: + conn = get_db() + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.close() + except: pass +threading.Thread(target=_wal_checkpoint_loop, daemon=True).start() + + +# ── Auth ──────────────────────────────────────────────── +ANON_USER = {"id": None, "role": "viewer", "username": "anonymous", "display_name": "匿名用户"} + + +def get_current_user(request: Request): + token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip() + if not token: + return ANON_USER + conn = get_db() + user = conn.execute("SELECT id, username, role, display_name, password, business_verified FROM users WHERE token = ?", (token,)).fetchone() + conn.close() + if not user: + return ANON_USER + return dict(user) + + +def require_role(*roles): + """Returns a dependency that checks the user has one of the given roles.""" + def checker(user=Depends(get_current_user)): + if user["role"] not in roles: + raise HTTPException(403, "权限不足") + return user + return checker + + +# ── Models ────────────────────────────────────────────── +class OilIn(BaseModel): + name: str + bottle_price: float + drop_count: int + retail_price: Optional[float] = None + + +class IngredientIn(BaseModel): + oil_name: str + drops: float + + +class RecipeIn(BaseModel): + name: str + note: str = "" + ingredients: list[IngredientIn] + tags: list[str] = [] + + +class RecipeUpdate(BaseModel): + name: Optional[str] = None + note: Optional[str] = None + ingredients: Optional[list[IngredientIn]] = None + tags: Optional[list[str]] = None + version: Optional[int] = None + + +class UserIn(BaseModel): + username: str + role: str = "viewer" + display_name: str = "" + + +class UserUpdate(BaseModel): + role: Optional[str] = None + display_name: Optional[str] = None + + +# ── Me ────────────────────────────────────────────────── +APP_VERSION = "20260401" + +@app.get("/api/version") +def get_version(): + return {"version": APP_VERSION} + + +@app.get("/api/me") +def get_me(user=Depends(get_current_user)): + return {"username": user["username"], "role": user["role"], "display_name": user.get("display_name", ""), "id": user.get("id"), "has_password": bool(user.get("password")), "business_verified": bool(user.get("business_verified"))} + + +# ── Bug Reports ───────────────────────────────────────── +@app.post("/api/bug-report", status_code=201) +def submit_bug(body: dict, user=Depends(get_current_user)): + content = body.get("content", "").strip() + if not content: + raise HTTPException(400, "请输入内容") + conn = get_db() + who = user.get("display_name") or user.get("username") or "匿名" + # Admin can set priority; user-submitted bugs default to urgent (0) + default_pri = 2 if user.get("role") == "admin" else 0 + priority = body.get("priority", default_pri) + c = conn.execute("INSERT INTO bug_reports (user_id, content, priority) VALUES (?, ?, ?)", (user.get("id"), content, priority)) + bug_id = c.lastrowid + # Auto-log: created + conn.execute( + "INSERT INTO bug_comments (bug_id, user_id, action, content) VALUES (?, ?, ?, ?)", + (bug_id, user.get("id"), "创建", content), + ) + # Only notify admin if submitter is NOT admin + if user.get("role") != "admin": + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "🐛 Bug 反馈", f"{who}:{content}\n[bug_id:{bug_id}]") + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/bug-reports") +def list_bugs(user=Depends(get_current_user)): + conn = get_db() + if user["role"] == "admin": + rows = conn.execute( + "SELECT b.id, b.content, b.is_resolved, b.created_at, b.user_id, b.priority, b.assigned_to, " + "u.display_name, u.username, a.display_name as assigned_name " + "FROM bug_reports b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN users a ON b.assigned_to = a.id " + "ORDER BY b.priority ASC, COALESCE((SELECT MAX(c.created_at) FROM bug_comments c WHERE c.bug_id=b.id), b.created_at) DESC" + ).fetchall() + else: + rows = conn.execute( + "SELECT b.id, b.content, b.is_resolved, b.created_at, b.user_id, b.priority, b.assigned_to, " + "u.display_name, u.username, a.display_name as assigned_name " + "FROM bug_reports b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN users a ON b.assigned_to = a.id " + "WHERE b.user_id = ? OR b.assigned_to = ? OR b.is_resolved IN (1, 3) " + "ORDER BY b.priority ASC, COALESCE((SELECT MAX(c.created_at) FROM bug_comments c WHERE c.bug_id=b.id), b.created_at) DESC", + (user.get("id"), user.get("id")) + ).fetchall() + bugs = [dict(r) for r in rows] + # Attach comments/log for each bug + for bug in bugs: + comments = conn.execute( + "SELECT c.id, c.action, c.content, c.created_at, u.display_name, u.username " + "FROM bug_comments c LEFT JOIN users u ON c.user_id = u.id " + "WHERE c.bug_id = ? ORDER BY c.created_at ASC", + (bug["id"],) + ).fetchall() + bug["comments"] = [dict(c) for c in comments] + conn.close() + return bugs + + +@app.put("/api/bug-reports/{bug_id}") +def update_bug(bug_id: int, body: dict, user=Depends(get_current_user)): + conn = get_db() + bug = conn.execute("SELECT user_id, content, is_resolved FROM bug_reports WHERE id = ?", (bug_id,)).fetchone() + if not bug: + conn.close() + raise HTTPException(404, "Bug not found") + + # status: 0=open, 1=待测试, 2=已修复(admin only), 3=已测试(tester feedback) + status_names = {0: "待处理", 1: "待测试", 2: "已修复", 3: "已测试"} + new_status = body.get("status") + note = body.get("note", "").strip() + + if new_status is not None: + # Only admin can mark as resolved (status 2) + if new_status == 2 and user["role"] != "admin": + conn.close() + raise HTTPException(403, "只有管理员可以标记为已修复") + + conn.execute("UPDATE bug_reports SET is_resolved = ? WHERE id = ?", (new_status, bug_id)) + + # Auto-log the status change + action = "→ " + status_names.get(new_status, str(new_status)) + conn.execute( + "INSERT INTO bug_comments (bug_id, user_id, action, content) VALUES (?, ?, ?, ?)", + (bug_id, user.get("id"), action, note), + ) + + notify_user_id = body.get("notify_user_id") + if new_status == 1 and notify_user_id: + # Admin sends to specific tester — user-targeted notification + conn.execute("UPDATE bug_reports SET assigned_to = ? WHERE id = ?", (notify_user_id, bug_id)) + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (notify_user_id,)).fetchone() + if target: + msg = "请帮忙测试「" + bug["content"][:80] + "」" + if note: + msg += "\n\n备注:" + note + msg += "\n[bug_id:" + str(bug_id) + "]" + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (target["role"], "🔧 Bug 待测试", msg, notify_user_id) + ) + elif new_status == 1 and bug["user_id"]: + # Admin marks as testing → notify reporter + reporter = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (bug["user_id"],)).fetchone() + if reporter: + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (reporter["role"], "🔧 你的 Bug 已修复,请测试", + "请帮忙测试「" + bug["content"][:80] + "」" + ("\n\n备注:" + note if note else "")) + ) + if reporter["role"] in ("viewer", "editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("senior_editor", "🔧 Bug 待测试(来自" + (reporter["display_name"] or reporter["username"]) + ")", + "问题「" + bug["content"][:50] + "」已修复,请协助测试确认。") + ) + elif new_status == 3: + # Tester confirms tested → notify admin + who = user.get("display_name") or user.get("username") or "测试者" + msg = who + " 已测试「" + bug["content"][:50] + "」" + if note: + msg += "\n\n备注:" + note + msg += "\n[bug_id:" + str(bug_id) + "]" + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "🧪 Bug 已测试", msg) + ) + elif new_status == 2: + # Admin marks as resolved + pass # No notification needed, admin did it themselves + + if "content" in body: + conn.execute("UPDATE bug_reports SET content = ? WHERE id = ?", (body["content"], bug_id)) + if "priority" in body: + conn.execute("UPDATE bug_reports SET priority = ? WHERE id = ?", (body["priority"], bug_id)) + + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/bug-reports/{bug_id}/comment") +@app.delete("/api/bug-reports/{bug_id}") +def delete_bug(bug_id: int, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("DELETE FROM bug_comments WHERE bug_id = ?", (bug_id,)) + conn.execute("DELETE FROM bug_reports WHERE id = ?", (bug_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +def add_bug_comment(bug_id: int, body: dict, user=Depends(get_current_user)): + """Add a manual comment/note to a bug's log.""" + content = body.get("content", "").strip() + if not content: + raise HTTPException(400, "请输入内容") + conn = get_db() + bug = conn.execute("SELECT id FROM bug_reports WHERE id = ?", (bug_id,)).fetchone() + if not bug: + conn.close() + raise HTTPException(404, "Bug not found") + conn.execute( + "INSERT INTO bug_comments (bug_id, user_id, action, content) VALUES (?, ?, ?, ?)", + (bug_id, user.get("id"), "备注", content), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/symptom-search") +def symptom_search(body: dict, user=Depends(get_current_user)): + """Search recipes by symptom, notify editors if no good match.""" + query = body.get("query", "").strip() + if not query: + return {"recipes": [], "exact": False} + conn = get_db() + # Search in recipe names + rows = conn.execute( + "SELECT id, name, note, owner_id, version FROM recipes ORDER BY id" + ).fetchall() + exact = [] + related = [] + for r in rows: + if query in r["name"]: + exact.append(_recipe_to_dict(conn, r)) + elif any(c in r["name"] for c in query): + related.append(_recipe_to_dict(conn, r)) + + # If user reports no match, notify editors + if body.get("report_missing"): + who = user.get("display_name") or user.get("username") or "用户" + for role in ("admin", "senior_editor", "editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "🔍 用户需求:" + query, + f"{who} 搜索了「{query}」,没有找到满意的配方,请考虑添加。") + ) + conn.execute("INSERT INTO search_log (user_id, query, matched_count) VALUES (?, ?, ?)", + (user.get("id"), query, 0)) + conn.commit() + + conn.close() + return {"exact": exact, "related": related[:20]} + + +# ── Register ──────────────────────────────────────────── +@app.post("/api/register", status_code=201) +def register(body: dict): + import secrets + username = body.get("username", "").strip() + password = body.get("password", "").strip() + display_name = body.get("display_name", "").strip() + if not username or len(username) < 2: + raise HTTPException(400, "用户名至少2个字符") + if not password or len(password) < 4: + raise HTTPException(400, "密码至少4位") + token = secrets.token_hex(24) + conn = get_db() + try: + conn.execute( + "INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)", + (username, token, "viewer", display_name or username, password) + ) + conn.commit() + except Exception: + conn.close() + raise HTTPException(400, "用户名已被占用") + conn.close() + return {"token": token} + + +# ── Login ─────────────────────────────────────────────── +@app.post("/api/login") +def login(body: dict): + username = body.get("username", "").strip() + password = body.get("password", "").strip() + if not username or not password: + raise HTTPException(400, "请输入用户名和密码") + conn = get_db() + user = conn.execute("SELECT token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone() + conn.close() + if not user: + raise HTTPException(401, "用户名不存在") + if not user["password"]: + raise HTTPException(401, "该账号未设置密码,请使用链接登录后设置密码") + if user["password"] != password: + raise HTTPException(401, "密码错误") + return {"token": user["token"], "display_name": user["display_name"], "role": user["role"]} + + +@app.put("/api/me") +def update_me(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + # Update display_name + if "display_name" in body: + dn = body["display_name"].strip() + if not dn: + conn.close() + raise HTTPException(400, "昵称不能为空") + conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (dn, user["id"])) + # Update username + if "username" in body: + un = body["username"].strip() + if not un or len(un) < 2: + conn.close() + raise HTTPException(400, "用户名至少2个字符") + existing = conn.execute("SELECT id FROM users WHERE username = ? AND id != ?", (un, user["id"])).fetchone() + if existing: + conn.close() + raise HTTPException(400, "用户名已被占用") + conn.execute("UPDATE users SET username = ? WHERE id = ?", (un, user["id"])) + # Update password (requires old password verification) + if "password" in body: + pw = body["password"].strip() + if pw and len(pw) < 4: + conn.close() + raise HTTPException(400, "新密码至少4位") + old_pw = body.get("old_password", "").strip() + current_pw = user.get("password") or "" + if current_pw and old_pw != current_pw: + conn.close() + raise HTTPException(400, "当前密码不正确") + if pw: + conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"])) + conn.commit() + conn.close() + return {"ok": True} + + +@app.put("/api/me/password") +def set_password(body: dict, user=Depends(get_current_user)): + """Legacy endpoint, kept for compatibility.""" + if not user["id"]: + raise HTTPException(403, "请先登录") + pw = body.get("password", "").strip() + if not pw or len(pw) < 4: + raise HTTPException(400, "密码至少4位") + conn = get_db() + conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"])) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Business Verification ────────────────────────────── +@app.post("/api/business-apply", status_code=201) +def business_apply(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + # Check if already has pending application + existing = conn.execute( + "SELECT id, status FROM business_applications WHERE user_id = ? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + if existing and existing["status"] == "pending": + conn.close() + raise HTTPException(400, "已有待审核的申请") + if user.get("business_verified"): + conn.close() + raise HTTPException(400, "已是认证商业用户") + business_name = body.get("business_name", "").strip() + document = body.get("document", "") # base64 image + if not business_name: + conn.close() + raise HTTPException(400, "请填写商户名称") + if document and len(document) > 2000000: + conn.close() + raise HTTPException(400, "文件太大,请压缩到1.5MB以内") + conn.execute( + "INSERT INTO business_applications (user_id, business_name, document) VALUES (?, ?, ?)", + (user["id"], business_name, document) + ) + who = user.get("display_name") or user.get("username") + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "🏢 商业认证申请", f"{who} 申请商业用户认证,商户名:{business_name}") + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/my-business-application") +def get_my_business_application(user=Depends(get_current_user)): + if not user["id"]: + return {"status": None} + conn = get_db() + row = conn.execute( + "SELECT business_name, document, status, reject_reason, created_at FROM business_applications WHERE user_id = ? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + conn.close() + if not row: + return {"status": None} + return dict(row) + + +@app.get("/api/business-applications") +def list_business_applications(user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute( + "SELECT a.id, a.user_id, a.business_name, a.document, a.status, a.created_at, " + "u.display_name, u.username FROM business_applications a " + "LEFT JOIN users u ON a.user_id = u.id ORDER BY a.id DESC" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/business-applications/{app_id}/approve") +def approve_business(app_id: int, user=Depends(require_role("admin"))): + conn = get_db() + app = conn.execute("SELECT user_id, business_name FROM business_applications WHERE id = ?", (app_id,)).fetchone() + if not app: + conn.close() + raise HTTPException(404, "申请不存在") + conn.execute("UPDATE business_applications SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?", (app_id,)) + conn.execute("UPDATE users SET business_verified = 1 WHERE id = ?", (app["user_id"],)) + # Notify user + target = conn.execute("SELECT role FROM users WHERE id = ?", (app["user_id"],)).fetchone() + if target: + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (target["role"], "🎉 商业认证通过", "恭喜!你的商业用户认证已通过,现在可以使用项目核算等商业功能。", app["user_id"]) + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/business-applications/{app_id}/reject") +def reject_business(app_id: int, body: dict = None, user=Depends(require_role("admin"))): + conn = get_db() + app = conn.execute("SELECT user_id FROM business_applications WHERE id = ?", (app_id,)).fetchone() + if not app: + conn.close() + raise HTTPException(404, "申请不存在") + reason = (body or {}).get("reason", "").strip() + conn.execute("UPDATE business_applications SET status = 'rejected', reviewed_at = datetime('now'), reject_reason = ? WHERE id = ?", (reason, app_id)) + target = conn.execute("SELECT role FROM users WHERE id = ?", (app["user_id"],)).fetchone() + if target: + msg = "你的商业用户认证申请未通过。" + if reason: + msg += "\n\n原因:" + reason + msg += "\n\n你可以修改后重新申请。" + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (target["role"], "商业认证未通过", msg, app["user_id"]) + ) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Translation Suggestions ──────────────────────────── +@app.post("/api/translation-suggest", status_code=201) +def suggest_translation(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + recipe_name = body.get("recipe_name", "").strip() + suggested_en = body.get("suggested_en", "").strip() + recipe_id = body.get("recipe_id") + if not recipe_name or not suggested_en: + raise HTTPException(400, "请填写翻译") + conn = get_db() + conn.execute( + "INSERT INTO translation_suggestions (recipe_id, recipe_name, suggested_en, user_id) VALUES (?, ?, ?, ?)", + (recipe_id, recipe_name, suggested_en, user["id"]) + ) + who = user.get("display_name") or user.get("username") + if user["role"] != "admin": + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "📝 翻译建议", f"{who} 建议将「{recipe_name}」翻译为「{suggested_en}」") + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/translation-suggestions") +def list_translation_suggestions(user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute( + "SELECT s.id, s.recipe_id, s.recipe_name, s.suggested_en, s.status, s.created_at, " + "u.display_name, u.username FROM translation_suggestions s " + "LEFT JOIN users u ON s.user_id = u.id WHERE s.status = 'pending' ORDER BY s.id DESC" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/translation-suggestions/{sid}/approve") +def approve_translation(sid: int, user=Depends(require_role("admin"))): + conn = get_db() + row = conn.execute("SELECT recipe_name, suggested_en FROM translation_suggestions WHERE id = ?", (sid,)).fetchone() + if not row: + conn.close() + raise HTTPException(404) + conn.execute("UPDATE translation_suggestions SET status = 'approved' WHERE id = ?", (sid,)) + conn.commit() + conn.close() + return {"ok": True, "recipe_name": row["recipe_name"], "suggested_en": row["suggested_en"]} + + +@app.post("/api/translation-suggestions/{sid}/reject") +def reject_translation(sid: int, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("UPDATE translation_suggestions SET status = 'rejected' WHERE id = ?", (sid,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/business-revoke/{user_id}") +def revoke_business(user_id: int, body: dict = None, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("UPDATE users SET business_verified = 0 WHERE id = ?", (user_id,)) + reason = (body or {}).get("reason", "").strip() + target = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() + if target: + msg = "你的商业用户资格已被取消。" + if reason: + msg += "\n\n原因:" + reason + msg += "\n\n如有疑问请联系管理员,也可重新申请认证。" + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (target["role"], "商业资格已取消", msg, user_id) + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/impersonate") +def impersonate(body: dict, user=Depends(require_role("admin"))): + target_id = body.get("user_id") + if not target_id: + raise HTTPException(400, "user_id required") + conn = get_db() + target = conn.execute("SELECT token FROM users WHERE id = ?", (target_id,)).fetchone() + conn.close() + if not target: + raise HTTPException(404, "User not found") + return {"token": target["token"]} + + +# ── Oils ──────────────────────────────────────────────── +@app.get("/api/oils") +def list_oils(): + conn = get_db() + rows = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active FROM oils ORDER BY name").fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/oils", status_code=201) +def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + conn.execute( + "INSERT INTO oils (name, bottle_price, drop_count, retail_price) VALUES (?, ?, ?, ?) " + "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, retail_price=excluded.retail_price", + (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price), + ) + log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, + json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/oils/{name}") +def delete_oil(name: str, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + row = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active FROM oils WHERE name = ?", (name,)).fetchone() + snapshot = dict(row) if row else {} + conn.execute("DELETE FROM oils WHERE name = ?", (name,)) + log_audit(conn, user["id"], "delete_oil", "oil", name, name, + json.dumps(snapshot, ensure_ascii=False)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Recipes ───────────────────────────────────────────── +def _recipe_to_dict(conn, row): + rid = row["id"] + ings = conn.execute( + "SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (rid,) + ).fetchall() + tags = conn.execute( + "SELECT tag_name FROM recipe_tags WHERE recipe_id = ?", (rid,) + ).fetchall() + owner = conn.execute( + "SELECT display_name, username FROM users WHERE id = ?", (row["owner_id"],) + ).fetchone() if row["owner_id"] else None + return { + "id": rid, + "name": row["name"], + "note": row["note"], + "owner_id": row["owner_id"], + "owner_name": (owner["display_name"] or owner["username"]) if owner else None, + "version": row["version"] if "version" in row.keys() else 1, + "ingredients": [{"oil_name": i["oil_name"], "drops": i["drops"]} for i in ings], + "tags": [t["tag_name"] for t in tags], + } + + +@app.get("/api/recipes") +def list_recipes(user=Depends(get_current_user)): + conn = get_db() + # Admin sees all; others see admin-owned (adopted) + their own + if user["role"] == "admin": + rows = conn.execute("SELECT id, name, note, owner_id, version FROM recipes ORDER BY id").fetchall() + else: + admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin_id = admin["id"] if admin else 1 + user_id = user.get("id") + if user_id: + rows = conn.execute( + "SELECT id, name, note, owner_id, version FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id", + (admin_id, user_id) + ).fetchall() + else: + rows = conn.execute( + "SELECT id, name, note, owner_id, version FROM recipes WHERE owner_id = ? ORDER BY id", + (admin_id,) + ).fetchall() + result = [_recipe_to_dict(conn, r) for r in rows] + conn.close() + return result + + +@app.get("/api/recipes/{recipe_id}") +def get_recipe(recipe_id: int): + conn = get_db() + row = conn.execute("SELECT id, name, note, owner_id, version FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + conn.close() + raise HTTPException(404, "Recipe not found") + result = _recipe_to_dict(conn, row) + conn.close() + return result + + +@app.post("/api/recipes", status_code=201) +def create_recipe(recipe: RecipeIn, user=Depends(require_role("admin", "senior_editor", "editor"))): + conn = get_db() + c = conn.cursor() + c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", + (recipe.name, recipe.note, user["id"])) + rid = c.lastrowid + for ing in recipe.ingredients: + c.execute( + "INSERT INTO recipe_ingredients (recipe_id, oil_name, drops) VALUES (?, ?, ?)", + (rid, ing.oil_name, ing.drops), + ) + for tag in recipe.tags: + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag)) + log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name) + # Notify admin when non-admin creates a recipe + if user["role"] != "admin": + who = user.get("display_name") or user["username"] + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", "📝 新配方待审核", + f"{who} 新增了配方「{recipe.name}」,请到管理配方查看并采纳。") + ) + conn.commit() + conn.close() + return {"id": rid} + + +def _check_recipe_permission(conn, recipe_id, user): + """Check if user can modify this recipe.""" + row = conn.execute("SELECT owner_id, name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + raise HTTPException(404, "Recipe not found") + if user["role"] in ("admin", "senior_editor"): + return row + if user["role"] == "editor" and row["owner_id"] == user["id"]: + return row + raise HTTPException(403, "只能修改自己创建的配方") + + +@app.put("/api/recipes/{recipe_id}") +def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(require_role("admin", "senior_editor", "editor"))): + conn = get_db() + c = conn.cursor() + _check_recipe_permission(conn, recipe_id, user) + + # Optimistic locking: check version if provided + if update.version is not None: + current = c.execute("SELECT version FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if current and current["version"] and current["version"] != update.version: + conn.close() + raise HTTPException(409, "此配方已被其他人修改,请刷新后重试") + + if update.name is not None: + c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id)) + if update.note is not None: + c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id)) + if update.ingredients is not None: + c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) + for ing in update.ingredients: + c.execute( + "INSERT INTO recipe_ingredients (recipe_id, oil_name, drops) VALUES (?, ?, ?)", + (recipe_id, ing.oil_name, ing.drops), + ) + if update.tags is not None: + c.execute("DELETE FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)) + for tag in update.tags: + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + c.execute( + "INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", + (recipe_id, tag), + ) + c.execute("UPDATE recipes SET updated_by = ?, version = COALESCE(version, 1) + 1 WHERE id = ?", (user["id"], recipe_id)) + log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id, update.name) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/recipes/{recipe_id}") +def delete_recipe(recipe_id: int, user=Depends(require_role("admin", "senior_editor", "editor"))): + conn = get_db() + row = _check_recipe_permission(conn, recipe_id, user) + # Save full snapshot for undo + full = conn.execute("SELECT id, name, note, owner_id, version FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + snapshot = _recipe_to_dict(conn, full) + log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"], + json.dumps(snapshot, ensure_ascii=False)) + conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Adopt (admin takes ownership of editor recipes) ──── +@app.post("/api/recipes/{recipe_id}/adopt") +def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))): + conn = get_db() + row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + conn.close() + raise HTTPException(404, "Recipe not found") + if row["owner_id"] == user["id"]: + conn.close() + return {"ok": True, "msg": "already owned"} + old_owner = conn.execute("SELECT display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() + old_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown" + conn.execute("UPDATE recipes SET owner_id = ?, updated_by = ? WHERE id = ?", (user["id"], user["id"], recipe_id)) + log_audit(conn, user["id"], "adopt_recipe", "recipe", recipe_id, row["name"], + json.dumps({"from_user": old_name})) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/recipes/adopt-batch") +def adopt_batch(body: dict, user=Depends(require_role("admin"))): + ids = body.get("ids", []) + if not ids: + raise HTTPException(400, "No recipe ids provided") + conn = get_db() + adopted = 0 + for rid in ids: + row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (rid,)).fetchone() + if row and row["owner_id"] != user["id"]: + conn.execute("UPDATE recipes SET owner_id = ?, updated_by = ? WHERE id = ?", (user["id"], user["id"], rid)) + log_audit(conn, user["id"], "adopt_recipe", "recipe", rid, row["name"]) + adopted += 1 + conn.commit() + conn.close() + return {"ok": True, "adopted": adopted} + + +# ── Tags ──────────────────────────────────────────────── +@app.get("/api/tags") +def list_tags(): + conn = get_db() + rows = conn.execute("SELECT name FROM tags ORDER BY name").fetchall() + conn.close() + return [r["name"] for r in rows] + + +@app.post("/api/tags", status_code=201) +def create_tag(body: dict, user=Depends(require_role("admin", "senior_editor", "editor"))): + name = body.get("name", "").strip() + if not name: + raise HTTPException(400, "Tag name required") + conn = get_db() + conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,)) + # Don't log tag creation (too frequent/noisy) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/tags/{name}") +def delete_tag(name: str, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("DELETE FROM recipe_tags WHERE tag_name = ?", (name,)) + conn.execute("DELETE FROM tags WHERE name = ?", (name,)) + log_audit(conn, user["id"], "delete_tag", "tag", name, name) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Users (admin only) ───────────────────────────────── +@app.get("/api/users") +def list_users(user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute("SELECT id, username, token, role, display_name, created_at, business_verified FROM users ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/users", status_code=201) +def create_user(body: UserIn, user=Depends(require_role("admin"))): + import secrets + token = secrets.token_hex(24) + conn = get_db() + try: + conn.execute( + "INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)", + (body.username, token, body.role, body.display_name), + ) + log_audit(conn, user["id"], "create_user", "user", body.username, body.display_name, + json.dumps({"role": body.role})) + conn.commit() + except Exception: + conn.close() + raise HTTPException(400, "用户名已存在") + conn.close() + return {"token": token, "username": body.username} + + +@app.delete("/api/users/{user_id}") +def delete_user(user_id: int, user=Depends(require_role("admin"))): + if user_id == user["id"]: + raise HTTPException(400, "不能删除自己") + conn = get_db() + target = conn.execute("SELECT id, username, token, role, display_name FROM users WHERE id = ?", (user_id,)).fetchone() + if not target: + conn.close() + raise HTTPException(404, "User not found") + snapshot = dict(target) + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"], + json.dumps(snapshot, ensure_ascii=False)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.put("/api/users/{user_id}") +def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin"))): + conn = get_db() + if body.role is not None: + conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id)) + if body.display_name is not None: + conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (body.display_name, user_id)) + log_audit(conn, user["id"], "update_user", "user", user_id, None, + json.dumps({"role": body.role, "display_name": body.display_name})) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Undo (admin only) ────────────────────────────────── +@app.post("/api/audit-log/{log_id}/undo") +def undo_action(log_id: int, user=Depends(require_role("admin"))): + conn = get_db() + entry = conn.execute("SELECT * FROM audit_log WHERE id = ?", (log_id,)).fetchone() + if not entry: + conn.close() + raise HTTPException(404, "Audit log entry not found") + + action = entry["action"] + detail = entry["detail"] + if not detail: + conn.close() + raise HTTPException(400, "此操作无法撤销(无快照数据)") + + snapshot = json.loads(detail) + + if action == "delete_recipe": + # Restore recipe from snapshot + c = conn.cursor() + c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", + (snapshot["name"], snapshot.get("note", ""), snapshot.get("owner_id"))) + new_id = c.lastrowid + for ing in snapshot.get("ingredients", []): + c.execute("INSERT INTO recipe_ingredients (recipe_id, oil_name, drops) VALUES (?, ?, ?)", + (new_id, ing["oil_name"], ing["drops"])) + for tag in snapshot.get("tags", []): + c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) + c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (new_id, tag)) + log_audit(conn, user["id"], "undo_delete_recipe", "recipe", new_id, snapshot["name"], + json.dumps({"original_log_id": log_id})) + conn.commit() + conn.close() + return {"ok": True, "new_id": new_id} + + elif action == "delete_oil": + conn.execute( + "INSERT OR IGNORE INTO oils (name, bottle_price, drop_count) VALUES (?, ?, ?)", + (snapshot["name"], snapshot["bottle_price"], snapshot["drop_count"])) + log_audit(conn, user["id"], "undo_delete_oil", "oil", snapshot["name"], snapshot["name"], + json.dumps({"original_log_id": log_id})) + conn.commit() + conn.close() + return {"ok": True} + + elif action == "delete_user": + try: + conn.execute( + "INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)", + (snapshot["username"], snapshot["token"], snapshot["role"], snapshot.get("display_name", ""))) + log_audit(conn, user["id"], "undo_delete_user", "user", snapshot["username"], snapshot.get("display_name"), + json.dumps({"original_log_id": log_id})) + conn.commit() + except Exception: + conn.close() + raise HTTPException(400, "恢复失败(用户名可能已被占用)") + conn.close() + return {"ok": True} + + conn.close() + raise HTTPException(400, "此操作类型不支持撤销") + + +# ── Audit Log (admin only) ───────────────────────────── +@app.get("/api/audit-log") +def get_audit_log(limit: int = 100, offset: int = 0, user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute( + "SELECT a.id, a.action, a.target_type, a.target_id, a.target_name, a.detail, a.created_at, " + "u.display_name as user_name, u.username " + "FROM audit_log a LEFT JOIN users u ON a.user_id = u.id " + "ORDER BY a.id DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ── Brand Settings ───────────────────────────────────── +@app.get("/api/brand") +def get_brand(user=Depends(get_current_user)): + if not user["id"]: + return {"qr_code": None, "brand_logo": None, "brand_bg": None, "brand_name": None, "brand_align": "center"} + conn = get_db() + row = conn.execute("SELECT qr_code, brand_logo, brand_bg, brand_name, brand_align FROM users WHERE id = ?", (user["id"],)).fetchone() + conn.close() + if not row: + return {"qr_code": None, "brand_logo": None, "brand_bg": None, "brand_name": None, "brand_align": "center"} + return dict(row) + + +@app.put("/api/brand") +def update_brand(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + if "qr_code" in body: + # Limit size: ~500KB base64 + if body["qr_code"] and len(body["qr_code"]) > 700000: + raise HTTPException(400, "图片太大,请压缩后上传") + conn.execute("UPDATE users SET qr_code = ? WHERE id = ?", (body["qr_code"], user["id"])) + if "brand_logo" in body: + if body["brand_logo"] and len(body["brand_logo"]) > 700000: + raise HTTPException(400, "图片太大,请压缩后上传") + conn.execute("UPDATE users SET brand_logo = ? WHERE id = ?", (body["brand_logo"], user["id"])) + if "brand_bg" in body: + if body["brand_bg"] and len(body["brand_bg"]) > 1500000: + raise HTTPException(400, "背景图太大,请压缩到1MB以内") + conn.execute("UPDATE users SET brand_bg = ? WHERE id = ?", (body["brand_bg"], user["id"])) + if "brand_name" in body: + conn.execute("UPDATE users SET brand_name = ? WHERE id = ?", (body["brand_name"], user["id"])) + if "brand_align" in body: + conn.execute("UPDATE users SET brand_align = ? WHERE id = ?", (body["brand_align"], user["id"])) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Profit Projects ──────────────────────────────────── +@app.get("/api/projects") +def list_projects(): + conn = get_db() + rows = conn.execute("SELECT * FROM profit_projects ORDER BY id DESC").fetchall() + conn.close() + return [{ **dict(r), "ingredients": json.loads(r["ingredients"]) } for r in rows] + + +@app.post("/api/projects", status_code=201) +def create_project(body: dict, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + c = conn.cursor() + c.execute( + "INSERT INTO profit_projects (name, ingredients, pricing, note, created_by) VALUES (?, ?, ?, ?, ?)", + (body["name"], json.dumps(body.get("ingredients", []), ensure_ascii=False), + body.get("pricing", 0), body.get("note", ""), user["id"]) + ) + conn.commit() + pid = c.lastrowid + conn.close() + return {"id": pid} + + +@app.put("/api/projects/{pid}") +def update_project(pid: int, body: dict, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + if "name" in body: + conn.execute("UPDATE profit_projects SET name = ? WHERE id = ?", (body["name"], pid)) + if "ingredients" in body: + conn.execute("UPDATE profit_projects SET ingredients = ? WHERE id = ?", + (json.dumps(body["ingredients"], ensure_ascii=False), pid)) + if "pricing" in body: + conn.execute("UPDATE profit_projects SET pricing = ? WHERE id = ?", (body["pricing"], pid)) + if "note" in body: + conn.execute("UPDATE profit_projects SET note = ? WHERE id = ?", (body["note"], pid)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/projects/{pid}") +def delete_project(pid: int, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + conn.execute("DELETE FROM profit_projects WHERE id = ?", (pid,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Diary (personal recipes + journal) ───────────────── +@app.get("/api/diary") +def list_diary(user=Depends(get_current_user)): + if not user["id"]: + return [] + conn = get_db() + rows = conn.execute( + "SELECT id, source_recipe_id, name, ingredients, note, tags, created_at " + "FROM user_diary WHERE user_id = ? ORDER BY id DESC", (user["id"],) + ).fetchall() + result = [] + for r in rows: + entries = conn.execute( + "SELECT id, content, created_at FROM diary_entries WHERE diary_id = ? ORDER BY created_at DESC", (r["id"],) + ).fetchall() + d = dict(r) + d["ingredients"] = json.loads(r["ingredients"]) + d["tags"] = json.loads(r["tags"] or "[]") + d["entries"] = [dict(e) for e in entries] + result.append(d) + conn.close() + return result + + +@app.post("/api/diary", status_code=201) +def create_diary(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + name = body.get("name", "").strip() + ingredients = body.get("ingredients", []) + note = body.get("note", "") + source_id = body.get("source_recipe_id") + if not name: + raise HTTPException(400, "请输入配方名称") + conn = get_db() + c = conn.cursor() + c.execute( + "INSERT INTO user_diary (user_id, source_recipe_id, name, ingredients, note) VALUES (?, ?, ?, ?, ?)", + (user["id"], source_id, name, json.dumps(ingredients, ensure_ascii=False), note) + ) + conn.commit() + did = c.lastrowid + conn.close() + return {"id": did} + + +@app.put("/api/diary/{diary_id}") +def update_diary(diary_id: int, body: dict, user=Depends(get_current_user)): + conn = get_db() + row = conn.execute("SELECT user_id FROM user_diary WHERE id = ?", (diary_id,)).fetchone() + if not row or row["user_id"] != user["id"]: + conn.close() + raise HTTPException(403, "无权操作") + if "name" in body: + conn.execute("UPDATE user_diary SET name = ? WHERE id = ?", (body["name"], diary_id)) + if "note" in body: + conn.execute("UPDATE user_diary SET note = ? WHERE id = ?", (body["note"], diary_id)) + if "ingredients" in body: + conn.execute("UPDATE user_diary SET ingredients = ? WHERE id = ?", + (json.dumps(body["ingredients"], ensure_ascii=False), diary_id)) + if "tags" in body: + conn.execute("UPDATE user_diary SET tags = ? WHERE id = ?", + (json.dumps(body["tags"], ensure_ascii=False), diary_id)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/diary/{diary_id}") +def delete_diary(diary_id: int, user=Depends(get_current_user)): + conn = get_db() + row = conn.execute("SELECT user_id FROM user_diary WHERE id = ?", (diary_id,)).fetchone() + if not row or row["user_id"] != user["id"]: + conn.close() + raise HTTPException(403, "无权操作") + conn.execute("DELETE FROM user_diary WHERE id = ?", (diary_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/diary/{diary_id}/entries", status_code=201) +def add_diary_entry(diary_id: int, body: dict, user=Depends(get_current_user)): + conn = get_db() + row = conn.execute("SELECT user_id FROM user_diary WHERE id = ?", (diary_id,)).fetchone() + if not row or row["user_id"] != user["id"]: + conn.close() + raise HTTPException(403, "无权操作") + content = body.get("content", "").strip() + if not content: + raise HTTPException(400, "内容不能为空") + conn.execute("INSERT INTO diary_entries (diary_id, content) VALUES (?, ?)", (diary_id, content)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/diary/entries/{entry_id}") +def delete_diary_entry(entry_id: int, user=Depends(get_current_user)): + conn = get_db() + row = conn.execute( + "SELECT d.user_id FROM diary_entries e JOIN user_diary d ON e.diary_id = d.id WHERE e.id = ?", + (entry_id,) + ).fetchone() + if not row or row["user_id"] != user["id"]: + conn.close() + raise HTTPException(403, "无权操作") + conn.execute("DELETE FROM diary_entries WHERE id = ?", (entry_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Favorites ────────────────────────────────────────── +@app.get("/api/favorites") +def get_favorites(user=Depends(get_current_user)): + if not user["id"]: + return [] + conn = get_db() + rows = conn.execute("SELECT recipe_id FROM user_favorites WHERE user_id = ? ORDER BY created_at DESC", (user["id"],)).fetchall() + conn.close() + return [r["recipe_id"] for r in rows] + + +@app.post("/api/favorites/{recipe_id}", status_code=201) +def add_favorite(recipe_id: int, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + conn.execute("INSERT OR IGNORE INTO user_favorites (user_id, recipe_id) VALUES (?, ?)", (user["id"], recipe_id)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/favorites/{recipe_id}") +def remove_favorite(recipe_id: int, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + conn.execute("DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?", (user["id"], recipe_id)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── User Inventory ───────────────────────────────────── +@app.get("/api/inventory") +def get_inventory(user=Depends(get_current_user)): + if not user["id"]: + return [] + conn = get_db() + rows = conn.execute("SELECT oil_name FROM user_inventory WHERE user_id = ? ORDER BY oil_name", (user["id"],)).fetchall() + conn.close() + return [r["oil_name"] for r in rows] + + +@app.post("/api/inventory", status_code=201) +def add_inventory(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + name = body.get("oil_name", "").strip() + if not name: + raise HTTPException(400, "oil_name required") + conn = get_db() + conn.execute("INSERT OR IGNORE INTO user_inventory (user_id, oil_name) VALUES (?, ?)", (user["id"], name)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/inventory/{oil_name}") +def remove_inventory(oil_name: str, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + conn.execute("DELETE FROM user_inventory WHERE user_id = ? AND oil_name = ?", (user["id"], oil_name)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/inventory/recipes") +def recipes_by_inventory(user=Depends(get_current_user)): + """Get recipes that can be made with user's inventory oils.""" + if not user["id"]: + return [] + conn = get_db() + inv = [r["oil_name"] for r in conn.execute( + "SELECT oil_name FROM user_inventory WHERE user_id = ?", (user["id"],)).fetchall()] + if not inv: + conn.close() + return [] + rows = conn.execute("SELECT id, name, note, owner_id, version FROM recipes ORDER BY id").fetchall() + result = [] + for r in rows: + recipe = _recipe_to_dict(conn, r) + eo_oils = [i["oil_name"] for i in recipe["ingredients"] if i["oil_name"] != "椰子油"] + matched = [o for o in eo_oils if o in inv] + if matched: + recipe["inventory_match"] = len(matched) + recipe["inventory_total"] = len(eo_oils) + recipe["inventory_missing"] = [o for o in eo_oils if o not in inv] + result.append(recipe) + conn.close() + result.sort(key=lambda r: (-r["inventory_match"], r["inventory_total"] - r["inventory_match"])) + return result + + +# ── Search Logging ───────────────────────────────────── +@app.post("/api/search-log") +def log_search(body: dict, user=Depends(get_current_user)): + query = body.get("query", "").strip() + matched = body.get("matched_count", 0) + if not query: + return {"ok": True} + conn = get_db() + conn.execute("INSERT INTO search_log (user_id, query, matched_count) VALUES (?, ?, ?)", + (user.get("id"), query, matched)) + # Instant notification when no match found + if matched == 0: + who = user.get("display_name") or user.get("username") or "用户" + for role in ("admin", "senior_editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "🔍 有人搜索了未收录的配方", + f"{who} 搜索了「{query}」但没有找到匹配配方,请考虑添加。") + ) + conn.commit() + conn.close() + return {"ok": True, "matched": matched} + + +@app.get("/api/search-log/unmatched") +def get_unmatched_searches(days: int = 7, user=Depends(require_role("admin", "senior_editor"))): + conn = get_db() + rows = conn.execute( + "SELECT query, COUNT(*) as cnt, MAX(created_at) as last_at " + "FROM search_log WHERE matched_count = 0 AND created_at > datetime('now', ?) " + "GROUP BY query ORDER BY cnt DESC LIMIT 50", + (f"-{days} days",) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ── Notifications ────────────────────────────────────── +@app.get("/api/notifications") +def get_notifications(user=Depends(get_current_user)): + if not user["id"]: + return [] + conn = get_db() + rows = conn.execute( + "SELECT id, title, body, is_read, created_at FROM notifications " + "WHERE (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all'))) " + "ORDER BY is_read ASC, id DESC LIMIT 200", + (user["id"], user["role"]) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/notifications/{nid}/read") +def mark_notification_read(nid: int, body: dict = None, user=Depends(get_current_user)): + conn = get_db() + notif = conn.execute("SELECT title FROM notifications WHERE id = ?", (nid,)).fetchone() + # Bug test notifications can only be marked read with force=true (from "已测试" flow) + force = (body or {}).get("force", False) if body else False + if notif and "待测试" in (notif["title"] or "") and not force: + conn.close() + raise HTTPException(400, "请先点击「已测试」完成测试") + conn.execute("UPDATE notifications SET is_read = 1 WHERE id = ?", (nid,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/notifications/{nid}/unread") +def mark_notification_unread(nid: int, user=Depends(get_current_user)): + conn = get_db() + conn.execute("UPDATE notifications SET is_read = 0 WHERE id = ?", (nid,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/notifications/read-all") +def mark_all_notifications_read(user=Depends(get_current_user)): + conn = get_db() + # Mark all as read EXCEPT bug test notifications (title contains '待测试') + conn.execute( + "UPDATE notifications SET is_read = 1 WHERE is_read = 0 " + "AND title NOT LIKE '%待测试%' " + "AND (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all')))", + (user["id"], user["role"]) + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/cron/weekly-review") +def weekly_review(user=Depends(require_role("admin"))): + """Generate weekly notifications. Call via cron or manually.""" + conn = get_db() + + # 1. Pending recipes for admin review + admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin_id = admin["id"] if admin else 1 + pending = conn.execute( + "SELECT COUNT(*) as cnt FROM recipes WHERE owner_id != ?", (admin_id,) + ).fetchone()["cnt"] + if pending > 0: + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + ("admin", f"有 {pending} 条配方待审核", + "其他用户新增了配方,请到「管理配方」→「待审核」查看并采纳。") + ) + + # 2. Unmatched searches for admin + senior_editor + unmatched = conn.execute( + "SELECT query, COUNT(*) as cnt FROM search_log " + "WHERE matched_count = 0 AND created_at > datetime('now', '-7 days') " + "GROUP BY query ORDER BY cnt DESC LIMIT 10" + ).fetchall() + if unmatched: + queries = "、".join([f"「{r['query']}」({r['cnt']}次)" for r in unmatched]) + for role in ("admin", "senior_editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "本周未匹配的搜索需求", + f"以下搜索没有找到配方:{queries}。请考虑完善相关配方。") + ) + + conn.commit() + conn.close() + return {"ok": True, "pending_recipes": pending, "unmatched_queries": len(unmatched)} + + +# ── Category Modules (homepage) ──────────────────────── +@app.get("/api/categories") +def list_categories(): + conn = get_db() + rows = conn.execute("SELECT * FROM category_modules ORDER BY sort_order, id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.post("/api/categories", status_code=201) +def create_category(body: dict, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute( + "INSERT INTO category_modules (name, subtitle, icon, bg_image, color_from, color_to, tag_name, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (body["name"], body.get("subtitle", ""), body.get("icon", "🌿"), body.get("bg_image", ""), + body.get("color_from", "#7a9e7e"), body.get("color_to", "#5a7d5e"), + body["tag_name"], body.get("sort_order", 0)) + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/categories/{cat_id}") +def delete_category(cat_id: int, user=Depends(require_role("admin"))): + conn = get_db() + conn.execute("DELETE FROM category_modules WHERE id = ?", (cat_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ── Static files (frontend) ──────────────────────────── +FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend") + + +@app.on_event("startup") +def startup(): + init_db() + defaults_path = os.path.join(os.path.dirname(__file__), "defaults.json") + if os.path.exists(defaults_path): + with open(defaults_path) as f: + data = json.load(f) + seed_defaults(data["oils_meta"], data["recipes"]) + + if os.path.isdir(FRONTEND_DIR): + app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3df3349 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +aiosqlite==0.20.0 diff --git a/deploy/backup-cronjob.yaml b/deploy/backup-cronjob.yaml new file mode 100644 index 0000000..64ee0ab --- /dev/null +++ b/deploy/backup-cronjob.yaml @@ -0,0 +1,39 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: hourly-backup + namespace: oil-calculator +spec: + schedule: "0 * * * *" # Every hour + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 2 + jobTemplate: + spec: + template: + spec: + containers: + - name: backup + image: registry.oci.euphon.net/oil-calculator:latest + command: + - sh + - -c + - | + BACKUP_DIR=/data/backups + mkdir -p $BACKUP_DIR + DATE=$(date +%Y%m%d_%H%M%S) + # Backup SQLite database using .backup for consistency + sqlite3 /data/oil_calculator.db ".backup '$BACKUP_DIR/oil_calculator_${DATE}.db'" + echo "Backup done: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h $BACKUP_DIR/oil_calculator_${DATE}.db | cut -f1))" + # Keep last 48 backups (2 days of hourly) + ls -t $BACKUP_DIR/oil_calculator_*.db | tail -n +49 | xargs rm -f 2>/dev/null + echo "Backups retained: $(ls $BACKUP_DIR/oil_calculator_*.db | wc -l)" + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: oil-calculator-data + restartPolicy: OnFailure + imagePullSecrets: + - name: regcred diff --git a/deploy/cronjob.yaml b/deploy/cronjob.yaml new file mode 100644 index 0000000..e5825bf --- /dev/null +++ b/deploy/cronjob.yaml @@ -0,0 +1,30 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: weekly-review + namespace: oil-calculator +spec: + schedule: "0 9 * * 1" # Every Monday 9:00 UTC (17:00 China time) + jobTemplate: + spec: + template: + spec: + containers: + - name: cron + image: curlimages/curl:latest + command: + - sh + - -c + - | + curl -sf -X POST \ + -H "Authorization: Bearer $(ADMIN_TOKEN)" \ + -H "Content-Type: application/json" \ + -d '{}' \ + http://oil-calculator.oil-calculator.svc/api/cron/weekly-review + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: oil-calculator-secrets + key: admin-token + restartPolicy: OnFailure diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..cc00512 --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oil-calculator + namespace: oil-calculator +spec: + replicas: 1 + selector: + matchLabels: + app: oil-calculator + template: + metadata: + labels: + app: oil-calculator + spec: + imagePullSecrets: + - name: regcred + containers: + - name: oil-calculator + image: registry.oci.euphon.net/oil-calculator:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: DB_PATH + value: /data/oil_calculator.db + - name: FRONTEND_DIR + value: /app/frontend + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: oil-calculator-secrets + key: admin-token + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + volumes: + - name: data + persistentVolumeClaim: + claimName: oil-calculator-data diff --git a/deploy/ingress.yaml b/deploy/ingress.yaml new file mode 100644 index 0000000..6c3f161 --- /dev/null +++ b/deploy/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: oil-calculator + namespace: oil-calculator + annotations: + traefik.ingress.kubernetes.io/router.tls.certresolver: le +spec: + ingressClassName: traefik + rules: + - host: oil.oci.euphon.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: oil-calculator + port: + number: 80 + tls: + - hosts: + - oil.oci.euphon.net diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..72d25bb --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: oil-calculator diff --git a/deploy/pvc.yaml b/deploy/pvc.yaml new file mode 100644 index 0000000..eba04a4 --- /dev/null +++ b/deploy/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: oil-calculator-data + namespace: oil-calculator +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..c9aed0e --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: oil-calculator + namespace: oil-calculator +spec: + selector: + app: oil-calculator + ports: + - port: 80 + targetPort: 8000 diff --git a/deploy/setup-kubeconfig.sh b/deploy/setup-kubeconfig.sh new file mode 100644 index 0000000..c166a3e --- /dev/null +++ b/deploy/setup-kubeconfig.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Creates a restricted kubeconfig for the oil-calculator namespace only. +# Run on the k8s server as a user with cluster-admin access. +set -e + +NAMESPACE=oil-calculator +SA_NAME=oil-calculator-deployer + +echo "Creating ServiceAccount, Role, and RoleBinding..." + +kubectl apply -f - < kubeconfig <P)Y_X?5}jkk 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 Ef|4%d@c;k- literal 0 HcmV?d00001 diff --git a/frontend/apple-touch-icon-192.png b/frontend/apple-touch-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..26f800c75c1cf27f13f153d538e7f1330503e0ae GIT binary patch literal 8426 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 M07*qoM6N<$f~Zhx5C8xG literal 0 HcmV?d00001 diff --git a/frontend/apple-touch-icon.png b/frontend/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cef6dbe9a557fb9e4eec0d169232bea62d5b7ff8 GIT binary patch literal 12574 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🌿 diff --git a/frontend/icon-192.png b/frontend/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5759a1901224707a8f257f41f1b9097e48059609 GIT binary patch literal 11902 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 Ef^S^wEC2ui literal 0 HcmV?d00001 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9a1ecb8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,8441 @@ + + + + + + + + + + + +doTERRA 配方计算器 + + + + + + + + +
+
+
🌿
+
+

+ doTERRA 配方计算器 + +

+

查询配方·计算成本·自制配方·导出卡片·精油知识

+
+
+
+ + + +
+ + + + + +
+
🙋 我的
+ + +
+ + +
+ + +
+
+
✨ 智能粘贴(添加到我的配方)
+
+ +
+
+ + +
+
+
+
+
+ + + + + + + + + + + + +
+ + +
+
📦 我的精油库存
+ +
已有精油
+
+
可做的配方
+
+
+ + +
+
💼 商业核算
+

商业用户专属功能,包含项目核算、成本分析等工具

+ + +
+
+ 📊 服务项目成本利润分析 + +
+ + +
+ + + +
+ + + +
+ + +
+
+ 🔔 通知 + +
+
+
+ + + +
+ + +
+ + + + +
+ +
+ + +
+ 📋 配方列表 +
+ + + +
+ +
+
🏷 批量打标签
+
📤 批量分享到公共库
+
🖼 批量导出卡片
+
🗑 批量删除
+
+
+ +
+
+ + + + +
+
+ + +
+
+
✨ 智能粘贴
+
+ + +
支持逗号、顿号、换行分隔,也支持连写。空行或「配方名:」开头自动分隔为多个配方。系统会逐个让你校准后再保存。
+
+
+ + +
+
+
+ +
+
➕ 手动新增配方
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+
+ + +
+
💧 精油价目表 种精油
+ +
+
+
💧
+
稀释比例
+
不同年龄段的稀释指南
+
+
+
⚠️
+
使用禁忌
+
安全使用精油的注意事项
+
+
+
+ + + + + +
+
+ +
+ + +
+ +
+
+
+ + +
+
📜 操作日志
+
+ + + + + + + +
+
+ 按用户: +
+
+
+
+ +
+
+ + + +
+
+ 🐛 Bug 追踪 + +
+
+
+
+ +
+
👥 用户管理
+ + + + +
+
新增用户
+
+ + + + +
+
+
+
+ + +
+
+
+ + + + + + diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..7d7a32b --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Weekly backup script - run locally or via cron +# Backs up: database from server + local code +# Keeps last 5 backups + +BACKUP_BASE="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator/backups" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="$BACKUP_BASE/$DATE" + +mkdir -p "$BACKUP_DIR" + +echo "📦 Backing up to: $BACKUP_DIR" + +# 1. Download database from server +echo " Downloading database..." +ssh fam@oci.euphon.net "kubectl exec -n oil-calculator deploy/oil-calculator -- cat /data/oil_calculator.db" > "$BACKUP_DIR/oil_calculator.db" 2>/dev/null + +if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/oil_calculator.db" ]; then + echo " ✅ Database: $(du -h "$BACKUP_DIR/oil_calculator.db" | cut -f1)" +else + echo " ❌ Database download failed" +fi + +# 2. Copy local code +echo " Copying code..." +PROJECT="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator" +cp -r "$PROJECT/backend" "$BACKUP_DIR/" +cp -r "$PROJECT/frontend" "$BACKUP_DIR/" +cp "$PROJECT/Dockerfile" "$BACKUP_DIR/" +echo " ✅ Code copied" + +# 3. Export all data as JSON (recipes, oils, users) +echo " Exporting data..." +ADMIN_TOKEN=$(cat /tmp/oil_admin_token.txt 2>/dev/null) +if [ -n "$ADMIN_TOKEN" ]; then + curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/recipes > "$BACKUP_DIR/recipes.json" 2>/dev/null + curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/oils > "$BACKUP_DIR/oils.json" 2>/dev/null + curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/tags > "$BACKUP_DIR/tags.json" 2>/dev/null + curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/users > "$BACKUP_DIR/users.json" 2>/dev/null + echo " ✅ Data exported" +else + echo " ⚠️ No admin token, skipping data export" +fi + +# 4. Keep only last 5 backups +echo " Cleaning old backups..." +ls -dt "$BACKUP_BASE"/*/ 2>/dev/null | tail -n +31 | xargs rm -rf 2>/dev/null +REMAINING=$(ls -d "$BACKUP_BASE"/*/ 2>/dev/null | wc -l) +echo " ✅ $REMAINING backups retained" + +echo "" +echo "✅ Backup complete: $BACKUP_DIR" +ls -la "$BACKUP_DIR/" diff --git a/scripts/remote-backup.sh b/scripts/remote-backup.sh new file mode 100755 index 0000000..96831da --- /dev/null +++ b/scripts/remote-backup.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Remote backup: download database from server to local Mac +# Run via cron: crontab -e → 0 * * * * /path/to/remote-backup.sh + +BACKUP_DIR="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator/backups/remote" +mkdir -p "$BACKUP_DIR" +DATE=$(date +%Y%m%d_%H%M%S) + +# Download database +ssh fam@oci.euphon.net "kubectl exec -n oil-calculator deploy/oil-calculator -- cat /data/oil_calculator.db" > "$BACKUP_DIR/oil_calculator_${DATE}.db" 2>/dev/null + +if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/oil_calculator_${DATE}.db" ]; then + echo "✅ Backup: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h "$BACKUP_DIR/oil_calculator_${DATE}.db" | cut -f1))" + # Keep last 168 backups (7 days hourly) + ls -t "$BACKUP_DIR"/oil_calculator_*.db 2>/dev/null | tail -n +169 | xargs rm -f 2>/dev/null +else + echo "❌ Backup failed" + rm -f "$BACKUP_DIR/oil_calculator_${DATE}.db" +fi diff --git a/scripts/scan-recipes-folder.py b/scripts/scan-recipes-folder.py new file mode 100644 index 0000000..e60913d --- /dev/null +++ b/scripts/scan-recipes-folder.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Scan the local recipe folder for new Excel files and extract recipes. +Run periodically (e.g., weekly) to find new content to add to the calculator. + +Usage: + python3 scripts/scan-recipes-folder.py + +It will: +1. Read all .xlsx files in the recipe folder +2. Compare with existing recipes in the API +3. Report any new recipes found +4. Optionally upload them (with --upload flag) +""" +import os +import sys +import json +import argparse + +RECIPE_FOLDER = os.path.expanduser("~/Hera DOCS/Essential Oil/商业和公司/成本和配方") +API_BASE = "https://oil.oci.euphon.net" + +def main(): + parser = argparse.ArgumentParser(description="Scan recipe folder for new content") + parser.add_argument("--upload", action="store_true", help="Upload new recipes to API") + parser.add_argument("--token", help="Admin API token") + args = parser.parse_args() + + print(f"📁 Scanning: {RECIPE_FOLDER}") + if not os.path.isdir(RECIPE_FOLDER): + print(f" Folder not found!") + return + + files = [f for f in os.listdir(RECIPE_FOLDER) if f.endswith('.xlsx') and not f.startswith('~$')] + print(f" Found {len(files)} Excel files:") + for f in files: + mtime = os.path.getmtime(os.path.join(RECIPE_FOLDER, f)) + from datetime import datetime + print(f" - {f} (modified: {datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')})") + + print(f"\n💡 To add recipes from these files, use the web UI's '智能粘贴' feature") + print(f" or manually add them via the API.") + print(f"\n🔗 Admin URL: {API_BASE}/?token=") + +if __name__ == "__main__": + main() diff --git a/精油配方计算器.html b/精油配方计算器.html new file mode 100644 index 0000000..ad21430 --- /dev/null +++ b/精油配方计算器.html @@ -0,0 +1,2057 @@ + + + + + +🌿 精油配方计算器 + + + + + + + +
+
+
🌿
+
+

DOTERRA 精油配方计算器(Hera)

+

查询配方 · 计算成本 · 导出卡片

+
+
+
+ + + +
+ + + + + +
+ + + +
+ 📋 配方列表 +
+ + +
+
+
+
+ + +
+
+
✨ 智能粘贴
+
+ + +
支持逗号、顿号、换行分隔,也支持无分隔直接连写(如:芳香调理8永久花10檀香10),自动用数字断句。不需要写椰子油,系统会按稀释比例自动计算。
+
+
+ + +
+
+
+ +
+
➕ 手动新增配方
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+
+ + +
+
💧 精油价目表 种精油
+
+ + + + + +
+ +
+
+ +
+ + + +