feat: isolate households and add explicit ownership succession

This commit is contained in:
Codex
2026-10-04 10:43:17 +08:00
parent a3f4544ddf
commit 72a3252e25
15 changed files with 635 additions and 60 deletions
+48 -21
View File
@@ -31,6 +31,7 @@ import onvif
import playback
import identity
import genealogy
import households
import ptz
ROOT = Path(__file__).resolve().parent
@@ -73,6 +74,7 @@ class Problem(Exception):
ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
def setting(key, default=None):
@@ -144,9 +146,9 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o
def serialized_save(function):
@functools.wraps(function)
def save(body):
def save(body, *args, **kwargs):
with LOCK:
return function(body)
return function(body, *args, **kwargs)
return save
@@ -162,11 +164,12 @@ def object_key(table, body):
@serialized_save
def save_site(body):
def save_site(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'sites', body)
key = object_key('sites', body)
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'))
item.update(id=key, kind=body.get('kind', 'home'), familyId=family)
if item['kind'] not in ('home', 'company', 'other'):
raise Problem('请选择家庭、公司或其他用途')
save_object('sites', item)
@@ -175,7 +178,8 @@ def save_site(body):
@serialized_save
def save_camera(body):
def save_camera(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'cameras', body, (('assets', 'assetId'), ('recorders', 'recorderId'), ('recorders', 'archiveRecorderId')))
key = object_key('cameras', body)
old = get_object('cameras', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -201,7 +205,8 @@ def save_camera(body):
raise Problem('关联的录像存储设备不存在')
item['archiveRecorderId'] = archive_recorder
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535))
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
path = clean_text(body.get(k, default), 500, required=True)
if not path.startswith('/') or path.startswith('//') or '#' in path:
@@ -224,14 +229,15 @@ def save_camera(body):
@serialized_save
def save_asset(body):
def save_asset(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'assets', body, (('sites', 'siteId'),))
key = object_key('assets', body)
site = body.get('siteId')
if not get_object('sites', site):
raise Problem('请选择所属空间')
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16))
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family)
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
@@ -245,7 +251,8 @@ def save_asset(body):
@serialized_save
def save_recorder(body):
def save_recorder(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'recorders', body, (('sites', 'siteId'),))
key = object_key('recorders', body)
old = get_object('recorders', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -254,7 +261,8 @@ def save_recorder(body):
if not get_object('sites', site):
raise Problem('请选择录像机所属空间')
item.update(id=key, siteId=site, host=host_address(body.get('host', '')),
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'))
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'):
raise Problem('接入驱动不正确')
password = body.get('password', '')
@@ -514,6 +522,11 @@ class Handler(BaseHTTPRequestHandler):
self.user = self.authenticated()
if not self.user:
raise Problem('请先登录', 401)
self.user = HOUSEHOLDS.context(self.user, self.headers.get('X-Household-Id'))
def require_manager(self):
if not HOUSEHOLDS.manager(self.user):
raise Problem('此操作需要家庭管理员权限', 403)
def require_admin(self):
if self.user['role'] != 'admin':
@@ -598,7 +611,7 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
self.require_admin()
self.require_manager()
if path == '/api/logout':
with LOCK:
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
@@ -622,7 +635,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
if data.get('action') != 'goto':
self.require_admin()
self.require_manager()
try:
result = ptz.CONTROLLER.preset_command(c, data)
except ValueError as e:
@@ -643,19 +656,26 @@ class Handler(BaseHTTPRequestHandler):
self.answer(result)
elif path == '/api/users':
self.answer(ACCOUNTS.save(data, self.user))
elif path == '/api/households':
self.answer(HOUSEHOLDS.save(data, self.user))
elif path == '/api/households/leadership':
self.answer(HOUSEHOLDS.leadership(data, self.user))
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
self.answer(save_site(data))
self.answer(save_site(data, self.user))
elif path == '/api/cameras':
self.answer(save_camera(data))
self.answer(save_camera(data, self.user))
elif path == '/api/assets':
self.answer(save_asset(data))
self.answer(save_asset(data, self.user))
elif path == '/api/recorders':
self.answer(save_recorder(data))
self.answer(save_recorder(data, self.user))
elif path == '/api/onvif':
old = get_object('cameras', data.get('id')) or {}
if data.get('id'):
HOUSEHOLDS.owns(self.user, old)
host = host_address(data.get('host', ''))
HOUSEHOLDS.endpoint(self.user, host)
port = integer(data.get('port', 80), 1, 65535)
username = clean_text(data.get('username', ''), 120, required=True)
password = data.get('password') or old.get('password', '')
@@ -667,6 +687,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502)
self.answer(result)
elif path == '/api/storage':
self.require_admin()
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
'maxGB': integer(data.get('maxGB'), 5, 100000),
'reserveGB': integer(data.get('reserveGB'), 2, 1000)}
@@ -675,6 +696,7 @@ class Handler(BaseHTTPRequestHandler):
audit('更新录像保留策略')
self.answer({'ok': True})
elif path == '/api/discover':
self.require_admin()
try:
network = ipaddress.ip_network(data.get('network', ''), strict=False)
if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30:
@@ -777,6 +799,7 @@ class Handler(BaseHTTPRequestHandler):
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == '/api/session':
user = self.authenticated()
user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id'))
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
'setupRequired': not bool(objects('users'))})
return
@@ -788,8 +811,11 @@ class Handler(BaseHTTPRequestHandler):
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/users':
self.require_admin()
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
if self.user['role'] == 'admin' or u['role'] != 'admin']})
elif path == '/api/households':
self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)})
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
@@ -798,7 +824,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem(str(e), 400)
except Exception:
raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502)
self.answer(dict(result, canManage=self.user['role'] == 'admin'))
self.answer(dict(result, canManage=HOUSEHOLDS.manager(self.user)))
elif path == '/api/ptz':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
@@ -811,7 +837,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/family':
self.answer(FAMILY.snapshot(self.user))
elif path == '/api/camera/settings':
self.require_admin()
self.require_manager()
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
try:
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
@@ -873,7 +899,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('接口不存在', 404)
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
@@ -911,6 +937,7 @@ def initialize():
global MEDIA_AUTH
FAMILY.initialize()
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):