85 lines
2.9 KiB
Python
85 lines
2.9 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""④-1 PostgreSQL users 表 DDL + 映射 —— issue #150 / #30 / PRD 8.2。
|
|||
|
|
|
|||
|
|
定义本地账号在 PostgreSQL 中的存储结构(#30 users 表 schema),并提供
|
|||
|
|
`UserStore` 与 PG 之间的映射。DDL 仅用标准库拼装(无 psycopg2 依赖),
|
|||
|
|
实际接库时上层注入连接即可。
|
|||
|
|
|
|||
|
|
表结构(对齐 core/data-bus/postgres_schema.py 的命名风格):
|
|||
|
|
|
|||
|
|
users (
|
|||
|
|
id BIGSERIAL PRIMARY KEY,
|
|||
|
|
username VARCHAR(64) UNIQUE NOT NULL,
|
|||
|
|
password_hash VARCHAR(255) NOT NULL, -- pbkdf2_sha256$<iter>$<salt>$<hash>
|
|||
|
|
role VARCHAR(16) NOT NULL DEFAULT 'readonly', -- readonly/engineer/admin
|
|||
|
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|||
|
|
last_login_at TIMESTAMPTZ
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
索引:username 唯一索引(登录走 username);role 普通索引(用户管理筛选)。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any, Dict, List
|
|||
|
|
|
|||
|
|
from .users import User, VALID_ROLES
|
|||
|
|
|
|||
|
|
USERS_TABLE_DDL = """CREATE TABLE IF NOT EXISTS users (
|
|||
|
|
id BIGSERIAL PRIMARY KEY,
|
|||
|
|
username VARCHAR(64) UNIQUE NOT NULL,
|
|||
|
|
password_hash VARCHAR(255) NOT NULL,
|
|||
|
|
role VARCHAR(16) NOT NULL DEFAULT 'readonly',
|
|||
|
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|||
|
|
last_login_at TIMESTAMPTZ,
|
|||
|
|
CONSTRAINT users_role_chk CHECK (role IN ('readonly','engineer','admin'))
|
|||
|
|
);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def row_to_user(row: Any) -> User:
|
|||
|
|
"""PG 行(dict/tuple-like)→ User。row 需含 keys: id/username/password_hash/role/active/created_at/last_login_at。"""
|
|||
|
|
def g(k, default=None):
|
|||
|
|
if isinstance(row, dict):
|
|||
|
|
return row.get(k, default)
|
|||
|
|
return getattr(row, k, default)
|
|||
|
|
created = g("created_at")
|
|||
|
|
last = g("last_login_at")
|
|||
|
|
# PG TIMESTAMPTZ → epoch(若为 datetime 有 timestamp())
|
|||
|
|
def to_epoch(v):
|
|||
|
|
if v is None:
|
|||
|
|
return None
|
|||
|
|
if hasattr(v, "timestamp"):
|
|||
|
|
return v.timestamp()
|
|||
|
|
return v
|
|||
|
|
return User(
|
|||
|
|
id=int(g("id")),
|
|||
|
|
username=str(g("username")),
|
|||
|
|
password_hash=str(g("password_hash")),
|
|||
|
|
role=str(g("role", "readonly")) or "readonly",
|
|||
|
|
active=bool(g("active", True)),
|
|||
|
|
created_at=to_epoch(created) or 0.0,
|
|||
|
|
last_login_at=to_epoch(last),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def user_to_row(user: User) -> Dict[str, Any]:
|
|||
|
|
"""User → PG 列字典(不含 id,用于 INSERT;UPDATE 时按 id 定位)。"""
|
|||
|
|
return {
|
|||
|
|
"username": user.username,
|
|||
|
|
"password_hash": user.password_hash,
|
|||
|
|
"role": user.role,
|
|||
|
|
"active": user.active,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_role(role: str) -> str:
|
|||
|
|
if role not in VALID_ROLES:
|
|||
|
|
raise ValueError("role must be one of %r" % (VALID_ROLES,))
|
|||
|
|
return role
|
|||
|
|
|
|||
|
|
|
|||
|
|
__all__ = ["USERS_TABLE_DDL", "row_to_user", "user_to_row", "validate_role"]
|