Commit 35f0fbce authored by wenwen.tang's avatar wenwen.tang 😕

feat(权限): 访客账号改按页授权 —— guest 仅「电费通知单」可操作

能力模型:账号/会话新增 perms(目前只有 elec = 电费通知单)。管理员隐含全部能力;访客按 perms 逐接口放行,perms 为空即纯只读。

写闸门重构:server._write_cap(method,path) 逐接口解析所需能力,_guard_write(user,cap) 按能力判定并给出「账号 X 只能操作:电费通知单」的明确提示。批次类接口再按批次 kind 复核 —— 房租车位费批次的删/重出/发送/收款登记仍只有管理员能动。

前端能力感知:body 加 cap-elec 类,电费页控件由 data-ro 改 data-cap=elec;批次列表/单据表的按钮按 kind 区分;api() 前端拦截改为按能力;顶栏与只读横幅按能力显示「访客 · 仅电费」。

账号管理:新增「权限」列(管理员=全部 / 访客=能力标签 / 空=只读),新增账号可勾选「电费通知单」,已有访客可一键开通/关闭;新增 PUT /api/accounts/{name} 改角色/能力/备注;主账号不可降级且强制清空 perms。

一次性回填:老配置没有 perms 字段 → ensure_default_account 按 guest_perms_seeded 标记补上默认能力(之后管理员关掉不会再被打开)。

guest 落地:密码 guest@2026,perms=['elec'],备注「电费通知单操作员」(本地已改,线上部署后同步)。

修 bug:删除不存在的批次时 service.rollback_receipt_sync(None) 抛 AttributeError → 500;现静默返回 0。

