Files
server-core/app/training/db.py
T
Pine 8b43d3df52 feat: 培训子应用迁入(app/training,/api/*)
- 报名/测评/政策/调研,独立 data/opc.db
- 源自原培训后端,路径调整至 server-core
2026-08-23 22:36:00 +08:00

249 lines
11 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.
"""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 根
DB_PATH = os.path.join(BASE_DIR, "data", "opc.db")
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
def get_conn():
conn = sqlite3.connect(DB_PATH)
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
);
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": "交流沙龙 · 1230 人小场", "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")
def init_db():
conn = get_conn()
conn.executescript(SCHEMA)
# 种子:内置超级账号(pine/123456,兼容原 mock
if conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] == 0:
from .auth import hash_password
conn.execute(
"INSERT INTO accounts (id,username,password,name,identities,created_at) VALUES (?,?,?,?,?,?)",
("U-PINE", "pine", hash_password("123456"), "Pine", json.dumps(["admin"]), now_iso()),
)
# 种子:排期
if conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] == 0:
for e in SEED_EVENTS:
conn.execute(
"INSERT INTO events (id,type,mode,title,subtitle,desc,location,host,image,link,start_at,duration_min,capacity,status) "
"VALUES (:id,:type,:mode,:title,:subtitle,:desc,:location,:host,:image,:link,:start_at,:duration_min,:capacity,:status)",
e,
)
# -- 幂等迁移:为已有 bookings 表补 audit_status 列 --
cols = [r["name"] for r in conn.execute("PRAGMA table_info(bookings)").fetchall()]
if "audit_status" not in cols:
conn.execute("ALTER TABLE bookings ADD COLUMN audit_status TEXT DEFAULT 'pending'")
# -- 幂等迁移:为已有 accounts 表补 wxid / phone / phone_bound / contact / avatar 列(旧库自愈,避免微信登录报缺列或每次新建账号) --
acols = [r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()]
for col, ddl in [
("wxid", "ALTER TABLE accounts ADD COLUMN wxid TEXT"),
("phone", "ALTER TABLE accounts ADD COLUMN phone TEXT"),
("contact", "ALTER TABLE accounts ADD COLUMN contact TEXT"),
("avatar", "ALTER TABLE accounts ADD COLUMN avatar TEXT"),
("phone_bound", "ALTER TABLE accounts ADD COLUMN phone_bound INTEGER DEFAULT 0"),
# 报名资料(个人中心设置,报名时自动带入)
("status_label", "ALTER TABLE accounts ADD COLUMN status_label TEXT"),
("topics", "ALTER TABLE accounts ADD COLUMN topics TEXT"),
("source", "ALTER TABLE accounts ADD COLUMN source TEXT"),
]:
if col not in acols:
conn.execute(ddl)
# -- 幂等迁移:为已有 events 表补 结束时间 / 签到开放时间 列 --
ecols = [r["name"] for r in conn.execute("PRAGMA table_info(events)").fetchall()]
for col, ddl in [
("end_at", "ALTER TABLE events ADD COLUMN end_at TEXT"),
("checkin_at", "ALTER TABLE events ADD COLUMN checkin_at TEXT"),
("audit_mode", "ALTER TABLE events ADD COLUMN audit_mode TEXT DEFAULT 'auto'"),
("show_capacity", "ALTER TABLE events ADD COLUMN show_capacity INTEGER DEFAULT 0"),
]:
if col not in ecols:
conn.execute(ddl)
conn.commit()
conn.close()
# ---------------- 通用读写 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()