Initial commit: Essential Oil Formula Cost Calculator
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
*.pyc
|
||||
__pycache__/
|
||||
deploy/kubeconfig
|
||||
all_recipes_extracted.json
|
||||
backups/
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
306
backend/database.py
Normal 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
8091
backend/defaults.json
Normal file
File diff suppressed because it is too large
Load Diff
1497
backend/main.py
Normal file
1497
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
aiosqlite==0.20.0
|
||||
39
deploy/backup-cronjob.yaml
Normal file
39
deploy/backup-cronjob.yaml
Normal 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
30
deploy/cronjob.yaml
Normal 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
47
deploy/deployment.yaml
Normal 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
23
deploy/ingress.yaml
Normal 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
4
deploy/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: oil-calculator
|
||||
11
deploy/pvc.yaml
Normal file
11
deploy/pvc.yaml
Normal 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
11
deploy/service.yaml
Normal 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
|
||||
82
deploy/setup-kubeconfig.sh
Normal file
82
deploy/setup-kubeconfig.sh
Normal 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
114
doc/deploy.md
Normal 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} | 删除标签 |
|
||||
BIN
frontend/apple-touch-icon-180.png
Normal file
BIN
frontend/apple-touch-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/apple-touch-icon-192.png
Normal file
BIN
frontend/apple-touch-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
frontend/apple-touch-icon.png
Normal file
BIN
frontend/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1
frontend/favicon.svg
Normal file
1
frontend/favicon.svg
Normal 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
BIN
frontend/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
8441
frontend/index.html
Normal file
8441
frontend/index.html
Normal file
File diff suppressed because one or more lines are too long
53
scripts/backup.sh
Executable file
53
scripts/backup.sh
Executable 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
19
scripts/remote-backup.sh
Executable 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
|
||||
46
scripts/scan-recipes-folder.py
Normal file
46
scripts/scan-recipes-folder.py
Normal 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
2057
精油配方计算器.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user