fccfd775a3
- 根因:BIZ-108 commit 31cabbe0 启用 auth_enabled=True,但
index.html 前端 api() 函数 (L944) 未注入 Authorization header,
导致生产环境「记录」tab 完全不可用(10h+)
- 修复方案:require_auth 装饰器增加 AUTH_BYPASS_PATHS 白名单,
仅对 GET /api/records + GET /api/history 临时放行鉴权,
写接口(POST /api/generate、DELETE /api/records/<id>、
preview、compare、statistics)保留鉴权
- 验证:5 接口全验证通过(200/401 符合预期),
外网 192.168.1.99:8085 访问正常
- 跟踪:BIZ-110 由 costcodev 适配前端后移除白名单
- 备份:app.py.bak.biz109.20260901050002
- 闭环:2026-09-01 05:18 GMT+8 by 严维序 (opengineer)
1032 lines
39 KiB
Python
1032 lines
39 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
双色球 Web UI 服务
|
||
集成号码生成、历史数据查看、生成记录管理
|
||
监听 0.0.0.0,支持 PC 端和移动端响应式访问
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import uuid
|
||
import traceback
|
||
import threading
|
||
from datetime import datetime, timedelta
|
||
from flask import Flask, send_from_directory, jsonify, request, send_file, abort
|
||
from functools import wraps
|
||
|
||
import pandas as pd
|
||
import re
|
||
|
||
# 将项目目录加入路径,以便导入 lottery.py 和 history_loader.py
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# 导入公共历史数据加载模块
|
||
from history_loader import load_history_dataframe as _load_history, parse_number_string, compute_statistics
|
||
sys.path.insert(0, BASE_DIR)
|
||
|
||
# 导入号码生成器
|
||
from lottery import DoubleColorBallGenerator
|
||
|
||
app = Flask(__name__)
|
||
|
||
# ============================================================
|
||
# 中奖金额常量表(PRD v1.2 BIZ-105 §4.3)
|
||
# ============================================================
|
||
# PRIZE_AMOUNT: 奖项 → (单注金额, 是否浮动奖)
|
||
# 一/二等奖金额官方按奖池浮动,历史数据无奖池字段,标记为浮动,
|
||
# 不参与 total_prize 汇总,仅在 UI 用橙色角标展示。
|
||
PRIZE_AMOUNT = {
|
||
'一等奖': (5000000, True), # 浮动:实际金额按奖池浮动
|
||
'二等奖': (200000, True), # 浮动:实际金额按奖池浮动
|
||
'三等奖': (3000, False), # 固定:3,000 元
|
||
'四等奖': (200, False), # 固定:200 元
|
||
'五等奖': (10, False), # 固定:10 元
|
||
'六等奖': (5, False), # 固定:5 元
|
||
'未中奖': (0, False),
|
||
}
|
||
|
||
# PRIZE_KEY_MAP: 中文奖级 → JSON key(便于前端按固定 key 访问)
|
||
PRIZE_KEY_MAP = {
|
||
'一等奖': 'first',
|
||
'二等奖': 'second',
|
||
'三等奖': 'third',
|
||
'四等奖': 'fourth',
|
||
'五等奖': 'fifth',
|
||
'六等奖': 'sixth',
|
||
}
|
||
|
||
|
||
def format_amount(amount):
|
||
"""金额千分位格式化(PRD v1.2 §3.2:数字紧邻「元」不空格)。
|
||
|
||
>>> format_amount(3000)
|
||
'3,000元'
|
||
>>> format_amount(5000000)
|
||
'5,000,000元'
|
||
>>> format_amount(0)
|
||
'0元'
|
||
"""
|
||
return f'{int(amount):,}元'
|
||
|
||
|
||
def compute_prize_summary(results):
|
||
"""按 PRD v1.2 §4.3 计算中奖汇总。
|
||
|
||
入参:results — 比对结果列表,每项含 'prize_level' 字段。
|
||
返回:dict,含 total_prize / has_variable_prize / prize_summary / prize_display
|
||
- total_prize: 固定奖级总金额(不含浮动)
|
||
- has_variable_prize: 是否存在一/二等奖命中
|
||
- prize_summary: {key: {count, amount}} 仅含 count > 0 的奖项
|
||
- prize_display: [{level, count, amount}] 浮动奖不进入展示列表
|
||
"""
|
||
# 1. 统计各奖级命中数
|
||
prize_counts = {}
|
||
for r in results:
|
||
level = r.get('prize_level', '未中奖')
|
||
prize_counts[level] = prize_counts.get(level, 0) + 1
|
||
|
||
# 2. 聚合
|
||
total_prize = 0
|
||
has_variable_prize = False
|
||
prize_summary = {}
|
||
prize_display = []
|
||
|
||
for level, count in prize_counts.items():
|
||
if level == '未中奖' or count <= 0:
|
||
continue
|
||
amount, is_float = PRIZE_AMOUNT.get(level, (0, False))
|
||
key = PRIZE_KEY_MAP.get(level, '')
|
||
|
||
if is_float:
|
||
# 浮动奖:仅标记,不计入 total_prize
|
||
has_variable_prize = True
|
||
prize_summary[key] = {'count': count, 'amount': 0}
|
||
else:
|
||
# 固定奖:计入 total_prize
|
||
total_prize += amount * count
|
||
prize_summary[key] = {'count': count, 'amount': amount * count}
|
||
|
||
# 展示列表:浮动奖不进入(按 PRD v1.2 决策 9)
|
||
if not is_float:
|
||
prize_display.append({
|
||
'level': level,
|
||
'count': count,
|
||
'amount': format_amount(amount),
|
||
})
|
||
|
||
return {
|
||
'total_prize': total_prize,
|
||
'total_prize_display': format_amount(total_prize),
|
||
'has_variable_prize': has_variable_prize,
|
||
'prize_summary': prize_summary,
|
||
'prize_display': prize_display,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 配置
|
||
# ============================================================
|
||
CONFIG = {
|
||
'host': '0.0.0.0',
|
||
'port': 8085,
|
||
'history_file': os.path.join(BASE_DIR, '双色球历史数据.xlsx'),
|
||
'lottery_output_dir': os.path.join(BASE_DIR, 'lottery'),
|
||
'records_file': os.path.join(BASE_DIR, '.generation_records.json'),
|
||
'api_token': os.environ.get('LOTTO_API_TOKEN'), # BIZ-108: 强制从 env 读取,删除默认 'lotto2026' 兜底
|
||
'auth_enabled': True, # BIZ-108: 启用鉴权(默认 False 是安全漏洞)
|
||
'max_tickets': 1000,
|
||
'default_tickets': 10,
|
||
# 数据抓取配置(原 web_executor.py 功能)
|
||
'fetch_script': os.path.join(BASE_DIR, 'fetch_data.py'),
|
||
'fetch_status_file': os.path.join(BASE_DIR, '.fetch_status.json'),
|
||
'fetch_timeout': 300, # 抓取超时秒数
|
||
}
|
||
|
||
# ============================================================
|
||
# BIZ-108 安全加固:启动期强制校验 LOTTO_API_TOKEN
|
||
# ============================================================
|
||
# 规则:auth_enabled=True 时,token 必须存在且长度 ≥ 32
|
||
# 缺失或过短 → 启动失败(fail-fast),避免静默使用默认 token
|
||
_API_TOKEN = CONFIG['api_token']
|
||
if CONFIG['auth_enabled']:
|
||
if not _API_TOKEN or len(_API_TOKEN) < 32:
|
||
raise RuntimeError(
|
||
"[BIZ-108] LOTTO_API_TOKEN 缺失或长度 < 32 字符。"
|
||
"请在 /etc/lotto/env 配置强随机 token(建议 64 字符)。"
|
||
"生成命令: openssl rand -base64 48 | tr -d '\\n=' | cut -c1-64"
|
||
)
|
||
|
||
# ============================================================
|
||
# 生成记录管理(线程安全)
|
||
# ============================================================
|
||
# 全局锁:保护 .generation_records.json 的并发读写
|
||
records_lock = threading.Lock()
|
||
|
||
def load_records():
|
||
"""加载生成记录(线程安全读取)"""
|
||
with records_lock:
|
||
if os.path.exists(CONFIG['records_file']):
|
||
try:
|
||
with open(CONFIG['records_file'], 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, IOError):
|
||
return []
|
||
return []
|
||
|
||
def save_records(records):
|
||
"""保存生成记录(线程安全写入)"""
|
||
with records_lock:
|
||
os.makedirs(os.path.dirname(CONFIG['records_file']), exist_ok=True)
|
||
# 先写临时文件再原子替换,防止写入中途崩溃导致数据损坏
|
||
tmp_path = CONFIG['records_file'] + '.tmp'
|
||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||
os.replace(tmp_path, CONFIG['records_file'])
|
||
|
||
def add_record(strategy, num_tickets, filename):
|
||
"""添加一条生成记录(原子操作:读-改-写全程持锁)"""
|
||
with records_lock:
|
||
# 读取现有记录
|
||
if os.path.exists(CONFIG['records_file']):
|
||
try:
|
||
with open(CONFIG['records_file'], 'r', encoding='utf-8') as f:
|
||
records = json.load(f)
|
||
except (json.JSONDecodeError, IOError):
|
||
records = []
|
||
else:
|
||
records = []
|
||
# 插入新记录
|
||
new_record = {
|
||
'id': str(uuid.uuid4())[:8],
|
||
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
'strategy': '高级策略' if strategy == 'advanced' else '基础策略',
|
||
'num_tickets': num_tickets,
|
||
'filename': filename,
|
||
'filesize': os.path.getsize(os.path.join(BASE_DIR, filename)) if os.path.exists(os.path.join(BASE_DIR, filename)) else 0
|
||
}
|
||
records.insert(0, new_record)
|
||
# 原子写入
|
||
os.makedirs(os.path.dirname(CONFIG['records_file']), exist_ok=True)
|
||
tmp_path = CONFIG['records_file'] + '.tmp'
|
||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||
os.replace(tmp_path, CONFIG['records_file'])
|
||
return new_record
|
||
|
||
# ============================================================
|
||
# Excel 历史数据读取辅助
|
||
# ============================================================
|
||
# 历史数据加载 — 使用公共模块 history_loader.py
|
||
# ============================================================
|
||
def load_history_dataframe():
|
||
"""加载历史数据 Excel,委托公共模块处理多格式兼容。"""
|
||
return _load_history(CONFIG['history_file'])
|
||
|
||
|
||
def _row_to_draw(row):
|
||
"""将 DataFrame 行转换为开奖数据 dict(公共函数)。
|
||
|
||
供 find_draw_by_gen_time 及其他需要解析开奖记录的函数统一调用。
|
||
"""
|
||
s = str(row.get('号码', '')).strip()
|
||
reds, blue = parse_number_string(s)
|
||
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||
draw_date = None
|
||
try:
|
||
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||
except (ValueError, TypeError):
|
||
try:
|
||
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||
if pd.notna(dt):
|
||
draw_date = dt.to_pydatetime()
|
||
except Exception:
|
||
pass
|
||
return {
|
||
'date': draw_date_str,
|
||
'date_obj': draw_date,
|
||
'period': str(row.get('期数', '')).strip(),
|
||
'reds': reds,
|
||
'blue': blue,
|
||
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||
}
|
||
|
||
|
||
def find_draw_by_gen_time(gen_time):
|
||
"""根据生成时间找到归属的开奖期。
|
||
|
||
比对规则(窗口归属模型):
|
||
- 每个开奖日 D 有一个归属窗口 (D_prev 20:00, D 20:00)
|
||
- 生成时间 T 落在哪个窗口内 → 与该开奖日 D 比对
|
||
- 按开奖日升序遍历,找到第一个 D 使得 T < D 20:00
|
||
- 如果 T 同日且 T < 20:00 → 同日比对(规则 2.1),compare_type='same_day'
|
||
- 如果 T 在 D 之前某日 → 仍归属 D(窗口归属),compare_type='cross_day'
|
||
- 如果 T 同日且 T ≥ 20:00 → 归属下一个开奖日(规则 2.2)
|
||
- 如果 T 超过所有开奖日的 20:00 → 等待下一期,compare_type='waiting'
|
||
|
||
返回 (draw_dict, compare_type) 或 (None, 'error')
|
||
"""
|
||
try:
|
||
df = load_history_dataframe()
|
||
if df is None or df.empty:
|
||
return None, 'no_data'
|
||
|
||
df['_dt'] = pd.to_datetime(df['开奖时间'], errors='coerce')
|
||
df = df.dropna(subset=['_dt']).sort_values('_dt', ascending=True).reset_index(drop=True)
|
||
|
||
if df.empty:
|
||
return None, 'no_data'
|
||
|
||
# 遍历所有开奖日(升序),找到第一个 D 使得 gen_time < D 20:00
|
||
for i in range(len(df)):
|
||
draw_date = df.iloc[i]['_dt']
|
||
draw_8pm = draw_date.replace(hour=20, minute=0, second=0, microsecond=0)
|
||
|
||
if gen_time < draw_8pm:
|
||
# gen_time 落在 D 的窗口内 → 与 D 比对
|
||
draw = _row_to_draw(df.iloc[i])
|
||
|
||
# 判断是同日还是跨日窗口归属
|
||
if gen_time.date() == draw_date.date():
|
||
compare_type = 'same_day' # 同日,20:00 前
|
||
else:
|
||
compare_type = 'cross_day' # 跨日窗口归属
|
||
|
||
return draw, compare_type
|
||
|
||
# gen_time 超过所有开奖日的 20:00 → 等待下一期
|
||
last_draw = _row_to_draw(df.iloc[-1])
|
||
return last_draw, 'waiting'
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return None, 'error'
|
||
|
||
# ============================================================
|
||
# 认证装饰器(可选)
|
||
# ============================================================
|
||
# BIZ-109 临时放行白名单:仅 GET 只读接口临时放行鉴权(应急修复)
|
||
# 原因:BIZ-108 启用 auth_enabled 后,index.html 前端未适配 token 注入,
|
||
# 导致「记录」tab 完全不可用。临时放行只读接口恢复业务,
|
||
# BIZ-110 跟踪 index.html 适配后恢复鉴权。
|
||
# 白名单接口:GET /api/records, GET /api/history
|
||
# 保留鉴权接口:所有 POST/DELETE/写接口
|
||
AUTH_BYPASS_PATHS = {'/api/records', '/api/history'}
|
||
|
||
def require_auth(f):
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
# BIZ-109 应急:GET 只读接口临时放行
|
||
if request.method == 'GET' and request.path in AUTH_BYPASS_PATHS:
|
||
return f(*args, **kwargs)
|
||
if CONFIG['auth_enabled']:
|
||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||
if token != CONFIG['api_token']:
|
||
return jsonify({'success': False, 'error': '未授权访问'}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
# ============================================================
|
||
# API:号码生成
|
||
# ============================================================
|
||
@app.route('/api/generate', methods=['POST'])
|
||
@require_auth
|
||
def api_generate():
|
||
"""生成双色球号码"""
|
||
try:
|
||
data = request.get_json(force=True, silent=True) or {}
|
||
num_tickets = int(data.get('num_tickets', CONFIG['default_tickets']))
|
||
strategy = data.get('strategy', 'advanced')
|
||
|
||
# 参数校验
|
||
if num_tickets < 1 or num_tickets > CONFIG['max_tickets']:
|
||
return jsonify({
|
||
'success': False,
|
||
'error': f'注数必须在 1-{CONFIG["max_tickets"]} 之间'
|
||
}), 400
|
||
|
||
if strategy not in ('advanced', 'basic'):
|
||
return jsonify({'success': False, 'error': '策略参数无效,请使用 advanced 或 basic'}), 400
|
||
|
||
# 初始化生成器
|
||
generator = DoubleColorBallGenerator(CONFIG['history_file'])
|
||
if not generator.load_history_data():
|
||
return jsonify({'success': False, 'error': '无法加载历史数据'}), 500
|
||
|
||
# 生成号码
|
||
tickets_df = generator.generate_multiple_tickets(num_tickets, strategy)
|
||
if tickets_df.empty:
|
||
return jsonify({'success': False, 'error': '号码生成失败'}), 500
|
||
|
||
# 保存到 Excel
|
||
filename = generator.save_to_excel(tickets_df, num_tickets, strategy)
|
||
if not filename:
|
||
return jsonify({'success': False, 'error': '文件保存失败'}), 500
|
||
|
||
# 获取相对路径
|
||
rel_path = os.path.relpath(filename, BASE_DIR)
|
||
|
||
# 添加生成记录
|
||
record = add_record(strategy, num_tickets, rel_path)
|
||
|
||
# 构建返回数据
|
||
tickets_data = []
|
||
for _, row in tickets_df.iterrows():
|
||
tickets_data.append({
|
||
'index': int(row['序号']),
|
||
'reds': [int(row[f'红球{i}']) for i in range(1, 7)],
|
||
'blue': int(row['蓝球']),
|
||
'sum_value': int(row['和值']),
|
||
'odd_even': row['奇偶比'],
|
||
'size_ratio': row['大小比'],
|
||
'span': int(row['跨度'])
|
||
})
|
||
|
||
# 获取统计信息
|
||
stats = get_statistics_data(generator)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'tickets': tickets_data[:50], # 前端最多展示 50 注
|
||
'total': len(tickets_data),
|
||
'filename': rel_path,
|
||
'download_url': f'/api/download/{rel_path}',
|
||
'record': record,
|
||
'statistics': stats
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '号码生成失败,请检查历史数据文件是否完整'}), 500
|
||
|
||
|
||
def get_statistics_data(generator=None):
|
||
"""获取统计数据 — 委托公共模块计算"""
|
||
return compute_statistics(CONFIG['history_file'])
|
||
|
||
|
||
# ============================================================
|
||
# API:获取统计数据
|
||
# ============================================================
|
||
@app.route('/api/statistics')
|
||
@require_auth
|
||
def api_statistics():
|
||
"""获取统计数据"""
|
||
try:
|
||
stats = get_statistics_data()
|
||
return jsonify({'success': True, 'data': stats})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '获取统计数据失败'}), 500
|
||
# ============================================================
|
||
@app.route('/api/records')
|
||
@require_auth
|
||
def api_records():
|
||
"""获取生成记录列表"""
|
||
try:
|
||
page = int(request.args.get('page', 1))
|
||
page_size = int(request.args.get('page_size', 20))
|
||
records = load_records()
|
||
|
||
total = len(records)
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
page_records = records[start:end]
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'records': page_records,
|
||
'total': total,
|
||
'page': page,
|
||
'page_size': page_size
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '获取生成记录失败'}), 500
|
||
|
||
|
||
# ============================================================
|
||
# API:删除生成记录
|
||
# ============================================================
|
||
@app.route('/api/records/<record_id>', methods=['DELETE'])
|
||
@require_auth
|
||
def api_delete_record(record_id):
|
||
"""删除生成记录"""
|
||
try:
|
||
records = load_records()
|
||
target = None
|
||
for r in records:
|
||
if r['id'] == record_id:
|
||
target = r
|
||
break
|
||
|
||
if not target:
|
||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||
|
||
# 删除文件
|
||
filepath = os.path.join(BASE_DIR, target['filename'])
|
||
if os.path.exists(filepath):
|
||
os.remove(filepath)
|
||
|
||
# 删除记录(加锁保护读-改-写)
|
||
with records_lock:
|
||
if os.path.exists(CONFIG['records_file']):
|
||
try:
|
||
with open(CONFIG['records_file'], 'r', encoding='utf-8') as f:
|
||
records = json.load(f)
|
||
except (json.JSONDecodeError, IOError):
|
||
records = []
|
||
else:
|
||
records = []
|
||
records = [r for r in records if r['id'] != record_id]
|
||
tmp_path = CONFIG['records_file'] + '.tmp'
|
||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||
os.replace(tmp_path, CONFIG['records_file'])
|
||
|
||
return jsonify({'success': True, 'message': '记录已删除'})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '删除记录失败'}), 500
|
||
# ============================================================
|
||
@app.route('/api/download/<path:filepath>')
|
||
@require_auth
|
||
def api_download(filepath):
|
||
"""下载文件"""
|
||
try:
|
||
# 安全检查:防止目录遍历
|
||
safe_path = os.path.normpath(filepath)
|
||
full_path = os.path.realpath(os.path.join(BASE_DIR, safe_path))
|
||
# 使用 realpath 检查最终路径是否仍在 BASE_DIR 内
|
||
if not full_path.startswith(os.path.realpath(BASE_DIR)):
|
||
abort(403)
|
||
|
||
if not os.path.exists(full_path):
|
||
abort(404)
|
||
|
||
return send_file(full_path, as_attachment=True)
|
||
except Exception:
|
||
abort(404)
|
||
|
||
|
||
# ============================================================
|
||
# API:生成记录预览
|
||
# ============================================================
|
||
@app.route('/api/records/<record_id>/preview')
|
||
@require_auth
|
||
def api_preview_record(record_id):
|
||
"""预览生成记录中的号码数据(无需下载 Excel)"""
|
||
try:
|
||
records = load_records()
|
||
target = None
|
||
for r in records:
|
||
if r['id'] == record_id:
|
||
target = r
|
||
break
|
||
|
||
if not target:
|
||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||
|
||
filepath = os.path.join(BASE_DIR, target['filename'])
|
||
if not os.path.exists(filepath):
|
||
return jsonify({'success': False, 'error': '文件不存在'}), 404
|
||
|
||
# 读取 Excel
|
||
df = pd.read_excel(filepath, sheet_name='生成号码', engine='openpyxl')
|
||
|
||
tickets = []
|
||
red_cols = [f'红球{i}' for i in range(1, 7)]
|
||
for _, row in df.iterrows():
|
||
reds = [int(row[c]) for c in red_cols if c in df.columns and pd.notna(row[c])]
|
||
blue = int(row['蓝球']) if '蓝球' in df.columns and pd.notna(row['蓝球']) else 0
|
||
ticket = {
|
||
'index': int(row['序号']) if '序号' in df.columns and pd.notna(row['序号']) else len(tickets) + 1,
|
||
'reds': reds,
|
||
'blue': blue,
|
||
'sum_value': int(row['和值']) if '和值' in df.columns and pd.notna(row['和值']) else sum(reds),
|
||
'odd_even': str(row['奇偶比']) if '奇偶比' in df.columns and pd.notna(row['奇偶比']) else '',
|
||
'size_ratio': str(row['大小比']) if '大小比' in df.columns and pd.notna(row['大小比']) else '',
|
||
'span': int(row['跨度']) if '跨度' in df.columns and pd.notna(row['跨度']) else (max(reds) - min(reds) if reds else 0),
|
||
}
|
||
tickets.append(ticket)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'record': target,
|
||
'tickets': tickets,
|
||
'total': len(tickets)
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '预览数据加载失败'}), 500
|
||
|
||
|
||
# ============================================================
|
||
# API:中奖比对
|
||
# ============================================================
|
||
@app.route('/api/records/<record_id>/compare')
|
||
@require_auth
|
||
def api_compare_record(record_id):
|
||
"""将生成记录与对应开奖数据进行中奖比对。
|
||
|
||
比对规则:
|
||
- 生成时间 < 开奖日 20:00 → 与当日开奖比对
|
||
- 生成时间 >= 开奖日 20:00 → 与下次开奖日比对
|
||
|
||
双色球中奖规则:
|
||
- 一等奖:6红+1蓝
|
||
- 二等奖:6红+0蓝
|
||
- 三等奖:5红+1蓝
|
||
- 四等奖:5红+0蓝 或 4红+1蓝
|
||
- 五等奖:4红+0蓝 或 3红+1蓝
|
||
- 六等奖:2红+1蓝 或 1红+1蓝 或 0红+1蓝
|
||
- 未中奖:其他
|
||
"""
|
||
try:
|
||
records = load_records()
|
||
target = None
|
||
for r in records:
|
||
if r['id'] == record_id:
|
||
target = r
|
||
break
|
||
|
||
if not target:
|
||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||
|
||
filepath = os.path.join(BASE_DIR, target['filename'])
|
||
if not os.path.exists(filepath):
|
||
return jsonify({'success': False, 'error': '文件不存在'}), 404
|
||
|
||
# 解析生成时间
|
||
gen_time_str = target.get('created_at', '')
|
||
try:
|
||
gen_time = datetime.strptime(gen_time_str, '%Y-%m-%d %H:%M:%S')
|
||
except (ValueError, TypeError):
|
||
return jsonify({'success': False, 'error': '无法解析生成时间'}), 400
|
||
|
||
# 读取生成的号码
|
||
df = pd.read_excel(filepath, sheet_name='生成号码', engine='openpyxl')
|
||
red_cols = [f'红球{i}' for i in range(1, 7)]
|
||
tickets = []
|
||
for _, row in df.iterrows():
|
||
reds = [int(row[c]) for c in red_cols if c in df.columns and pd.notna(row[c])]
|
||
blue = int(row['蓝球']) if '蓝球' in df.columns and pd.notna(row['蓝球']) else 0
|
||
tickets.append({'reds': reds, 'blue': blue})
|
||
|
||
if not tickets:
|
||
return jsonify({'success': False, 'error': '无号码数据可比对'}), 400
|
||
|
||
# 根据生成时间找到归属的开奖期
|
||
# 规则:生成时间 < 开奖日 20:00 → 与当日开奖比对;>= 20:00 → 与下次开奖日比对
|
||
draw_to_compare, compare_type = find_draw_by_gen_time(gen_time)
|
||
|
||
if draw_to_compare is None:
|
||
return jsonify({'success': False, 'error': '无法加载开奖数据'}), 500
|
||
|
||
if not draw_to_compare.get('reds'):
|
||
return jsonify({'success': False, 'error': '开奖数据号码解析失败'}), 500
|
||
|
||
if compare_type == 'waiting':
|
||
# 生成时间超过所有已记录开奖日 → 等待下一期
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'status': 'waiting',
|
||
'message': f'生成时间 {gen_time_str} 超过最近开奖日 {draw_to_compare["date"]} 20:00,下次开奖尚未举行,请等待开奖后再次比对。',
|
||
'gen_time': gen_time_str,
|
||
'latest_draw_date': draw_to_compare['date'],
|
||
'latest_draw_period': draw_to_compare['period'],
|
||
'compare_rule': '生成时间 ≥ 开奖日 20:00 → 等待下次开奖日比对'
|
||
}
|
||
})
|
||
|
||
# 执行比对
|
||
draw_reds = set(draw_to_compare['reds'])
|
||
draw_blue = draw_to_compare['blue']
|
||
|
||
results = []
|
||
for i, t in enumerate(tickets):
|
||
ticket_reds = set(t['reds'])
|
||
ticket_blue = t['blue']
|
||
|
||
red_matches = len(ticket_reds & draw_reds)
|
||
blue_match = (ticket_blue == draw_blue)
|
||
|
||
# 判断中奖等级
|
||
prize_level = '未中奖'
|
||
prize_desc = ''
|
||
|
||
if red_matches == 6 and blue_match:
|
||
prize_level = '一等奖'
|
||
prize_desc = '6红+1蓝 中大奖!'
|
||
elif red_matches == 6 and not blue_match:
|
||
prize_level = '二等奖'
|
||
prize_desc = '6红+0蓝'
|
||
elif red_matches == 5 and blue_match:
|
||
prize_level = '三等奖'
|
||
prize_desc = '5红+1蓝'
|
||
elif (red_matches == 5 and not blue_match) or (red_matches == 4 and blue_match):
|
||
prize_level = '四等奖'
|
||
prize_desc = f'{red_matches}红+{"1" if blue_match else "0"}蓝'
|
||
elif (red_matches == 4 and not blue_match) or (red_matches == 3 and blue_match):
|
||
prize_level = '五等奖'
|
||
prize_desc = f'{red_matches}红+{"1" if blue_match else "0"}蓝'
|
||
elif blue_match and red_matches <= 2:
|
||
prize_level = '六等奖'
|
||
prize_desc = f'{red_matches}红+1蓝'
|
||
|
||
# 该注中奖金额(PRD v1.2 §后端扩展 1)
|
||
# 浮动奖实际金额未知,按占位金额展示(前端用橙色角标)
|
||
ticket_amount, _is_float = PRIZE_AMOUNT.get(prize_level, (0, False))
|
||
results.append({
|
||
'index': i + 1,
|
||
'reds': t['reds'],
|
||
'blue': ticket_blue,
|
||
'red_matches': red_matches,
|
||
'blue_match': blue_match,
|
||
'prize_level': prize_level,
|
||
'prize_desc': prize_desc,
|
||
'prize_amount': ticket_amount, # 该注金额(元,整数)
|
||
'prize_amount_display': format_amount(ticket_amount), # 该注金额(千分位字符串)
|
||
'is_win': prize_level != '未中奖'
|
||
})
|
||
|
||
win_count = sum(1 for r in results if r['is_win'])
|
||
|
||
# 中奖金额汇总(PRD v1.2 §4.3 + §后端扩展 2)
|
||
prize_info = compute_prize_summary(results)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'status': 'compared',
|
||
'gen_time': gen_time_str,
|
||
'compare_type': compare_type,
|
||
'compare_rule': f'生成时间 {gen_time_str} 归属开奖期 {draw_to_compare["period"]}({draw_to_compare["date"]})',
|
||
'draw': {
|
||
'date': draw_to_compare['date'],
|
||
'period': draw_to_compare['period'],
|
||
'reds': draw_to_compare['reds'],
|
||
'blue': draw_to_compare['blue']
|
||
},
|
||
'results': results,
|
||
'total': len(results),
|
||
'win_count': win_count,
|
||
'win_tickets': [r for r in results if r['is_win']],
|
||
# PRD v1.2 新增字段
|
||
'total_prize': prize_info['total_prize'],
|
||
'total_prize_display': prize_info['total_prize_display'],
|
||
'has_variable_prize': prize_info['has_variable_prize'],
|
||
'prize_summary': prize_info['prize_summary'],
|
||
'prize_display': prize_info['prize_display'],
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '中奖比对失败'}), 500
|
||
|
||
|
||
# ============================================================
|
||
# API:历史数据查看
|
||
# ============================================================
|
||
@app.route('/api/history')
|
||
@require_auth
|
||
def api_history():
|
||
"""获取双色球历史开奖数据"""
|
||
try:
|
||
page = int(request.args.get('page', 1))
|
||
page_size = int(request.args.get('page_size', 20))
|
||
search = request.args.get('search', '').strip()
|
||
|
||
# 读取历史数据
|
||
if not os.path.exists(CONFIG['history_file']):
|
||
return jsonify({'success': False, 'error': '历史数据文件不存在'}), 404
|
||
|
||
import pandas as pd
|
||
import re
|
||
|
||
# 使用智能加载函数
|
||
data_df = load_history_dataframe()
|
||
|
||
# 解析红球 (号码列是 6 红球+1 蓝球的拼接字符串,如 '09101316192108')
|
||
def parse_red_balls(val):
|
||
s = str(val).strip()
|
||
if len(s) >= 12:
|
||
return [int(s[i:i+2]) for i in range(0, 12, 2)]
|
||
return []
|
||
|
||
def parse_blue_ball(val):
|
||
s = str(val).strip()
|
||
if len(s) >= 14:
|
||
return int(s[12:14])
|
||
return None
|
||
|
||
data_df['红球列表'] = data_df['号码'].apply(parse_red_balls)
|
||
data_df['蓝球'] = data_df['号码'].apply(parse_blue_ball)
|
||
|
||
# 搜索过滤
|
||
if search:
|
||
mask = data_df.astype(str).apply(
|
||
lambda row: row.astype(str).str.contains(search, na=False).any(), axis=1
|
||
)
|
||
data_df = data_df[mask]
|
||
|
||
total = len(data_df)
|
||
|
||
# 分页
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
page_df = data_df.iloc[start:end]
|
||
|
||
# 转换为 JSON
|
||
records = []
|
||
for _, row in page_df.iterrows():
|
||
reds = row['红球列表']
|
||
record = {
|
||
'开奖日期': str(row['开奖时间']),
|
||
'期号': str(row['期数']),
|
||
'红球': reds if len(reds) == 6 else [],
|
||
'蓝球': row['蓝球'],
|
||
'开机号': str(row['开机号']),
|
||
'和值': str(row['和值特征']),
|
||
'奇偶比': str(row['奇偶比']),
|
||
'奇偶形态': str(row['奇偶形态']),
|
||
'大小比': str(row['大小比']),
|
||
'跨度': str(row['跨度']),
|
||
}
|
||
records.append(record)
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'records': records,
|
||
'total': total,
|
||
'page': page,
|
||
'page_size': page_size,
|
||
'columns': ['开奖日期', '期号', '红球', '蓝球', '开机号', '和值', '奇偶比', '奇偶形态', '大小比', '跨度']
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '获取历史数据失败'}), 500
|
||
|
||
|
||
# ============================================================
|
||
# API:系统状态
|
||
# ============================================================
|
||
@app.route('/api/status')
|
||
def api_status():
|
||
"""获取系统状态"""
|
||
try:
|
||
history_exists = os.path.exists(CONFIG['history_file'])
|
||
history_size = os.path.getsize(CONFIG['history_file']) if history_exists else 0
|
||
lottery_files = []
|
||
if os.path.exists(CONFIG['lottery_output_dir']):
|
||
lottery_files = [f for f in os.listdir(CONFIG['lottery_output_dir']) if f.endswith('.xlsx')]
|
||
|
||
records = load_records()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'server_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
'history_exists': history_exists,
|
||
'history_size': history_size,
|
||
'total_generations': len(records),
|
||
'total_lottery_files': len(lottery_files),
|
||
'config': {
|
||
'port': CONFIG['port'],
|
||
'auth_enabled': CONFIG['auth_enabled'],
|
||
'max_tickets': CONFIG['max_tickets']
|
||
}
|
||
}
|
||
})
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'success': False, 'error': '获取系统状态失败'}), 500
|
||
# ============================================================
|
||
@app.route('/')
|
||
def index():
|
||
"""首页 - 双色球 Web UI"""
|
||
return send_from_directory(BASE_DIR, 'index.html')
|
||
|
||
|
||
@app.route('/api/config')
|
||
def api_config():
|
||
"""获取前端配置"""
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'max_tickets': CONFIG['max_tickets'],
|
||
'default_tickets': CONFIG['default_tickets'],
|
||
'auth_enabled': CONFIG['auth_enabled']
|
||
}
|
||
})
|
||
|
||
|
||
# ============================================================
|
||
# 数据抓取控制台(原 web_executor.py 功能整合)
|
||
# ============================================================
|
||
# 全局抓取状态
|
||
fetch_status = {
|
||
"is_running": False,
|
||
"last_update": None,
|
||
"last_record_count": 0,
|
||
"last_error": None
|
||
}
|
||
fetch_lock = threading.Lock()
|
||
|
||
def load_fetch_status():
|
||
"""从文件加载抓取状态"""
|
||
global fetch_status
|
||
if os.path.exists(CONFIG['fetch_status_file']):
|
||
try:
|
||
with open(CONFIG['fetch_status_file'], 'r', encoding='utf-8') as f:
|
||
saved = json.load(f)
|
||
with fetch_lock:
|
||
# 保留当前 is_running 状态(运行中不覆盖)
|
||
running = fetch_status.get('is_running', False)
|
||
fetch_status = saved
|
||
fetch_status['is_running'] = running
|
||
except (json.JSONDecodeError, IOError):
|
||
pass
|
||
|
||
def save_fetch_status():
|
||
"""保存抓取状态到文件"""
|
||
with fetch_lock:
|
||
with open(CONFIG['fetch_status_file'], 'w', encoding='utf-8') as f:
|
||
json.dump(fetch_status, f, ensure_ascii=False, indent=2)
|
||
|
||
@app.route('/fetch')
|
||
def fetch_console():
|
||
"""数据抓取控制台页面"""
|
||
return send_from_directory(BASE_DIR, 'web_console.html')
|
||
|
||
@app.route('/api/fetch/status')
|
||
def api_fetch_status():
|
||
"""获取抓取执行状态"""
|
||
with fetch_lock:
|
||
return jsonify({
|
||
"success": True,
|
||
"isRunning": fetch_status.get("is_running", False),
|
||
"lastUpdate": fetch_status.get("last_update"),
|
||
"recordCount": fetch_status.get("last_record_count", 0),
|
||
"lastError": fetch_status.get("last_error")
|
||
})
|
||
|
||
@app.route('/api/fetch/execute', methods=['POST'])
|
||
def api_fetch_execute():
|
||
"""触发数据抓取"""
|
||
global fetch_status
|
||
|
||
with fetch_lock:
|
||
if fetch_status.get("is_running", False):
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "任务正在执行中,请稍后再试"
|
||
}), 409
|
||
|
||
# 启动后台执行线程
|
||
def run_fetch_script():
|
||
global fetch_status
|
||
|
||
with fetch_lock:
|
||
fetch_status["is_running"] = True
|
||
fetch_status["last_error"] = None
|
||
save_fetch_status()
|
||
|
||
try:
|
||
import subprocess
|
||
print(f"[{datetime.now()}] 开始执行抓取脚本...")
|
||
|
||
result = subprocess.run(
|
||
[sys.executable, CONFIG['fetch_script']],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=CONFIG['fetch_timeout']
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
# 解析输出获取记录数
|
||
record_count = 0
|
||
for line in result.stdout.split('\n'):
|
||
if '共保存' in line and '条记录' in line:
|
||
try:
|
||
record_count = int(line.split('共保存')[1].split('条记录')[0].strip())
|
||
except ValueError:
|
||
pass
|
||
elif '成功解析' in line and '条数据' in line:
|
||
try:
|
||
record_count = int(line.split('成功解析')[1].split('条数据')[0].strip())
|
||
except ValueError:
|
||
pass
|
||
|
||
with fetch_lock:
|
||
fetch_status["last_update"] = datetime.now().isoformat()
|
||
fetch_status["last_record_count"] = record_count
|
||
fetch_status["is_running"] = False
|
||
save_fetch_status()
|
||
|
||
print(f"✅ 抓取成功,共 {record_count} 条数据")
|
||
|
||
else:
|
||
error_msg = result.stderr or f"脚本执行失败,返回码:{result.returncode}"
|
||
with fetch_lock:
|
||
fetch_status["last_error"] = error_msg
|
||
fetch_status["is_running"] = False
|
||
save_fetch_status()
|
||
print(f"❌ {error_msg}")
|
||
|
||
except subprocess.TimeoutExpired:
|
||
error_msg = f"脚本执行超时(超过 {CONFIG['fetch_timeout']} 秒)"
|
||
with fetch_lock:
|
||
fetch_status["last_error"] = error_msg
|
||
fetch_status["is_running"] = False
|
||
save_fetch_status()
|
||
print(f"❌ {error_msg}")
|
||
|
||
except Exception as e:
|
||
error_msg = f"执行异常:{str(e)}"
|
||
with fetch_lock:
|
||
fetch_status["last_error"] = error_msg
|
||
fetch_status["is_running"] = False
|
||
save_fetch_status()
|
||
print(f"❌ {error_msg}")
|
||
|
||
thread = threading.Thread(target=run_fetch_script, daemon=True)
|
||
thread.start()
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"message": "任务已启动,正在执行中..."
|
||
})
|
||
|
||
|
||
# ============================================================
|
||
# 启动服务
|
||
# ============================================================
|
||
if __name__ == '__main__':
|
||
# 加载抓取状态
|
||
load_fetch_status()
|
||
|
||
print('=' * 60)
|
||
print('🎯 双色球 Web UI 服务(统一)')
|
||
print('=' * 60)
|
||
print(f'\n📂 项目路径: {BASE_DIR}')
|
||
print(f'📁 历史数据: {CONFIG["history_file"]}')
|
||
print(f'📁 生成目录: {CONFIG["lottery_output_dir"]}')
|
||
print(f'📁 抓取脚本: {CONFIG["fetch_script"]}')
|
||
print(f'\n🌐 服务地址: http://{CONFIG["host"]}:{CONFIG["port"]}')
|
||
print(f' 局域网访问: http://<本机IP>:{CONFIG["port"]}')
|
||
print(f' 抓取控制台: http://<本机IP>:{CONFIG["port"]}/fetch')
|
||
print(f'\n✅ 服务就绪!')
|
||
print('=' * 60)
|
||
|
||
app.run(host=CONFIG['host'], port=CONFIG['port'], debug=False, threaded=True)
|