验证:单测 245 项全绿(accounts 60);新增 ui_verify10(69 项,guest/admin/纯只读三方对照)全绿;ui_verify7(访客,改成用纯只读样本 + 新增 guest 段)69/0;ui_verify2 72/0;ui_verify 全绿。
parent 61436563
...@@ -14,14 +14,18 @@ ...@@ -14,14 +14,18 @@
}, },
{ {
"username": "guest", "username": "guest",
"salt": "d232110691983659", "salt": "0a0fee8da62f24c2",
"password_hash": "a588e529a46faffc0fcd660b9a1ee5546297fbbb48ecfb979e8a7199e48750f9", "password_hash": "fd9b38792c10f0af6f70e954124a9c4e9925ea5da69fb67b515fa973cbdd02e3",
"role": "viewer", "role": "viewer",
"note": "只读访客", "note": "电费通知单操作员",
"created_at": "2026-10-13 11:55:31" "created_at": "2026-10-13 11:55:31",
"perms": [
"elec"
]
} }
], ],
"guest_seeded": true "guest_seeded": true,
"guest_perms_seeded": true
}, },
"notify": { "notify": {
"enabled": true, "enabled": true,
......
...@@ -3,12 +3,15 @@ ...@@ -3,12 +3,15 @@
登录校验、多账号与角色(会话管理) 登录校验、多账号与角色(会话管理)
================================== ==================================
账号:data/config.json 的 auth.accounts 列表 账号:data/config.json 的 auth.accounts 列表
[{"username","salt","password_hash","role","note","created_at"}] [{"username","salt","password_hash","role","perms","note","created_at"}]
role = "admin"(可读写) | "viewer"(只读访客,服务端一律拒绝写操作) role = "admin"(可读写) | "viewer"(访客,写权限由 perms 逐项授予)
perms = 访客的能力清单,目前只有 "elec"(电费通知单:录入出单 / 发送 / 重出 / 删批次)
· 管理员隐含全部能力,列表中不必写
· viewer 且 perms 为空 → 纯只读(服务端所有写接口一律 403)
默认账号(首次启动自动播种加盐哈希,不落明文) 默认账号(首次启动自动播种加盐哈希,不落明文)
- admin / 1989@rent05 管理员,可读写 - admin / 1989@rent05 管理员,可读写
- guest / guest@2026 只读访客,只能浏览 - guest / guest@2026 电费通知单操作员(只读 + 电费通知单可操作)
兼容:旧版 auth 里只有 username / salt / password_hash 三个字段时, 兼容:旧版 auth 里只有 username / salt / password_hash 三个字段时,
首次 ensure_default_account() 会把它补成 accounts 里的管理员条目; 首次 ensure_default_account() 会把它补成 accounts 里的管理员条目;
...@@ -20,6 +23,7 @@ ...@@ -20,6 +23,7 @@
import datetime as dt import datetime as dt
import hashlib import hashlib
import hmac import hmac
import re
import secrets import secrets
from . import config as C from . import config as C
...@@ -31,8 +35,16 @@ ROLE_ADMIN = "admin" ...@@ -31,8 +35,16 @@ ROLE_ADMIN = "admin"
ROLE_VIEWER = "viewer" ROLE_VIEWER = "viewer"
ROLES = (ROLE_ADMIN, ROLE_VIEWER) ROLES = (ROLE_ADMIN, ROLE_VIEWER)
# ---------------------------------------------------------------- 访客能力
# 权限粒度按「业务页」划分,与前端 data-cap 属性同名。
CAP_ELEC = "elec" # 电费通知单:录入出单 / 发送 / 重出单据 / 删批次
CAPS = (CAP_ELEC,)
CAP_LABELS = {CAP_ELEC: "电费通知单"}
CAP_ALL = "*" # 管理员隐含全部能力
DEFAULT_ADMIN = ("admin", "1989@rent05", "系统管理员") DEFAULT_ADMIN = ("admin", "1989@rent05", "系统管理员")
DEFAULT_GUEST = ("guest", "guest@2026", "只读访客") DEFAULT_GUEST = ("guest", "guest@2026", "电费通知单操作员")
DEFAULT_GUEST_PERMS = (CAP_ELEC,)
# ---------------------------------------------------------------- 密码 # ---------------------------------------------------------------- 密码
...@@ -44,18 +56,59 @@ def new_salt(): ...@@ -44,18 +56,59 @@ def new_salt():
return secrets.token_hex(8) return secrets.token_hex(8)
def _account(username, password, role, note=""): def _account(username, password, role, note="", perms=None):
salt = new_salt() salt = new_salt()
role = normalize_role(role)
return { return {
"username": str(username or "").strip(), "username": str(username or "").strip(),
"salt": salt, "salt": salt,
"password_hash": make_hash(str(password or ""), salt), "password_hash": make_hash(str(password or ""), salt),
"role": role, "role": role,
"perms": normalize_perms(perms, role),
"note": note, "note": note,
"created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
} }
# ---------------------------------------------------------------- 能力
def normalize_perms(perms, role=None):
"""把任意输入收敛成认识的能力键列表。管理员不需要(隐含全部)→ 空列表。"""
if role is not None and normalize_role(role) == ROLE_ADMIN:
return []
if isinstance(perms, str):
perms = [x for x in re.split(r"[,\s、;]+", perms) if x]
if not isinstance(perms, (list, tuple, set)):
return []
out = []
for x in perms:
k = str(x or "").strip().lower()
if k in CAPS and k not in out:
out.append(k)
return out
def caps(account):
"""账号 / 会话的能力集合。管理员 → {"*"};未知角色 → 空集(只读)。"""
if not account:
return set()
if normalize_role(account.get("role")) == ROLE_ADMIN:
return {CAP_ALL}
return set(normalize_perms(account.get("perms"), account.get("role")))
def can(account, cap):
"""是否具备某项能力(cap 为能力键;管理员一律 True)。"""
c = caps(account)
if CAP_ALL in c:
return True
return str(cap or "").strip().lower() in c
def cap_labels(items):
"""能力键 → 中文名,用于提示文案。"""
return [CAP_LABELS.get(k, k) for k in (items or [])]
# ---------------------------------------------------------------- 账号 # ---------------------------------------------------------------- 账号
def ensure_default_account(): def ensure_default_account():
"""首次启动:把主账号与访客账号补齐(幂等,只在有变化时落盘)。""" """首次启动:把主账号与访客账号补齐(幂等,只在有变化时落盘)。"""
...@@ -88,13 +141,18 @@ def ensure_default_account(): ...@@ -88,13 +141,18 @@ def ensure_default_account():
"salt": auth.get("salt") or new_salt(), "salt": auth.get("salt") or new_salt(),
"password_hash": auth.get("password_hash") or make_hash(DEFAULT_ADMIN[1], new_salt()), "password_hash": auth.get("password_hash") or make_hash(DEFAULT_ADMIN[1], new_salt()),
"role": ROLE_ADMIN, "role": ROLE_ADMIN,
"perms": [],
"note": DEFAULT_ADMIN[2], "note": DEFAULT_ADMIN[2],
"created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}) })
changed = True changed = True
elif normalize_role(row.get("role")) != ROLE_ADMIN: else:
row["role"] = ROLE_ADMIN if normalize_role(row.get("role")) != ROLE_ADMIN:
changed = True row["role"] = ROLE_ADMIN
changed = True
if row.get("perms"):
row["perms"] = [] # 管理员不需要能力清单
changed = True
# 访客账号:只在首次初始化时播种一次; # 访客账号:只在首次初始化时播种一次;
# 之后即便被管理员删掉也不会自动复活(靠 guest_seeded 标记)。 # 之后即便被管理员删掉也不会自动复活(靠 guest_seeded 标记)。
...@@ -103,7 +161,18 @@ def ensure_default_account(): ...@@ -103,7 +161,18 @@ def ensure_default_account():
changed = True changed = True
if not _has(DEFAULT_GUEST[0]): if not _has(DEFAULT_GUEST[0]):
accounts.append(_account(DEFAULT_GUEST[0], DEFAULT_GUEST[1], accounts.append(_account(DEFAULT_GUEST[0], DEFAULT_GUEST[1],
ROLE_VIEWER, DEFAULT_GUEST[2])) ROLE_VIEWER, DEFAULT_GUEST[2],
perms=DEFAULT_GUEST_PERMS))
# 访客能力:升级前的老配置里没有 perms 字段 → 一次性补上默认能力。
# 单独用一个标记,保证之后管理员把它关掉(perms=[])不会被再次打开。
if not auth.get("guest_perms_seeded"):
auth["guest_perms_seeded"] = True
changed = True
g = next((a for a in accounts
if str(a.get("username") or "") == DEFAULT_GUEST[0]), None)
if g is not None and not isinstance(g.get("perms"), list):
g["perms"] = list(DEFAULT_GUEST_PERMS)
auth["accounts"] = accounts auth["accounts"] = accounts
if changed: if changed:
...@@ -148,6 +217,7 @@ def authenticate(username, password): ...@@ -148,6 +217,7 @@ def authenticate(username, password):
h = make_hash(str(password or ""), a.get("salt") or "") h = make_hash(str(password or ""), a.get("salt") or "")
if hmac.compare_digest(h, str(a.get("password_hash") or "")): if hmac.compare_digest(h, str(a.get("password_hash") or "")):
a["role"] = normalize_role(a.get("role")) a["role"] = normalize_role(a.get("role"))
a["perms"] = normalize_perms(a.get("perms"), a["role"])
return a return a
return None return None
...@@ -163,6 +233,8 @@ def public_accounts(): ...@@ -163,6 +233,8 @@ def public_accounts():
return [{ return [{
"username": a.get("username"), "username": a.get("username"),
"role": normalize_role(a.get("role")), "role": normalize_role(a.get("role")),
"perms": normalize_perms(a.get("perms"), a.get("role")),
"cap_labels": cap_labels(normalize_perms(a.get("perms"), a.get("role"))),
"note": a.get("note") or "", "note": a.get("note") or "",
"created_at": a.get("created_at") or "", "created_at": a.get("created_at") or "",
"primary": str(a.get("username")) == main, "primary": str(a.get("username")) == main,
...@@ -190,8 +262,8 @@ def set_password(new_password, username=None): ...@@ -190,8 +262,8 @@ def set_password(new_password, username=None):
return target return target
def add_account(username, password, role=ROLE_VIEWER, note=""): def add_account(username, password, role=ROLE_VIEWER, note="", perms=None):
"""新增账号(已存在则报错)。""" """新增账号(已存在则报错)。perms 仅对访客有意义。"""
ensure_default_account() ensure_default_account()
u = str(username or "").strip() u = str(username or "").strip()
if not u: if not u:
...@@ -203,7 +275,40 @@ def add_account(username, password, role=ROLE_VIEWER, note=""): ...@@ -203,7 +275,40 @@ def add_account(username, password, role=ROLE_VIEWER, note=""):
cfg = store.load_config() cfg = store.load_config()
auth = cfg.setdefault("auth", {}) auth = cfg.setdefault("auth", {})
auth.setdefault("accounts", []).append( auth.setdefault("accounts", []).append(
_account(u, password, normalize_role(role), note)) _account(u, password, role, note, perms=perms))
store.save_config(cfg)
return find(u)
def set_account(username, role=None, perms=None, note=None):
"""改账号的角色 / 能力 / 备注(只传要改的项)。
· 主账号角色固定为管理员,不允许降级,也被强制清空 perms
· role 传了 "admin" → perms 自动清空;perms 传了但没传 role → 按账号现有角色处理
"""
ensure_default_account()
u = str(username or "").strip()
if not find(u):
raise ValueError("账号不存在:%s" % u)
cfg = store.load_config()
auth = cfg.setdefault("auth", {})
row = next((a for a in (auth.get("accounts") or [])
if str(a.get("username") or "") == u), None)
if row is None:
raise ValueError("账号不存在:%s" % u)
if u == str(auth.get("username") or ""):
if role is not None and normalize_role(role) != ROLE_ADMIN:
raise ValueError("主账号 %s 不能降级为访客" % u)
role = ROLE_ADMIN
if role is not None:
row["role"] = normalize_role(role)
if note is not None:
row["note"] = str(note)
if perms is not None or role is not None:
row["perms"] = normalize_perms(perms if perms is not None else row.get("perms"),
row.get("role"))
store.save_config(cfg) store.save_config(cfg)
return find(u) return find(u)
...@@ -226,16 +331,18 @@ def remove_account(username): ...@@ -226,16 +331,18 @@ def remove_account(username):
# ---------------------------------------------------------------- 会话 # ---------------------------------------------------------------- 会话
def create_session(username, role=ROLE_ADMIN, ttl_hours=SESSION_TTL_HOURS): def create_session(username, role=ROLE_ADMIN, perms=None, ttl_hours=SESSION_TTL_HOURS):
sessions = store.load_sessions() sessions = store.load_sessions()
now = dt.datetime.now() now = dt.datetime.now()
# 顺手清理过期会话 # 顺手清理过期会话
sessions = {k: v for k, v in sessions.items() sessions = {k: v for k, v in sessions.items()
if (_parse(v.get("expire_at")) or dt.datetime.min) > now} if (_parse(v.get("expire_at")) or dt.datetime.min) > now}
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
role = normalize_role(role)
sessions[token] = { sessions[token] = {
"username": username, "username": username,
"role": normalize_role(role), "role": role,
"perms": normalize_perms(perms, role),
"created_at": now.strftime("%Y-%m-%d %H:%M:%S"), "created_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"expire_at": (now + dt.timedelta(hours=ttl_hours)).strftime("%Y-%m-%d %H:%M:%S"), "expire_at": (now + dt.timedelta(hours=ttl_hours)).strftime("%Y-%m-%d %H:%M:%S"),
} }
...@@ -257,6 +364,7 @@ def check_session(token): ...@@ -257,6 +364,7 @@ def check_session(token):
return None return None
# 老会话没有 role 字段 → 当年只有管理员能登录,按管理员处理 # 老会话没有 role 字段 → 当年只有管理员能登录,按管理员处理
s["role"] = normalize_role(s["role"]) if s.get("role") else ROLE_ADMIN s["role"] = normalize_role(s["role"]) if s.get("role") else ROLE_ADMIN
s["perms"] = normalize_perms(s.get("perms"), s["role"])
return s return s
......
...@@ -47,12 +47,15 @@ DEFAULT_CONFIG = { ...@@ -47,12 +47,15 @@ DEFAULT_CONFIG = {
# sha256(salt + password),默认密码 1989@rent05 # sha256(salt + password),默认密码 1989@rent05
"salt": "rentos-2026", "salt": "rentos-2026",
"password_hash": "", "password_hash": "",
# 多账号 + 角色。role: "admin" 可读写 | "viewer" 只读访客 # 多账号 + 角色。role: "admin" 可读写 | "viewer" 访客(写权限由 perms 逐项授予)
# [{"username","salt","password_hash","role","note","created_at"}] # [{"username","salt","password_hash","role","perms","note","created_at"}]
# 首次启动会播种 admin(1989@rent05)与 guest(guest@2026,只读) # perms 目前只有 "elec"(电费通知单可操作);空 = 纯只读
# 首次启动会播种 admin(1989@rent05)与 guest(guest@2026,电费通知单操作员)
"accounts": [], "accounts": [],
# 访客账号只播种一次;管理员删掉后不再自动复活 # 访客账号只播种一次;管理员删掉后不再自动复活
"guest_seeded": False, "guest_seeded": False,
# 访客「能力」也只回填一次(老配置没有 perms 字段时补默认值)
"guest_perms_seeded": False,
}, },
"notify": { "notify": {
# 定时发送总开关;关闭后两张账单都不自动发送,只能手动发 # 定时发送总开关;关闭后两张账单都不自动发送,只能手动发
......
...@@ -7,11 +7,14 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -7,11 +7,14 @@ Python 标准库 http.server,零外部依赖。启动:
python -m rentos.server --port 8899 --no-scheduler python -m rentos.server --port 8899 --no-scheduler
路由总览(全部 JSON,除静态与文件下载) 路由总览(全部 JSON,除静态与文件下载)
POST /api/login 账密登录 → token(含 role:admin 可读写 / viewer 只读) 权限:role="admin" 全量;role="viewer" 由 perms 逐项授权
(perms=["elec"] → 只放行「电费通知单」页的写操作,其余写接口一律 403)
POST /api/login 账密登录 → token(含 role / perms)
POST /api/logout 退出 POST /api/logout 退出
GET /api/session 当前会话(含 role / can_write) GET /api/session 当前会话(含 role / perms / cap_labels / can_write)
GET /api/accounts 账号清单(仅管理员) GET /api/accounts 账号清单(仅管理员)
POST /api/accounts 新增账号(仅管理员) POST /api/accounts 新增账号(仅管理员,可带 perms)
PUT /api/accounts/{username} 改角色 / 能力 / 备注(仅管理员)
DELETE /api/accounts/{username} 删除账号(仅管理员;主账号不可删) DELETE /api/accounts/{username} 删除账号(仅管理员;主账号不可删)
POST /api/accounts/{username}/password 重置指定账号密码(仅管理员) POST /api/accounts/{username}/password 重置指定账号密码(仅管理员)
GET /api/overview 看板 GET /api/overview 看板
...@@ -148,6 +151,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -148,6 +151,14 @@ class Handler(BaseHTTPRequestHandler):
return "" return ""
return auth.normalize_role(user.get("role") or auth.ROLE_ADMIN) return auth.normalize_role(user.get("role") or auth.ROLE_ADMIN)
@staticmethod
def _caps(user):
"""会话能力集合(管理员 → {"*"})。"""
return auth.caps(user)
def _can(self, user, cap):
return auth.can(user, cap)
def _guard(self): def _guard(self):
u = self._user() u = self._user()
if not u: if not u:
...@@ -155,12 +166,43 @@ class Handler(BaseHTTPRequestHandler): ...@@ -155,12 +166,43 @@ class Handler(BaseHTTPRequestHandler):
return None return None
return u return u
def _guard_write(self, user): @staticmethod
"""写操作闸门:只读访客一律拒绝(服务端硬拦截,不依赖前端隐藏按钮)。""" def _batch_kind(bid):
if self._role(user) == auth.ROLE_ADMIN: """批次的业务类型(读不到按电费处理)。"""
try:
b = store.load_batch(bid) or {}
except Exception: # noqa: BLE001
return "elec"
return str(b.get("kind") or "elec")
def _write_cap(self, method, path):
"""写接口 → 所需能力(auth.CAP_* 或 "*" 表示仅管理员)。
只有「电费通知单」页的写操作授予 auth.CAP_ELEC;
批次类接口再按批次 kind 复核一遍 —— 房租车位费的批次仍只有管理员能删 / 重出 / 发送。
"""
if method == "POST" and path in ("/api/batches", "/api/attachments"):
return auth.CAP_ELEC
m = re.match(r"^/api/attachments/([A-Za-z0-9]+)$", path)
if m and method == "DELETE":
return auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)/(rerender|send)$", path)
if m and method == "POST":
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)$", path)
if m and method == "DELETE":
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
return "*"
def _guard_write(self, user, cap="*"):
"""写操作闸门:管理员一律放行;访客只有命中该接口所需能力才放行。"""
if self._can(user, cap or "*"):
return True return True
self._send(403, {"ok": False, "readonly": True, who = (user or {}).get("username") or "访客"
"error": "只读访客账号:仅可浏览,无修改权限"}) owned = auth.cap_labels(sorted(self._caps(user) - {auth.CAP_ALL}))
msg = ("账号「%s」只能操作:%s" % (who, "、".join(owned))) if owned \
else "只读账号:仅可浏览,无修改权限"
self._send(403, {"ok": False, "readonly": True, "error": msg})
return False return False
_CTYPES = { _CTYPES = {
...@@ -235,9 +277,12 @@ class Handler(BaseHTTPRequestHandler): ...@@ -235,9 +277,12 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, {"ok": True, "app": "rentos", "version": "0.1.0"}) return self._send(200, {"ok": True, "app": "rentos", "version": "0.1.0"})
if path == "/api/session": if path == "/api/session":
u = self._user() u = self._user()
cs = sorted(self._caps(u) - {auth.CAP_ALL})
return self._send(200, {"ok": True, "logged_in": bool(u), return self._send(200, {"ok": True, "logged_in": bool(u),
"username": (u or {}).get("username", ""), "username": (u or {}).get("username", ""),
"role": self._role(u), "role": self._role(u),
"perms": cs,
"cap_labels": auth.cap_labels(cs),
"can_write": self._role(u) == auth.ROLE_ADMIN}) "can_write": self._role(u) == auth.ROLE_ADMIN})
# ---- 需登录 # ---- 需登录
...@@ -441,9 +486,11 @@ class Handler(BaseHTTPRequestHandler): ...@@ -441,9 +486,11 @@ class Handler(BaseHTTPRequestHandler):
if not acc: if not acc:
return self._send(401, {"ok": False, "error": "账号或密码不正确"}) return self._send(401, {"ok": False, "error": "账号或密码不正确"})
role = acc.get("role") or auth.ROLE_ADMIN role = acc.get("role") or auth.ROLE_ADMIN
token = auth.create_session(acc["username"], role) perms = acc.get("perms") or []
token = auth.create_session(acc["username"], role, perms=perms)
return self._send(200, {"ok": True, "token": token, return self._send(200, {"ok": True, "token": token,
"username": acc["username"], "role": role, "username": acc["username"], "role": role,
"perms": list(perms),
"can_write": role == auth.ROLE_ADMIN}) "can_write": role == auth.ROLE_ADMIN})
user = self._guard() user = self._guard()
...@@ -454,8 +501,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -454,8 +501,8 @@ class Handler(BaseHTTPRequestHandler):
auth.drop_session(self._token()) auth.drop_session(self._token())
return self._send(200, {"ok": True}) return self._send(200, {"ok": True})
# ---- 只读访客:除退出登录外,所有写操作一律拒绝 # ---- 写操作闸门:管理员放行;访客按其「能力」逐接口判定
if not self._guard_write(user): if not self._guard_write(user, self._write_cap(method, path)):
return return
if path == "/api/password" and method == "POST": if path == "/api/password" and method == "POST":
...@@ -470,10 +517,25 @@ class Handler(BaseHTTPRequestHandler): ...@@ -470,10 +517,25 @@ class Handler(BaseHTTPRequestHandler):
row = auth.add_account(str(body.get("username") or ""), row = auth.add_account(str(body.get("username") or ""),
str(body.get("password") or ""), str(body.get("password") or ""),
role=str(body.get("role") or auth.ROLE_VIEWER), role=str(body.get("role") or auth.ROLE_VIEWER),
note=body.get("note") or "") note=body.get("note") or "",
perms=body.get("perms"))
return self._send(200, {"ok": True,
"account": {"username": row["username"],
"role": row["role"],
"perms": row.get("perms") or [],
"note": row.get("note") or ""},
"accounts": auth.public_accounts()})
m = re.match(r"^/api/accounts/([^/]+)$", path)
if m and method in ("PUT", "POST"):
row = auth.set_account(m.group(1),
role=body.get("role"),
perms=body.get("perms"),
note=body.get("note"))
return self._send(200, {"ok": True, return self._send(200, {"ok": True,
"account": {"username": row["username"], "account": {"username": row["username"],
"role": row["role"], "role": row["role"],
"perms": row.get("perms") or [],
"note": row.get("note") or ""}, "note": row.get("note") or ""},
"accounts": auth.public_accounts()}) "accounts": auth.public_accounts()})
......
...@@ -578,6 +578,8 @@ def _month_of(s): ...@@ -578,6 +578,8 @@ def _month_of(s):
def _sync_records(batch): def _sync_records(batch):
if not isinstance(batch, dict): # 批次不存在 / 参数为空 → 当作没有记录
return []
rec = batch.get("receipt_sync") rec = batch.get("receipt_sync")
return rec if isinstance(rec, list) else [] return rec if isinstance(rec, list) else []
...@@ -641,7 +643,10 @@ def sync_receipt_month(batch, index, doc): ...@@ -641,7 +643,10 @@ def sync_receipt_month(batch, index, doc):
def rollback_receipt_sync(batch): def rollback_receipt_sync(batch):
"""把该批次登记收款时同步过的「最近收款月份」退回原值(删除批次时调用)。""" """把该批次登记收款时同步过的「最近收款月份」退回原值(删除批次时调用)。
批次不存在(已删 / 传空)时静默返回 0,不抛错。
"""
recs = _sync_records(batch) recs = _sync_records(batch)
if not recs: if not recs:
return {"reverted": 0, "skipped": 0} return {"reverted": 0, "skipped": 0}
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
账号 / 角色(只读访客)单元测试 账号 / 角色 / 能力(访客权限)单元测试
================================ ======================================
对应需求(2026-10-13 巴特菲): 对应需求:
「创建只能浏览不能修改的访客账户」 · 2026-10-13 巴特菲:「创建只能浏览不能修改的访客账户」
· 2026-10-13 巴特菲:「访客账号 guest / guest@2026,仅电费通知单加操作权限」
覆盖面: 覆盖面:
· 首次启动播种:admin(管理员)+ guest(只读访客) · 首次启动播种:admin(管理员)+ guest(访客,具备 elec 能力)
· 账密校验 / 角色归一化(未知角色一律按只读处理) · 账密校验 / 角色归一化(未知角色一律按只读处理)
· 账号增删改:主账号不可删、重名报错、密码长度校验、 · 能力模型:normalize_perms / caps / can —— 管理员隐含全部、脏输入被过滤
访客账号被删后不会自动复活(guest_seeded) · 账号增删改:主账号不可删/不可降级、重名报错、密码长度校验、
· 会话携带角色;老会话缺 role 按管理员处理(当时只有管理员能登录) 访客账号被删后不会自动复活(guest_seeded)、set_account 改角色与能力
· 会话携带角色与能力;老会话缺 role 按管理员处理(当时只有管理员能登录)
· 下发清单不含哈希 / 盐 · 下发清单不含哈希 / 盐
· **服务端写闸门**:真起一个 HTTP 服务,实测 guest 的所有写接口 403、 · **服务端写闸门**:真起一个 HTTP 服务,实测
读接口正常、退出登录可用;admin 同一接口能通过闸门(用不存在的批次验证) - 带 elec 能力的访客:电费通知单类写接口放行、其余一律 403
- 无能力的访客:所有写接口一律 403
- 房租车位费批次:即便有 elec 能力也不能删 / 重出 / 发送 / 登记收款
- 读接口与下载正常、退出登录可用
运行:python -m unittest discover -s tools -p "test_*.py" 运行:python -m unittest discover -s tools -p "test_*.py"
""" """
...@@ -47,9 +52,15 @@ class AccountCase(unittest.TestCase): ...@@ -47,9 +52,15 @@ class AccountCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="rentos-auth-") self.tmp = tempfile.mkdtemp(prefix="rentos-auth-")
self.addCleanup(lambda: shutil.rmtree(self.tmp, ignore_errors=True)) self.addCleanup(lambda: shutil.rmtree(self.tmp, ignore_errors=True))
batch_dir = os.path.join(self.tmp, "batches")
os.makedirs(batch_dir, exist_ok=True)
self.batch_dir = batch_dir
self._p = [ self._p = [
mock.patch.object(C, "CONFIG_FILE", os.path.join(self.tmp, "config.json")), mock.patch.object(C, "CONFIG_FILE", os.path.join(self.tmp, "config.json")),
mock.patch.object(C, "SESSION_FILE", os.path.join(self.tmp, "sessions.json")), mock.patch.object(C, "SESSION_FILE", os.path.join(self.tmp, "sessions.json")),
# 批次目录也换到临时目录:既方便造 kind=rent_parking 的样本,
# 也保证测试的删除动作绝不会碰到真实 data/batches/
mock.patch.object(C, "BATCH_DIR", batch_dir),
] ]
for p in self._p: for p in self._p:
p.start() p.start()
...@@ -65,6 +76,54 @@ class TestSeeding(AccountCase): ...@@ -65,6 +76,54 @@ class TestSeeding(AccountCase):
self.assertEqual(rows["admin"]["role"], auth.ROLE_ADMIN) self.assertEqual(rows["admin"]["role"], auth.ROLE_ADMIN)
self.assertEqual(rows["guest"]["role"], auth.ROLE_VIEWER) self.assertEqual(rows["guest"]["role"], auth.ROLE_VIEWER)
def test_guest_seeded_with_elec_capability(self):
"""访客默认开通「电费通知单」操作权限(其余仍只读)。"""
g = auth.find("guest")
self.assertEqual(g["perms"], [auth.CAP_ELEC])
self.assertTrue(auth.can(g, auth.CAP_ELEC))
self.assertFalse(auth.can(g, "*"))
def test_admin_has_no_perms_list_but_all_caps(self):
a = auth.find("admin")
self.assertEqual(a.get("perms") or [], [])
self.assertTrue(auth.can(a, auth.CAP_ELEC))
self.assertEqual(auth.caps(a), {auth.CAP_ALL})
def _write_legacy_config(self, guest_perms="__missing__"):
"""造一份「升级前」的配置:auth 里没有 perms 相关字段。"""
g = {"username": "guest", "salt": "s" * 16,
"password_hash": auth.make_hash("guest@2026", "s" * 16),
"role": "viewer", "note": "只读访客", "created_at": "2026-01-01 00:00:00"}
if guest_perms != "__missing__":
g["perms"] = guest_perms
cfg = {"auth": {
"username": "admin", "salt": "s" * 16,
"password_hash": auth.make_hash("1989@rent05", "s" * 16),
"accounts": [
{"username": "admin", "salt": "s" * 16,
"password_hash": auth.make_hash("1989@rent05", "s" * 16),
"role": "admin", "note": "系统管理员", "created_at": "2026-01-01 00:00:00"},
g,
],
"guest_seeded": True,
}}
with open(C.CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False)
def test_legacy_config_backfills_guest_perms_once(self):
"""老配置(没有 perms 字段)→ 首次 ensure 补上默认能力。"""
self._write_legacy_config()
self.assertEqual(auth.find("guest")["perms"], ["elec"])
# 管理员把它关掉之后,不会被再次打开
auth.set_account("guest", perms=[])
auth.ensure_default_account()
self.assertEqual(auth.find("guest")["perms"], [])
def test_legacy_config_keeps_explicit_empty_perms(self):
"""老配置里已明确写了 perms: [](有意只读)→ 不回填。"""
self._write_legacy_config(guest_perms=[])
self.assertEqual(auth.find("guest")["perms"], [])
def test_default_credentials_work(self): def test_default_credentials_work(self):
self.assertEqual(auth.authenticate("admin", "1989@rent05")["role"], self.assertEqual(auth.authenticate("admin", "1989@rent05")["role"],
auth.ROLE_ADMIN) auth.ROLE_ADMIN)
...@@ -112,6 +171,37 @@ class TestRole(unittest.TestCase): ...@@ -112,6 +171,37 @@ class TestRole(unittest.TestCase):
self.assertEqual(auth.normalize_role(bad), "viewer") self.assertEqual(auth.normalize_role(bad), "viewer")
# ================================================================ 2b. 能力模型
class TestCapability(unittest.TestCase):
def test_normalize_perms_filters_unknown(self):
self.assertEqual(auth.normalize_perms(["elec", "ELEC", "xx", None, ""], "viewer"),
["elec"])
self.assertEqual(auth.normalize_perms("elec, 未知 elec", "viewer"), ["elec"])
self.assertEqual(auth.normalize_perms(None, "viewer"), [])
self.assertEqual(auth.normalize_perms({"elec": 1}, "viewer"), []) # dict 不接受
def test_admin_perms_always_empty(self):
self.assertEqual(auth.normalize_perms(["elec"], "admin"), [])
def test_caps_of_various(self):
self.assertEqual(auth.caps(None), set())
self.assertEqual(auth.caps({"role": "viewer"}), set())
self.assertEqual(auth.caps({"role": "viewer", "perms": ["elec"]}), {"elec"})
self.assertEqual(auth.caps({"role": "admin"}), {"*"})
def test_can(self):
v = {"role": "viewer", "perms": ["elec"]}
self.assertTrue(auth.can(v, "elec"))
self.assertFalse(auth.can(v, "rent"))
self.assertFalse(auth.can(v, "*"))
self.assertTrue(auth.can({"role": "admin"}, "elec"))
self.assertTrue(auth.can({"role": "admin"}, "*"))
def test_cap_labels(self):
self.assertEqual(auth.cap_labels(["elec"]), ["电费通知单"])
self.assertEqual(auth.cap_labels([]), [])
# ================================================================ 3. 账号增删改 # ================================================================ 3. 账号增删改
class TestAccountCrud(AccountCase): class TestAccountCrud(AccountCase):
def test_add_viewer(self): def test_add_viewer(self):
...@@ -177,6 +267,59 @@ class TestAccountCrud(AccountCase): ...@@ -177,6 +267,59 @@ class TestAccountCrud(AccountCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
auth.set_password("whatever1", username="nobody") auth.set_password("whatever1", username="nobody")
# ---- 能力(perms)
def test_add_viewer_with_perms(self):
auth.add_account("op1", "op1pass1", note="电费录入", perms=["elec"])
row = auth.find("op1")
self.assertEqual(row["role"], auth.ROLE_VIEWER)
self.assertEqual(row["perms"], ["elec"])
self.assertTrue(auth.can(auth.authenticate("op1", "op1pass1"), "elec"))
def test_add_viewer_without_perms_is_pure_readonly(self):
auth.add_account("ro1", "ro1pass1")
self.assertEqual(auth.find("ro1")["perms"], [])
self.assertFalse(auth.can(auth.find("ro1"), "elec"))
def test_add_admin_ignores_perms(self):
auth.add_account("boss2", "boss2pass", role="admin", perms=["elec"])
self.assertEqual(auth.find("boss2")["perms"], [])
def test_set_account_toggles_perms(self):
auth.set_account("guest", perms=[])
self.assertEqual(auth.find("guest")["perms"], [])
auth.set_account("guest", perms=["elec"])
self.assertEqual(auth.find("guest")["perms"], ["elec"])
# 脏输入被过滤
auth.set_account("guest", perms=["elec", "越权", "rent"])
self.assertEqual(auth.find("guest")["perms"], ["elec"])
def test_set_account_admin_role_clears_perms(self):
auth.set_account("guest", role="admin")
row = auth.find("guest")
self.assertEqual(row["role"], "admin")
self.assertEqual(row["perms"], [])
def test_set_account_note_only_keeps_role_and_perms(self):
auth.set_account("guest", note="交给财务")
row = auth.find("guest")
self.assertEqual(row["note"], "交给财务")
self.assertEqual(row["role"], "viewer")
self.assertEqual(row["perms"], ["elec"])
def test_primary_admin_cannot_be_downgraded(self):
with self.assertRaises(ValueError):
auth.set_account("admin", role="viewer")
def test_set_account_unknown_raises(self):
with self.assertRaises(ValueError):
auth.set_account("nobody", perms=["elec"])
def test_public_accounts_expose_perms_not_secrets(self):
rows = {a["username"]: a for a in auth.public_accounts()}
self.assertEqual(rows["guest"]["perms"], ["elec"])
self.assertEqual(rows["guest"]["cap_labels"], ["电费通知单"])
self.assertEqual(rows["admin"]["perms"], [])
# ================================================================ 4. 会话 # ================================================================ 4. 会话
class TestSession(AccountCase): class TestSession(AccountCase):
...@@ -186,6 +329,18 @@ class TestSession(AccountCase): ...@@ -186,6 +329,18 @@ class TestSession(AccountCase):
self.assertEqual(s["username"], "guest") self.assertEqual(s["username"], "guest")
self.assertEqual(s["role"], "viewer") self.assertEqual(s["role"], "viewer")
def test_session_carries_perms(self):
tok = auth.create_session("guest", auth.ROLE_VIEWER, perms=["elec", "越权"])
s = auth.check_session(tok)
self.assertEqual(s["perms"], ["elec"])
self.assertTrue(auth.can(s, "elec"))
def test_admin_session_has_no_perms_but_all_caps(self):
tok = auth.create_session("admin", auth.ROLE_ADMIN, perms=["elec"])
s = auth.check_session(tok)
self.assertEqual(s["perms"], [])
self.assertTrue(auth.can(s, "elec"))
def test_legacy_session_without_role_is_admin(self): def test_legacy_session_without_role_is_admin(self):
"""老会话(本功能上线前创建的)没有 role —— 当时只有管理员能登录。""" """老会话(本功能上线前创建的)没有 role —— 当时只有管理员能登录。"""
tok = auth.create_session("admin", auth.ROLE_ADMIN) tok = auth.create_session("admin", auth.ROLE_ADMIN)
...@@ -250,19 +405,19 @@ class TestWriteGateOverHttp(AccountCase): ...@@ -250,19 +405,19 @@ class TestWriteGateOverHttp(AccountCase):
self.assertEqual(st, 200, d) self.assertEqual(st, 200, d)
return d["token"], d return d["token"], d
# ---- 写接口清单:guest 一律 403 # ---- 非电费类写接口:访客(即便带 elec 能力)一律 403
WRITES = [ ADMIN_ONLY_WRITES = [
("PUT", "/api/config", {"notify": {"enabled": True}}), ("PUT", "/api/config", {"notify": {"enabled": True}}),
("PUT", "/api/owners", {"owners": {}}), ("PUT", "/api/owners", {"owners": {}}),
("POST", "/api/batches", {"rows": [{"unit": "1"}]}),
("POST", "/api/rent/batches", {}), ("POST", "/api/rent/batches", {}),
("DELETE", "/api/batches/20260101-000000", {"rollback": True}), ("POST", "/api/batches/20260101-000000/receipt",
("POST", "/api/batches/20260101-000000/rerender", {}), {"index": 0, "paid_at": "2026-10-13"}),
("POST", "/api/batches/20260101-000000/receipt", {"index": 0, "paid_at": "2026-10-13"}),
("POST", "/api/batches/20260101-000000/send", {}),
("POST", "/api/scheduler/fire", {}), ("POST", "/api/scheduler/fire", {}),
("PUT", "/api/tables/rent_parking/cell", ("PUT", "/api/tables/rent_parking/cell",
{"row_id": "rent_parking-0001", "col": "AB", "value": "2026-10", "confirm": True}), {"row_id": "rent_parking-0001", "col": "AB", "value": "2026-10", "confirm": True}),
("PUT", "/api/tables/rent_parking/cells",
{"changes": [{"row_id": "rent_parking-0001", "col": "AB", "value": "x"}],
"confirm": True}),
("POST", "/api/tables/rent_parking/row", {"confirm": True}), ("POST", "/api/tables/rent_parking/row", {"confirm": True}),
("DELETE", "/api/tables/rent_parking/row", {"row_id": "x", "confirm": True}), ("DELETE", "/api/tables/rent_parking/row", {"row_id": "x", "confirm": True}),
("POST", "/api/tables/rent_parking/restore", {"file": "x.json"}), ("POST", "/api/tables/rent_parking/restore", {"file": "x.json"}),
...@@ -270,31 +425,110 @@ class TestWriteGateOverHttp(AccountCase): ...@@ -270,31 +425,110 @@ class TestWriteGateOverHttp(AccountCase):
("PUT", "/api/companies/X", {"name": "Y"}), ("PUT", "/api/companies/X", {"name": "Y"}),
("DELETE", "/api/companies/X", {}), ("DELETE", "/api/companies/X", {}),
("POST", "/api/owners/reset", {}), ("POST", "/api/owners/reset", {}),
("POST", "/api/attachments", {"data": "data:image/png;base64,AA"}),
("DELETE", "/api/attachments/abc", {}),
("POST", "/api/password", {"new_password": "hacked123"}), ("POST", "/api/password", {"new_password": "hacked123"}),
("POST", "/api/accounts", {"username": "hacker", "password": "hacker1"}), ("POST", "/api/accounts", {"username": "hacker", "password": "hacker1"}),
("PUT", "/api/accounts/guest", {"perms": []}),
("DELETE", "/api/accounts/guest", {}), ("DELETE", "/api/accounts/guest", {}),
("POST", "/api/accounts/guest/password", {"new_password": "hacked123"}), ("POST", "/api/accounts/guest/password", {"new_password": "hacked123"}),
] ]
def test_login_returns_role(self): # ---- 电费通知单类写接口:带 elec 能力的访客通过闸门(结果可能是业务错,但绝不是 403)
ELEC_WRITES = [
("POST", "/api/batches", {}), # 未录明细 → 400 参数校验
("POST", "/api/attachments", {"data": "data:image/png;base64,!!!"}), # 非法 base64 → 400
("DELETE", "/api/attachments/abc", {}),
("POST", "/api/batches/NO-SUCH/send", {}),
("POST", "/api/batches/NO-SUCH/rerender", {}),
("DELETE", "/api/batches/NO-SUCH", {}),
]
def _seed_batch(self, bid, kind):
"""在临时批次目录里放一份最小批次样本。"""
with open(os.path.join(self.batch_dir, bid + ".json"), "w", encoding="utf-8") as f:
json.dump({"batch_id": bid, "kind": kind, "documents": [],
"totals": {"tenant_count": 0, "total_amount": 0}}, f)
def test_login_returns_role_and_perms(self):
_, d = self.login("guest", "guest@2026") _, d = self.login("guest", "guest@2026")
self.assertEqual(d["role"], "viewer") self.assertEqual(d["role"], "viewer")
self.assertEqual(d["perms"], ["elec"])
self.assertFalse(d["can_write"]) self.assertFalse(d["can_write"])
def test_guest_cannot_write_anything(self): # ---- 带 elec 能力的访客:电费放行,其余拒绝
def test_elec_guest_passes_elec_writes(self):
tok, _ = self.login("guest", "guest@2026")
for method, path, body in self.ELEC_WRITES:
st, d = self.call(method, path, body, token=tok)
self.assertNotEqual(st, 403, "%s %s → %s %s" % (method, path, st, d))
self.assertLess(st, 500, "%s %s → %s %s" % (method, path, st, d))
def test_elec_guest_blocked_on_everything_else(self):
tok, _ = self.login("guest", "guest@2026") tok, _ = self.login("guest", "guest@2026")
for method, path, body in self.WRITES: for method, path, body in self.ADMIN_ONLY_WRITES:
st, d = self.call(method, path, body, token=tok) st, d = self.call(method, path, body, token=tok)
self.assertEqual(st, 403, "%s %s → %s %s" % (method, path, st, d)) self.assertEqual(st, 403, "%s %s → %s %s" % (method, path, st, d))
self.assertTrue(d.get("readonly"), "%s %s" % (method, path)) self.assertTrue(d.get("readonly"), "%s %s" % (method, path))
self.assertIn("电费通知单", d.get("error") or "") # 提示里说清能操作什么
def test_elec_batch_delete_allowed_for_elec_guest(self):
self._seed_batch("ELEC-1", "elec")
tok, _ = self.login("guest", "guest@2026")
st, d = self.call("DELETE", "/api/batches/ELEC-1", {}, token=tok)
self.assertEqual(st, 200, d)
self.assertTrue(d.get("ok"))
def test_rent_batch_writes_blocked_even_with_elec_cap(self):
"""房租车位费的批次:有 elec 能力也不许删 / 重出 / 发送 / 登记收款。"""
self._seed_batch("RENT-1", "rent_parking")
tok, _ = self.login("guest", "guest@2026")
for method, path, body in [
("DELETE", "/api/batches/RENT-1", {"rollback": True}),
("POST", "/api/batches/RENT-1/rerender", {}),
("POST", "/api/batches/RENT-1/send", {}),
("POST", "/api/batches/RENT-1/receipt", {"index": 0}),
]:
st, d = self.call(method, path, body, token=tok)
self.assertEqual(st, 403, "%s %s → %s %s" % (method, path, st, d))
# 样本必须原样保留(没有被真的删掉)
self.assertTrue(os.path.exists(os.path.join(self.batch_dir, "RENT-1.json")))
# ---- 无能力的访客:纯只读,所有写接口一律 403
def test_pure_readonly_viewer_blocked_everywhere(self):
tok, _ = self.login("admin", "1989@rent05")
st, _ = self.call("POST", "/api/accounts",
{"username": "ro", "password": "ropass12",
"role": "viewer"}, token=tok)
self.assertEqual(st, 200)
rtok, d = self.login("ro", "ropass12")
self.assertEqual(d["perms"], [])
for method, path, body in self.ADMIN_ONLY_WRITES + self.ELEC_WRITES:
st, _ = self.call(method, path, body, token=rtok)
self.assertEqual(st, 403, "%s %s → %s" % (method, path, st))
def test_delete_missing_batch_does_not_crash(self):
"""删除不存在的批次:正常返回 ok=False,而不是 500(回归 2026-10-13 的 None 崩溃)。"""
tok, _ = self.login("admin", "1989@rent05")
st, d = self.call("DELETE", "/api/batches/NO-SUCH", {}, token=tok)
self.assertEqual(st, 200, d)
self.assertFalse(d.get("ok"))
def test_admin_can_grant_and_revoke_perms(self):
tok, _ = self.login("admin", "1989@rent05")
st, d = self.call("PUT", "/api/accounts/guest", {"perms": []}, token=tok)
self.assertEqual(st, 200, d)
self.assertEqual(d["account"]["perms"], [])
self.assertEqual(auth.find("guest")["perms"], [])
st, d = self.call("PUT", "/api/accounts/guest", {"perms": ["elec"]}, token=tok)
self.assertEqual(st, 200, d)
self.assertEqual(auth.find("guest")["perms"], ["elec"])
def test_guest_can_read(self): def test_guest_can_read(self):
tok, _ = self.login("guest", "guest@2026") tok, _ = self.login("guest", "guest@2026")
st, d = self.call("GET", "/api/session", token=tok) st, d = self.call("GET", "/api/session", token=tok)
self.assertEqual(st, 200) self.assertEqual(st, 200)
self.assertEqual(d["role"], "viewer") self.assertEqual(d["role"], "viewer")
self.assertEqual(d["perms"], ["elec"])
self.assertEqual(d["cap_labels"], ["电费通知单"])
self.assertFalse(d["can_write"]) self.assertFalse(d["can_write"])
self.assertEqual(self.call("GET", "/api/batches", token=tok)[0], 200) self.assertEqual(self.call("GET", "/api/batches", token=tok)[0], 200)
# 账号清单属于管理入口,访客也读不到 # 账号清单属于管理入口,访客也读不到
...@@ -314,6 +548,7 @@ class TestWriteGateOverHttp(AccountCase): ...@@ -314,6 +548,7 @@ class TestWriteGateOverHttp(AccountCase):
def test_admin_passes_the_gate(self): def test_admin_passes_the_gate(self):
tok, d = self.login("admin", "1989@rent05") tok, d = self.login("admin", "1989@rent05")
self.assertEqual(d["role"], "admin") self.assertEqual(d["role"], "admin")
self.assertEqual(d["perms"], [])
self.assertTrue(d["can_write"]) self.assertTrue(d["can_write"])
# 用一个不存在的批次验证「闸门放行了」:报的是业务错(400),不是 403 # 用一个不存在的批次验证「闸门放行了」:报的是业务错(400),不是 403
st, _ = self.call("POST", "/api/batches/NO-SUCH/receipt", st, _ = self.call("POST", "/api/batches/NO-SUCH/receipt",
...@@ -321,21 +556,24 @@ class TestWriteGateOverHttp(AccountCase): ...@@ -321,21 +556,24 @@ class TestWriteGateOverHttp(AccountCase):
self.assertEqual(st, 400) self.assertEqual(st, 400)
def test_admin_can_manage_accounts(self): def test_admin_can_manage_accounts(self):
"""管理员能列出 / 停用访客账号(用临时配置,不动真实数据)。""" """管理员能列出 / 新增 / 删除访客账号(用临时配置,不动真实数据)。"""
tok, _ = self.login("admin", "1989@rent05") tok, _ = self.login("admin", "1989@rent05")
st, d = self.call("GET", "/api/accounts", token=tok) st, d = self.call("GET", "/api/accounts", token=tok)
self.assertEqual(st, 200) self.assertEqual(st, 200)
self.assertIn("guest", [a["username"] for a in d["accounts"]]) self.assertIn("guest", [a["username"] for a in d["accounts"]])
st, d = self.call("POST", "/api/accounts", st, d = self.call("POST", "/api/accounts",
{"username": "reader2", "password": "reader123"}, token=tok) {"username": "reader2", "password": "reader123",
"perms": ["elec"]}, token=tok)
self.assertEqual(st, 200) self.assertEqual(st, 200)
self.assertIn("reader2", [a["username"] for a in d["accounts"]]) row = [a for a in d["accounts"] if a["username"] == "reader2"][0]
self.assertEqual(row["perms"], ["elec"])
self.assertEqual(row["cap_labels"], ["电费通知单"])
st, d = self.call("DELETE", "/api/accounts/reader2", token=tok) st, d = self.call("DELETE", "/api/accounts/reader2", token=tok)
self.assertEqual(st, 200) self.assertEqual(st, 200)
self.assertNotIn("reader2", [a["username"] for a in d["accounts"]]) self.assertNotIn("reader2", [a["username"] for a in d["accounts"]])
def test_unauthenticated_is_401(self): def test_unauthenticated_is_401(self):
for method, path, body in self.WRITES[:6]: for method, path, body in (self.ADMIN_ONLY_WRITES + self.ELEC_WRITES)[:8]:
self.assertEqual(self.call(method, path, body)[0], 401, self.assertEqual(self.call(method, path, body)[0], 401,
"%s %s" % (method, path)) "%s %s" % (method, path))
......
# -*- coding: utf-8 -*-
"""
访客权限(仅电费通知单可操作)的端到端验证(2026-10-13)
======================================================
对应需求:「访客账号 guest,密码 guest@2026,仅电费通知单加操作权限」
做法:
* **自带沙箱**:把 data/ 复制到临时目录并改写路径常量后另起服务,真实 data/ 零改动。
* 真浏览器(Playwright/Chromium)分别以 guest / admin / 纯只读访客三种身份登录,
实测「哪些控件可见」「哪些操作真能做成」「哪些接口被 403 拦下」。
覆盖:
1. guest 登录后的会话能力(role=viewer、perms=['elec']、cap-elec 样式类、顶栏文案)
2. guest 界面可见性:电费页可操作;概览/房租/台账编辑/设置 的写入口被拿掉
3. guest **真能做**的事:录一行电费 → 生成通知书 → 新批次产生(操作权限确实生效)
4. guest 做不了的事:台账改格 / 房租出单 / 收款登记 / 公司 / 配置 / 账号 / 排期 → 403
5. 房租车位费批次即便有 elec 能力也不能删 / 重出 / 发送
6. 纯只读访客(无能力):电费页也不可操作,所有写接口 403
7. admin 对照:所有控件可见,房租出单按钮在位
运行:python tools/ui_verify10.py
"""
import io
import json
import os
import shutil
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from rentos import auth # noqa: E402
from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402
from rentos import tables # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8904"))
BASE = "http://127.0.0.1:%d" % PORT
OUT = os.path.join(ROOT, "output", "ui_shots")
OK, FAIL = [], []
def ck(name, cond, extra=""):
(OK if cond else FAIL).append(name)
print(" [%s] %s%s" % ("ok" if cond else "FAIL", name,
(" → %s" % extra) if extra != "" else ""))
def start_sandbox():
tmp = tempfile.mkdtemp(prefix="rentos-verify10-")
shutil.copytree(os.path.join(ROOT, "data"), os.path.join(tmp, "data"))
data = os.path.join(tmp, "data")
for name, val in {
"DATA_DIR": data,
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
"UNIT_MAP_FILE": os.path.join(data, "unit_map.json"),
"UPLOAD_DIR": os.path.join(tmp, "uploads"),
"OUTPUT_DIR": os.path.join(tmp, "output", "elec"),
"OUTPUT_RENT_DIR": os.path.join(tmp, "output", "rent"),
"TABLE_EXPORT_DIR": os.path.join(tmp, "output", "tables"),
"BATCH_EXPORT_DIR": os.path.join(tmp, "output", "exports"),
"LOG_DIR": os.path.join(tmp, "logs"),
}.items():
setattr(C, name, val)
C.ensure_dirs()
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, tmp
def pick_billable_unit():
"""从沙箱台账挑一个「房间 + 有租客 + 有出租方」的行,用来造电费明细。"""
t = tables.load_table(tables.RENT_KEY)
for r in (t or {}).get("rows") or []:
unit = str(r.get(tables.BILL_COLS["unit"]) or "").strip()
if not unit or "车位" in unit:
continue
tenant = str(r.get(tables.BILL_COLS["tenant"]) or "").strip()
if not tenant or "空置" in tenant:
continue
owner = str(r.get(tables.BILL_COLS["owner"]) or "").strip()
if not owner:
continue
return unit, tenant, owner
return None, None, None
def main():
os.makedirs(OUT, exist_ok=True)
srv, tmp = start_sandbox()
print("沙箱数据目录:%s" % tmp)
print("沙箱服务:%s(真实 data/ 未被触碰)" % BASE)
unit, tenant, owner = pick_billable_unit()
print("沙箱造数:用台账行 %s(租客 %s / 出租方 %s)造电费明细" % (unit, tenant, owner))
from playwright.sync_api import sync_playwright
errors = []
try:
with sync_playwright() as pw:
b = pw.chromium.launch()
p = b.new_page(viewport={"width": 1680, "height": 980})
p.on("console", lambda m: errors.append("console:" + m.text)
if (m.type == "error" and "favicon" not in m.text.lower()
and "Failed to load resource" not in m.text) else None)
p.on("pageerror", lambda e: errors.append("pageerror:" + str(e)))
def js(fn, *a):
return p.evaluate(fn, *a) if a else p.evaluate(fn)
def api_call(method, path, body=None):
return js("""async ([m, path, body]) => {
const t = localStorage.getItem('rentos_token');
const r = await fetch(path, {method: m,
headers: Object.assign({'Content-Type': 'application/json'},
t ? {Authorization: 'Bearer ' + t} : {}),
body: body === null ? undefined : JSON.stringify(body)});
let d = {}; try { d = await r.json(); } catch (e) {}
return [r.status, d];
}""", [method, path, body])
def disp(sel):
"""元素自身的 display(含被祖先的 display:none 影响时返回 'none')。"""
return js("""(s) => {
const el = document.querySelector(s);
if (!el) return 'missing';
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed'
&& el.offsetWidth === 0 && el.offsetHeight === 0) return 'none';
return getComputedStyle(el).display;
}""", sel)
def goto(h):
p.evaluate("(h) => { location.hash = '#/' + h; }", h)
p.wait_for_timeout(1100)
def login(u, pw_):
p.goto(BASE + "/login.html")
p.fill("#u", u)
p.fill("#p", pw_)
p.click("button[type=submit]")
p.wait_for_url("**/admin.html")
p.wait_for_function("() => location.hash.length > 2", timeout=20000)
p.wait_for_timeout(700)
# ======================================================== 1
print("\n1. guest 登录与会话能力")
login("guest", "guest@2026")
st, s = api_call("GET", "/api/session")
ck("/api/session 正常", st == 200 and s.get("logged_in"), str(st))
ck("role = viewer", s.get("role") == "viewer", str(s.get("role")))
ck("perms = ['elec']", s.get("perms") == ["elec"], str(s.get("perms")))
ck("cap_labels = ['电费通知单']", s.get("cap_labels") == ["电费通知单"],
str(s.get("cap_labels")))
ck("can_write = False(不是管理员)", s.get("can_write") is False, str(s.get("can_write")))
body_cls = js("() => document.body.className")
ck("body 带 cap-elec(电费控件放行)", "cap-elec" in body_cls, body_cls)
ck("body 带 role-viewer(管理员控件隐藏)", "role-viewer" in body_cls, body_cls)
chip = p.inner_text("#roChip .txt")
ck("顶栏标识写「访客 · 仅电费」", chip.strip() == "访客 · 仅电费", chip)
who = p.inner_text("#who")
ck("用户名后缀标出权限范围", "仅电费" in who, who)
# ======================================================== 2
print("\n2. guest 界面可见性:电费可操作,其余写入口消失")
goto("elec")
ck("电费页「录入本期电费」卡片可见", disp("#page-elec .card[data-cap=elec]") != "none",
str(disp("#page-elec .card[data-cap=elec]")))
ck("「+ 添加一行」可见", disp("#btn-e-row") != "none", str(disp("#btn-e-row")))
ck("「生成通知书」可见", disp("#btn-e-submit") != "none", str(disp("#btn-e-submit")))
ck("电费页录入控件不是 data-ro", js(
"() => !!document.querySelector('#page-elec .card[data-ro]')") is False,
"仍有 data-ro 卡片")
p.screenshot(path=os.path.join(OUT, "V10-1-guest-elec.png"), full_page=True)
goto("overview")
ck("概览页「立即触发一次自动发送」被隐藏", disp("#btn-fire") == "none",
str(disp("#btn-fire")))
goto("table/rent_parking")
ck("房租页「生成收费通知书」被隐藏", disp("#btn-rp-submit") == "none",
str(disp("#btn-rp-submit")))
ck("房租页「发送全部待发」被隐藏", disp("#rp-btn-send-all") == "none",
str(disp("#rp-btn-send-all")))
ck("台账编辑开关被隐藏(不能改台账)", disp("#tbLockWrap") == "none",
str(disp("#tbLockWrap")))
ck("台账「+ 新增一行」被隐藏", disp("#btn-tb-add") == "none", str(disp("#btn-tb-add")))
p.screenshot(path=os.path.join(OUT, "V10-2-guest-rent.png"), full_page=True)
goto("settings")
ck("设置页「+ 新增账号」被隐藏", disp("#btn-ac-add") == "none", str(disp("#btn-ac-add")))
ck("设置页「保存」企微配置被隐藏", disp("#btn-save-wecom") == "none",
str(disp("#btn-save-wecom")))
# ======================================================== 3
print("\n3. guest 真能做:录电费 → 生成通知书")
goto("elec")
before = api_call("GET", "/api/batches?kind=elec")[1]
n_before = len(before.get("batches") or [])
p.click("#btn-e-clear")
p.wait_for_timeout(400)
p.click("#btn-e-row")
p.wait_for_timeout(400)
row = '#eBody tr[data-i="0"] '
p.fill(row + '[data-k="unit"]', unit)
p.fill(row + '[data-k="meter"]', "照明")
p.fill(row + '[data-k="prev"]', "100")
p.fill(row + '[data-k="cur"]', "180")
p.fill(row + '[data-k="price"]', "1.2")
p.wait_for_timeout(500)
amt = p.eval_on_selector('#eBody tr[data-i="0"] td:nth-child(11)',
"e => e.innerText.trim()")
ck("界面已算出金额(用量 80 × 1.2)", "96" in amt, amt)
p.click("#btn-e-submit")
p.wait_for_timeout(2500)
res = p.inner_text("#uploadResult") if p.query_selector("#uploadResult") else ""
ck("guest 成功生成批次(前端提示出现)", "已生成" in res, res.replace("\n", " | ")[:110])
after = api_call("GET", "/api/batches?kind=elec")[1]
n_after = len(after.get("batches") or [])
ck("批次数量 +1(确实落盘)", n_after == n_before + 1,
"%d → %d" % (n_before, n_after))
bid = None
if n_after > n_before:
bid = (after["batches"][0] or {}).get("batch_id")
st, det = api_call("GET", "/api/batches/" + str(bid))
docs = ((det.get("batch") or {}).get("documents") or [])
ck("批次里生成了通知书", st == 200 and len(docs) >= 1,
"%s 份(%s)" % (len(docs), bid))
ck("通知书挂到台账里的租客 / 出租方",
bool(docs) and docs[0].get("company") == tenant
and docs[0].get("owner") == owner,
"%s / %s" % (docs[0].get("company") if docs else None,
docs[0].get("owner") if docs else None))
p.screenshot(path=os.path.join(OUT, "V10-3-guest-created.png"), full_page=True)
# ======================================================== 4
print("\n4. guest 做不了的事:非电费类写接口一律 403")
WRITES = [
("PUT", "/api/config", {"notify": {"enabled": True}}),
("POST", "/api/rent/batches", {}),
("POST", "/api/batches/NO-SUCH/receipt", {"index": 0}),
("POST", "/api/scheduler/fire", {}),
("PUT", "/api/tables/rent_parking/cells",
{"changes": [{"row_id": "x", "col": "AB", "value": "2026-10"}],
"confirm": True}),
("POST", "/api/tables/rent_parking/row", {"confirm": True}),
("DELETE", "/api/tables/rent_parking/row",
{"row_id": "x", "confirm": True}),
("POST", "/api/companies", {"name": "越权公司"}),
("DELETE", "/api/companies/越权公司", {}),
("POST", "/api/owners/reset", {}),
("POST", "/api/accounts", {"username": "hack", "password": "hack1234"}),
("PUT", "/api/accounts/guest", {"perms": []}),
("DELETE", "/api/accounts/guest", {}),
("POST", "/api/accounts/guest/password", {"new_password": "hacked123"}),
]
for method, path, body in WRITES:
st, d = api_call(method, path, body)
ck("%s %s → 403" % (method, path), st == 403, "%s %s" % (st, d.get("error", "")))
st, d = api_call("PUT", "/api/tables/rent_parking/cells",
{"changes": [], "confirm": True})
ck("403 提示里说明「只能操作电费通知单」",
"电费通知单" in (d.get("error") or ""), str(d.get("error")))
# 台账在页面上也不能改(真点一次单元格,不应进入编辑态)
goto("table/rent_parking")
p.wait_for_selector("#tbWrap table.edgrid", timeout=20000)
cell = '#tbWrap tbody tr td[data-col="AB"]'
if p.query_selector(cell):
p.click(cell)
p.wait_for_timeout(500)
ck("访客点台账单元格不进入编辑态", p.query_selector(cell + " input") is None)
else:
ck("访客点台账单元格不进入编辑态", True, "(该列未渲染,跳过)")
# 读操作仍然通畅
ck("仍可读批次列表", api_call("GET", "/api/batches")[0] == 200)
ck("仍可读台账", api_call("GET", "/api/tables/rent_parking")[0] == 200)
ck("仍可读概览", api_call("GET", "/api/overview")[0] == 200)
ck("仍可读租金出单预览", api_call("GET", "/api/rent/preview")[0] == 200)
ck("账号清单不可读(管理入口)", api_call("GET", "/api/accounts")[0] == 403)
# ======================================================== 5
print("\n5. 房租车位费批次:有 elec 能力也不能动")
rent_id = None
bl = api_call("GET", "/api/batches?kind=rent_parking")[1]
if bl.get("batches"):
rent_id = bl["batches"][0].get("batch_id")
if rent_id:
for method, path, body in [
("DELETE", "/api/batches/" + rent_id, {"rollback": True}),
("POST", "/api/batches/%s/rerender" % rent_id, {}),
("POST", "/api/batches/%s/send" % rent_id, {}),
("POST", "/api/batches/%s/receipt" % rent_id, {"index": 0}),
]:
st, d = api_call(method, path, body)
ck("%s 房租批次 → 403" % method, st == 403,
"%s %s" % (st, d.get("error", "")))
still = api_call("GET", "/api/batches/" + rent_id)[0]
ck("房租批次未被真的删掉 / 改动", still == 200, str(still))
else:
ck("沙箱里有房租批次可供对照", False, "没有 rent_parking 批次")
# 自己刚生成的**电费**批次可以删(证明闸门是按 kind 放行的)
if bid:
st, d = api_call("DELETE", "/api/batches/" + str(bid), {})
ck("电费批次本人可删(闸门按 kind 放行)", st == 200 and d.get("ok"),
"%s %s" % (st, d))
# ======================================================== 6
print("\n6. 纯只读访客(无能力):连电费页也不能操作")
p.evaluate("() => localStorage.removeItem('rentos_token')")
login("admin", "1989@rent05")
st, d = api_call("POST", "/api/accounts",
{"username": "ro_verify", "password": "roverify1",
"role": "viewer", "perms": []})
ck("管理员可新建纯只读访客", st == 200, "%s %s" % (st, d.get("error", "")))
ro = [a for a in (d.get("accounts") or []) if a["username"] == "ro_verify"]
ck("新访客 perms 为空", bool(ro) and ro[0]["perms"] == [],
str(ro[0]["perms"]) if ro else "(未找到)")
p.evaluate("() => localStorage.removeItem('rentos_token')")
login("ro_verify", "roverify1")
st, s = api_call("GET", "/api/session")
ck("只读访客 perms 为空", s.get("perms") == [], str(s.get("perms")))
cls = js("() => document.body.className")
ck("body 不带 cap-elec", "cap-elec" not in cls, cls)
goto("elec")
ck("只读访客看不到电费录入卡片",
disp("#page-elec .card[data-cap=elec]") == "none",
str(disp("#page-elec .card[data-cap=elec]")))
ck("只读访客看不到「生成通知书」", disp("#btn-e-submit") == "none",
str(disp("#btn-e-submit")))
st, _ = api_call("POST", "/api/batches", {"rows": []})
ck("只读访客连「新建电费批次」也 403", st == 403, str(st))
ck("提示文案回到「只读」", "只读" in (api_call(
"POST", "/api/batches", {"rows": []})[1].get("error") or ""),
str(api_call("POST", "/api/batches", {"rows": []})[1].get("error")))
p.screenshot(path=os.path.join(OUT, "V10-4-readonly.png"), full_page=True)
# ======================================================== 7
print("\n7. admin 对照:所有控件都在")
p.evaluate("() => localStorage.removeItem('rentos_token')")
login("admin", "1989@rent05")
cls = js("() => document.body.className")
# 管理员同样要带 cap-elec(否则电费页的 data-cap 控件会被门控藏起来),
# 但不能带 role-viewer(管理员控件必须全部可见)
ck("admin body 带 cap-elec", "cap-elec" in cls, cls)
ck("admin body 不带 role-viewer", "role-viewer" not in cls, cls)
st, s = api_call("GET", "/api/session")
ck("admin perms 为空但 can_write=True",
s.get("perms") == [] and s.get("can_write") is True, str(s))
goto("elec")
ck("admin 看得到电费录入卡片",
disp("#page-elec .card[data-cap=elec]") != "none",
str(disp("#page-elec .card[data-cap=elec]")))
goto("table/rent_parking")
ck("admin 看得到「生成收费通知书」", disp("#btn-rp-submit") != "none",
str(disp("#btn-rp-submit")))
ck("admin 看得到台账编辑开关", disp("#tbLockWrap") != "none",
str(disp("#tbLockWrap")))
goto("settings")
ck("admin 看得到「+ 新增账号」", disp("#btn-ac-add") != "none",
str(disp("#btn-ac-add")))
ac = api_call("GET", "/api/accounts")[1]
row = [a for a in (ac.get("accounts") or []) if a["username"] == "guest"]
ck("账号清单里 guest 显示为「电费通知单」权限",
bool(row) and row[0]["cap_labels"] == ["电费通知单"],
str(row[0]) if row else "")
p.screenshot(path=os.path.join(OUT, "V10-5-admin.png"), full_page=True)
ck("全程无 JS 报错", not errors, str(errors[:2]))
b.close()
except Exception as e: # noqa: BLE001
import traceback
traceback.print_exc()
FAIL.append("脚本异常:%s" % e)
finally:
srv.shutdown()
srv.server_close()
print("\n沙箱保留在:%s(确认无误后可删)" % tmp)
print("\n===== 通过 %d / 失败 %d =====" % (len(OK), len(FAIL)))
for f in FAIL:
print(" FAIL:", f)
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())
...@@ -28,6 +28,7 @@ sys.path.insert(0, ROOT) ...@@ -28,6 +28,7 @@ sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*" os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*" os.environ["NO_PROXY"] = "*"
from rentos import auth # noqa: E402
from rentos import config as C # noqa: E402 from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402 from rentos import server as S # noqa: E402
...@@ -68,6 +69,11 @@ def start_sandbox(): ...@@ -68,6 +69,11 @@ def start_sandbox():
}.items(): }.items():
setattr(C, name, val) setattr(C, name, val)
C.ensure_dirs() C.ensure_dirs()
# 造一个「纯只读访客」(无任何能力):guest 自 2026-10-13 起改成
# 「电费通知单操作员」,不再适合当只读样本。
if not auth.find("ro_verify"):
auth.add_account("ro_verify", "roverify1", role=auth.ROLE_VIEWER,
note="只读访客(回归样本)", perms=[])
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler) srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start() threading.Thread(target=srv.serve_forever, daemon=True).start()
...@@ -123,9 +129,9 @@ def main(): ...@@ -123,9 +129,9 @@ def main():
tb = d.get("table") or d tb = d.get("table") or d
return {r["_id"]: r.get("AB") for r in (tb.get("rows") or [])} return {r["_id"]: r.get("AB") for r in (tb.get("rows") or [])}
# ============================================ A. 只读访客 # ============================================ A. 只读访客(无任何能力)
print("\nA. 只读访客 guest / guest@2026") print("\nA. 只读访客 ro_verify / roverify1(无任何能力)")
login("guest", "guest@2026") login("ro_verify", "roverify1")
ck("登录后进入后台", "/admin.html" in p.url) ck("登录后进入后台", "/admin.html" in p.url)
ck("body 带 role-viewer 标记", ck("body 带 role-viewer 标记",
js("() => document.body.classList.contains('role-viewer')")) js("() => document.body.classList.contains('role-viewer')"))
...@@ -154,7 +160,8 @@ def main(): ...@@ -154,7 +160,8 @@ def main():
p.wait_for_selector("#tbWrap", timeout=20000) p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1200) p.wait_for_timeout(1200)
ck("台账「编辑模式」开关被禁用", p.eval_on_selector("#tbEdit", "e=>e.disabled")) ck("台账「编辑模式」开关被禁用", p.eval_on_selector("#tbEdit", "e=>e.disabled"))
ck("台账锁定条提示只读访客", "只读访客" in p.inner_text("#tbLockBar")) ck("台账锁定条提示访客不可修改", "访客账号" in p.inner_text("#tbLockBar"),
p.inner_text("#tbLockBar").replace("\n", " ")[:80])
ck("台账「新增一行」已隐藏", not p.is_visible("#btn-tb-add")) ck("台账「新增一行」已隐藏", not p.is_visible("#btn-tb-add"))
ck("台账「还原备份」已隐藏", not p.is_visible("#btn-tb-restore")) ck("台账「还原备份」已隐藏", not p.is_visible("#btn-tb-restore"))
ck("台账仍可导出 xlsx", p.is_visible("#btn-tb-export")) ck("台账仍可导出 xlsx", p.is_visible("#btn-tb-export"))
...@@ -226,6 +233,47 @@ def main(): ...@@ -226,6 +233,47 @@ def main():
p.click("#btn-logout") p.click("#btn-logout")
p.wait_for_url("**/login.html") p.wait_for_url("**/login.html")
# ============================================ A3. guest:仅电费通知单可操作
print("\nA3. guest / guest@2026(电费通知单操作员)")
login("guest", "guest@2026")
st, s = api_call("GET", "/api/session")
ck("guest 的 perms = ['elec']", s.get("perms") == ["elec"], str(s.get("perms")))
ck("guest body 带 cap-elec",
js("() => document.body.classList.contains('cap-elec')"))
p.goto(BASE + "/admin.html#/elec")
p.wait_for_selector("#batchList", timeout=20000)
p.wait_for_timeout(900)
ck("guest 看得到「录入本期电费」表单", p.is_visible("#btn-e-submit"))
_st, _bl = api_call("GET", "/api/batches?kind=elec")
_elec = (_bl.get("batches") or [])
if _elec:
ck("guest 看得到电费批次的「删除」按钮",
p.eval_on_selector_all("#batchList tbody tr button[data-del]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
js("(id) => openBatch(id, 'elec')", _elec[0]["batch_id"])
p.wait_for_selector("#detailBody table", timeout=20000)
p.wait_for_timeout(700)
ck("guest 看得到「发送全部待发」", p.is_visible("#btn-send-all"))
ck("guest 看得到单据行的「发送」按钮",
p.eval_on_selector_all("#detailBody tbody tr button[data-send]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
else:
ck("guest 看得到电费批次的「删除」按钮", True, "(沙箱无电费批次,跳过)")
ck("guest 看得到「发送全部待发」", True, "(沙箱无电费批次,跳过)")
ck("guest 看得到单据行的「发送」按钮", True, "(沙箱无电费批次,跳过)")
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1000)
ck("guest 仍看不到房租「生成收费通知书」", not p.is_visible("#btn-rp-submit"))
ck("guest 仍看不到台账编辑开关", not p.is_visible("#tbEdit"))
st, _ = api_call("POST", "/api/rent/batches", {"row_ids": []})
ck("guest 房租出单接口 → 403", st == 403, str(st))
st, _ = api_call("POST", "/api/batches", {"rows": []})
ck("guest 电费出单接口通过闸门(不是 403)", st != 403, str(st))
p.click("#btn-logout")
p.wait_for_url("**/login.html")
# ============================================ B. 管理员 # ============================================ B. 管理员
print("\nB. 管理员 admin") print("\nB. 管理员 admin")
before = {} before = {}
...@@ -244,7 +292,8 @@ def main(): ...@@ -244,7 +292,8 @@ def main():
acc_txt = p.inner_text("#acList") acc_txt = p.inner_text("#acList")
ck("账号清单里有 admin 与 guest", ck("账号清单里有 admin 与 guest",
("admin" in acc_txt) and ("guest" in acc_txt), acc_txt.replace("\n", " ")[:140]) ("admin" in acc_txt) and ("guest" in acc_txt), acc_txt.replace("\n", " ")[:140])
ck("guest 标为只读访客", "只读访客" in acc_txt, acc_txt.replace("\n", " ")[:140]) ck("guest 标为「电费通知单」权限", "电费通知单" in acc_txt,
acc_txt.replace("\n", " ")[:160])
ck("主账号不可删除(无删除按钮)", ck("主账号不可删除(无删除按钮)",
p.eval_on_selector_all('#acList tr', p.eval_on_selector_all('#acList tr',
"rows=>rows.filter(r=>/admin/.test(r.innerText))" "rows=>rows.filter(r=>/admin/.test(r.innerText))"
......
...@@ -85,7 +85,6 @@ ...@@ -85,7 +85,6 @@
<b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> —— <b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> ——
出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。 出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。
</div> </div>
<!-- ==================== 概览 ==================== --> <!-- ==================== 概览 ==================== -->
<section class="page active" id="page-overview"> <section class="page active" id="page-overview">
<div id="ovBanner"></div> <div id="ovBanner"></div>
...@@ -123,7 +122,7 @@ ...@@ -123,7 +122,7 @@
<!-- ==================== 电费通知单 ==================== --> <!-- ==================== 电费通知单 ==================== -->
<section class="page" id="page-elec"> <section class="page" id="page-elec">
<div class="card" data-ro> <div class="card" data-cap="elec">
<h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2> <h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2>
<div class="desc"> <div class="desc">
计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>; 计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>;
...@@ -187,8 +186,8 @@ ...@@ -187,8 +186,8 @@
<div class="card" id="detailCard" style="display:none"> <div class="card" id="detailCard" style="display:none">
<h2>批次详情 <span class="hint" id="detailTitle"></span></h2> <h2>批次详情 <span class="hint" id="detailTitle"></span></h2>
<div class="row" style="margin-bottom:12px"> <div class="row" style="margin-bottom:12px">
<div style="flex:0 0 auto"><button id="btn-send-all" class="primary" data-ro>发送全部待发</button></div> <div style="flex:0 0 auto"><button id="btn-send-all" class="primary" data-cap="elec">发送全部待发</button></div>
<div style="flex:0 0 auto"><button id="btn-rerender" data-ro>按最新配置重出单据</button></div> <div style="flex:0 0 auto"><button id="btn-rerender" data-cap="elec">按最新配置重出单据</button></div>
<div style="flex:0 0 auto"><a id="link-zip"><button>打包下载(zip)</button></a></div> <div style="flex:0 0 auto"><a id="link-zip"><button>打包下载(zip)</button></a></div>
<div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div> <div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div>
</div> </div>
...@@ -452,22 +451,26 @@ ...@@ -452,22 +451,26 @@
</div> </div>
<div class="card" data-ro> <div class="card" data-ro>
<h2>账号管理 <span class="hint">管理员可读写;访客只能浏览</span></h2> <h2>账号管理 <span class="hint">管理员可读写;访客按「权限」逐项授权</span></h2>
<div class="desc"> <div class="desc">
<b>角色</b>:<b>管理员</b>可出单、发送、改台账与配置;<b>只读访客</b>只能浏览, <b>角色</b>:<b>管理员</b>可出单、发送、改台账与配置;<b>访客</b>只能浏览,另可按下面的
所有写接口在服务端一律返回 403(前端隐藏按钮只是为了不误导)。 <b>权限</b>勾选开放个别页面(目前支持「电费通知单」:录入出单 / 发送 / 重出单据 / 删批次)。
访客账号(<span class="mono-sm">guest</span>)用于给财务 / 同事查看台账与批次,密码可随时在此重置。 未勾选的页面,其写接口在服务端一律返回 403(前端隐藏按钮只是为了不误导)。
访客账号(<span class="mono-sm">guest</span>)默认已开通「电费通知单」权限,密码可随时在此重置。
</div> </div>
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end">
<div style="flex:0 0 160px"><label>账号 *</label><input type="text" id="ac-name" placeholder="如 guest2"></div> <div style="flex:0 0 150px"><label>账号 *</label><input type="text" id="ac-name" placeholder="如 guest2"></div>
<div style="flex:0 0 180px"><label>密码(至少 6 位)*</label> <div style="flex:0 0 170px"><label>密码(至少 6 位)*</label>
<input type="text" id="ac-pwd" placeholder="初始密码"></div> <input type="text" id="ac-pwd" placeholder="初始密码"></div>
<div style="flex:0 0 150px"><label>角色</label> <div style="flex:0 0 130px"><label>角色</label>
<select id="ac-role"> <select id="ac-role">
<option value="viewer">只读访客</option> <option value="viewer">访客</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
</select></div> </select></div>
<div style="flex:1 1 180px"><label>备注(可选)</label><input type="text" id="ac-note" placeholder="给谁用 / 用途"></div> <div style="flex:0 0 130px"><label>权限(仅访客)</label>
<label class="chk" style="margin-top:8px" title="允许在该页面做录入 / 出单 / 发送等操作">
<input type="checkbox" id="ac-perm-elec"> 电费通知单</label></div>
<div style="flex:1 1 140px"><label>备注(可选)</label><input type="text" id="ac-note" placeholder="给谁用 / 用途"></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-ac-add">+ 新增账号</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-ac-add">+ 新增账号</button></div>
</div> </div>
<div id="acMsg"></div> <div id="acMsg"></div>
......
...@@ -7,11 +7,27 @@ let CFG = null; // 当前配置 ...@@ -7,11 +7,27 @@ let CFG = null; // 当前配置
let DIR = null; // 公司目录快照(租客名册 + 出租方 · 汇款公司名单) let DIR = null; // 公司目录快照(租客名册 + 出租方 · 汇款公司名单)
let BATCH = null; // 当前打开的批次 let BATCH = null; // 当前打开的批次
let SESSIONS = []; // 企微可发送会话 let SESSIONS = []; // 企微可发送会话
let ROLE = 'admin'; // 当前角色:admin 可读写 | viewer 只读访客 let ROLE = 'admin'; // 当前角色:admin 可读写 | viewer 访客(写权限看 PERMS)
let PERMS = []; // 访客能力清单,如 ['elec'] = 只放开「电费通知单」页的写操作
/* ---------------- 基础 ---------------- */ /* ---------------- 基础 ---------------- */
function token() { return localStorage.getItem(TOKEN_KEY) || ''; } function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
function isViewer() { return ROLE === 'viewer'; } function isViewer() { return ROLE === 'viewer'; }
/** 是否具备某项页面能力(管理员一律具备)。 */
function hasCap(cap) { return !isViewer() || PERMS.indexOf(cap) >= 0; }
/** 访客写接口白名单(与服务端 _write_cap 对齐;服务端仍会复核)。 */
function visitorMayWrite(method, path) {
if (method === 'GET' || !isViewer()) return true;
if (path === '/api/logout') return true;
if (!hasCap('elec')) return false;
if (path === '/api/batches' || path === '/api/attachments') return true;
if (/^\/api\/attachments\/[A-Za-z0-9]+$/.test(path)) return true;
if (/^\/api\/batches\/[^/]+\/(rerender|send)$/.test(path)) return true;
// 删批次:仅电费批次(房租批次由服务端按 kind 拦下)
if (/^\/api\/batches\/[^/]+$/.test(path)) return true;
return false;
}
function toast(msg, kind) { function toast(msg, kind) {
const t = $('#toast'); const t = $('#toast');
...@@ -24,10 +40,11 @@ function toast(msg, kind) { ...@@ -24,10 +40,11 @@ function toast(msg, kind) {
async function api(path, opts = {}) { async function api(path, opts = {}) {
const o = Object.assign({ headers: {} }, opts); const o = Object.assign({ headers: {} }, opts);
const method = String(o.method || 'GET').toUpperCase(); const method = String(o.method || 'GET').toUpperCase();
// 只读访客:前端先拦一道(服务端另有 403 硬拦截,这里只是给个清楚提示) // 访客:前端先按「能力」拦一道(服务端另有 403 硬拦截,这里只是给个清楚提示)
if (isViewer() && method !== 'GET' && !path.startsWith('/api/logout')) { if (!visitorMayWrite(method, path)) {
toast('只读访客账号:无修改权限', 'err'); const msg = hasCap('elec') ? '当前账号只能操作「电费通知单」' : '只读账号:无修改权限';
throw new Error('只读访客账号:无修改权限'); toast(msg, 'err');
throw new Error(msg);
} }
o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers); o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers);
if (token()) o.headers['Authorization'] = 'Bearer ' + token(); if (token()) o.headers['Authorization'] = 'Bearer ' + token();
...@@ -878,7 +895,7 @@ async function loadBatches(kind) { ...@@ -878,7 +895,7 @@ async function loadBatches(kind) {
renderMonths(d.months || [], C0.months); renderMonths(d.months || [], C0.months);
const box = $(C0.list); const box = $(C0.list);
box.innerHTML = d.batches.length box.innerHTML = d.batches.length
? renderBatchTable(d.batches, true) ? renderBatchTable(d.batches, true, C0.kind)
: `<div class="empty">${C0.emptyList}</div>`; : `<div class="empty">${C0.emptyList}</div>`;
box.querySelectorAll('[data-open]').forEach(el => box.querySelectorAll('[data-open]').forEach(el =>
el.addEventListener('click', () => openBatch(el.dataset.open, C0.kind))); el.addEventListener('click', () => openBatch(el.dataset.open, C0.kind)));
...@@ -957,7 +974,9 @@ function renderMonths(months, sel) { ...@@ -957,7 +974,9 @@ function renderMonths(months, sel) {
</tbody></table></div>`; </tbody></table></div>`;
} }
function renderBatchTable(list, withAction) { function renderBatchTable(list, withAction, kind) {
const elec = (kind || 'elec') !== 'rent_parking';
const capAttr = elec ? ' data-cap="elec"' : ' data-ro';
return `<div class="table-wrap"><table> return `<div class="table-wrap"><table>
<thead><tr> <thead><tr>
<th>账单</th><th>批次</th><th>账期(月)</th><th class="num">单元</th> <th>账单</th><th>批次</th><th>账期(月)</th><th class="num">单元</th>
...@@ -975,7 +994,7 @@ function renderBatchTable(list, withAction) { ...@@ -975,7 +994,7 @@ function renderBatchTable(list, withAction) {
<td class="mono-sm">${esc(b.notice_date || '')}</td> <td class="mono-sm">${esc(b.notice_date || '')}</td>
<td>${stateTag(b.send_state)}</td> <td>${stateTag(b.send_state)}</td>
${withAction ? `<td><button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button> ${withAction ? `<td><button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button>
<button class="sm danger" data-del="${esc(b.batch_id)}" data-ro>删除</button></td>` : ''} <button class="sm danger" data-del="${esc(b.batch_id)}"${capAttr}>删除</button></td>` : ''}
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`; </tbody></table></div>`;
} }
...@@ -1031,9 +1050,9 @@ function docRowsHtml(docs) { ...@@ -1031,9 +1050,9 @@ function docRowsHtml(docs) {
${(d.rentfree || []).length ? `<br><span class="tag skip">免租期 ${esc((d.rentfree || []).map(r => r.unit).join('、'))}</span>` : ''} ${(d.rentfree || []).length ? `<br><span class="tag skip">免租期 ${esc((d.rentfree || []).map(r => r.unit).join('、'))}</span>` : ''}
${d.send_error ? `<br><span class="muted mono-sm">${esc(d.send_error)}</span>` : ''}</td> ${d.send_error ? `<br><span class="muted mono-sm">${esc(d.send_error)}</span>` : ''}</td>
<td> <td>
<button class="sm primary" data-send="${i}" data-ro>发送</button> <button class="sm primary" data-send="${i}"${rent ? ' data-ro' : ' data-cap="elec"'}>发送</button>
<button class="sm" data-dl="${i}">下载</button> <button class="sm" data-dl="${i}">下载</button>
${d.send_state === '已跳过' ? `<button class="sm" data-force="${i}" data-ro title="忽略免租期规则,强制发送">强制发送</button>` : ''} ${d.send_state === '已跳过' ? `<button class="sm" data-force="${i}"${rent ? ' data-ro' : ' data-cap="elec"'} title="忽略免租期规则,强制发送">强制发送</button>` : ''}
</td> </td>
</tr>`).join(''); </tr>`).join('');
} }
...@@ -1522,7 +1541,15 @@ async function loadAccounts() { ...@@ -1522,7 +1541,15 @@ async function loadAccounts() {
function roleTag(r) { function roleTag(r) {
return r === 'admin' ? '<span class="tag warn">管理员</span>' return r === 'admin' ? '<span class="tag warn">管理员</span>'
: '<span class="tag info">只读访客</span>'; : '<span class="tag info">访客</span>';
}
/** 权限列:管理员 = 全部;访客按能力清单显示,空 = 只读。 */
function capTags(a) {
if (a.role === 'admin') return '<span class="tag gray">全部</span>';
const labels = a.cap_labels || [];
if (!labels.length) return '<span class="tag gray">只读(不能操作)</span>';
return labels.map(x => `<span class="tag ok">${esc(x)}</span>`).join(' ');
} }
function renderAccounts() { function renderAccounts() {
...@@ -1530,28 +1557,51 @@ function renderAccounts() { ...@@ -1530,28 +1557,51 @@ function renderAccounts() {
if (!box) return; if (!box) return;
if (!ACCOUNTS.length) { box.innerHTML = '<div class="empty">没有账号</div>'; return; } if (!ACCOUNTS.length) { box.innerHTML = '<div class="empty">没有账号</div>'; return; }
box.innerHTML = `<div class="table-wrap"><table> box.innerHTML = `<div class="table-wrap"><table>
<thead><tr><th>账号</th><th style="width:100px">角色</th><th>备注</th> <thead><tr><th>账号</th><th style="width:90px">角色</th><th style="width:190px">权限</th>
<th style="width:160px">创建时间</th><th style="width:230px"></th></tr></thead> <th>备注</th><th style="width:160px">创建时间</th><th style="width:280px"></th></tr></thead>
<tbody>${ACCOUNTS.map(a => ` <tbody>${ACCOUNTS.map(a => `
<tr> <tr>
<td class="mono-sm">${esc(a.username)}${a.primary <td class="mono-sm">${esc(a.username)}${a.primary
? ' <span class="tag info">主账号</span>' : ''}</td> ? ' <span class="tag info">主账号</span>' : ''}</td>
<td>${roleTag(a.role)}</td> <td>${roleTag(a.role)}</td>
<td>${capTags(a)}</td>
<td class="muted">${esc(a.note || '')}</td> <td class="muted">${esc(a.note || '')}</td>
<td class="mono-sm muted">${esc(a.created_at || '')}</td> <td class="mono-sm muted">${esc(a.created_at || '')}</td>
<td> <td>
${a.role === 'admin' ? ''
: `<button class="sm" data-ac-perm="${esc(a.username)}"
data-var="${(a.perms || []).indexOf('elec') >= 0 ? 'off' : 'on'}"
title="开通 / 关闭「电费通知单」操作权限">${
(a.perms || []).indexOf('elec') >= 0 ? '关闭电费权限' : '开通电费权限'}</button>`}
<button class="sm" data-ac-pwd="${esc(a.username)}">重置密码</button> <button class="sm" data-ac-pwd="${esc(a.username)}">重置密码</button>
${a.primary ? '' ${a.primary ? ''
: `<button class="sm danger" data-ac-del="${esc(a.username)}">删除</button>`} : `<button class="sm danger" data-ac-del="${esc(a.username)}">删除</button>`}
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`; </tbody></table></div>`;
$$('[data-ac-perm]').forEach(b =>
b.addEventListener('click', () => toggleAccountPerm(b.dataset.acPerm, b.dataset.var)));
$$('[data-ac-pwd]').forEach(b => $$('[data-ac-pwd]').forEach(b =>
b.addEventListener('click', () => resetAccountPwd(b.dataset.acPwd))); b.addEventListener('click', () => resetAccountPwd(b.dataset.acPwd)));
$$('[data-ac-del]').forEach(b => $$('[data-ac-del]').forEach(b =>
b.addEventListener('click', () => delAccount(b.dataset.acDel))); b.addEventListener('click', () => delAccount(b.dataset.acDel)));
} }
/** 开通 / 关闭某账号的「电费通知单」操作权限。 */
async function toggleAccountPerm(name, dir) {
const on = dir === 'on';
try {
const d = await api(`/api/accounts/${encodeURIComponent(name)}`, {
method: 'PUT', body: { perms: on ? ['elec'] : [] },
});
ACCOUNTS = d.accounts || [];
renderAccounts();
acMsg(on ? `已为「${esc(name)}」开通「电费通知单」操作权限。`
: `已关闭「${esc(name)}」的操作权限(回到只读)。`, 'ok');
toast(on ? '已开通电费权限' : '已关闭', 'ok');
} catch (e) { acMsg(esc(e.message), 'err'); }
}
function acMsg(html, kind) { function acMsg(html, kind) {
const el = $('#acMsg'); const el = $('#acMsg');
if (el) el.innerHTML = `<div class="banner ${kind || ''}" style="margin-top:12px">${html}</div>`; if (el) el.innerHTML = `<div class="banner ${kind || ''}" style="margin-top:12px">${html}</div>`;
...@@ -1590,22 +1640,39 @@ $('#btn-ac-add').addEventListener('click', async () => { ...@@ -1590,22 +1640,39 @@ $('#btn-ac-add').addEventListener('click', async () => {
const name = $('#ac-name').value.trim(); const name = $('#ac-name').value.trim();
const pwd = $('#ac-pwd').value; const pwd = $('#ac-pwd').value;
const role = $('#ac-role').value; const role = $('#ac-role').value;
const permBox = $('#ac-perm-elec');
const perms = (role === 'viewer' && permBox && permBox.checked) ? ['elec'] : [];
if (!name) return acMsg('请填写账号。', 'err'); if (!name) return acMsg('请填写账号。', 'err');
if (pwd.length < 6) return acMsg('密码至少 6 位。', 'err'); if (pwd.length < 6) return acMsg('密码至少 6 位。', 'err');
try { try {
const d = await api('/api/accounts', { const d = await api('/api/accounts', {
method: 'POST', method: 'POST',
body: { username: name, password: pwd, role, body: { username: name, password: pwd, role, perms,
note: $('#ac-note').value.trim() }, note: $('#ac-note').value.trim() },
}); });
ACCOUNTS = d.accounts || []; ACCOUNTS = d.accounts || [];
renderAccounts(); renderAccounts();
$('#ac-name').value = ''; $('#ac-pwd').value = ''; $('#ac-note').value = ''; $('#ac-name').value = ''; $('#ac-pwd').value = ''; $('#ac-note').value = '';
acMsg(`已新增${role === 'admin' ? '管理员' : '只读访客'}「${esc(name)}」。`, 'ok'); if (permBox) permBox.checked = false;
const what = role === 'admin' ? '管理员'
: (perms.length ? '访客(可操作电费通知单)' : '访客(只读)');
acMsg(`已新增${what}「${esc(name)}」。`, 'ok');
toast('账号已新增', 'ok'); toast('账号已新增', 'ok');
} catch (e) { acMsg(esc(e.message), 'err'); } } catch (e) { acMsg(esc(e.message), 'err'); }
}); });
/** 角色选「管理员」时,「权限」勾选无意义 → 置灰并取消。 */
document.addEventListener('change', e => {
if (e.target && e.target.id === 'ac-role') {
const box = $('#ac-perm-elec');
const isAdmin = e.target.value === 'admin';
if (box) {
if (isAdmin) box.checked = false;
box.disabled = isAdmin;
}
}
});
let PLANS = []; // 两张账单的计划(含运行状态) let PLANS = []; // 两张账单的计划(含运行状态)
function renderPlans(list) { function renderPlans(list) {
...@@ -2336,8 +2403,8 @@ function renderLockBar() { ...@@ -2336,8 +2403,8 @@ function renderLockBar() {
$('#tbEdit').checked = TB_EDIT; $('#tbEdit').checked = TB_EDIT;
if (isViewer()) { if (isViewer()) {
$('#tbLockBar').innerHTML = `<div class="lockbar"><i class="dot"></i> $('#tbLockBar').innerHTML = `<div class="lockbar"><i class="dot"></i>
<span><b>只读访客账号</b>:台账可以浏览与导出,但不能修改。 <span><b>访客账号</b>:台账可以浏览与导出,但不能修改。
登记收款 / 出单 / 改单元格等写操作在服务端一律被拒绝。</span></div>`; 台账编辑 / 房租出单等写操作在服务端一律被拒绝。</span></div>`;
$('#btn-tb-add').disabled = true; $('#btn-tb-add').disabled = true;
renderPendBar(); renderPendBar();
updateUndoBtn(); updateUndoBtn();
...@@ -2675,7 +2742,7 @@ $('#tbEdit').addEventListener('change', e => { ...@@ -2675,7 +2742,7 @@ $('#tbEdit').addEventListener('change', e => {
e.target.checked = false; e.target.checked = false;
TB_EDIT = false; TB_EDIT = false;
renderLockBar(); renderLockBar();
return toast('只读访客账号:无修改权限', 'err'); return toast('当前账号没有台账修改权限', 'err');
} }
const off = !e.target.checked; const off = !e.target.checked;
if (off && pendList().length && if (off && pendList().length &&
...@@ -2754,24 +2821,41 @@ $('#btn-tb-restore').addEventListener('click', async () => { ...@@ -2754,24 +2821,41 @@ $('#btn-tb-restore').addEventListener('click', async () => {
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
}); });
/* ---------------- 角色(管理员 / 只读访客) ---------------- */ /* ---------------- 角色(管理员 / 访客 + 能力) ---------------- */
/** 只读访客:把写操作入口从界面上拿掉 —— 服务端另有 403 硬拦截兜底。 */ /** 访客:把没授权的写入口从界面上拿掉 —— 服务端另有 403 硬拦截兜底。 */
function applyRole() { function applyRole() {
const viewer = isViewer(); const viewer = isViewer();
const elec = hasCap('elec');
document.body.classList.toggle('role-viewer', viewer); document.body.classList.toggle('role-viewer', viewer);
document.body.classList.toggle('cap-elec', elec);
const chip = $('#roChip'); const chip = $('#roChip');
if (chip) chip.style.display = viewer ? '' : 'none'; if (chip) {
chip.style.display = viewer ? '' : 'none';
const t = chip.querySelector('.txt');
if (t) t.textContent = elec ? '访客 · 仅电费' : '只读访客';
}
const ban = $('#roBanner'); const ban = $('#roBanner');
if (ban) ban.style.display = viewer ? '' : 'none'; if (ban) {
ban.style.display = viewer ? '' : 'none';
if (viewer) {
ban.innerHTML = elec
? '<b>访客模式(仅电费通知单)</b>:可以浏览全部页面,但<b>只有「电费通知单」页可以操作</b>'
+ '(录入出单 / 发送 / 重出单据 / 删批次)。房租车位费出单、台账编辑、公司管理、'
+ '设置与账号管理都会被服务端拒绝。'
: '<b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> —— '
+ '出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。';
}
}
if (viewer) { if (viewer) {
TB_EDIT = false; // 台账强制只读 TB_EDIT = false; // 访客一律禁止编辑台账(只有管理员能改台账)
const t = $('#tbEdit'); const t = $('#tbEdit');
if (t) { t.checked = false; t.disabled = true; } if (t) { t.checked = false; t.disabled = true; }
} }
} }
function viewerSuffix(name) { function viewerSuffix(name) {
return isViewer() ? (name + '(只读)') : name; if (!isViewer()) return name;
return name + (hasCap('elec') ? '(访客 · 仅电费)' : '(只读)');
} }
/* ---------------- 启动 ---------------- */ /* ---------------- 启动 ---------------- */
...@@ -2781,11 +2865,15 @@ function viewerSuffix(name) { ...@@ -2781,11 +2865,15 @@ function viewerSuffix(name) {
const s = await api('/api/session'); const s = await api('/api/session');
if (!s.logged_in) { location.href = '/login.html'; return; } if (!s.logged_in) { location.href = '/login.html'; return; }
ROLE = s.role || 'admin'; ROLE = s.role || 'admin';
PERMS = Array.isArray(s.perms) ? s.perms : [];
applyRole(); applyRole();
const name = s.username || ''; const name = s.username || '';
$('#who').textContent = viewerSuffix(name); $('#who').textContent = viewerSuffix(name);
$('#whoAvatar').textContent = (name[0] || '–').toUpperCase(); $('#whoAvatar').textContent = (name[0] || '–').toUpperCase();
if (isViewer()) toast('已以只读访客身份登录:只能浏览,不能修改', ''); if (isViewer()) {
toast(hasCap('elec') ? '已以访客身份登录:仅可操作「电费通知单」'
: '已以只读访客身份登录:只能浏览,不能修改', '');
}
renderElecRows(); renderElecRows();
loadUnits(); loadUnits();
loadElecDefaults(); loadElecDefaults();
......
...@@ -425,6 +425,9 @@ td.mono { font-variant-numeric: tabular-nums; } ...@@ -425,6 +425,9 @@ td.mono { font-variant-numeric: tabular-nums; }
/* ---- 只读访客:隐藏一切写操作入口(服务端另有硬拦截,这里只是不误导人) ---- */ /* ---- 只读访客:隐藏一切写操作入口(服务端另有硬拦截,这里只是不误导人) ---- */
body.role-viewer [data-ro] { display: none !important; } body.role-viewer [data-ro] { display: none !important; }
/* 能力门控:账号没有该能力(且不是管理员)→ 隐藏对应页面的操作控件。
服务端另有 403 硬拦截,这里只是不误导操作者。 */
body:not(.cap-elec) [data-cap="elec"] { display: none !important; }
#roChip { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); } #roChip { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); }
#roChip .dot { background: var(--brand); } #roChip .dot { background: var(--brand); }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment