453 lines
17 KiB
Python
453 lines
17 KiB
Python
import os
|
|
import sqlite3
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, FastAPI
|
|
|
|
from app.security import hash_password
|
|
|
|
|
|
DATABASE_PATH = Path(os.getenv("DATABASE_PATH", Path(__file__).parents[1] / "app.sqlite3"))
|
|
|
|
|
|
def ensure_user_schema(connection: sqlite3.Connection) -> None:
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
name TEXT NOT NULL,
|
|
phone TEXT,
|
|
nickname TEXT,
|
|
avatar TEXT,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
total_spent REAL NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
|
|
columns = {
|
|
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
|
for row in connection.execute("PRAGMA table_info(users)").fetchall()
|
|
}
|
|
additions = {
|
|
"registered_at": "TEXT",
|
|
"username": "TEXT",
|
|
"password_hash": "TEXT",
|
|
"failed_login_count": "INTEGER NOT NULL DEFAULT 0",
|
|
"locked_until": "TEXT",
|
|
"name": "TEXT",
|
|
"phone": "TEXT",
|
|
"nickname": "TEXT",
|
|
"avatar": "TEXT",
|
|
"is_admin": "INTEGER NOT NULL DEFAULT 0",
|
|
"total_spent": "REAL NOT NULL DEFAULT 0",
|
|
}
|
|
|
|
for column_name, definition in additions.items():
|
|
if column_name not in columns:
|
|
connection.execute(f"ALTER TABLE users ADD COLUMN {column_name} {definition}")
|
|
|
|
columns = {
|
|
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
|
for row in connection.execute("PRAGMA table_info(users)").fetchall()
|
|
}
|
|
|
|
connection.execute(
|
|
"UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL OR registered_at = ''"
|
|
)
|
|
if "full_name" in columns:
|
|
connection.execute("UPDATE users SET name = full_name WHERE (name IS NULL OR name = '') AND full_name IS NOT NULL")
|
|
if "username" in columns:
|
|
connection.execute("UPDATE users SET name = username WHERE name IS NULL OR name = ''")
|
|
|
|
connection.execute("UPDATE users SET name = 'Unknown User' WHERE name IS NULL OR name = ''")
|
|
connection.execute("UPDATE users SET is_admin = 0 WHERE is_admin IS NULL")
|
|
connection.execute("UPDATE users SET total_spent = 0 WHERE total_spent IS NULL")
|
|
connection.execute("UPDATE users SET failed_login_count = 0 WHERE failed_login_count IS NULL")
|
|
connection.execute("UPDATE users SET username = lower(nickname) WHERE (username IS NULL OR username = '') AND nickname IS NOT NULL AND nickname != ''")
|
|
connection.execute(
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
|
|
ON users(phone)
|
|
WHERE phone IS NOT NULL AND phone != ''
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_unique
|
|
ON users(username)
|
|
WHERE username IS NOT NULL AND username != ''
|
|
"""
|
|
)
|
|
|
|
|
|
def set_user_password(connection: sqlite3.Connection, username: str, password: str) -> None:
|
|
connection.execute(
|
|
"""
|
|
UPDATE users
|
|
SET username = ?, password_hash = ?
|
|
WHERE username = ? OR nickname = ? OR phone = ?
|
|
""",
|
|
(username, hash_password(password), username, username, username),
|
|
)
|
|
|
|
|
|
def ensure_default_user_credentials(connection: sqlite3.Connection) -> None:
|
|
admin = connection.execute(
|
|
"SELECT id, password_hash FROM users WHERE username = 'admin' OR nickname = 'admin' OR phone = '13800000000'"
|
|
).fetchone()
|
|
if admin is not None and not str(admin["password_hash"] or "").startswith("$2"):
|
|
set_user_password(connection, "admin", "admin123456")
|
|
|
|
demo = connection.execute(
|
|
"SELECT id, password_hash FROM users WHERE username = 'demo' OR nickname = 'demo' OR phone = '13900000000'"
|
|
).fetchone()
|
|
if demo is not None and not str(demo["password_hash"] or "").startswith("$2"):
|
|
set_user_password(connection, "demo", "demo123456")
|
|
|
|
rows = connection.execute(
|
|
"SELECT id FROM users WHERE username IS NULL OR username = '' ORDER BY id"
|
|
).fetchall()
|
|
for row in rows:
|
|
connection.execute("UPDATE users SET username = ? WHERE id = ?", (f"user{row['id']}", row["id"]))
|
|
|
|
|
|
def ensure_product_schema(connection: sqlite3.Connection) -> None:
|
|
columns = {
|
|
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
|
for row in connection.execute("PRAGMA table_info(products)").fetchall()
|
|
}
|
|
additions = {
|
|
"alias": "TEXT",
|
|
"description": "TEXT",
|
|
"payment_button_name": "TEXT NOT NULL DEFAULT '立即支付'",
|
|
"donation_button_name": "TEXT NOT NULL DEFAULT '随喜'",
|
|
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
|
"show_participant_count": "INTEGER NOT NULL DEFAULT 0",
|
|
"cover_image": "TEXT",
|
|
"images_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"listed_at": "TEXT",
|
|
"sale_starts_at": "TEXT",
|
|
"sale_ends_at": "TEXT",
|
|
"delisted_at": "TEXT",
|
|
"show_countdown": "INTEGER NOT NULL DEFAULT 0",
|
|
"show_on_home": "INTEGER NOT NULL DEFAULT 0",
|
|
"specs_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"details_html": "TEXT",
|
|
"share_title": "TEXT",
|
|
"share_description": "TEXT",
|
|
"share_image": "TEXT",
|
|
"merit_certificate_reserved": "INTEGER NOT NULL DEFAULT 0",
|
|
"auto_process": "INTEGER NOT NULL DEFAULT 0",
|
|
"sales_count": "INTEGER NOT NULL DEFAULT 0",
|
|
}
|
|
|
|
for column_name, definition in additions.items():
|
|
if column_name not in columns:
|
|
connection.execute(f"ALTER TABLE products ADD COLUMN {column_name} {definition}")
|
|
|
|
|
|
def init_db() -> None:
|
|
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with sqlite3.connect(DATABASE_PATH) as connection:
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS todos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
done INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
ensure_user_schema(connection)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS temples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
location TEXT,
|
|
contact_phone TEXT,
|
|
description TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 1
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
temple_id INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
price REAL NOT NULL DEFAULT 0,
|
|
stock INTEGER NOT NULL DEFAULT 0,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
|
)
|
|
"""
|
|
)
|
|
ensure_product_schema(connection)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS ritual_services (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
temple_id INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
price REAL NOT NULL DEFAULT 0,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
temple_id INTEGER NOT NULL,
|
|
customer_name TEXT NOT NULL,
|
|
customer_phone TEXT,
|
|
order_type TEXT NOT NULL,
|
|
item_name TEXT NOT NULL,
|
|
amount REAL NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (temple_id) REFERENCES temples(id)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS payment_transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
temple_id INTEGER NOT NULL,
|
|
order_id INTEGER NOT NULL,
|
|
channel TEXT NOT NULL DEFAULT 'wechat',
|
|
out_trade_no TEXT NOT NULL UNIQUE,
|
|
amount INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'created',
|
|
prepay_id TEXT,
|
|
transaction_id TEXT,
|
|
raw_notify_json TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (temple_id) REFERENCES temples(id),
|
|
FOREIGN KEY (order_id) REFERENCES orders(id)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_captchas (
|
|
id TEXT PRIMARY KEY,
|
|
answer_hash TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
revoked_at TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)
|
|
"""
|
|
)
|
|
session_columns = {
|
|
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
|
for row in connection.execute("PRAGMA table_info(auth_sessions)").fetchall()
|
|
}
|
|
if "revoked_at" not in session_columns:
|
|
connection.execute("ALTER TABLE auth_sessions ADD COLUMN revoked_at TEXT")
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT,
|
|
ip_address TEXT NOT NULL,
|
|
user_agent TEXT,
|
|
success INTEGER NOT NULL DEFAULT 0,
|
|
failure_reason TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS login_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
ip_address TEXT NOT NULL,
|
|
user_agent TEXT,
|
|
is_new_ip INTEGER NOT NULL DEFAULT 0,
|
|
is_new_device INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)
|
|
"""
|
|
)
|
|
|
|
todo_count = connection.execute("SELECT COUNT(*) FROM todos").fetchone()[0]
|
|
if todo_count == 0:
|
|
connection.executemany(
|
|
"INSERT INTO todos (title, done) VALUES (?, ?)",
|
|
[
|
|
("Learn FastAPI routes", 0),
|
|
("Open interactive API docs", 0),
|
|
],
|
|
)
|
|
|
|
user_count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
if user_count == 0:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO users
|
|
(name, phone, nickname, avatar, is_admin, total_spent)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
[
|
|
("管理员", "13800000000", "admin", "", 1, 0),
|
|
("演示用户", "13900000000", "demo", "", 0, 128.5),
|
|
],
|
|
)
|
|
ensure_default_user_credentials(connection)
|
|
|
|
temple_count = connection.execute("SELECT COUNT(*) FROM temples").fetchone()[0]
|
|
if temple_count == 0:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO temples (name, location, contact_phone, description, is_active)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
[
|
|
("福田寺", "深圳福田", "0755-10000001", "自在福田示例寺院", 1),
|
|
("南山寺", "深圳南山", "0755-10000002", "用于验证数据隔离的示例寺院", 1),
|
|
],
|
|
)
|
|
|
|
product_count = connection.execute("SELECT COUNT(*) FROM products").fetchone()[0]
|
|
if product_count == 0:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO products
|
|
(
|
|
temple_id, name, alias, payment_button_name, donation_button_name,
|
|
price, stock, sort_order, cover_image, show_on_home, specs_json,
|
|
details_html, share_title, share_description, is_active, sales_count
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
[
|
|
(
|
|
1,
|
|
"平安香",
|
|
"平安",
|
|
"立即结缘",
|
|
"随喜功德",
|
|
29.9,
|
|
100,
|
|
10,
|
|
"",
|
|
1,
|
|
'[{"name":"单份","price":29.9,"stock":100,"sort_order":1,"is_chaodu":false}]',
|
|
"<p>祈愿平安顺遂。</p>",
|
|
"平安香",
|
|
"为家人祈福平安",
|
|
1,
|
|
32,
|
|
),
|
|
(
|
|
1,
|
|
"祈福灯",
|
|
"供灯",
|
|
"立即供灯",
|
|
"随喜供灯",
|
|
99.0,
|
|
50,
|
|
20,
|
|
"",
|
|
1,
|
|
'[{"name":"一盏","price":99,"stock":50,"sort_order":1,"is_chaodu":false}]',
|
|
"<p>供灯祈福,愿心光明。</p>",
|
|
"祈福灯",
|
|
"供灯祈福,照亮善愿",
|
|
1,
|
|
18,
|
|
),
|
|
(
|
|
2,
|
|
"南山香礼",
|
|
"香礼",
|
|
"立即支付",
|
|
"随喜",
|
|
39.9,
|
|
80,
|
|
10,
|
|
"",
|
|
1,
|
|
'[{"name":"单份","price":39.9,"stock":80,"sort_order":1,"is_chaodu":false}]',
|
|
"<p>南山寺香礼。</p>",
|
|
"南山香礼",
|
|
"南山寺祈福香礼",
|
|
1,
|
|
11,
|
|
),
|
|
],
|
|
)
|
|
|
|
ritual_count = connection.execute("SELECT COUNT(*) FROM ritual_services").fetchone()[0]
|
|
if ritual_count == 0:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO ritual_services (temple_id, name, description, price, is_active)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
[
|
|
(1, "祈福法会", "为家人祈福平安", 199.0, 1),
|
|
(1, "超度法事", "超度追思法事", 399.0, 1),
|
|
(2, "供灯祈福", "南山寺供灯祈福", 129.0, 1),
|
|
],
|
|
)
|
|
|
|
order_count = connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
|
|
if order_count == 0:
|
|
connection.executemany(
|
|
"""
|
|
INSERT INTO orders
|
|
(temple_id, customer_name, customer_phone, order_type, item_name, amount, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
[
|
|
(1, "张三", "13800138000", "product", "平安香", 29.9, "paid"),
|
|
(1, "李四", "13900139000", "ritual", "祈福法会", 199.0, "pending"),
|
|
(2, "王五", "13700137000", "product", "南山香礼", 39.9, "paid"),
|
|
],
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
yield
|
|
|
|
|
|
def get_db():
|
|
connection = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
|
|
connection.row_factory = sqlite3.Row
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
Database = Annotated[sqlite3.Connection, Depends(get_db)]
|