File size: 29,372 Bytes
74212ee
 
 
 
 
 
 
46057b5
74212ee
8dccf15
74212ee
 
46057b5
6c8276c
74212ee
46057b5
 
 
74212ee
 
 
 
 
 
 
 
a901be4
 
d67dd17
 
922a0dc
74212ee
 
2fbcfb2
b6ec259
74212ee
ea27d36
 
 
 
 
 
74212ee
 
 
 
 
fa9cfc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922a0dc
d67dd17
 
 
fa9cfc2
 
 
 
 
 
 
 
 
 
 
922a0dc
072770a
fa9cfc2
 
 
74212ee
 
 
 
922a0dc
74212ee
 
 
 
 
 
922a0dc
74212ee
 
 
 
 
 
 
 
 
 
922a0dc
fa9cfc2
46fee31
74212ee
 
 
922a0dc
fa9cfc2
b366f89
fa9cfc2
 
 
 
 
 
a901be4
 
 
 
d67dd17
a901be4
 
 
 
d67dd17
a901be4
 
 
 
 
 
 
 
d67dd17
a901be4
 
 
 
 
d67dd17
a901be4
 
d67dd17
 
 
27b4945
 
a901be4
27b4945
d67dd17
a901be4
fa9cfc2
d67dd17
 
6c8276c
d67dd17
6c8276c
d67dd17
 
a901be4
d67dd17
 
a901be4
fa9cfc2
d67dd17
a894d93
a901be4
d67dd17
fa9cfc2
 
 
 
 
a901be4
fa9cfc2
a901be4
27b4945
 
 
 
d67dd17
74212ee
 
 
 
 
 
 
 
 
 
922a0dc
 
74212ee
 
 
 
 
 
 
 
 
 
 
fa9cfc2
74212ee
 
 
 
 
922a0dc
 
74212ee
 
 
 
 
 
 
 
 
 
fa9cfc2
74212ee
 
 
 
 
 
922a0dc
fa9cfc2
 
74212ee
 
 
 
 
 
 
 
 
 
 
2fbcfb2
74212ee
 
 
 
 
 
fa9cfc2
74212ee
 
 
 
 
 
 
 
 
 
 
 
922a0dc
2fbcfb2
74212ee
b6ec259
a901be4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74212ee
 
 
 
 
 
 
 
 
922a0dc
 
fa9cfc2
74212ee
 
ea27d36
74212ee
 
 
ea27d36
fa9cfc2
ea27d36
 
 
a901be4
ea27d36
a901be4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea27d36
 
 
 
 
 
 
 
 
 
 
74212ee
a901be4
922a0dc
74212ee
 
ea27d36
 
 
 
 
fa9cfc2
ea27d36
74212ee
b6ec259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74212ee
d67dd17
74212ee
 
 
 
2fbcfb2
8853baa
922a0dc
ea27d36
ef264f4
74212ee
 
 
922a0dc
2fbcfb2
 
f390492
922a0dc
74212ee
 
 
 
 
2fbcfb2
 
 
 
 
 
fa9cfc2
2fbcfb2
b85625c
2fbcfb2
fa9cfc2
2fbcfb2
 
922a0dc
2fbcfb2
 
922a0dc
 
ea27d36
 
922a0dc
 
 
ea27d36
 
922a0dc
fa9cfc2
922a0dc
2fbcfb2
 
74212ee
 
6c8276c
f390492
922a0dc
74212ee
 
 
 
f390492
 
 
 
 
 
 
 
 
922a0dc
a894d93
2fbcfb2
 
 
 
a894d93
2fbcfb2
 
 
922a0dc
fa9cfc2
ea27d36
27b4945
2fbcfb2
 
 
 
 
 
 
 
74212ee
2fbcfb2
fa9cfc2
2fbcfb2
74212ee
 
f390492
 
 
 
 
 
 
 
 
 
 
fa9cfc2
2fbcfb2
922a0dc
2fbcfb2
 
fa9cfc2
ef264f4
 
 
922a0dc
ef264f4
 
922a0dc
2fbcfb2
 
 
922a0dc
2fbcfb2
 
