oclaw/svc/persistence/sa_repos/app_users.py
oliver d14e9d3596 feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts.

- Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init.

- start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export.

- Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1.

- Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:33:50 +08:00

296 lines
11 KiB
Python

"""app_user CRUD slices via SQLAlchemy Core."""
from __future__ import annotations
from typing import Any, Mapping
from sqlalchemy import case, delete, exists, func, insert, literal, or_, select, union, update
from sqlalchemy.engine import Engine
from svc.persistence.db.tables import app_user, channel_identity, channel_identity_v2
def _user_row_to_public_dict(r: Mapping[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"tenant_id": r["tenant_id"],
"username": r["username"],
"display_name": r["display_name"],
"role": r["role"],
"is_active": bool(int(r["is_active"] or 0)),
"created_at": r["created_at"],
"password_hash": r["password_hash"],
"avatar_attachment_id": str(r["avatar_attachment_id"] or "").strip() or None,
}
class AppUsersSaRepository:
__slots__ = ("_engine",)
def __init__(self, engine: Engine) -> None:
self._engine = engine
def count_by_tenant_username(self, *, tenant_id: str, username: str) -> int:
tid, un = str(tenant_id), str(username)
with self._engine.connect() as conn:
n = conn.execute(
select(func.count())
.select_from(app_user)
.where(app_user.c.tenant_id == tid, app_user.c.username == un)
).scalar_one()
return int(n or 0)
def insert_user(
self,
*,
user_id: str,
tenant_id: str,
username: str,
display_name: str,
role: str,
password_hash: str,
is_active: int,
created_at: str,
avatar_attachment_id: str | None = None,
) -> None:
with self._engine.begin() as conn:
conn.execute(
insert(app_user).values(
id=str(user_id),
tenant_id=str(tenant_id),
username=str(username),
display_name=str(display_name),
role=str(role),
password_hash=str(password_hash or ""),
is_active=int(is_active),
created_at=str(created_at),
avatar_attachment_id=avatar_attachment_id,
)
)
def _select_user_columns(self):
return select(
app_user.c.id,
app_user.c.tenant_id,
app_user.c.username,
app_user.c.display_name,
app_user.c.role,
func.coalesce(app_user.c.is_active, literal(1)).label("is_active"),
app_user.c.created_at,
func.coalesce(app_user.c.password_hash, literal("")).label("password_hash"),
func.coalesce(app_user.c.avatar_attachment_id, literal("")).label("avatar_attachment_id"),
)
def fetch_by_tenant_and_id(self, *, tenant_id: str, user_id: str) -> dict[str, Any] | None:
tid, uid = str(tenant_id), str(user_id)
stmt = self._select_user_columns().where(app_user.c.tenant_id == tid, app_user.c.id == uid).limit(1)
with self._engine.connect() as conn:
row = conn.execute(stmt).mappings().first()
return _user_row_to_public_dict(row) if row else None
def fetch_by_tenant_and_username(self, *, tenant_id: str, username: str) -> dict[str, Any] | None:
tid, un = str(tenant_id), str(username)
stmt = (
self._select_user_columns()
.where(app_user.c.tenant_id == tid, app_user.c.username == un)
.limit(1)
)
with self._engine.connect() as conn:
row = conn.execute(stmt).mappings().first()
return _user_row_to_public_dict(row) if row else None
def fetch_first_by_username_global(self, *, username: str) -> dict[str, Any] | None:
un = str(username)
stmt = (
self._select_user_columns()
.where(app_user.c.username == un)
.order_by(app_user.c.created_at.asc())
.limit(1)
)
with self._engine.connect() as conn:
row = conn.execute(stmt).mappings().first()
return _user_row_to_public_dict(row) if row else None
def list_users_for_tenant(
self,
*,
tenant_id: str,
limit: int,
offset: int,
q: str | None,
include_inactive: bool,
) -> list[dict[str, Any]]:
tid = str(tenant_id)
lim = max(1, int(limit))
off = max(0, int(offset))
has_password = case(
(func.trim(func.coalesce(app_user.c.password_hash, literal(""))) != literal(""), 1),
else_=0,
).label("has_password")
wecom_linked = or_(
exists(
select(literal(1))
.select_from(channel_identity_v2)
.where(
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
channel_identity_v2.c.user_id == app_user.c.id,
channel_identity_v2.c.channel == literal("wecom"),
)
),
exists(
select(literal(1))
.select_from(channel_identity)
.where(
channel_identity.c.tenant_id == app_user.c.tenant_id,
channel_identity.c.user_id == app_user.c.id,
channel_identity.c.channel == literal("wecom"),
)
),
).label("wecom_linked")
channel_linked = or_(
exists(
select(literal(1))
.select_from(channel_identity_v2)
.where(
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
channel_identity_v2.c.user_id == app_user.c.id,
)
),
exists(
select(literal(1))
.select_from(channel_identity)
.where(
channel_identity.c.tenant_id == app_user.c.tenant_id,
channel_identity.c.user_id == app_user.c.id,
)
),
).label("channel_linked")
eid_ci = func.trim(func.coalesce(channel_identity.c.external_user_id, literal("")))
sq1 = (
select(eid_ci.label("eid"))
.where(
channel_identity.c.tenant_id == app_user.c.tenant_id,
channel_identity.c.user_id == app_user.c.id,
channel_identity.c.channel == literal("wecom"),
eid_ci != literal(""),
)
.distinct()
)
eid_v2 = func.trim(func.coalesce(channel_identity_v2.c.external_user_id, literal("")))
sq2 = (
select(eid_v2.label("eid"))
.where(
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
channel_identity_v2.c.user_id == app_user.c.id,
channel_identity_v2.c.channel == literal("wecom"),
eid_v2 != literal(""),
)
.distinct()
)
u_sub = union(sq1, sq2).subquery()
if self._engine.dialect.name == "postgresql":
wecom_ids_expr = select(func.string_agg(u_sub.c.eid, literal(", "))).scalar_subquery()
else:
wecom_ids_expr = select(func.group_concat(u_sub.c.eid, literal(", "))).scalar_subquery()
stmt = (
select(
app_user.c.id,
app_user.c.tenant_id,
app_user.c.username,
app_user.c.display_name,
app_user.c.role,
func.coalesce(app_user.c.is_active, literal(1)).label("is_active"),
app_user.c.created_at,
has_password,
wecom_linked,
channel_linked,
wecom_ids_expr.label("wecom_external_user_ids"),
)
.where(app_user.c.tenant_id == tid)
)
token = str(q or "").strip()
if token:
key = f"%{token.lower()}%"
stmt = stmt.where(
or_(
func.lower(app_user.c.display_name).like(key),
func.lower(func.coalesce(app_user.c.username, literal(""))).like(key),
app_user.c.id.like(f"%{token[:32]}%"),
)
)
if not include_inactive:
stmt = stmt.where(func.coalesce(app_user.c.is_active, literal(1)) == literal(1))
stmt = stmt.order_by(app_user.c.created_at.desc()).limit(lim).offset(off)
with self._engine.connect() as conn:
rows = conn.execute(stmt).mappings().all()
out: list[dict[str, Any]] = []
for r in rows:
has_pw = bool(int(r["has_password"] or 0))
uname = str(r["username"] or "")
can_chat = bool(has_pw)
wl = r["wecom_linked"]
cl = r["channel_linked"]
out.append(
{
"id": r["id"],
"tenant_id": r["tenant_id"],
"username": r["username"],
"display_name": r["display_name"],
"role": r["role"],
"is_active": bool(int(r["is_active"] or 0)),
"created_at": r["created_at"],
"has_password": has_pw,
"wecom_linked": bool(int(wl or 0)),
"channel_linked": bool(int(cl or 0)),
"can_chat_login": can_chat,
"wecom_external_user_ids": str(r["wecom_external_user_ids"] or "").strip(),
}
)
return out
def update_user_account(
self,
*,
tenant_id: str,
user_id: str,
display_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
password_hash: str | None = None,
avatar_attachment_id: str | None = None,
) -> bool:
tid, uid = str(tenant_id), str(user_id)
vals: dict[str, Any] = {}
if display_name is not None:
vals["display_name"] = str(display_name).strip() or "User"
if role is not None:
vals["role"] = str(role).strip() or "member"
if is_active is not None:
vals["is_active"] = 1 if is_active else 0
if password_hash is not None:
vals["password_hash"] = str(password_hash)
if avatar_attachment_id is not None:
aid = str(avatar_attachment_id).strip()
vals["avatar_attachment_id"] = aid if aid else None
if not vals:
return False
with self._engine.begin() as conn:
res = conn.execute(
update(app_user).where(app_user.c.tenant_id == tid, app_user.c.id == uid).values(**vals)
)
return bool(int(res.rowcount or 0) > 0)
def delete_user_account(self, *, tenant_id: str, user_id: str) -> int:
tid, uid = str(tenant_id), str(user_id)
with self._engine.begin() as conn:
res = conn.execute(delete(app_user).where(app_user.c.tenant_id == tid, app_user.c.id == uid))
return int(res.rowcount or 0)
__all__ = ["AppUsersSaRepository"]