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:
@@ -0,0 +1,33 @@
|
||||
# `packages/identity/` — 账号与会话
|
||||
|
||||
**职责**(架构 §4.1):账号、登录、会话、设备、游客迁移、同意授权记录。
|
||||
**不负责**:权益判定(归 `billing`)。
|
||||
|
||||
## 铁律
|
||||
|
||||
**`identity` 不得依赖任何其他业务域**(架构 §4.2)—— 登录链路不能被业务拖累。
|
||||
import-linter 契约 `identity must not depend on any other business domain` 会
|
||||
在 CI 拦下违规 import。
|
||||
|
||||
## 已实现
|
||||
|
||||
- `id_user` / `id_user_identity` / `id_consent` 表
|
||||
- 外部身份 → 内部 `user_id` 映射(内部主键与外部身份解耦,便于加登录通道)
|
||||
- 同意记录写入
|
||||
- JWT access token(`packages/shared/security.py`)
|
||||
|
||||
## 待实现
|
||||
|
||||
| 需求 | 内容 |
|
||||
| --- | --- |
|
||||
| FR-ACC-01 | 华为账号登录(UnionID/OpenID) |
|
||||
| FR-ACC-04 | 手机号验证码登录(5 分钟有效、单号限频) |
|
||||
| FR-ACC-05 | 双令牌:`id_session` + 服务端可撤销的 refresh token |
|
||||
| FR-ACC-02/03 | 游客模式与数据合并(合并必须幂等) |
|
||||
| FR-ACC-08 | **人脸单独同意页**(CR-01:必须独立,不得混入总协议) |
|
||||
| FR-ACC-09 | 账号注销:7 日内彻底删除,含 COS 对象(CR-03) |
|
||||
| FR-ACC-11 | 未成年人判定与功能限制(CR-04) |
|
||||
| FR-SYS-01 | 越权防护:所有资源访问校验归属,不能只靠 ID 不可猜 |
|
||||
|
||||
`POST /auth/dev-token` 只是脚手架,`DEV_AUTH_ENABLED=false` 时不可用,
|
||||
生产环境必须关闭。
|
||||
@@ -0,0 +1 @@
|
||||
"""identity 域:账号、登录、会话、同意授权。不得依赖其他业务域。"""
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
"""Data access for the identity domain. **Only this module may touch id_* tables.**"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.shared.models import Consent, User, UserIdentity
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: int) -> User | None:
|
||||
return await session.get(User, user_id)
|
||||
|
||||
|
||||
async def find_identity(
|
||||
session: AsyncSession, *, provider: str, union_id: str
|
||||
) -> UserIdentity | None:
|
||||
result = await session.execute(
|
||||
select(UserIdentity).where(
|
||||
UserIdentity.provider == provider, UserIdentity.union_id == union_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def add_user(session: AsyncSession, user: User) -> User:
|
||||
session.add(user)
|
||||
return user
|
||||
|
||||
|
||||
def add_identity(session: AsyncSession, identity: UserIdentity) -> UserIdentity:
|
||||
session.add(identity)
|
||||
return identity
|
||||
|
||||
|
||||
def add_consent(session: AsyncSession, consent: Consent) -> Consent:
|
||||
session.add(consent)
|
||||
return consent
|
||||
Executable
+100
@@ -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
|
||||
Reference in New Issue
Block a user