922a0dc
d67dd17
b366f89
 
 
 
 
922a0dc
8dccf15
922a0dc
6c8276c
2fbcfb2
922a0dc
b366f89
 
 
 
 
922a0dc
 
 
ea27d36
922a0dc
2fbcfb2
922a0dc
b366f89
 
922a0dc
b366f89
6c8276c
2fbcfb2
922a0dc
2fbcfb2
 
65d09c8
74212ee
8dccf15
fa9cfc2
2fbcfb2
 
 
 
 
922a0dc
2fbcfb2
 
922a0dc
2fbcfb2
 
 
 
922a0dc
2fbcfb2
922a0dc
74212ee
8853baa
2fbcfb2
922a0dc
 
2fbcfb2
922a0dc
2fbcfb2
8853baa
2fbcfb2
 
8dccf15
2fbcfb2
74212ee
 
2fbcfb2
74212ee
 
2fbcfb2
ea27d36
2fbcfb2
fa9cfc2
ef264f4
 
922a0dc
ef264f4
 
 
922a0dc
ea27d36
 
 
2fbcfb2
ea27d36
2fbcfb2
ea27d36
2fbcfb2
922a0dc
ea27d36
922a0dc
ea27d36
 
 
 
 
 
 
922a0dc
ea27d36
ef264f4
 
 
922a0dc
fa9cfc2
 
ea27d36
 
 
922a0dc
ea27d36
 
27b4945
fa9cfc2
2fbcfb2
922a0dc
ea27d36
922a0dc
8853baa
ef264f4
922a0dc
fa9cfc2
ea27d36
27b4945
ea27d36
 
fa9cfc2
922a0dc
 
ea27d36
 
 
65d09c8
ea27d36
 
 
 
 
 
922a0dc
ea27d36
 
fa9cfc2
d67dd17
 
6c8276c
 
d67dd17
 
b6ec259
 
 
 
2fbcfb2
ea27d36
 
2fbcfb2
ea27d36
a894d93
922a0dc
ef264f4
2fbcfb2
 
 
74212ee
2fbcfb2
d67dd17
74212ee
 
 
 
922a0dc
74212ee
 
922a0dc
74212ee
2fbcfb2
74212ee
6c8276c
8dccf15
 
 
 
 
 
922a0dc
8dccf15
 
 
922a0dc
8dccf15
 
 
b85625c
8dccf15
74212ee
2fbcfb2
74212ee
6c8276c
74212ee
2fbcfb2
 
8dccf15
74212ee
 
 
2fbcfb2
922a0dc
2fbcfb2
74212ee
2fbcfb2
922a0dc
2fbcfb2
6c8276c
2fbcfb2
 
 
 
 
 
74212ee
2fbcfb2
74212ee
6c8276c
2fbcfb2
922a0dc
74212ee
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
import streamlit as st
import requests
import time
import json
import os
import threading
import re
import urllib3
from datetime import datetime, timedelta, timezone, time as dt_time
from collections import deque
from dotenv import load_dotenv

# --- 0. 基础配置 ---
st.set_page_config(page_title="空场防空转监控", page_icon="💸", layout="wide")

# 屏蔽 HTTPS 证书警告 (TMS 系统通常使用自签名证书)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

try:
    from streamlit_autorefresh import st_autorefresh
except ImportError:
    st_autorefresh = None

# --- 1. 配置与常量 ---
load_dotenv()

# 企业微信机器人配置
WEWORK_BOT_WEBHOOK = os.getenv("WEWORK_BOT_WEBHOOK")

# 影城系统配置
CINEMA_ID = os.getenv("CINEMA_ID")
TMS_APP_SECRET = os.getenv("TMS_APP_SECRET")
TMS_TICKET = os.getenv("TMS_TICKET")
TMS_X_SESSION_ID = os.getenv("TMS_X_SESSION_ID")
TMS_THEATER_ID = os.getenv("TMS_THEATER_ID") # 新增:影院ID

HALL_ID_MAP = {
    "1": "79181753", "2": "87350725", "3": "93340931",
    "4": "98009245", "5": "02194530", "6": "07183751",
    "7": "11314566", "8": "15532561", "9": "20079450"
}

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(CURRENT_DIR)
TOKEN_FILE = os.path.join(ROOT_DIR, 'token_data.json')


# --- 2. 统计与工具类 ---

class DailyStats:
    """每日运行数据统计"""
    def __init__(self):
        self.reset()
        self.yesterday_stats = {
            'zero_sessions': 0,
            'triggers': 0,
            'notify_fails': 0,
            'api_fails': 0,
            'date': '未知'
        }

    def reset(self):
        self.zero_sessions = 0  # 0票总场次
        self.triggers = 0       # 触发通知次数
        self.notify_fails = 0   # 发送通知失败次数
        self.api_fails = 0      # API获取失败次数

    def snapshot_as_yesterday(self, date_str):
        self.yesterday_stats = {
            'zero_sessions': self.zero_sessions,
            'triggers': self.triggers,
            'notify_fails': self.notify_fails,
            'api_fails': self.api_fails,
            'date': date_str
        }
        self.reset()


# 全局统计实例
daily_stats = DailyStats()


def get_beijing_now():
    utc_now = datetime.now(timezone.utc)
    return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)


def get_business_date():
    now = get_beijing_now()
    if now.time() < dt_time(6, 0):
        return (now - timedelta(days=1)).strftime("%Y-%m-%d")
    return now.strftime("%Y-%m-%d")


def parse_show_datetime(date_str, time_str):
    try:
        show_t = datetime.strptime(time_str, "%H:%M").time()
        base_date = datetime.strptime(date_str, "%Y-%m-%d")
        if show_t < dt_time(6, 0):
            base_date += timedelta(days=1)
        return datetime.combine(base_date.date(), show_t)
    except:
        return None


def extract_hall_number_raw(hall_name):
    """仅提取数字ID,用于API映射"""
    match = re.search(r'(\d+)', str(hall_name))
    return match.group(1) if match else str(hall_name)


def format_hall_name(hall_name):
    """格式化影厅名称:【和成天下1号厅】 -> 1号厅"""
    match = re.search(r'(\d+)号', str(hall_name))
    if match:
        return f"{match.group(1)}号厅"
    return str(hall_name)


# --- 3. 企业微信机器人推送模块 ---
class WeWorkBotPusher:
    def __init__(self, webhook_url):
        self.webhook_url = webhook_url

    def send_text(self, content):
        """发送纯文本消息"""
        if not self.webhook_url:
            return

        headers = {"Content-Type": "application/json"}
        data = {
            "msgtype": "text",
            "text": {
                "content": content
            }
        }
        
        try:
            resp = requests.post(self.webhook_url, json=data, headers=headers, timeout=10)
            result = resp.json()
            if result.get("errcode") != 0:
                print(f"❌ 企业微信机器人发送失败: {result}")
                daily_stats.notify_fails += 1
        except Exception as e:
            print(f"❌ 企业微信机器人发送异常: {e}")
            daily_stats.notify_fails += 1


# --- 4. 通知管理系统 ---
class NotificationManager:
    def __init__(self):
        self.bot = WeWorkBotPusher(WEWORK_BOT_WEBHOOK)

    def send_idle_alert(self, hall_name, movie_name, show_time, count_info, remark_text=""):
        """发送空转告警 (企业微信机器人)"""
        daily_stats.triggers += 1
        
        msg = (
            f"发现 {hall_name} 空场空转!\n\n"
            f"{hall_name} {show_time}{movie_name}》\n"
            f"无人购票但服务器上有排程,未撤场,或许正在播放,请检查。\n"
            f"{count_info}{remark_text}"
        )
        self.bot.send_text(msg)

    def send_daily_report(self, today_date_cn):
        """发送每日报告 (企业微信机器人)"""
        y_stats = daily_stats.yesterday_stats
        
        msg = (
            f"影城“空转”检查服务就绪\n\n"
            f"{today_date_cn},今日排片数据已加载,开始智能检查。\n\n"
            f"昨日情况:\n"
            f"0票总场次:{y_stats['zero_sessions']}\n"
            f"触发通知次数:{y_stats['triggers']}\n"
            f"发送通知失败次数:{y_stats['notify_fails']}\n"
            f"API获取失败次数:{y_stats['api_fails']}\n"
            f"服务正常运行中。"
        )
        self.bot.send_text(msg)

notifier = NotificationManager()


# --- 5. API 管理模块 ---
class TicketAPIManager:
    def __init__(self, logger_func):
        self.logger = logger_func
        self.last_login_fail = 0

    def load_token(self):
        if os.path.exists(TOKEN_FILE):
            try:
                with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
                    return json.load(f).get('token')
            except:
                pass
        return None

    def login(self):
        if time.time() - self.last_login_fail < 300: return None
        username = os.getenv("CINEMA_USERNAME")
        password = os.getenv("CINEMA_PASSWORD")
        res_code = os.getenv("CINEMA_RES_CODE")
        device_id = os.getenv("CINEMA_DEVICE_ID")

        if not all([username, password, res_code]):
            self.logger("❌ 票务系统环境变量缺失")
            daily_stats.api_fails += 1
            return None

        try:
            session = requests.Session()
            login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
            login_data = {'username': username, 'password': password, 'type': '1', 'resCode': res_code,
                          'deviceid': device_id, 'dtype': 'ios'}
            session.post(login_url, data=login_data, timeout=15)
            resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
            info = resp.json()
            if info.get("success") and info.get("data", {}).get("token"):
                with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                    json.dump(info['data'], f)
                return info['data']['token']
        except Exception as e:
            self.last_login_fail = time.time()
            self.logger(f"❌ 票务登录失败: {e}")
            daily_stats.api_fails += 1
        return None

    def fetch_schedule(self, date_str):
        token = self.load_token()
        if not token:
            token = self.login()
            if not token:
                daily_stats.api_fails += 1
                return None

        url = 'https://cawapi.yinghezhong.com/showInfo/getHallShowInfo'
        params = {'showDate': date_str, 'token': token, '_': int(time.time() * 1000)}
        headers = {'User-Agent': 'Mozilla/5.0'}

        try:
            res = requests.get(url, params=params, headers=headers, timeout=10)
            data = res.json()
            if data.get('code') == 1:
                return data.get('data', [])
            elif data.get('code') == 500:
                token = self.login()
                if token:
                    params['token'] = token
                    res = requests.get(url, params=params, headers=headers, timeout=10)
                    return res.json().get('data', [])
        except Exception as e:
            self.logger(f"⚠️ 票务API异常: {e}")
            daily_stats.api_fails += 1
        return None


class TMSAPIManager:
    def __init__(self, logger_func):
        self.logger = logger_func
        self.auth_token = None
        self.last_token_time = 0

    def get_token(self):
        if self.auth_token and (time.time() - self.last_token_time < 1800):
            return self.auth_token

        if not all([TMS_APP_SECRET, TMS_TICKET]): return None

        # 获取 OA 系统 Token
        url = f'https://tms.hengdianfilm.com/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={TMS_TICKET}'
        headers = {
            'Accept': 'application/json, text/javascript, */*; q=0.01',
            'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
            'Content-Type': 'application/json',
            'Cookie': f'JSESSIONID={TMS_X_SESSION_ID}',
            'DNT': '1',
            'Origin': 'https://tms.hengdianfilm.com',
            'Priority': 'u=0, i',
            'Referer': f'https://tms.hengdianfilm.com/hd/oalogin?ticket={TMS_TICKET}',
            'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
            'Sec-CH-UA-Mobile': '?0',
            'Sec-CH-UA-Platform': '"macOS"',
            'Sec-Fetch-Dest': 'empty',
            'Sec-Fetch-Mode': 'cors',
            'Sec-Fetch-Site': 'same-origin',
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
            'X-Requested-With': 'XMLHttpRequest',
        }
        payload = {'appId': 'hd', 'appSecret': TMS_APP_SECRET, 'timeStamp': int(time.time() * 1000)}

        try:
            res = requests.post(url, json=payload, headers=headers, timeout=10)
            data = res.json()
            if data.get('error_code') == '0000':
                self.auth_token = data['param']
                self.last_token_time = time.time()
                return self.auth_token
        except:
            pass
        daily_stats.api_fails += 1
        return None

    def fetch_hall_schedule_list(self, hall_number):
        token = self.get_token()
        if not token: return None

        tms_hall_id = HALL_ID_MAP.get(str(hall_number))
        if not tms_hall_id: return None

        url = 'https://tms.hengdianfilm.com/cinema-api/cinema/schedule/server/list'
        session_id = TMS_X_SESSION_ID or ''
        
        headers = {
            'Accept': 'application/json, text/javascript, */*; q=0.01',
            'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6',
            'Content-Type': 'application/json; charset=UTF-8',
            'Cookie': f'JSESSIONID={session_id}',
            'DNT': '1',
            'Origin': 'https://tms.hengdianfilm.com',
            'Priority': 'u=1, i',
            'Referer': f'https://tms.hengdianfilm.com/hd/index?CinemaMonitorEdit&HALL_ID%3D{tms_hall_id}',
            'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
            'Sec-CH-UA-Mobile': '?0',
            'Sec-CH-UA-Platform': '"macOS"',
            'Sec-Fetch-Dest': 'empty',
            'Sec-Fetch-Mode': 'cors',
            'Sec-Fetch-Site': 'same-origin',
            'Token': token,
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
            'X-Requested-With': 'XMLHttpRequest',
            'X-SESSIONID': session_id
        }
        params = {'token': 'hd', 'murl': 'CinemaMonitor'}
        json_data = {
            'THEATER_ID': 38205954,
            'STATE': 0,
            'HALL_ID': tms_hall_id,
            'START_TIME': int(time.time() * 1000),
            'PAGE_CAPACITY': 20,
            'PAGE_INDEX': 1,
        }

        try:
            res = requests.post(url, params=params, headers=headers, json=json_data, timeout=10,
                                verify=False)
            data = res.json()
            if data.get("RSPCD") == "000000":
                return data.get("BODY", {}).get("LIST", [])
            else:
                return None
        except Exception as e:
            self.logger(f"⚠️ TMS 请求异常: {e}")
            daily_stats.api_fails += 1
            return None

    def trigger_schedule_refresh(self):
        """触发 TMS 排期刷新"""
        token = self.get_token()
        if not token or not TMS_THEATER_ID: return

        try:
            # 动态生成时间戳
            now_tm = time.localtime()
            today_midnight = time.mktime((now_tm.tm_year, now_tm.tm_mon, now_tm.tm_mday, 0, 0, 0, 0, 0, 0))
            start_date = int(today_midnight * 1000)
            end_date = start_date + (24 * 60 * 60 * 1000)

            url = 'https://tms.hengdianfilm.com/cinema-api/tms/cmd/show/schedule'
            session_id = TMS_X_SESSION_ID or ''
            
            headers = {
                'accept': 'application/json, text/javascript, */*; q=0.01',
                'content-type': 'application/json; charset=UTF-8',
                'origin': 'https://tms.hengdianfilm.com',
                'referer': f'https://tms.hengdianfilm.com/hd/index?Scheduling&THEATER_ID={TMS_THEATER_ID}&DATE={start_date}&PAGE_CAPACITY=20&PAGE_INDEX=1',
                'token': token, # 这里的 token 由 get_token() 动态获取
                'user-agent': 'Mozilla/5.0',
                'x-requested-with': 'XMLHttpRequest',
                'x-sessionid': session_id
            }
            
            params = {
                'token': 'hd',
                'murl': 'Scheduling',
            }

            cookies = {'JSESSIONID': session_id}
            
            json_data = {
                'THEATER_LIST': [
                    {
                        'THEATER_ID': int(TMS_THEATER_ID),
                        'START_DATE': start_date,
                        'END_DATE': end_date,
                    },
                ],
            }

            # 发送请求,非阻塞,忽略证书错误
            requests.post(url, params=params, headers=headers, cookies=cookies, json=json_data, timeout=5, verify=False)
            self.logger("🔄 TMS 排期刷新指令已发送")

        except Exception as e:
            # 刷新失败不影响主程序
            self.logger(f"⚠️ TMS 排期刷新失败: {e}")


# --- 6. 智能监控主逻辑 ---
class PlaybackMonitor:
    def __init__(self):
        self.logs = deque(maxlen=50)
        self.status_text = "初始化中..."
        self.next_wakeup_str = "--:--:--"
        self.active_monitors = []

        self.cleared_sessions = set()
        self.processed_checks = set()

        self.ticket_api = TicketAPIManager(self.log)
        self.tms_api = TMSAPIManager(self.log)

        self.current_business_date = None
        self.daily_schedule_cache = None
        self.last_daily_report_date = None

        self.thread = threading.Thread(target=self._run_loop, daemon=True)
        self.thread.start()

    def log(self, msg):
        ts = get_beijing_now().strftime("%H:%M:%S")
        entry = f"[{ts}] {msg}"
        self.logs.appendleft(entry)
        print(entry)

    def _get_target_check_points(self, schedule_list, business_date):
        check_points = []
        zero_count = 0
        for item in schedule_list:
            sold = int(item.get('soldTicketNum') or 0)
            if sold == 0:
                zero_count += 1
                start_str = item.get('showStartTime')
                if not start_str: continue

                show_dt = parse_show_datetime(business_date, start_str)
                if not show_dt: continue

                for i in range(3):
                    check_time = show_dt + timedelta(minutes=i * 5)
                    check_points.append({
                        'time': check_time,
                        'type': 'CHECK',
                        'check_index': i,
                        'data': item
                    })

        daily_stats.zero_sessions = zero_count

        check_points.sort(key=lambda x: x['time'])
        return check_points

    def _run_loop(self):
        self.log("🚀 空场防空转监控服务已启动")
        is_first_run = True

        while True:
            try:
                now = get_beijing_now()
                biz_date = get_business_date()
                today_str = now.strftime("%Y-%m-%d")

                if is_first_run:
                    if now.time() >= dt_time(9, 0):
                        self.last_daily_report_date = today_str
                        self.log(f"🟡 首次运行,跳过当日即时通知: {today_str}")
                    else:
                        self.log("🟡 首次运行完成初始化,等待 09:00 后再发送每日报告")
                    is_first_run = False

                # --- 初始化与日期变更 ---
                need_refresh = False
                if self.daily_schedule_cache is None:
                    need_refresh = True
                    self.log("🆕 初始化:获取全天排片...")
                    self.current_business_date = biz_date
                elif self.current_business_date != biz_date:
                    if now.time() >= dt_time(9, 0):
                        need_refresh = True

                        daily_stats.snapshot_as_yesterday(self.current_business_date)
                        self.cleared_sessions.clear()
                        self.processed_checks.clear()
                        self.log(f"🌞 新营业日 ({biz_date}):刷新排片...")

                if need_refresh:
                    schedule = self.ticket_api.fetch_schedule(biz_date)
                    if schedule:
                        self.daily_schedule_cache = schedule
                        self.current_business_date = biz_date
                        self.log(f"✅ 排片已更新,共 {len(schedule)} 场。")
                    else:
                        self.log("⚠️ 获取排片失败,5分钟后重试")
                        daily_stats.api_fails += 1
                        time.sleep(300)
                        continue

                if now.time() >= dt_time(9, 0) and self.last_daily_report_date != today_str and self.daily_schedule_cache is not None:
                    try:
                        d_obj = datetime.strptime(biz_date, "%Y-%m-%d")
                        date_cn = f"{d_obj.year}{d_obj.month}{d_obj.day}日"
                    except:
                        date_cn = biz_date

                    self.log(f"🔔 发送每日统计报告: {date_cn}")
                    notifier.send_daily_report(date_cn)
                    self.last_daily_report_date = today_str

                # --- 任务计算 ---
                check_points = self._get_target_check_points(self.daily_schedule_cache, biz_date)

                next_target = None
                for cp in check_points:
                    hall_id = extract_hall_number_raw(cp['data']['hallName'])
                    start_str = cp['data']['showStartTime']
                    check_idx = cp['check_index']
                    dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"

                    if dedup_key in self.processed_checks:
                        continue

                    if cp['time'] > (now - timedelta(seconds=30)):
                        next_target = cp
                        break

                if next_target:
                    target_time = next_target['time']

                    # 09:00 拦截器
                    nine_am_today = datetime.combine(now.date(), dt_time(9, 0))
                    force_wake_for_daily_reset = False
                    if now < nine_am_today and target_time > nine_am_today:
                        target_time = nine_am_today
                        force_wake_for_daily_reset = True

                    sleep_seconds = max(0, (target_time - now).total_seconds())

                    self.status_text = "💤 休眠中"
                    self.next_wakeup_str = target_time.strftime('%H:%M:%S')

                    if force_wake_for_daily_reset:
                        self.active_monitors = ["等待 09:00 日报刷新..."]
                    else:
                        raw_hall = next_target['data']['hallName']
                        clean_hall = format_hall_name(raw_hall)
                        self.active_monitors = [
                            f"下个任务: {clean_hall} {next_target['data']['showStartTime']} (第{next_target['check_index'] + 1}次检查)"]

                    self.log(f"💤 智能休眠 {int(sleep_seconds)}秒,将在 {self.next_wakeup_str} 唤醒...")

                    time.sleep(sleep_seconds)

                    if force_wake_for_daily_reset:
                        continue

                    # --- 正常唤醒检查 ---
                    self.status_text = "🔥 正在检查"
                    self.log("⏰ 唤醒!正在同步最新票务数据...")

                    latest_schedule = self.ticket_api.fetch_schedule(biz_date)
                    if latest_schedule:
                        self.daily_schedule_cache = latest_schedule
                    else:
                        self.log("⚠️ 同步排片失败,使用旧数据")
                        daily_stats.api_fails += 1
                        latest_schedule = self.daily_schedule_cache

                    check_now = get_beijing_now()
                    current_targets = []
                    latest_check_points = self._get_target_check_points(latest_schedule, biz_date)

                    for cp in latest_check_points:
                        time_diff = abs((cp['time'] - check_now).total_seconds())
                        if time_diff < 120:
                            current_targets.append(cp)

                    if current_targets:
                        self._process_targets(current_targets, biz_date)

                    time.sleep(5)

                else:
                    self.status_text = "🌙 今日监控结束"
                    self.active_monitors = []
                    tmr_9am = datetime.combine(datetime.strptime(biz_date, "%Y-%m-%d").date() + timedelta(days=1),
                                               dt_time(9, 0))
                    seconds_to_tmr = (tmr_9am - now).total_seconds()

                    if seconds_to_tmr > 3600:
                        self.log("暂无目标,休眠 1 小时...")
                        time.sleep(3600)
                    else:
                        self.log(f"休眠至明日 09:00...")
                        time.sleep(seconds_to_tmr)

            except Exception as e:
                self.log(f"❌ 主循环异常: {e}")
                time.sleep(60)

    def _process_targets(self, targets, biz_date):
        halls_to_check = {}
        for t in targets:
            hall_id = extract_hall_number_raw(t['data']['hallName'])
            start_str = t['data']['showStartTime']
            check_idx = t['check_index']

            dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
            if dedup_key in self.processed_checks:
                continue

            if hall_id not in halls_to_check:
                halls_to_check[hall_id] = []
            halls_to_check[hall_id].append(t)

        if not halls_to_check: return

        self.log(f"🔍 核查 {len(halls_to_check)} 个影厅 TMS 状态...")
        display_results = []

        for hall_id, target_list in halls_to_check.items():

            all_cleared = True
            for target in target_list:
                start_str = target['data'].get('showStartTime')
                key = f"{biz_date}_{hall_id}_{start_str}"
                if key not in self.cleared_sessions:
                    all_cleared = False
                    break

            if all_cleared:
                for target in target_list:
                    dedup_key = f"{biz_date}_{hall_id}_{target['data']['showStartTime']}_{target['check_index']}"
                    self.processed_checks.add(dedup_key)

                clean_name = format_hall_name(target_list[0]['data']['hallName'])
                display_results.append(f"{clean_name} ✅ [已跳过 (已确认撤场)]")
                continue

            tms_list = self.tms_api.fetch_hall_schedule_list(hall_id)

            for target in target_list:
                session = target['data']
                check_idx = target['check_index']
                hall_name_raw = session.get('hallName')
                movie_name = session.get('movieName')
                ticket_start_str = session.get('showStartTime')
                ticket_start_dt = parse_show_datetime(biz_date, ticket_start_str)

                unique_key = f"{biz_date}_{hall_id}_{ticket_start_str}"
                dedup_key = f"{biz_date}_{hall_id}_{ticket_start_str}_{check_idx}"

                if dedup_key in self.processed_checks: continue
                if unique_key in self.cleared_sessions:
                    self.processed_checks.add(dedup_key)
                    continue

                hall_name_clean = format_hall_name(hall_name_raw)
                status_desc = f"{hall_name_clean} {ticket_start_str} (第{check_idx + 1}/3次)"

                if tms_list is not None:
                    match_found = False
                    for tms_item in tms_list:
                        t_start_str = tms_item.get('START_TIME')
                        if not t_start_str: continue
                        t_start_dt = parse_show_datetime(biz_date, t_start_str)
                        if not t_start_dt: continue
                        if abs((t_start_dt - ticket_start_dt).total_seconds()) < 1800:
                            match_found = True
                            break

                    if match_found:
                        status_desc += " ⚠️ [空转! TMS未撤]"
                        self.log(f"🚨 发现空转: {hall_name_clean}{movie_name}》")
                        
                        # 调用新的告警接口
                        count_info = f"第 {check_idx + 1}/3 次检查"
                        remark = "仅停止播放未撤排程下次检查依然会推送通知。"
                        notifier.send_idle_alert(hall_name_clean, movie_name, ticket_start_str, count_info, remark)
                        
                        # --- NEW: 第一次发现后,触发TMS刷新 ---
                        if check_idx == 0:
                            self.tms_api.trigger_schedule_refresh()

                    else:
                        status_desc += " ✅ [正常 (TMS无排期)]"
                        self.cleared_sessions.add(unique_key)
                else:
                    status_desc += " ❓ [TMS查询失败]"
                    daily_stats.api_fails += 1

                self.processed_checks.add(dedup_key)
                display_results.append(status_desc)

        self.active_monitors = display_results


