mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
- 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>
296 lines
11 KiB
Python
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"]
|