619 lines
21 KiB
Python
619 lines
21 KiB
Python
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 / Markdown:Simple 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,))
|