Files
meida_backend/packages/identity/service.py
T
xsl 7c67506474 feat(domains): identity, billing, task services and domain placeholders
Billing implements reserve -> commit -> refund against bl_quota and bl_ledger
with row-level locking and idempotency keys; the refund commits on its own
transaction so a failed request cannot silently keep a user's quota. Task
implements the unified task centre over the state machine. Identity issues and
resolves users. Remaining domains are placeholders whose READMEs state what
belongs there and what must not.
2026-08-14 00:26:39 +08:00

101 lines
3.3 KiB
Python
Executable File

"""identity 域 service.
架构文档 §4.2: ``identity`` 不得依赖其他业务域 —— 登录链路不能被业务拖累。
import-linter enforces this (contract "identity must not depend on any other
business domain").
V1 scope still missing here: 华为账号登录 (FR-ACC-01)、手机号验证码 (FR-ACC-04)、
双令牌与撤销 (FR-ACC-05)、游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定
(FR-ACC-11). See packages/identity/README.md.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from packages.domain.ids import next_id
from packages.identity import repo
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import Consent, User, UserIdentity
#: Provider used by the dev-only token endpoint. Never valid in prod.
DEV_PROVIDER = "dev"
class IdentityService:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_user(self, user_id: int) -> User:
user = await repo.get_user(self._session, user_id)
if user is None or user.deleted_at is not None or user.status != "active":
raise AppError(ErrorCode.UNAUTHENTICATED, message="账号不存在或已注销")
return user
async def get_or_create_by_external_identity(
self,
*,
provider: str,
union_id: str,
open_id: str | None = None,
register_source: str = "unknown",
nickname: str | None = None,
) -> User:
"""Map an external identity onto an internal ``user_id`` (FR-ACC-01).
The internal id is deliberately decoupled from the provider id so a
second login channel can be attached later without a data migration.
"""
identity = await repo.find_identity(self._session, provider=provider, union_id=union_id)
if identity is not None:
return await self.get_user(identity.user_id)
user = repo.add_user(
self._session,
User(
user_id=next_id(),
status="active",
nickname=nickname,
register_source=register_source,
last_active_at=datetime.now(UTC),
),
)
repo.add_identity(
self._session,
UserIdentity(
identity_id=next_id(),
user_id=user.user_id,
provider=provider,
union_id=union_id,
open_id=open_id,
),
)
await self._session.flush()
return user
async def record_consent(
self,
*,
user_id: int,
consent_type: str,
doc_version: str,
client_ip: str | None = None,
user_agent: str | None = None,
) -> Consent:
"""CR-01 留证。人脸的单独同意 (``consent_type='face'``) 必须独立成行。"""
consent = repo.add_consent(
self._session,
Consent(
consent_id=next_id(),
user_id=user_id,
consent_type=consent_type,
doc_version=doc_version,
client_ip=client_ip,
ua=user_agent,
),
)
await self._session.flush()
return consent