mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 23:23:32 +08:00
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>
This commit is contained in:
parent
2b32d11f43
commit
d14e9d3596
103 changed files with 7574 additions and 1641 deletions
296
svc/persistence/sa_repos/app_users.py
Normal file
296
svc/persistence/sa_repos/app_users.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue