File size: 5,874 Bytes
3df0958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f6d7ba
3df0958
1f6d7ba
3df0958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478c16c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4ea50b
478c16c
a4ea50b
 
478c16c
 
a4ea50b
 
478c16c
 
 
a4ea50b
 
 
 
 
 
 
 
 
 
478c16c
 
 
a4ea50b
 
478c16c
 
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
"""运行时可变配置(由面板写入,重启后仍生效)。

与 admin_state.py 区分:admin_state 只放管理员登录态(session/password/...),白名单字段严格;
本模块放"用户在面板里可以调的业务配置",目前只有 register_domain(子号注册用的 CloudMail 域名),
将来可以扩 batch_size、cool_down 等。持久化到项目根 `runtime_config.json`。
"""

import json
import logging
import os
import threading
import time
from pathlib import Path

from autoteam.textio import read_text, write_text

logger = logging.getLogger(__name__)

PROJECT_ROOT = Path(__file__).parent.parent.parent
RUNTIME_CONFIG_FILE = PROJECT_ROOT / "runtime_config.json"
RUNTIME_CONFIG_MODE = 0o666

_LOCK = threading.Lock()


def _load():
    if not RUNTIME_CONFIG_FILE.exists():
        return {}
    try:
        raw = read_text(RUNTIME_CONFIG_FILE).strip()
        if not raw:
            return {}
        data = json.loads(raw)
        return data if isinstance(data, dict) else {}
    except Exception as exc:
        # 静默吞会把用户在面板里设的 register_domain 等覆盖值丢掉,下一轮 _save 会把
        # 损坏文件写回空 dict。保留一份 .corrupt-<ts>.json 便于事后排查。
        corrupt_path = RUNTIME_CONFIG_FILE.with_suffix(f".corrupt-{int(time.time())}.json")
        try:
            RUNTIME_CONFIG_FILE.rename(corrupt_path)
            logger.error("[runtime_config] 解析失败, 已保留原文件为 %s: %s", corrupt_path.name, exc)
        except Exception as rename_exc:
            logger.error("[runtime_config] 解析失败且无法重命名 (%s): %s", exc, rename_exc)
        return {}


def _save(data):
    target = RUNTIME_CONFIG_FILE.resolve()
    write_text(target, json.dumps(data, indent=2, ensure_ascii=False))
    try:
        os.chmod(target, RUNTIME_CONFIG_MODE)
    except Exception:
        pass


def get(key, default=None):
    with _LOCK:
        return _load().get(key, default)


def set_value(key, value):
    with _LOCK:
        data = _load()
        data[key] = value
        _save(data)
        return data


def get_register_domain():
    """返回用于子号注册的 CloudMail 域名。

    优先级:runtime_config.json → 环境变量 CLOUDMAIL_DOMAIN(向后兼容)。
    返回值已 lstrip "@"。
    """
    from autoteam.config import CLOUDMAIL_DOMAIN

    override = (get("register_domain") or "").strip()
    if override:
        return override.lstrip("@").strip()
    return (CLOUDMAIL_DOMAIN or "").lstrip("@").strip()


def set_register_domain(domain):
    """写入 register_domain 覆盖值。空串表示清除 override 走环境变量。"""
    cleaned = (domain or "").strip().lstrip("@").strip()
    set_value("register_domain", cleaned)
    return cleaned


# SPEC-2 FR-E2/E3 — sync_account_states 探测被踢识别的并发上限 + 去重冷却。
# 默认 concurrency=5(单次 sync 最多 5 个账号并发探测 wham/usage),
# cooldown=30 分钟(同一账号 30 分钟内不重复探测,避免抖动)。
# 上下界:concurrency [1, 16],cooldown [1, 1440] 分钟。
_SYNC_PROBE_CONCURRENCY_DEFAULT = 5
_SYNC_PROBE_COOLDOWN_MINUTES_DEFAULT = 30


def get_sync_probe_concurrency():
    """返回 sync_account_states 内并发探测被踢账号的最大 worker 数。"""
    raw = get("sync_probe_concurrency", _SYNC_PROBE_CONCURRENCY_DEFAULT)
    try:
        n = int(raw)
    except (TypeError, ValueError):
        return _SYNC_PROBE_CONCURRENCY_DEFAULT
    return max(1, min(16, n))


def set_sync_probe_concurrency(value):
    try:
        n = int(value)
    except (TypeError, ValueError):
        n = _SYNC_PROBE_CONCURRENCY_DEFAULT
    n = max(1, min(16, n))
    set_value("sync_probe_concurrency", n)
    return n


def get_sync_probe_cooldown_minutes():
    """返回同一账号被探测后多久内不重复探测(分钟)。"""
    raw = get("sync_probe_cooldown_minutes", _SYNC_PROBE_COOLDOWN_MINUTES_DEFAULT)
    try:
        n = int(raw)
    except (TypeError, ValueError):
        return _SYNC_PROBE_COOLDOWN_MINUTES_DEFAULT
    return max(1, min(1440, n))


def set_sync_probe_cooldown_minutes(value):
    try:
        n = int(value)
    except (TypeError, ValueError):
        n = _SYNC_PROBE_COOLDOWN_MINUTES_DEFAULT
    n = max(1, min(1440, n))
    set_value("sync_probe_cooldown_minutes", n)
    return n


# SPEC-2 FR-G — 邀请席位偏好。
#   "default" 走 default→usage_based 兜底 + PATCH 升级,优先 ChatGPT 完整席位(老行为,默认)
#   "codex"   直接 usage_based 邀请,跳过 PATCH,锁 codex-only 席位(节约 ChatGPT 席位时使用)
#   "chatgpt" 别名,Round 7 P2.1 转移期支持,setter/getter 内部归一化为 "default"
_PREFERRED_SEAT_TYPE_DEFAULT = "default"
_PREFERRED_SEAT_TYPE_VALID = {"default", "chatgpt", "codex"}
_PREFERRED_SEAT_TYPE_NORMALIZE = {"chatgpt": "default"}


def _normalize_preferred_seat_type(raw):
    """把任意输入归一化为 {default, codex} 之一(chatgpt 别名 → default,非法/空 → default)。"""
    val = (str(raw or "") or _PREFERRED_SEAT_TYPE_DEFAULT).strip().lower()
    if val not in _PREFERRED_SEAT_TYPE_VALID:
        return _PREFERRED_SEAT_TYPE_DEFAULT
    return _PREFERRED_SEAT_TYPE_NORMALIZE.get(val, val)


def get_preferred_seat_type():
    """返回邀请席位偏好。'default'(默认/优先 PATCH 升级 ChatGPT 席位) 或 'codex'(锁 codex-only)。

    Round 7 P2.1:已落盘的 'chatgpt' 旧值在读取时也归一化为 'default'。
    """
    raw = get("preferred_seat_type", _PREFERRED_SEAT_TYPE_DEFAULT)
    return _normalize_preferred_seat_type(raw)


def set_preferred_seat_type(value):
    """写入席位偏好;接受 'chatgpt' 作为 'default' 的转移期别名(Round 7 P2.1)。"""
    val = _normalize_preferred_seat_type(value)
    set_value("preferred_seat_type", val)
    return val