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.
This commit is contained in:
xsl
2026-08-14 00:26:39 +08:00
parent 54dc741811
commit 7c67506474
36 changed files with 1213 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
"""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