Files
mengyaprofile/mengyaprofile-backend/db.py
2026-05-16 19:03:34 +08:00

619 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sqlite3
import unicodedata
from contextlib import contextmanager
DATA_DIR = os.environ.get('DATA_DIR', os.path.join(os.path.dirname(__file__), 'data'))
DATABASE_PATH = os.environ.get('DATABASE_PATH', os.path.join(DATA_DIR, 'site.db'))
_SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'schema.sql')
def _new_connection():
os.makedirs(os.path.dirname(DATABASE_PATH) or '.', exist_ok=True)
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON')
conn.execute('PRAGMA journal_mode = WAL')
return conn
@contextmanager
def get_connection():
conn = _new_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _apply_schema(conn):
with open(_SCHEMA_PATH, 'r', encoding='utf-8') as f:
conn.executescript(f.read())
def _profile_is_empty(conn):
row = conn.execute('SELECT COUNT(*) AS c FROM profile').fetchone()
return row['c'] == 0
def _clean_tag_list(tags):
out = []
for t in tags or []:
s = str(t).strip()
if s:
out.append(s)
return out
def _tech_name_set(conn):
return {
str(r['name']).strip().lower()
for r in conn.execute('SELECT name FROM techstack_item')
if r['name'] and str(r['name']).strip()
}
def _split_merged_tags(conn, merged):
tech_set = _tech_name_set(conn)
cat, tech = [], []
for t in merged or []:
s = str(t).strip()
if not s:
continue
(tech if s.lower() in tech_set else cat).append(s)
return cat, tech
def _project_tag_payload_from_body(conn, data):
if 'techTags' in data:
return _clean_tag_list(data.get('tags')), _clean_tag_list(data.get('techTags'))
return _split_merged_tags(conn, data.get('tags') or [])
def _replace_project_tags(conn, project_id, tags):
conn.execute('DELETE FROM project_tag WHERE project_id = ?', (project_id,))
for j, tag in enumerate(_clean_tag_list(tags)):
conn.execute(
'INSERT INTO project_tag (project_id, sort_order, tag) VALUES (?, ?, ?)',
(project_id, j, tag),
)
def _replace_project_tech_tags(conn, project_id, tags):
conn.execute('DELETE FROM project_tech_tag WHERE project_id = ?', (project_id,))
for j, tag in enumerate(_clean_tag_list(tags)):
conn.execute(
'INSERT INTO project_tech_tag (project_id, sort_order, tag) VALUES (?, ?, ?)',
(project_id, j, tag),
)
def _migrate_legacy_project_tags_if_needed(conn):
tech_set = _tech_name_set(conn)
for row in conn.execute('SELECT id FROM project'):
pid = row['id']
if conn.execute(
'SELECT 1 FROM project_tech_tag WHERE project_id = ? LIMIT 1',
(pid,),
).fetchone():
continue
tag_rows = conn.execute(
'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id',
(pid,),
).fetchall()
if not tag_rows:
continue
cat, tech = [], []
for r in tag_rows:
s = str(r['tag']).strip()
if not s:
continue
if s.lower() in tech_set:
tech.append(s)
else:
cat.append(s)
if not tech:
continue
conn.execute('DELETE FROM project_tag WHERE project_id = ?', (pid,))
for j, t in enumerate(cat):
conn.execute(
'INSERT INTO project_tag (project_id, sort_order, tag) VALUES (?,?,?)',
(pid, j, t),
)
for j, t in enumerate(tech):
conn.execute(
'INSERT INTO project_tech_tag (project_id, sort_order, tag) VALUES (?,?,?)',
(pid, j, t),
)
def _seed_minimal_defaults(conn):
"""空库时写入 profile 与技术栈区块占位行,内容通过管理后台或 SQL 维护。"""
conn.execute('INSERT INTO profile (id, nickname) VALUES (1, ?)', ('',))
conn.execute('INSERT INTO techstack_section (id, title) VALUES (1, ?)', ('技术栈',))
def _ensure_builtin_tech_items(conn):
"""内置技术栈项:按名称幂等追加(无则插入)。"""
builtin = (
(
'Vite',
'https://vitejs.dev/',
'https://pan.shumengya.top/d/smy/image/devkit/vite.svg',
'#646CFF',
),
(
'Tailwind CSS',
'https://tailwindcss.com/',
'https://pan.shumengya.top/d/smy/image/devkit/tailwind.svg',
'#06B6D4',
),
(
'RabbitMQ',
'https://www.rabbitmq.com/',
'https://pan.shumengya.top/d/smy/image/devkit/rabbitmq.svg',
'#FF6600',
),
)
for name, link, svg, color in builtin:
if conn.execute(
'SELECT 1 FROM techstack_item WHERE name = ? LIMIT 1',
(name,),
).fetchone():
continue
m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM techstack_item').fetchone()['m']
sort_order = int(m) + 10
conn.execute(
'''INSERT INTO techstack_item (sort_order, name, link, svg, color, is_show)
VALUES (?, ?, ?, ?, ?, ?)''',
(sort_order, name, link, svg, color, 1),
)
def _ensure_contact_is_show_column(conn):
"""旧库 contact 表无 is_show 时追加列(默认展示)。"""
cols = {str(r['name']) for r in conn.execute('PRAGMA table_info(contact)').fetchall()}
if cols and 'is_show' not in cols:
conn.execute(
'ALTER TABLE contact ADD COLUMN is_show INTEGER NOT NULL DEFAULT 1'
)
def init_storage():
with get_connection() as conn:
_apply_schema(conn)
_ensure_contact_is_show_column(conn)
if _profile_is_empty(conn):
_seed_minimal_defaults(conn)
else:
_migrate_legacy_project_tags_if_needed(conn)
_ensure_builtin_tech_items(conn)
_apply_tech_item_display_patches(conn)
_purge_retired_tech_labels(conn)
def _normalize_tech_label_key(s):
"""仅保留字母数字并小写NFKC 合并全角/兼容字形,匹配 d1SQL 各类写法。"""
if s is None:
return ''
t = unicodedata.normalize('NFKC', str(s)).strip()
return ''.join(c.lower() for c in t if c.isalnum())
def _purge_retired_tech_labels(conn):
"""移除已废弃的技术名:项目标签 + 技术栈表(幂等)。"""
retired_keys = {'d1sql'}
for table in ('project_tech_tag', 'project_tag'):
rows = conn.execute(f'SELECT id, tag FROM {table}').fetchall()
for r in rows:
if _normalize_tech_label_key(r['tag']) in retired_keys:
conn.execute(f'DELETE FROM {table} WHERE id = ?', (r['id'],))
rows = conn.execute('SELECT id, name FROM techstack_item').fetchall()
for r in rows:
if _normalize_tech_label_key(r['name']) in retired_keys:
conn.execute('DELETE FROM techstack_item WHERE id = ?', (r['id'],))
def _apply_tech_item_display_patches(conn):
"""微调技术栈徽章底色(图标为自带颜色的 SVG 时避免与底色撞色)。"""
patches = (
# Docker.svg 为蓝色填充:深蓝→亮蓝渐变,与白字、鲸鱼标层次更清晰
(
'Docker',
'linear-gradient(135deg, #052e4a 0%, #0d47a1 42%, #1e88e5 100%)',
),
# Flask.svg 为 #3babc3 青色,避免纯黑底;深青绿过渡到品牌青
(
'Flask',
'linear-gradient(135deg, #0f3a40 0%, #1e6570 45%, #2d8fa0 100%)',
),
# JSON / MarkdownSimple Icons 等品牌资料中的标准色(实心)
# JSON: https://simpleicons.org — #000000与 Crockford JSON 标识一致)
('JSON', '#000000'),
# Markdown: Markdown 标识常用主色 — #083FA5
('Markdown', '#083FA5'),
)
for name, color in patches:
conn.execute(
'UPDATE techstack_item SET color = ? WHERE name = ?',
(color, name),
)
def _row_to_profile(row):
if row is None:
return None
keys = ('favicon', 'nickname', 'avatar', 'introduction', 'footer', 'site', 'homepage')
return {k: row[k] for k in keys if row[k] is not None}
def get_profile():
with get_connection() as conn:
row = conn.execute(
'''SELECT favicon, nickname, avatar, introduction, footer, site, homepage
FROM profile WHERE id = 1'''
).fetchone()
return _row_to_profile(row)
def get_projects():
with get_connection() as conn:
rows = conn.execute(
'SELECT id, title, description, link, icon, is_admin, is_show, is_develop FROM project ORDER BY sort_order, id'
).fetchall()
out = []
for row in rows:
tags = [
r['tag']
for r in conn.execute(
'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id',
(row['id'],),
)
]
tech_tags = [
r['tag']
for r in conn.execute(
'SELECT tag FROM project_tech_tag WHERE project_id = ? ORDER BY sort_order, id',
(row['id'],),
)
]
out.append({
'id': row['id'],
'title': row['title'],
'description': row['description'] or '',
'link': row['link'] or '',
'icon': row['icon'] or '',
'tags': tags,
'techTags': tech_tags,
'admin': bool(row['is_admin']),
'show': bool(row['is_show']),
'develop': bool(row['is_develop']),
})
return {'projects': out}
def _contact_row_to_dict(row):
return {
'id': row['id'],
'type': row['type'],
'label': row['label'] or '',
'value': row['value'] or '',
'link': row['link'] or '',
'icon': row['icon'] or '',
'show': bool(row['is_show']),
}
def get_contacts():
"""前台公开接口:仅返回首页展示的联系方式。"""
with get_connection() as conn:
rows = conn.execute(
'''SELECT id, type, label, value, link, icon, is_show FROM contact
WHERE COALESCE(is_show, 1) = 1
ORDER BY sort_order, id'''
).fetchall()
return {
'contacts': [_contact_row_to_dict(row) for row in rows],
}
def get_techstack():
"""技术栈 svg 字段:可为 data/logo 下文件名,或 http(s) 外链 SVG 地址。"""
with get_connection() as conn:
sec = conn.execute('SELECT title FROM techstack_section WHERE id = 1').fetchone()
title = sec['title'] if sec else '技术栈'
rows = conn.execute(
'SELECT id, name, link, svg, color, is_show FROM techstack_item ORDER BY sort_order, id'
).fetchall()
return {
'title': title,
'items': [
{
'id': row['id'],
'name': row['name'],
'link': row['link'] or '',
'svg': row['svg'] or '',
'color': row['color'] or '',
'show': bool(row['is_show']),
}
for row in rows
],
}
def get_all(include_hidden_contacts=False):
with get_connection() as conn:
profile_row = conn.execute(
'''SELECT favicon, nickname, avatar, introduction, footer, site, homepage
FROM profile WHERE id = 1'''
).fetchone()
profile = _row_to_profile(profile_row)
tech_sec = conn.execute('SELECT title FROM techstack_section WHERE id = 1').fetchone()
tech_title = tech_sec['title'] if tech_sec else '技术栈'
tech_rows = conn.execute(
'SELECT id, name, link, svg, color, is_show FROM techstack_item ORDER BY sort_order, id'
).fetchall()
techstack = {
'title': tech_title,
'items': [
{
'id': r['id'],
'name': r['name'],
'link': r['link'] or '',
'svg': r['svg'] or '',
'color': r['color'] or '',
'show': bool(r['is_show']),
}
for r in tech_rows
],
}
proj_rows = conn.execute(
'SELECT id, title, description, link, icon, is_admin, is_show, is_develop FROM project ORDER BY sort_order, id'
).fetchall()
projects = []
for row in proj_rows:
tags = [
r['tag']
for r in conn.execute(
'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id',
(row['id'],),
)
]
tech_tags = [
r['tag']
for r in conn.execute(
'SELECT tag FROM project_tech_tag WHERE project_id = ? ORDER BY sort_order, id',
(row['id'],),
)
]
projects.append({
'id': row['id'],
'title': row['title'],
'description': row['description'] or '',
'link': row['link'] or '',
'icon': row['icon'] or '',
'tags': tags,
'techTags': tech_tags,
'admin': bool(row['is_admin']),
'show': bool(row['is_show']),
'develop': bool(row['is_develop']),
})
contact_rows = conn.execute(
'SELECT id, type, label, value, link, icon, is_show FROM contact ORDER BY sort_order, id'
).fetchall()
contact_list = []
for cr in contact_rows:
if not include_hidden_contacts and not bool(cr['is_show']):
continue
contact_list.append(_contact_row_to_dict(cr))
contacts = {'contacts': contact_list}
return {
'profile': profile,
'techstack': techstack,
'projects': {'projects': projects},
'contacts': contacts,
}
def update_profile(payload):
allowed = {'favicon', 'nickname', 'avatar', 'introduction', 'footer', 'site', 'homepage'}
updates = {k: payload[k] for k in allowed if k in payload}
if not updates:
return
sets = ', '.join(f'{k} = ?' for k in updates)
vals = list(updates.values())
with get_connection() as conn:
conn.execute(f'UPDATE profile SET {sets} WHERE id = 1', vals)
def create_project(data):
with get_connection() as conn:
cat_tags, tech_tags = _project_tag_payload_from_body(conn, data)
m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM project').fetchone()['m']
sort_order = int(m) + 10
cur = conn.execute(
'''INSERT INTO project (sort_order, title, description, link, icon, is_admin, is_show, is_develop)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(
sort_order,
data['title'],
data.get('description') or '',
data.get('link') or '',
data.get('icon') or '',
1 if data.get('admin') else 0,
1 if data.get('show', True) else 0,
1 if data.get('develop') else 0,
),
)
pid = cur.lastrowid
_replace_project_tags(conn, pid, cat_tags)
_replace_project_tech_tags(conn, pid, tech_tags)
return pid
def update_project(project_id, data):
with get_connection() as conn:
fields = []
vals = []
if 'title' in data:
fields.append('title = ?')
vals.append(data['title'])
if 'description' in data:
fields.append('description = ?')
vals.append(data['description'] or '')
if 'link' in data:
fields.append('link = ?')
vals.append(data['link'] or '')
if 'icon' in data:
fields.append('icon = ?')
vals.append(data['icon'] or '')
if 'admin' in data:
fields.append('is_admin = ?')
vals.append(1 if data['admin'] else 0)
if 'show' in data:
fields.append('is_show = ?')
vals.append(1 if data['show'] else 0)
if 'develop' in data:
fields.append('is_develop = ?')
vals.append(1 if data['develop'] else 0)
if fields:
vals.append(project_id)
conn.execute(
f"UPDATE project SET {', '.join(fields)} WHERE id = ?",
vals,
)
if 'tags' in data or 'techTags' in data:
if 'tags' in data and 'techTags' in data:
_replace_project_tags(conn, project_id, data['tags'])
_replace_project_tech_tags(conn, project_id, data['techTags'])
elif 'tags' in data:
cat, tech = _split_merged_tags(conn, data['tags'])
_replace_project_tags(conn, project_id, cat)
_replace_project_tech_tags(conn, project_id, tech)
else:
_replace_project_tech_tags(conn, project_id, data['techTags'])
def delete_project(project_id):
with get_connection() as conn:
conn.execute('DELETE FROM project WHERE id = ?', (project_id,))
def create_contact(data):
with get_connection() as conn:
m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM contact').fetchone()['m']
sort_order = int(m) + 10
cur = conn.execute(
'''INSERT INTO contact (sort_order, type, label, value, link, icon, is_show)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(
sort_order,
data['type'],
data.get('label') or '',
data.get('value') or '',
data.get('link') or '',
data.get('icon') or '',
1 if data.get('show', True) else 0,
),
)
return cur.lastrowid
def update_contact(contact_id, data):
with get_connection() as conn:
fields = []
vals = []
for col, key in (
('type', 'type'),
('label', 'label'),
('value', 'value'),
('link', 'link'),
('icon', 'icon'),
):
if key in data:
fields.append(f'{col} = ?')
vals.append(data[key] or '')
if 'show' in data:
fields.append('is_show = ?')
vals.append(1 if data['show'] else 0)
if fields:
vals.append(contact_id)
conn.execute(
f"UPDATE contact SET {', '.join(fields)} WHERE id = ?",
vals,
)
def delete_contact(contact_id):
with get_connection() as conn:
conn.execute('DELETE FROM contact WHERE id = ?', (contact_id,))
def set_techstack_title(title):
t = title or '技术栈'
with get_connection() as conn:
row = conn.execute('SELECT id FROM techstack_section WHERE id = 1').fetchone()
if row:
conn.execute('UPDATE techstack_section SET title = ? WHERE id = 1', (t,))
else:
conn.execute('INSERT INTO techstack_section (id, title) VALUES (1, ?)', (t,))
def create_techstack_item(data):
with get_connection() as conn:
m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM techstack_item').fetchone()['m']
sort_order = int(m) + 10
cur = conn.execute(
'''INSERT INTO techstack_item (sort_order, name, link, svg, color, is_show)
VALUES (?, ?, ?, ?, ?, ?)''',
(
sort_order,
data['name'],
data.get('link') or '',
data.get('svg') or '',
data.get('color') or '',
1 if data.get('show', True) else 0,
),
)
return cur.lastrowid
def update_techstack_item(item_id, data):
with get_connection() as conn:
fields = []
vals = []
if 'name' in data:
fields.append('name = ?')
vals.append(data['name'])
if 'link' in data:
fields.append('link = ?')
vals.append(data['link'] or '')
if 'svg' in data:
fields.append('svg = ?')
vals.append(data['svg'] or '')
if 'color' in data:
fields.append('color = ?')
vals.append(data['color'] or '')
if 'show' in data:
fields.append('is_show = ?')
vals.append(1 if data['show'] else 0)
if fields:
vals.append(item_id)
conn.execute(
f"UPDATE techstack_item SET {', '.join(fields)} WHERE id = ?",
vals,
)
def delete_techstack_item(item_id):
with get_connection() as conn:
conn.execute('DELETE FROM techstack_item WHERE id = ?', (item_id,))