Initial commit
This commit is contained in:
16
xuexiao/scripts/debug_migrate.py
Normal file
16
xuexiao/scripts/debug_migrate.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app import create_app
|
||||
from models import db, Course, Student, StudentAccount
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
for model in (Course, Student, StudentAccount):
|
||||
t = model.__tablename__
|
||||
rows = db.session.execute(db.text(f'SHOW COLUMNS FROM `{t}`')).fetchall()
|
||||
existing = {r[0] for r in rows}
|
||||
model_cols = {c.name for c in model.__table__.columns}
|
||||
missing = model_cols - existing
|
||||
print(t, 'missing:', missing)
|
||||
88
xuexiao/scripts/global_check.py
Normal file
88
xuexiao/scripts/global_check.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""全局自检:路由、数据、财务展示。用法: python scripts/global_check.py"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
ISSUES = []
|
||||
|
||||
|
||||
def issue(level, code, msg):
|
||||
ISSUES.append((level, code, msg))
|
||||
print(f'[{level}] {code}: {msg}')
|
||||
|
||||
|
||||
def main():
|
||||
from app import create_app
|
||||
from models import (
|
||||
db, StudentAccount, Course, MonthlySnapshot, RechargeRecord,
|
||||
ConsumptionRecord, RefundRecord,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# 数据健康
|
||||
n_recharge = RechargeRecord.query.count()
|
||||
n_consume = ConsumptionRecord.query.count()
|
||||
n_snap = MonthlySnapshot.query.count()
|
||||
n_course_price = Course.query.filter(Course.price_per_hour > 0).count()
|
||||
n_orig = StudentAccount.query.filter(
|
||||
StudentAccount.original_price_per_lesson > 0,
|
||||
).count()
|
||||
|
||||
if n_snap > 100 and n_recharge == 0 and n_consume == 0:
|
||||
issue(
|
||||
'INFO', 'DATA-01',
|
||||
'业务数据主要来自 Excel 快照(充值/消课表为空),统计页应看「课时核对表」月份',
|
||||
)
|
||||
if n_snap > 0 and n_orig == 0:
|
||||
issue(
|
||||
'WARN', 'DATA-02',
|
||||
'账户未写入 original_price_per_lesson;已支持从快照单价回填,请重启 app',
|
||||
)
|
||||
if n_course_price == 0:
|
||||
issue(
|
||||
'WARN', 'DATA-03',
|
||||
'课程表 price_per_hour 均为 0,建议在「课程管理」维护原价单价',
|
||||
)
|
||||
|
||||
# 路由冒烟
|
||||
client = app.test_client()
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
paths = [
|
||||
'/students', '/students/detail/1', '/statistics',
|
||||
'/keshibiao/snapshots?year=2026&month=4',
|
||||
]
|
||||
for p in paths:
|
||||
r = client.get(p, follow_redirects=True)
|
||||
if r.status_code >= 500:
|
||||
issue('FAIL', 'HTTP', f'{p} -> {r.status_code}')
|
||||
|
||||
# 财务计算抽样
|
||||
from finance_display import account_refund_display, build_refund_preview
|
||||
from keshi_display import attach_latest_snapshots, snapshot_lessons_display, get_latest_period
|
||||
|
||||
py, pm = get_latest_period()
|
||||
acc = StudentAccount.query.first()
|
||||
if acc:
|
||||
snap = attach_latest_snapshots([acc], py, pm).get(acc.id)
|
||||
lg = snapshot_lessons_display(snap, acc) if snap else {}
|
||||
d = account_refund_display(acc, lg, snap)
|
||||
if d['kind'] == 'no_price' and snap and float(snap.unit_price or 0) > 0:
|
||||
issue(
|
||||
'WARN', 'FIN-01',
|
||||
'快照有单价但应退仍为「-」,请重启以执行单价回填',
|
||||
)
|
||||
|
||||
print('\n=== 汇总 ===')
|
||||
if not ISSUES:
|
||||
print('未发现阻塞性问题。')
|
||||
else:
|
||||
fails = [i for i in ISSUES if i[0] == 'FAIL']
|
||||
print(f'共 {len(ISSUES)} 条(失败 {len(fails)})')
|
||||
return 1 if any(i[0] == 'FAIL' for i in ISSUES) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
189
xuexiao/scripts/run_bug_audit.py
Normal file
189
xuexiao/scripts/run_bug_audit.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
全系统 Bug 审查脚本(对应 全系统Bug审查指令.md)
|
||||
用法: python scripts/run_bug_audit.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
ISSUES = []
|
||||
OK = []
|
||||
|
||||
|
||||
def fail(level, code, msg):
|
||||
ISSUES.append({'level': level, 'code': code, 'msg': msg})
|
||||
|
||||
|
||||
def pass_(code, msg):
|
||||
OK.append({'code': code, 'msg': msg})
|
||||
|
||||
|
||||
def audit_routes():
|
||||
from routes import register_all_routes
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
sensitive = {
|
||||
'refund_add': 'refund_add',
|
||||
'consumption_add': 'consumption_add',
|
||||
'consumption_batch': 'consumption_add',
|
||||
'recharge_add': 'recharge_add',
|
||||
'transfer_add': 'transfer_add',
|
||||
'keshibiao_import': 'keshibiao_import',
|
||||
}
|
||||
with app.app_context():
|
||||
for rule in app.url_map.iter_rules():
|
||||
ep = rule.endpoint
|
||||
if ep not in sensitive:
|
||||
continue
|
||||
vf = app.view_functions.get(ep)
|
||||
if not vf:
|
||||
continue
|
||||
src = (vf.__doc__ or '') + str(vf)
|
||||
# 检查装饰器链(简化:看函数 __wrapped__)
|
||||
fn = vf
|
||||
names = []
|
||||
while fn:
|
||||
names.append(getattr(fn, '__name__', ''))
|
||||
fn = getattr(fn, '__wrapped__', None)
|
||||
if 'permission_required' not in str(names) and 'decorated' not in str(names):
|
||||
# 读取源码文件更可靠
|
||||
import inspect
|
||||
try:
|
||||
mod = inspect.getmodule(vf)
|
||||
path = inspect.getfile(vf)
|
||||
text = Path(path).read_text(encoding='utf-8')
|
||||
block = text.split(f"endpoint='{ep}'")[0].split('def ')[-1]
|
||||
except Exception:
|
||||
text = ''
|
||||
if f"endpoint='{ep}'" in text:
|
||||
idx = text.find(f"endpoint='{ep}'")
|
||||
chunk = text[max(0, idx - 400):idx + 50]
|
||||
if 'permission_required' not in chunk:
|
||||
fail('🟡', f'PERM-{ep}', f'路由 {ep} 可能缺少 permission_required')
|
||||
else:
|
||||
pass_(f'PERM-{ep}', f'{ep} 已配置权限')
|
||||
else:
|
||||
pass_(f'PERM-{ep}', f'{ep} 已检查')
|
||||
else:
|
||||
pass_(f'PERM-{ep}', f'{ep} 权限装饰器存在')
|
||||
|
||||
|
||||
def audit_business_rules():
|
||||
import business_rules as r
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(r.deduct_hours)
|
||||
if 'gifted_hours' in src and 'normal_hours' in src:
|
||||
pass_('BR-01', 'deduct_hours 含正课/赠课分支')
|
||||
if 'consumed_normal' in inspect.getsource(r.calc_transfer_to_hours):
|
||||
fail('🔴', 'BR-02', '转课换算仍引用 consumed_normal')
|
||||
|
||||
tsrc = Path(ROOT / 'routes' / 'transfer.py').read_text(encoding='utf-8')
|
||||
if 'consumed_normal' in tsrc and '+ transfer_hours' in tsrc:
|
||||
fail('🔴', 'BR-03', 'transfer.py 仍将转课计入 consumed_normal')
|
||||
else:
|
||||
pass_('BR-03', '转课不污染 consumed_normal')
|
||||
|
||||
rsrc = Path(ROOT / 'routes' / 'refund.py').read_text(encoding='utf-8')
|
||||
if rsrc.count('promo_clawback_hours') > 2 and 'apply_refund' in rsrc:
|
||||
# 路由层不应重复扣赠课
|
||||
lines = [l for l in rsrc.splitlines() if 'gifted_hours' in l and 'promo' in l]
|
||||
dup = [l for l in rsrc.splitlines() if 'account.gifted_hours' in l and 'promo_clawback' in l]
|
||||
if dup:
|
||||
fail('🔴', 'BR-04', 'refund.py 可能重复扣赠课')
|
||||
else:
|
||||
pass_('BR-04', '退费赠课扣回仅在 apply_refund')
|
||||
else:
|
||||
pass_('BR-04', '退费路由无重复扣赠课')
|
||||
|
||||
if hasattr(r, 'propagate_snapshot_chain'):
|
||||
pass_('BR-05', '已实现快照链式 propagate')
|
||||
else:
|
||||
fail('🟠', 'BR-05', '缺少 propagate_snapshot_chain')
|
||||
|
||||
if hasattr(r, 'calc_consumed_hours_value'):
|
||||
pass_('BR-06', '消课金额按记录时点单价核算')
|
||||
else:
|
||||
fail('🟠', 'BR-06', '退费仍用单一单价×consumed_normal')
|
||||
|
||||
|
||||
def audit_db_models():
|
||||
from models import ConsumptionRecord, StudentAccount, RechargeRecord, RefundRecord
|
||||
cols = {c.name for c in ConsumptionRecord.__table__.columns}
|
||||
if 'unit_price_at_consume' in cols:
|
||||
pass_('DB-01', 'ConsumptionRecord.unit_price_at_consume 存在')
|
||||
else:
|
||||
fail('🟠', 'DB-01', '缺少 unit_price_at_consume 列')
|
||||
|
||||
if 'gift_clawed_back' in {c.name for c in RechargeRecord.__table__.columns}:
|
||||
pass_('DB-02', 'gift_clawed_back 存在')
|
||||
else:
|
||||
fail('🟠', 'DB-02', '缺少 gift_clawed_back')
|
||||
|
||||
acc_cols = {c.name for c in StudentAccount.__table__.columns}
|
||||
if 'unit_price' in acc_cols:
|
||||
pass_('DB-03', 'student_accounts.unit_price 存在')
|
||||
|
||||
|
||||
def audit_chain_snapshots():
|
||||
from app import create_app
|
||||
from models import MonthlySnapshot
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
snaps = MonthlySnapshot.query.limit(500).all()
|
||||
by_acc = {}
|
||||
for s in snaps:
|
||||
by_acc.setdefault(s.account_id, []).append(s)
|
||||
breaks = 0
|
||||
for aid, items in by_acc.items():
|
||||
items.sort(key=lambda x: (x.year, x.month))
|
||||
for i in range(len(items) - 1):
|
||||
cur, nxt = items[i], items[i + 1]
|
||||
ey, em = cur.year, cur.month
|
||||
ny = ey + (1 if em == 12 else 0)
|
||||
nm = 1 if em == 12 else em + 1
|
||||
if (nxt.year, nxt.month) != (ny, nm):
|
||||
continue
|
||||
for label, end_v, prev_v in (
|
||||
('正课', cur.end_lessons, nxt.prev_lessons),
|
||||
('余额', cur.end_balance, nxt.prev_balance),
|
||||
):
|
||||
if end_v is None or prev_v is None:
|
||||
continue
|
||||
if abs(float(end_v) - float(prev_v)) > 0.05:
|
||||
breaks += 1
|
||||
if breaks:
|
||||
fail('🟠', 'CHAIN-01', f'数据库中有 {breaks} 处月初≠上月末(源表或需 repair)')
|
||||
else:
|
||||
pass_('CHAIN-01', '抽样快照链衔接正常')
|
||||
|
||||
|
||||
def main():
|
||||
print('=== 教培管理系统 Bug 审查 ===\n')
|
||||
audit_business_rules()
|
||||
audit_db_models()
|
||||
try:
|
||||
audit_routes()
|
||||
except Exception as e:
|
||||
fail('🟡', 'ROUTE-AUDIT', f'路由审查异常: {e}')
|
||||
try:
|
||||
audit_chain_snapshots()
|
||||
except Exception as e:
|
||||
fail('🟠', 'CHAIN-AUDIT', f'链式审查异常: {e}')
|
||||
|
||||
for item in OK:
|
||||
print(f' [OK] {item["code"]}: {item["msg"]}')
|
||||
print()
|
||||
if not ISSUES:
|
||||
print('未发现新问题。')
|
||||
return 0
|
||||
for item in ISSUES:
|
||||
print(f' [{item["level"]}] {item["code"]}: {item["msg"]}')
|
||||
print(f'\n合计: {len(ISSUES)} 项待关注, {len(OK)} 项通过')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
13
xuexiao/scripts/run_migrate.py
Normal file
13
xuexiao/scripts/run_migrate.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app import create_app
|
||||
from utils import auto_migrate
|
||||
from models import db
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
auto_migrate()
|
||||
rows = db.session.execute(db.text('SHOW COLUMNS FROM `courses`')).fetchall()
|
||||
print('courses columns:', [r[0] for r in rows])
|
||||
23
xuexiao/scripts/test_alter.py
Normal file
23
xuexiao/scripts/test_alter.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app import create_app
|
||||
from models import db, Course
|
||||
from utils import _migrate_col_type, is_mysql
|
||||
from db_bootstrap import is_doris
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print('is_mysql', is_mysql(), 'is_doris', is_doris())
|
||||
col = Course.__table__.columns['course_code']
|
||||
col_type = _migrate_col_type(col)
|
||||
sql = f"ALTER TABLE `courses` ADD COLUMN `course_code` {col_type} DEFAULT ''"
|
||||
print('SQL:', sql)
|
||||
try:
|
||||
db.session.execute(db.text(sql))
|
||||
db.session.commit()
|
||||
print('SUCCESS')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print('FAIL:', e)
|
||||
Reference in New Issue
Block a user