2026-08-23 22:36:00 +08:00
|
|
|
|
"""SQLite 数据层(兼容原 mock 后端 JSON store,迁移用)
|
|
|
|
|
|
位于 server-core/app/training/,数据落在 server-core/data/opc.db(与平台库 app.db 并列)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
import time
|
|
|
|
|
|
import random
|
|
|
|
|
|
|
|
|
|
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # server-core 根
|
2026-08-24 20:31:57 +08:00
|
|
|
|
DB_PATH = os.path.join(BASE_DIR, "serverdata", "data", "app.db")
|
2026-08-23 22:36:00 +08:00
|
|
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_conn():
|
2026-08-24 01:43:52 +08:00
|
|
|
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
2026-08-23 22:36:00 +08:00
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gen_id(prefix):
|
|
|
|
|
|
ts = time.time()
|
|
|
|
|
|
s = int(ts)
|
|
|
|
|
|
# base36 大写,模拟 JS genId
|
|
|
|
|
|
def b36(n):
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return "0"
|
|
|
|
|
|
d = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
|
|
|
|
out = ""
|
|
|
|
|
|
while n:
|
|
|
|
|
|
out = d[n % 36] + out
|
|
|
|
|
|
n //= 36
|
|
|
|
|
|
return out
|
|
|
|
|
|
return prefix + b36(s).upper() + b36(random.randint(0, 35)).upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SCHEMA = """
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
username TEXT UNIQUE,
|
|
|
|
|
|
wxid TEXT,
|
|
|
|
|
|
phone TEXT,
|
|
|
|
|
|
contact TEXT,
|
|
|
|
|
|
name TEXT,
|
|
|
|
|
|
avatar TEXT,
|
|
|
|
|
|
password TEXT,
|
|
|
|
|
|
phone_bound INTEGER DEFAULT 0,
|
|
|
|
|
|
identities TEXT,
|
|
|
|
|
|
created_at TEXT
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
type TEXT,
|
|
|
|
|
|
mode TEXT DEFAULT 'offline',
|
|
|
|
|
|
title TEXT,
|
|
|
|
|
|
subtitle TEXT,
|
|
|
|
|
|
desc TEXT,
|
|
|
|
|
|
location TEXT,
|
|
|
|
|
|
host TEXT,
|
|
|
|
|
|
image TEXT,
|
|
|
|
|
|
link TEXT,
|
|
|
|
|
|
start_at TEXT,
|
|
|
|
|
|
duration_min INTEGER DEFAULT 90,
|
|
|
|
|
|
capacity INTEGER DEFAULT 0,
|
|
|
|
|
|
status TEXT DEFAULT 'open'
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS bookings (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
created_at TEXT,
|
|
|
|
|
|
status TEXT DEFAULT 'pending',
|
|
|
|
|
|
username TEXT,
|
|
|
|
|
|
name TEXT,
|
|
|
|
|
|
contact TEXT,
|
|
|
|
|
|
status_label TEXT,
|
|
|
|
|
|
want TEXT,
|
|
|
|
|
|
event_id TEXT,
|
|
|
|
|
|
event_title TEXT,
|
|
|
|
|
|
event_start TEXT,
|
|
|
|
|
|
topics TEXT,
|
|
|
|
|
|
question TEXT,
|
|
|
|
|
|
source TEXT,
|
|
|
|
|
|
checkin_at TEXT,
|
|
|
|
|
|
audit_status TEXT DEFAULT 'pending'
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS tests (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
created_at TEXT,
|
|
|
|
|
|
username TEXT,
|
|
|
|
|
|
type_code TEXT,
|
|
|
|
|
|
persona TEXT,
|
|
|
|
|
|
adapt_index INTEGER,
|
|
|
|
|
|
adapt_level TEXT,
|
|
|
|
|
|
tracks TEXT,
|
|
|
|
|
|
version TEXT
|
|
|
|
|
|
);
|
2026-08-24 16:57:13 +08:00
|
|
|
|
CREATE TABLE IF NOT EXISTS courses (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
category TEXT,
|
|
|
|
|
|
level TEXT,
|
|
|
|
|
|
title TEXT,
|
|
|
|
|
|
subtitle TEXT,
|
|
|
|
|
|
desc TEXT,
|
|
|
|
|
|
price REAL DEFAULT 0,
|
|
|
|
|
|
status TEXT DEFAULT 'draft',
|
|
|
|
|
|
start_at TEXT,
|
|
|
|
|
|
end_at TEXT,
|
|
|
|
|
|
venue TEXT,
|
|
|
|
|
|
quota INTEGER DEFAULT 0,
|
|
|
|
|
|
image TEXT,
|
|
|
|
|
|
host TEXT,
|
|
|
|
|
|
created_at TEXT
|
|
|
|
|
|
);
|
2026-08-23 22:36:00 +08:00
|
|
|
|
CREATE TABLE IF NOT EXISTS policy_logs (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
created_at TEXT,
|
|
|
|
|
|
username TEXT,
|
|
|
|
|
|
answers TEXT,
|
|
|
|
|
|
policies_count INTEGER,
|
|
|
|
|
|
subsidies_count INTEGER,
|
|
|
|
|
|
loans_count INTEGER,
|
|
|
|
|
|
summary TEXT
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS plan_logs (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
created_at TEXT,
|
|
|
|
|
|
username TEXT,
|
|
|
|
|
|
region TEXT,
|
|
|
|
|
|
status TEXT,
|
|
|
|
|
|
need_park INTEGER,
|
|
|
|
|
|
has_staff INTEGER,
|
|
|
|
|
|
steps_count INTEGER
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS survey_logs (
|
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
|
created_at TEXT,
|
|
|
|
|
|
username TEXT,
|
|
|
|
|
|
source TEXT,
|
|
|
|
|
|
answers TEXT
|
|
|
|
|
|
);
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
SEED_EVENTS = [
|
|
|
|
|
|
{"id": "E-F001", "type": "free", "mode": "online", "title": "AI 让民宿主每月省 10 小时", "subtitle": "民宿 / 文旅创业 · 公益课", "desc": "拆解民宿经营里最耗时的重复劳动——接单、排房、回复咨询。现场演示 AI 怎么把每周 10 小时的杂活接住,让你把时间还给体验和口碑。不卖课,点到即止。", "location": "视频号直播", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-f1/600/400", "link": "", "start_at": "2026-09-04T19:30:00+08:00", "duration_min": 90, "capacity": 0, "status": "open"},
|
|
|
|
|
|
{"id": "E-S001", "type": "salon", "mode": "offline", "title": "民宿 AI 化改造实战", "subtitle": "交流沙龙 · 12–30 人小场", "desc": "带上你的民宿,现场用 AI 搭建一套内容与客服小助手:从组标题、生成海报到自动回复,走通一遍。1 对 1 快诊你的具体场景,带走可复用的模板。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-s1/600/400", "link": "", "start_at": "2026-09-05T14:00:00+08:00", "duration_min": 180, "capacity": 30, "status": "invite"},
|
|
|
|
|
|
{"id": "E-F002", "type": "free", "mode": "offline", "title": "用 AI 做云南咖啡品牌", "subtitle": "咖啡 / 农特产创业 · 公益课", "desc": "从云南豆讲到品牌:怎么用一个好故事、一套 AI 内容打法,把咖啡从产地卖到全国。现场看一条内容从 0 到发布的完整流程。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-f2/600/400", "link": "", "start_at": "2026-09-06T19:30:00+08:00", "duration_min": 90, "capacity": 60, "status": "open"},
|
|
|
|
|
|
{"id": "E-S002", "type": "salon", "mode": "offline", "title": "技能打包:把手艺变成产品", "subtitle": "交流沙龙 · 副业转型专场", "desc": "工作坊:把你的技能、经验和手艺,改成能标价、能交付、能复制的产品。现场分组拆解你的卡点,产出一版可落地的产品草图。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-s2/600/400", "link": "", "start_at": "2026-09-07T14:00:00+08:00", "duration_min": 180, "capacity": 24, "status": "open"},
|
|
|
|
|
|
{"id": "E-F003", "type": "free", "mode": "online", "title": "一人公司财税避坑入门", "subtitle": "自由职业 / 小老板 · 公益课", "desc": "个体户、一人公司最容易踩的财税坑:发票、成本票、申报节点、核定与查账。用大白话讲清楚,让你不再为报税发愁。", "location": "视频号直播", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-f3/600/400", "link": "", "start_at": "2026-09-09T19:30:00+08:00", "duration_min": 90, "capacity": 0, "status": "pending"},
|
|
|
|
|
|
{"id": "E-F004", "type": "free", "mode": "offline", "title": "你适不适合创业?先做一次 OPC 测评", "subtitle": "大众 · 公益课", "desc": "现场 30 题速测你的 OPC 适配度:独立、风险、自驱、单兵、AI 意愿、安全垫六维速览,带你读懂自己的类型码与适合的赛道。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-f4/600/400", "link": "", "start_at": "2026-09-10T19:30:00+08:00", "duration_min": 90, "capacity": 60, "status": "open"},
|
|
|
|
|
|
{"id": "E-S003", "type": "salon", "mode": "offline", "title": "OPC 测评报告 1 对 1 解读专场", "subtitle": "交流沙龙 · 测评者组场", "desc": "每人 15 分钟快诊:读懂你的 4 字母类型码、适配 6 维和推荐赛道,现场把报告讲到能落地,并给出你的起点方向。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-s3/600/400", "link": "", "start_at": "2026-09-11T14:00:00+08:00", "duration_min": 180, "capacity": 20, "status": "open"},
|
|
|
|
|
|
{"id": "E-S004", "type": "salon", "mode": "offline", "title": "资源对接夜:能提供 × 需要", "subtitle": "交流沙龙 · 综合场", "desc": "一张桌子,把你的「能提供」和「需要」摆上台面,现场匹配同行与本地资源。可能认识下一个合作伙伴,也可能接住第一单。", "location": "昆明市大学生创业园", "host": "云超服 · OPC 培训", "image": "https://picsum.photos/seed/opc-s4/600/400", "link": "", "start_at": "2026-09-12T14:00:00+08:00", "duration_min": 180, "capacity": 30, "status": "done"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now_iso():
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 16:57:13 +08:00
|
|
|
|
SEED_COURSES = [
|
|
|
|
|
|
{"id": "C-001", "category": "AI应用", "level": "入门", "title": "AI 内容一键产出:从标题到海报", "subtitle": "公益课回放", "desc": "把组标题、写正文、配图、做海报这一条链路用 AI 跑通。", "price": 0, "status": "published", "start_at": "2026-09-04T19:30:00+08:00", "end_at": "", "venue": "视频号直播", "quota": 0, "image": "", "host": "云超服 · OPC 培训", "created_at": "2026-08-24T00:00:00Z"},
|
|
|
|
|
|
{"id": "C-002", "category": "创业", "level": "进阶", "title": "一人公司财税避坑进阶", "subtitle": "线上标准课", "desc": "个体户 / 一人公司:发票、成本票、申报节点、核定与查账。", "price": 499, "status": "draft", "start_at": "2026-09-20T19:30:00+08:00", "end_at": "", "venue": "视频号直播", "quota": 200, "image": "", "host": "云超服 · OPC 培训", "created_at": "2026-08-24T00:00:00Z"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:31:57 +08:00
|
|
|
|
# 运行时建表/种子已移除:表由 alembic(ORM 模型 accounts/events/... )创建;种子经 scripts/db/seed.py。
|
2026-08-23 22:36:00 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------- 通用读写 helpers ----------------
|
|
|
|
|
|
|
|
|
|
|
|
def rows_to_list(rows):
|
|
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_one(table, **kw):
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
where = " AND ".join([f"{k}=?" for k in kw])
|
|
|
|
|
|
row = conn.execute(f"SELECT * FROM {table} WHERE {where}", list(kw.values())).fetchone()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return dict(row) if row else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_by_id(table, rid):
|
|
|
|
|
|
return fetch_one(table, id=rid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_all(table, order_by=None):
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
sql = f"SELECT * FROM {table}"
|
|
|
|
|
|
if order_by:
|
|
|
|
|
|
sql += f" ORDER BY {order_by}"
|
|
|
|
|
|
rows = conn.execute(sql).fetchall()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return rows_to_list(rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def insert(table, data):
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
keys = list(data.keys())
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
f"INSERT INTO {table} ({','.join(keys)}) VALUES ({','.join(['?']*len(keys))})",
|
|
|
|
|
|
[data[k] for k in keys],
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_row(table, rid, patch):
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
keys = list(patch.keys())
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
f"UPDATE {table} SET {','.join([f'{k}=?' for k in keys])} WHERE id=?",
|
|
|
|
|
|
[patch[k] for k in keys] + [rid],
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_row(table, rid):
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
conn.close()
|