Initial commit: Essential Oil Formula Cost Calculator

This commit is contained in:
2026-04-06 13:46:32 +00:00
commit 0368e85abe
25 changed files with 20897 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
*.pyc
__pycache__/
deploy/kubeconfig
all_recipes_extracted.json
backups/

16
Dockerfile Normal file
View File

@@ -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"]

306
backend/database.py Normal file
View File

@@ -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()

8091
backend/defaults.json Normal file

File diff suppressed because it is too large Load Diff

1497
backend/main.py Normal file

File diff suppressed because it is too large Load Diff

3
backend/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
aiosqlite==0.20.0

View File

@@ -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

30
deploy/cronjob.yaml Normal file
View File

@@ -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

47
deploy/deployment.yaml Normal file
View File

@@ -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

23
deploy/ingress.yaml Normal file
View File

@@ -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

4
deploy/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: oil-calculator

11
deploy/pvc.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: oil-calculator-data
namespace: oil-calculator
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

11
deploy/service.yaml Normal file
View File

@@ -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

View File

@@ -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 - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${SA_NAME}
namespace: ${NAMESPACE}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ${SA_NAME}-role
namespace: ${NAMESPACE}
rules:
- apiGroups: ["", "apps", "networking.k8s.io"]
resources: ["pods", "services", "deployments", "replicasets", "ingresses", "persistentvolumeclaims", "configmaps", "secrets", "pods/log"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ${SA_NAME}-binding
namespace: ${NAMESPACE}
subjects:
- kind: ServiceAccount
name: ${SA_NAME}
namespace: ${NAMESPACE}
roleRef:
kind: Role
name: ${SA_NAME}-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: ${SA_NAME}-token
namespace: ${NAMESPACE}
annotations:
kubernetes.io/service-account.name: ${SA_NAME}
type: kubernetes.io/service-account-token
EOF
echo "Waiting for token..."
sleep 3
# Get cluster info
CLUSTER_SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CLUSTER_CA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl get secret ${SA_NAME}-token -n ${NAMESPACE} -o jsonpath='{.data.token}' | base64 -d)
cat > kubeconfig <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: ${CLUSTER_CA}
server: ${CLUSTER_SERVER}
name: oil-calculator
contexts:
- context:
cluster: oil-calculator
namespace: ${NAMESPACE}
user: ${SA_NAME}
name: oil-calculator
current-context: oil-calculator
users:
- name: ${SA_NAME}
user:
token: ${TOKEN}
EOF
echo "Kubeconfig written to ./kubeconfig"
echo "Test with: KUBECONFIG=./kubeconfig kubectl get pods"

114
doc/deploy.md Normal file
View File

@@ -0,0 +1,114 @@
# 精油配方计算器 - 部署文档
## 架构
- **前端**: 静态 HTML由 FastAPI 直接 serve
- **后端**: FastAPI + SQLite端口 8000
- **部署**: Kubernetes (k3s) on `oci.euphon.net`
- **域名**: https://oil.oci.euphon.net
- **TLS**: Traefik + Let's Encrypt (自动)
## 目录结构
```
├── backend/
│ ├── main.py # FastAPI 应用
│ ├── database.py # SQLite 数据库操作
│ ├── defaults.json # 默认精油和配方数据(首次启动时 seed
│ └── requirements.txt
├── frontend/
│ └── index.html # 前端页面
├── deploy/
│ ├── namespace.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── pvc.yaml # 1Gi 持久卷,存放 SQLite 数据库
│ ├── ingress.yaml
│ ├── setup-kubeconfig.sh # 生成受限 kubeconfig 的脚本
│ └── kubeconfig # 受限 kubeconfig仅 oil-calculator namespace
├── Dockerfile
└── doc/deploy.md
```
## 首次部署
已完成,以下为记录。
```bash
# 1. SSH 到服务器
ssh fam@oci.euphon.net
# 2. 上传项目
scp oil-calc.tar.gz fam@oci.euphon.net:/tmp/
ssh fam@oci.euphon.net "mkdir -p ~/oil-calculator && cd ~/oil-calculator && tar xzf /tmp/oil-calc.tar.gz"
# 3. 构建并推送镜像
cd ~/oil-calculator
docker build -t registry.oci.euphon.net/oil-calculator:latest .
docker push registry.oci.euphon.net/oil-calculator:latest
# 4. 部署 k8s 资源
cd deploy
kubectl apply -f namespace.yaml
kubectl apply -f pvc.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f ingress.yaml
# 5. 需要 regcred 才能拉取私有镜像(从已有 ns 复制)
kubectl get secret regcred -n guitar -o yaml | sed 's/namespace: guitar/namespace: oil-calculator/' | kubectl apply -f -
```
## 更新部署
```bash
# 在本地打包上传
cd "/Users/hera/Hera DOCS/Projects/Essential Oil Formula Cost Calculator"
tar czf /tmp/oil-calc.tar.gz Dockerfile backend/ frontend/ deploy/
scp /tmp/oil-calc.tar.gz fam@oci.euphon.net:/tmp/
# SSH 到服务器构建并重启
ssh fam@oci.euphon.net
cd ~/oil-calculator && tar xzf /tmp/oil-calc.tar.gz
docker build -t registry.oci.euphon.net/oil-calculator:latest .
docker push registry.oci.euphon.net/oil-calculator:latest
kubectl rollout restart deploy/oil-calculator -n oil-calculator
```
或者使用受限 kubeconfig仅重启不含构建
```bash
KUBECONFIG=deploy/kubeconfig kubectl rollout restart deploy/oil-calculator
```
## 受限 Kubeconfig
文件位于 `deploy/kubeconfig`,权限范围:
- **Namespace**: `oil-calculator` only
- **ServiceAccount**: `oil-calculator-deployer`
- **权限**: pods, services, deployments, replicasets, ingresses, PVC, configmaps, secrets, pods/log 的完整 CRUD
- **无法**访问其他 namespace 或集群级资源
重新生成:`bash deploy/setup-kubeconfig.sh`
## K8s 配置要点
- **Ingress class**: `traefik`
- **TLS annotation**: `traefik.ingress.kubernetes.io/router.tls.certresolver: le`
- **镜像仓库**: `registry.oci.euphon.net`(需要 `regcred` secret
- **数据持久化**: PVC 挂载到 `/data`SQLite 数据库在 `/data/oil_calculator.db`
## API 端点
| Method | Path | 说明 |
|--------|------|------|
| GET | /api/oils | 所有精油列表 |
| POST | /api/oils | 添加/更新精油 |
| DELETE | /api/oils/{name} | 删除精油 |
| GET | /api/recipes | 所有配方列表 |
| POST | /api/recipes | 新增配方 |
| PUT | /api/recipes/{id} | 更新配方 |
| DELETE | /api/recipes/{id} | 删除配方 |
| GET | /api/tags | 所有标签 |
| POST | /api/tags | 新增标签 |
| DELETE | /api/tags/{name} | 删除标签 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1
frontend/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="85">🌿</text></svg>

After

Width:  |  Height:  |  Size: 111 B

BIN
frontend/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8441
frontend/index.html Normal file

File diff suppressed because one or more lines are too long

53
scripts/backup.sh Executable file
View File

@@ -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/"

19
scripts/remote-backup.sh Executable file
View File

@@ -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

View File

@@ -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=<your-token>")
if __name__ == "__main__":
main()

2057
精油配方计算器.html Normal file

File diff suppressed because one or more lines are too long