# --- 7. Streamlit 前端 ---
@st.cache_resource
def get_monitor():
    return PlaybackMonitor()


def main():
    monitor = get_monitor()

    if st_autorefresh:
        st_autorefresh(interval=30 * 1000, key="pb_refresh")

    st.title("💸 空场防空转监控")

    remaining_zero_count = 0
    remaining_total_count = 0
    if monitor.daily_schedule_cache:
        now = get_beijing_now()
        biz_date = monitor.current_business_date or get_business_date()

        for item in monitor.daily_schedule_cache:
            start_str = item.get('showStartTime')
            if not start_str: continue

            show_dt = parse_show_datetime(biz_date, start_str)
            if show_dt and show_dt > now:
                remaining_total_count += 1
                if int(item.get('soldTicketNum') or 0) == 0:
                    remaining_zero_count += 1

    c1, c2, c3 = st.columns(3)
    with c1:
        st.metric("运行状态", monitor.status_text)
    with c2:
        st.metric("下次唤醒", monitor.next_wakeup_str)
    with c3:
        st.metric("剩余 0 票场次 / 剩余总场次", f"{remaining_zero_count} / {remaining_total_count}")

    st.divider()

    col_logs, col_mon = st.columns([3, 2])

    with col_logs:
        st.subheader("📜 运行日志")
        st.text_area("Logs", "\n".join(list(monitor.logs)), height=450, disabled=True)

    with col_mon:
        st.subheader("🎯 实时检查结果")
        if monitor.active_monitors:
            for m in monitor.active_monitors:
                if "⚠️" in m:
                    st.error(m)
                elif "✅" in m:
                    st.success(m)
                else:
                    st.info(m)
        else:
            st.caption("暂无检查结果")


if __name__ == "__main__":
    main()