diff --git a/app/api/controllers/trading.py b/app/api/controllers/trading.py index ef777e0..afac8e0 100644 --- a/app/api/controllers/trading.py +++ b/app/api/controllers/trading.py @@ -30,13 +30,20 @@ from app.api.dependencies.database import get_session from app.api.schemas.trading import OrderCreateRequest from app.api.views.envelope import envelope, list_envelope from app.core.contracts import RequestContext +from app.service.authorization_service import AuthorizationService +from app.service.suitability_service import SuitabilityService from app.service.trade_service import TradeService router = APIRouter(prefix="/api/v1/users/me", tags=["trading"]) def _service(session: AsyncSession, context: RequestContext) -> TradeService: - return TradeService(session) + return TradeService(session, suitability_evaluator=SuitabilityService()) + + +async def _authorize(context: RequestContext, permission: str) -> None: + """Enforce the endpoint permission declared in docs/05 before DB work.""" + await AuthorizationService.require(context, permission) # T001 账户看板 @@ -45,6 +52,7 @@ async def get_account_dashboard( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "account:read:self") data = await _service(session, context).get_account_dashboard(context) return envelope(data, context) @@ -56,6 +64,7 @@ async def submit_order( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:order:create") data = await _service(session, context).submit_order(payload, context) return envelope(data, context) @@ -68,6 +77,7 @@ async def list_orders( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:order:read") cursor_id = int(cursor) if cursor else None items, next_cursor = await _service(session, context).list_orders( context, limit=limit, cursor=cursor_id @@ -85,6 +95,7 @@ async def get_order( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:order:read") data = await _service(session, context).get_order(order_no, context) return envelope(data, context) @@ -96,6 +107,7 @@ async def cancel_order( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:order:cancel") order = await _service(session, context).cancel_order(order_no, context) return envelope(order, context) @@ -106,6 +118,7 @@ async def list_holdings( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "holding:read:self") data = await _service(session, context).list_holdings(context) return envelope(data, context) @@ -118,6 +131,7 @@ async def list_transactions( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:txn:read") cursor_id = int(cursor) if cursor else None data = await _service(session, context).list_transactions( context, limit=limit, cursor=cursor_id @@ -132,6 +146,7 @@ async def get_transaction( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "trade:txn:read") item = await _service(session, context).get_transaction(txn_no, context) return envelope(item, context) @@ -144,8 +159,9 @@ async def list_cash_ledger( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: + await _authorize(context, "account:read:self") cursor_id = int(cursor) if cursor else None data = await _service(session, context).list_cash_ledger( context, limit=limit, cursor=cursor_id ) - return envelope(data, context) \ No newline at end of file + return envelope(data, context) diff --git a/app/service/trade_service.py b/app/service/trade_service.py index 1c780be..9f30deb 100644 --- a/app/service/trade_service.py +++ b/app/service/trade_service.py @@ -67,7 +67,7 @@ from app.model.fund import ( FundSimOrder, FundTransaction, ) -from app.service.suitability_service import SuitabilityToolInput +from app.service.suitability_service import SuitabilityService, SuitabilityToolInput TWO_PLACES = Decimal("0.01") FOUR_PLACES = Decimal("0.0001") @@ -104,7 +104,9 @@ class TradeService: suitability_evaluator: object | None = None, ) -> None: self._session = session - self._suitability_evaluator = suitability_evaluator + # Every real trade must pass the shared suitability service. Tests and + # explicit callers may still inject a compatible evaluator. + self._suitability_evaluator = suitability_evaluator or SuitabilityService() async def _next_id(self, model: Any) -> int: """返回 ``model`` 表的下一个可用主键。 @@ -182,29 +184,34 @@ class TradeService: ) return product - async def _check_suitability(self, customer_id: int, product: FundProduct) -> None: - """首版:若注入了 `SuitabilityService.evaluate` 则按其决策判定。""" - if self._suitability_evaluator is None: - return + async def _check_suitability( + self, customer_id: int, product: FundProduct, context: RequestContext + ) -> None: + """Apply the authoritative suitability decision before an order. + + A missing/misconfigured evaluator is a server configuration error and + must not silently turn into an approval. The shared service also loads + the customer's risk assessment from the database rather than trusting + client supplied risk fields. + """ evaluate = getattr(self._suitability_evaluator, "evaluate", None) if evaluate is None or not callable(evaluate): - return + raise SuitabilityMismatchError("适当性服务不可用,交易已拒绝") try: risk_level_int = int(product.risk_level.lstrip("Rr")) except (AttributeError, ValueError): - return - try: - decision = await evaluate( - SuitabilityToolInput( - customer_id=str(customer_id), - product_risk_level=risk_level_int, - product_requires_disclosure=bool(product.risk_disclosure_required), - requires_confirmation=bool(product.second_confirmation_required), - ), - context=None, + raise SuitabilityMismatchError( + f"产品 {getattr(product, 'product_code', '')} 风险等级无效,交易已拒绝" ) - except Exception: # noqa: BLE001 - return + decision = await evaluate( + SuitabilityToolInput( + customer_id=str(customer_id), + product_risk_level=risk_level_int, + product_requires_disclosure=bool(product.risk_disclosure_required), + requires_confirmation=bool(product.second_confirmation_required), + ), + context=context, + ) if not getattr(decision, "allowed", True): reason = getattr(decision, "reason_code", "unspecified") raise SuitabilityMismatchError( @@ -285,7 +292,7 @@ class TradeService: now = datetime.now(UTC).replace(tzinfo=None) product = await self._load_tradable_product(payload.product_code) - await self._check_suitability(customer_id, product) + await self._check_suitability(customer_id, product, context) quote = await self._fetch_quote(product) account = await self._load_account(customer_id) holding = await self._load_holding(customer_id, product.id) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index d93549d..5a8d419 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -117,7 +117,10 @@ async function request(endpointId, options = {}) { signal: controller.signal, }); const payload = await response.json().catch(() => ({})); - if (response.status === 401 && endpoint.auth !== false) clearAuthSession(); + if (response.status === 401 && endpoint.auth !== false) { + clearAuthSession({ eventType: 'session-expired' }); + window.dispatchEvent(new CustomEvent('portal:auth-expired')); + } const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId; document.documentElement.dataset.traceId = responseTraceId; if (!response.ok || payload.error) { @@ -178,6 +181,10 @@ async function stream(endpointId, body, options = {}) { }); if (!response.ok || !response.body) { const payload = await response.json().catch(() => ({})); + if (response.status === 401 && endpoint.auth !== false) { + clearAuthSession({ eventType: 'session-expired' }); + window.dispatchEvent(new CustomEvent('portal:auth-expired')); + } const detail = payload.error || {}; throw new ApiError(detail.message || '流式请求未完成', { code: detail.code, diff --git a/app/static/portal/common/auth.js b/app/static/portal/common/auth.js index 8b16cee..9ac1935 100644 --- a/app/static/portal/common/auth.js +++ b/app/static/portal/common/auth.js @@ -4,7 +4,9 @@ const SESSION_KEY = 'portalAuthContext'; const IDENTITY_COOKIE = 'portal_auth_user'; const CONTEXT_COOKIE = 'portal_auth_context'; const AUTH_CHANNEL_NAME = 'portal-auth-session'; -const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched']); +const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']); +const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +const SESSION_CHECK_INTERVAL_MS = 15 * 1000; let authChannel = null; let authSyncStarted = false; @@ -38,13 +40,59 @@ function writeSharedContext(context, maxAge) { } function hasMatchingIdentity(token, context) { + const expiresAt = tokenExpiry(token); return Boolean( token && context - && readCookie(IDENTITY_COOKIE) === String(context.userId), + && readCookie(IDENTITY_COOKIE) === String(context.userId) + && (!expiresAt || Date.now() < expiresAt), ); } +function tokenExpiry(token) { + try { + const payload = token.split('.')[1]; + if (!payload) return 0; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='); + const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join(''))); + return Number(value.exp || 0) * 1000; + } catch { + return 0; + } +} + +function sessionExpired() { + clearAuthSession({ eventType: 'session-expired' }); + if (isProtectedPortalPage()) { + window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`); + } +} + +function checkSessionLifetime() { + const token = readCookie('auth_token'); + const context = readSharedContext() || readSessionContext(); + if (!token || !context || !hasMatchingIdentity(token, context)) return; + const expiresAt = Number(context.expiresAt || tokenExpiry(token)); + const lastActivityAt = Number(context.lastActivityAt || Date.now()); + if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) { + sessionExpired(); + } +} + +function touchActivity() { + const token = readCookie('auth_token'); + const context = readSharedContext() || readSessionContext(); + if (!token || !context || !hasMatchingIdentity(token, context)) return; + const now = Date.now(); + const expiresAt = Number(context.expiresAt || tokenExpiry(token)); + if (expiresAt && now >= expiresAt) { sessionExpired(); return; } + if (now - Number(context.lastActivityAt || 0) < 60_000) return; + const next = { ...context, lastActivityAt: now, expiresAt }; + const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); + writeSharedContext(next, remaining); +} + function openAuthChannel() { if (!('BroadcastChannel' in window)) return null; if (authChannel) return authChannel; @@ -80,7 +128,8 @@ function redirectForExternalAuthChange(type) { window.location.replace('/portal/guest/home/?reason=signed-out'); return; } - window.location.replace(`${loginPathForCurrentPortal()}?reason=account-switched`); + const reason = type === 'session-expired' ? 'session-expired' : 'account-switched'; + window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`); } function handleExternalAuthChange(type) { @@ -108,6 +157,11 @@ export function startAuthSync() { }); window.addEventListener('focus', reconcileSharedSession); window.addEventListener('pageshow', reconcileSharedSession); + ['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => { + window.addEventListener(eventName, touchActivity, { passive: true }); + }); + window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS); + checkSessionLifetime(); } export function getAccessToken() { @@ -130,6 +184,8 @@ export function setAuthSession(loginData, username) { roles: Array.isArray(loginData.roles) ? loginData.roles : [], dataScope: loginData.data_scope || 'self', permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [], + expiresAt: Date.now() + maxAge * 1000, + lastActivityAt: Date.now(), }; document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`; writeSharedContext(context, maxAge); @@ -190,8 +246,12 @@ export function updateAuthPermissions(permissions, dataScope) { permissions: Array.isArray(permissions) ? [...permissions] : [], dataScope: dataScope || context.dataScope, }; + const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token'))); + const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800; + next.expiresAt = expiresAt; + next.lastActivityAt = Date.now(); sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); - writeSharedContext(next, 1800); + writeSharedContext(next, remaining); return next; } diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 1574a12..3ad8897 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -135,6 +135,10 @@ export function mountShell({ active, mode = 'public' }) { networkStatus.textContent = detail.message || '请求未完成'; networkStatus.classList.add('network-status--visible'); }); + window.addEventListener('portal:auth-expired', () => { + networkStatus.textContent = '登录已过期,请重新登录'; + networkStatus.classList.add('network-status--visible'); + }); if (new URLSearchParams(window.location.search).get('reason') === 'signed-out') { networkStatus.textContent = '您已安全退出登录'; networkStatus.classList.add('network-status--visible'); diff --git a/app/static/portal/common/login-controller.js b/app/static/portal/common/login-controller.js index 70c1c75..e0d935f 100644 --- a/app/static/portal/common/login-controller.js +++ b/app/static/portal/common/login-controller.js @@ -9,6 +9,7 @@ import { const REASON_MESSAGES = Object.freeze({ 'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。', 'signed-out': '您已安全退出登录。', + 'session-expired': '登录已过期或长时间未操作,请重新登录。', }); export function setupLogin({ mode }) { diff --git a/tests/unit/service/test_trade_service.py b/tests/unit/service/test_trade_service.py index dc3b414..61383c5 100644 --- a/tests/unit/service/test_trade_service.py +++ b/tests/unit/service/test_trade_service.py @@ -25,7 +25,8 @@ from app.api.schemas.trading import ( HoldingListResponse, PortfolioSummary, ) -from app.core.errors import FundQuoteUnavailableError +from app.core.contracts import RequestContext +from app.core.errors import FundQuoteUnavailableError, SuitabilityMismatchError from app.service.trade_service import TradeService, _FeeRule # --------------------------------------------------------------------------- @@ -206,3 +207,28 @@ async def test_quote_freshness_only_blocks_trade_path() -> None: assert snapshot.price == Decimal("4.5000") with pytest.raises(FundQuoteUnavailableError, match="行情已过期"): await service._fetch_quote(product, enforce_freshness=True) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_trade_suitability_uses_request_context_and_denies_mismatch() -> None: + evaluator = SimpleNamespace( + evaluate=AsyncMock( + return_value=SimpleNamespace(allowed=False, reason_code="RISK_LEVEL_MISMATCH") + ) + ) + service = TradeService(session=None, suitability_evaluator=evaluator) # type: ignore[arg-type] + product = SimpleNamespace( + product_code="510500", + risk_level="R4", + risk_disclosure_required=1, + second_confirmation_required=0, + ) + context = RequestContext( + user_id="9001", trace_id="trade-test", roles=("customer",), customer_ids=("9001",) + ) + + with pytest.raises(SuitabilityMismatchError, match="RISK_LEVEL_MISMATCH"): + await service._check_suitability(9001, product, context) # type: ignore[arg-type] + + evaluator.evaluate.assert_awaited_once() + assert evaluator.evaluate.await_args.kwargs["context"] is context