Enforce trading authorization and suitability checks

This commit is contained in:
张胜宇
2026-09-13 19:24:59 +08:00
parent b9aafce120
commit e3c316aafb
7 changed files with 149 additions and 28 deletions
+18 -2
View File
@@ -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)
return envelope(data, context)
+27 -20
View File
@@ -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)
+8 -1
View File
@@ -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,
+64 -4
View File
@@ -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;
}
@@ -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');
@@ -9,6 +9,7 @@ import {
const REASON_MESSAGES = Object.freeze({
'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。',
'signed-out': '您已安全退出登录。',
'session-expired': '登录已过期或长时间未操作,请重新登录。',
});
export function setupLogin({ mode }) {