From 4ac0a0b516fed0bbdbc10d276a13559dbd0bd27b Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 16:21:40 +0800 Subject: [PATCH] feat: organize places and device workspaces with scoped heritage records --- ARCHITECTURE.md | 13 ++++++++ README.md | 23 +++++++++++++ VERSION | 2 +- app.py | 12 +++++-- heritage.py | 78 +++++++++++++++++++++++++++++++++++++++++++ identity.py | 7 ++-- test_app.py | 2 +- test_heritage.py | 78 +++++++++++++++++++++++++++++++++++++++++++ test_steward.js | 51 ++++++++++++++++++++++++++++ web/app.js | 40 +++++++++++++--------- web/chinese-theme.css | 8 +++++ web/devices.js | 4 +-- web/family.js | 2 +- web/heritage.js | 53 +++++++++++++++++++++++++++++ web/households.js | 8 ++--- web/index.html | 42 ++++++++++++----------- web/monitor.js | 6 ++-- web/steward.js | 66 ++++++++++++++++++++++++++++++++++++ 18 files changed, 444 insertions(+), 51 deletions(-) create mode 100644 heritage.py create mode 100644 test_heritage.py create mode 100644 test_steward.js create mode 100644 web/heritage.js create mode 100644 web/steward.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dc92e53..e683db4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,3 +98,16 @@ 区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。 新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。 + + +## 场所、设备上下文与传承(v0.1.24) + +`steward.js` 管理入口和当前设备上下文。根入口是归属列表;进入归属后才展示内部导航。`deviceCameras` 按摄像头 `assetId` 或录像机关联字段 `recorderId` / `archiveRecorderId` 选择已有授权通道,不推断不存在的镜头。通用设备没有视频页签。前端上下文缩小显示范围,后端的家庭与区域授权始终独立执行。 + +`heritage.py` 通过 `GET /api/heritage` 和 `POST /api/heritage` 维护 `heritage_entries`,kind 为 culture 或 asset,只允许 home 类归属。写入在全局写锁内检查归属、编辑权限、不可变类别和每条档案的 revision。关联设备需要同归属,普通编辑者还须具备该设备区域权限。文字统一转义;外链限定无嵌入凭据的 HTTP/HTTPS,浏览器用 noopener/noreferrer 打开,不由服务器抓取。 + +`heritageAccess` 独立于 `familyAccess`,普通账号默认 none,read 只能读,edit 可创建、编辑及归档;管理员限定于其管理归属,超级管理员可明确切换归属。权限改变撤销现有会话。新表无默认真实资料;旧账户未补写该字段也按 none 处理。 + +前端 `heritage.js` 用独立请求代次防止晚返回的数据覆盖其他页面;退出、切换家庭清空资料和表单。更新使用修订号防并发,归档可恢复;目前保留当前版本及最近修改人、时间,不保存该档案的逐次历史快照。所有人、保管人、意向接收人均是档案文本,不是认证标识,不参与权限或所有权计算。 + +当前架构边界:非超级管理员一账号一归属;财产记录与系统角色交接是独立流程;通用设备登记不代表支持控制;录像机既有历史录像仍需品牌适配。未来多归属成员表与正式交接流程须先完成明确授权模型,再扩展数据结构。 diff --git a/README.md b/README.md index be483a0..f42ff96 100644 --- a/README.md +++ b/README.md @@ -209,3 +209,26 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 - 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。 - 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。 - 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。 + + +## 智能数字管家的入口与传承档案(v0.1.24) + +系统从「我的家庭与场所」开始。进入一个家庭、办公室或工厂后,左侧才显示该归属下的「场所总览」「区域与设备」「成员与权限」「区域设置」。家庭另有「家庭传承」。平台存储与审计仅向超级管理员开放。 + +### 从设备进入功能 + +在「区域与设备」点击设备名称:摄像头内部提供「设备档案 / 实时画面 / 录像回放 / 镜头设置」;实时画面与回放仅列出该摄像头的已授权镜头。普通成员没有镜头设置入口。录像机同样作为设备存在,展示明确关联的通道;通用设备先提供档案。场所总览的「本场所监控墙」可集中查看本场所已授权镜头,分屏与顺序按账号、归属及设备分别保存。 + +切换设备会释放之前的播放与云台会话;切换归属会清空私有视图并使旧请求失效。新版入口不自动拉取所有摄像头视频。原始摄像机接入参数、媒体配置和录像策略保持原值。 + +### 家庭传承 + +- 「人物与家谱」打开关系图,「家庭记事」直接进入时间线。 +- 「文化与家风」保存故事、家训、手艺、整理人、日期、正文及资料链接。 +- 「家庭物品与财产档案」保存来历、所有人或共有说明、保管人、意向接收人和交接说明,可关联本家庭已有设备。 +- 档案支持搜索、查看详情、编辑、归档和恢复。编辑时检查修订号,拒绝用旧表单覆盖新内容。资料按家庭隔离;仅保存文字及链接,不上传或备份链接目标文件。 +- 在「成员与权限」独立设置「文化与财产档案权限」为不开放、查看或编辑。家庭管理员管理本家庭全部档案;普通成员已有的家谱权限不会自动开放财产资料。改变该权限会撤销该账号现有会话。 + +交接说明只是记录意愿,不自动改变财产所有权、设备责任人、账号角色或家主安排。实际系统权限交接继续走「我的家庭与场所 → 负责人安排与交接」。当前非超级管理员账号仍只属于一个家庭或场所,多归属成员身份尚未实现。 + +升级幂等创建 `heritage_entries` 表,不自动修改现有账号、设备或家谱。未新增授权的普通成员默认无法读取此表。仍需在升级前备份数据库。录像机历史录像适配、通用设备实际控制、物品正式交接流程尚待后续实现;本版不宣称解决摄像机源流全部卡顿问题。 diff --git a/VERSION b/VERSION index 001d752..5a48b6b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.23 +0.1.24 diff --git a/app.py b/app.py index 1e21cbd..3b57c44 100644 --- a/app.py +++ b/app.py @@ -33,6 +33,7 @@ import identity import genealogy import households import inventory +import heritage import ptz ROOT = Path(__file__).resolve().parent @@ -77,6 +78,7 @@ ACCOUNTS = identity.Accounts(sys.modules[__name__]) FAMILY = genealogy.Family(sys.modules[__name__]) HOUSEHOLDS = households.Households(sys.modules[__name__]) INVENTORY = inventory.Inventory(sys.modules[__name__]) +HERITAGE = heritage.Heritage(sys.modules[__name__]) def setting(key, default=None): @@ -615,14 +617,14 @@ class Handler(BaseHTTPRequestHandler): try: path = url.urlsplit(self.path).path # 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB. - data = self.body(131072 if path == '/api/family/event' else 16384) + data = self.body(131072 if path in ('/api/family/event','/api/heritage') else 16384) self.validate_origin() if path in ('/api/login', '/api/setup'): self.login(data, path == '/api/setup') return 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'): + '/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'): self.require_manager() if path == '/api/logout': with LOCK: @@ -674,6 +676,8 @@ class Handler(BaseHTTPRequestHandler): self.answer(HOUSEHOLDS.leadership(data, self.user)) elif path == '/api/device-view': self.answer(HOUSEHOLDS.save_view(data, self.user)) + elif path == '/api/heritage': + self.answer(HERITAGE.save(data, self.user)) elif path == '/api/devices': self.answer(INVENTORY.save(data, self.user)) elif path in ('/api/family/person', '/api/family/link', '/api/family/event'): @@ -830,6 +834,8 @@ class Handler(BaseHTTPRequestHandler): 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/heritage': + self.answer(HERITAGE.snapshot(self.user)) elif path == '/api/households': self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)}) elif path == '/api/ptz/presets': @@ -917,6 +923,7 @@ class Handler(BaseHTTPRequestHandler): 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', '/households.js': 'households.js', '/device-model.js': 'device-model.js', '/devices.js': 'devices.js', + '/steward.js': 'steward.js', '/heritage.js': 'heritage.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', @@ -955,6 +962,7 @@ def initialize(): FAMILY.initialize() ACCOUNTS.initialize() HOUSEHOLDS.initialize() + HERITAGE.initialize() if not setting('storage'): set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) if not setting('mediaSecret'): diff --git a/heritage.py b/heritage.py new file mode 100644 index 0000000..9f210bf --- /dev/null +++ b/heritage.py @@ -0,0 +1,78 @@ +"""Scoped cultural and property records; records never transfer access or ownership.""" +import datetime as dt +from urllib.parse import urlsplit + + +class Heritage: + def __init__(self, app): + self.a = app + + def initialize(self): + with self.a.LOCK, self.a.DB: + self.a.DB.execute('CREATE TABLE IF NOT EXISTS heritage_entries (id TEXT PRIMARY KEY, body TEXT NOT NULL)') + + def require(self, user, edit=False): + a = self.a + home = a.get_object('households', a.HOUSEHOLDS.scope(user)) + if home.get('kind', 'home') != 'home': + raise a.Problem('家庭传承资料属于家庭,请先进入对应家庭', 403) + if not a.HOUSEHOLDS.manager(user) and user.get('heritageAccess', 'none') not in (('edit',) if edit else ('read', 'edit')): + raise a.Problem('尚未获得文化与财产档案权限,请联系家庭管理员', 403) + + def snapshot(self, user): + self.require(user) + return dict(entries=self.a.HOUSEHOLDS.objects(user, 'heritage_entries'), + canEdit=self.a.HOUSEHOLDS.manager(user) or user.get('heritageAccess') == 'edit') + + def save(self, data, user): + a = self.a + with a.LOCK: + self.require(user, True) + family = a.HOUSEHOLDS.prepare(user, 'heritage_entries', data) + key = a.object_key('heritage_entries', data) + old = a.get_object('heritage_entries', key) or {} + if data.get('revision', 0) != old.get('revision', 0): + raise a.Problem('档案已被更新,请重新打开后再保存', 409) + kind = data.get('kind') + if kind not in ('culture', 'asset') or old and kind != old['kind']: + raise a.Problem('请选择文化资料或财产档案;已有档案不能变更类别') + archived = data.get('archived', False) + if not isinstance(archived, bool): + raise a.Problem('归档状态不正确') + name = a.clean_text(data.get('name', ''), 120, True) + date = a.clean_text(data.get('date', ''), 10) + if date: + try: + if dt.date.fromisoformat(date).isoformat() != date: + raise ValueError() + except ValueError: + raise a.Problem('日期格式不正确') + link = a.clean_text(data.get('sourceUrl', ''), 1500) + if link: + try: + parts = urlsplit(link) + if parts.scheme not in ('http', 'https') or not parts.hostname or parts.username or parts.password: + raise ValueError() + except ValueError: + raise a.Problem('资料链接须为不含账号密码的 HTTP 或 HTTPS 地址') + related = a.clean_text(data.get('deviceId', ''), 32) + source = data.get('deviceSource', '') + if related: + table = {'camera': 'assets', 'recorder': 'recorders', 'device': 'devices'}.get(source) + if not table: + raise a.Problem('关联设备类型不正确') + a.HOUSEHOLDS.owns(user, a.get_object(table, related)) + # Ordinary record editors may reference only their own authorized areas. + device = a.get_object(table, related) + if not a.HOUSEHOLDS.manager(user) and device.get('siteId') not in a.HOUSEHOLDS.site_ids(user): + raise a.Problem('未获授权关联这台设备', 403) + fields = {k: a.clean_text(data.get(k, ''), 80) for k in ('ownerName', 'custodianName', 'successorName', 'contributor')} + item = dict(id=key, familyId=family, kind=kind, name=name, date=date, sourceUrl=link, + content=a.clean_text(data.get('content', ''), 8000), + handoverNote=a.clean_text(data.get('handoverNote', ''), 2000), + deviceId=related, deviceSource=source if related else '', archived=archived, + revision=old.get('revision', 0)+1, **fields, + updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['name']) + a.save_object('heritage_entries', item) + a.audit('保存传承资料', user['username']+' / '+kind) + return item diff --git a/identity.py b/identity.py index 492c4c7..8009699 100644 --- a/identity.py +++ b/identity.py @@ -64,6 +64,9 @@ class Accounts: raise a.Problem('用户名已存在', 409) role = data.get('role', 'member') access = data.get('familyAccess', 'none') + heritage_access = data.get('heritageAccess', old.get('heritageAccess', 'none') if old else 'none') + if heritage_access not in ('none', 'read', 'edit'): + raise a.Problem('文化与财产档案权限不正确') if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'): raise a.Problem('权限选项不正确') if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')): @@ -116,10 +119,10 @@ class Accounts: credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')} user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True), role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control, - personId=person, relatedToId=related, includeSubspaces=descendants, + personId=person, relatedToId=related, includeSubspaces=descendants, heritageAccess=heritage_access, relationship=a.clean_text(data.get('relationship', ''), 40), **credentials) # Revoke sessions when credentials or authorization change, not for label edits. - if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')): + if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'heritageAccess', 'disabled', 'username', 'ptzControl', 'personId')): a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,)) a.save_object('users', user) a.audit('更新账号' if old else '建立账号', username) diff --git a/test_app.py b/test_app.py index 8248dd2..fdbea6f 100644 --- a/test_app.py +++ b/test_app.py @@ -105,7 +105,7 @@ class WebTests(unittest.TestCase): self.assertEqual(headers['Cache-Control'], 'no-store') self.assertNotIn(b'__ASSET_VERSION__', body) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) - self.assertEqual(len(assets), 22) + self.assertEqual(len(assets), 24) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) diff --git a/test_heritage.py b/test_heritage.py new file mode 100644 index 0000000..4c05243 --- /dev/null +++ b/test_heritage.py @@ -0,0 +1,78 @@ +"""Real HTTP coverage for scoped cultural and property records.""" +import json +import unittest +from test_app import app +import test_households as fixture + + +class HeritageTests(unittest.TestCase): + setUp = fixture.HouseholdTests.setUp + stop_server = fixture.HouseholdTests.stop_server + request = fixture.HouseholdTests.request + + def create(self, **fields): + data = dict(name='家书', kind='culture'); data.update(fields) + code, row = self.request('/api/heritage', data=data) + self.assertEqual(code, 200, row) + return row + + def allow(self, level): + user = app.get_object('users', self.members[self.aid]['id']) + user['heritageAccess'] = level + app.save_object('users', user) + return user + + def test_genealogy_access_does_not_grant_property_access(self): + self.create() + member = self.members[self.aid] + self.assertEqual(self.request('/api/family', member)[0], 200) + self.assertEqual(self.request('/api/heritage', member)[0], 403) + self.assertEqual(self.request('/api/heritage', member, dict(name='x', kind='asset'))[0], 403) + reader = self.allow('read') + self.assertEqual(len(self.request('/api/heritage', reader)[1]['entries']), 1) + self.assertFalse(self.request('/api/heritage', reader)[1]['canEdit']) + self.assertEqual(self.request('/api/heritage', reader, dict(name='x', kind='asset'))[0], 403) + + def test_cross_household_reads_edits_and_device_references_are_rejected(self): + row = self.create() + self.assertEqual(self.request('/api/heritage', self.other)[1]['entries'], []) + self.assertIn(self.request('/api/heritage', self.other, row)[0], (403,404)) + self.assertEqual(self.request('/api/heritage', data=dict(name='x', kind='culture', + deviceId=self.assets[self.bid]['id'], deviceSource='camera'))[0], 404) + self.assertEqual(self.request('/api/heritage', self.owner, home=self.bid)[0], 403) + + def test_revision_archive_and_restore(self): + row = self.create() + editor = self.allow('edit') + code, archived = self.request('/api/heritage', editor, dict(row, archived=True)) + self.assertEqual(code, 200);self.assertEqual(archived['revision'], 2) + self.assertEqual(self.request('/api/heritage', editor, row)[0], 409) + code, restored = self.request('/api/heritage', editor, dict(archived, archived=False)) + self.assertEqual(code, 200);self.assertFalse(restored['archived']) + self.assertEqual(restored['revision'], 3) + + def test_validation_and_non_home_scope(self): + for values in [dict(sourceUrl='javascript:alert(1)'),dict(sourceUrl='https://user:pass@example.com'), + dict(date='2026-02-30'),dict(archived='false'),dict(kind='unsupported')]: + data=dict(name='x',kind='culture');data.update(values) + self.assertEqual(self.request('/api/heritage',data=data)[0],400) + home=app.get_object('households',self.bid) + app.HOUSEHOLDS.save(dict(home,kind='office'),self.root) + self.assertEqual(self.request('/api/heritage',self.other)[0],403) + + def test_handover_intent_never_changes_accounts_or_device_ownership(self): + users=json.dumps(app.objects('users'),sort_keys=True) + devices=json.dumps(app.objects('assets'),sort_keys=True) + row=self.create(kind='asset',ownerName='甲',custodianName='乙',successorName='丙',handoverNote='记录意愿') + self.assertEqual(row['successorName'],'丙') + self.assertEqual(json.dumps(app.objects('users'),sort_keys=True),users) + self.assertEqual(json.dumps(app.objects('assets'),sort_keys=True),devices) + + def test_new_permission_revokes_session_and_editor_needs_device_grant(self): + member=app.get_object('users',self.members[self.aid]['id']) + app.ACCOUNTS.save(dict(member,heritageAccess='edit'),self.root) + self.assertEqual(self.request('/api/heritage',member)[0],401) + member=app.get_object('users',member['id']);member['siteIds']=[] + app.save_object('users',member) + with self.assertRaises(app.Problem): + app.HERITAGE.save(dict(name='未授权设备',kind='asset',deviceSource='camera',deviceId=self.assets[self.aid]['id']),member) diff --git a/test_steward.js b/test_steward.js new file mode 100644 index 0000000..af462a1 --- /dev/null +++ b/test_steward.js @@ -0,0 +1,51 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'); +const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8'); +const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length)); +test('navigation starts at places and contains no global camera functions',()=>{ + const nav=html.match(/