400 lines
19 KiB
Python
400 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""Hera Planner - 数据持久化服务器"""
|
||
import json, os, shutil, hashlib, time
|
||
from datetime import datetime
|
||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||
from pathlib import Path
|
||
|
||
DATA_DIR = os.environ.get('DATA_DIR', '/data')
|
||
STATIC_DIR = os.environ.get('STATIC_DIR', '/app/static')
|
||
BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
|
||
PASS_FILE = os.path.join(DATA_DIR, 'password.txt')
|
||
BUDDY_FILE = os.path.join(DATA_DIR, 'sleep_buddy.json')
|
||
BUDDY_PASS_FILE = os.path.join(DATA_DIR, 'buddy_password.txt')
|
||
|
||
DEFAULT_HASH = '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'
|
||
|
||
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||
Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
|
||
|
||
DATA_FILE = os.path.join(DATA_DIR, 'planner_data.json')
|
||
|
||
def get_pass_hash():
|
||
if os.path.exists(PASS_FILE): return open(PASS_FILE).read().strip()
|
||
return DEFAULT_HASH
|
||
|
||
def set_pass_hash(h):
|
||
with open(PASS_FILE, 'w') as f: f.write(h)
|
||
|
||
def get_buddy_pass():
|
||
if os.path.exists(BUDDY_PASS_FILE): return open(BUDDY_PASS_FILE).read().strip()
|
||
return DEFAULT_HASH
|
||
|
||
def set_buddy_pass(h):
|
||
with open(BUDDY_PASS_FILE, 'w') as f: f.write(h)
|
||
|
||
def load_data():
|
||
if os.path.exists(DATA_FILE):
|
||
with open(DATA_FILE, 'r', encoding='utf-8') as f: return json.load(f)
|
||
return {}
|
||
|
||
def save_data(data):
|
||
tmp = DATA_FILE + '.tmp'
|
||
with open(tmp, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, DATA_FILE)
|
||
|
||
def load_buddy():
|
||
if os.path.exists(BUDDY_FILE):
|
||
with open(BUDDY_FILE, 'r', encoding='utf-8') as f: return json.load(f)
|
||
return {'users': {}, 'notifications': []}
|
||
|
||
def save_buddy(data):
|
||
tmp = BUDDY_FILE + '.tmp'
|
||
with open(tmp, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, BUDDY_FILE)
|
||
|
||
def merge_protected(existing, incoming):
|
||
"""服务器端数据保护:所有重要数据都做合并,防止多设备覆盖丢失"""
|
||
# 按 id 合并:sp_notes, sp_bugs, sp_goals, sp_checklists, sp_custom_reminders
|
||
# 按日期合并:sp_sleep
|
||
# 深度合并:sp_health_checks, sp_music_checks, sp_health_plans, sp_music_plans
|
||
# 嵌套合并:sp_todos (每个象限按 id),sp_docs (按 doc id + entries)
|
||
# 不合并(last-write-wins):sp_inbox, sp_reviews, sp_modules, sp_schedule 等简单配置
|
||
ARRAY_KEYS_BY_ID = ['sp_notes', 'sp_bugs', 'sp_goals', 'sp_checklists', 'sp_custom_reminders', 'sp_gym', 'sp_period'] # 按 id 合并
|
||
ARRAY_KEYS_BY_DATE = ['sp_sleep'] # 睡眠按日期合并
|
||
OBJECT_MERGE_KEYS = ['sp_health_checks', 'sp_music_checks', 'sp_health_plans', 'sp_music_plans'] # 打卡数据深度合并
|
||
# sp_todos 是 {q1:[],q2:[],q3:[],q4:[]},需要按 id 合并每个象限
|
||
NESTED_ARRAY_KEYS = ['sp_todos'] # 对象中的数组按 id 合并
|
||
|
||
for key in ARRAY_KEYS_BY_ID:
|
||
if key in existing and key in incoming:
|
||
try:
|
||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||
if isinstance(server, list) and isinstance(client, list):
|
||
# 检查删除标记
|
||
deleted_key = key + '_deleted'
|
||
deleted_ids = set()
|
||
if deleted_key in incoming:
|
||
try: deleted_ids = set(json.loads(incoming[deleted_key]))
|
||
except: pass
|
||
# 按 id 合并,客户端优先,排除已删除的
|
||
merged = {r.get('id', i): r for i, r in enumerate(server) if r.get('id') not in deleted_ids}
|
||
for i, r in enumerate(client):
|
||
merged[r.get('id', f'c_{i}')] = r
|
||
incoming[key] = json.dumps(list(merged.values()))
|
||
except: pass
|
||
|
||
for key in ARRAY_KEYS_BY_DATE:
|
||
if key in existing and key in incoming:
|
||
try:
|
||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||
if isinstance(server, list) and isinstance(client, list):
|
||
# 检查是否有删除标记
|
||
deleted_key = key + '_deleted'
|
||
deleted_dates = set()
|
||
if deleted_key in incoming:
|
||
try: deleted_dates = set(json.loads(incoming[deleted_key]))
|
||
except: pass
|
||
merged = {r['date']: r for r in server if r['date'] not in deleted_dates}
|
||
for r in client:
|
||
merged[r['date']] = r
|
||
incoming[key] = json.dumps(sorted(merged.values(), key=lambda x: x.get('date',''), reverse=True))
|
||
except: pass
|
||
|
||
for key in OBJECT_MERGE_KEYS:
|
||
if key in existing and key in incoming:
|
||
try:
|
||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||
if isinstance(server, dict) and isinstance(client, dict):
|
||
# 深度合并:对于数组值按 id 合并,对于对象值合并 key
|
||
merged = dict(server)
|
||
for k, v in client.items():
|
||
if isinstance(v, list) and isinstance(merged.get(k), list):
|
||
m = {r.get('id', i): r for i, r in enumerate(merged[k])}
|
||
for i, r in enumerate(v):
|
||
m[r.get('id', f'c_{i}')] = r
|
||
merged[k] = list(m.values())
|
||
elif isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||
merged[k] = {**merged[k], **v}
|
||
else:
|
||
merged[k] = v
|
||
incoming[key] = json.dumps(merged)
|
||
except: pass
|
||
|
||
# sp_todos: {q1:[...], q2:[...], ...} — 每个象限的数组按 id 合并
|
||
for key in NESTED_ARRAY_KEYS:
|
||
if key in existing and key in incoming:
|
||
try:
|
||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||
if isinstance(server, dict) and isinstance(client, dict):
|
||
merged = {}
|
||
all_keys = set(list(server.keys()) + list(client.keys()))
|
||
for k in all_keys:
|
||
sv = server.get(k, [])
|
||
cv = client.get(k, [])
|
||
if isinstance(sv, list) and isinstance(cv, list):
|
||
m = {r.get('id', i): r for i, r in enumerate(sv)}
|
||
for i, r in enumerate(cv):
|
||
m[r.get('id', f'c_{i}')] = r
|
||
merged[k] = list(m.values())
|
||
else:
|
||
merged[k] = cv if cv else sv
|
||
incoming[key] = json.dumps(merged)
|
||
except: pass
|
||
|
||
# sp_docs: 按 doc id 合并,每个 doc 的 entries 也按内容合并
|
||
if 'sp_docs' in existing and 'sp_docs' in incoming:
|
||
try:
|
||
server_docs = json.loads(existing['sp_docs']) if isinstance(existing['sp_docs'], str) else existing['sp_docs']
|
||
client_docs = json.loads(incoming['sp_docs']) if isinstance(incoming['sp_docs'], str) else incoming['sp_docs']
|
||
if isinstance(server_docs, list) and isinstance(client_docs, list):
|
||
merged = {d.get('id', d.get('name', i)): d for i, d in enumerate(server_docs)}
|
||
for i, d in enumerate(client_docs):
|
||
did = d.get('id', d.get('name', f'c_{i}'))
|
||
if did in merged:
|
||
# 合并 entries:客户端优先,但保留服务器独有的
|
||
se = merged[did].get('entries', [])
|
||
ce = d.get('entries', [])
|
||
entry_map = {}
|
||
for e in se:
|
||
eid = e.get('noteId', '') or e.get('text', '')
|
||
entry_map[eid] = e
|
||
for e in ce:
|
||
eid = e.get('noteId', '') or e.get('text', '')
|
||
entry_map[eid] = e
|
||
d['entries'] = list(entry_map.values())
|
||
merged[did] = d
|
||
incoming['sp_docs'] = json.dumps(list(merged.values()))
|
||
except: pass
|
||
|
||
return incoming
|
||
|
||
def do_backup():
|
||
if not os.path.exists(DATA_FILE): return
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
shutil.copy2(DATA_FILE, os.path.join(BACKUP_DIR, f'planner_{ts}.json'))
|
||
for old in sorted(Path(BACKUP_DIR).glob('planner_*.json'))[:-30]: old.unlink()
|
||
|
||
def json_response(handler, code, data):
|
||
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
||
handler.send_response(code)
|
||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||
handler.send_header('Content-Length', len(body))
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
def read_body(handler):
|
||
length = int(handler.headers.get('Content-Length', 0))
|
||
return json.loads(handler.rfile.read(length))
|
||
|
||
def no_cache_html(handler, fpath):
|
||
with open(fpath, 'rb') as f: content = f.read()
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
handler.send_header('Content-Length', len(content))
|
||
handler.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0')
|
||
handler.send_header('Pragma', 'no-cache')
|
||
handler.send_header('Expires', '0')
|
||
handler.end_headers()
|
||
handler.wfile.write(content)
|
||
|
||
class Handler(SimpleHTTPRequestHandler):
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, directory=STATIC_DIR, **kwargs)
|
||
|
||
def do_GET(self):
|
||
if self.path == '/api/data':
|
||
json_response(self, 200, load_data())
|
||
elif self.path == '/api/backups':
|
||
backups = sorted(Path(BACKUP_DIR).glob('planner_*.json'), reverse=True)
|
||
items = [{'name': b.name, 'size': b.stat().st_size, 'time': b.stem.split('_',1)[1]} for b in backups[:20]]
|
||
json_response(self, 200, items)
|
||
elif self.path == '/api/sleep-buddy':
|
||
# 只返回睡眠数据,不返回其他任何数据
|
||
json_response(self, 200, load_buddy())
|
||
elif self.path == '/api/version':
|
||
# 返回 HTML 文件的修改时间戳,用于客户端自动刷新
|
||
try:
|
||
mtime = os.path.getmtime(os.path.join(STATIC_DIR, 'index.html'))
|
||
json_response(self, 200, {'v': str(int(mtime))})
|
||
except: json_response(self, 200, {'v': '0'})
|
||
elif self.path in ('/', '/index.html'):
|
||
no_cache_html(self, os.path.join(STATIC_DIR, 'index.html'))
|
||
elif self.path in ('/sleep', '/sleep-buddy', '/sleep-buddy.html'):
|
||
fpath = os.path.join(STATIC_DIR, 'sleep-buddy.html')
|
||
if os.path.exists(fpath): no_cache_html(self, fpath)
|
||
else: self.send_response(404); self.end_headers()
|
||
else:
|
||
super().do_GET()
|
||
|
||
def do_POST(self):
|
||
if self.path == '/api/login':
|
||
try:
|
||
body = read_body(self)
|
||
if body.get('hash', '') == get_pass_hash():
|
||
json_response(self, 200, {'ok': True})
|
||
else:
|
||
json_response(self, 401, {'ok': False, 'error': '密码不正确'})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
elif self.path == '/api/change-password':
|
||
try:
|
||
body = read_body(self)
|
||
if body.get('oldHash', '') != get_pass_hash():
|
||
json_response(self, 401, {'ok': False, 'error': '当前密码不正确'})
|
||
else:
|
||
set_pass_hash(body.get('newHash', ''))
|
||
json_response(self, 200, {'ok': True})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
elif self.path == '/api/data':
|
||
try:
|
||
data = read_body(self)
|
||
data.pop('sp_pass_hash', None)
|
||
existing = load_data()
|
||
|
||
# 服务器端数据保护:合并所有数组类型的数据,防止设备推送不完整数据导致丢失
|
||
# 对于客户端没有的 key,保留服务器已有的(新设备不会冲掉旧数据)
|
||
if existing:
|
||
for k, v in existing.items():
|
||
if k.startswith('sp_') and k not in data:
|
||
data[k] = v # 客户端没这个 key,保留服务器的
|
||
data = merge_protected(existing, data)
|
||
save_data(data)
|
||
do_backup()
|
||
json_response(self, 200, {'ok': True})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
# ===== Sleep Buddy API(完全独立,不接触主数据) =====
|
||
elif self.path == '/api/buddy-register':
|
||
try:
|
||
body = read_body(self)
|
||
username = body.get('username', '').strip()
|
||
pw_hash = body.get('hash', '')
|
||
if not username or not pw_hash:
|
||
json_response(self, 400, {'ok': False, 'error': '请输入用户名和密码'})
|
||
else:
|
||
buddy = load_buddy()
|
||
accounts = buddy.get('accounts', {})
|
||
if username in accounts:
|
||
json_response(self, 400, {'ok': False, 'error': '用户名已存在'})
|
||
else:
|
||
accounts[username] = {'hash': pw_hash}
|
||
buddy['accounts'] = accounts
|
||
if username not in buddy.get('users', {}):
|
||
buddy.setdefault('users', {})[username] = []
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
elif self.path == '/api/buddy-delete-user':
|
||
# 只允许通过 Planner 密码删除(管理员操作)
|
||
try:
|
||
body = read_body(self)
|
||
admin_hash = body.get('adminHash', '')
|
||
username = body.get('username', '').strip()
|
||
if admin_hash != get_pass_hash():
|
||
json_response(self, 401, {'ok': False, 'error': '需要管理员密码'})
|
||
else:
|
||
buddy = load_buddy()
|
||
buddy.get('accounts', {}).pop(username, None)
|
||
buddy.get('users', {}).pop(username, None)
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
elif self.path == '/api/buddy-login':
|
||
try:
|
||
body = read_body(self)
|
||
username = body.get('username', '').strip()
|
||
pw_hash = body.get('hash', '')
|
||
buddy = load_buddy()
|
||
accounts = buddy.get('accounts', {})
|
||
if username in accounts and accounts[username].get('hash') == pw_hash:
|
||
json_response(self, 200, {'ok': True, 'username': username})
|
||
else:
|
||
json_response(self, 401, {'ok': False, 'error': '用户名或密码不正确'})
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
|
||
elif self.path == '/api/sleep-buddy':
|
||
try:
|
||
body = read_body(self)
|
||
buddy = load_buddy()
|
||
user = body.get('user', '')
|
||
action = body.get('action', '')
|
||
|
||
if action == 'record':
|
||
# 记录睡眠
|
||
if user not in buddy['users']: buddy['users'][user] = []
|
||
record = body.get('record', {})
|
||
# 去重
|
||
buddy['users'][user] = [r for r in buddy['users'][user] if r.get('date') != record.get('date')]
|
||
buddy['users'][user].append(record)
|
||
buddy['users'][user].sort(key=lambda x: x.get('date',''), reverse=True)
|
||
# 只保留最近60天
|
||
buddy['users'][user] = buddy['users'][user][:60]
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
|
||
elif action == 'delete-record':
|
||
date = body.get('date', '')
|
||
if user in buddy.get('users', {}):
|
||
buddy['users'][user] = [r for r in buddy['users'][user] if r.get('date') != date]
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
|
||
elif action == 'sleep-now':
|
||
# 标记去睡觉,通知其他人
|
||
buddy['notifications'].append({
|
||
'from': user,
|
||
'time': datetime.now().strftime('%H:%M'),
|
||
'date': datetime.now().strftime('%Y-%m-%d'),
|
||
'msg': f'{user} 去睡觉啦,你也早点休息!',
|
||
'ts': time.time(),
|
||
})
|
||
# 只保留最近20条通知
|
||
buddy['notifications'] = buddy['notifications'][-20:]
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
|
||
elif action == 'get-notifications':
|
||
# 获取未读通知(不是自己发的,最近24小时内的)
|
||
cutoff = time.time() - 86400
|
||
notifs = [n for n in buddy.get('notifications', [])
|
||
if n.get('from') != user and n.get('ts', 0) > cutoff]
|
||
json_response(self, 200, {'notifications': notifs})
|
||
|
||
elif action == 'set-target':
|
||
target = body.get('target', '22:00')
|
||
buddy.setdefault('targets', {})[user] = target
|
||
save_buddy(buddy)
|
||
json_response(self, 200, {'ok': True})
|
||
|
||
else:
|
||
json_response(self, 200, buddy)
|
||
except Exception as e:
|
||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
|
||
def log_message(self, format, *args):
|
||
if '/api/' in (args[0] if args else ''):
|
||
print(f"[{datetime.now():%H:%M:%S}] {args[0]}")
|
||
|
||
if __name__ == '__main__':
|
||
port = int(os.environ.get('PORT', '8080'))
|
||
server = HTTPServer(('0.0.0.0', port), Handler)
|
||
print(f"Planner server running on port {port}")
|
||
server.serve_forever()
|